first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
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
Reference in new issue
Block a user