import * as _ from 'lodash' ; import * as os from 'os' ; import { Interceptor } from '..' ; import { HtkConfig } from '../../config' ; import { generateSPKIFingerprint } from 'mockttp' ; import { reportError } from '../../error-tracking' ; import { delay } from '../../util/promise' ; import { ANDROID_TEMP , createAdbClient , getConnectedDevices , getRootCommand , pushFile , injectSystemCertificate , stringAsStream , hasCertInstalled , bringToFront } from './adb-commands' ; import { streamLatestApk , clearAllApks } from './fetch-apk' ; import { parseCert , getCertificateFingerprint , getCertificateSubjectHash } from '../../certificates' ; function urlSafeBase64 ( content ) { return Buffer . from ( content , 'utf8' ) . toString ( 'base64' ) . replace ( '+' , '-' ) . replace ( '/' , '_' ) ; } export class AndroidAdbInterceptor implements Interceptor { readonly id = 'android-adb' ; readonly version = '1.0.0' ; private readonly deviceProxyMapping : { [ port ] : string [ ] ; } = { } ; private adbClient = createAdbClient ( ) ; constructor ( private config ) { } async isActivable ( ) { return ( await getConnectedDevices ( this . adbClient ) ) . length > 0 ; } activableTimeout = 3000 ; isActive ( ) { return false ; } async getMetadata ( ) { return { deviceIds : await getConnectedDevices ( this . adbClient ) } ; } async activate ( proxyPort , options ) { await this . injectSystemCertIfPossible ( options . deviceId , this . config . https . certContent ) ; if ( ! ( await this . adbClient . isInstalled ( options . deviceId , 'tech.httptoolkit.android.v1' ) ) ) { console . log ( "App not installed, installing..." ) ; try { await this . adbClient . install ( options . deviceId , await streamLatestApk ( this . config ) ) ; } catch ( e ) { console . log ( "Resetting & retrying APK install, after initial failure:" , e ) ; await clearAllApks ( this . config ) ; await this . adbClient . install ( options . deviceId , await streamLatestApk ( this . config ) ) ; } console . log ( "App installed successfully" ) ; await delay ( 200 ) ; } await bringToFront ( this . adbClient , options . deviceId , 'tech.httptoolkit.android.v1/tech.httptoolkit.android.MainActivity' ) . catch ( reportError ) ; const setupParams = { addresses : [ '10.0.2.2' , '10.0.3.2' , ] . concat ( _ . flatMap ( os . networkInterfaces ( ) , ( addresses ) => ( addresses || [ ] ) . filter ( a => ! a . internal ) . map ( a => a . address ) ) ) , port : proxyPort , localTunnelPort : proxyPort , certFingerprint : generateSPKIFingerprint ( this . config . https . certContent ) } ; const intentData = urlSafeBase64 ( JSON . stringify ( setupParams ) ) ; await this . adbClient . reverse ( options . deviceId , 'tcp:' + proxyPort , 'tcp:' + proxyPort ) . catch ( ( ) => { } ) ; await this . adbClient . startActivity ( options . deviceId , { wait : true , action : 'tech.httptoolkit.android.ACTIVATE' , data : `https://android.httptoolkit.tech/connect/?data=${ intentData }` } ) ; this . deviceProxyMapping [ proxyPort ] = this . deviceProxyMapping [ proxyPort ] || [ ] ; if ( ! this . deviceProxyMapping [ proxyPort ] . includes ( options . deviceId ) ) { this . deviceProxyMapping [ proxyPort ] . push ( options . deviceId ) ; let tunnelConnectFailures = 0 ; const tunnelCheckInterval = setInterval ( async ( ) => { if ( this . deviceProxyMapping [ proxyPort ] . includes ( options . deviceId ) ) { try { await this . adbClient . reverse ( options . deviceId , 'tcp:' + proxyPort , 'tcp:' + proxyPort ) ; tunnelConnectFailures = 0 ; } catch ( e ) { tunnelConnectFailures += 1 ; console . log ( `${ options . deviceId } ADB tunnel failed` , e ) ; if ( tunnelConnectFailures >= 5 ) { console . log ( `${ options . deviceId } disconnected, dropping the ADB tunnel` ) ; this . deviceProxyMapping [ proxyPort ] = this . deviceProxyMapping [ proxyPort ] . filter ( id => id !== options . deviceId ) ; clearInterval ( tunnelCheckInterval ) ; } } } else { clearInterval ( tunnelCheckInterval ) ; } } , 2000 ) ; tunnelCheckInterval . unref ( ) ; } } async deactivate ( port ) { const deviceIds = this . deviceProxyMapping [ port ] || [ ] ; return Promise . all ( deviceIds . map ( deviceId => this . adbClient . startActivity ( deviceId , { wait : true , action : 'tech.httptoolkit.android.DEACTIVATE' } ) ) ) ; } async deactivateAll ( ) { return Promise . all ( Object . keys ( this . deviceProxyMapping ) . map ( port => this . deactivate ( port ) ) ) ; } private async injectSystemCertIfPossible ( deviceId , certContent ) { const rootCmd = await getRootCommand ( this . adbClient , deviceId ) ; if ( ! rootCmd ) { console . log ( 'Root not available, skipping cert injection' ) ; return ; } const cert = parseCert ( certContent ) ; try { const subjectHash = getCertificateSubjectHash ( cert ) ; const fingerprint = getCertificateFingerprint ( cert ) ; if ( await hasCertInstalled ( this . adbClient , deviceId , subjectHash , fingerprint ) ) { console . log ( "Cert already installed, nothing to do" ) ; return ; } const certPath = `${ ANDROID_TEMP }/${ subjectHash }.0` ; console . log ( `Adding cert file as ${ certPath }` ) ; await pushFile ( this . adbClient , deviceId , stringAsStream ( certContent . replace ( '\r\n' , '\n' ) ) , certPath , 0o444 ) ; await injectSystemCertificate ( this . adbClient , deviceId , rootCmd , certPath ) ; console . log ( `Cert injected` ) ; } catch ( e ) { reportError ( e ) ; } } }