import * as plugins from '../plugins.js'; import { canonicalizeSourceRouteId, type IHttpRedirectInfo, } from '../../ts_interfaces/data/route-management.js'; import type { IDcRouterRouteConfig, IRouteIngress, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js'; const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect'; const REDIRECT_STATUS_CODE = 301; const REDIRECT_PRIORITY = 0; const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}'; const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000; interface IRedirectCandidate { key: string; id: string; domainPattern: string; pathPattern?: string; sourceRouteNames: Set; sourceRouteIds: Set; ingress: IRouteIngress; remoteIngress?: IRouteRemoteIngress; } interface IRedirectConflictAssessment { status: 'covered' | 'skipped'; routeNames: string[]; } export interface IHttpRedirectDerivationResult { redirects: IHttpRedirectInfo[]; runtimeRoutes: IDcRouterRouteConfig[]; } export function deriveHttpRedirectConfiguration( routes: plugins.smartproxy.IRouteConfig[], ): IHttpRedirectDerivationResult { const candidates = collectRedirectCandidates(routes); const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route)); const redirects: IHttpRedirectInfo[] = []; const runtimeRoutes: IDcRouterRouteConfig[] = []; for (const candidate of candidates) { const conflict = findHttpConflict(candidate, httpRoutes); const redirectInfo: IHttpRedirectInfo = { id: candidate.id, status: conflict?.status || 'active', domainPattern: candidate.domainPattern, pathPattern: candidate.pathPattern, fromTemplate: 'http://{domain}{path}', toTemplate: REDIRECT_TARGET_TEMPLATE, statusCode: REDIRECT_STATUS_CODE, priority: REDIRECT_PRIORITY, sourceRouteNames: [...candidate.sourceRouteNames].sort(), sourceRouteIds: [...candidate.sourceRouteIds].sort(), coveredByRouteNames: conflict?.routeNames || [], remoteIngress: Boolean(candidate.remoteIngress?.enabled), notes: conflict ? conflict.status === 'covered' ? 'An explicit HTTP route already covers this redirect scope.' : 'Skipped because one or more explicit HTTP routes overlap at equal or lower priority.' : undefined, }; redirects.push(redirectInfo); if (redirectInfo.status === 'active') { runtimeRoutes.push(buildRuntimeRedirectRoute(candidate)); } } return { redirects, runtimeRoutes }; } export function deriveHttpRedirects( routes: plugins.smartproxy.IRouteConfig[], ): IHttpRedirectInfo[] { return deriveHttpRedirectConfiguration(routes).redirects; } export function buildHttpRedirectRuntimeRoutes( routes: plugins.smartproxy.IRouteConfig[], ): IDcRouterRouteConfig[] { return deriveHttpRedirectConfiguration(routes).runtimeRoutes; } function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] { const candidates = new Map(); for (const route of routes) { if (!isHttpsRedirectSource(route)) { continue; } for (const domainPattern of getDomainPatterns(route)) { const key = createRedirectKey(domainPattern, route.match.path); const existing = candidates.get(key); if (existing) { existing.sourceRouteNames.add(getRouteDisplayName(route)); if (route.id) existing.sourceRouteIds.add(canonicalizeSourceRouteId(route.id)); existing.ingress = mergeIngress(existing.ingress, getRouteIngress(route as IDcRouterRouteConfig)); existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress); continue; } const id = createRedirectRouteName(domainPattern, route.match.path); candidates.set(key, { key, id, domainPattern, pathPattern: route.match.path, sourceRouteNames: new Set([getRouteDisplayName(route)]), sourceRouteIds: new Set(route.id ? [canonicalizeSourceRouteId(route.id)] : []), ingress: getRouteIngress(route as IDcRouterRouteConfig), remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress), }); } } return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id)); } function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean { if (isGeneratedRedirectRoute(route)) return false; if (route.enabled === false) return false; if (route.action.type !== 'forward') return false; if (!route.match.ports) return false; if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false; if (!route.action.tls) return false; if (!route.match.domains) return false; if (route.match.transport === 'udp') return false; if (route.match.protocol && route.match.protocol !== 'http') return false; if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false; return true; } function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean { if (isGeneratedRedirectRoute(route)) return false; if (route.enabled === false) return false; if (!route.match.ports) return false; if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false; if (route.match.transport === 'udp') return false; return true; } function findHttpConflict( candidate: IRedirectCandidate, httpRoutes: plugins.smartproxy.IRouteConfig[], ): IRedirectConflictAssessment | undefined { const overlaps = httpRoutes.filter((route) => httpRouteOverlapsCandidate(route, candidate)); const coveringRoutes = overlaps.filter((route) => httpRouteCoversCandidate(route, candidate)); if (coveringRoutes.length > 0) { return { status: 'covered', routeNames: coveringRoutes.map(getRouteDisplayName).sort(), }; } const unsafeOverlaps = overlaps.filter((route) => { const priority = typeof route.priority === 'number' ? route.priority : 0; return !route.match.path || priority <= REDIRECT_PRIORITY; }); if (unsafeOverlaps.length === 0) { return undefined; } return { status: 'skipped', routeNames: unsafeOverlaps.map(getRouteDisplayName).sort(), }; } function httpRouteOverlapsCandidate( route: plugins.smartproxy.IRouteConfig, candidate: IRedirectCandidate, ): boolean { return routeDomainOverlapsCandidate(route, candidate.domainPattern) && pathOverlaps(route.match.path, candidate.pathPattern); } function httpRouteCoversCandidate( route: plugins.smartproxy.IRouteConfig, candidate: IRedirectCandidate, ): boolean { if (route.match.clientIp || route.match.headers || route.match.tlsVersion) { return false; } return routeDomainCoversCandidate(route, candidate.domainPattern) && pathCovers(route.match.path, candidate.pathPattern); } function routeDomainOverlapsCandidate( route: plugins.smartproxy.IRouteConfig, candidatePattern: string, ): boolean { const routePatterns = getDomainPatterns(route); if (routePatterns.length === 0) { return true; } return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern)); } function routeDomainCoversCandidate( route: plugins.smartproxy.IRouteConfig, candidatePattern: string, ): boolean { const routePatterns = getDomainPatterns(route); if (routePatterns.length === 0) { return true; } return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern)); } function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] { if (!route.match.domains) return []; return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; } function normalizePattern(pattern: string): string { return pattern.trim().toLowerCase().replace(/\.$/, ''); } function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean { const cover = normalizePattern(coverPattern); const candidate = normalizePattern(candidatePattern); if (cover === candidate) return true; if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate); const coverSuffix = getLeadingWildcardSuffix(cover); const candidateSuffix = getLeadingWildcardSuffix(candidate); if (coverSuffix && candidateSuffix) { return candidateSuffix.endsWith(coverSuffix); } return false; } function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean { const first = normalizePattern(firstPattern); const second = normalizePattern(secondPattern); if (first === second) return true; if (!first.includes('*')) return domainPatternMatchesHostname(second, first); if (!second.includes('*')) return domainPatternMatchesHostname(first, second); const firstSuffix = getLeadingWildcardSuffix(first); const secondSuffix = getLeadingWildcardSuffix(second); if (firstSuffix && secondSuffix) { return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix); } return false; } function domainPatternMatchesHostname(pattern: string, hostname: string): boolean { const regex = wildcardPatternToRegex(normalizePattern(pattern)); return regex.test(normalizePattern(hostname)); } function wildcardPatternToRegex(pattern: string): RegExp { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i'); } function getLeadingWildcardSuffix(pattern: string): string | undefined { if (!pattern.startsWith('*')) return undefined; if (pattern.slice(1).includes('*')) return undefined; return pattern.slice(1); } function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean { if (!coverPath) return true; if (!candidatePath) return false; if (coverPath === candidatePath) return true; if (!coverPath.includes('*')) return false; const coverPrefix = coverPath.split('*')[0]; if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix); const candidatePrefix = candidatePath.split('*')[0]; return candidatePrefix.startsWith(coverPrefix); } function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean { if (!firstPath || !secondPath) return true; if (firstPath === secondPath) return true; const firstPrefix = firstPath.split('*')[0]; const secondPrefix = secondPath.split('*')[0]; return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix); } function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig { return { id: candidate.id, name: candidate.id, description: 'Generated HTTP to HTTPS redirect', priority: REDIRECT_PRIORITY, tags: ['system', 'redirect', 'auto'], match: { ports: 80, domains: candidate.domainPattern, ...(candidate.pathPattern ? { path: candidate.pathPattern } : {}), }, action: { type: 'socket-handler', socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE), }, ingress: candidate.ingress, ...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}), }; } function getRouteIngress(route: IDcRouterRouteConfig): IRouteIngress { if (route.ingress) { return { directHub: route.ingress.directHub, smartVpn: route.ingress.smartVpn, }; } if (route.vpnOnly) { return { directHub: false, smartVpn: true, }; } return { directHub: true, smartVpn: true, }; } function mergeIngress(current: IRouteIngress, next: IRouteIngress): IRouteIngress { return { directHub: current.directHub || next.directHub, smartVpn: current.smartVpn || next.smartVpn, }; } function mergeRemoteIngress( current: IRouteRemoteIngress | undefined, next: IRouteRemoteIngress | undefined, ): IRouteRemoteIngress | undefined { if (!next?.enabled) return current; if (!current?.enabled) { return { enabled: true, ...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}), }; } const currentFilter = current.edgeFilter || []; const nextFilter = next.edgeFilter || []; if (currentFilter.length === 0 || nextFilter.length === 0) { return { enabled: true }; } return { enabled: true, edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(), }; } function createRedirectKey(domainPattern: string, pathPattern?: string): string { return `${normalizePattern(domainPattern)}|${pathPattern || ''}`; } function createRedirectRouteName(domainPattern: string, pathPattern?: string): string { const key = createRedirectKey(domainPattern, pathPattern); const slug = key .replace(/\*/g, 'wildcard') .replace(/[^a-zA-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || 'route'; const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8); return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`; } function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string { return route.name || route.id || 'unnamed-route'; } function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean { return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX)); } function createHttpRedirectHandler( locationTemplate: string, statusCode: number, ): NonNullable { return (socket, context) => { const cleanup = () => { clearTimeout(timeout); socket.removeListener('data', handleData); socket.removeListener('error', cleanup); socket.removeListener('close', cleanup); }; const handleData = (data: string | Uint8Array) => { cleanup(); const request = parseHttpRequest(data); if (!request) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; } const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost'; const finalLocation = locationTemplate .replace('{domain}', domain) .replace('{port}', String(context.port)) .replace('{path}', request.path || '/') .replace('{clientIp}', context.clientIp); const message = `Redirecting to ${finalLocation}`; const response = [ `HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`, `Location: ${finalLocation}`, 'Content-Type: text/plain', `Content-Length: ${message.length}`, 'Connection: close', '', message, ].join('\r\n'); socket.end(response); }; const timeout = setTimeout(() => { cleanup(); socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n'); }, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType & { unref?: () => void }; timeout.unref?.(); socket.once('data', handleData); socket.once('error', cleanup); socket.once('close', cleanup); }; } function parseHttpRequest(data: string | Uint8Array): { method: string; path: string; headers: Record; } | undefined { const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data); const headerEnd = requestText.indexOf('\r\n\r\n'); const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText; const lines = headerText.split('\r\n'); const [method, rawPath] = (lines[0] || '').split(' '); if (!method || !rawPath) return undefined; const headers: Record = {}; for (const line of lines.slice(1)) { const colonIndex = line.indexOf(':'); if (colonIndex <= 0) continue; const key = line.slice(0, colonIndex).trim().toLowerCase(); const value = line.slice(colonIndex + 1).trim(); headers[key] = value; } return { method, path: normalizeRequestPath(rawPath), headers, }; } function normalizeRequestPath(rawPath: string): string { if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) { try { const url = new URL(rawPath); return `${url.pathname}${url.search}` || '/'; } catch { return '/'; } } return rawPath.startsWith('/') ? rawPath : '/'; } function normalizeHostHeader(hostHeader: string | undefined): string | undefined { if (!hostHeader) return undefined; const host = hostHeader.split(',')[0].trim(); if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined; if (host.startsWith('[')) { const bracketIndex = host.indexOf(']'); return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined; } return host.replace(/:(80|443)$/, ''); } function getHttpStatusText(statusCode: number): string { switch (statusCode) { case 301: return 'Moved Permanently'; case 302: return 'Found'; case 307: return 'Temporary Redirect'; case 308: return 'Permanent Redirect'; default: return 'Redirect'; } }