first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
@@ -0,0 +1,7 @@
|
||||
// Backward-compatibility facade.
|
||||
//
|
||||
// Business logic app catalog sudah dipindahkan ke folder `./app-catalog/*`.
|
||||
// File asli sebelum modularisasi disimpan di `./backup/app-catalog.service.ts`.
|
||||
// Semua export publik tetap dipertahankan lewat re-export di bawah ini.
|
||||
|
||||
export * from "./app-catalog";
|
||||
@@ -0,0 +1,23 @@
|
||||
# App Catalog Modules
|
||||
|
||||
Folder ini berisi modularisasi business logic untuk `app-catalog.service.ts`.
|
||||
|
||||
Struktur:
|
||||
|
||||
- `types.ts`: kontrak publik dan type cache.
|
||||
- `catalog-options.ts`: opsi sumber katalog yang diexpose ke route.
|
||||
- `cache.ts`: cache remote catalog dan helper result cache.
|
||||
- `utils.ts`: helper generik untuk parsing value dan structured text.
|
||||
- `normalize.ts`: normalisasi field template generik.
|
||||
- `manifest-parsers.ts`: parser manifest CasaOS, Umbrel, dan generic manifest.
|
||||
- `remote.ts`: normalisasi URL remote, deteksi ZIP response, dan fetch wrapper.
|
||||
- `archive.ts`: scan archive ZIP dan ekstraksi katalog.
|
||||
- `service.ts`: orchestration utama `getCatalogTemplates()`.
|
||||
- `index.ts`: public barrel untuk API modul ini.
|
||||
|
||||
Pattern:
|
||||
|
||||
- Helper generik disimpan di file kecil sesuai concern.
|
||||
- `service.ts` hanya mengorkestrasi alur, tidak memuat detail parsing besar.
|
||||
- Hanya `index.ts` yang menjadi surface API publik modul baru.
|
||||
- `../app-catalog.service.ts` dipertahankan sebagai compatibility facade untuk callsite lama.
|
||||
@@ -0,0 +1,214 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import { CatalogSourceMode, CatalogSyncResult } from "./types";
|
||||
import { dedupeTemplates, normalizePorts } from "./normalize";
|
||||
import {
|
||||
normalizeSingleManifest,
|
||||
parseCasaOsCompose,
|
||||
parseCasaOsConfigJson,
|
||||
} from "./manifest-parsers";
|
||||
import { asRecord, parseStructuredText, slugify, toStringArray } from "./utils";
|
||||
|
||||
function getAppDirKey(entryName: string): string | undefined {
|
||||
const match = entryName.match(/(^|\/)Apps\/([^/]+)\//i);
|
||||
return match?.[2]?.toLowerCase();
|
||||
}
|
||||
|
||||
function getGithubRawArchiveBase(
|
||||
archiveUrl: string,
|
||||
): { owner: string; repo: string; ref: string } | undefined {
|
||||
try {
|
||||
const parsed = new URL(archiveUrl);
|
||||
|
||||
if (parsed.hostname === "codeload.github.com") {
|
||||
const match = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/zip\/refs\/(?:heads|tags)\/(.+)$/i,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2], ref: match[3] };
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.hostname === "github.com") {
|
||||
const refMatch = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/refs\/(?:heads|tags)\/(.+)\.zip$/i,
|
||||
);
|
||||
|
||||
if (refMatch) {
|
||||
return { owner: refMatch[1], repo: refMatch[2], ref: refMatch[3] };
|
||||
}
|
||||
|
||||
const commitMatch = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/([a-f0-9]{7,40})\.zip$/i,
|
||||
);
|
||||
|
||||
if (commitMatch) {
|
||||
return {
|
||||
owner: commitMatch[1],
|
||||
repo: commitMatch[2],
|
||||
ref: commitMatch[3],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getArchiveEntryBaseUrl(
|
||||
archiveUrl: string,
|
||||
entryName: string,
|
||||
): string | undefined {
|
||||
const github = getGithubRawArchiveBase(archiveUrl);
|
||||
if (!github) return undefined;
|
||||
|
||||
const entryPath = entryName.split("/").filter(Boolean).slice(1).join("/");
|
||||
if (!entryPath) return undefined;
|
||||
|
||||
return `https://raw.githubusercontent.com/${github.owner}/${github.repo}/${github.ref}/${entryPath}`;
|
||||
}
|
||||
|
||||
export function parseCasaOsArchiveCatalog(
|
||||
zip: AdmZip,
|
||||
archiveUrl = "",
|
||||
): AppTemplate[] {
|
||||
const configEntries = new Map<string, string>();
|
||||
const composeEntries = new Map<string, string>();
|
||||
const results: AppTemplate[] = [];
|
||||
|
||||
zip.getEntries().forEach((entry) => {
|
||||
if (entry.isDirectory) return;
|
||||
|
||||
const appDirKey = getAppDirKey(entry.entryName);
|
||||
if (!appDirKey) return;
|
||||
|
||||
const lower = entry.entryName.toLowerCase();
|
||||
if (lower.endsWith("/config.json")) {
|
||||
configEntries.set(appDirKey, entry.entryName);
|
||||
}
|
||||
|
||||
if (
|
||||
lower.endsWith("/docker-compose.yml") ||
|
||||
lower.endsWith("/docker-compose.yaml")
|
||||
) {
|
||||
composeEntries.set(appDirKey, entry.entryName);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(
|
||||
new Set([...configEntries.keys(), ...composeEntries.keys()]),
|
||||
).forEach((appDirKey) => {
|
||||
const configPath = configEntries.get(appDirKey);
|
||||
const composePath = composeEntries.get(appDirKey);
|
||||
|
||||
try {
|
||||
const configPayload = configPath
|
||||
? JSON.parse(zip.readAsText(configPath))
|
||||
: undefined;
|
||||
const composePayload = composePath
|
||||
? parseStructuredText(zip.readAsText(composePath))
|
||||
: undefined;
|
||||
|
||||
let template: AppTemplate | null = null;
|
||||
|
||||
if (composePayload) {
|
||||
template = parseCasaOsCompose(
|
||||
asRecord(composePayload) ?? {},
|
||||
appDirKey,
|
||||
composePath
|
||||
? getArchiveEntryBaseUrl(archiveUrl, composePath)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (!template && configPayload) {
|
||||
template = parseCasaOsConfigJson(
|
||||
asRecord(configPayload) ?? {},
|
||||
appDirKey,
|
||||
configPath
|
||||
? getArchiveEntryBaseUrl(archiveUrl, configPath)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (template) {
|
||||
if (configPayload && !template.defaultPort) {
|
||||
template.defaultPort = normalizePorts(asRecord(configPayload)?.port);
|
||||
}
|
||||
if (configPayload && template.tags.length === 0) {
|
||||
template.tags = toStringArray(asRecord(configPayload)?.categories);
|
||||
}
|
||||
results.push(template);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed app folder data.
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function parseGenericArchiveCatalog(
|
||||
zip: AdmZip,
|
||||
archiveUrl = "",
|
||||
): AppTemplate[] {
|
||||
const templates: AppTemplate[] = [];
|
||||
const candidateEntries = zip
|
||||
.getEntries()
|
||||
.filter((entry) => {
|
||||
if (entry.isDirectory) return false;
|
||||
const lower = entry.entryName.toLowerCase();
|
||||
if (!/\.(json|ya?ml)$/.test(lower)) return false;
|
||||
return /(app|catalog|manifest|template|store|config|compose|umbrel)/.test(
|
||||
lower,
|
||||
);
|
||||
})
|
||||
.slice(0, 300);
|
||||
|
||||
candidateEntries.forEach((entry) => {
|
||||
try {
|
||||
const payload = parseStructuredText(entry.getData().toString("utf8"));
|
||||
const fallbackId = slugify(
|
||||
entry.entryName.split("/").slice(-2, -1)[0] ?? entry.name,
|
||||
);
|
||||
templates.push(
|
||||
...normalizeSingleManifest(
|
||||
payload,
|
||||
fallbackId,
|
||||
getArchiveEntryBaseUrl(archiveUrl, entry.entryName),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore malformed files.
|
||||
}
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function parseArchiveResponse(
|
||||
response: Response,
|
||||
source: CatalogSourceMode,
|
||||
label: string,
|
||||
url: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const zip = new AdmZip(buffer);
|
||||
const casaOsTemplates = parseCasaOsArchiveCatalog(zip, url);
|
||||
const genericTemplates = parseGenericArchiveCatalog(zip, url);
|
||||
|
||||
return {
|
||||
templates: dedupeTemplates([...casaOsTemplates, ...genericTemplates]),
|
||||
meta: {
|
||||
source,
|
||||
label,
|
||||
url,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
format: casaOsTemplates.length > 0 ? "casaos-archive" : "archive",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import { CacheEntry, CatalogMeta, CatalogSyncResult } from "./types";
|
||||
|
||||
const REMOTE_CATALOG_CACHE = new Map<string, CacheEntry>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export function getCacheKey(source: string, url?: string): string {
|
||||
return `${source}:${(url ?? "").trim()}`;
|
||||
}
|
||||
|
||||
export function getCachedCatalog(key: string): CacheEntry | undefined {
|
||||
const entry = REMOTE_CATALOG_CACHE.get(key);
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
REMOTE_CATALOG_CACHE.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setCachedCatalog(
|
||||
key: string,
|
||||
templates: AppTemplate[],
|
||||
meta: CatalogMeta,
|
||||
): CatalogSyncResult {
|
||||
const expiresAt = Date.now() + CACHE_TTL_MS;
|
||||
const cacheMeta: CatalogMeta = {
|
||||
...meta,
|
||||
cached: false,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
};
|
||||
|
||||
REMOTE_CATALOG_CACHE.set(key, {
|
||||
templates,
|
||||
meta: cacheMeta,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { templates, meta: cacheMeta };
|
||||
}
|
||||
|
||||
export function makeCachedResult(entry: CacheEntry): CatalogSyncResult {
|
||||
return {
|
||||
templates: entry.templates,
|
||||
meta: {
|
||||
...entry.meta,
|
||||
cached: true,
|
||||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CatalogSourceOption } from "./types";
|
||||
|
||||
export const CATALOG_SOURCE_OPTIONS: CatalogSourceOption[] = [
|
||||
{
|
||||
id: "local",
|
||||
label: "Built-in Catalog",
|
||||
description: "Use the local catalog bundled with this backend.",
|
||||
requiresUrl: false,
|
||||
},
|
||||
{
|
||||
id: "auto-detect",
|
||||
label: "Custom Source",
|
||||
description:
|
||||
"Paste a remote URL and let the backend detect whether it is a manifest, compose/config file, or ZIP app store archive.",
|
||||
requiresUrl: true,
|
||||
placeholder:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
exampleUrl: "https://casaos-appstore.paodayag.dev/linuxserver.zip",
|
||||
},
|
||||
{
|
||||
id: "manifest-url",
|
||||
label: "Manifest URL",
|
||||
description:
|
||||
"Fetch app entries from a remote JSON, YAML, CasaOS config, compose, or Umbrel manifest URL.",
|
||||
requiresUrl: true,
|
||||
placeholder: "https://example.com/apps.json",
|
||||
exampleUrl:
|
||||
"https://raw.githubusercontent.com/bigbeartechworld/big-bear-casaos/master/Apps/2fauth/config.json",
|
||||
},
|
||||
{
|
||||
id: "github-archive",
|
||||
label: "GitHub Archive ZIP",
|
||||
description:
|
||||
"Download a GitHub archive ZIP and scan CasaOS app folders, config.json, docker-compose.yml, JSON, or YAML manifests.",
|
||||
requiresUrl: true,
|
||||
placeholder:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
exampleUrl:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./catalog-options";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,207 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
buildImageWithVersion,
|
||||
slugify,
|
||||
toStringArray,
|
||||
} from "./utils";
|
||||
import {
|
||||
extractCandidateEntries,
|
||||
normalizeEntry,
|
||||
normalizeEnv,
|
||||
normalizeIcon,
|
||||
normalizePorts,
|
||||
normalizeVolumes,
|
||||
} from "./normalize";
|
||||
|
||||
function getMainService(
|
||||
composeRecord: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const services = asRecord(composeRecord.services);
|
||||
if (!services) return undefined;
|
||||
|
||||
const casaOs = asRecord(composeRecord["x-casaos"]);
|
||||
const mainName = asString(casaOs?.main);
|
||||
if (mainName) {
|
||||
const service = asRecord(services[mainName]);
|
||||
if (service) return service;
|
||||
}
|
||||
|
||||
const firstKey = Object.keys(services)[0];
|
||||
return firstKey ? asRecord(services[firstKey]) : undefined;
|
||||
}
|
||||
|
||||
function isGenericAppTemplateRecord(record: Record<string, unknown>): boolean {
|
||||
return [
|
||||
"desc",
|
||||
"category",
|
||||
"tags",
|
||||
"defaultPort",
|
||||
"defaultEnv",
|
||||
"defaultVolumes",
|
||||
"defaultCommand",
|
||||
"defaultNetwork",
|
||||
"restartPolicy",
|
||||
"presets",
|
||||
"presentation",
|
||||
].some((key) => key in record);
|
||||
}
|
||||
|
||||
export function parseCasaOsCompose(
|
||||
composeRecord: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const casaOs = asRecord(composeRecord["x-casaos"]);
|
||||
const mainService = getMainService(composeRecord);
|
||||
const titleBlock = asRecord(casaOs?.title);
|
||||
const descBlock = asRecord(casaOs?.description);
|
||||
const taglineBlock = asRecord(casaOs?.tagline);
|
||||
|
||||
const name =
|
||||
asString(titleBlock?.en_us) ??
|
||||
asString(casaOs?.title) ??
|
||||
asString(casaOs?.name) ??
|
||||
fallbackId;
|
||||
const description =
|
||||
asString(descBlock?.en_us) ??
|
||||
asString(taglineBlock?.en_us) ??
|
||||
asString(casaOs?.description) ??
|
||||
"Community app compose manifest";
|
||||
const image =
|
||||
asString(mainService?.image) ??
|
||||
buildImageWithVersion(asString(casaOs?.image), asString(casaOs?.version));
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return {
|
||||
id: asString(casaOs?.id) ?? slugify(fallbackId),
|
||||
name,
|
||||
desc: description,
|
||||
category: asString(casaOs?.category) ?? "Community",
|
||||
icon: normalizeIcon({ icon: casaOs?.icon, logo: casaOs?.logo }, baseUrl),
|
||||
image,
|
||||
defaultPort: normalizePorts(casaOs?.port_map ?? mainService?.ports),
|
||||
defaultEnv: normalizeEnv(mainService?.environment),
|
||||
defaultVolumes: normalizeVolumes(mainService?.volumes) || undefined,
|
||||
defaultCommand: asString(mainService?.command),
|
||||
defaultNetwork:
|
||||
asString(mainService?.network_mode) ?? asString(casaOs?.network),
|
||||
restartPolicy:
|
||||
asString(mainService?.restart) ??
|
||||
asString(casaOs?.restart_policy) ??
|
||||
"unless-stopped",
|
||||
popular: false,
|
||||
tags: [
|
||||
...new Set(
|
||||
[
|
||||
asString(casaOs?.category),
|
||||
"community",
|
||||
...toStringArray(casaOs?.architectures),
|
||||
].filter((tag): tag is string => Boolean(tag)),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCasaOsConfigJson(
|
||||
record: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const id = asString(record.id) ?? slugify(fallbackId);
|
||||
const name =
|
||||
asString(record.name) ??
|
||||
asString(asRecord(record.title)?.en_us) ??
|
||||
asString(record.id) ??
|
||||
fallbackId;
|
||||
const image = buildImageWithVersion(
|
||||
asString(record.image),
|
||||
asString(record.version),
|
||||
);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: normalizeIcon(record, baseUrl),
|
||||
desc:
|
||||
asString(record.description) ??
|
||||
asString(record.short_desc) ??
|
||||
"Community app config",
|
||||
category: toStringArray(record.categories)[0] ?? "Community",
|
||||
image,
|
||||
defaultPort: normalizePorts(record.port),
|
||||
defaultEnv: normalizeEnv(record.form_fields),
|
||||
restartPolicy: "unless-stopped",
|
||||
popular: Boolean(record.featured ?? false),
|
||||
tags: [...new Set([...toStringArray(record.categories), "community"])],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUmbrelManifest(
|
||||
record: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const name = asString(record.name) ?? fallbackId;
|
||||
const id = asString(record.id) ?? slugify(fallbackId);
|
||||
const port = asNumber(record.port);
|
||||
const image =
|
||||
asString(record.image) ??
|
||||
buildImageWithVersion(asString(record.repo), asString(record.version));
|
||||
|
||||
if (!name || !image) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: normalizeIcon(record, baseUrl),
|
||||
desc:
|
||||
asString(record.description) ??
|
||||
asString(record.tagline) ??
|
||||
"Umbrel manifest",
|
||||
category: asString(record.category) ?? "Umbrel",
|
||||
image,
|
||||
defaultPort: port ? `${port}:${port}` : normalizePorts(record.port),
|
||||
defaultEnv: "",
|
||||
defaultVolumes: undefined,
|
||||
defaultCommand: undefined,
|
||||
defaultNetwork: undefined,
|
||||
restartPolicy: "unless-stopped",
|
||||
popular: false,
|
||||
tags: [
|
||||
...new Set(["umbrel", asString(record.category) ?? ""].filter(Boolean)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSingleManifest(
|
||||
payload: unknown,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record) return [];
|
||||
|
||||
const compose = parseCasaOsCompose(record, fallbackId, baseUrl);
|
||||
if (compose) return [compose];
|
||||
|
||||
if (isGenericAppTemplateRecord(record)) {
|
||||
const generic = normalizeEntry(record, 0, baseUrl);
|
||||
if (generic) return [generic];
|
||||
}
|
||||
|
||||
const casaConfig = parseCasaOsConfigJson(record, fallbackId, baseUrl);
|
||||
if (casaConfig) return [casaConfig];
|
||||
|
||||
const umbrel = parseUmbrelManifest(record, fallbackId, baseUrl);
|
||||
if (umbrel) return [umbrel];
|
||||
|
||||
return extractCandidateEntries(payload)
|
||||
.map((entry, index) => normalizeEntry(entry, index, baseUrl))
|
||||
.filter((entry): entry is AppTemplate => Boolean(entry));
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
buildImageWithVersion,
|
||||
slugify,
|
||||
toStringArray,
|
||||
} from "./utils";
|
||||
|
||||
function isAbsoluteHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveRemoteAssetUrl(
|
||||
value: unknown,
|
||||
baseUrl?: string,
|
||||
): string | undefined {
|
||||
const raw = asString(value);
|
||||
if (!raw) return undefined;
|
||||
if (isAbsoluteHttpUrl(raw)) return raw;
|
||||
if (!baseUrl) return undefined;
|
||||
|
||||
try {
|
||||
return new URL(raw, baseUrl).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTags(entry: Record<string, unknown>): string[] {
|
||||
const explicit = toStringArray(
|
||||
entry.tags ?? entry.keywords ?? entry.categories ?? entry.category,
|
||||
);
|
||||
return explicit.slice(0, 8);
|
||||
}
|
||||
|
||||
export function normalizePorts(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}:${value}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
if (typeof item === "number") return `${item}:${item}`;
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const host = String(
|
||||
record.host ?? record.hostPort ?? record.public ?? record.port ?? "",
|
||||
).trim();
|
||||
const container = String(
|
||||
record.container ??
|
||||
record.containerPort ??
|
||||
record.private ??
|
||||
record.target ??
|
||||
record.port ??
|
||||
"",
|
||||
).trim();
|
||||
const protocol = String(record.protocol ?? "tcp")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!host || !container) return "";
|
||||
return protocol === "udp"
|
||||
? `${host}:${container}/udp`
|
||||
: `${host}:${container}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([container, host]) => {
|
||||
const hostStr = String(host ?? "").trim();
|
||||
const containerStr = container.trim();
|
||||
return hostStr && containerStr ? `${hostStr}:${containerStr}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function normalizeEnv(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const key = String(
|
||||
record.key ?? record.name ?? record.label ?? "",
|
||||
).trim();
|
||||
const val = String(
|
||||
record.value ?? record.default ?? record.defaultValue ?? "",
|
||||
).trim();
|
||||
return key ? `${key}=${val}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([key, val]) => `${key}=${String(val ?? "").trim()}`)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function normalizeVolumes(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const host = String(
|
||||
record.host ?? record.source ?? record.src ?? record.bind ?? "",
|
||||
).trim();
|
||||
const container = String(
|
||||
record.container ??
|
||||
record.target ??
|
||||
record.dst ??
|
||||
record.destination ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
return host && container ? `${host}:${container}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([container, host]) => {
|
||||
const hostStr = String(host ?? "").trim();
|
||||
return hostStr && container.trim()
|
||||
? `${hostStr}:${container.trim()}`
|
||||
: "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function normalizePresetList(entry: Record<string, unknown>) {
|
||||
const presetsRaw = Array.isArray(entry.presets) ? entry.presets : [];
|
||||
|
||||
return presetsRaw
|
||||
.map((preset) => {
|
||||
const record = asRecord(preset);
|
||||
if (!record) return null;
|
||||
|
||||
const name =
|
||||
asString(record.name) ??
|
||||
asString(record.title) ??
|
||||
asString(record.id) ??
|
||||
"Preset";
|
||||
const id = asString(record.id) ?? slugify(name);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
desc: asString(record.desc) ?? asString(record.description) ?? "",
|
||||
defaultPort: normalizePorts(
|
||||
record.defaultPort ?? record.ports ?? record.port_map,
|
||||
),
|
||||
defaultEnv: normalizeEnv(
|
||||
record.defaultEnv ?? record.env ?? record.environment,
|
||||
),
|
||||
defaultVolumes: normalizeVolumes(
|
||||
record.defaultVolumes ?? record.volumes ?? record.storage,
|
||||
),
|
||||
defaultCommand:
|
||||
asString(record.defaultCommand) ?? asString(record.command),
|
||||
defaultNetwork:
|
||||
asString(record.defaultNetwork) ?? asString(record.network),
|
||||
restartPolicy:
|
||||
asString(record.restartPolicy) ?? asString(record.restart),
|
||||
};
|
||||
})
|
||||
.filter((preset): preset is NonNullable<typeof preset> => Boolean(preset));
|
||||
}
|
||||
|
||||
function normalizePresentation(
|
||||
entry: Record<string, unknown>,
|
||||
baseUrl?: string,
|
||||
): AppTemplate["presentation"] | undefined {
|
||||
const presentation = asRecord(entry.presentation);
|
||||
if (!presentation) return undefined;
|
||||
|
||||
const icon = resolveRemoteAssetUrl(presentation.icon, baseUrl);
|
||||
const color = asString(presentation.color);
|
||||
const installs = asNumber(presentation.installs);
|
||||
|
||||
if (!icon && !color && installs === undefined) return undefined;
|
||||
|
||||
return {
|
||||
...(icon ? { icon } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(installs !== undefined ? { installs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIcon(
|
||||
entry: Record<string, unknown>,
|
||||
baseUrl?: string,
|
||||
): string | undefined {
|
||||
const iconBlock = asRecord(entry.icon);
|
||||
const logoBlock = asRecord(entry.logo);
|
||||
|
||||
return (
|
||||
resolveRemoteAssetUrl(entry.icon, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.iconUrl, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.icon_url, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logo, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logoUrl, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logo_url, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.thumbnail, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.thumb, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.avatar, baseUrl) ??
|
||||
resolveRemoteAssetUrl(iconBlock?.en_us, baseUrl) ??
|
||||
resolveRemoteAssetUrl(logoBlock?.en_us, baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeEntry(
|
||||
entry: Record<string, unknown>,
|
||||
index: number,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const name =
|
||||
asString(entry.name) ??
|
||||
asString(entry.title) ??
|
||||
asString(entry.app_name) ??
|
||||
"";
|
||||
const image =
|
||||
asString(entry.image) ??
|
||||
asString(entry.docker_image) ??
|
||||
asString(entry.container_image) ??
|
||||
asString(entry.repo) ??
|
||||
buildImageWithVersion(asString(entry.image), asString(entry.version));
|
||||
|
||||
if (!name || !image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
(asString(entry.id) ??
|
||||
asString(entry.slug) ??
|
||||
asString(entry.appid) ??
|
||||
slugify(name)) ||
|
||||
`remote-${index}`;
|
||||
|
||||
const presets = normalizePresetList(entry);
|
||||
const presentation = normalizePresentation(entry, baseUrl);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
desc:
|
||||
asString(entry.desc) ??
|
||||
asString(entry.description) ??
|
||||
asString(entry.short_desc) ??
|
||||
asString(entry.subtitle) ??
|
||||
"Remote app catalog entry",
|
||||
category:
|
||||
asString(entry.category) ??
|
||||
asString(entry.type) ??
|
||||
asString(entry.section) ??
|
||||
"Remote",
|
||||
icon: normalizeIcon(entry, baseUrl),
|
||||
image,
|
||||
defaultPort: normalizePorts(
|
||||
entry.defaultPort ??
|
||||
entry.ports ??
|
||||
entry.port_map ??
|
||||
entry.portMap ??
|
||||
entry.port,
|
||||
),
|
||||
defaultEnv: normalizeEnv(
|
||||
entry.defaultEnv ?? entry.env ?? entry.environment ?? entry.envs,
|
||||
),
|
||||
defaultVolumes:
|
||||
normalizeVolumes(
|
||||
entry.defaultVolumes ?? entry.volumes ?? entry.storage ?? entry.mounts,
|
||||
) || undefined,
|
||||
defaultCommand: asString(entry.defaultCommand) ?? asString(entry.command),
|
||||
defaultNetwork: asString(entry.defaultNetwork) ?? asString(entry.network),
|
||||
restartPolicy:
|
||||
asString(entry.restartPolicy) ??
|
||||
asString(entry.restart) ??
|
||||
asString(entry.restart_policy) ??
|
||||
"unless-stopped",
|
||||
popular: Boolean(entry.popular ?? entry.featured ?? false),
|
||||
tags: normalizeTags(entry),
|
||||
presentation,
|
||||
presets: presets.length > 0 ? presets : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCandidateEntries(
|
||||
payload: unknown,
|
||||
): Record<string, unknown>[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
if (!record) return [];
|
||||
|
||||
for (const key of [
|
||||
"apps",
|
||||
"templates",
|
||||
"data",
|
||||
"items",
|
||||
"catalog",
|
||||
"entries",
|
||||
]) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
}
|
||||
}
|
||||
|
||||
return [record];
|
||||
}
|
||||
|
||||
export function dedupeTemplates(templates: AppTemplate[]): AppTemplate[] {
|
||||
const seen = new Set<string>();
|
||||
return templates.filter((template) => {
|
||||
if (seen.has(template.id)) return false;
|
||||
seen.add(template.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { CatalogSourceMode } from "./types";
|
||||
import { slugify } from "./utils";
|
||||
|
||||
export function normalizeGithubManifestUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.hostname === "github.com" && parsed.pathname.includes("/blob/")) {
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
const owner = parts[0];
|
||||
const repo = parts[1];
|
||||
const branch = parts[3];
|
||||
const filePath = parts.slice(4).join("/");
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function normalizeGithubArchiveUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.hostname === "github.com") {
|
||||
const match = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/refs\/(heads|tags)\/([^/]+)\.zip$/,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [, owner, repo, refType, refName] = match;
|
||||
return `https://codeload.github.com/${owner}/${repo}/zip/refs/${refType}/${refName}`;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getFallbackIdFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "remote-manifest";
|
||||
const normalizedLast = lastSegment.toLowerCase();
|
||||
|
||||
if (
|
||||
/^(config\.json|docker-compose\.ya?ml|compose\.ya?ml|umbrel-app\.ya?ml|app\.json|manifest\.json|manifest\.ya?ml)$/.test(
|
||||
normalizedLast,
|
||||
)
|
||||
) {
|
||||
return slugify(segments.at(-2) ?? lastSegment);
|
||||
}
|
||||
|
||||
return slugify(lastSegment.replace(/\.(json|ya?ml)$/i, ""));
|
||||
} catch {
|
||||
return slugify(url.split("/").pop() ?? "remote-manifest");
|
||||
}
|
||||
}
|
||||
|
||||
function isZipUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /\.zip$/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return /\.zip($|\?)/i.test(url);
|
||||
}
|
||||
}
|
||||
|
||||
function isZipContentType(contentType?: string | null): boolean {
|
||||
if (!contentType) return false;
|
||||
|
||||
const normalized = contentType.toLowerCase();
|
||||
return (
|
||||
normalized.includes("zip") ||
|
||||
normalized.includes("compressed") ||
|
||||
normalized.includes("x-zip")
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDispositionFileName(header?: string | null): string {
|
||||
if (!header) return "";
|
||||
|
||||
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
return utf8Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = header.match(/filename="?([^";]+)"?/i);
|
||||
return plainMatch?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function isZipResponse(response: Response, url: string): boolean {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const fileName = getContentDispositionFileName(
|
||||
response.headers.get("content-disposition"),
|
||||
);
|
||||
|
||||
return (
|
||||
isZipUrl(url) || isZipContentType(contentType) || /\.zip$/i.test(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRemoteResponse(url: string): Promise<Response> {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("Remote catalog URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept:
|
||||
"application/json, application/yaml, text/yaml, text/x-yaml, text/plain, application/octet-stream, */*",
|
||||
"User-Agent": "vps-panel-app-catalog/1.0",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Remote request failed with status ${response.status} for ${response.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function normalizeRemoteUrl(
|
||||
source: CatalogSourceMode,
|
||||
url: string,
|
||||
): string {
|
||||
const manifestNormalized = normalizeGithubManifestUrl(url);
|
||||
return source === "github-archive"
|
||||
? normalizeGithubArchiveUrl(url)
|
||||
: normalizeGithubArchiveUrl(manifestNormalized);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { APP_TEMPLATES } from "../../config/app-templates";
|
||||
import {
|
||||
getCachedCatalog,
|
||||
getCacheKey,
|
||||
makeCachedResult,
|
||||
setCachedCatalog,
|
||||
} from "./cache";
|
||||
import { CatalogSyncResult, CatalogSourceMode } from "./types";
|
||||
import { parseArchiveResponse } from "./archive";
|
||||
import { dedupeTemplates } from "./normalize";
|
||||
import { normalizeSingleManifest } from "./manifest-parsers";
|
||||
import {
|
||||
fetchRemoteResponse,
|
||||
getFallbackIdFromUrl,
|
||||
isZipResponse,
|
||||
normalizeRemoteUrl,
|
||||
} from "./remote";
|
||||
import {
|
||||
CatalogSourceValidationError,
|
||||
isLikelyHtmlDocument,
|
||||
parseStructuredText,
|
||||
} from "./utils";
|
||||
|
||||
function detectTextFormat(text: string): string {
|
||||
return text.trim().startsWith("{") || text.trim().startsWith("[")
|
||||
? "json"
|
||||
: "yaml";
|
||||
}
|
||||
|
||||
async function loadRemoteTextCatalog(
|
||||
source: Extract<CatalogSourceMode, "auto-detect" | "manifest-url">,
|
||||
url: string,
|
||||
label: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const normalizedUrl = normalizeRemoteUrl(source, url);
|
||||
const response = await fetchRemoteResponse(normalizedUrl);
|
||||
|
||||
if (isZipResponse(response, normalizedUrl)) {
|
||||
return parseArchiveResponse(response, source, label, normalizedUrl);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (isLikelyHtmlDocument(text, response.headers.get("content-type"))) {
|
||||
throw new CatalogSourceValidationError();
|
||||
}
|
||||
|
||||
const payload = parseStructuredText(text);
|
||||
const fallbackId = getFallbackIdFromUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
templates: dedupeTemplates(
|
||||
normalizeSingleManifest(payload, fallbackId, normalizedUrl),
|
||||
),
|
||||
meta: {
|
||||
source,
|
||||
label,
|
||||
url: normalizedUrl,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
format: detectTextFormat(text),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGithubArchiveCatalog(
|
||||
url: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const normalizedUrl = normalizeRemoteUrl("github-archive", url);
|
||||
const response = await fetchRemoteResponse(normalizedUrl);
|
||||
return parseArchiveResponse(
|
||||
response,
|
||||
"github-archive",
|
||||
"GitHub Archive ZIP",
|
||||
normalizedUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCatalogTemplates(
|
||||
source: CatalogSourceMode,
|
||||
url?: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
if (source === "local") {
|
||||
return {
|
||||
templates: APP_TEMPLATES,
|
||||
meta: {
|
||||
source,
|
||||
label: "Built-in Catalog",
|
||||
fetchedAt,
|
||||
isRemote: false,
|
||||
cached: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!url?.trim()) {
|
||||
throw new Error("Remote catalog URL is required");
|
||||
}
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const cacheKey = getCacheKey(source, trimmedUrl);
|
||||
const cachedEntry = getCachedCatalog(cacheKey);
|
||||
if (cachedEntry) {
|
||||
return makeCachedResult(cachedEntry);
|
||||
}
|
||||
|
||||
const result =
|
||||
source === "auto-detect"
|
||||
? await loadRemoteTextCatalog(source, trimmedUrl, "Custom Source")
|
||||
: source === "manifest-url"
|
||||
? await loadRemoteTextCatalog(source, trimmedUrl, "Remote Manifest URL")
|
||||
: await loadGithubArchiveCatalog(trimmedUrl);
|
||||
|
||||
if (result.templates.length === 0) {
|
||||
throw new Error(
|
||||
"No app templates could be extracted from the remote source",
|
||||
);
|
||||
}
|
||||
|
||||
return setCachedCatalog(cacheKey, result.templates, result.meta);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
|
||||
export type CatalogSourceMode =
|
||||
| "local"
|
||||
| "auto-detect"
|
||||
| "manifest-url"
|
||||
| "github-archive";
|
||||
|
||||
export interface CatalogMeta {
|
||||
source: CatalogSourceMode;
|
||||
label: string;
|
||||
url?: string;
|
||||
fetchedAt: string;
|
||||
isRemote: boolean;
|
||||
cached?: boolean;
|
||||
expiresAt?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface CatalogSyncResult {
|
||||
templates: AppTemplate[];
|
||||
meta: CatalogMeta;
|
||||
}
|
||||
|
||||
export interface CatalogSourceOption {
|
||||
id: CatalogSourceMode;
|
||||
label: string;
|
||||
description: string;
|
||||
requiresUrl: boolean;
|
||||
placeholder?: string;
|
||||
exampleUrl?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
templates: AppTemplate[];
|
||||
meta: CatalogMeta;
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export const REMOTE_CATALOG_SOURCE_HELP =
|
||||
"Custom source must point to a raw JSON/YAML app manifest or a direct ZIP archive URL, not a browser HTML page. Use a raw file URL or a repository archive URL.";
|
||||
|
||||
export class CatalogSourceValidationError extends Error {
|
||||
constructor(message = REMOTE_CATALOG_SOURCE_HELP) {
|
||||
super(message);
|
||||
this.name = "CatalogSourceValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildImageWithVersion(
|
||||
image?: string,
|
||||
version?: string,
|
||||
): string {
|
||||
const imageValue = image?.trim() ?? "";
|
||||
const versionValue = version?.trim() ?? "";
|
||||
|
||||
if (!imageValue) return "";
|
||||
if (!versionValue) return imageValue;
|
||||
|
||||
const lastColon = imageValue.lastIndexOf(":");
|
||||
const lastSlash = imageValue.lastIndexOf("/");
|
||||
if (lastColon > lastSlash) {
|
||||
return imageValue;
|
||||
}
|
||||
|
||||
return `${imageValue}:${versionValue}`;
|
||||
}
|
||||
|
||||
export function isLikelyHtmlDocument(
|
||||
text: string,
|
||||
contentType?: string | null,
|
||||
): boolean {
|
||||
if (contentType?.toLowerCase().includes("text/html")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const preview = text.trimStart().slice(0, 4096).toLowerCase();
|
||||
if (!preview) return false;
|
||||
|
||||
return (
|
||||
preview.startsWith("<!doctype html") ||
|
||||
preview.startsWith("<html") ||
|
||||
preview.includes("<head") ||
|
||||
preview.includes("<body") ||
|
||||
preview.includes("<style") ||
|
||||
preview.includes("<script")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseStructuredText(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
if (isLikelyHtmlDocument(trimmed)) {
|
||||
throw new CatalogSourceValidationError();
|
||||
}
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
return yaml.load(trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogSourceValidationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new CatalogSourceValidationError(
|
||||
"Custom source could not be parsed as JSON or YAML. Check that the URL returns a raw app manifest or use a direct ZIP archive URL.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Server } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { auditLog } from "./audit.service";
|
||||
import { dispatchRuntimeNotification } from "./notification.service";
|
||||
import * as ssh from "./ssh.service";
|
||||
import { APP_TEMPLATES } from "../config/app-templates";
|
||||
|
||||
type DockerInspectRuntime = {
|
||||
Config?: {
|
||||
Image?: string;
|
||||
Env?: string[];
|
||||
Cmd?: string[] | null;
|
||||
};
|
||||
HostConfig?: {
|
||||
RestartPolicy?: { Name?: string | null };
|
||||
PortBindings?: Record<
|
||||
string,
|
||||
Array<{ HostIp?: string; HostPort?: string }> | null
|
||||
>;
|
||||
NetworkMode?: string | null;
|
||||
};
|
||||
Mounts?: Array<{
|
||||
Source?: string;
|
||||
Destination?: string;
|
||||
RW?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type DockerPortBindings = Record<
|
||||
string,
|
||||
Array<{ HostIp?: string; HostPort?: string }> | null
|
||||
>;
|
||||
|
||||
function formatEnvLines(env?: string[] | null): string {
|
||||
return (env ?? []).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function formatMountBindings(mounts?: DockerInspectRuntime["Mounts"]): string {
|
||||
return (mounts ?? [])
|
||||
.map((mount) => {
|
||||
if (!mount.Source || !mount.Destination) return "";
|
||||
return `${mount.Source}:${mount.Destination}${mount.RW === false ? ":ro" : ""}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function formatPortBindings(bindings?: DockerPortBindings): string {
|
||||
if (!bindings) return "";
|
||||
|
||||
return Object.entries(bindings)
|
||||
.flatMap(([containerPortSpec, hostBindings]) => {
|
||||
if (!hostBindings || hostBindings.length === 0) return [];
|
||||
const [containerPort, protocol = "tcp"] = containerPortSpec.split("/");
|
||||
|
||||
return hostBindings
|
||||
.map((binding) => {
|
||||
const hostPort = binding.HostPort?.trim();
|
||||
if (!hostPort) return "";
|
||||
return `${hostPort}:${containerPort}${protocol !== "tcp" ? `/${protocol}` : ""}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function formatCommandParts(cmd?: string[] | null): string {
|
||||
return (cmd ?? []).filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function firstDefined(...values: Array<string | undefined>): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const TRUSTED_APP_SENSITIVE_PATHS: Record<string, string[]> = {
|
||||
docker: ["/var/run/docker.sock"],
|
||||
portainer: ["/var/run/docker.sock"],
|
||||
traefik: ["/var/run/docker.sock"],
|
||||
};
|
||||
|
||||
function resolveAppMountValidation(appId: string) {
|
||||
const allowSensitivePaths = TRUSTED_APP_SENSITIVE_PATHS[appId];
|
||||
return allowSensitivePaths ? { allowSensitivePaths } : undefined;
|
||||
}
|
||||
|
||||
type RebuildRuntimeConfig = {
|
||||
image: string;
|
||||
ports: string;
|
||||
env: string;
|
||||
volumes: string;
|
||||
network: string;
|
||||
restartPolicy: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
async function resolveRuntimeConfig(install: {
|
||||
appId: string;
|
||||
port: string | null;
|
||||
containerName: string;
|
||||
server: Server;
|
||||
}): Promise<RebuildRuntimeConfig> {
|
||||
try {
|
||||
const inspect = (await ssh.dockerInspect(
|
||||
install.server,
|
||||
install.containerName!,
|
||||
)) as DockerInspectRuntime;
|
||||
|
||||
return {
|
||||
image: inspect.Config?.Image?.trim() || "",
|
||||
ports: formatPortBindings(inspect.HostConfig?.PortBindings),
|
||||
env: formatEnvLines(inspect.Config?.Env),
|
||||
volumes: formatMountBindings(inspect.Mounts),
|
||||
network: inspect.HostConfig?.NetworkMode?.trim() || "bridge",
|
||||
restartPolicy:
|
||||
inspect.HostConfig?.RestartPolicy?.Name?.trim() || "unless-stopped",
|
||||
command: formatCommandParts(inspect.Config?.Cmd),
|
||||
};
|
||||
} catch {
|
||||
const template = APP_TEMPLATES.find((item) => item.id === install.appId);
|
||||
if (!template) {
|
||||
throw new Error(
|
||||
"Current runtime configuration is unavailable, so this app cannot be rebuilt automatically",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
image: template.image,
|
||||
ports: firstDefined(install.port || undefined, template.defaultPort),
|
||||
env: firstDefined(template.defaultEnv),
|
||||
volumes: firstDefined(template.defaultVolumes),
|
||||
network: firstDefined(template.defaultNetwork, "bridge"),
|
||||
restartPolicy: firstDefined(template.restartPolicy, "unless-stopped"),
|
||||
command: firstDefined(template.defaultCommand),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuildAppInstall(options: {
|
||||
installId: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const install = await prisma.appInstall.findUnique({
|
||||
where: { id: options.installId },
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (!install) {
|
||||
throw new Error("Install not found");
|
||||
}
|
||||
|
||||
if (!install.containerName) {
|
||||
throw new Error("Install has no container name to rebuild");
|
||||
}
|
||||
|
||||
const dockerStatus = await ssh.getDockerRuntimeStatus(install.server);
|
||||
if (!dockerStatus.available) {
|
||||
throw new Error(
|
||||
dockerStatus.reason || "Docker is not available on the selected server",
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeConfig = await resolveRuntimeConfig({
|
||||
appId: install.appId,
|
||||
port: install.port,
|
||||
containerName: install.containerName,
|
||||
server: install.server,
|
||||
});
|
||||
if (!runtimeConfig.image) {
|
||||
throw new Error("Unable to determine which image should be rebuilt");
|
||||
}
|
||||
|
||||
await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "INSTALLING", error: null },
|
||||
});
|
||||
|
||||
let pulledLatestImage = false;
|
||||
let usedImageCache = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
await ssh.dockerPullImage(install.server, runtimeConfig.image);
|
||||
pulledLatestImage = true;
|
||||
} catch {
|
||||
usedImageCache = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.dockerAction(install.server, install.containerName, "stop");
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.dockerAction(install.server, install.containerName, "rm");
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
|
||||
const dockerId = await ssh.runContainer(install.server, {
|
||||
name: install.containerName,
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
env: runtimeConfig.env,
|
||||
volumes: runtimeConfig.volumes,
|
||||
network: runtimeConfig.network,
|
||||
restartPolicy: runtimeConfig.restartPolicy,
|
||||
command: runtimeConfig.command,
|
||||
mountValidation: resolveAppMountValidation(install.appId),
|
||||
});
|
||||
|
||||
const normalizedDockerId = dockerId.trim().slice(0, 12);
|
||||
|
||||
await prisma.container.updateMany({
|
||||
where: {
|
||||
serverId: install.serverId,
|
||||
name: { equals: install.containerName, mode: "insensitive" },
|
||||
},
|
||||
data: {
|
||||
image: runtimeConfig.image,
|
||||
status: "RUNNING",
|
||||
dockerId: normalizedDockerId || null,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "RUNNING", error: null },
|
||||
include: {
|
||||
server: { select: { name: true, ip: true, organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: options.userId,
|
||||
serverId: install.serverId,
|
||||
action: "APP_REBUILD",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `App "${install.appName}" rebuilt on "${install.server.name}"`,
|
||||
meta: {
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
network: runtimeConfig.network,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.organizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: options.organizationId,
|
||||
action: "app_deploy",
|
||||
title: `App rebuilt: ${install.appName}`,
|
||||
message: `App ${install.appName} was rebuilt successfully on ${install.server.name} using image ${runtimeConfig.image}.`,
|
||||
serverId: install.serverId,
|
||||
resourceType: "app_install",
|
||||
resourceId: install.id,
|
||||
metadata: {
|
||||
installId: install.id,
|
||||
appId: install.appId,
|
||||
appName: install.appName,
|
||||
image: runtimeConfig.image,
|
||||
serverId: install.serverId,
|
||||
serverName: install.server.name,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updated,
|
||||
image: runtimeConfig.image,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to rebuild app";
|
||||
|
||||
await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "FAILED", error: message },
|
||||
});
|
||||
|
||||
await prisma.container.updateMany({
|
||||
where: {
|
||||
serverId: install.serverId,
|
||||
name: { equals: install.containerName, mode: "insensitive" },
|
||||
},
|
||||
data: { status: "ERROR" },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: options.userId,
|
||||
serverId: install.serverId,
|
||||
action: "APP_REBUILD_FAILED",
|
||||
category: "SYSTEM",
|
||||
level: "ERROR",
|
||||
message: `App "${install.appName}" rebuild failed on "${install.server.name}": ${message}`,
|
||||
meta: {
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
network: runtimeConfig.network,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.organizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: options.organizationId,
|
||||
action: "app_build_error",
|
||||
title: `App rebuild failed: ${install.appName}`,
|
||||
message: `App ${install.appName} failed to rebuild on ${install.server.name}. ${message}`,
|
||||
serverId: install.serverId,
|
||||
resourceType: "app_install",
|
||||
resourceId: install.id,
|
||||
metadata: {
|
||||
installId: install.id,
|
||||
appId: install.appId,
|
||||
appName: install.appName,
|
||||
image: runtimeConfig.image,
|
||||
serverId: install.serverId,
|
||||
serverName: install.server.name,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import prisma from "../lib/prisma";
|
||||
import { LogCategory, LogLevel } from "@prisma/client";
|
||||
|
||||
interface LogEntry {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId?: string;
|
||||
action: string;
|
||||
category: LogCategory;
|
||||
level?: LogLevel;
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function resolveOrganizationId(entry: LogEntry) {
|
||||
if (entry.organizationId) return entry.organizationId;
|
||||
|
||||
if (entry.serverId) {
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id: entry.serverId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (server) return server.organizationId;
|
||||
}
|
||||
|
||||
if (entry.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: entry.userId },
|
||||
select: { activeOrganizationId: true },
|
||||
});
|
||||
|
||||
if (user?.activeOrganizationId) return user.activeOrganizationId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function auditLog(entry: LogEntry): Promise<void> {
|
||||
try {
|
||||
const organizationId = await resolveOrganizationId(entry);
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
organizationId,
|
||||
serverId: entry.serverId,
|
||||
action: entry.action,
|
||||
category: entry.category,
|
||||
level: entry.level ?? LogLevel.INFO,
|
||||
message: entry.message,
|
||||
meta: entry.meta as object | undefined,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Never let logging crash the main flow
|
||||
console.error("[AuditLog] Failed to write log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogs(opts: {
|
||||
organizationId?: string;
|
||||
serverId?: string;
|
||||
category?: LogCategory;
|
||||
level?: LogLevel;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
search,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
} = opts;
|
||||
|
||||
const [logs, total] = await prisma.$transaction([
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
...(search
|
||||
? { message: { contains: search, mode: "insensitive" } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
server: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
...(search
|
||||
? { message: { contains: search, mode: "insensitive" } }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,864 @@
|
||||
import { Container, ProxyType, Server } from "@prisma/client";
|
||||
import yaml from "js-yaml";
|
||||
import {
|
||||
DockerContainerInspect,
|
||||
dockerInspect,
|
||||
exec,
|
||||
resolveSslCertificate,
|
||||
restartManagedService,
|
||||
} from "./ssh.service";
|
||||
|
||||
export interface DomainProvisioningResult {
|
||||
upstream: string;
|
||||
configPath: string;
|
||||
reloadTarget: string;
|
||||
}
|
||||
|
||||
export class DomainProvisioningError extends Error {}
|
||||
|
||||
interface ContainerPortBinding {
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
hostPort: number | null;
|
||||
}
|
||||
|
||||
interface ContainerUpstreamTarget {
|
||||
upstream: string;
|
||||
selectedPort: number;
|
||||
}
|
||||
|
||||
function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
function escapeDoubleQuotedShellArg(value: string): string {
|
||||
return `"${value.replace(/[\\"$`]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
function shellCommand(command: string): string {
|
||||
return `sh -c ${escapeShellArg(command)}`;
|
||||
}
|
||||
|
||||
function privilegedShellCommand(server: Server, command: string): string {
|
||||
return server.username === "root"
|
||||
? shellCommand(command)
|
||||
: `sudo ${shellCommand(command)}`;
|
||||
}
|
||||
|
||||
async function execPrivileged(server: Server, script: string): Promise<string> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Remote command failed");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function execPrivilegedLoose(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function pathExists(server: Server, filePath: string): Promise<boolean> {
|
||||
const result = await exec(
|
||||
server,
|
||||
shellCommand(
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
),
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
async function pathExistsPrivileged(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await execPrivilegedLoose(
|
||||
server,
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
async function readPrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (!(await pathExistsPrivileged(server, filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return execPrivileged(server, `cat ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
async function writePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const encoded = Buffer.from(content, "utf8").toString("base64");
|
||||
const parentDir = filePath.replace(/\/[^/]+$/, "") || "/";
|
||||
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
`mkdir -p ${escapeDoubleQuotedShellArg(parentDir)}`,
|
||||
`printf %s ${escapeDoubleQuotedShellArg(encoded)} | base64 -d > ${escapeDoubleQuotedShellArg(filePath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
}
|
||||
|
||||
async function removePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await execPrivileged(server, `rm -f ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
function sanitizeDomainFileName(domainName: string): string {
|
||||
return domainName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
function sanitizeContainerFileName(containerName: string): string {
|
||||
return containerName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
function buildManagedNginxFileBase(
|
||||
containerName: string,
|
||||
domainName: string,
|
||||
): string {
|
||||
return `${sanitizeContainerFileName(containerName)}--${sanitizeDomainFileName(domainName)}`;
|
||||
}
|
||||
|
||||
function findHttpBlockEnd(configContent: string): number | null {
|
||||
const httpMatch = /\bhttp\s*\{/.exec(configContent);
|
||||
if (!httpMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const braceStart = configContent.indexOf("{", httpMatch.index);
|
||||
if (braceStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < configContent.length; index += 1) {
|
||||
const char = configContent[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureNginxSitesEnabledInclude(configContent: string): string {
|
||||
if (
|
||||
/include\s+\/etc\/nginx\/sites-enabled\/\*\.conf\s*;/.test(configContent)
|
||||
) {
|
||||
return configContent;
|
||||
}
|
||||
|
||||
const httpBlockEnd = findHttpBlockEnd(configContent);
|
||||
if (httpBlockEnd === null) {
|
||||
throw new DomainProvisioningError(
|
||||
"Could not find the http block in nginx.conf to register /etc/nginx/sites-enabled/*.conf.",
|
||||
);
|
||||
}
|
||||
|
||||
const includeLine = "\n include /etc/nginx/sites-enabled/*.conf;\n";
|
||||
return `${configContent.slice(0, httpBlockEnd)}${includeLine}${configContent.slice(httpBlockEnd)}`;
|
||||
}
|
||||
|
||||
function normalizeNginxFailureMessage(message: string): string {
|
||||
if (
|
||||
/socket\(\) \[::\]:80 failed \(97: Address family not supported by protocol\)/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
"Nginx validation failed on the target server because an IPv6 listener like `listen [::]:80` is enabled, but the host does not support IPv6.",
|
||||
"Adjust the existing Nginx configuration on that server to remove or conditionalize the IPv6 listener, then retry Add Domain.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async function detectNginxMainConfigPath(server: Server): Promise<string> {
|
||||
const candidates = new Set([
|
||||
"/etc/nginx/nginx.conf",
|
||||
"/etc/openresty/nginx.conf",
|
||||
"/usr/local/nginx/conf/nginx.conf",
|
||||
"/usr/local/openresty/nginx/conf/nginx.conf",
|
||||
"/usr/local/etc/nginx/nginx.conf",
|
||||
"/opt/nginx/conf/nginx.conf",
|
||||
"/www/server/nginx/conf/nginx.conf",
|
||||
]);
|
||||
|
||||
const versionOutput = await execPrivilegedLoose(
|
||||
server,
|
||||
"nginx -V 2>&1 || true",
|
||||
);
|
||||
const combinedVersionOutput = `${versionOutput.stdout}\n${versionOutput.stderr}`;
|
||||
const confPathMatch = combinedVersionOutput.match(/--conf-path=([^\s]+)/);
|
||||
if (confPathMatch?.[1]) {
|
||||
candidates.add(confPathMatch[1].trim());
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExistsPrivileged(server, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryResult = await execPrivilegedLoose(
|
||||
server,
|
||||
[
|
||||
"for root in /etc /usr/local /opt /www /home /srv; do",
|
||||
' if [ -d "$root" ]; then',
|
||||
' found=$(find -L "$root" -maxdepth 6 -type f -name nginx.conf 2>/dev/null | head -n 1);',
|
||||
' if [ -n "$found" ]; then printf "%s\\n" "$found"; exit 0; fi',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const discoveredPath = `${discoveryResult.stdout || ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
if (discoveredPath && (await pathExistsPrivileged(server, discoveredPath))) {
|
||||
return discoveredPath;
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
`nginx.conf was not found on ${server.name}. Checked Nginx --conf-path and common locations: ${Array.from(candidates).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNginxSitesEnabledRegistration(
|
||||
server: Server,
|
||||
): Promise<void> {
|
||||
const mainConfigPath = await detectNginxMainConfigPath(server);
|
||||
const currentContent = await readPrivilegedFile(server, mainConfigPath);
|
||||
|
||||
if (currentContent === null) {
|
||||
throw new DomainProvisioningError(
|
||||
`Unable to read Nginx main config at ${mainConfigPath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextContent = ensureNginxSitesEnabledInclude(currentContent);
|
||||
if (nextContent !== currentContent) {
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndReloadNginx(
|
||||
server: Server,
|
||||
rollbackPaths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execPrivileged(server, "nginx -t");
|
||||
await restartManagedService(server, "nginx");
|
||||
} catch (error) {
|
||||
for (const rollbackPath of rollbackPaths) {
|
||||
await removePrivilegedFile(server, rollbackPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
normalizeNginxFailureMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getContainerNetworkIp(inspect: DockerContainerInspect): string | null {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const networks = networkSettings?.Networks ?? {};
|
||||
for (const network of Object.values(networks)) {
|
||||
const ipAddress = `${network?.IPAddress ?? ""}`.trim();
|
||||
if (ipAddress) {
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContainerPortBindings(
|
||||
inspect: DockerContainerInspect,
|
||||
): ContainerPortBinding[] {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Ports?: Record<string, Array<{ HostPort?: string }> | null>;
|
||||
}
|
||||
| undefined;
|
||||
const config = inspect.Config as
|
||||
| {
|
||||
ExposedPorts?: Record<string, Record<string, never>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const bindings = new Map<string, ContainerPortBinding>();
|
||||
|
||||
for (const [rawPort, hosts] of Object.entries(networkSettings?.Ports ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hosts || hosts.length === 0) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const hostPort = Number(host?.HostPort ?? "");
|
||||
bindings.set(`${rawPort}:${host?.HostPort ?? "internal"}`, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: Number.isFinite(hostPort) ? hostPort : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawPort of Object.keys(config?.ExposedPorts ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = Array.from(bindings.values()).some(
|
||||
(binding) =>
|
||||
binding.containerPort === containerPort &&
|
||||
binding.protocol === protocol,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bindings.values()).sort(
|
||||
(left, right) => left.containerPort - right.containerPort,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeContainerTargetPorts(
|
||||
inspect: DockerContainerInspect,
|
||||
): Array<{
|
||||
containerPort: number;
|
||||
hostPort: number | null;
|
||||
protocol: string;
|
||||
label: string;
|
||||
}> {
|
||||
return getContainerPortBindings(inspect).map((binding) => ({
|
||||
...binding,
|
||||
label: binding.hostPort
|
||||
? `${binding.containerPort}/${binding.protocol} (published as ${binding.hostPort})`
|
||||
: `${binding.containerPort}/${binding.protocol} (container internal)`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveContainerUpstream(
|
||||
server: Server,
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">,
|
||||
targetPort: number,
|
||||
): Promise<ContainerUpstreamTarget> {
|
||||
const inspect = await dockerInspect(
|
||||
server,
|
||||
container.dockerId || container.name,
|
||||
);
|
||||
const bindings = getContainerPortBindings(inspect);
|
||||
const matching = bindings.find(
|
||||
(binding) => binding.containerPort === targetPort,
|
||||
);
|
||||
|
||||
if (!matching) {
|
||||
throw new Error(
|
||||
`Container ${container.name} does not expose port ${targetPort}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matching.hostPort) {
|
||||
return {
|
||||
upstream: `http://127.0.0.1:${matching.hostPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
const containerIp = getContainerNetworkIp(inspect);
|
||||
if (!containerIp) {
|
||||
throw new Error(
|
||||
`Container ${container.name} has no reachable IP address for internal port ${targetPort}. Publish the port first or connect it to a reachable Docker network.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
upstream: `http://${containerIp}:${targetPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNginxProxyLocation(upstream: string): string[] {
|
||||
return [
|
||||
" location / {",
|
||||
` proxy_pass ${upstream};`,
|
||||
" proxy_http_version 1.1;",
|
||||
" proxy_set_header Host $host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
" }",
|
||||
];
|
||||
}
|
||||
|
||||
function buildNginxConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslCertName?: string | null,
|
||||
): string {
|
||||
if (!sslCertName) {
|
||||
return [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${domainName};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const certPath = `/etc/letsencrypt/live/${sslCertName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${sslCertName}/privkey.pem`;
|
||||
|
||||
return [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${domainName};`,
|
||||
" return 301 https://$host$request_uri;",
|
||||
"}",
|
||||
"",
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${domainName};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function provisionNginxConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
containerName: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const domainSafeName = sanitizeDomainFileName(domainName);
|
||||
const safeName = buildManagedNginxFileBase(containerName, domainName);
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
const configPath = `/etc/nginx/sites-available/${safeName}.conf`;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${safeName}.conf`;
|
||||
const previousConfigPath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
|
||||
await ensureNginxSitesEnabledRegistration(server);
|
||||
const sslCertificate = sslEnabled
|
||||
? await resolveSslCertificate(server, domainName).catch(() => null)
|
||||
: null;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildNginxConfig(domainName, upstream, sslCertificate?.certName ?? null),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
"mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled",
|
||||
`ln -sfn ${escapeDoubleQuotedShellArg(configPath)} ${escapeDoubleQuotedShellArg(enabledPath)}`,
|
||||
`rm -f ${escapeDoubleQuotedShellArg(legacyConfigPath)}`,
|
||||
safeName === domainSafeName
|
||||
? "true"
|
||||
: `rm -f ${escapeDoubleQuotedShellArg(previousEnabledPath)} ${escapeDoubleQuotedShellArg(previousConfigPath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
await validateAndReloadNginx(server, [enabledPath, configPath]);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "nginx",
|
||||
};
|
||||
}
|
||||
|
||||
function buildCaddyConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): string {
|
||||
const siteLabel = sslEnabled ? domainName : `http://${domainName}`;
|
||||
return [
|
||||
`${siteLabel} {`,
|
||||
` reverse_proxy ${upstream.replace(/^https?:\/\//, "")}`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function provisionCaddyConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const mainConfigCandidates = [
|
||||
"/etc/caddy/Caddyfile",
|
||||
"/usr/local/etc/caddy/Caddyfile",
|
||||
];
|
||||
|
||||
let mainConfigPath: string | null = null;
|
||||
let mainConfigContent: string | null = null;
|
||||
for (const candidate of mainConfigCandidates) {
|
||||
const content = await readPrivilegedFile(server, candidate);
|
||||
if (content !== null) {
|
||||
mainConfigPath = candidate;
|
||||
mainConfigContent = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainConfigPath || mainConfigContent === null) {
|
||||
throw new Error(
|
||||
`Caddyfile was not found on ${server.name}. Expected one of: ${mainConfigCandidates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importLine = "import /etc/caddy/portainer-domains/*.caddy";
|
||||
if (!mainConfigContent.includes(importLine)) {
|
||||
const nextContent = `${mainConfigContent.trimEnd()}\n\n${importLine}\n`;
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
|
||||
const configPath = `/etc/caddy/portainer-domains/${sanitizeDomainFileName(domainName)}.caddy`;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildCaddyConfig(domainName, upstream, sslEnabled),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "caddy",
|
||||
};
|
||||
}
|
||||
|
||||
interface TraefikStaticConfigLocation {
|
||||
path: string;
|
||||
format: "yaml" | "toml";
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function detectTraefikStaticConfig(
|
||||
server: Server,
|
||||
): Promise<TraefikStaticConfigLocation | null> {
|
||||
const candidates: Array<{ path: string; format: "yaml" | "toml" }> = [
|
||||
{ path: "/etc/traefik/traefik.yml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.yaml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.toml", format: "toml" },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const content = await readPrivilegedFile(server, candidate.path);
|
||||
if (content !== null) {
|
||||
return { ...candidate, content };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureTraefikFileProvider(server: Server): Promise<string> {
|
||||
const dynamicDirectory = "/etc/traefik/dynamic/portainer";
|
||||
const staticConfig = await detectTraefikStaticConfig(server);
|
||||
if (!staticConfig) {
|
||||
throw new Error(
|
||||
"Traefik static config file was not found. Configure a file provider first so Portainer can write dynamic domain routes.",
|
||||
);
|
||||
}
|
||||
|
||||
if (staticConfig.format === "yaml") {
|
||||
const parsed =
|
||||
(yaml.load(staticConfig.content) as Record<string, unknown> | null) ?? {};
|
||||
const providers =
|
||||
typeof parsed.providers === "object" && parsed.providers !== null
|
||||
? (parsed.providers as Record<string, unknown>)
|
||||
: {};
|
||||
const fileProvider =
|
||||
typeof providers.file === "object" && providers.file !== null
|
||||
? (providers.file as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
`${fileProvider.directory ?? ""}`.trim() !== dynamicDirectory ||
|
||||
fileProvider.watch !== true
|
||||
) {
|
||||
parsed.providers = {
|
||||
...providers,
|
||||
file: {
|
||||
...fileProvider,
|
||||
directory: dynamicDirectory,
|
||||
watch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
staticConfig.path,
|
||||
yaml.dump(parsed, { lineWidth: 120 }),
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
if (!/\[providers\.file\]/.test(staticConfig.content)) {
|
||||
const nextContent = `${staticConfig.content.trimEnd()}\n\n[providers.file]\n directory = "${dynamicDirectory}"\n watch = true\n`;
|
||||
await writePrivilegedFile(server, staticConfig.path, nextContent);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
function buildTraefikDynamicConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): string {
|
||||
const key = sanitizeDomainFileName(domainName).replace(/\./g, "-");
|
||||
const parsed = new URL(upstream);
|
||||
|
||||
return yaml.dump(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`portainer-${key}`]: {
|
||||
rule: `Host(\`${domainName}\`)`,
|
||||
service: `portainer-${key}`,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
[`portainer-${key}`]: {
|
||||
loadBalancer: {
|
||||
servers: [{ url: `${parsed.protocol}//${parsed.host}` }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadTraefik(server: Server): Promise<void> {
|
||||
const script = [
|
||||
"if command -v systemctl >/dev/null 2>&1 && systemctl status traefik >/dev/null 2>&1; then",
|
||||
" systemctl restart traefik",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if command -v docker >/dev/null 2>&1; then",
|
||||
' TRAEFIK_ID=$(docker ps --format "{{.ID}}|{{.Names}}|{{.Image}}" | awk -F"|" \'BEGIN{IGNORECASE=1} /traefik/ {print $1; exit}\')',
|
||||
' if [ -n "$TRAEFIK_ID" ]; then',
|
||||
' docker restart "$TRAEFIK_ID" >/dev/null',
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("; ");
|
||||
|
||||
await execPrivileged(server, script);
|
||||
}
|
||||
|
||||
async function provisionTraefikConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const dynamicDirectory = await ensureTraefikFileProvider(server);
|
||||
const configPath = `${dynamicDirectory}/${sanitizeDomainFileName(domainName)}.yml`;
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildTraefikDynamicConfig(domainName, upstream),
|
||||
);
|
||||
await reloadTraefik(server);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "traefik",
|
||||
};
|
||||
}
|
||||
|
||||
export async function provisionDomainProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
proxy: ProxyType;
|
||||
sslEnabled: boolean;
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">;
|
||||
targetPort: number;
|
||||
}): Promise<DomainProvisioningResult> {
|
||||
const upstreamTarget = await resolveContainerUpstream(
|
||||
options.server,
|
||||
options.container,
|
||||
options.targetPort,
|
||||
);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
return provisionNginxConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.container.name,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
return provisionCaddyConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "TRAEFIK") {
|
||||
return provisionTraefikConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Proxy ${options.proxy} does not support automatic provisioning.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeManagedDomainProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
proxy: ProxyType;
|
||||
containerName?: string | null;
|
||||
}): Promise<void> {
|
||||
if (options.proxy === "NONE") {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
const managedSafeName = options.containerName
|
||||
? buildManagedNginxFileBase(options.containerName, options.domainName)
|
||||
: domainSafeName;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${managedSafeName}.conf`;
|
||||
const availablePath = `/etc/nginx/sites-available/${managedSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
const previousAvailablePath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
|
||||
await removePrivilegedFile(options.server, enabledPath);
|
||||
await removePrivilegedFile(options.server, availablePath);
|
||||
if (managedSafeName !== domainSafeName) {
|
||||
await removePrivilegedFile(options.server, previousEnabledPath);
|
||||
await removePrivilegedFile(options.server, previousAvailablePath);
|
||||
}
|
||||
await removePrivilegedFile(options.server, legacyConfigPath);
|
||||
await validateAndReloadNginx(options.server, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
const mainConfigPath = (await pathExistsPrivileged(
|
||||
options.server,
|
||||
"/etc/caddy/Caddyfile",
|
||||
))
|
||||
? "/etc/caddy/Caddyfile"
|
||||
: "/usr/local/etc/caddy/Caddyfile";
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/caddy/portainer-domains/${domainSafeName}.caddy`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/traefik/dynamic/portainer/${domainSafeName}.yml`,
|
||||
);
|
||||
await reloadTraefik(options.server);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
// Backward-compatibility facade.
|
||||
//
|
||||
// Business logic domain provisioning sudah dipindahkan ke folder
|
||||
// `./domain-provisioning/*`. File asli sebelum modularisasi disimpan di
|
||||
// `./backup/domain-provisioning.service.ts`. Semua export publik tetap
|
||||
// dipertahankan lewat re-export di bawah ini.
|
||||
|
||||
export * from "./domain-provisioning";
|
||||
@@ -0,0 +1,23 @@
|
||||
# Domain Provisioning Modules
|
||||
|
||||
Folder ini berisi modularisasi business logic dari `domain-provisioning.service.ts`.
|
||||
|
||||
Struktur:
|
||||
|
||||
- `types.ts`: kontrak publik dan type internal utama.
|
||||
- `shell.ts`: helper shell command dan privileged execution.
|
||||
- `filesystem.ts`: helper baca/tulis/hapus file remote.
|
||||
- `names.ts`: helper nama file dan identifier config.
|
||||
- `container-upstream.ts`: resolusi port container dan upstream target.
|
||||
- `nginx.ts`: provisioning dan cleanup config Nginx.
|
||||
- `caddy.ts`: provisioning dan cleanup config Caddy.
|
||||
- `traefik.ts`: provisioning dan cleanup config Traefik.
|
||||
- `service.ts`: orchestration API publik untuk provisioning domain.
|
||||
- `index.ts`: public barrel.
|
||||
|
||||
Pattern:
|
||||
|
||||
- Helper umum dipisah dari provider-specific logic.
|
||||
- Setiap provider proxy punya file sendiri.
|
||||
- `service.ts` hanya mengorkestrasi pemilihan provider.
|
||||
- `../domain-provisioning.service.ts` dipertahankan sebagai compatibility facade.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import {
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
pathExistsPrivileged,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import { sanitizeDomainFileName } from "./names";
|
||||
import { escapeDoubleQuotedShellArg, execPrivileged } from "./shell";
|
||||
import { DomainProvisioningResult } from "./types";
|
||||
|
||||
function buildCaddyConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): string {
|
||||
const siteLabel = sslEnabled ? domainName : `http://${domainName}`;
|
||||
return [
|
||||
`${siteLabel} {`,
|
||||
` reverse_proxy ${upstream.replace(/^https?:\/\//, "")}`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function provisionCaddyConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const mainConfigCandidates = [
|
||||
"/etc/caddy/Caddyfile",
|
||||
"/usr/local/etc/caddy/Caddyfile",
|
||||
];
|
||||
|
||||
let mainConfigPath: string | null = null;
|
||||
let mainConfigContent: string | null = null;
|
||||
for (const candidate of mainConfigCandidates) {
|
||||
const content = await readPrivilegedFile(server, candidate);
|
||||
if (content !== null) {
|
||||
mainConfigPath = candidate;
|
||||
mainConfigContent = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainConfigPath || mainConfigContent === null) {
|
||||
throw new Error(
|
||||
`Caddyfile was not found on ${server.name}. Expected one of: ${mainConfigCandidates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importLine = "import /etc/caddy/portainer-domains/*.caddy";
|
||||
if (!mainConfigContent.includes(importLine)) {
|
||||
const nextContent = `${mainConfigContent.trimEnd()}\n\n${importLine}\n`;
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
|
||||
const configPath = `/etc/caddy/portainer-domains/${sanitizeDomainFileName(domainName)}.caddy`;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildCaddyConfig(domainName, upstream, sslEnabled),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "caddy",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedCaddyProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
}): Promise<void> {
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
const mainConfigPath = (await pathExistsPrivileged(
|
||||
options.server,
|
||||
"/etc/caddy/Caddyfile",
|
||||
))
|
||||
? "/etc/caddy/Caddyfile"
|
||||
: "/usr/local/etc/caddy/Caddyfile";
|
||||
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/caddy/portainer-domains/${domainSafeName}.caddy`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Container, Server } from "@prisma/client";
|
||||
import { DockerContainerInspect, dockerInspect } from "../ssh.service";
|
||||
import { ContainerPortBinding, ContainerUpstreamTarget } from "./types";
|
||||
|
||||
function getContainerNetworkIp(inspect: DockerContainerInspect): string | null {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const networks = networkSettings?.Networks ?? {};
|
||||
for (const network of Object.values(networks)) {
|
||||
const ipAddress = `${network?.IPAddress ?? ""}`.trim();
|
||||
if (ipAddress) {
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContainerPortBindings(
|
||||
inspect: DockerContainerInspect,
|
||||
): ContainerPortBinding[] {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Ports?: Record<string, Array<{ HostPort?: string }> | null>;
|
||||
}
|
||||
| undefined;
|
||||
const config = inspect.Config as
|
||||
| {
|
||||
ExposedPorts?: Record<string, Record<string, never>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const bindings = new Map<string, ContainerPortBinding>();
|
||||
|
||||
for (const [rawPort, hosts] of Object.entries(networkSettings?.Ports ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hosts || hosts.length === 0) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const hostPort = Number(host?.HostPort ?? "");
|
||||
bindings.set(`${rawPort}:${host?.HostPort ?? "internal"}`, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: Number.isFinite(hostPort) ? hostPort : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawPort of Object.keys(config?.ExposedPorts ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = Array.from(bindings.values()).some(
|
||||
(binding) =>
|
||||
binding.containerPort === containerPort &&
|
||||
binding.protocol === protocol,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bindings.values()).sort(
|
||||
(left, right) => left.containerPort - right.containerPort,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeContainerTargetPorts(
|
||||
inspect: DockerContainerInspect,
|
||||
): Array<{
|
||||
containerPort: number;
|
||||
hostPort: number | null;
|
||||
protocol: string;
|
||||
label: string;
|
||||
}> {
|
||||
return getContainerPortBindings(inspect).map((binding) => ({
|
||||
...binding,
|
||||
label: binding.hostPort
|
||||
? `${binding.containerPort}/${binding.protocol} (published as ${binding.hostPort})`
|
||||
: `${binding.containerPort}/${binding.protocol} (container internal)`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveContainerUpstream(
|
||||
server: Server,
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">,
|
||||
targetPort: number,
|
||||
): Promise<ContainerUpstreamTarget> {
|
||||
const inspect = await dockerInspect(
|
||||
server,
|
||||
container.dockerId || container.name,
|
||||
);
|
||||
const bindings = getContainerPortBindings(inspect);
|
||||
const matching = bindings.find(
|
||||
(binding) => binding.containerPort === targetPort,
|
||||
);
|
||||
|
||||
if (!matching) {
|
||||
throw new Error(
|
||||
`Container ${container.name} does not expose port ${targetPort}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matching.hostPort) {
|
||||
return {
|
||||
upstream: `http://127.0.0.1:${matching.hostPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
const containerIp = getContainerNetworkIp(inspect);
|
||||
if (!containerIp) {
|
||||
throw new Error(
|
||||
`Container ${container.name} has no reachable IP address for internal port ${targetPort}. Publish the port first or connect it to a reachable Docker network.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
upstream: `http://${containerIp}:${targetPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "../ssh.service";
|
||||
import {
|
||||
escapeDoubleQuotedShellArg,
|
||||
execPrivileged,
|
||||
execPrivilegedLoose,
|
||||
shellCommand,
|
||||
} from "./shell";
|
||||
|
||||
export async function pathExists(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await exec(
|
||||
server,
|
||||
shellCommand(
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
),
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
export async function pathExistsPrivileged(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await execPrivilegedLoose(
|
||||
server,
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
export async function readPrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (!(await pathExistsPrivileged(server, filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return execPrivileged(server, `cat ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
export async function writePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const encoded = Buffer.from(content, "utf8").toString("base64");
|
||||
const parentDir = filePath.replace(/\/[^/]+$/, "") || "/";
|
||||
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
`mkdir -p ${escapeDoubleQuotedShellArg(parentDir)}`,
|
||||
`printf %s ${escapeDoubleQuotedShellArg(encoded)} | base64 -d > ${escapeDoubleQuotedShellArg(filePath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await execPrivileged(server, `rm -f ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,57 @@
|
||||
export function sanitizeDomainFileName(domainName: string): string {
|
||||
return domainName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
export function sanitizeContainerFileName(containerName: string): string {
|
||||
return containerName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
export function buildManagedNginxFileBase(
|
||||
containerName: string,
|
||||
domainName: string,
|
||||
): string {
|
||||
void containerName;
|
||||
return sanitizeDomainFileName(domainName);
|
||||
}
|
||||
|
||||
function normalizeAnchorDomain(domainName: string): string {
|
||||
return domainName.trim().toLowerCase().replace(/^\*\./, "");
|
||||
}
|
||||
|
||||
export function getDomainConfigAnchor(domainNames: string[]): string {
|
||||
const normalized = Array.from(
|
||||
new Set(domainNames.map(normalizeAnchorDomain).filter(Boolean)),
|
||||
);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return "shared-domain";
|
||||
}
|
||||
|
||||
normalized.sort((left, right) => {
|
||||
const leftLabels = left.split(".").length;
|
||||
const rightLabels = right.split(".").length;
|
||||
|
||||
if (leftLabels !== rightLabels) {
|
||||
return leftLabels - rightLabels;
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
const candidate = normalized[0] ?? "shared-domain";
|
||||
const labels = candidate.split(".").filter(Boolean);
|
||||
|
||||
if (labels.length >= 2) {
|
||||
return labels.slice(-2).join(".");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function buildManagedNginxSharedFileBase(
|
||||
containerName: string,
|
||||
domainNames: string[],
|
||||
): string {
|
||||
void containerName;
|
||||
return sanitizeDomainFileName(getDomainConfigAnchor(domainNames));
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { resolveSslCertificate, restartManagedService } from "../ssh.service";
|
||||
import {
|
||||
pathExistsPrivileged,
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import {
|
||||
buildManagedNginxFileBase,
|
||||
buildManagedNginxSharedFileBase,
|
||||
getDomainConfigAnchor,
|
||||
sanitizeDomainFileName,
|
||||
sanitizeContainerFileName,
|
||||
} from "./names";
|
||||
import {
|
||||
escapeDoubleQuotedShellArg,
|
||||
execPrivileged,
|
||||
execPrivilegedLoose,
|
||||
} from "./shell";
|
||||
import { DomainProvisioningError, DomainProvisioningResult } from "./types";
|
||||
|
||||
function findHttpBlockEnd(configContent: string): number | null {
|
||||
const httpMatch = /\bhttp\s*\{/.exec(configContent);
|
||||
if (!httpMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const braceStart = configContent.indexOf("{", httpMatch.index);
|
||||
if (braceStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < configContent.length; index += 1) {
|
||||
const char = configContent[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureNginxSitesEnabledInclude(configContent: string): string {
|
||||
if (
|
||||
/include\s+\/etc\/nginx\/sites-enabled\/\*\.conf\s*;/.test(configContent)
|
||||
) {
|
||||
return configContent;
|
||||
}
|
||||
|
||||
const httpBlockEnd = findHttpBlockEnd(configContent);
|
||||
if (httpBlockEnd === null) {
|
||||
throw new DomainProvisioningError(
|
||||
"Could not find the http block in nginx.conf to register /etc/nginx/sites-enabled/*.conf.",
|
||||
);
|
||||
}
|
||||
|
||||
const includeLine = "\n include /etc/nginx/sites-enabled/*.conf;\n";
|
||||
return `${configContent.slice(0, httpBlockEnd)}${includeLine}${configContent.slice(httpBlockEnd)}`;
|
||||
}
|
||||
|
||||
function normalizeNginxFailureMessage(message: string): string {
|
||||
if (
|
||||
/socket\(\) \[::\]:80 failed \(97: Address family not supported by protocol\)/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
"Nginx validation failed on the target server because an IPv6 listener like `listen [::]:80` is enabled, but the host does not support IPv6.",
|
||||
"Adjust the existing Nginx configuration on that server to remove or conditionalize the IPv6 listener, then retry Add Domain.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function buildNginxProxyLocation(upstream: string): string[] {
|
||||
return [
|
||||
" location / {",
|
||||
` proxy_pass ${upstream};`,
|
||||
" proxy_http_version 1.1;",
|
||||
" proxy_set_header Host $host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
" }",
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeDomainSet(domainNames: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
domainNames.map((name) => name.trim().toLowerCase()).filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildServerNameValue(domainNames: string[]): string {
|
||||
return normalizeDomainSet(domainNames).join(" ");
|
||||
}
|
||||
|
||||
type NginxServerEntry = {
|
||||
domainName: string;
|
||||
upstream: string;
|
||||
};
|
||||
|
||||
function normalizeNginxServerEntries(
|
||||
entries: NginxServerEntry[] | undefined,
|
||||
fallbackDomainNames: string[],
|
||||
fallbackUpstream: string,
|
||||
): NginxServerEntry[] {
|
||||
const normalized = new Map<string, NginxServerEntry>();
|
||||
|
||||
for (const entry of entries ?? []) {
|
||||
const domainName = entry.domainName.trim().toLowerCase();
|
||||
const upstream = entry.upstream.trim();
|
||||
if (domainName && upstream) {
|
||||
normalized.set(domainName, { domainName, upstream });
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.size === 0) {
|
||||
for (const domainName of fallbackDomainNames) {
|
||||
normalized.set(domainName, { domainName, upstream: fallbackUpstream });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalized.values()).sort((left, right) =>
|
||||
left.domainName.localeCompare(right.domainName),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSharedSslCertificateName(options: {
|
||||
server: Server;
|
||||
domainNames: string[];
|
||||
primaryDomainName: string;
|
||||
sslEnabled: boolean;
|
||||
}): Promise<string | null> {
|
||||
if (!options.sslEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exactDomains = normalizeDomainSet(options.domainNames).filter(
|
||||
(domainName) => !domainName.startsWith("*."),
|
||||
);
|
||||
if (exactDomains.length !== options.domainNames.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certificate = await resolveSslCertificate(
|
||||
options.server,
|
||||
options.primaryDomainName,
|
||||
).catch(() => null);
|
||||
if (!certificate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certificateDomains = new Set(
|
||||
certificate.domainNames.map((domainName) => domainName.toLowerCase()),
|
||||
);
|
||||
|
||||
return exactDomains.every((domainName) => certificateDomains.has(domainName))
|
||||
? certificate.certName
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildNginxConfig(
|
||||
domainNames: string[],
|
||||
upstream: string,
|
||||
configMode: "SHARED" | "ISOLATED",
|
||||
primaryDomainName: string,
|
||||
sslCertName?: string | null,
|
||||
serverEntries?: NginxServerEntry[],
|
||||
): string {
|
||||
const serverNameValue = buildServerNameValue(domainNames);
|
||||
const entries = normalizeNginxServerEntries(
|
||||
serverEntries,
|
||||
domainNames,
|
||||
upstream,
|
||||
);
|
||||
|
||||
if (!sslCertName) {
|
||||
const blocks =
|
||||
configMode === "SHARED"
|
||||
? entries.flatMap((entry) => [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${entry.domainName};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(entry.upstream),
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
: [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${serverNameValue};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
`# doktainer-config-mode: ${configMode}`,
|
||||
`# doktainer-primary-domain: ${primaryDomainName}`,
|
||||
...blocks,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const certPath = `/etc/letsencrypt/live/${sslCertName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${sslCertName}/privkey.pem`;
|
||||
|
||||
const httpsBlocks =
|
||||
configMode === "SHARED"
|
||||
? entries.flatMap((entry) => [
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${entry.domainName};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(entry.upstream),
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
: [
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${serverNameValue};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
`# doktainer-config-mode: ${configMode}`,
|
||||
`# doktainer-primary-domain: ${primaryDomainName}`,
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${serverNameValue};`,
|
||||
" return 301 https://$host$request_uri;",
|
||||
"}",
|
||||
"",
|
||||
...httpsBlocks,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function detectNginxMainConfigPath(server: Server): Promise<string> {
|
||||
const candidates = new Set([
|
||||
"/etc/nginx/nginx.conf",
|
||||
"/etc/openresty/nginx.conf",
|
||||
"/usr/local/nginx/conf/nginx.conf",
|
||||
"/usr/local/openresty/nginx/conf/nginx.conf",
|
||||
"/usr/local/etc/nginx/nginx.conf",
|
||||
"/opt/nginx/conf/nginx.conf",
|
||||
"/www/server/nginx/conf/nginx.conf",
|
||||
]);
|
||||
|
||||
const versionOutput = await execPrivilegedLoose(
|
||||
server,
|
||||
"nginx -V 2>&1 || true",
|
||||
);
|
||||
const combinedVersionOutput = `${versionOutput.stdout}\n${versionOutput.stderr}`;
|
||||
const confPathMatch = combinedVersionOutput.match(/--conf-path=([^\s]+)/);
|
||||
if (confPathMatch?.[1]) {
|
||||
candidates.add(confPathMatch[1].trim());
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExistsPrivileged(server, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryResult = await execPrivilegedLoose(
|
||||
server,
|
||||
[
|
||||
"for root in /etc /usr/local /opt /www /home /srv; do",
|
||||
' if [ -d "$root" ]; then',
|
||||
' found=$(find -L "$root" -maxdepth 6 -type f -name nginx.conf 2>/dev/null | head -n 1);',
|
||||
' if [ -n "$found" ]; then printf "%s\\n" "$found"; exit 0; fi',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const discoveredPath = `${discoveryResult.stdout || ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
if (discoveredPath && (await pathExistsPrivileged(server, discoveredPath))) {
|
||||
return discoveredPath;
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
`nginx.conf was not found on ${server.name}. Checked Nginx --conf-path and common locations: ${Array.from(candidates).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNginxSitesEnabledRegistration(
|
||||
server: Server,
|
||||
): Promise<void> {
|
||||
const mainConfigPath = await detectNginxMainConfigPath(server);
|
||||
const currentContent = await readPrivilegedFile(server, mainConfigPath);
|
||||
|
||||
if (currentContent === null) {
|
||||
throw new DomainProvisioningError(
|
||||
`Unable to read Nginx main config at ${mainConfigPath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextContent = ensureNginxSitesEnabledInclude(currentContent);
|
||||
if (nextContent !== currentContent) {
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndReloadNginx(
|
||||
server: Server,
|
||||
rollbackPaths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execPrivileged(server, "nginx -t");
|
||||
await restartManagedService(server, "nginx");
|
||||
} catch (error) {
|
||||
for (const rollbackPath of rollbackPaths) {
|
||||
await removePrivilegedFile(server, rollbackPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
normalizeNginxFailureMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionNginxConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
containerName: string,
|
||||
sslEnabled: boolean,
|
||||
options?: {
|
||||
domainNames?: string[];
|
||||
serverEntries?: NginxServerEntry[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
primaryDomainName?: string | null;
|
||||
},
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const configMode = options?.configMode ?? "ISOLATED";
|
||||
const domainNames = normalizeDomainSet(options?.domainNames ?? [domainName]);
|
||||
const primaryDomainName =
|
||||
options?.primaryDomainName?.trim().toLowerCase() ||
|
||||
domainNames[0] ||
|
||||
domainName;
|
||||
const safeName =
|
||||
configMode === "SHARED"
|
||||
? buildManagedNginxSharedFileBase(containerName, domainNames)
|
||||
: buildManagedNginxFileBase(containerName, domainName);
|
||||
const legacySharedSafeName =
|
||||
configMode === "SHARED"
|
||||
? `${sanitizeContainerFileName(containerName)}--${sanitizeDomainFileName(getDomainConfigAnchor(domainNames))}`
|
||||
: null;
|
||||
const legacyServiceSharedSafeName =
|
||||
configMode === "SHARED"
|
||||
? `doktainer-${sanitizeContainerFileName(containerName)}`
|
||||
: null;
|
||||
const configPath = `/etc/nginx/sites-available/${safeName}.conf`;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${safeName}.conf`;
|
||||
const cleanupCommands = domainNames.flatMap((managedDomainName) => {
|
||||
const managedDomainSafeName = sanitizeDomainFileName(managedDomainName);
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${managedDomainSafeName}.conf`;
|
||||
const previousConfigPath = `/etc/nginx/sites-available/${managedDomainSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${managedDomainSafeName}.conf`;
|
||||
const isolatedBase = buildManagedNginxFileBase(
|
||||
containerName,
|
||||
managedDomainName,
|
||||
);
|
||||
const isolatedAvailablePath = `/etc/nginx/sites-available/${isolatedBase}.conf`;
|
||||
const isolatedEnabledPath = `/etc/nginx/sites-enabled/${isolatedBase}.conf`;
|
||||
|
||||
const commands = [`rm -f ${escapeDoubleQuotedShellArg(legacyConfigPath)}`];
|
||||
|
||||
if (managedDomainSafeName !== safeName) {
|
||||
commands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(previousEnabledPath)} ${escapeDoubleQuotedShellArg(previousConfigPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isolatedBase !== safeName) {
|
||||
commands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(isolatedEnabledPath)} ${escapeDoubleQuotedShellArg(isolatedAvailablePath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return commands;
|
||||
});
|
||||
|
||||
if (legacySharedSafeName && legacySharedSafeName !== safeName) {
|
||||
cleanupCommands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-enabled/${legacySharedSafeName}.conf`)} ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-available/${legacySharedSafeName}.conf`)}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
legacyServiceSharedSafeName &&
|
||||
legacyServiceSharedSafeName !== safeName
|
||||
) {
|
||||
cleanupCommands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-enabled/${legacyServiceSharedSafeName}.conf`)} ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-available/${legacyServiceSharedSafeName}.conf`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ensureNginxSitesEnabledRegistration(server);
|
||||
const sslCertificateName =
|
||||
configMode === "SHARED"
|
||||
? await resolveSharedSslCertificateName({
|
||||
server,
|
||||
domainNames,
|
||||
primaryDomainName,
|
||||
sslEnabled,
|
||||
})
|
||||
: sslEnabled
|
||||
? ((await resolveSslCertificate(server, domainName).catch(() => null))
|
||||
?.certName ?? null)
|
||||
: null;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildNginxConfig(
|
||||
domainNames,
|
||||
upstream,
|
||||
configMode,
|
||||
primaryDomainName,
|
||||
sslCertificateName,
|
||||
options?.serverEntries,
|
||||
),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
"mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled",
|
||||
`ln -sfn ${escapeDoubleQuotedShellArg(configPath)} ${escapeDoubleQuotedShellArg(enabledPath)}`,
|
||||
...cleanupCommands,
|
||||
].join(" && "),
|
||||
);
|
||||
await validateAndReloadNginx(server, [enabledPath, configPath]);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "nginx",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedNginxProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
containerName?: string | null;
|
||||
domainNames?: string[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
}): Promise<void> {
|
||||
const configMode = options.configMode ?? "ISOLATED";
|
||||
const domainNames = normalizeDomainSet(
|
||||
options.domainNames ?? [options.domainName],
|
||||
);
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
const managedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? buildManagedNginxSharedFileBase(options.containerName, domainNames)
|
||||
: options.containerName
|
||||
? buildManagedNginxFileBase(options.containerName, options.domainName)
|
||||
: domainSafeName;
|
||||
const legacySharedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? `${sanitizeContainerFileName(options.containerName)}--${sanitizeDomainFileName(getDomainConfigAnchor(domainNames))}`
|
||||
: null;
|
||||
const legacyServiceSharedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? `doktainer-${sanitizeContainerFileName(options.containerName)}`
|
||||
: null;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${managedSafeName}.conf`;
|
||||
const availablePath = `/etc/nginx/sites-available/${managedSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
const previousAvailablePath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
|
||||
await removePrivilegedFile(options.server, enabledPath);
|
||||
await removePrivilegedFile(options.server, availablePath);
|
||||
if (legacySharedSafeName && legacySharedSafeName !== managedSafeName) {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-enabled/${legacySharedSafeName}.conf`,
|
||||
);
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-available/${legacySharedSafeName}.conf`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
legacyServiceSharedSafeName &&
|
||||
legacyServiceSharedSafeName !== managedSafeName
|
||||
) {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-enabled/${legacyServiceSharedSafeName}.conf`,
|
||||
);
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-available/${legacyServiceSharedSafeName}.conf`,
|
||||
);
|
||||
}
|
||||
if (managedSafeName !== domainSafeName) {
|
||||
await removePrivilegedFile(options.server, previousEnabledPath);
|
||||
await removePrivilegedFile(options.server, previousAvailablePath);
|
||||
}
|
||||
await removePrivilegedFile(options.server, legacyConfigPath);
|
||||
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
[
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/conf.d/portainer-domains ] && find /etc/nginx/conf.d/portainer-domains -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/conf.d/portainer-domains ] && find /etc/nginx/conf.d/portainer-domains -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
].join(" && "),
|
||||
);
|
||||
|
||||
await validateAndReloadNginx(options.server, []);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { provisionCaddyConfig, removeManagedCaddyProxyConfig } from "./caddy";
|
||||
import {
|
||||
describeContainerTargetPorts,
|
||||
resolveContainerUpstream,
|
||||
} from "./container-upstream";
|
||||
import { provisionNginxConfig, removeManagedNginxProxyConfig } from "./nginx";
|
||||
import {
|
||||
ProvisionDomainProxyOptions,
|
||||
RemoveManagedDomainProxyOptions,
|
||||
} from "./types";
|
||||
import {
|
||||
provisionTraefikConfig,
|
||||
removeManagedTraefikProxyConfig,
|
||||
} from "./traefik";
|
||||
|
||||
export { describeContainerTargetPorts, resolveContainerUpstream };
|
||||
|
||||
export async function provisionDomainProxyConfig(
|
||||
options: ProvisionDomainProxyOptions,
|
||||
) {
|
||||
const upstreamTarget = await resolveContainerUpstream(
|
||||
options.server,
|
||||
options.container,
|
||||
options.targetPort,
|
||||
);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
return provisionNginxConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.container.name,
|
||||
options.sslEnabled,
|
||||
{
|
||||
domainNames: options.domainNames,
|
||||
serverEntries: options.nginxServerEntries,
|
||||
configMode: options.configMode,
|
||||
primaryDomainName: options.primaryDomainName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
return provisionCaddyConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "TRAEFIK") {
|
||||
return provisionTraefikConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Proxy ${options.proxy} does not support automatic provisioning.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeManagedDomainProxyConfig(
|
||||
options: RemoveManagedDomainProxyOptions,
|
||||
): Promise<void> {
|
||||
if (options.proxy === "NONE") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
await removeManagedNginxProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
containerName: options.containerName,
|
||||
domainNames: options.domainNames,
|
||||
configMode: options.configMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
await removeManagedCaddyProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await removeManagedTraefikProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "../ssh.service";
|
||||
|
||||
export function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function escapeDoubleQuotedShellArg(value: string): string {
|
||||
return `"${value.replace(/[\\"$`]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
export function shellCommand(command: string): string {
|
||||
return `sh -c ${escapeShellArg(command)}`;
|
||||
}
|
||||
|
||||
export function privilegedShellCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
): string {
|
||||
return server.username === "root"
|
||||
? shellCommand(command)
|
||||
: `sudo ${shellCommand(command)}`;
|
||||
}
|
||||
|
||||
export async function execPrivileged(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<string> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Remote command failed");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function execPrivilegedLoose(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.code ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import yaml from "js-yaml";
|
||||
import {
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import { sanitizeDomainFileName } from "./names";
|
||||
import { execPrivileged } from "./shell";
|
||||
import { DomainProvisioningResult } from "./types";
|
||||
|
||||
interface TraefikStaticConfigLocation {
|
||||
path: string;
|
||||
format: "yaml" | "toml";
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function detectTraefikStaticConfig(
|
||||
server: Server,
|
||||
): Promise<TraefikStaticConfigLocation | null> {
|
||||
const candidates: Array<{ path: string; format: "yaml" | "toml" }> = [
|
||||
{ path: "/etc/traefik/traefik.yml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.yaml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.toml", format: "toml" },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const content = await readPrivilegedFile(server, candidate.path);
|
||||
if (content !== null) {
|
||||
return { ...candidate, content };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureTraefikFileProvider(server: Server): Promise<string> {
|
||||
const dynamicDirectory = "/etc/traefik/dynamic/portainer";
|
||||
const staticConfig = await detectTraefikStaticConfig(server);
|
||||
if (!staticConfig) {
|
||||
throw new Error(
|
||||
"Traefik static config file was not found. Configure a file provider first so Portainer can write dynamic domain routes.",
|
||||
);
|
||||
}
|
||||
|
||||
if (staticConfig.format === "yaml") {
|
||||
const parsed =
|
||||
(yaml.load(staticConfig.content) as Record<string, unknown> | null) ?? {};
|
||||
const providers =
|
||||
typeof parsed.providers === "object" && parsed.providers !== null
|
||||
? (parsed.providers as Record<string, unknown>)
|
||||
: {};
|
||||
const fileProvider =
|
||||
typeof providers.file === "object" && providers.file !== null
|
||||
? (providers.file as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
`${fileProvider.directory ?? ""}`.trim() !== dynamicDirectory ||
|
||||
fileProvider.watch !== true
|
||||
) {
|
||||
parsed.providers = {
|
||||
...providers,
|
||||
file: {
|
||||
...fileProvider,
|
||||
directory: dynamicDirectory,
|
||||
watch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
staticConfig.path,
|
||||
yaml.dump(parsed, { lineWidth: 120 }),
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
if (!/\[providers\.file\]/.test(staticConfig.content)) {
|
||||
const nextContent = `${staticConfig.content.trimEnd()}\n\n[providers.file]\n directory = "${dynamicDirectory}"\n watch = true\n`;
|
||||
await writePrivilegedFile(server, staticConfig.path, nextContent);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
function buildTraefikDynamicConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): string {
|
||||
const key = sanitizeDomainFileName(domainName).replace(/\./g, "-");
|
||||
const parsed = new URL(upstream);
|
||||
|
||||
return yaml.dump(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`portainer-${key}`]: {
|
||||
rule: `Host(\`${domainName}\`)`,
|
||||
service: `portainer-${key}`,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
[`portainer-${key}`]: {
|
||||
loadBalancer: {
|
||||
servers: [{ url: `${parsed.protocol}//${parsed.host}` }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadTraefik(server: Server): Promise<void> {
|
||||
const script = [
|
||||
"if command -v systemctl >/dev/null 2>&1 && systemctl status traefik >/dev/null 2>&1; then",
|
||||
" systemctl restart traefik",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if command -v docker >/dev/null 2>&1; then",
|
||||
' TRAEFIK_ID=$(docker ps --format "{{.ID}}|{{.Names}}|{{.Image}}" | awk -F"|" \'BEGIN{IGNORECASE=1} /traefik/ {print $1; exit}\')',
|
||||
' if [ -n "$TRAEFIK_ID" ]; then',
|
||||
' docker restart "$TRAEFIK_ID" >/dev/null',
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("; ");
|
||||
|
||||
await execPrivileged(server, script);
|
||||
}
|
||||
|
||||
export async function provisionTraefikConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const dynamicDirectory = await ensureTraefikFileProvider(server);
|
||||
const configPath = `${dynamicDirectory}/${sanitizeDomainFileName(domainName)}.yml`;
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildTraefikDynamicConfig(domainName, upstream),
|
||||
);
|
||||
await reloadTraefik(server);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "traefik",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedTraefikProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
}): Promise<void> {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/traefik/dynamic/portainer/${sanitizeDomainFileName(options.domainName)}.yml`,
|
||||
);
|
||||
await reloadTraefik(options.server);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container, ProxyType, Server } from "@prisma/client";
|
||||
|
||||
export interface DomainProvisioningResult {
|
||||
upstream: string;
|
||||
configPath: string;
|
||||
reloadTarget: string;
|
||||
}
|
||||
|
||||
export class DomainProvisioningError extends Error {}
|
||||
|
||||
export interface ContainerPortBinding {
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
hostPort: number | null;
|
||||
}
|
||||
|
||||
export interface ContainerUpstreamTarget {
|
||||
upstream: string;
|
||||
selectedPort: number;
|
||||
}
|
||||
|
||||
export interface ProvisionDomainProxyOptions {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
domainNames?: string[];
|
||||
nginxServerEntries?: Array<{
|
||||
domainName: string;
|
||||
upstream: string;
|
||||
}>;
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
primaryDomainName?: string | null;
|
||||
proxy: ProxyType;
|
||||
sslEnabled: boolean;
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">;
|
||||
targetPort: number;
|
||||
}
|
||||
|
||||
export interface RemoveManagedDomainProxyOptions {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
domainNames?: string[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
proxy: ProxyType;
|
||||
containerName?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
export interface CloudflareVerificationInput {
|
||||
zoneId: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface AwsVerificationInput {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationInput {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface GitProviderVerificationInput {
|
||||
provider: "github" | "gitlab" | "bitbucket" | "gitea";
|
||||
providerUrl?: string;
|
||||
appUrl?: string;
|
||||
installationUrl?: string;
|
||||
}
|
||||
|
||||
function toTrimmedUrl(value?: string) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function isPrivateIpv4Address(hostname: string) {
|
||||
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.length !== 4 || octets.some((part) => Number.isNaN(part))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [first, second] = octets;
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
first === 0 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpAddress(hostname: string) {
|
||||
const ipVersion = net.isIP(hostname);
|
||||
if (ipVersion === 4) {
|
||||
return isPrivateIpv4Address(hostname);
|
||||
}
|
||||
|
||||
if (ipVersion === 6) {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return (
|
||||
normalized === "::1" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe80:") ||
|
||||
normalized === "::"
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname: string) {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized.endsWith(".localhost") ||
|
||||
normalized.endsWith(".local") ||
|
||||
normalized.endsWith(".internal")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertSafeResolvedHostname(hostname: string) {
|
||||
const resolved = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
if (resolved.some((record) => isPrivateIpAddress(record.address))) {
|
||||
throw new Error(
|
||||
"Private or internal destinations are not allowed for integration verification",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSafeOutboundUrl(raw: string) {
|
||||
const trimmed = toTrimmedUrl(raw);
|
||||
if (!trimmed) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
|
||||
const url = new URL(trimmed);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS URLs are supported");
|
||||
}
|
||||
|
||||
if (process.env.ALLOW_PRIVATE_INTEGRATION_URLS === "true") {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (isLocalHostname(url.hostname) || isPrivateIpAddress(url.hostname)) {
|
||||
throw new Error(
|
||||
"Private or internal destinations are not allowed for integration verification",
|
||||
);
|
||||
}
|
||||
|
||||
await assertSafeResolvedHostname(url.hostname);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function probePublicUrl(url: string) {
|
||||
const safeUrl = await assertSafeOutboundUrl(url);
|
||||
const response = await fetch(safeUrl, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": "Doktainer",
|
||||
Accept: "text/html,application/json;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`URL check failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGitProviderConfiguration(
|
||||
input: GitProviderVerificationInput,
|
||||
) {
|
||||
const normalizedProviderUrl = toTrimmedUrl(input.providerUrl);
|
||||
const normalizedAppUrl = toTrimmedUrl(input.appUrl);
|
||||
const normalizedInstallationUrl = toTrimmedUrl(input.installationUrl);
|
||||
|
||||
const baseUrl =
|
||||
normalizedProviderUrl ||
|
||||
(input.provider === "github"
|
||||
? "https://github.com"
|
||||
: input.provider === "gitlab"
|
||||
? "https://gitlab.com"
|
||||
: input.provider === "bitbucket"
|
||||
? "https://bitbucket.org"
|
||||
: "");
|
||||
|
||||
const checks: Array<{ label: string; url: string }> = [];
|
||||
|
||||
if (baseUrl) {
|
||||
checks.push({ label: "Provider URL", url: baseUrl });
|
||||
}
|
||||
|
||||
if (normalizedAppUrl) {
|
||||
checks.push({ label: "App URL", url: normalizedAppUrl });
|
||||
}
|
||||
|
||||
if (normalizedInstallationUrl) {
|
||||
checks.push({ label: "Installation URL", url: normalizedInstallationUrl });
|
||||
}
|
||||
|
||||
if (checks.length === 0) {
|
||||
throw new Error(
|
||||
"At least one provider URL, app URL, or installation URL is required for verification",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
checks.map(async (check) => ({
|
||||
label: check.label,
|
||||
...(await probePublicUrl(check.url)),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
message: `${input.provider.toUpperCase()} configuration looks reachable`,
|
||||
detail: {
|
||||
provider: input.provider,
|
||||
checks: results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyCloudflareConnection(
|
||||
input: CloudflareVerificationInput,
|
||||
) {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(input.zoneId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
success?: boolean;
|
||||
result?: { id: string; name: string; status?: string };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.success || !payload.result) {
|
||||
throw new Error(
|
||||
payload.errors?.[0]?.message || "Cloudflare verification failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to Cloudflare zone ${payload.result.name}`,
|
||||
detail: payload.result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAwsS3Connection(input: AwsVerificationInput) {
|
||||
if (input.endpoint) {
|
||||
await assertSafeOutboundUrl(input.endpoint);
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: input.region,
|
||||
endpoint: input.endpoint || undefined,
|
||||
forcePathStyle: Boolean(input.endpoint),
|
||||
credentials: {
|
||||
accessKeyId: input.accessKeyId,
|
||||
secretAccessKey: input.secretAccessKey,
|
||||
},
|
||||
});
|
||||
|
||||
await client.send(new HeadBucketCommand({ Bucket: input.bucket }));
|
||||
|
||||
return {
|
||||
message: `Connected to S3 bucket ${input.bucket}`,
|
||||
detail: {
|
||||
bucket: input.bucket,
|
||||
region: input.region,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGithubConnection(input: TokenVerificationInput) {
|
||||
const response = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "VPS-Panel",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
login?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.login) {
|
||||
throw new Error(payload.message || "GitHub verification failed");
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to GitHub account ${payload.login}`,
|
||||
detail: {
|
||||
login: payload.login,
|
||||
name: payload.name ?? payload.login,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGitlabConnection(input: TokenVerificationInput) {
|
||||
const response = await fetch("https://gitlab.com/api/v4/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
username?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.username) {
|
||||
throw new Error(
|
||||
payload.message || payload.error || "GitLab verification failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to GitLab account ${payload.username}`,
|
||||
detail: {
|
||||
username: payload.username,
|
||||
name: payload.name ?? payload.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import prisma from "../lib/prisma";
|
||||
import { decrypt } from "../lib/crypto";
|
||||
import { assertSafeOutboundUrl } from "./integration-verification.service";
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
type RuntimeNotificationProvider = {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
actions: string[];
|
||||
channel: string | null;
|
||||
webhookUrlEnc: string | null;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string | null;
|
||||
smtpPasswordEnc: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
smtpFromName: string | null;
|
||||
telegramChatId: string | null;
|
||||
telegramBotTokenEnc: string | null;
|
||||
serverUrl: string | null;
|
||||
topic: string | null;
|
||||
userKey: string | null;
|
||||
apiKeyEnc: string | null;
|
||||
};
|
||||
|
||||
export interface RuntimeNotificationEvent {
|
||||
organizationId: string;
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
panelName?: string;
|
||||
serverId?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_PANEL_NAME = process.env.PANEL_NAME || "DOKTAINER";
|
||||
const RUNTIME_ACTION_LABELS: Record<string, string> = {
|
||||
server_down: "Server Down",
|
||||
high_cpu_over_80: "High CPU (>80%)",
|
||||
high_ram_over_90: "High RAM (>90%)",
|
||||
server_threshold: "Server Threshold",
|
||||
container_crash: "Container Crash",
|
||||
ssl_expiring: "SSL Expiring",
|
||||
security_breach: "Security Breach",
|
||||
app_deploy: "App Deploy",
|
||||
app_build_error: "App Build Error",
|
||||
docker_cleanup: "Docker Cleanup",
|
||||
database_backup: "Database Backup",
|
||||
doktainer_backup: "Doktainer Backup",
|
||||
volume_backup: "Volume Backup",
|
||||
};
|
||||
const RUNTIME_ACTION_ALIASES: Record<string, string> = {
|
||||
backup_database: "database_backup",
|
||||
backup_doktainer: "doktainer_backup",
|
||||
};
|
||||
const runtimeNotificationCooldowns: Record<string, number> = {
|
||||
server_down: 15 * 60 * 1000,
|
||||
high_cpu_over_80: 30 * 60 * 1000,
|
||||
high_ram_over_90: 30 * 60 * 1000,
|
||||
server_threshold: 30 * 60 * 1000,
|
||||
container_crash: 15 * 60 * 1000,
|
||||
ssl_expiring: 24 * 60 * 60 * 1000,
|
||||
security_breach: 10 * 60 * 1000,
|
||||
app_deploy: 5 * 60 * 1000,
|
||||
app_build_error: 5 * 60 * 1000,
|
||||
docker_cleanup: 5 * 60 * 1000,
|
||||
database_backup: 5 * 60 * 1000,
|
||||
volume_backup: 5 * 60 * 1000,
|
||||
doktainer_backup: 5 * 60 * 1000,
|
||||
};
|
||||
const runtimeNotificationRecentDeliveries = new Map<string, number>();
|
||||
|
||||
function normalizeRuntimeAction(action: string) {
|
||||
const normalizedAction = action
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
return RUNTIME_ACTION_ALIASES[normalizedAction] ?? normalizedAction;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
|
||||
function getRuntimeActionLabel(action: string) {
|
||||
const normalizedAction = normalizeRuntimeAction(action);
|
||||
return (
|
||||
RUNTIME_ACTION_LABELS[normalizedAction] ||
|
||||
normalizedAction
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function formatNotificationTimestamp() {
|
||||
return new Date().toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function toNotificationStatus(
|
||||
action: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
const rawStatus = metadata?.status;
|
||||
|
||||
if (typeof rawStatus === "string" && rawStatus.trim()) {
|
||||
return rawStatus.trim().toUpperCase();
|
||||
}
|
||||
|
||||
if (action === "app_build_error" || action === "security_breach") {
|
||||
return "ALERT";
|
||||
}
|
||||
|
||||
if (action === "server_down") {
|
||||
return "OFFLINE";
|
||||
}
|
||||
|
||||
return "INFO";
|
||||
}
|
||||
|
||||
function getNotificationIcon(status: string) {
|
||||
if (["FAILED", "ERROR", "ALERT", "OFFLINE"].includes(status)) {
|
||||
return "🚨";
|
||||
}
|
||||
|
||||
if (["RUNNING", "STARTED", "PROCESSING", "PENDING"].includes(status)) {
|
||||
return "⏳";
|
||||
}
|
||||
|
||||
if (["SUCCESS", "COMPLETED", "ONLINE"].includes(status)) {
|
||||
return "✅";
|
||||
}
|
||||
|
||||
return "🔔";
|
||||
}
|
||||
|
||||
function getDiscordColor(status: string) {
|
||||
if (["FAILED", "ERROR", "ALERT", "OFFLINE"].includes(status)) {
|
||||
return 0xdc2626;
|
||||
}
|
||||
|
||||
if (["RUNNING", "STARTED", "PROCESSING", "PENDING"].includes(status)) {
|
||||
return 0xf59e0b;
|
||||
}
|
||||
|
||||
if (["SUCCESS", "COMPLETED", "ONLINE"].includes(status)) {
|
||||
return 0x16a34a;
|
||||
}
|
||||
|
||||
return 0x2563eb;
|
||||
}
|
||||
|
||||
function formatMetadataValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : null;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map((entry) => formatMetadataValue(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return items.length ? items.join(", ") : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type NotificationFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
function buildNotificationFacts(input: {
|
||||
panelName: string;
|
||||
action: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
status: string;
|
||||
}) {
|
||||
const facts: NotificationFact[] = [
|
||||
{ label: "Panel", value: input.panelName, inline: true },
|
||||
{
|
||||
label: "Event",
|
||||
value: getRuntimeActionLabel(input.action),
|
||||
inline: true,
|
||||
},
|
||||
{ label: "Status", value: input.status, inline: true },
|
||||
];
|
||||
const metadata = input.metadata ?? {};
|
||||
const pushFact = (label: string, value: unknown, inline = true) => {
|
||||
const formattedValue = formatMetadataValue(value);
|
||||
if (formattedValue) {
|
||||
facts.push({ label, value: formattedValue, inline });
|
||||
}
|
||||
};
|
||||
|
||||
pushFact("Server", metadata.serverName);
|
||||
pushFact("IP", metadata.serverIp);
|
||||
pushFact("Backup", metadata.backupName);
|
||||
pushFact("Type", metadata.backupType);
|
||||
pushFact("Target", metadata.target);
|
||||
|
||||
if (typeof metadata.sizeMb === "number") {
|
||||
pushFact("Size", `${metadata.sizeMb.toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
pushFact("Container", metadata.dbContainer);
|
||||
pushFact("Path", metadata.filePath, false);
|
||||
pushFact("Error", metadata.error, false);
|
||||
|
||||
if (Array.isArray(metadata.breaches)) {
|
||||
const breachSummary = metadata.breaches
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metric = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).metric,
|
||||
);
|
||||
const value = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).value,
|
||||
);
|
||||
const threshold = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).threshold,
|
||||
);
|
||||
|
||||
if (!metric || !value || !threshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${metric.toUpperCase()} ${value}% / ${threshold}%`;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join("; ");
|
||||
|
||||
if (breachSummary) {
|
||||
facts.push({ label: "Threshold", value: breachSummary, inline: false });
|
||||
}
|
||||
}
|
||||
|
||||
return facts.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildFormattedNotification(input: {
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
panelName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const action = normalizeRuntimeAction(input.action);
|
||||
const status = toNotificationStatus(action, input.metadata);
|
||||
const timestamp = formatNotificationTimestamp();
|
||||
const facts = buildNotificationFacts({
|
||||
panelName: input.panelName,
|
||||
action,
|
||||
metadata: input.metadata,
|
||||
status,
|
||||
});
|
||||
const icon = getNotificationIcon(status);
|
||||
const text = [
|
||||
`${icon} ${input.title}`,
|
||||
"",
|
||||
input.message,
|
||||
facts.length
|
||||
? `Detail:\n${facts.map((fact) => `- ${fact.label}: ${fact.value}`).join("\n")}`
|
||||
: "",
|
||||
`${input.panelName} • ${timestamp}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const html = [
|
||||
`<p><strong>${escapeHtml(`${icon} ${input.title}`)}</strong></p>`,
|
||||
`<p>${escapeHtml(input.message)}</p>`,
|
||||
facts.length
|
||||
? `<ul>${facts
|
||||
.map(
|
||||
(fact) =>
|
||||
`<li><strong>${escapeHtml(fact.label)}:</strong> ${escapeHtml(fact.value)}</li>`,
|
||||
)
|
||||
.join("")}</ul>`
|
||||
: "",
|
||||
`<p><em>${escapeHtml(`${input.panelName} • ${timestamp}`)}</em></p>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const telegramHtml = [
|
||||
`<b>${escapeHtml(`${icon} ${input.title}`)}</b>`,
|
||||
escapeHtml(input.message),
|
||||
facts.length
|
||||
? `<b>Detail</b>\n${facts
|
||||
.map(
|
||||
(fact) =>
|
||||
`• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.value)}`,
|
||||
)
|
||||
.join("\n")}`
|
||||
: "",
|
||||
`<i>${escapeHtml(`${input.panelName} • ${timestamp}`)}</i>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const discordEmbeds = [
|
||||
{
|
||||
title: `${icon} ${input.title}`,
|
||||
description: input.message,
|
||||
color: getDiscordColor(status),
|
||||
fields: facts.map((fact) => ({
|
||||
name: fact.label,
|
||||
value: fact.value,
|
||||
inline: false,
|
||||
})),
|
||||
footer: {
|
||||
text: `${input.panelName} • ${getRuntimeActionLabel(action)}`,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
status,
|
||||
subject: `[${input.panelName}] ${input.title}`,
|
||||
text,
|
||||
html,
|
||||
telegramHtml,
|
||||
discordEmbeds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeNotificationKey(event: RuntimeNotificationEvent) {
|
||||
const statusKey =
|
||||
typeof event.metadata?.status === "string" && event.metadata.status.trim()
|
||||
? event.metadata.status.trim().toUpperCase()
|
||||
: "INFO";
|
||||
|
||||
return [
|
||||
event.organizationId,
|
||||
normalizeRuntimeAction(event.action),
|
||||
statusKey,
|
||||
event.resourceType ?? "global",
|
||||
event.resourceId ?? event.serverId ?? event.title,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function shouldSendRuntimeNotification(event: RuntimeNotificationEvent) {
|
||||
const action = normalizeRuntimeAction(event.action);
|
||||
const cooldownMs = runtimeNotificationCooldowns[action] ?? 10 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const key = buildRuntimeNotificationKey(event);
|
||||
const lastSentAt = runtimeNotificationRecentDeliveries.get(key);
|
||||
|
||||
if (lastSentAt && now - lastSentAt < cooldownMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeNotificationRecentDeliveries.set(key, now);
|
||||
|
||||
for (const [entryKey, sentAt] of runtimeNotificationRecentDeliveries) {
|
||||
if (now - sentAt > 24 * 60 * 60 * 1000) {
|
||||
runtimeNotificationRecentDeliveries.delete(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendEmailNotification(input: {
|
||||
smtp: SmtpConfig;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: input.smtp.host,
|
||||
port: input.smtp.port,
|
||||
secure: input.smtp.secure,
|
||||
auth: input.smtp.username
|
||||
? {
|
||||
user: input.smtp.username,
|
||||
pass: input.smtp.password,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transport.verify();
|
||||
await transport.sendMail({
|
||||
from: input.smtp.fromName
|
||||
? `${input.smtp.fromName} <${input.smtp.fromEmail}>`
|
||||
: input.smtp.fromEmail,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWebhookNotification(input: {
|
||||
providerType: string;
|
||||
webhookUrl: string;
|
||||
title: string;
|
||||
message: string;
|
||||
text: string;
|
||||
panelName: string;
|
||||
action: string;
|
||||
discordEmbeds?: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const providerType = input.providerType.toUpperCase();
|
||||
let body: unknown;
|
||||
|
||||
if (providerType === "DISCORD") {
|
||||
body = {
|
||||
content: `**${input.title}**`,
|
||||
embeds: input.discordEmbeds ?? [],
|
||||
};
|
||||
} else if (providerType === "SLACK" || providerType === "MATTERMOST") {
|
||||
body = {
|
||||
text: input.text,
|
||||
};
|
||||
} else if (providerType === "LARK") {
|
||||
body = {
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: input.text,
|
||||
},
|
||||
};
|
||||
} else if (providerType === "TEAMS") {
|
||||
body = {
|
||||
title: input.title,
|
||||
text: input.text.replace(/\n/g, "<br/>"),
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
panelName: input.panelName,
|
||||
action: input.action,
|
||||
metadata: input.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(input.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
text || `Webhook notification failed for ${input.providerType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendGotifyNotification(input: {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}) {
|
||||
const baseUrl = input.serverUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(`${baseUrl}/message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Gotify-Key": input.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
priority: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Gotify notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNtfyNotification(input: {
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
apiKey?: string;
|
||||
}) {
|
||||
const baseUrl = input.serverUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(
|
||||
`${baseUrl}/${encodeURIComponent(input.topic)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(input.apiKey ? { Authorization: `Bearer ${input.apiKey}` } : {}),
|
||||
Title: input.title,
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
body: input.message,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "ntfy notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushoverNotification(input: {
|
||||
userKey: string;
|
||||
apiKey: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}) {
|
||||
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
user: input.userKey,
|
||||
token: input.apiKey,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Pushover notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResendNotification(input: {
|
||||
apiKey: string;
|
||||
fromEmail: string;
|
||||
fromName?: string | null;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: input.fromName
|
||||
? `${input.fromName} <${input.fromEmail}>`
|
||||
: input.fromEmail,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Resend notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverRuntimeNotification(input: {
|
||||
provider: RuntimeNotificationProvider;
|
||||
recipientEmail?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
telegramHtml: string;
|
||||
panelName: string;
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
discordEmbeds: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const { provider } = input;
|
||||
const providerType = provider.type.toUpperCase();
|
||||
|
||||
if (providerType === "EMAIL") {
|
||||
if (
|
||||
!provider.smtpHost ||
|
||||
!provider.smtpFromEmail ||
|
||||
!input.recipientEmail
|
||||
) {
|
||||
throw new Error(
|
||||
`Email provider \"${provider.name}\" is missing SMTP or recipient data`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendEmailNotification({
|
||||
smtp: {
|
||||
host: provider.smtpHost,
|
||||
port: provider.smtpPort,
|
||||
secure: provider.smtpSecure,
|
||||
username: provider.smtpUsername ?? undefined,
|
||||
password: provider.smtpPasswordEnc
|
||||
? decrypt(provider.smtpPasswordEnc)
|
||||
: undefined,
|
||||
fromEmail: provider.smtpFromEmail,
|
||||
fromName: provider.smtpFromName ?? undefined,
|
||||
},
|
||||
to: input.recipientEmail,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "TELEGRAM") {
|
||||
if (!provider.telegramBotTokenEnc || !provider.telegramChatId) {
|
||||
throw new Error(`Telegram provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${decrypt(provider.telegramBotTokenEnc)}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: provider.telegramChatId,
|
||||
text: input.telegramHtml,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.description || "Telegram notification failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
["DISCORD", "SLACK", "LARK", "TEAMS", "MATTERMOST", "CUSTOM"].includes(
|
||||
providerType,
|
||||
)
|
||||
) {
|
||||
if (!provider.webhookUrlEnc) {
|
||||
throw new Error(
|
||||
`Webhook provider \"${provider.name}\" is missing webhook URL`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendWebhookNotification({
|
||||
providerType,
|
||||
webhookUrl: decrypt(provider.webhookUrlEnc),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
text: input.text,
|
||||
panelName: input.panelName,
|
||||
action: input.action,
|
||||
discordEmbeds: input.discordEmbeds,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "GOTIFY") {
|
||||
if (!provider.serverUrl || !provider.apiKeyEnc) {
|
||||
throw new Error(`Gotify provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendGotifyNotification({
|
||||
serverUrl: provider.serverUrl,
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "NTFY") {
|
||||
if (!provider.serverUrl || !provider.topic) {
|
||||
throw new Error(`ntfy provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendNtfyNotification({
|
||||
serverUrl: provider.serverUrl,
|
||||
topic: provider.topic,
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
apiKey: provider.apiKeyEnc ? decrypt(provider.apiKeyEnc) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "PUSHOVER") {
|
||||
if (!provider.userKey || !provider.apiKeyEnc) {
|
||||
throw new Error(`Pushover provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendPushoverNotification({
|
||||
userKey: provider.userKey,
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "RESEND") {
|
||||
if (
|
||||
!provider.apiKeyEnc ||
|
||||
!provider.smtpFromEmail ||
|
||||
!input.recipientEmail
|
||||
) {
|
||||
throw new Error(`Resend provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendResendNotification({
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
fromEmail: provider.smtpFromEmail,
|
||||
fromName: provider.smtpFromName,
|
||||
to: input.recipientEmail,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider type \"${provider.type}\" is not supported for runtime notifications`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function dispatchRuntimeNotification(
|
||||
event: RuntimeNotificationEvent,
|
||||
) {
|
||||
const action = normalizeRuntimeAction(event.action);
|
||||
if (!action || !event.organizationId) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
if (!shouldSendRuntimeNotification({ ...event, action })) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const allProviders = (await prisma.userNotificationProvider.findMany({
|
||||
where: {
|
||||
organizationId: event.organizationId,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
})) as RuntimeNotificationProvider[];
|
||||
|
||||
const providers = allProviders.filter((provider) =>
|
||||
provider.actions.some(
|
||||
(providerAction) => normalizeRuntimeAction(providerAction) === action,
|
||||
),
|
||||
);
|
||||
|
||||
if (providers.length === 0) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const userIds = [...new Set(providers.map((provider) => provider.userId))];
|
||||
const users = userIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userEmails = new Map(users.map((user) => [user.id, user.email]));
|
||||
const panelName = event.panelName || DEFAULT_PANEL_NAME;
|
||||
const formatted = buildFormattedNotification({
|
||||
action,
|
||||
title: event.title,
|
||||
message: event.message,
|
||||
panelName,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
deliverRuntimeNotification({
|
||||
provider,
|
||||
recipientEmail: userEmails.get(provider.userId),
|
||||
subject: formatted.subject,
|
||||
text: formatted.text,
|
||||
html: formatted.html,
|
||||
telegramHtml: formatted.telegramHtml,
|
||||
panelName,
|
||||
action,
|
||||
title: event.title,
|
||||
message: event.message,
|
||||
discordEmbeds: formatted.discordEmbeds,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
sent += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
failed += 1;
|
||||
console.error(
|
||||
`[Notification] Failed runtime delivery for provider ${providers[index]?.name || providers[index]?.id || "unknown"}:`,
|
||||
result.reason,
|
||||
);
|
||||
});
|
||||
|
||||
return { sent, failed, skipped: false };
|
||||
}
|
||||
|
||||
export async function sendTestEmailNotification(input: {
|
||||
smtp: SmtpConfig;
|
||||
to: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
await sendEmailNotification({
|
||||
smtp: input.smtp,
|
||||
to: input.to,
|
||||
subject: `${input.panelName} test email notification`,
|
||||
text: `This is a test notification from ${input.panelName}. Your email provider is configured correctly.`,
|
||||
html: `<p>This is a test notification from <strong>${input.panelName}</strong>.</p><p>Your email provider is configured correctly.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendTestTelegramNotification(input: {
|
||||
token: string;
|
||||
chatId: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Telegram berhasil",
|
||||
message:
|
||||
"Provider Telegram siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${input.token}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: input.chatId,
|
||||
text: preview.telegramHtml,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.description || "Telegram test notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestDiscordNotification(input: {
|
||||
webhookUrl: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const safeWebhookUrl = await assertSafeOutboundUrl(input.webhookUrl);
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Discord berhasil",
|
||||
message:
|
||||
"Webhook Discord siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(safeWebhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: "**Preview notification**",
|
||||
embeds: preview.discordEmbeds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Discord test notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestWebhookNotification(input: {
|
||||
webhookUrl: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const safeWebhookUrl = await assertSafeOutboundUrl(input.webhookUrl);
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Webhook berhasil",
|
||||
message:
|
||||
"Custom webhook siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(safeWebhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "Preview notification",
|
||||
message: preview.text,
|
||||
panelName: input.panelName,
|
||||
action: "security_breach",
|
||||
metadata: {
|
||||
status: preview.status,
|
||||
preview: true,
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Webhook test notification failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type ProcessJobStatus = "queued" | "running" | "success" | "error";
|
||||
|
||||
export interface ProcessJobLogEntry {
|
||||
id: number;
|
||||
at: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProcessJob {
|
||||
id: string;
|
||||
type: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
status: ProcessJobStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
logs: ProcessJobLogEntry[];
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
nextLogId: number;
|
||||
subscribers: Set<(entry: ProcessJobLogEntry) => void>;
|
||||
}
|
||||
|
||||
const JOB_TTL_MS = 60 * 60 * 1000;
|
||||
const MAX_LOGS_PER_JOB = 1000;
|
||||
const jobs = new Map<string, ProcessJob>();
|
||||
|
||||
function pruneJobs(now = Date.now()) {
|
||||
for (const [jobId, job] of jobs.entries()) {
|
||||
if (
|
||||
job.subscribers.size === 0 &&
|
||||
(job.status === "success" || job.status === "error") &&
|
||||
now - job.updatedAt > JOB_TTL_MS
|
||||
) {
|
||||
jobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createProcessJob(input: {
|
||||
type: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
pruneJobs();
|
||||
|
||||
const now = Date.now();
|
||||
const job: ProcessJob = {
|
||||
id: randomUUID(),
|
||||
type: input.type,
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId,
|
||||
status: "queued",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
logs: [],
|
||||
nextLogId: 1,
|
||||
subscribers: new Set(),
|
||||
};
|
||||
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getProcessJob(jobId: string) {
|
||||
pruneJobs();
|
||||
return jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
export function appendProcessJobLog(job: ProcessJob, message: string) {
|
||||
const normalized = message.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const entry: ProcessJobLogEntry = {
|
||||
id: job.nextLogId,
|
||||
at: new Date().toISOString(),
|
||||
message: line,
|
||||
};
|
||||
|
||||
job.nextLogId += 1;
|
||||
job.updatedAt = Date.now();
|
||||
job.logs.push(entry);
|
||||
|
||||
if (job.logs.length > MAX_LOGS_PER_JOB) {
|
||||
job.logs.splice(0, job.logs.length - MAX_LOGS_PER_JOB);
|
||||
}
|
||||
|
||||
for (const subscriber of job.subscribers) {
|
||||
subscriber(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateProcessJob(
|
||||
job: ProcessJob,
|
||||
patch: {
|
||||
status?: ProcessJobStatus;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
},
|
||||
) {
|
||||
job.status = patch.status ?? job.status;
|
||||
job.result = patch.result ?? job.result;
|
||||
job.error = patch.error ?? job.error;
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function subscribeProcessJob(
|
||||
job: ProcessJob,
|
||||
subscriber: (entry: ProcessJobLogEntry) => void,
|
||||
) {
|
||||
job.subscribers.add(subscriber);
|
||||
|
||||
return () => {
|
||||
job.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeProcessJob(job: ProcessJob) {
|
||||
return {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: new Date(job.updatedAt).toISOString(),
|
||||
logs: job.logs,
|
||||
result: job.result,
|
||||
error: job.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
type ServerAccessScope = {
|
||||
role: string;
|
||||
allServersAccess: boolean;
|
||||
serverIds: string[];
|
||||
};
|
||||
|
||||
export async function getServerAccessScope(
|
||||
userId?: string | null,
|
||||
): Promise<ServerAccessScope | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
role: true,
|
||||
allServersAccess: true,
|
||||
serverAssignments: { select: { serverId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
role: user.role,
|
||||
allServersAccess: user.role === "SUPER_ADMIN" || user.allServersAccess,
|
||||
serverIds: user.serverAssignments.map((assignment) => assignment.serverId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAccessibleServerFilter(userId?: string | null) {
|
||||
return getAccessibleServerFilterForOrganization(userId);
|
||||
}
|
||||
|
||||
export async function getAccessibleServerFilterForOrganization(
|
||||
userId?: string | null,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const scope = await getServerAccessScope(userId);
|
||||
if (!scope) {
|
||||
return organizationId ? { organizationId } : undefined;
|
||||
}
|
||||
|
||||
if (scope.allServersAccess) {
|
||||
return organizationId ? { organizationId } : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(organizationId ? { organizationId } : {}),
|
||||
id: {
|
||||
in: scope.serverIds.length > 0 ? scope.serverIds : ["__no_access__"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function userCanAccessServer(
|
||||
userId: string | undefined,
|
||||
serverId: string,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const filter = await getAccessibleServerFilterForOrganization(
|
||||
userId,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
const server = await prisma.server.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
...(filter ?? {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return Boolean(server);
|
||||
}
|
||||
|
||||
export async function getAccessibleServer(
|
||||
userId: string | undefined,
|
||||
serverId: string,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const filter = await getAccessibleServerFilterForOrganization(
|
||||
userId,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
return prisma.server.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
...(filter ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# SSH Service Modules
|
||||
|
||||
Folder ini adalah implementasi modular untuk domain SSH service.
|
||||
|
||||
Status saat ini:
|
||||
|
||||
- Implementasi publik sudah dipindahkan dari file lama ke modul-modul di folder ini.
|
||||
- `../ssh.service.ts` sekarang berfungsi sebagai backward-compatibility barrel yang me-re-export API publik dari folder ini.
|
||||
- Backup implementasi monolitik lama disimpan di `../backup/ssh.service.ts` sebagai referensi migrasi.
|
||||
|
||||
Tujuan:
|
||||
|
||||
- Memisahkan domain logic agar mudah dibaca, diuji, dan di-maintain.
|
||||
- Menjaga surface API publik tetap kompatibel selama callsite lama belum dimigrasikan.
|
||||
- Menyediakan pola yang jelas untuk refactor lanjutan.
|
||||
|
||||
## Mapping file → domain
|
||||
|
||||
- `connection.ts`: pooling & lifecycle koneksi, test koneksi
|
||||
- `commands.ts`: eksekusi command (strict / streaming)
|
||||
- `metrics.ts`: pengambilan system metrics + helper format
|
||||
- `platform.ts`: deteksi distro/package manager + status Docker runtime
|
||||
- `server-config.ts`: snapshot konfigurasi server, service list, disk mount, reboot/reset/restart
|
||||
- `web-stack.ts`: inspeksi capability web server & manage komponen (install/upgrade/remove)
|
||||
- `docker-engine.ts`: install/uninstall/reinstall engine + prune
|
||||
- `docker-networks.ts`: list/inspect/create/remove network
|
||||
- `docker-containers.ts`: list container, logs/action/inspect/stats/top + file ops + run container
|
||||
- `firewall.ts`: status & rule UFW/firewall
|
||||
- `fail2ban.ts`: status/enable/disable/install/unban Fail2ban
|
||||
- `domains.ts`: discovery domain
|
||||
- `ssl.ts`: list/resolve/issue/renew/delete SSL certificate
|
||||
|
||||
## Struktur layer
|
||||
|
||||
- `index.ts`: public barrel untuk modul SSH publik.
|
||||
- `internal/*`: helper privat lintas-domain, tidak diexport lewat `index.ts`.
|
||||
- domain file (`connection.ts`, `docker-containers.ts`, dst): hanya berisi API bisnis yang memang ingin dipakai dari luar.
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- Kalau helper hanya dipakai oleh satu domain, simpan lokal di file domain tersebut.
|
||||
- Kalau helper dipakai lintas domain tapi bukan bagian dari API bisnis, simpan di `internal/`.
|
||||
- Hanya export dari `index.ts` untuk API yang memang boleh dipakai callsite luar.
|
||||
- Hindari domain file import dari `index.ts`; import langsung dari file domain/internal yang dibutuhkan untuk mengurangi risiko circular dependency.
|
||||
|
||||
## Pattern yang dipakai
|
||||
|
||||
- `connection.ts` adalah fondasi koneksi.
|
||||
- `commands.ts` berada di atas `connection.ts` sebagai abstraction untuk eksekusi command.
|
||||
- Modul domain lain bergantung pada `commands.ts`, `platform.ts`, atau helper `internal/*` sesuai kebutuhan.
|
||||
- `internal/*` boleh dipakai oleh beberapa domain, tetapi tidak menjadi bagian dari kontrak publik service.
|
||||
|
||||
Pattern ini sengaja menjaga dependency graph tetap satu arah:
|
||||
|
||||
- `connection` -> dipakai oleh `commands`
|
||||
- `commands`/`platform`/`internal` -> dipakai oleh domain modules
|
||||
- `index` -> hanya re-export public modules
|
||||
|
||||
## Checklist maintainability
|
||||
|
||||
Saat menambah modul baru, usahakan mengikuti checklist ini:
|
||||
|
||||
1. Tentukan dulu apakah logic tersebut domain publik atau helper internal.
|
||||
2. Simpan type/interface sedekat mungkin dengan domain yang memakainya.
|
||||
3. Export hanya API yang memang dibutuhkan callsite luar.
|
||||
4. Jangan import balik dari barrel `index.ts` ke dalam modul domain.
|
||||
5. Jika file domain mulai terlalu besar, pecah lagi berdasarkan subdomain perilaku, bukan berdasarkan jenis syntax.
|
||||
|
||||
Kandidat split lanjutan yang wajar bila file terus membesar:
|
||||
|
||||
- `docker-containers.ts`: bisa dipisah menjadi inventory, runtime actions, dan file operations.
|
||||
- `web-stack.ts`: bisa dipisah menjadi detection dan component management.
|
||||
- `ssl.ts`: bisa dipisah menjadi discovery/resolution dan issue/renew/delete flow.
|
||||
|
||||
## Langkah refactor berikutnya (opsional)
|
||||
|
||||
Kalau sudah siap memecah implementasi, step aman biasanya:
|
||||
|
||||
1. Ubah callsite di routes/services lain secara bertahap agar import dari domain file yang relevan atau dari `services/ssh-services`.
|
||||
2. Pertahankan `ssh.service.ts` sebagai compatibility barrel selama migration window masih dibutuhkan.
|
||||
3. Jaga agar helper baru masuk ke `internal/` bila bukan bagian dari kontrak publik.
|
||||
4. Review file domain yang masih besar, lalu split berdasarkan subdomain logic saat ada perubahan besar berikutnya.
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
import { Server } from "@prisma/client";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
|
||||
import {
|
||||
closeConnection,
|
||||
getConnection,
|
||||
withIsolatedConnection,
|
||||
} from "./connection";
|
||||
|
||||
const commandQueues = new Map<string, Promise<unknown>>();
|
||||
const streamCommandQueues = new Map<string, Promise<unknown>>();
|
||||
const commandLogSink = new AsyncLocalStorage<(data: string) => void>();
|
||||
|
||||
type ExecOptions = {
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
};
|
||||
|
||||
function isRecoverableSshError(message: string): boolean {
|
||||
return (
|
||||
message.includes("Channel open failure") ||
|
||||
message.includes("Not connected") ||
|
||||
message.includes("No response from server") ||
|
||||
message.includes("Keepalive timeout") ||
|
||||
message.includes("client-timeout") ||
|
||||
message.includes("Timed out while waiting for handshake") ||
|
||||
message.includes("ECONNRESET")
|
||||
);
|
||||
}
|
||||
|
||||
function logCommandTimeout(server: Server, command: string, timeoutMs: number) {
|
||||
const type = /\bdocker\s+exec\b/.test(command)
|
||||
? "ssh-exec-timeout"
|
||||
: "ssh-timeout";
|
||||
console.warn(
|
||||
`[${type}] ${JSON.stringify({
|
||||
type,
|
||||
serverId: server.id,
|
||||
host: server.ip,
|
||||
duration: timeoutMs,
|
||||
command: command.slice(0, 160),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
function logQueueTimeout(server: Server, command: string, timeoutMs: number) {
|
||||
const type = /\bdocker\s+exec\b/.test(command)
|
||||
? "ssh-exec-queue-timeout"
|
||||
: "ssh-queue-timeout";
|
||||
console.warn(
|
||||
`[${type}] ${JSON.stringify({
|
||||
type,
|
||||
serverId: server.id,
|
||||
host: server.ip,
|
||||
queueWait: timeoutMs,
|
||||
command: command.slice(0, 160),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
function enqueueCommand<T>(
|
||||
queue: Map<string, Promise<unknown>>,
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
options: { queueTimeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
let started = false;
|
||||
let cancelled = false;
|
||||
const previous = queue.get(server.id) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
if (cancelled) {
|
||||
throw new Error("Command cancelled before execution");
|
||||
}
|
||||
|
||||
started = true;
|
||||
return task();
|
||||
});
|
||||
|
||||
const queued = next.finally(() => {
|
||||
if (queue.get(server.id) === queued) {
|
||||
queue.delete(server.id);
|
||||
}
|
||||
});
|
||||
queue.set(server.id, queued);
|
||||
|
||||
const queueTimeoutMs = options.queueTimeoutMs;
|
||||
if (!queueTimeoutMs || queueTimeoutMs <= 0) {
|
||||
return queued;
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (settled || started) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
cancelled = true;
|
||||
logQueueTimeout(server, command, queueTimeoutMs);
|
||||
reject(
|
||||
new Error(
|
||||
`Command queue timed out after ${Math.ceil(queueTimeoutMs / 1000)}s`,
|
||||
),
|
||||
);
|
||||
}, queueTimeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
void queued
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function enqueueServerCommand<T>(
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
options: { queueTimeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
return enqueueCommand(commandQueues, server, command, task, options);
|
||||
}
|
||||
|
||||
function enqueueStreamCommand<T>(
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return enqueueCommand(streamCommandQueues, server, command, task);
|
||||
}
|
||||
|
||||
function withCommandTimeout<T>(
|
||||
execute: () => Promise<T>,
|
||||
timeoutMs: number | undefined,
|
||||
onTimeout: () => void,
|
||||
): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
onTimeout();
|
||||
reject(
|
||||
new Error(`Command timed out after ${Math.ceil(timeoutMs / 1000)}s`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
void execute()
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr
|
||||
*/
|
||||
export async function exec(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: ExecOptions = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
let stdout = "";
|
||||
let code = 0;
|
||||
|
||||
await streamCommand(
|
||||
server,
|
||||
command,
|
||||
(data) => {
|
||||
stdout += data;
|
||||
logSink(data);
|
||||
},
|
||||
(exitCode) => {
|
||||
code = exitCode;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
code,
|
||||
signal: null,
|
||||
stdout,
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
return enqueueServerCommand(
|
||||
server,
|
||||
command,
|
||||
async () => {
|
||||
try {
|
||||
const ssh = await getConnection(server);
|
||||
return await withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
closeConnection(server.id);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const shouldRetry = isRecoverableSshError(message);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
closeConnection(server.id);
|
||||
const ssh = await getConnection(server);
|
||||
return withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
closeConnection(server.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function execIsolated(
|
||||
server: Server,
|
||||
command: string,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const runOnce = () =>
|
||||
withIsolatedConnection(server, async (ssh) =>
|
||||
withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options?.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options!.timeoutMs!);
|
||||
try {
|
||||
ssh.dispose();
|
||||
} catch {
|
||||
// Ignore disposal races after timing out an isolated probe.
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
return await runOnce();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isRecoverableSshError(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return runOnce();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command, throw on non-zero exit
|
||||
*/
|
||||
export async function execStrict(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: ExecOptions = {},
|
||||
): Promise<string> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
let output = "";
|
||||
let exitCode = 0;
|
||||
|
||||
await streamCommand(
|
||||
server,
|
||||
command,
|
||||
(data) => {
|
||||
output += data;
|
||||
logSink(data);
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Command failed: ${output.trim() || command}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const result = await exec(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
const output = [result.stderr, result.stdout]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
throw new Error(`Command failed: ${output || command}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function withCommandLogSink<T>(
|
||||
onData: (data: string) => void,
|
||||
execute: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return commandLogSink.run(onData, execute);
|
||||
}
|
||||
|
||||
export async function execStrictIsolated(
|
||||
server: Server,
|
||||
command: string,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<string> {
|
||||
const result = await execIsolated(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
const output = [result.stderr, result.stdout]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
throw new Error(`Command failed: ${output || command}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
// ─── Process streaming (for WebSocket terminal) ───────────────────────────────
|
||||
|
||||
export async function streamCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
onData: (data: string) => void,
|
||||
onClose: (code: number) => void,
|
||||
): Promise<void> {
|
||||
return enqueueStreamCommand(server, command, async () => {
|
||||
const ssh = await getConnection(server);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssh.connection!.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
stream.off("data", onStdout);
|
||||
stream.stderr?.off("data", onStderr);
|
||||
stream.off("close", onCloseStream);
|
||||
stream.off("end", onEnd);
|
||||
stream.off("error", onError);
|
||||
stream.stderr?.off("error", onError);
|
||||
stream.destroy();
|
||||
};
|
||||
const finish = (code = 0) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
onClose(code);
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onStdout = (d: Buffer) => onData(d.toString());
|
||||
const onStderr = (d: Buffer) => onData(d.toString());
|
||||
const onCloseStream = (code: number) => finish(code);
|
||||
const onEnd = () => finish(0);
|
||||
const onError = (error: Error) => fail(error);
|
||||
|
||||
stream.on("data", onStdout);
|
||||
stream.stderr?.on("data", onStderr);
|
||||
stream.on("close", onCloseStream);
|
||||
stream.on("end", onEnd);
|
||||
stream.on("error", onError);
|
||||
stream.stderr?.on("error", onError);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { Server } from "@prisma/client";
|
||||
import { decrypt } from "../../lib/crypto";
|
||||
|
||||
// Pool: serverId → active SSH connection
|
||||
const pool = new Map<string, NodeSSH>();
|
||||
const connectionPromises = new Map<string, Promise<NodeSSH>>();
|
||||
|
||||
function attachConnectionLifecycle(serverId: string, ssh: NodeSSH): void {
|
||||
const connection = ssh.connection;
|
||||
if (!connection) return;
|
||||
|
||||
connection.on("error", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
|
||||
connection.on("close", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
|
||||
connection.on("end", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
}
|
||||
|
||||
type SshConnectOptions = {
|
||||
readyTimeout?: number;
|
||||
};
|
||||
|
||||
function buildConnectionOptions(
|
||||
server: Server,
|
||||
options: SshConnectOptions = {},
|
||||
) {
|
||||
return {
|
||||
host: server.ip,
|
||||
port: server.sshPort,
|
||||
username: server.username,
|
||||
readyTimeout: options.readyTimeout ?? 10000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectSsh(
|
||||
ssh: NodeSSH,
|
||||
server: Server,
|
||||
options: SshConnectOptions = {},
|
||||
): Promise<void> {
|
||||
const baseOpts = buildConnectionOptions(server, options);
|
||||
|
||||
if (server.authType === "SSH_KEY" && server.sshKeyEnc) {
|
||||
await ssh.connect({
|
||||
...baseOpts,
|
||||
privateKey: decrypt(server.sshKeyEnc),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.passwordEnc) {
|
||||
await ssh.connect({
|
||||
...baseOpts,
|
||||
password: decrypt(server.passwordEnc),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("No SSH credentials configured for this server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an SSH connection for a server
|
||||
*/
|
||||
export async function getConnection(server: Server): Promise<NodeSSH> {
|
||||
// Return existing connection if alive
|
||||
const existing = pool.get(server.id);
|
||||
if (existing?.isConnected()) return existing;
|
||||
|
||||
const pending = connectionPromises.get(server.id);
|
||||
if (pending) return pending;
|
||||
|
||||
const connectPromise = (async () => {
|
||||
const ssh = new NodeSSH();
|
||||
await connectSsh(ssh, server);
|
||||
|
||||
attachConnectionLifecycle(server.id, ssh);
|
||||
pool.set(server.id, ssh);
|
||||
return ssh;
|
||||
})().finally(() => {
|
||||
connectionPromises.delete(server.id);
|
||||
});
|
||||
|
||||
connectionPromises.set(server.id, connectPromise);
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and remove a connection from the pool
|
||||
*/
|
||||
export function closeConnection(serverId: string): void {
|
||||
const conn = pool.get(serverId);
|
||||
pool.delete(serverId);
|
||||
connectionPromises.delete(serverId);
|
||||
|
||||
try {
|
||||
conn?.dispose();
|
||||
} catch {
|
||||
// Ignore connection disposal races during keepalive timeout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function withIsolatedConnection<T>(
|
||||
server: Server,
|
||||
task: (ssh: NodeSSH) => Promise<T>,
|
||||
options: SshConnectOptions = {},
|
||||
): Promise<T> {
|
||||
const ssh = new NodeSSH();
|
||||
|
||||
try {
|
||||
await connectSsh(ssh, server, options);
|
||||
return await task(ssh);
|
||||
} finally {
|
||||
try {
|
||||
ssh.dispose();
|
||||
} catch {
|
||||
// Ignore disposal races for short-lived isolated probes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SSH connectivity — returns true/false
|
||||
*/
|
||||
export async function testConnection(server: Server): Promise<boolean> {
|
||||
try {
|
||||
await withIsolatedConnection(server, (ssh) => ssh.execCommand("echo ok"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testConnectionDetailed(
|
||||
server: Server,
|
||||
): Promise<{ connected: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await withIsolatedConnection(server, (ssh) =>
|
||||
ssh.execCommand("echo ok"),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
connected: false,
|
||||
error: result.stderr || result.stdout || "SSH command failed",
|
||||
};
|
||||
}
|
||||
return { connected: true };
|
||||
} catch (err: unknown) {
|
||||
closeConnection(server.id);
|
||||
return {
|
||||
connected: false,
|
||||
error: err instanceof Error ? err.message : "SSH connection failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { execStrict } from "./commands";
|
||||
import type { DockerRuntimeStatus, ServerPlatformInfo } from "./platform";
|
||||
import { detectServerPlatform, getDockerRuntimeStatus } from "./platform";
|
||||
import { execDocker } from "./internal/docker";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: docker-engine).
|
||||
|
||||
const DOCKER_PRUNE_TIMEOUT_MS = 60_000;
|
||||
|
||||
function dockerPruneTimeout() {
|
||||
return {
|
||||
timeoutMs: DOCKER_PRUNE_TIMEOUT_MS,
|
||||
queueTimeoutMs: DOCKER_PRUNE_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DockerPruneOptions {
|
||||
images?: boolean;
|
||||
containers?: boolean;
|
||||
networks?: boolean;
|
||||
volumes?: boolean;
|
||||
buildCache?: boolean;
|
||||
}
|
||||
|
||||
export async function pruneDockerArtifacts(
|
||||
server: Server,
|
||||
options: DockerPruneOptions = {},
|
||||
): Promise<{
|
||||
output: string;
|
||||
summary: string;
|
||||
details: string[];
|
||||
docker: DockerRuntimeStatus;
|
||||
}> {
|
||||
const docker = await getDockerRuntimeStatus(server);
|
||||
|
||||
if (!docker.installed) {
|
||||
throw new Error("Docker is not installed on this server");
|
||||
}
|
||||
|
||||
const selectedOptions = {
|
||||
images: options.images === true,
|
||||
containers: options.containers === true,
|
||||
networks: options.networks === true,
|
||||
volumes: options.volumes === true,
|
||||
buildCache: options.buildCache === true,
|
||||
};
|
||||
|
||||
if (!Object.values(selectedOptions).some(Boolean)) {
|
||||
throw new Error("Select at least one Docker artifact to prune");
|
||||
}
|
||||
|
||||
const commands = [
|
||||
selectedOptions.containers
|
||||
? {
|
||||
command: "docker container prune -f 2>&1",
|
||||
summary: "Stopped containers pruned",
|
||||
detailLabel: "Stopped containers",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.networks
|
||||
? {
|
||||
command: "docker network prune -f 2>&1",
|
||||
summary: "Unused networks pruned",
|
||||
detailLabel: "Unused networks",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.images
|
||||
? {
|
||||
command: "docker image prune -a -f 2>&1",
|
||||
summary: "Unused images pruned",
|
||||
detailLabel: "Unused images",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.volumes
|
||||
? {
|
||||
command: "docker volume prune -f 2>&1",
|
||||
summary: "Unused volumes pruned",
|
||||
detailLabel: "Unused volumes",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.buildCache
|
||||
? {
|
||||
command: "docker builder prune -f 2>&1",
|
||||
summary: "Build cache pruned",
|
||||
detailLabel: "Build cache",
|
||||
}
|
||||
: null,
|
||||
].filter(
|
||||
(
|
||||
value,
|
||||
): value is {
|
||||
command: string;
|
||||
summary: string;
|
||||
detailLabel: string;
|
||||
} => Boolean(value),
|
||||
);
|
||||
|
||||
const outputs: string[] = [];
|
||||
const details: string[] = [];
|
||||
const reclaimedTotals: string[] = [];
|
||||
|
||||
for (const item of commands) {
|
||||
const result = await execDocker(server, item.command, dockerPruneTimeout());
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr || result.stdout || "Failed to prune Docker data",
|
||||
);
|
||||
}
|
||||
|
||||
const output = result.stdout.trim() || `${item.summary}.`;
|
||||
outputs.push(`== ${item.detailLabel} ==\n${output}`);
|
||||
details.push(item.summary);
|
||||
|
||||
const reclaimedMatch = output.match(/Total reclaimed space:\s*(.+)$/im);
|
||||
if (reclaimedMatch?.[1]?.trim()) {
|
||||
reclaimedTotals.push(`${item.detailLabel}: ${reclaimedMatch[1].trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = outputs.join("\n\n").trim() || "Docker cleanup completed";
|
||||
const summary = [
|
||||
"Docker cleanup completed",
|
||||
reclaimedTotals.length > 0 ? reclaimedTotals.join(". ") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(". ");
|
||||
|
||||
return {
|
||||
output,
|
||||
summary,
|
||||
details:
|
||||
reclaimedTotals.length > 0 ? [...details, ...reclaimedTotals] : details,
|
||||
docker: await getDockerRuntimeStatus(server),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Docker install is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Docker install requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string[]
|
||||
> = {
|
||||
"apt-get": [
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get update",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io",
|
||||
"systemctl enable --now docker",
|
||||
],
|
||||
dnf: ["dnf install -y docker", "systemctl enable --now docker"],
|
||||
yum: ["yum install -y docker", "systemctl enable --now docker"],
|
||||
zypper: [
|
||||
"zypper --non-interactive install docker",
|
||||
"systemctl enable --now docker",
|
||||
],
|
||||
apk: [
|
||||
"apk add docker",
|
||||
"rc-update add docker default",
|
||||
"service docker start",
|
||||
],
|
||||
};
|
||||
|
||||
for (const command of installCommands[platform.packageManager]) {
|
||||
await execStrict(server, privilegedCommand(server, command));
|
||||
}
|
||||
|
||||
return getDockerRuntimeStatus(server);
|
||||
}
|
||||
|
||||
export async function uninstallDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
const docker = await getDockerRuntimeStatus(server);
|
||||
|
||||
if (!docker.installed) {
|
||||
return docker;
|
||||
}
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Docker removal is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Docker removal requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const removeCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string[]
|
||||
> = {
|
||||
"apt-get": [
|
||||
"systemctl disable --now docker || true",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get remove -y docker.io docker docker-engine docker-ce docker-ce-cli containerd runc || true",
|
||||
],
|
||||
dnf: [
|
||||
"systemctl disable --now docker || true",
|
||||
"dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine containerd.io || true",
|
||||
],
|
||||
yum: [
|
||||
"systemctl disable --now docker || true",
|
||||
"yum remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine containerd.io || true",
|
||||
],
|
||||
zypper: [
|
||||
"systemctl disable --now docker || true",
|
||||
"zypper --non-interactive remove docker || true",
|
||||
],
|
||||
apk: ["service docker stop || true", "apk del docker || true"],
|
||||
};
|
||||
|
||||
for (const command of removeCommands[platform.packageManager]) {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(command)}`),
|
||||
);
|
||||
}
|
||||
|
||||
return getDockerRuntimeStatus(server);
|
||||
}
|
||||
|
||||
export async function reinstallDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
await uninstallDockerEngine(server);
|
||||
return installDockerEngine(server);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { execDockerStrict } from "./internal/docker";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: docker-networks).
|
||||
|
||||
const DOCKER_NETWORK_LIST_TIMEOUT_MS = 10_000;
|
||||
const DOCKER_NETWORK_INSPECT_TIMEOUT_MS = 10_000;
|
||||
const DOCKER_NETWORK_MUTATION_TIMEOUT_MS = 20_000;
|
||||
|
||||
function dockerNetworkCommandTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface DockerNetworkInfo {
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
subnet?: string;
|
||||
gateway?: string;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
export interface DockerNetworkContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
endpointId: string | null;
|
||||
macAddress: string | null;
|
||||
ipv4Address: string | null;
|
||||
ipv6Address: string | null;
|
||||
}
|
||||
|
||||
export interface DockerNetworkInspect {
|
||||
name: string;
|
||||
id: string | null;
|
||||
created: string | null;
|
||||
scope: string;
|
||||
driver: string;
|
||||
enableIPv4: boolean | null;
|
||||
enableIPv6: boolean | null;
|
||||
internal: boolean;
|
||||
attachable: boolean;
|
||||
ingress: boolean;
|
||||
labels: Record<string, string>;
|
||||
options: Record<string, string>;
|
||||
subnet: string | null;
|
||||
gateway: string | null;
|
||||
containers: DockerNetworkContainer[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function listDockerNetworks(
|
||||
server: Server,
|
||||
): Promise<DockerNetworkInfo[]> {
|
||||
const networkList = await execDockerStrict(
|
||||
server,
|
||||
`docker network ls --format '{{.Name}}|{{.Driver}}|{{.Scope}}'`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!networkList.trim()) return [];
|
||||
|
||||
const rawNetworks = networkList
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [name, driver, scope] = line.split("|");
|
||||
return {
|
||||
name: name?.trim() || "",
|
||||
driver: driver?.trim() || "bridge",
|
||||
scope: scope?.trim() || "local",
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name);
|
||||
|
||||
const inspected = await Promise.all(
|
||||
rawNetworks.map(async (network) => {
|
||||
try {
|
||||
const inspectOutput = await execDockerStrict(
|
||||
server,
|
||||
`docker network inspect ${escapeShellArg(network.name)}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_INSPECT_TIMEOUT_MS),
|
||||
);
|
||||
const parsed = JSON.parse(inspectOutput) as Array<{
|
||||
IPAM?: { Config?: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Containers?: Record<string, unknown>;
|
||||
}>;
|
||||
const details = parsed[0] ?? {};
|
||||
const ipam = details.IPAM?.Config?.[0];
|
||||
const containers = Object.keys(details.Containers ?? {}).length;
|
||||
|
||||
return {
|
||||
...network,
|
||||
subnet: ipam?.Subnet,
|
||||
gateway: ipam?.Gateway,
|
||||
containers,
|
||||
} satisfies DockerNetworkInfo;
|
||||
} catch {
|
||||
return {
|
||||
...network,
|
||||
containers: 0,
|
||||
} satisfies DockerNetworkInfo;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return inspected.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function dockerNetworkInspect(
|
||||
server: Server,
|
||||
networkName: string,
|
||||
): Promise<DockerNetworkInspect> {
|
||||
const stdout = await execDockerStrict(
|
||||
server,
|
||||
`docker network inspect ${escapeShellArg(networkName)}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_INSPECT_TIMEOUT_MS),
|
||||
);
|
||||
const parsed = JSON.parse(stdout) as Array<{
|
||||
Name?: string;
|
||||
Id?: string;
|
||||
Created?: string;
|
||||
Scope?: string;
|
||||
Driver?: string;
|
||||
EnableIPv4?: boolean;
|
||||
EnableIPv6?: boolean;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
Ingress?: boolean;
|
||||
Labels?: Record<string, string> | null;
|
||||
Options?: Record<string, string> | null;
|
||||
IPAM?: { Config?: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Containers?: Record<
|
||||
string,
|
||||
{
|
||||
Name?: string;
|
||||
EndpointID?: string;
|
||||
MacAddress?: string;
|
||||
IPv4Address?: string;
|
||||
IPv6Address?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
const detail = parsed[0] ?? {};
|
||||
const ipam = detail.IPAM?.Config?.[0];
|
||||
const containers = Object.entries(detail.Containers ?? {}).map(
|
||||
([containerId, container]) => ({
|
||||
id: containerId,
|
||||
name: container?.Name ?? containerId,
|
||||
endpointId: container?.EndpointID ?? null,
|
||||
macAddress: container?.MacAddress ?? null,
|
||||
ipv4Address: container?.IPv4Address ?? null,
|
||||
ipv6Address: container?.IPv6Address ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: detail.Name ?? networkName,
|
||||
id: detail.Id ?? null,
|
||||
created: detail.Created ?? null,
|
||||
scope: detail.Scope ?? "local",
|
||||
driver: detail.Driver ?? "bridge",
|
||||
enableIPv4: detail.EnableIPv4 ?? null,
|
||||
enableIPv6: detail.EnableIPv6 ?? null,
|
||||
internal: Boolean(detail.Internal),
|
||||
attachable: Boolean(detail.Attachable),
|
||||
ingress: Boolean(detail.Ingress),
|
||||
labels: detail.Labels ?? {},
|
||||
options: detail.Options ?? {},
|
||||
subnet: ipam?.Subnet ?? null,
|
||||
gateway: ipam?.Gateway ?? null,
|
||||
containers: containers.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
raw: (detail as Record<string, unknown>) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDockerNetwork(
|
||||
server: Server,
|
||||
opts: {
|
||||
name: string;
|
||||
driver?: string;
|
||||
subnet?: string;
|
||||
gateway?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const flags = [
|
||||
opts.driver?.trim() ? `--driver ${escapeShellArg(opts.driver.trim())}` : "",
|
||||
opts.subnet?.trim() ? `--subnet ${escapeShellArg(opts.subnet.trim())}` : "",
|
||||
opts.gateway?.trim()
|
||||
? `--gateway ${escapeShellArg(opts.gateway.trim())}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
await execDockerStrict(
|
||||
server,
|
||||
`docker network create ${flags} ${escapeShellArg(opts.name.trim())}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_MUTATION_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeDockerNetwork(
|
||||
server: Server,
|
||||
networkName: string,
|
||||
): Promise<void> {
|
||||
await execDockerStrict(
|
||||
server,
|
||||
`docker network rm ${escapeShellArg(networkName.trim())}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_MUTATION_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "./commands";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: domains).
|
||||
|
||||
const DOMAIN_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const DOMAIN_FILE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function domainDiscoveryTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface DiscoveredDomain {
|
||||
name: string;
|
||||
proxy: "TRAEFIK" | "NGINX" | "CADDY" | "NONE";
|
||||
discoverySource: "NGINX" | "TRAEFIK" | "CADDY" | "CADDY_ADMIN" | "CERTBOT";
|
||||
value: string | null;
|
||||
sslEnabled: boolean;
|
||||
dockerId?: string | null;
|
||||
containerName?: string | null;
|
||||
targetPort?: number | null;
|
||||
sourceConfigPath?: string | null;
|
||||
managedByDoktainer?: boolean;
|
||||
managedConfigMode?: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain?: string | null;
|
||||
}
|
||||
|
||||
interface DockerInspectLabels {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
Config?: {
|
||||
Image?: string;
|
||||
Labels?: Record<string, string> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CaddyJsonNode {
|
||||
match?: Array<{
|
||||
host?: string[];
|
||||
}>;
|
||||
handle?: CaddyJsonNode[];
|
||||
routes?: CaddyJsonNode[];
|
||||
subroute?: {
|
||||
routes?: CaddyJsonNode[];
|
||||
};
|
||||
servers?: Record<string, { routes?: CaddyJsonNode[] }>;
|
||||
apps?: {
|
||||
http?: {
|
||||
servers?: Record<string, { routes?: CaddyJsonNode[] }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDomainName(value: string): string | null {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.;]+$/g, "");
|
||||
|
||||
if (!normalized) return null;
|
||||
if (normalized === "_" || normalized === "localhost") return null;
|
||||
if (normalized.includes("*") || normalized.startsWith("~")) return null;
|
||||
if (!normalized.includes(".")) return null;
|
||||
if (!/^[a-z0-9.-]+$/.test(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseHostRuleDomains(rule: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
|
||||
for (const match of rule.matchAll(/Host(?:Regexp)?\(([^)]*)\)/gi)) {
|
||||
const args = match[1] ?? "";
|
||||
for (const token of args.split(",")) {
|
||||
const cleaned = token.replace(/["'`{}\s]/g, "").trim();
|
||||
if (!cleaned || cleaned.includes(":")) continue;
|
||||
|
||||
const name = normalizeDomainName(cleaned);
|
||||
if (name) {
|
||||
domains.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
function normalizeContainerName(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = `${value ?? ""}`
|
||||
.trim()
|
||||
.replace(/^\/+/, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]/g, "-");
|
||||
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function extractNumericPortCandidate(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const upstreamTemplateMatch = trimmed.match(
|
||||
/\{\{\s*upstreams\s+(\d{1,5})\s*\}\}/i,
|
||||
);
|
||||
if (upstreamTemplateMatch) {
|
||||
const port = Number(upstreamTemplateMatch[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
const portMatch = trimmed.match(/:(\d{1,5})(?:\b|\/|$)/);
|
||||
if (!portMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number(portMatch[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
function isLikelyIpv4Host(value: string): boolean {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value.trim());
|
||||
}
|
||||
|
||||
function isLikelyIpv6Host(value: string): boolean {
|
||||
return value.includes(":");
|
||||
}
|
||||
|
||||
function isLocalhostHost(value: string): boolean {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "0.0.0.0" ||
|
||||
normalized === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
function extractProxyPassTarget(content: string): {
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
} {
|
||||
const sanitized = content.replace(/^\s*#.*$/gm, "");
|
||||
|
||||
for (const match of sanitized.matchAll(
|
||||
/\bproxy_pass\s+(https?:\/\/([^;\s/]+)(?:\/[^;\s]*)?)\s*;/gi,
|
||||
)) {
|
||||
const authority = `${match[2] ?? ""}`.trim();
|
||||
if (!authority) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedAuthority = authority.replace(/^\[|\]$/g, "");
|
||||
const lastColonIndex = normalizedAuthority.lastIndexOf(":");
|
||||
const host =
|
||||
lastColonIndex > -1
|
||||
? normalizedAuthority.slice(0, lastColonIndex)
|
||||
: normalizedAuthority;
|
||||
|
||||
return {
|
||||
host: host.trim() || null,
|
||||
port: extractNumericPortCandidate(match[1] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
return { host: null, port: null };
|
||||
}
|
||||
|
||||
function extractTraefikTargetPort(
|
||||
labels: Record<string, string>,
|
||||
): number | null {
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (
|
||||
/^traefik\.(http|tcp)\.services\.[^.]+\.loadbalancer\.server\.port$/i.test(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
const port = Number(value.trim());
|
||||
if (Number.isInteger(port) && port >= 1 && port <= 65535) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCaddyTargetPort(labels: Record<string, string>): number | null {
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (
|
||||
/^caddy(?:_\d+)?\.reverse_proxy$/i.test(key) ||
|
||||
/^caddy\.reverse_proxy(?:_\d+)?$/i.test(key)
|
||||
) {
|
||||
const port = extractNumericPortCandidate(value);
|
||||
if (port) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMarkedFileChunk(chunk: string): {
|
||||
path: string | null;
|
||||
content: string;
|
||||
} {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
return { path: null, content: "" };
|
||||
}
|
||||
|
||||
const lines = trimmed.split(/\r?\n/);
|
||||
const firstLine = lines[0] ?? "";
|
||||
if (firstLine.startsWith("__FILE__:")) {
|
||||
return {
|
||||
path: firstLine.slice("__FILE__:".length).trim() || null,
|
||||
content: lines.slice(1).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { path: null, content: trimmed };
|
||||
}
|
||||
|
||||
function deriveManagedNginxContainerName(
|
||||
filePath: string | null,
|
||||
domainName: string,
|
||||
): string | null {
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedDomain = normalizeDomainName(domainName);
|
||||
if (!normalizedDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = filePath.replace(/^.*[\\/]/, "").replace(/\.conf$/i, "");
|
||||
const suffix = `--${normalizedDomain.replace(/[^a-z0-9.-]/g, "-")}`;
|
||||
if (!fileName.toLowerCase().endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.slice(0, -suffix.length) || null;
|
||||
}
|
||||
|
||||
function extractManagedNginxMetadata(content: string): {
|
||||
managedByDoktainer: boolean;
|
||||
managedConfigMode: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain: string | null;
|
||||
} {
|
||||
const normalized = content.replace(/\r/g, "");
|
||||
const managedByDoktainer = /#\s*doktainer-managed\s*:\s*true/i.test(
|
||||
normalized,
|
||||
);
|
||||
const managedConfigModeMatch = normalized.match(
|
||||
/#\s*doktainer-config-mode\s*:\s*(SHARED|ISOLATED)/i,
|
||||
);
|
||||
const primaryDomainMatch = normalized.match(
|
||||
/#\s*doktainer-primary-domain\s*:\s*([^\n#]+)/i,
|
||||
);
|
||||
|
||||
return {
|
||||
managedByDoktainer,
|
||||
managedConfigMode:
|
||||
managedConfigModeMatch?.[1]?.toUpperCase() === "SHARED"
|
||||
? "SHARED"
|
||||
: managedConfigModeMatch?.[1]?.toUpperCase() === "ISOLATED"
|
||||
? "ISOLATED"
|
||||
: null,
|
||||
managedPrimaryDomain:
|
||||
normalizeDomainName(primaryDomainMatch?.[1]?.trim() ?? "") ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function addDiscoveredDomain(
|
||||
collection: Map<string, DiscoveredDomain>,
|
||||
domainName: string,
|
||||
proxy: DiscoveredDomain["proxy"],
|
||||
discoverySource: DiscoveredDomain["discoverySource"],
|
||||
value: string | null,
|
||||
sslEnabled = false,
|
||||
metadata?: {
|
||||
dockerId?: string | null;
|
||||
containerName?: string | null;
|
||||
targetPort?: number | null;
|
||||
sourceConfigPath?: string | null;
|
||||
managedByDoktainer?: boolean;
|
||||
managedConfigMode?: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain?: string | null;
|
||||
},
|
||||
): void {
|
||||
const normalized = normalizeDomainName(domainName);
|
||||
if (!normalized) return;
|
||||
|
||||
const existing = collection.get(normalized);
|
||||
collection.set(normalized, {
|
||||
name: normalized,
|
||||
proxy:
|
||||
existing?.proxy === "NGINX"
|
||||
? existing.proxy
|
||||
: existing?.proxy === "TRAEFIK" && proxy === "CADDY"
|
||||
? existing.proxy
|
||||
: proxy !== "NONE"
|
||||
? proxy
|
||||
: (existing?.proxy ?? "NONE"),
|
||||
discoverySource:
|
||||
existing?.discoverySource === "NGINX"
|
||||
? existing.discoverySource
|
||||
: existing?.discoverySource === "TRAEFIK" &&
|
||||
discoverySource === "CADDY_ADMIN"
|
||||
? existing.discoverySource
|
||||
: discoverySource,
|
||||
value: existing?.value ?? value,
|
||||
sslEnabled: existing?.sslEnabled || sslEnabled || false,
|
||||
dockerId: existing?.dockerId ?? metadata?.dockerId ?? null,
|
||||
containerName:
|
||||
existing?.containerName ??
|
||||
normalizeContainerName(metadata?.containerName) ??
|
||||
null,
|
||||
targetPort: existing?.targetPort ?? metadata?.targetPort ?? null,
|
||||
sourceConfigPath:
|
||||
existing?.sourceConfigPath ?? metadata?.sourceConfigPath ?? null,
|
||||
managedByDoktainer:
|
||||
existing?.managedByDoktainer ?? metadata?.managedByDoktainer ?? false,
|
||||
managedConfigMode:
|
||||
existing?.managedConfigMode ?? metadata?.managedConfigMode ?? null,
|
||||
managedPrimaryDomain:
|
||||
existing?.managedPrimaryDomain ?? metadata?.managedPrimaryDomain ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function extractCaddyfileDomains(content: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (line.startsWith("import ")) continue;
|
||||
if (line.startsWith("(") || line === "}") continue;
|
||||
if (!line.includes("{")) continue;
|
||||
|
||||
const addressSection = line.replace(/\{.*$/, "").trim();
|
||||
if (!addressSection) continue;
|
||||
|
||||
for (const token of addressSection.split(",")) {
|
||||
const candidate = token
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.split(/\s+/)[0]
|
||||
?.trim();
|
||||
|
||||
const domainName = normalizeDomainName(candidate ?? "");
|
||||
if (domainName) {
|
||||
domains.add(domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
function collectDomainsFromCaddyJsonNode(
|
||||
node: unknown,
|
||||
bucket: Set<string>,
|
||||
): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
const typed = node as CaddyJsonNode;
|
||||
|
||||
for (const matcher of typed.match ?? []) {
|
||||
for (const host of matcher.host ?? []) {
|
||||
const domainName = normalizeDomainName(host);
|
||||
if (domainName) {
|
||||
bucket.add(domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of typed.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const route of typed.handle ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const route of typed.subroute?.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const server of Object.values(typed.servers ?? {})) {
|
||||
for (const route of server.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of Object.values(typed.apps?.http?.servers ?? {})) {
|
||||
for (const route of server.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractCaddyJsonDomains(content: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const domains = new Set<string>();
|
||||
collectDomainsFromCaddyJsonNode(parsed, domains);
|
||||
return Array.from(domains);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractNginxServerNameDomains(content: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
const sanitized = content.replace(/^\s*#.*$/gm, "");
|
||||
|
||||
for (const match of sanitized.matchAll(/\bserver_name\b([\s\S]*?);/gi)) {
|
||||
const value = (match[1] ?? "")
|
||||
.replace(/\\\r?\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const token of value.split(/\s+/)) {
|
||||
const normalized = normalizeDomainName(
|
||||
token.replace(/^["']+|["']+$/g, ""),
|
||||
);
|
||||
if (normalized) {
|
||||
domains.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
async function listNginxDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const [configDump, fileDump] = await Promise.all([
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"if command -v nginx >/dev/null 2>&1; then",
|
||||
` ${privilegedCommand(server, "nginx -T")} 2>&1 || true`,
|
||||
"fi",
|
||||
].join(" "),
|
||||
)}`,
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for path in /etc/nginx/nginx.conf /etc/nginx/sites-enabled /etc/nginx/conf.d /usr/local/nginx/conf/nginx.conf /usr/local/nginx/conf/conf.d; do",
|
||||
' if [ -f "$path" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$path";',
|
||||
' cat "$path" 2>/dev/null;',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
' elif [ -d "$path" ]; then',
|
||||
' find -L "$path" -maxdepth 3 -type f 2>/dev/null | while IFS= read -r file; do',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file" 2>/dev/null;',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" done;",
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
),
|
||||
]);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const content of [
|
||||
configDump.stdout,
|
||||
configDump.stderr,
|
||||
...fileDump.stdout.split("__END_FILE__").map((chunk) => {
|
||||
const parsed = parseMarkedFileChunk(chunk);
|
||||
return parsed.content;
|
||||
}),
|
||||
]) {
|
||||
for (const domainName of extractNginxServerNameDomains(content)) {
|
||||
addDiscoveredDomain(domains, domainName, "NGINX", "NGINX", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of fileDump.stdout.split("__END_FILE__")) {
|
||||
const { path, content } = parseMarkedFileChunk(chunk);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const proxyPassTarget = extractProxyPassTarget(content);
|
||||
const managedMetadata = extractManagedNginxMetadata(content);
|
||||
for (const domainName of extractNginxServerNameDomains(content)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"NGINX",
|
||||
"NGINX",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
sourceConfigPath: path,
|
||||
managedByDoktainer: managedMetadata.managedByDoktainer,
|
||||
managedConfigMode: managedMetadata.managedConfigMode,
|
||||
managedPrimaryDomain: managedMetadata.managedPrimaryDomain,
|
||||
containerName:
|
||||
deriveManagedNginxContainerName(path, domainName) ??
|
||||
(proxyPassTarget.host &&
|
||||
!isLocalhostHost(proxyPassTarget.host) &&
|
||||
!isLikelyIpv4Host(proxyPassTarget.host) &&
|
||||
!isLikelyIpv6Host(proxyPassTarget.host)
|
||||
? proxyPassTarget.host
|
||||
: null),
|
||||
targetPort: proxyPassTarget.port,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listTraefikDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const inspectOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'ids=$(docker ps -q 2>/dev/null); if [ -n "$ids" ]; then docker inspect $ids 2>/dev/null; fi',
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!inspectOutput.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let containers: DockerInspectLabels[] = [];
|
||||
try {
|
||||
containers = JSON.parse(inspectOutput.stdout) as DockerInspectLabels[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const container of containers) {
|
||||
const labels = container.Config?.Labels ?? {};
|
||||
const traefikEnabled = `${labels["traefik.enable"] ?? ""}`.toLowerCase();
|
||||
const targetPort = extractTraefikTargetPort(labels);
|
||||
const containerName = normalizeContainerName(container.Name);
|
||||
const routerRules = Object.entries(labels)
|
||||
.filter(([key, value]) => {
|
||||
if (!value) return false;
|
||||
return /^traefik\.(http|tcp)\.routers\.[^.]+\.rule$/i.test(key);
|
||||
})
|
||||
.map(([, value]) => value);
|
||||
|
||||
if (traefikEnabled === "false" || routerRules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rule of routerRules) {
|
||||
for (const domainName of parseHostRuleDomains(rule)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"TRAEFIK",
|
||||
"TRAEFIK",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
dockerId: container.Id ?? null,
|
||||
containerName,
|
||||
targetPort,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listCaddyAdminApiDomains(
|
||||
server: Server,
|
||||
): Promise<DiscoveredDomain[]> {
|
||||
const hostConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"if command -v curl >/dev/null 2>&1; then",
|
||||
" curl --max-time 2 -fsS http://127.0.0.1:2019/config || curl --max-time 2 -fsS http://localhost:2019/config || true",
|
||||
"elif command -v wget >/dev/null 2>&1; then",
|
||||
" wget -T 2 -qO- http://127.0.0.1:2019/config || wget -T 2 -qO- http://localhost:2019/config || true",
|
||||
"fi",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(hostConfig.stdout)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY_ADMIN", server.ip);
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listCaddyDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const caddyfileOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for file in /etc/caddy/Caddyfile /usr/local/etc/caddy/Caddyfile /opt/*/Caddyfile /srv/*/Caddyfile; do",
|
||||
' if [ -f "$file" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file";',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const caddyJsonOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for file in /etc/caddy/*.json /usr/local/etc/caddy/*.json /opt/*/caddy*.json /srv/*/caddy*.json; do",
|
||||
' if [ -f "$file" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file";',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const inspectOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'ids=$(docker ps -q 2>/dev/null); if [ -n "$ids" ]; then docker inspect $ids 2>/dev/null; fi',
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const caddyAdminDomains = await listCaddyAdminApiDomains(server);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const chunk of caddyfileOutput.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyfileDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of caddyJsonOutput.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyJsonDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const discovered of caddyAdminDomains) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
discovered.name,
|
||||
discovered.proxy,
|
||||
discovered.discoverySource,
|
||||
discovered.value ?? server.ip,
|
||||
discovered.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
let containers: DockerInspectLabels[] = [];
|
||||
try {
|
||||
containers = inspectOutput.stdout.trim()
|
||||
? (JSON.parse(inspectOutput.stdout) as DockerInspectLabels[])
|
||||
: [];
|
||||
} catch {
|
||||
containers = [];
|
||||
}
|
||||
|
||||
for (const container of containers) {
|
||||
const labels = container.Config?.Labels ?? {};
|
||||
const imageName = `${container.Config?.Image ?? ""}`.toLowerCase();
|
||||
const containerName = `${container.Name ?? ""}`.toLowerCase();
|
||||
const targetPort = extractCaddyTargetPort(labels);
|
||||
const isCaddyContainer =
|
||||
imageName.includes("caddy") || containerName.includes("caddy");
|
||||
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (!value) continue;
|
||||
|
||||
if (key === "caddy" || /^caddy_\d+$/i.test(key)) {
|
||||
for (const token of value.split(/[\s,]+/)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
token,
|
||||
"CADDY",
|
||||
"CADDY",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
dockerId: container.Id ?? null,
|
||||
containerName,
|
||||
targetPort,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCaddyContainer || !container.Id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const containerConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
`for file in /etc/caddy/Caddyfile /config/caddy/Caddyfile /etc/caddy/config.json /config/caddy/config.json; do`,
|
||||
` if docker exec ${container.Id} test -f \"$file\" 2>/dev/null; then`,
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
` docker exec ${container.Id} cat \"$file\" 2>/dev/null;`,
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
for (const chunk of containerConfig.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyfileDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
const containerAdminConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
`docker exec ${container.Id} sh -lc`,
|
||||
escapeShellArg(
|
||||
[
|
||||
"if command -v curl >/dev/null 2>&1; then",
|
||||
" curl --max-time 2 -fsS http://127.0.0.1:2019/config || true",
|
||||
"elif command -v wget >/dev/null 2>&1; then",
|
||||
" wget -T 2 -qO- http://127.0.0.1:2019/config || true",
|
||||
"fi",
|
||||
].join(" "),
|
||||
),
|
||||
].join(" "),
|
||||
)} 2>/dev/null`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(
|
||||
containerAdminConfig.stdout,
|
||||
)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"CADDY",
|
||||
"CADDY_ADMIN",
|
||||
server.ip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
export async function listServerDomains(
|
||||
server: Server,
|
||||
): Promise<DiscoveredDomain[]> {
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
const [nginxDomains, traefikDomains, caddyDomains] = await Promise.all([
|
||||
listNginxDomains(server),
|
||||
listTraefikDomains(server),
|
||||
listCaddyDomains(server),
|
||||
]);
|
||||
|
||||
for (const discovered of [
|
||||
...nginxDomains,
|
||||
...traefikDomains,
|
||||
...caddyDomains,
|
||||
]) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
discovered.name,
|
||||
discovered.proxy,
|
||||
discovered.discoverySource,
|
||||
discovered.value ?? server.ip,
|
||||
discovered.sslEnabled,
|
||||
{
|
||||
dockerId: discovered.dockerId,
|
||||
containerName: discovered.containerName,
|
||||
targetPort: discovered.targetPort,
|
||||
sourceConfigPath: discovered.sourceConfigPath,
|
||||
managedByDoktainer: discovered.managedByDoktainer,
|
||||
managedConfigMode: discovered.managedConfigMode,
|
||||
managedPrimaryDomain: discovered.managedPrimaryDomain,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(domains.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execStrict } from "./commands";
|
||||
import type { ServerPlatformInfo } from "./platform";
|
||||
import { detectServerPlatform } from "./platform";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { hasCommand } from "./internal/system";
|
||||
|
||||
export interface BannedIP {
|
||||
ip: string;
|
||||
jail: string;
|
||||
bannedAt: string;
|
||||
}
|
||||
|
||||
export interface Fail2banStatus {
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
bannedIPs: BannedIP[];
|
||||
}
|
||||
|
||||
export async function getFail2banStatus(
|
||||
server: Server,
|
||||
): Promise<Fail2banStatus> {
|
||||
const installed = await hasCommand(server, "fail2ban-client");
|
||||
if (!installed) {
|
||||
return { enabled: false, installed: false, bannedIPs: [] };
|
||||
}
|
||||
|
||||
const statusResult = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "systemctl is-active fail2ban")} 2>/dev/null || echo "inactive"`,
|
||||
);
|
||||
const enabled = statusResult.stdout.trim() === "active";
|
||||
|
||||
const bannedIPs: BannedIP[] = [];
|
||||
if (enabled) {
|
||||
const banned = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "fail2ban-client status sshd")} 2>/dev/null | grep 'Banned IP list' | sed 's/.*Banned IP list://g'`,
|
||||
);
|
||||
const ips = banned.stdout.trim().split(/\s+/).filter(Boolean);
|
||||
for (const ip of ips) {
|
||||
bannedIPs.push({ ip, jail: "sshd", bannedAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
return { enabled, installed: true, bannedIPs };
|
||||
}
|
||||
|
||||
export async function installFail2ban(server: Server): Promise<void> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Fail2ban install is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Fail2ban install requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string
|
||||
> = {
|
||||
"apt-get": "apt-get update && apt-get install -y fail2ban",
|
||||
dnf: "dnf install -y fail2ban",
|
||||
yum: "yum install -y epel-release && yum install -y fail2ban",
|
||||
zypper: "zypper --non-interactive install fail2ban",
|
||||
apk: "apk add fail2ban",
|
||||
};
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(installCommands[platform.packageManager])}`,
|
||||
),
|
||||
);
|
||||
await enableFail2ban(server);
|
||||
}
|
||||
|
||||
export async function enableFail2ban(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, "systemctl enable --now fail2ban"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableFail2ban(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, "systemctl disable --now fail2ban"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unbanIP(
|
||||
server: Server,
|
||||
ip: string,
|
||||
jail = "sshd",
|
||||
): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `fail2ban-client set ${jail} unbanip ${ip}`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execStrict } from "./commands";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
export interface FirewallRule {
|
||||
number: number;
|
||||
rule: string;
|
||||
action: string;
|
||||
direction: string;
|
||||
from: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export async function getFirewallStatus(
|
||||
server: Server,
|
||||
): Promise<{ enabled: boolean; rules: FirewallRule[] }> {
|
||||
const status = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "ufw status numbered")} 2>/dev/null || echo "inactive"`,
|
||||
);
|
||||
const output = status.stdout;
|
||||
const enabled =
|
||||
!output.includes("inactive") && !output.includes("Status: inactive");
|
||||
|
||||
const rules: FirewallRule[] = [];
|
||||
const lines = output.split("\n");
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\[\s*(\d+)\]\s+(.+)$/);
|
||||
if (match) {
|
||||
const columns = match[2]
|
||||
.split(/\s{2,}/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (columns.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionParts = columns[1].split(/\s+/).filter(Boolean);
|
||||
rules.push({
|
||||
number: Number(match[1]),
|
||||
rule: columns[0],
|
||||
action: (actionParts[0] || "").toUpperCase(),
|
||||
direction: (actionParts[1] || "").toUpperCase(),
|
||||
from: columns.slice(2).join(" "),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { enabled, rules };
|
||||
}
|
||||
|
||||
export async function enableFirewall(server: Server): Promise<void> {
|
||||
await execStrict(server, privilegedCommand(server, "ufw --force enable"));
|
||||
}
|
||||
|
||||
export async function disableFirewall(server: Server): Promise<void> {
|
||||
await execStrict(server, privilegedCommand(server, "ufw --force disable"));
|
||||
}
|
||||
|
||||
export async function addFirewallRule(
|
||||
server: Server,
|
||||
rule: string,
|
||||
action: "allow" | "deny",
|
||||
from?: string,
|
||||
): Promise<void> {
|
||||
const fromPart = from && from !== "Anywhere" ? `from ${from} ` : "";
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `ufw ${action} ${fromPart}${rule}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteFirewallRule(
|
||||
server: Server,
|
||||
ruleNum: number,
|
||||
): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `ufw --force delete ${ruleNum}`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./connection";
|
||||
export * from "./commands";
|
||||
export * from "./metrics";
|
||||
export * from "./platform";
|
||||
export * from "./server-config";
|
||||
export * from "./web-stack";
|
||||
export * from "./docker-engine";
|
||||
export * from "./docker-networks";
|
||||
export * from "./docker-containers";
|
||||
export * from "./firewall";
|
||||
export * from "./fail2ban";
|
||||
export * from "./domains";
|
||||
export * from "./ssl";
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { execStrict } from "../commands";
|
||||
import { escapeShellArg } from "./shell";
|
||||
import { privilegedCommand } from "./privilege";
|
||||
|
||||
type WebStackAction = "install" | "upgrade" | "reinstall" | "remove";
|
||||
|
||||
const certbotBinaryCandidates = [
|
||||
"certbot",
|
||||
"/snap/bin/certbot",
|
||||
"/usr/bin/certbot",
|
||||
"/usr/local/bin/certbot",
|
||||
];
|
||||
|
||||
export function buildCertbotResolveScript(): string {
|
||||
const candidates = certbotBinaryCandidates.join(" ");
|
||||
|
||||
return [
|
||||
'resolved=""',
|
||||
`for candidate in ${candidates}; do`,
|
||||
' if command -v "$candidate" >/dev/null 2>&1; then',
|
||||
' resolved=$(command -v "$candidate")',
|
||||
" break",
|
||||
" fi",
|
||||
' if [ -x "$candidate" ]; then',
|
||||
' resolved="$candidate"',
|
||||
" break",
|
||||
" fi",
|
||||
"done",
|
||||
'if [ -z "$resolved" ] && command -v python3 >/dev/null 2>&1; then',
|
||||
" if python3 -m certbot --version >/dev/null 2>&1; then",
|
||||
' resolved="python3 -m certbot"',
|
||||
" fi",
|
||||
"fi",
|
||||
'if [ -n "$resolved" ]; then',
|
||||
' printf "%s\\n" "$resolved"',
|
||||
"fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotCommandTest(): string {
|
||||
return [
|
||||
buildCertbotResolveScript(),
|
||||
'resolved=$(printf "%s" "$resolved")',
|
||||
'[ -n "$resolved" ]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotVersionCommand(): string {
|
||||
return [
|
||||
`resolved=$(${buildCertbotResolveScript()})`,
|
||||
'if [ -n "$resolved" ]; then',
|
||||
' eval "$resolved --version" 2>/dev/null | head -n1',
|
||||
"fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildAptCertbotCommand(
|
||||
action: Exclude<WebStackAction, "remove">,
|
||||
): string {
|
||||
const aptRepairPrefix = [
|
||||
"export DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg --configure -a || true",
|
||||
"apt-get install -f -y || true",
|
||||
"apt-get update",
|
||||
];
|
||||
|
||||
const installStep =
|
||||
action === "upgrade"
|
||||
? [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --only-upgrade -y certbot || apt-get install --only-upgrade -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --only-upgrade -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install --only-upgrade -y certbot || true",
|
||||
"fi",
|
||||
]
|
||||
: action === "reinstall"
|
||||
? [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --reinstall -y certbot || apt-get install --reinstall -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --reinstall -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install --reinstall -y certbot || true",
|
||||
"fi",
|
||||
]
|
||||
: [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install -y certbot || apt-get install -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install -y certbot || true",
|
||||
"fi",
|
||||
];
|
||||
|
||||
return [
|
||||
...aptRepairPrefix,
|
||||
...installStep,
|
||||
`${buildCertbotCommandTest()} >/dev/null 2>&1`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotEmailFlag(domainName: string): string {
|
||||
const configuredEmail = process.env.CERTBOT_EMAIL?.trim();
|
||||
if (configuredEmail) {
|
||||
return `--email ${escapeShellArg(configuredEmail)}`;
|
||||
}
|
||||
|
||||
const fallbackEmail = `admin@${domainName}`;
|
||||
return `--email ${escapeShellArg(fallbackEmail)}`;
|
||||
}
|
||||
|
||||
export async function ensureCertbotInstalled(server: Server): Promise<void> {
|
||||
await resolveCertbotCommand(server);
|
||||
}
|
||||
|
||||
export async function resolveCertbotCommand(server: Server): Promise<string> {
|
||||
try {
|
||||
const result = await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`${buildCertbotResolveScript()} | grep -q . && ${buildCertbotResolveScript()}`)}`,
|
||||
),
|
||||
);
|
||||
return result.trim().split(/\r?\n/).find(Boolean) ?? "certbot";
|
||||
} catch {
|
||||
throw new Error(
|
||||
`certbot is not installed or not executable on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
|
||||
import { exec } from "../commands";
|
||||
import { detectServerPlatform } from "../platform";
|
||||
|
||||
function shouldRetryDockerWithSudo(output: string): boolean {
|
||||
const lower = output.toLowerCase();
|
||||
return (
|
||||
lower.includes("permission denied") ||
|
||||
lower.includes("docker.sock") ||
|
||||
lower.includes("you must be root")
|
||||
);
|
||||
}
|
||||
|
||||
export async function execDocker(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: { timeoutMs?: number; queueTimeoutMs?: number } = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const directResult = await exec(server, command, options);
|
||||
if (directResult.code === 0 || server.username === "root") {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const output = `${directResult.stderr || ""}\n${directResult.stdout || ""}`;
|
||||
if (!shouldRetryDockerWithSudo(output)) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const platform = await detectServerPlatform(server);
|
||||
if (!platform.sudoNonInteractive) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const sudoResult = await exec(server, `sudo -n ${command}`, options);
|
||||
return sudoResult.code === 0 ? sudoResult : directResult;
|
||||
}
|
||||
|
||||
export async function execDockerStrict(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: { timeoutMs?: number; queueTimeoutMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const result = await execDocker(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Command failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
export function privilegedCommand(server: Server, command: string): string {
|
||||
return server.username === "root" ? command : `sudo ${command}`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'"`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
|
||||
import { exec } from "../commands";
|
||||
import { escapeShellArg } from "./shell";
|
||||
|
||||
type CommandExecutor = (
|
||||
server: Server,
|
||||
command: string,
|
||||
) => Promise<SSHExecCommandResponse>;
|
||||
|
||||
export async function hasCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
executor: CommandExecutor = exec,
|
||||
): Promise<boolean> {
|
||||
const result = await executor(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`command -v ${command} >/dev/null 2>&1`)}`,
|
||||
);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
export type { CommandExecutor };
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { NodeSSH } from "node-ssh";
|
||||
|
||||
import { getConnection, withIsolatedConnection } from "./connection";
|
||||
|
||||
export interface SystemMetrics {
|
||||
cpuPct: number;
|
||||
ramPct: number;
|
||||
diskPct: number;
|
||||
ramUsed: bigint;
|
||||
ramTotal: bigint;
|
||||
diskUsed: bigint;
|
||||
diskTotal: bigint;
|
||||
networkRxBps: bigint;
|
||||
networkTxBps: bigint;
|
||||
uptimeSec: bigint;
|
||||
os: string;
|
||||
}
|
||||
|
||||
const NETWORK_BYTES_COMMAND =
|
||||
"awk -F'[: ]+' 'NR>2 && $1 != \"lo\" { rx += $3; tx += $11 } END { print rx+0, tx+0 }' /proc/net/dev";
|
||||
const CPU_STAT_COMMAND = "awk '/^cpu / {print}' /proc/stat";
|
||||
const MEMORY_COMMAND =
|
||||
"awk '/^MemTotal:/ { total=$2 * 1024 } /^MemAvailable:/ { available=$2 * 1024 } END { print total+0, total-available }' /proc/meminfo";
|
||||
|
||||
function parseBigIntPair(output: string): [bigint, bigint] {
|
||||
const [leftRaw = "0", rightRaw = "0"] = output.trim().split(/\s+/, 2);
|
||||
|
||||
return [BigInt(leftRaw || "0"), BigInt(rightRaw || "0")];
|
||||
}
|
||||
|
||||
function diffCounter(start: bigint, end: bigint): bigint {
|
||||
return end >= start ? end - start : 0n;
|
||||
}
|
||||
|
||||
function parseCpuStat(output: string): bigint[] {
|
||||
return output
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(1)
|
||||
.map((value) => BigInt(value || "0"));
|
||||
}
|
||||
|
||||
function sumCpuFields(fields: bigint[]): bigint {
|
||||
return fields.reduce((total, value) => total + value, 0n);
|
||||
}
|
||||
|
||||
function calculateCpuPct(start: bigint[], end: bigint[]): number {
|
||||
const startTotal = sumCpuFields(start);
|
||||
const endTotal = sumCpuFields(end);
|
||||
const totalDiff = endTotal - startTotal;
|
||||
if (totalDiff <= 0n) return 0;
|
||||
|
||||
const startIdle = (start[3] ?? 0n) + (start[4] ?? 0n);
|
||||
const endIdle = (end[3] ?? 0n) + (end[4] ?? 0n);
|
||||
const idleDiff = endIdle >= startIdle ? endIdle - startIdle : 0n;
|
||||
const busyDiff = totalDiff > idleDiff ? totalDiff - idleDiff : 0n;
|
||||
|
||||
return Math.min(100, Math.max(0, (Number(busyDiff) / Number(totalDiff)) * 100));
|
||||
}
|
||||
|
||||
async function collectMetricsFromConnection(
|
||||
ssh: NodeSSH,
|
||||
): Promise<SystemMetrics> {
|
||||
// Parallel execution for speed
|
||||
const [cpuStartOut, memOut, diskOut, uptimeOut, osOut, networkStartOut] =
|
||||
await Promise.all([
|
||||
ssh.execCommand(CPU_STAT_COMMAND),
|
||||
ssh.execCommand(MEMORY_COMMAND),
|
||||
ssh.execCommand("df -B1 / | awk 'NR==2{print $2, $3}'"),
|
||||
ssh.execCommand("cat /proc/uptime | awk '{print int($1)}'"),
|
||||
ssh.execCommand(
|
||||
"cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'",
|
||||
),
|
||||
ssh.execCommand(NETWORK_BYTES_COMMAND),
|
||||
]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const [cpuEndOut, networkEndOut] = await Promise.all([
|
||||
ssh.execCommand(CPU_STAT_COMMAND),
|
||||
ssh.execCommand(NETWORK_BYTES_COMMAND),
|
||||
]);
|
||||
|
||||
const cpuPct = calculateCpuPct(
|
||||
parseCpuStat(cpuStartOut.stdout),
|
||||
parseCpuStat(cpuEndOut.stdout),
|
||||
);
|
||||
|
||||
const [ramTotal, ramUsed] = memOut.stdout.trim().split(" ").map(BigInt);
|
||||
|
||||
const [diskTotal, diskUsed] = diskOut.stdout.trim().split(" ").map(BigInt);
|
||||
const [networkRxStart, networkTxStart] = parseBigIntPair(
|
||||
networkStartOut.stdout,
|
||||
);
|
||||
const [networkRxEnd, networkTxEnd] = parseBigIntPair(networkEndOut.stdout);
|
||||
|
||||
const uptimeSec = BigInt(uptimeOut.stdout.trim() || "0");
|
||||
const os = osOut.stdout.trim() || "Linux";
|
||||
|
||||
const ramPct = ramTotal > 0n ? Number((ramUsed * 100n) / ramTotal) : 0;
|
||||
const diskPct = diskTotal > 0n ? Number((diskUsed * 100n) / diskTotal) : 0;
|
||||
const networkRxBps = diffCounter(networkRxStart, networkRxEnd);
|
||||
const networkTxBps = diffCounter(networkTxStart, networkTxEnd);
|
||||
|
||||
return {
|
||||
cpuPct,
|
||||
ramPct,
|
||||
diskPct,
|
||||
ramUsed,
|
||||
ramTotal,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
networkRxBps,
|
||||
networkTxBps,
|
||||
uptimeSec,
|
||||
os,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect CPU, RAM, Disk, Uptime metrics via SSH
|
||||
*/
|
||||
export async function collectMetrics(
|
||||
server: Server,
|
||||
options?: { isolated?: boolean },
|
||||
): Promise<SystemMetrics> {
|
||||
if (options?.isolated) {
|
||||
return withIsolatedConnection(server, collectMetricsFromConnection);
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server);
|
||||
return collectMetricsFromConnection(ssh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime seconds into "Xd Yh" string
|
||||
*/
|
||||
export function formatUptime(seconds: bigint): string {
|
||||
const s = Number(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execIsolated } from "./commands";
|
||||
import { hasCommand, type CommandExecutor } from "./internal/system";
|
||||
|
||||
export interface ServerPlatformInfo {
|
||||
distro: string | null;
|
||||
packageManager: "apt-get" | "dnf" | "yum" | "zypper" | "apk" | null;
|
||||
sudoNonInteractive: boolean;
|
||||
supportedForFail2banInstall: boolean;
|
||||
}
|
||||
|
||||
export interface DockerRuntimeStatus {
|
||||
installed: boolean;
|
||||
daemonRunning: boolean;
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
reason: string | null;
|
||||
canInstall: boolean;
|
||||
probeFailed?: boolean;
|
||||
platform: ServerPlatformInfo;
|
||||
}
|
||||
|
||||
export interface RuntimeProbeOptions {
|
||||
isolated?: boolean;
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
platform?: ServerPlatformInfo;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_PROBE_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const UNKNOWN_SERVER_PLATFORM: ServerPlatformInfo = {
|
||||
distro: null,
|
||||
packageManager: null,
|
||||
sudoNonInteractive: false,
|
||||
supportedForFail2banInstall: false,
|
||||
};
|
||||
|
||||
export function createDockerProbeFailureStatus(
|
||||
reason: string,
|
||||
platform: ServerPlatformInfo = UNKNOWN_SERVER_PLATFORM,
|
||||
): DockerRuntimeStatus {
|
||||
return {
|
||||
installed: false,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason,
|
||||
canInstall: false,
|
||||
probeFailed: true,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandExecutor(
|
||||
options: RuntimeProbeOptions = {},
|
||||
): CommandExecutor {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_RUNTIME_PROBE_TIMEOUT_MS;
|
||||
const queueTimeoutMs = options.queueTimeoutMs ?? timeoutMs;
|
||||
|
||||
if (!options.isolated) {
|
||||
return (server, command) =>
|
||||
exec(server, command, { timeoutMs, queueTimeoutMs });
|
||||
}
|
||||
|
||||
return (server, command) =>
|
||||
execIsolated(server, command, { timeoutMs });
|
||||
}
|
||||
|
||||
export async function detectServerPlatform(
|
||||
server: Server,
|
||||
options: RuntimeProbeOptions = {},
|
||||
): Promise<ServerPlatformInfo> {
|
||||
const executor = resolveCommandExecutor(options);
|
||||
const [osRelease, aptGet, dnf, yum, zypper, apk, sudoCheck] =
|
||||
await Promise.all([
|
||||
executor(
|
||||
server,
|
||||
"cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d= -f2- | tr -d '\"'",
|
||||
),
|
||||
hasCommand(server, "apt-get", executor),
|
||||
hasCommand(server, "dnf", executor),
|
||||
hasCommand(server, "yum", executor),
|
||||
hasCommand(server, "zypper", executor),
|
||||
hasCommand(server, "apk", executor),
|
||||
server.username === "root"
|
||||
? Promise.resolve(true)
|
||||
: executor(server, "sudo -n true >/dev/null 2>&1"),
|
||||
]);
|
||||
|
||||
const packageManager = aptGet
|
||||
? "apt-get"
|
||||
: dnf
|
||||
? "dnf"
|
||||
: yum
|
||||
? "yum"
|
||||
: zypper
|
||||
? "zypper"
|
||||
: apk
|
||||
? "apk"
|
||||
: null;
|
||||
|
||||
const sudoNonInteractive =
|
||||
server.username === "root"
|
||||
? true
|
||||
: !!sudoCheck && typeof sudoCheck !== "boolean"
|
||||
? sudoCheck.code === 0
|
||||
: false;
|
||||
|
||||
return {
|
||||
distro: osRelease.stdout.trim() || null,
|
||||
packageManager,
|
||||
sudoNonInteractive,
|
||||
supportedForFail2banInstall:
|
||||
!!packageManager && (server.username === "root" || sudoNonInteractive),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDockerError(output: string): string {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {
|
||||
return "Docker is not ready on this server";
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("cannot connect to the docker daemon")) {
|
||||
return "Docker is installed but the daemon is not running";
|
||||
}
|
||||
|
||||
if (lower.includes("permission denied")) {
|
||||
return "Docker is installed but the current SSH user cannot access the Docker daemon";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function getDockerRuntimeStatus(
|
||||
server: Server,
|
||||
options: RuntimeProbeOptions = {},
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const executor = resolveCommandExecutor(options);
|
||||
const platform =
|
||||
options.platform ?? (await detectServerPlatform(server, options));
|
||||
const installed = await hasCommand(server, "docker", executor);
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed: false,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: "Docker CLI was not found on this server",
|
||||
canInstall:
|
||||
!!platform.packageManager &&
|
||||
(server.username === "root" || platform.sudoNonInteractive),
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
const directResult = await executor(
|
||||
server,
|
||||
"docker info --format '{{.ServerVersion}}' 2>&1",
|
||||
);
|
||||
|
||||
if (directResult.code === 0) {
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: true,
|
||||
available: true,
|
||||
version: directResult.stdout.trim() || null,
|
||||
reason: null,
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.username !== "root" && platform.sudoNonInteractive) {
|
||||
const sudoResult = await executor(
|
||||
server,
|
||||
`sudo -n docker info --format '{{.ServerVersion}}' 2>&1`,
|
||||
);
|
||||
|
||||
if (sudoResult.code === 0) {
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: true,
|
||||
available: true,
|
||||
version: sudoResult.stdout.trim() || null,
|
||||
reason: null,
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: normalizeDockerError(sudoResult.stderr || sudoResult.stdout),
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: normalizeDockerError(directResult.stderr || directResult.stdout),
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { closeConnection } from "./connection";
|
||||
import { exec, execStrict } from "./commands";
|
||||
import type { DockerRuntimeStatus, ServerPlatformInfo } from "./platform";
|
||||
import {
|
||||
createDockerProbeFailureStatus,
|
||||
detectServerPlatform,
|
||||
getDockerRuntimeStatus,
|
||||
UNKNOWN_SERVER_PLATFORM,
|
||||
} from "./platform";
|
||||
import type { ServerWebCapability } from "./web-stack";
|
||||
import {
|
||||
getInspectableHostServices,
|
||||
inspectWebServerCapability,
|
||||
} from "./web-stack";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
const CONFIG_FAST_TIMEOUT_MS = 8_000;
|
||||
const CONFIG_LIST_TIMEOUT_MS = 12_000;
|
||||
const CONFIG_DOCKER_TIMEOUT_MS = 18_000;
|
||||
const CONFIG_WEB_STACK_TIMEOUT_MS = 18_000;
|
||||
|
||||
function configCommandTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface ServerSystemUser {
|
||||
username: string;
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
home: string | null;
|
||||
shell: string | null;
|
||||
groups: string[];
|
||||
isRoot: boolean;
|
||||
isSshUser: boolean;
|
||||
}
|
||||
|
||||
export interface ServerServiceStatus {
|
||||
name: string;
|
||||
active: string;
|
||||
enabled: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface ServerDiskMount {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
size: string;
|
||||
used: string;
|
||||
available: string;
|
||||
usedPercent: string;
|
||||
mountPoint: string;
|
||||
}
|
||||
|
||||
export interface ServerConfigSnapshot {
|
||||
hostname: string | null;
|
||||
os: string | null;
|
||||
kernel: string | null;
|
||||
currentUser: string | null;
|
||||
serverUser: string;
|
||||
users: ServerSystemUser[];
|
||||
rootUser: ServerSystemUser | null;
|
||||
nonRootUsers: ServerSystemUser[];
|
||||
hasRootUser: boolean;
|
||||
sudoNonInteractive: boolean;
|
||||
docker: DockerRuntimeStatus;
|
||||
services: ServerServiceStatus[];
|
||||
webServer: ServerWebCapability;
|
||||
diskMounts: ServerDiskMount[];
|
||||
lastBoot: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export async function getServerConfigSnapshot(
|
||||
server: Server,
|
||||
): Promise<ServerConfigSnapshot> {
|
||||
const usersScript = [
|
||||
"while IFS=: read -r user _ uid gid _ home shell; do",
|
||||
' if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then',
|
||||
' groups=$(id -nG "$user" 2>/dev/null || true)',
|
||||
' printf \'%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups"',
|
||||
" fi",
|
||||
"done < /etc/passwd",
|
||||
].join("\n");
|
||||
|
||||
const platformResult = await Promise.allSettled([
|
||||
detectServerPlatform(server, configCommandTimeout(CONFIG_FAST_TIMEOUT_MS)),
|
||||
]);
|
||||
const platform =
|
||||
platformResult[0]?.status === "fulfilled"
|
||||
? platformResult[0].value
|
||||
: UNKNOWN_SERVER_PLATFORM;
|
||||
|
||||
const [
|
||||
hostnameResult,
|
||||
kernelResult,
|
||||
currentUserResult,
|
||||
usersResult,
|
||||
lastBootResult,
|
||||
servicesResult,
|
||||
diskMountsResult,
|
||||
dockerResult,
|
||||
] = await Promise.allSettled([
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "hostnamectl --static 2>/dev/null || hostname 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "uname -r 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "whoami 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(usersScript)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
"bash -lc \"uptime -s 2>/dev/null || who -b 2>/dev/null | sed 's/.*system boot[[:space:]]*//'\"",
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
listServerServices(server),
|
||||
listDiskMounts(server),
|
||||
getDockerRuntimeStatus(server, {
|
||||
...configCommandTimeout(CONFIG_DOCKER_TIMEOUT_MS),
|
||||
platform,
|
||||
}),
|
||||
]);
|
||||
|
||||
const services =
|
||||
servicesResult.status === "fulfilled" ? servicesResult.value : [];
|
||||
const diskMounts =
|
||||
diskMountsResult.status === "fulfilled" ? diskMountsResult.value : [];
|
||||
const docker =
|
||||
dockerResult.status === "fulfilled"
|
||||
? dockerResult.value
|
||||
: createDockerProbeFailureStatus(
|
||||
dockerResult.reason instanceof Error
|
||||
? dockerResult.reason.message
|
||||
: "Docker status probe failed while loading server config",
|
||||
platform,
|
||||
);
|
||||
|
||||
const webServerResult = await Promise.allSettled([
|
||||
inspectWebServerCapability(
|
||||
server,
|
||||
platform,
|
||||
services,
|
||||
configCommandTimeout(CONFIG_WEB_STACK_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
const webServer =
|
||||
webServerResult[0]?.status === "fulfilled"
|
||||
? webServerResult[0].value
|
||||
: createUnavailableWebCapability(platform, webServerResult[0]?.reason);
|
||||
|
||||
const usersOutput =
|
||||
usersResult.status === "fulfilled" ? usersResult.value.stdout : "";
|
||||
const users = usersOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [username, uidRaw, gidRaw, homeRaw, shellRaw, groupsRaw] =
|
||||
line.split("|");
|
||||
const uid = Number(uidRaw);
|
||||
const gid = Number(gidRaw);
|
||||
|
||||
return {
|
||||
username,
|
||||
uid: Number.isFinite(uid) ? uid : null,
|
||||
gid: Number.isFinite(gid) ? gid : null,
|
||||
home: homeRaw || null,
|
||||
shell: shellRaw || null,
|
||||
groups: (groupsRaw || "")
|
||||
.split(/\s+/)
|
||||
.map((group) => group.trim())
|
||||
.filter(Boolean),
|
||||
isRoot: uid === 0 || username === "root",
|
||||
isSshUser: username === server.username,
|
||||
} satisfies ServerSystemUser;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.isRoot && !right.isRoot) return -1;
|
||||
if (!left.isRoot && right.isRoot) return 1;
|
||||
return left.username.localeCompare(right.username);
|
||||
});
|
||||
|
||||
const rootUser = users.find((user) => user.isRoot) ?? null;
|
||||
const nonRootUsers = users.filter((user) => !user.isRoot);
|
||||
|
||||
return {
|
||||
hostname:
|
||||
hostnameResult.status === "fulfilled"
|
||||
? hostnameResult.value.stdout.trim() || null
|
||||
: null,
|
||||
os: server.os || docker.platform.distro || null,
|
||||
kernel:
|
||||
kernelResult.status === "fulfilled"
|
||||
? kernelResult.value.stdout.trim() || null
|
||||
: null,
|
||||
currentUser:
|
||||
currentUserResult.status === "fulfilled"
|
||||
? currentUserResult.value.stdout.trim() || null
|
||||
: null,
|
||||
serverUser: server.username,
|
||||
users,
|
||||
rootUser,
|
||||
nonRootUsers,
|
||||
hasRootUser: Boolean(rootUser),
|
||||
sudoNonInteractive: docker.platform.sudoNonInteractive,
|
||||
docker,
|
||||
services,
|
||||
webServer,
|
||||
diskMounts,
|
||||
lastBoot:
|
||||
lastBootResult.status === "fulfilled"
|
||||
? lastBootResult.value.stdout.trim() || null
|
||||
: null,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createUnavailableWebCapability(
|
||||
platform: ServerPlatformInfo,
|
||||
reason: unknown,
|
||||
): ServerWebCapability {
|
||||
const message =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Web stack probe failed while loading server config";
|
||||
|
||||
return {
|
||||
ready: false,
|
||||
summary: "Web stack status unavailable",
|
||||
notes: [message],
|
||||
packageManager: platform.packageManager,
|
||||
canManage: false,
|
||||
primaryWebServer: null,
|
||||
support: {
|
||||
staticSites: false,
|
||||
phpApps: false,
|
||||
javascriptApps: false,
|
||||
sslAutomation: false,
|
||||
processManager: false,
|
||||
relationalDatabase: false,
|
||||
cache: false,
|
||||
},
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listServerServices(
|
||||
server: Server,
|
||||
): Promise<ServerServiceStatus[]> {
|
||||
const serviceCandidates = getInspectableHostServices();
|
||||
const script = [
|
||||
"command -v systemctl >/dev/null 2>&1 || exit 0",
|
||||
`for svc in ${serviceCandidates.join(" ")}; do`,
|
||||
' load_state=$(systemctl show "$svc" --property=LoadState --value 2>/dev/null || true)',
|
||||
' if [ -n "$load_state" ] && [ "$load_state" != "not-found" ]; then',
|
||||
' active=$(systemctl is-active "$svc" 2>/dev/null || echo inactive)',
|
||||
' enabled=$(systemctl is-enabled "$svc" 2>/dev/null || echo unknown)',
|
||||
' description=$(systemctl show "$svc" --property=Description --value 2>/dev/null || true)',
|
||||
' printf "%s|%s|%s|%s\\n" "$svc" "$active" "$enabled" "$description"',
|
||||
" fi",
|
||||
"done",
|
||||
].join("\n");
|
||||
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
if (!result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, active, enabled, description] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
active: active || "inactive",
|
||||
enabled: enabled || "unknown",
|
||||
description: description || null,
|
||||
} satisfies ServerServiceStatus;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDiskMounts(
|
||||
server: Server,
|
||||
): Promise<ServerDiskMount[]> {
|
||||
const primaryCommand = [
|
||||
"if command -v findmnt >/dev/null 2>&1; then",
|
||||
" findmnt -J -b -o SOURCE,FSTYPE,SIZE,USED,AVAIL,USE%,TARGET 2>/dev/null",
|
||||
"fi",
|
||||
].join("\n");
|
||||
const primaryResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(primaryCommand)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (primaryResult.stdout.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(primaryResult.stdout) as {
|
||||
filesystems?: Array<{
|
||||
source?: string;
|
||||
fstype?: string;
|
||||
size?: string | number;
|
||||
used?: string | number;
|
||||
avail?: string | number;
|
||||
"use%"?: string;
|
||||
target?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const mounts = (parsed.filesystems ?? [])
|
||||
.filter(
|
||||
(filesystem) =>
|
||||
filesystem.target &&
|
||||
!["tmpfs", "devtmpfs"].includes(filesystem.fstype ?? ""),
|
||||
)
|
||||
.map((filesystem) => ({
|
||||
filesystem: filesystem.source || "—",
|
||||
type: filesystem.fstype || "—",
|
||||
size: String(filesystem.size ?? "—"),
|
||||
used: String(filesystem.used ?? "—"),
|
||||
available: String(filesystem.avail ?? "—"),
|
||||
usedPercent: filesystem["use%"] || "—",
|
||||
mountPoint: filesystem.target || "—",
|
||||
}))
|
||||
.filter((mount) => mount.mountPoint !== "—");
|
||||
|
||||
if (mounts.length > 0) {
|
||||
return mounts;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to df parsing below when findmnt JSON is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCommand =
|
||||
'df -hPT -x tmpfs -x devtmpfs 2>/dev/null | awk \'NR>1 {print $1 "|" $2 "|" $3 "|" $4 "|" $5 "|" $6 "|" substr($0, index($0,$7))}\'';
|
||||
const fallbackResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(fallbackCommand)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!fallbackResult.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fallbackResult.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [filesystem, type, size, used, available, usedPercent, mountPoint] =
|
||||
line.split("|");
|
||||
return {
|
||||
filesystem: filesystem || "—",
|
||||
type: type || "—",
|
||||
size: size || "—",
|
||||
used: used || "—",
|
||||
available: available || "—",
|
||||
usedPercent: usedPercent || "—",
|
||||
mountPoint: mountPoint || "—",
|
||||
} satisfies ServerDiskMount;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetServer(server: Server): Promise<void> {
|
||||
const rebootScript =
|
||||
"( sleep 2; (shutdown -r now || systemctl reboot || reboot) ) >/dev/null 2>&1 &";
|
||||
const result = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(rebootScript)}`),
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Failed to reset server");
|
||||
}
|
||||
|
||||
closeConnection(server.id);
|
||||
}
|
||||
|
||||
export async function restartNginx(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
'bash -lc "systemctl restart nginx || systemctl restart apache2 || systemctl restart httpd"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadNginx(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
'bash -lc "nginx -t && (systemctl reload nginx || nginx -s reload || systemctl restart nginx || systemctl restart apache2 || systemctl restart httpd)"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartManagedService(
|
||||
server: Server,
|
||||
serviceName: string,
|
||||
): Promise<void> {
|
||||
const normalized = serviceName.trim().toLowerCase();
|
||||
const allowedServices = new Set([
|
||||
"docker",
|
||||
"fail2ban",
|
||||
"caddy",
|
||||
"nginx",
|
||||
"apache2",
|
||||
"httpd",
|
||||
"ssh",
|
||||
"sshd",
|
||||
"ufw",
|
||||
]);
|
||||
|
||||
if (!allowedServices.has(normalized)) {
|
||||
throw new Error(`Service ${serviceName} is not allowed for restart`);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === "nginx" ||
|
||||
normalized === "apache2" ||
|
||||
normalized === "httpd"
|
||||
) {
|
||||
await restartNginx(server);
|
||||
return;
|
||||
}
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`systemctl restart ${normalized}`)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function rebootServer(server: Server): Promise<void> {
|
||||
await resetServer(server);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec, execStrict } from "./commands";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
import {
|
||||
buildCertbotEmailFlag,
|
||||
resolveCertbotCommand,
|
||||
} from "./internal/certbot";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: ssl).
|
||||
|
||||
export interface SslCertificateResult {
|
||||
issuer: string;
|
||||
certPem: string;
|
||||
keyPem: string;
|
||||
issuedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SslRenewStepTimings {
|
||||
resolveCertificateMs: number;
|
||||
certbotRenewMs: number;
|
||||
readCertificateMs: number;
|
||||
fallbackIssueMs?: number;
|
||||
certbotLockWaitMs?: number;
|
||||
certbotAttempts?: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface SslRenewCertificateResult extends SslCertificateResult {
|
||||
timings: SslRenewStepTimings;
|
||||
strategy: "renew" | "fallback-issue";
|
||||
}
|
||||
|
||||
export interface SslRenewProgress {
|
||||
stage: string;
|
||||
message: string;
|
||||
timings?: Partial<SslRenewStepTimings>;
|
||||
}
|
||||
|
||||
export interface DiscoveredSslCertificate extends SslCertificateResult {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
}
|
||||
|
||||
export interface DeletedSslCertificateResult {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
deletedFromServer: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedSslCertificate {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
}
|
||||
|
||||
function parseCertbotCertificateLineages(
|
||||
output: string,
|
||||
): ResolvedSslCertificate[] {
|
||||
const results: ResolvedSslCertificate[] = [];
|
||||
let currentCertName: string | null = null;
|
||||
let currentDomainNames: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!currentCertName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const certName = normalizeDomainName(currentCertName) ?? currentCertName;
|
||||
const domainNames = Array.from(
|
||||
new Set(
|
||||
currentDomainNames
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
|
||||
if (certName && domainNames.length > 0) {
|
||||
results.push({ certName, domainNames });
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith("Certificate Name:")) {
|
||||
flush();
|
||||
currentCertName = line.slice("Certificate Name:".length).trim();
|
||||
currentDomainNames = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("Domains:")) {
|
||||
currentDomainNames = line
|
||||
.slice("Domains:".length)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return results;
|
||||
}
|
||||
|
||||
async function listCertbotCertificateLineages(
|
||||
server: Server,
|
||||
): Promise<ResolvedSslCertificate[]> {
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
const result = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${certbotBinary} certificates 2>&1`),
|
||||
);
|
||||
const output = `${result.stdout}\n${result.stderr}`.trim();
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(output || "certbot certificates failed");
|
||||
}
|
||||
|
||||
return parseCertbotCertificateLineages(output);
|
||||
}
|
||||
|
||||
async function detectActiveWebServer(
|
||||
server: Server,
|
||||
): Promise<"nginx" | "apache2" | "httpd" | null> {
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'for svc in nginx apache2 httpd; do if systemctl is-active "$svc" >/dev/null 2>&1; then echo "$svc"; break; fi; done',
|
||||
)}`,
|
||||
);
|
||||
const name = result.stdout.trim();
|
||||
if (name === "nginx") return "nginx";
|
||||
if (name === "apache2" || name === "httpd") return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMissingCertbotWebServerPlugin(output: string): boolean {
|
||||
return /requested (nginx|apache) plugin does not appear to be installed/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
async function issueCertificateWithTemporaryWebServerStop(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
serviceName: "nginx" | "apache2" | "httpd",
|
||||
options?: {
|
||||
forceRenewal?: boolean;
|
||||
certName?: string;
|
||||
domainNames?: string[];
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
const certName = options?.certName ?? domainName;
|
||||
const domainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
const standaloneCommand = [
|
||||
`${certbotCommand} certonly --standalone --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(domainName),
|
||||
...domainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(certName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
options?.forceRenewal ? "--force-renewal" : "--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
const script = [
|
||||
`systemctl stop ${escapeShellArg(serviceName)}`,
|
||||
`trap 'systemctl start ${escapeShellArg(serviceName)} >/dev/null 2>&1 || true' EXIT`,
|
||||
standaloneCommand,
|
||||
"status=$?",
|
||||
"trap - EXIT",
|
||||
`systemctl start ${escapeShellArg(serviceName)}`,
|
||||
"exit $status",
|
||||
].join("\n");
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(script)}`),
|
||||
);
|
||||
|
||||
return readIssuedCertificate(server, certName);
|
||||
}
|
||||
|
||||
function isPort80Conflict(output: string): boolean {
|
||||
return (
|
||||
output.includes("Could not bind TCP port 80") ||
|
||||
output.includes("Problem binding to port 80") ||
|
||||
output.includes("port 80 because it is already in use")
|
||||
);
|
||||
}
|
||||
|
||||
function isCertbotAlreadyRunning(output: string): boolean {
|
||||
return /another instance of certbot is already running/i.test(output);
|
||||
}
|
||||
|
||||
function isUnsupportedNoRandomSleepOption(output: string): boolean {
|
||||
return /(?:unrecognized arguments?|no such option|unknown option).*--no-random-sleep-on-renew/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isAcmeChallengeFailure(output: string): boolean {
|
||||
return /some challenges have failed|unauthorized|invalid response from|dns problem|no valid ip addresses found|nxdomain|servfail/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCertbotOutput(output: string): string {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-4)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
async function describeAcmeChallengeFailure(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
output: string,
|
||||
): Promise<string | null> {
|
||||
if (!isAcmeChallengeFailure(output)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookupResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
`if command -v getent >/dev/null 2>&1; then getent ahostsv4 ${escapeShellArg(domainName)} | awk '{print $1}' | sort -u; elif command -v host >/dev/null 2>&1; then host ${escapeShellArg(domainName)} | awk '/has address/ {print $4}' | sort -u; elif command -v nslookup >/dev/null 2>&1; then nslookup ${escapeShellArg(domainName)} 2>/dev/null | awk '/^Address: / {print $2}' | sort -u; fi`,
|
||||
)}`,
|
||||
).catch(() => null);
|
||||
|
||||
const resolvedIps = `${lookupResult?.stdout ?? ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((value, index, all) => all.indexOf(value) === index);
|
||||
|
||||
if (resolvedIps.length === 0) {
|
||||
return `Let's Encrypt validation failed because ${domainName} does not currently resolve to a public IPv4 address from the server perspective. Ensure the DNS record exists and has propagated before generating SSL.`;
|
||||
}
|
||||
|
||||
if (!resolvedIps.includes(server.ip.trim())) {
|
||||
return `Let's Encrypt validation failed because ${domainName} currently resolves to ${resolvedIps.join(", ")}, not to this server IP ${server.ip}. Update DNS or wait for propagation before generating SSL.`;
|
||||
}
|
||||
|
||||
return `Let's Encrypt validation still failed even though ${domainName} resolves to this server. Ensure port 80 is publicly reachable, firewall/security group allows HTTP, and any CDN/proxy forwards the HTTP-01 challenge to the origin.`;
|
||||
}
|
||||
|
||||
async function issueCertificateWithDetectedPlugin(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: {
|
||||
forceRenewal?: boolean;
|
||||
certName?: string;
|
||||
domainNames?: string[];
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
const webServer = await detectActiveWebServer(server);
|
||||
const domainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
|
||||
if (
|
||||
webServer !== "nginx" &&
|
||||
webServer !== "apache2" &&
|
||||
webServer !== "httpd"
|
||||
) {
|
||||
throw new Error(
|
||||
`Port 80 is already in use and no compatible web server plugin (nginx/apache) ` +
|
||||
`is active on ${server.name}. Please either free port 80 or ensure nginx/apache ` +
|
||||
`is running before issuing the certificate for ${domainName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pluginFlag = webServer === "nginx" ? "--nginx" : "--apache";
|
||||
const webServerLabel = webServer === "nginx" ? "nginx" : "apache";
|
||||
const pluginCommand = [
|
||||
`${certbotCommand} certonly ${pluginFlag} --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(domainName),
|
||||
...domainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(options?.certName ?? domainName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
options?.forceRenewal ? "--force-renewal" : "--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
try {
|
||||
await execStrict(server, privilegedCommand(server, pluginCommand));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (isMissingCertbotWebServerPlugin(message)) {
|
||||
try {
|
||||
return await issueCertificateWithTemporaryWebServerStop(
|
||||
server,
|
||||
domainName,
|
||||
webServer,
|
||||
options,
|
||||
);
|
||||
} catch (standaloneError) {
|
||||
const standaloneMessage =
|
||||
standaloneError instanceof Error
|
||||
? standaloneError.message
|
||||
: String(standaloneError);
|
||||
const challengeDiagnostic = await describeAcmeChallengeFailure(
|
||||
server,
|
||||
domainName,
|
||||
standaloneMessage,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
challengeDiagnostic
|
||||
? `${challengeDiagnostic} Certbot output: ${summarizeCertbotOutput(standaloneMessage)}`
|
||||
: `Port 80 is in use, the Certbot ${webServerLabel} plugin is not installed, and standalone fallback after temporarily stopping ${webServer} also failed: ${standaloneMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Port 80 is in use, so Certbot switched to the ${webServerLabel} plugin, ` +
|
||||
`but that also failed: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return readIssuedCertificate(server, options?.certName ?? domainName);
|
||||
}
|
||||
|
||||
function normalizeDomainName(value: string): string | null {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.;]+$/g, "");
|
||||
|
||||
if (!normalized) return null;
|
||||
if (normalized === "_" || normalized === "localhost") return null;
|
||||
if (normalized.includes("*") || normalized.startsWith("~")) return null;
|
||||
if (!normalized.includes(".")) return null;
|
||||
if (!/^[a-z0-9.-]+$/.test(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeIssuedDomainNames(domainNames: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
domainNames
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function readIssuedCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<SslCertificateResult> {
|
||||
const certPath = `/etc/letsencrypt/live/${domainName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${domainName}/privkey.pem`;
|
||||
|
||||
const [certPem, keyPem, issuerOut, startDateOut, endDateOut] =
|
||||
await Promise.all([
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `cat ${escapeShellArg(certPath)}`),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `cat ${escapeShellArg(keyPath)}`),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -issuer | sed 's/^issuer=//'`,
|
||||
),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -startdate | cut -d= -f2-`,
|
||||
),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -enddate | cut -d= -f2-`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const issuedAt = startDateOut.trim() ? new Date(startDateOut.trim()) : null;
|
||||
const expiresAt = endDateOut.trim() ? new Date(endDateOut.trim()) : null;
|
||||
|
||||
return {
|
||||
issuer: issuerOut.trim() || "Let's Encrypt",
|
||||
certPem,
|
||||
keyPem,
|
||||
issuedAt: issuedAt && !Number.isNaN(issuedAt.getTime()) ? issuedAt : null,
|
||||
expiresAt:
|
||||
expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSslCertificates(
|
||||
server: Server,
|
||||
): Promise<DiscoveredSslCertificate[]> {
|
||||
const certDirs = await exec(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'for dir in /etc/letsencrypt/live/*; do if [ -d "$dir" ]; then basename "$dir"; fi; done 2>/dev/null',
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
|
||||
const certNames = certDirs.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((name) => name !== "README");
|
||||
|
||||
if (certNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: DiscoveredSslCertificate[] = [];
|
||||
|
||||
for (const certName of certNames) {
|
||||
const certPath = `/etc/letsencrypt/live/${certName}/fullchain.pem`;
|
||||
const sanOutput = await exec(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -ext subjectAltName 2>/dev/null`,
|
||||
),
|
||||
);
|
||||
|
||||
const domainNames = Array.from(
|
||||
new Set(
|
||||
[
|
||||
certName,
|
||||
...Array.from(sanOutput.stdout.matchAll(/DNS:([^,\s]+)/g)).map(
|
||||
(match) => match[1] || "",
|
||||
),
|
||||
]
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
|
||||
if (domainNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = await readIssuedCertificate(server, certName);
|
||||
results.push({
|
||||
certName,
|
||||
domainNames,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort((left, right) =>
|
||||
left.certName.localeCompare(right.certName),
|
||||
);
|
||||
}
|
||||
|
||||
async function findSslCertificateForDomain(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<ResolvedSslCertificate | null> {
|
||||
const normalized = normalizeDomainName(domainName);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let certificates: ResolvedSslCertificate[] = [];
|
||||
|
||||
try {
|
||||
certificates = await listCertbotCertificateLineages(server);
|
||||
} catch {
|
||||
const discoveredCertificates = await listSslCertificates(server);
|
||||
certificates = discoveredCertificates.map(({ certName, domainNames }) => ({
|
||||
certName,
|
||||
domainNames,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
certificates.find((certificate) =>
|
||||
certificate.domainNames.some((name) => name === normalized),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<ResolvedSslCertificate | null> {
|
||||
const certificate = await findSslCertificateForDomain(server, domainName);
|
||||
if (!certificate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
certName: certificate.certName,
|
||||
domainNames: certificate.domainNames,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<DeletedSslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
|
||||
const certificate = await resolveSslCertificate(server, domainName);
|
||||
if (!certificate) {
|
||||
return {
|
||||
certName: domainName,
|
||||
domainNames: [domainName],
|
||||
deletedFromServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
const deleteCommand = [
|
||||
`${certbotCommand} delete --non-interactive`,
|
||||
`--cert-name ${escapeShellArg(certificate.certName)}`,
|
||||
].join(" ");
|
||||
|
||||
const deleteResult = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${deleteCommand} 2>&1`),
|
||||
);
|
||||
const deleteOutput = (deleteResult.stdout + deleteResult.stderr).trim();
|
||||
|
||||
if (deleteResult.code !== 0) {
|
||||
throw new Error(
|
||||
deleteOutput ||
|
||||
`certbot delete failed for certificate lineage ${certificate.certName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
certName: certificate.certName,
|
||||
domainNames: certificate.domainNames,
|
||||
deletedFromServer: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: {
|
||||
domainNames?: string[];
|
||||
certName?: string;
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const issuedDomainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
const primaryDomainName = normalizeDomainName(domainName);
|
||||
const certName = normalizeDomainName(options?.certName ?? domainName);
|
||||
|
||||
if (!primaryDomainName) {
|
||||
throw new Error(`Invalid domain name: ${domainName}`);
|
||||
}
|
||||
|
||||
if (issuedDomainNames.length === 0) {
|
||||
throw new Error("No valid exact domains were provided for SSL issuance");
|
||||
}
|
||||
|
||||
if (!issuedDomainNames.includes(primaryDomainName)) {
|
||||
issuedDomainNames.unshift(primaryDomainName);
|
||||
}
|
||||
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
|
||||
const certbotCommand = [
|
||||
`${certbotBinary} certonly --standalone --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(primaryDomainName),
|
||||
...issuedDomainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(certName ?? primaryDomainName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
"--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
const issueResult = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${certbotCommand} 2>&1`),
|
||||
);
|
||||
const issueOutput = (issueResult.stdout + issueResult.stderr).trim();
|
||||
|
||||
if (issueResult.code === 0) {
|
||||
return readIssuedCertificate(server, certName ?? primaryDomainName);
|
||||
}
|
||||
|
||||
if (isPort80Conflict(issueOutput)) {
|
||||
return issueCertificateWithDetectedPlugin(server, primaryDomainName, {
|
||||
certName: certName ?? primaryDomainName,
|
||||
domainNames: issuedDomainNames,
|
||||
});
|
||||
}
|
||||
|
||||
const challengeDiagnostic = await describeAcmeChallengeFailure(
|
||||
server,
|
||||
primaryDomainName,
|
||||
issueOutput,
|
||||
);
|
||||
|
||||
if (challengeDiagnostic) {
|
||||
throw new Error(
|
||||
`${challengeDiagnostic} Certbot output: ${summarizeCertbotOutput(issueOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
issueOutput || `certbot issue failed for ${primaryDomainName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function renewSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: { onProgress?: (progress: SslRenewProgress) => void },
|
||||
): Promise<SslRenewCertificateResult> {
|
||||
const certbotLockRetryIntervalMs = 5000;
|
||||
const certbotLockRetryTimeoutMs = 3 * 60 * 1000;
|
||||
const totalStartedAt = Date.now();
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
const resolveStartedAt = Date.now();
|
||||
const certificate = await resolveSslCertificate(server, domainName);
|
||||
const resolveCertificateMs = Date.now() - resolveStartedAt;
|
||||
|
||||
if (!certificate) {
|
||||
throw new Error(
|
||||
`certificate not found on server ${server.name} for domain ${domainName}`,
|
||||
);
|
||||
}
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message: `Resolved certificate lineage ${certificate.certName}; starting Certbot renewal`,
|
||||
timings: { resolveCertificateMs },
|
||||
});
|
||||
|
||||
// Step 1: Use `certbot renew --cert-name` which reads the saved renewal config
|
||||
// (respects the original authenticator - webroot, nginx, apache, etc.)
|
||||
// Disable Certbot's random sleep because this is a user-triggered action.
|
||||
const buildRenewCommand = (includeNoRandomSleep: boolean) =>
|
||||
privilegedCommand(
|
||||
server,
|
||||
[
|
||||
`${certbotBinary} renew`,
|
||||
`--cert-name ${escapeShellArg(certificate.certName)}`,
|
||||
"--force-renewal",
|
||||
"--non-interactive",
|
||||
includeNoRandomSleep ? "--no-random-sleep-on-renew" : "",
|
||||
"2>&1",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
let renewCommand = buildRenewCommand(true);
|
||||
let retriedWithoutNoRandomSleep = false;
|
||||
let renewResult;
|
||||
let renewOutput = "";
|
||||
let certbotRenewMs = 0;
|
||||
let certbotLockWaitMs = 0;
|
||||
let certbotAttempts = 0;
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message: `Running Certbot renewal for ${certificate.certName}`,
|
||||
timings: { resolveCertificateMs },
|
||||
});
|
||||
|
||||
/*
|
||||
* Older Certbot builds may not understand --no-random-sleep-on-renew.
|
||||
* Retry without it instead of failing the renewal on legacy hosts.
|
||||
*/
|
||||
const retryWithoutNoRandomSleep = () => {
|
||||
retriedWithoutNoRandomSleep = true;
|
||||
renewCommand = buildRenewCommand(false);
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message:
|
||||
"Certbot on this server does not support no-random-sleep; retrying with legacy flags",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
certbotAttempts += 1;
|
||||
const certbotAttemptStartedAt = Date.now();
|
||||
renewResult = await exec(server, renewCommand);
|
||||
certbotRenewMs += Date.now() - certbotAttemptStartedAt;
|
||||
renewOutput = (renewResult.stdout + renewResult.stderr).trim();
|
||||
|
||||
if (
|
||||
!retriedWithoutNoRandomSleep &&
|
||||
isUnsupportedNoRandomSleepOption(renewOutput)
|
||||
) {
|
||||
retryWithoutNoRandomSleep();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isCertbotAlreadyRunning(renewOutput)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (certbotLockWaitMs >= certbotLockRetryTimeoutMs) {
|
||||
throw new Error(
|
||||
`Another Certbot task is still running on ${server.name} after waiting ${Math.round(certbotLockWaitMs / 1000)}s. This usually means a cron/systemd/manual Certbot renewal is already in progress. Retry after that task finishes. Last output: ${renewOutput}`,
|
||||
);
|
||||
}
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_LOCK_WAIT",
|
||||
message: `Another Certbot task is running on ${server.name}; waiting ${certbotLockRetryIntervalMs / 1000}s before retry ${certbotAttempts + 1}`,
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs: certbotLockWaitMs + certbotLockRetryIntervalMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
|
||||
await wait(certbotLockRetryIntervalMs);
|
||||
certbotLockWaitMs += certbotLockRetryIntervalMs;
|
||||
}
|
||||
|
||||
const hasPort80Conflict = isPort80Conflict(renewOutput);
|
||||
|
||||
if (!hasPort80Conflict && renewResult.code === 0) {
|
||||
const readStartedAt = Date.now();
|
||||
const renewedCertificate = await readIssuedCertificate(
|
||||
server,
|
||||
certificate.certName,
|
||||
);
|
||||
const readCertificateMs = Date.now() - readStartedAt;
|
||||
|
||||
return {
|
||||
...renewedCertificate,
|
||||
strategy: "renew",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
readCertificateMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
totalMs: Date.now() - totalStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasPort80Conflict) {
|
||||
// Some other certbot error — surface it directly
|
||||
throw new Error(renewOutput || `certbot renew failed for ${domainName}`);
|
||||
}
|
||||
|
||||
const fallbackStartedAt = Date.now();
|
||||
options?.onProgress?.({
|
||||
stage: "FALLBACK_ISSUE",
|
||||
message: `Port 80 is busy; switching to detected web server plugin for ${domainName}`,
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
const renewedCertificate = await issueCertificateWithDetectedPlugin(
|
||||
server,
|
||||
domainName,
|
||||
{
|
||||
certName: certificate.certName,
|
||||
forceRenewal: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...renewedCertificate,
|
||||
strategy: "fallback-issue",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
readCertificateMs: 0,
|
||||
fallbackIssueMs: Date.now() - fallbackStartedAt,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
totalMs: Date.now() - totalStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
// Backward-compatibility barrel.
|
||||
//
|
||||
// Implementasi publik SSH sudah dipindahkan per domain ke folder
|
||||
// `./ssh-services/*`. File monolitik lama disimpan di `./backup/ssh.service.ts`
|
||||
// sebagai referensi migrasi. Semua export publik tetap dipertahankan lewat
|
||||
// re-export di bawah ini.
|
||||
|
||||
export * from "./ssh-services";
|
||||
@@ -0,0 +1,80 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const TERMINAL_SOCKET_TICKET_TTL_MS = 60 * 1000;
|
||||
|
||||
export interface TerminalSocketTicket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const terminalSocketTickets = new Map<string, TerminalSocketTicket>();
|
||||
|
||||
function pruneExpiredTerminalSocketTickets(now = Date.now()) {
|
||||
for (const [ticketId, ticket] of terminalSocketTickets.entries()) {
|
||||
if (ticket.expiresAt <= now) {
|
||||
terminalSocketTickets.delete(ticketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function issueTerminalSocketTicket(input: {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
}): TerminalSocketTicket {
|
||||
const now = Date.now();
|
||||
pruneExpiredTerminalSocketTickets(now);
|
||||
|
||||
const ticket: TerminalSocketTicket = {
|
||||
id: randomUUID(),
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId,
|
||||
serverId: input.serverId,
|
||||
sessionId: input.sessionId,
|
||||
createdAt: now,
|
||||
expiresAt: now + TERMINAL_SOCKET_TICKET_TTL_MS,
|
||||
};
|
||||
|
||||
terminalSocketTickets.set(ticket.id, ticket);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export function consumeTerminalSocketTicket(input: {
|
||||
ticketId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
}): TerminalSocketTicket | null {
|
||||
const now = Date.now();
|
||||
pruneExpiredTerminalSocketTickets(now);
|
||||
|
||||
if (!input.ticketId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ticket = terminalSocketTickets.get(input.ticketId);
|
||||
terminalSocketTickets.delete(input.ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ticket.expiresAt <= now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ticket.serverId !== input.serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((ticket.sessionId ?? undefined) !== (input.sessionId ?? undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { generateSecret, generateURI, verify } from "otplib";
|
||||
import QRCode from "qrcode";
|
||||
import { decrypt, encrypt } from "../lib/crypto";
|
||||
|
||||
export async function createTwoFactorSetup(input: {
|
||||
accountEmail: string;
|
||||
issuer: string;
|
||||
}) {
|
||||
const secret = generateSecret();
|
||||
const otpauthUrl = generateURI({
|
||||
issuer: input.issuer,
|
||||
label: input.accountEmail,
|
||||
secret,
|
||||
});
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl, {
|
||||
margin: 1,
|
||||
width: 220,
|
||||
});
|
||||
|
||||
return {
|
||||
secret,
|
||||
encryptedSecret: encrypt(secret),
|
||||
otpauthUrl,
|
||||
qrCodeDataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorToken(secret: string, token: string) {
|
||||
const result = await verify({
|
||||
token: token.replace(/\s+/g, ""),
|
||||
secret,
|
||||
});
|
||||
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
export async function verifyEncryptedTwoFactorToken(
|
||||
encryptedSecret: string,
|
||||
token: string,
|
||||
) {
|
||||
return verifyTwoFactorToken(decrypt(encryptedSecret), token);
|
||||
}
|
||||
|
||||
export function revealManualEntryKey(encryptedSecret: string) {
|
||||
return decrypt(encryptedSecret);
|
||||
}
|
||||
Reference in new issue
Block a user