diff --git a/src/server/services/ssh-services/domains.ts b/src/server/services/ssh-services/domains.ts index 519ebb4..470696a 100644 --- a/src/server/services/ssh-services/domains.ts +++ b/src/server/services/ssh-services/domains.ts @@ -446,6 +446,80 @@ function extractNginxServerNameDomains(content: string): string[] { return Array.from(domains); } +export interface NginxDomainTarget { + name: string; + host: string | null; + port: number | null; +} + +function extractNginxServerBlocks(content: string): string[] { + const sanitized = content.replace(/^\s*#.*$/gm, ""); + const blocks: string[] = []; + const serverPattern = /\bserver\s*\{/gi; + + for (const match of sanitized.matchAll(serverPattern)) { + const matchIndex = match.index ?? -1; + const openingBrace = sanitized.indexOf("{", matchIndex); + if (openingBrace < 0) continue; + + let depth = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (let index = openingBrace; index < sanitized.length; index += 1) { + const character = sanitized[index]; + + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (quote) { + if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "{") { + depth += 1; + } else if (character === "}") { + depth -= 1; + if (depth === 0) { + blocks.push(sanitized.slice(matchIndex, index + 1)); + break; + } + } + } + } + + return blocks; +} + +export function extractNginxDomainTargets( + content: string, +): NginxDomainTarget[] { + const serverBlocks = extractNginxServerBlocks(content); + const scopes = serverBlocks.length > 0 ? serverBlocks : [content]; + const targets = new Map(); + + for (const scope of scopes) { + const proxyPassTarget = extractProxyPassTarget(scope); + for (const name of extractNginxServerNameDomains(scope)) { + const existing = targets.get(name); + if (!existing || (!existing.host && proxyPassTarget.host)) { + targets.set(name, { name, ...proxyPassTarget }); + } + } + } + + return Array.from(targets.values()); +} + async function listNginxDomains(server: Server): Promise { const [configDump, fileDump] = await Promise.all([ exec( @@ -501,12 +575,11 @@ async function listNginxDomains(server: Server): Promise { continue; } - const proxyPassTarget = extractProxyPassTarget(content); const managedMetadata = extractManagedNginxMetadata(content); - for (const domainName of extractNginxServerNameDomains(content)) { + for (const target of extractNginxDomainTargets(content)) { addDiscoveredDomain( domains, - domainName, + target.name, "NGINX", "NGINX", server.ip, @@ -517,14 +590,14 @@ async function listNginxDomains(server: Server): Promise { managedConfigMode: managedMetadata.managedConfigMode, managedPrimaryDomain: managedMetadata.managedPrimaryDomain, containerName: - deriveManagedNginxContainerName(path, domainName) ?? - (proxyPassTarget.host && - !isLocalhostHost(proxyPassTarget.host) && - !isLikelyIpv4Host(proxyPassTarget.host) && - !isLikelyIpv6Host(proxyPassTarget.host) - ? proxyPassTarget.host + deriveManagedNginxContainerName(path, target.name) ?? + (target.host && + !isLocalhostHost(target.host) && + !isLikelyIpv4Host(target.host) && + !isLikelyIpv6Host(target.host) + ? target.host : null), - targetPort: proxyPassTarget.port, + targetPort: target.port, }, ); } diff --git a/tests/backend/nginx-domain-sync.test.ts b/tests/backend/nginx-domain-sync.test.ts new file mode 100644 index 0000000..d6bb031 --- /dev/null +++ b/tests/backend/nginx-domain-sync.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { extractNginxDomainTargets } from "../../src/server/services/ssh-services/domains"; + +test("nginx sync resolves each shared domain from its own server block", () => { + const config = ` +# doktainer-managed: true +# doktainer-config-mode: SHARED +# doktainer-primary-domain: example.com +server { + listen 80; + server_name example.com; + + location / { + proxy_pass http://frontend:3000; + } +} + +server { + listen 80; + server_name api.example.com; + + location / { + proxy_pass http://backend:8080; + } +} +`; + + assert.deepEqual(extractNginxDomainTargets(config), [ + { name: "example.com", host: "frontend", port: 3000 }, + { name: "api.example.com", host: "backend", port: 8080 }, + ]); +}); + +test("nginx sync prefers the proxied HTTPS block over an HTTP redirect block", () => { + const config = ` +server { + listen 80; + server_name example.com api.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name example.com; + location / { proxy_pass http://frontend:3000; } +} + +server { + listen 443 ssl; + server_name api.example.com; + location / { proxy_pass http://backend:8080; } +} +`; + + assert.deepEqual(extractNginxDomainTargets(config), [ + { name: "example.com", host: "frontend", port: 3000 }, + { name: "api.example.com", host: "backend", port: 8080 }, + ]); +}); + +test("nginx sync keeps multiple names in one server block on the same target", () => { + const config = ` +server { + listen 80; + server_name example.com www.example.com; + location / { proxy_pass http://frontend:3000; } +} +`; + + assert.deepEqual(extractNginxDomainTargets(config), [ + { name: "example.com", host: "frontend", port: 3000 }, + { name: "www.example.com", host: "frontend", port: 3000 }, + ]); +});