import * as _ from 'lodash' ; import * as os from 'os' ; import * as path from 'path' ; import * as stream from 'stream' ; import * as net from 'net' ; import * as http from 'http' ; import * as Dockerode from 'dockerode' ; import * as getRawBody from 'raw-body' ; import { AbortController } from 'node-abort-controller' ; import { chmod , deleteFile , readDir } from '../../util/fs' ; import { rawHeadersToHeaders } from '../../util/http' ; import { destroyable , DestroyableServer } from '../../destroyable-server' ; import { reportError } from '../../error-tracking' ; import { addShutdownHandler } from '../../shutdown' ; import { isInterceptedContainer , transformContainerCreationConfig , DOCKER_HOST_HOSTNAME , getDockerHostIp } from './docker-commands' ; import { injectIntoBuildStream , getBuildOutputPipeline } from './docker-build-injection' ; import { ensureDockerServicesRunning , isDockerAvailable } from './docker-interception-services' ; import { transformComposeResponseLabels } from './docker-compose' ; export const getDockerPipePath = ( proxyPort , targetPlatform = process . platform ) => { if ( targetPlatform === 'win32' ) { return `//./pipe/httptoolkit-${ proxyPort }-docker` ; } else { return path . join ( os . tmpdir ( ) , `httptoolkit-${ proxyPort }-docker.sock` ) ; } } ; if ( process . platform !== 'win32' ) { addShutdownHandler ( async ( ) => Promise . all ( ( await readDir ( os . tmpdir ( ) ) ) . filter ( ( filename ) => filename . match ( /^httptoolkit-\d+-docker.sock$/ ) ) . map ( ( filename ) => deleteFile ( path . join ( os . tmpdir ( ) , filename ) ) ) ) ) ; } const API_VERSION_MATCH = /^\/v?([\.\d]+)\// ; const CREATE_CONTAINER_MATCHER = /^\/[^\/]+\/containers\/create/ ; const START_CONTAINER_MATCHER = /^\/[^\/]+\/containers\/([^\/]+)\/start/ ; const BUILD_IMAGE_MATCHER = /^\/[^\/]+\/build$/ ; const EVENTS_MATCHER = /^\/[^\/]+\/events$/ ; const ATTACH_CONTAINER_MATCHER = /^\/[^\/]+\/containers\/([^\/]+)\/attach/ ; const CONTAINER_LIST_MATCHER = /^\/[^\/]+\/containers\/json/ ; const CONTAINER_INSPECT_MATCHER = /^\/[^\/]+\/containers\/[^\/]+\/json/ ; const DOCKER_PROXY_MAP = { } ; export async function ensureDockerProxyRunning ( proxyPort , httpsConfig ) { if ( ! DOCKER_PROXY_MAP [ proxyPort ] ) { DOCKER_PROXY_MAP [ proxyPort ] = createDockerProxy ( proxyPort , httpsConfig ) ; } await DOCKER_PROXY_MAP [ proxyPort ] ; } ; export async function stopDockerProxy ( proxyPort ) { const proxy = await DOCKER_PROXY_MAP [ proxyPort ] ; if ( ! proxy ) return ; delete DOCKER_PROXY_MAP [ proxyPort ] ; await proxy . destroy ( ) ; } async function createDockerProxy ( proxyPort , httpsConfig ) { const docker = new Dockerode ( ) ; const modem = docker . modem as any as ( { socketPath : string ; } | { host : string ; port : number ; } ) ; const dockerHostOptions = 'socketPath' in modem ? { socketPath : modem . socketPath } : { host : modem . host , port : modem . port } ; const agent = new http . Agent ( { keepAlive : true } ) ; const sendToDocker = ( req , bodyStream = req ) => { const headers = rawHeadersToHeaders ( req . rawHeaders ) ; const dockerReq = http . request ( { ... dockerHostOptions , agent : agent , method : req . method , headers : _ . omitBy ( headers , ( _v , k ) => k . toLowerCase ( ) === 'content-length' ) , path : req . url , } ) ; bodyStream . pipe ( dockerReq ) ; return dockerReq ; } ; const server = http . createServer ( async ( req , res ) => { if ( ! await isDockerAvailable ( ) ) { res . writeHead ( 504 , "Docker not available" ) . end ( "HTTP Toolkit could not connect to Docker" ) ; return ; } let requestBodyStream = req ; const reqUrl = new URL ( req . url ! , 'http://localhost' ) ; const reqPath = reqUrl . pathname ; const dockerApiVersion = API_VERSION_MATCH . exec ( reqPath ) ?. [ 1 ] ; ensureDockerServicesRunning ( proxyPort ) ; if ( reqPath . match ( CREATE_CONTAINER_MATCHER ) ) { const body = await getRawBody ( req ) ; const config = JSON . parse ( body . toString ( 'utf8' ) ) as Dockerode . ContainerCreateOptions ; const imageConfig = await docker . getImage ( config . Image ! ) . inspect ( ) . catch ( ( ) => undefined ) ; const proxyHost = getDockerHostIp ( process . platform , { apiVersion : dockerApiVersion ! } ) ; const hasDockerComposeLabels = Object . keys ( config . Labels ?? [ ] ) . includes ( "com.docker.compose.version" ) ; const transformedConfig = transformContainerCreationConfig ( config , imageConfig , { interceptionType : 'mount' , certPath : httpsConfig . certPath , proxyPort , proxyHost } ) ; requestBodyStream = stream . Readable . from ( JSON . stringify ( transformedConfig ) ) ; } const startContainerMatch = START_CONTAINER_MATCHER . exec ( reqPath ) ; if ( startContainerMatch ) { const containerId = startContainerMatch [ 1 ] ; const containerData = await docker . getContainer ( containerId ) . inspect ( ) . catch ( ( ) => undefined ) ; if ( containerData && ! isInterceptedContainer ( containerData , proxyPort ) ) { res . writeHead ( 400 ) . end ( "HTTP Toolkit cannot intercept startup of preexisting non-intercepted containers. " + "The container must be recreated here first - try `docker run <image>` instead." ) ; } } let extraDockerCommandCount ; if ( reqPath . match ( BUILD_IMAGE_MATCHER ) ) { if ( reqUrl . searchParams . get ( 'remote' ) ) { res . writeHead ( 400 ) ; reportError ( "Build interception failed due to unsupported 'remote' param" ) ; if ( reqUrl . searchParams . get ( 'remote' ) === 'client-session' ) { res . end ( "HTTP Toolkit does not yet support BuildKit-powered builds" ) ; } else { res . end ( "HTTP Toolkit does not support intercepting remote build sources" ) ; } return ; } const dockerfileName = reqUrl . searchParams . get ( 'dockerfile' ) ?? 'Dockerfile' ; const streamInjection = await injectIntoBuildStream ( dockerfileName , req , { certContent : httpsConfig . certContent , proxyPort } ) ; requestBodyStream = streamInjection . injectedStream ; extraDockerCommandCount = streamInjection . totalCommandsAddedPromise ; if ( process . platform === 'linux' ) { reqUrl . searchParams . append ( 'extrahosts' , `${ DOCKER_HOST_HOSTNAME }:${ getDockerHostIp ( process . platform , { apiVersion : dockerApiVersion ! } ) }` ) ; req . url = reqUrl . toString ( ) ; } } const dockerReq = sendToDocker ( req , requestBodyStream ) ; dockerReq . on ( 'error' , ( e ) => { console . error ( 'Docker proxy error' , e ) ; res . destroy ( ) ; } ) ; dockerReq . on ( 'response' , async ( dockerRes ) => { res . on ( 'error' , ( e ) => { console . error ( 'Docker proxy conn error' , e ) ; dockerRes . destroy ( ) ; } ) ; const isContainerInspect = reqPath . match ( CONTAINER_INSPECT_MATCHER ) ; const isComposeContainerQuery = reqPath . match ( CONTAINER_LIST_MATCHER ) && reqUrl . searchParams . get ( 'filters' ) ?. includes ( "com.docker.compose" ) ; const shouldRemapContainerData = isContainerInspect || isComposeContainerQuery ; if ( shouldRemapContainerData ) { delete dockerRes . headers [ 'content-length' ] ; } res . writeHead ( dockerRes . statusCode ! , dockerRes . statusMessage , dockerRes . headers ) ; res . flushHeaders ( ) ; if ( reqPath . match ( BUILD_IMAGE_MATCHER ) && dockerRes . statusCode === 200 ) { dockerRes . pipe ( getBuildOutputPipeline ( await extraDockerCommandCount ! ) ) . pipe ( res ) ; } else if ( shouldRemapContainerData ) { const data = await new Promise < Buffer > ( ( resolve , reject ) => { const dataChunks = [ ] ; dockerRes . on ( 'data' , ( d ) => dataChunks . push ( d ) ) ; dockerRes . on ( 'end' , ( ) => resolve ( Buffer . concat ( dataChunks ) ) ) ; dockerRes . on ( 'error' , reject ) ; } ) ; try { if ( isComposeContainerQuery ) { const containerQueryResponse = JSON . parse ( data . toString ( 'utf8' ) ) ; const modifiedResponse = containerQueryResponse . map ( ( container ) => ( { ... container , Labels : transformComposeResponseLabels ( proxyPort , container . Labels ) } ) ) ; res . end ( JSON . stringify ( modifiedResponse ) ) ; } else { const containerInspectResponse = JSON . parse ( data . toString ( 'utf8' ) ) ; const modifiedResponse = { ... containerInspectResponse , Config : { ... containerInspectResponse . Config , Labels : transformComposeResponseLabels ( proxyPort , containerInspectResponse . Config ?. Labels ) } } ; res . end ( JSON . stringify ( modifiedResponse ) ) ; } } catch ( e ) { console . error ( "Failed to parse container data response" , e ) ; res . end ( data ) ; } } else { dockerRes . pipe ( res ) ; } } ) ; } ) ; server . on ( 'upgrade' , async ( req , socket , head ) => { if ( ! await isDockerAvailable ( ) ) { socket . end ( "HTTP/1.1 504 Docker not available\r\n\r\n" + "HTTP Toolkit could not connect to Docker\r\n" ) ; return ; } const dockerReq = sendToDocker ( req ) ; dockerReq . on ( 'error' , ( e ) => { console . error ( 'Docker proxy error' , e ) ; socket . destroy ( ) ; } ) ; socket . on ( 'error' , ( e ) => { console . error ( 'Docker proxy conn error' , e ) ; dockerReq . destroy ( ) ; } ) ; const attachMatch = ATTACH_CONTAINER_MATCHER . exec ( req . url ! ) ; const isPythonDockerCompose = ( req . headers [ 'user-agent' ] ?? '' ) . startsWith ( 'docker-compose/' ) ; if ( attachMatch && process . platform === 'win32' && ! isPythonDockerCompose ) { const abortController = new AbortController ( ) ; docker . getContainer ( attachMatch [ 1 ] ) . wait ( { condition : 'next-exit' , abortSignal : abortController . signal } ) . then ( ( ) => { setTimeout ( ( ) => { socket . end ( ) ; } , 500 ) ; } ) . catch ( ( err ) => { if ( abortController . signal . aborted ) return ; console . log ( "Error waiting for container exit on attach" , err ) ; } ) ; socket . on ( 'close' , ( ) => { abortController . abort ( ) ; } ) ; } dockerReq . on ( 'upgrade' , ( dockerRes , dockerSocket , dockerHead ) => { socket . write ( `HTTP/1.1 ${ dockerRes . statusCode } ${ dockerRes . statusMessage }\r\n` + Object . keys ( dockerRes . headers ) . map ( ( key ) => `${ key }: ${ dockerRes . headers [ key ] }\r\n` ) . join ( "" ) + "\r\n" ) ; if ( dockerHead . length ) socket . write ( dockerHead ) ; if ( head . length ) dockerSocket . write ( head ) ; dockerSocket . on ( 'error' , ( e ) => { console . error ( 'Docker proxy error' , e ) ; socket . destroy ( ) ; } ) ; socket . pipe ( dockerSocket ) ; dockerSocket . pipe ( socket ) ; } ) ; } ) ; const proxyListenPath = getDockerPipePath ( proxyPort ) ; if ( process . platform !== 'win32' ) { await deleteFile ( proxyListenPath ) . catch ( ( ) => { } ) ; } if ( process . platform === 'win32' ) { server . keepAliveTimeout = 1000 * 60 * 60 ; } await new Promise < void > ( ( resolve , reject ) => { server . listen ( proxyListenPath , resolve ) ; server . on ( 'error' , reject ) ; } ) ; if ( process . platform !== 'win32' ) { await chmod ( proxyListenPath , 0o700 ) ; } return destroyable ( server ) ; } ;