first commit

This commit is contained in:
KodekaTeam committed 2026-06-02 00:17:50 +07:00
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";
+23
View File
@@ -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.
+214
View File
@@ -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",
},
};
}
+53
View File
@@ -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",
},
];
+3
View File
@@ -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;
});
}
+136
View File
@@ -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);
}
+122
View File
@@ -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);
}
+38
View File
@@ -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;
}
+126
View File
@@ -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);
}
}
+113
View File
@@ -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,
},
};
}
+997
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;");
}
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");
}
}
+135
View File
@@ -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),
);
}
+832
View File
@@ -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}`),
);
}
+13
View File
@@ -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 };
+146
View File
@@ -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);
}
+821
View File
@@ -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
+8
View File
@@ -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;
}
+46
View File
@@ -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);
}