import { i18n } from '@kbn/i18n' ; import { getDataPath } from '@kbn/utils' ; import { spawn } from 'child_process' ; import del from 'del' ; import fs from 'fs' ; import { uniq } from 'lodash' ; import path from 'path' ; import puppeteer , { Browser , ConsoleMessage , HTTPRequest , Page } from 'puppeteer' ; import { createInterface } from 'readline' ; import * as Rx from 'rxjs' ; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber' ; import { catchError , ignoreElements , map , mergeMap , reduce , takeUntil , tap } from 'rxjs/operators' ; import type { Logger } from 'src/core/server' ; import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server' ; import { ConfigType } from '../../../config' ; import { getChromiumDisconnectedError } from '../' ; import { safeChildProcess } from '../../safe_child_process' ; import { HeadlessChromiumDriver } from '../driver' ; import { args } from './args' ; import { getMetrics , PerformanceMetrics } from './metrics' ; interface CreatePageOptions { browserTimezone ? : string ; openUrlTimeout : number ; } interface CreatePageResult { driver : HeadlessChromiumDriver ; unexpectedExit$ : Rx . Observable < never > ; metrics$ : Rx . Observable < PerformanceMetrics > ; note close : ( ) => Rx . Observable < void > ; } export const DEFAULT_VIEWPORT = { width : 1950 , height : 1200 , } ; const DEFAULT_ARGS = [ '--disable-background-networking' , '--enable-features=NetworkService,NetworkServiceInProcess' , '--disable-background-timer-throttling' , '--disable-backgrounding-occluded-windows' , '--disable-breakpad' , '--disable-client-side-phishing-detection' , '--disable-component-extensions-with-background-pages' , '--disable-default-apps' , '--disable-dev-shm-usage' , '--disable-extensions' , '--disable-features=TranslateUI' , '--disable-hang-monitor' , '--disable-ipc-flooding-protection' , '--disable-popup-blocking' , '--disable-prompt-on-repost' , '--disable-renderer-backgrounding' , '--disable-sync' , '--force-color-profile=srgb' , '--metrics-recording-only' , '--no-first-run' , '--enable-automation' , '--password-store=basic' , '--use-mock-keychain' , '--remote-debugging-port=0' , '--headless' , ] ; const DIAGNOSTIC_TIME = 5 * 1000 ; export class HeadlessChromiumDriverFactory { private userDataDir = fs . mkdtempSync ( path . join ( getDataPath ( ) , 'chromium-' ) ) ; type = 'chromium' ; constructor ( private screenshotMode , private config , private logger , private binaryPath ) { if ( this . config . browser . chromium . disableSandbox ) { logger . warn ( `Enabling the Chromium sandbox provides an additional layer of protection.` ) ; } } private getChromiumArgs ( ) { return args ( { userDataDir : this . userDataDir , disableSandbox : this . config . browser . chromium . disableSandbox , proxy : this . config . browser . chromium . proxy , viewport : DEFAULT_VIEWPORT , } ) ; } createPage ( { browserTimezone , openUrlTimeout } , pLogger = this . logger ) { return Rx . Observable . create ( async ( observer ) => { const logger = pLogger . get ( 'browser-driver' ) ; logger . info ( `Creating browser page driver` ) ; const chromiumArgs = this . getChromiumArgs ( ) ; logger . debug ( `Chromium launch args set to: ${ chromiumArgs }` ) ; let browser ; try { browser = await puppeteer . launch ( { pipe : ! this . config . browser . chromium . inspect , userDataDir : this . userDataDir , executablePath : this . binaryPath , ignoreHTTPSErrors : true , handleSIGHUP : false , args : chromiumArgs , env : { TZ : browserTimezone , } , } ) ; } catch ( err ) { observer . error ( new Error ( `Error spawning Chromium browser! ${ err }` ) ) ; return ; } const page = await browser . newPage ( ) ; const devTools = await page . target ( ) . createCDPSession ( ) ; await devTools . send ( 'Performance.enable' , { timeDomain : 'timeTicks' } ) ; const startMetrics = await devTools . send ( 'Performance.getMetrics' ) ; const metrics$ = new Rx . Subject < PerformanceMetrics > ( ) ; const versionInfo = await devTools . send ( 'Browser.getVersion' ) ; logger . debug ( `Browser version: ${ JSON . stringify ( versionInfo ) }` ) ; await page . emulateTimezone ( browserTimezone ) ; page . setDefaultTimeout ( openUrlTimeout ) ; logger . debug ( `Browser page driver created` ) ; const childProcess = { async kill ( ) { if ( page . isClosed ( ) ) return ; try { if ( devTools && startMetrics ) { const endMetrics = await devTools . send ( 'Performance.getMetrics' ) ; const metrics = getMetrics ( startMetrics , endMetrics ) ; const { cpuInPercentage , memoryInMegabytes } = metrics ; metrics$ . next ( metrics ) ; logger . debug ( `Chromium consumed CPU ${ cpuInPercentage }% Memory ${ memoryInMegabytes }MB` ) ; } } catch ( error ) { logger . error ( error ) ; } finally { metrics$ . complete ( ) ; } try { logger . debug ( 'Attempting to close browser...' ) ; await browser ?. close ( ) ; logger . debug ( 'Browser closed.' ) ; } catch ( err ) { logger . error ( err ) ; } } , } ; const { terminate$ } = safeChildProcess ( logger , childProcess ) ; observer . add ( ( ) => { if ( page . isClosed ( ) ) return ; logger . debug ( `It looks like the browser is no longer being used. Closing the browser...` ) ; childProcess . kill ( ) ; } ) ; observer . add ( terminate$ . pipe ( tap ( ( signal ) => { logger . debug ( `Termination signal received: ${ signal }` ) ; } ) , ignoreElements ( ) ) . subscribe ( observer ) ) ; this . getBrowserLogger ( page , logger ) . subscribe ( ) ; this . getProcessLogger ( browser , logger ) . subscribe ( ) ; const driver = new HeadlessChromiumDriver ( this . screenshotMode , this . config , page ) ; const unexpectedExit$ = this . getPageExit ( browser , page ) ; observer . next ( { driver , unexpectedExit$ , metrics$ : metrics$ . asObservable ( ) , close : ( ) => Rx . from ( childProcess . kill ( ) ) , } ) ; observer . add ( ( ) => { const userDataDir = this . userDataDir ; logger . debug ( `deleting chromium user data directory at [${ userDataDir }]` ) ; del ( userDataDir , { force : true } ) . catch ( ( error ) => { logger . error ( `error deleting user data directory at [${ userDataDir }]!` ) ; logger . error ( error ) ; } ) ; } ) ; } ) ; } getBrowserLogger ( page , logger ) { const consoleMessages$ = Rx . fromEvent < ConsoleMessage > ( page , 'console' ) . pipe ( map ( ( line ) => { const formatLine = ( ) => `{ text: "${ line . text ( ) ?. trim ( ) }", url: ${ line . location ( ) ?. url } }` ; if ( line . type ( ) === 'error' ) { logger . get ( 'headless-browser-console' ) . error ( `Error in browser console: ${ formatLine ( ) }` ) ; } else { logger . get ( `headless-browser-console:${ line . type ( ) }` ) . debug ( `Message in browser console: ${ formatLine ( ) }` ) ; } } ) ) ; const uncaughtExceptionPageError$ = Rx . fromEvent < Error > ( page , 'pageerror' ) . pipe ( map ( ( err ) => { logger . warn ( i18n . translate ( 'xpack.screenshotting.browsers.chromium.pageErrorDetected' , { defaultMessage : `Reporting encountered an uncaught error on the page that will be ignored: {err}` , values : { err : err . toString ( ) } , } ) ) ; } ) ) ; const pageRequestFailed$ = Rx . fromEvent < HTTPRequest > ( page , 'requestfailed' ) . pipe ( map ( ( req ) => { const failure = req . failure && req . failure ( ) ; if ( failure ) { logger . warn ( `Request to [${ req . url ( ) }] failed! [${ failure . errorText }]. This error will be ignored.` ) ; } } ) ) ; return Rx . merge ( consoleMessages$ , uncaughtExceptionPageError$ , pageRequestFailed$ ) ; } getProcessLogger ( browser , logger ) { const childProcess = browser . process ( ) ; if ( childProcess == null ) { throw new TypeError ( 'childProcess is null or undefined!' ) ; } const processClose$ = Rx . fromEvent < void > ( childProcess , 'close' ) . pipe ( tap ( ( ) => { logger . get ( 'headless-browser-process' ) . debug ( 'child process closed' ) ; } ) ) ; return processClose$ ; } getPageExit ( browser , page ) { const pageError$ = Rx . fromEvent < Error > ( page , 'error' ) . pipe ( mergeMap ( ( err ) => { return Rx . throwError ( i18n . translate ( 'xpack.screenshotting.browsers.chromium.errorDetected' , { defaultMessage : 'Reporting encountered an error: {err}' , values : { err : err . toString ( ) } , } ) ) ; } ) ) ; const browserDisconnect$ = Rx . fromEvent ( browser , 'disconnected' ) . pipe ( mergeMap ( ( ) => Rx . throwError ( getChromiumDisconnectedError ( ) ) ) ) ; return Rx . merge ( pageError$ , browserDisconnect$ ) ; } diagnose ( overrideFlags = [ ] ) { const kbnArgs = this . getChromiumArgs ( ) ; const finalArgs = uniq ( [ ... DEFAULT_ARGS , ... kbnArgs , ... overrideFlags ] ) ; const browserProcess = spawn ( this . binaryPath , finalArgs , { detached : process . platform !== 'win32' , } ) ; const rl = createInterface ( { input : browserProcess . stderr } ) ; const exit$ = Rx . fromEvent ( browserProcess , 'exit' ) . pipe ( map ( ( code ) => { this . logger . error ( `Browser exited abnormally, received code: ${ code }` ) ; return i18n . translate ( 'xpack.screenshotting.diagnostic.browserCrashed' , { defaultMessage : `Browser exited abnormally during startup` , } ) ; } ) ) ; const error$ = Rx . fromEvent ( browserProcess , 'error' ) . pipe ( map ( ( err ) => { this . logger . error ( `Browser process threw an error on startup` ) ; this . logger . error ( err as string | Error ) ; return i18n . translate ( 'xpack.screenshotting.diagnostic.browserErrored' , { defaultMessage : `Browser process threw an error on startup` , } ) ; } ) ) ; const browserProcessLogger = this . logger . get ( 'chromium-stderr' ) ; const log$ = Rx . fromEvent ( rl , 'line' ) . pipe ( tap ( ( message ) => { if ( typeof message === 'string' ) { browserProcessLogger . info ( message ) ; } } ) ) ; return Rx . merge ( exit$ , error$ , log$ ) . pipe ( takeUntil ( Rx . timer ( DIAGNOSTIC_TIME ) ) , reduce ( ( acc , curr ) => `${ acc }${ curr }\n` , '' ) , tap ( ( ) => { if ( browserProcess && browserProcess . pid && ! browserProcess . killed ) { browserProcess . kill ( 'SIGKILL' ) ; this . logger . info ( `Successfully sent 'SIGKILL' to browser process (PID: ${ browserProcess . pid })` ) ; } browserProcess . removeAllListeners ( ) ; rl . removeAllListeners ( ) ; rl . close ( ) ; del ( this . userDataDir , { force : true } ) . catch ( ( error ) => { this . logger . error ( `Error deleting user data directory at [${ this . userDataDir }]!` ) ; this . logger . error ( error ) ; } ) ; } ) , catchError ( ( error ) => { this . logger . error ( error ) ; return Rx . of ( error ) ; } ) ) ; } } export type { PerformanceMetrics } ;