import * as _ from 'lodash' ; import * as stream from 'stream' ; import * as Docker from 'dockerode' ; import * as EventStream from 'event-stream' ; import * as mobx from 'mobx' ; import { reportError } from '../../error-tracking' ; import { DOCKER_HOST_HOSTNAME , isInterceptedContainer } from './docker-commands' ; import { isDockerAvailable } from './docker-interception-services' ; import { updateDockerTunnelledNetworks } from './docker-tunnel-proxy' ; import { getDnsServer } from '../../dns-server' ; interface DockerEvent { Type : string ; Action : string ; Actor : { ID : string ; Attributes : unknown ; } ; } let dockerEventStream ; /**
 * Get a stream of events from Docker. Although Dockerode will only give us the raw event buffers,
 * this creates a stream of parsed object data so we can easily process everything.
 *
 * The resulting stream is created on demand and cached. If there's any issues (errors or close
 * from docker itself) then the cache is dropped, so the next usage will recreate it.
 */ function getDockerEventStream ( docker ) { if ( ! dockerEventStream ) { const dockerEventParsingStream = EventStream . pipeline ( EventStream . split ( ) , EventStream . mapSync ( ( buffer ) => buffer . toString ( 'utf8' ) ) , EventStream . filterSync ( ( line ) => line . length > 0 ) , EventStream . mapSync ( ( rawLine ) => JSON . parse ( rawLine ) ) ) ; dockerEventStream = dockerEventParsingStream ; docker . getEvents ( ) . then ( ( rawEventStream ) => { rawEventStream . pipe ( dockerEventParsingStream ) ; rawEventStream . on ( 'error' , ( e ) => { dockerEventParsingStream ?. emit ( 'error' , e ) ; } ) ; rawEventStream . on ( 'close' , ( ) => { dockerEventParsingStream ?. end ( ) ; dockerEventStream = undefined ; } ) ; } ) . catch ( ( e ) => { dockerEventParsingStream ?. emit ( 'error' , e ) ; dockerEventStream = undefined ; } ) ; } return dockerEventStream ; } const dockerNetworkMonitors = { } ; /**
 * Activate the network monitor for this proxy port, which will subscribe to Docker events,
 * track the aliases for Docker containers that our intercepted containers might want to
 * talk to, and ensure the DNS & tunnel for this proxy port are configured correctly for each
 * network where we need to route & tunnel that traffic.
 *
 * This method has no effect (and no downside) if the monitor is already running, so it's
 * expected that this will be called whenever a user interacts with Docker in a way related
 * to HTTP Toolkit interception for this port. It's useful to call this often, because its
 * dependent on the events stream connection from Docker that may be fragile and need resetting,
 * in a way that other background services (like the proxy or tunnel container) are not.
 *
 * Network monitors are cached and run in the background, staying alive until either the
 * the Docker event stream shuts down (i.e. Docker engine disappears or similar) or it's
 * explicitly shut down with stopMonitoringDockerNetworkAliases for this proxy port.
 */ export async function monitorDockerNetworkAliases ( proxyPort ) { if ( ! await isDockerAvailable ( ) ) return undefined ; if ( ! dockerNetworkMonitors [ proxyPort ] ) { const docker = new Docker ( ) ; const stream = getDockerEventStream ( docker ) ; stream . on ( 'error' , ( e ) => { console . log ( `Docker stream for port ${ proxyPort } hit an error` ) ; reportError ( e ) ; } ) ; const dnsServer = await getDnsServer ( proxyPort ) ; const networkMonitor = new DockerNetworkMonitor ( docker , proxyPort , stream ) ; mobx . autorun ( ( ) => dnsServer . setHosts ( networkMonitor . aliasIpMap ) ) ; mobx . reaction ( ( ) => networkMonitor . interceptedNetworks , ( interceptedNetworks ) => updateDockerTunnelledNetworks ( proxyPort , interceptedNetworks ) . catch ( console . warn ) ) ; dockerNetworkMonitors [ proxyPort ] = networkMonitor ; stream . on ( 'close' , async ( ) => { if ( dockerNetworkMonitors [ proxyPort ] === networkMonitor ) { delete dockerNetworkMonitors [ proxyPort ] ; networkMonitor . stop ( ) ; } } ) ; } return dockerNetworkMonitors [ proxyPort ] ; } export function stopMonitoringDockerNetworkAliases ( proxyPort ) { const monitor = dockerNetworkMonitors [ proxyPort ] ; if ( ! monitor ) return ; delete dockerNetworkMonitors [ proxyPort ] ; monitor . stop ( ) ; } function combineSets < T > ( ... sets ) { const result = [ ] ; for ( let set of sets ) { result . push ( ... set ) ; } return new Set ( result ) ; } function combineSetMaps < T > ( ... setMaps ) { const keys = _ . uniq ( _ . flatMap ( setMaps , ( mapping ) => Object . keys ( mapping ) ) ) ; return _ . fromPairs ( keys . map ( ( key ) => [ key , combineSets ( ... setMaps . map ( ( mapping ) => mapping [ key ] ) . filter ( set => ! ! set ) ) ] ) ) ; } /**
 * Network monitors tracks which networks the intercepted containers are connected to, and
 * monitors the network aliases & IPs accessible on those networks.
 */ class DockerNetworkMonitor { constructor ( private docker , private proxyPort , private dockerEventStream ) { mobx . makeObservable ( this , { 'interceptedNetworks' : mobx . computed . struct , 'dockerRoutedAliases' : mobx . computed . struct , 'aliasIpMap' : mobx . computed . struct } ) ; dockerEventStream . on ( 'data' , this . onEvent ) ; this . refreshAllNetworks ( ) ; } async stop ( ) { this . dockerEventStream . removeListener ( 'data' , this . onEvent ) ; } private readonly networkTargets : { [ networkId ] : { [ hostname ] : ReadonlySet < string > ; } ; } = mobx . observable ( { } ) ; get interceptedNetworks ( ) : string [ ] { return Object . keys ( this . networkTargets ) ; } get dockerRoutedAliases ( ) : ReadonlySet < string > { return new Set ( [ ... _ . flatten ( Object . values ( this . networkTargets ) . map ( ( networkMap ) => Object . keys ( networkMap ) ) ) . filter ( ( host ) => host !== DOCKER_HOST_HOSTNAME ) ] ) ; } get aliasIpMap ( ) : { [ host ] : ReadonlySet < string > ; } { return combineSetMaps ( ... Object . values ( this . networkTargets ) , { [ DOCKER_HOST_HOSTNAME ] : new Set ( [ '127.0.0.1' ] ) } ) ; } onEvent = async ( event ) => { if ( event . Type !== 'network' ) return ; if ( event . Action === 'destroy' ) { const networkId = event . Actor . ID ; if ( networkId in this . networkTargets ) { mobx . runInAction ( ( ) => { delete this . networkTargets [ event . Actor . ID ] ; } ) ; } } else if ( event . Action === 'connect' || event . Action === 'disconnect' ) { console . log ( `Updating Docker aliases for network ${ event . Actor . ID }...` ) ; const networkAliases = await this . getNetworkAliases ( event . Actor . ID ) ; mobx . runInAction ( ( ) => { if ( networkAliases ) { this . networkTargets [ event . Actor . ID ] = networkAliases ; } else { delete this . networkTargets [ event . Actor . ID ] ; } } ) ; } } ; private async refreshAllNetworks ( ) { console . log ( 'Updating all Docker network aliases...' ) ; const networks = await this . docker . listNetworks ( ) ; const networkMap = await Promise . all ( networks . map ( async ( network ) => { const aliases = await this . getNetworkAliases ( network . Id ) ; return [ network . Id , aliases ] as const ; } ) ) ; mobx . runInAction ( ( ) => { networkMap . forEach ( ( [ networkId , networkAliases ] ) => { if ( networkAliases ) { this . networkTargets [ networkId ] = networkAliases ; } else { delete this . networkTargets [ networkId ] ; } } ) ; } ) ; } private isInterceptedContainer ( container ) { return isInterceptedContainer ( container , this . proxyPort ) ; } private async getNetworkAliases ( networkId ) { const networkDetails = await this . docker . getNetwork ( networkId ) . inspect ( ) ; const isDefaultBridge = networkDetails . Options ?. [ 'com.docker.network.bridge.default_bridge' ] === 'true' ; if ( networkDetails . Driver === 'null' || networkDetails . Driver === 'host' ) { return undefined ; } const networkContainers = ( await Promise . all ( Object . values ( networkDetails . Containers ?? { } ) . map ( ( networkContainer ) => this . docker . getContainer ( networkContainer . Name ) . inspect ( ) . catch ( ( ) => undefined ) ) ) ) . filter ( ( container ) => ! ! container ) as Docker . ContainerInspectInfo [ ] ; if ( ! networkContainers . find ( ( container ) => this . isInterceptedContainer ( container ) ) ) { return undefined ; } const aliases = [ ] ; await Promise . all ( networkContainers . map ( async ( container ) => { const networkConfig = _ . find ( container . NetworkSettings . Networks , { NetworkID : networkId } ) ; const containerIp = networkConfig ?. IPAddress ; if ( ! networkConfig || ! containerIp ) return ; aliases . push ( [ containerIp , containerIp ] ) ; if ( isDefaultBridge ) { if ( this . isInterceptedContainer ( container ) ) { aliases . push ( [ container . Config . Hostname , containerIp ] ) ; } } else { aliases . push ( [ container . Config . Hostname , containerIp ] ) ; } aliases . push ( ... ( networkConfig . Aliases || [ ] ) . map ( ( alias ) => [ alias , containerIp ] as const ) ) ; if ( this . isInterceptedContainer ( container ) ) { aliases . push ( ... _ ( container . HostConfig . ExtraHosts ?? [ ] ) . reverse ( ) . map ( ( hostPair ) => { const hostParts = hostPair . split ( ':' ) ; const alias = hostParts [ 0 ] ; const target = hostParts . slice ( 1 ) . join ( ':' ) ; const targetIp = target === 'host-gateway' ? '127.0.0.1' : target ; return [ alias , targetIp ] as const ; } ) . uniqBy ( ( [ alias ] ) => alias ) . valueOf ( ) ) ; const linkStrings = container . HostConfig . Links || [ ] ; const linkAliases = await Promise . all ( linkStrings . map ( async ( link ) => { const endOfContainerName = link . indexOf ( ':/' ) ; const aliasIndex = link . lastIndexOf ( '/' ) ; const linkedContainerName = link . slice ( 1 , endOfContainerName ) ; const linkAlias = link . slice ( aliasIndex + 1 ) ; const linkedContainer = networkContainers . find ( c => c . Name === linkedContainerName ) ?? await this . docker . getContainer ( linkedContainerName ) . inspect ( ) . catch ( ( ) => undefined ) ; if ( ! linkedContainer ) return [ ] ; const linkedContainerIp = linkedContainer . NetworkSettings . Networks [ networkId ] ?. IPAddress || linkedContainer . NetworkSettings . IPAddress ; return [ [ linkAlias , linkedContainerIp ] as const , [ linkedContainer . Name , linkedContainerIp ] as const , [ linkedContainer . Config . Hostname , linkedContainerIp ] as const ] as const ; } ) ) ; aliases . push ( ... _ . flatten ( linkAliases ) ) ; } } ) ) ; return aliases . reduce ( ( aliasMap , [ alias , target ] ) => { if ( ! aliasMap [ alias ] ) aliasMap [ alias ] = new Set ( ) ; aliasMap [ alias ] . add ( target ) ; return aliasMap ; } , { } as { [ alias ] : Set < string > ; } ) ; } }