Віддалене налагодження: інтегруємо код у рантайм Node.js

10 хв. читання

Ця стаття описує метод динамічної зміни поведінки запущеного процесу Node.js. Робиться це через увімкнення інтерфейсу віддаленого інспектора, а потім — через протокол налагодження Chrome.

На Linux або MacOS можна надіслати сигнал SIGUSR1 до запущеного процесу Node.js. Під час процесу відкриється сервер websocket, що прослуховує лише локальні інтерфейси. Якщо підімкнутись до websocket, можна розпочати сесію налаштування процесу Node.js, а потім ввести туди код. Зрештою, можна відключити інтерфейс websocket до того, як ви від'єднаєтесь від сокета.

Вступ

Інструментальний код — це, ймовірно, одне з найкращих завдань, яке траплялось автору статті. Sqreen для Node.js вимагає, щоб користувачі імпортували агента (npm-пакет, що називається sqreen) у свою програму, аби він виконував функції захисту безпосередньо у процесі.

Далі ми перевіримо, чи можна зробити те саме з обмеженням інструментарію у вже запущеному застосунку. А після цього розглянемо цікавий приклад віддаленого налагодження в Node.js.

Зауважте: метод, викладений у цьому пості, не варто застосовувати на реальному сервері або продукті. Ця публікація описує лише нетипові шляхи розв'язання проблеми. Автор використав їх, щоб отримати цікавий результат з віддаленим налагодженням у Node.js. Втім, деякі інструменти все ж можуть використовуватися для збору конкретних даних у робочому застосунку.

Зміна стану запущеного процесу Node.js

Почати налагодження Node.js у запущеному стані можна, якщо:

  1. У вас є доступ до машини, де відбувається процес.
  2. Ви можете визначити PID запущеного процесу Node.js.
  3. Ви маєте достатньо прав, щоб надіслати сигнал процесу Node.js.
  4. Ви маєте достатньо прав, щоб відкрити з'єднання websocket з процесом Node.js.
  5. Node.js працює на MacOS або Linux.

У цій статті ми розглянемо ось такий застосунок Node.js:

'use strict';
require('http').createServer((req, res) => {
    res.end('ok');
}).listen(8080);

Якщо всі ці припущення дійсні, то ви можете надіслати сигнал SIGUSR1 до застосунку. Це можна зробити через shell:

kill -USR1 <PID>

Або рядком коду Node.js:

process.kill(<PID>, 'SIGUSR1');

У кожному разі наш застосунок логує таке:

Debugger listening on ws://127.0.0.1:9229/d332eecd-502c-44f1-9371-96e3e9f91fdc
For help, see: https://nodejs.org/en/docs/inspector

Тут ми успішно змінили стан процесу Node.js та увімкнули дебагер. Це можна перевірити через Google Chrome або Chromium, якщо перейти за посиланням chrome://inspect.

Screenshot-2021-01-21-at-15-20-00-censored

Підключення до процесу та введення скриптів

Тут починається найцікавіше. Тепер, коли процес працює у режимі налагодження, ми хочемо підключитися до нього і використати Chrome DevTools Protocol. Так ми знайдемо екземпляр http.Server та внесемо до нього зміни.

Для цього ми візьмемо пакет chrome-remote-interface. Це допоможе нам використовувати DevTools Protocol із зручним програмним інтерфейсом. Те, що ми хочемо зробити, занадто круто, аби користуватись лише Chrome DevTools для Node.js.

Наша мета тут — отримати вказівник на екземпляр http.Server (запущений в процесі), щоб пізніше змінити його стан.

Отже, запустимо цей скрипт у іншому процесі.

'use strict';
const CDP = require('chrome-remote-interface');
const main = async function () {

    const client = await CDP({
        port: 9229
    });

    await client.Runtime.enable();
    const ServerPrototypeResult = await client.Runtime.evaluate({
        expression:"require('http').Server.prototype", includeCommandLineAPI: true, returnByValue: false
    });

    const ServerInstanceListResult = await client.Runtime.queryObjects({
        prototypeObjectId: ServerPrototypeResult.result.objectId
    });
    const ServerInstancesResult = await client.Runtime.getProperties({
        objectId: ServerInstanceListResult.objects.objectId
    });

    const serverInstance = ServerInstancesResult.result[0].value.objectId;
    console.log({ serverInstance });

    await client.close();
};

main(); // у Node 15 помилка зупинить процес

Тут є на що подивитися:

const client = await CDP({
  port: 9229
});

await client.Runtime.enable();

Ця частина використовувала chrome-remote-interface для:

  • Зв'язку з вебсокет-сервером на порту 9229.
  • Запуску Runtime Domain у DevTools Protocol.

Методи, які ми використаємо пізніше, є частиною цього домену, тому нам не потрібно підключати будь-які інші домени. Та якщо нам знадобиться ще один, його також потрібно спочатку увімкнути.

const ServerPrototypeResult = await client.Runtime.evaluate({
  expression:"require('http').Server.prototype", includeCommandLineAPI: true, returnByValue: false
});

Команда Runtime.evaluate запускає довільний вираз у віддаленому процесі. Іншими словами, ми можемо запустити будь-який код у процесі Node.js. Далі ми робимо так:

  1. Запускаємо код require('http').Server.prototype та повертаємо результат цього виклику.
  2. Використовуємо прапорець includeCommandLineAPI, без нього метод require не буде доступний у скрипті, а виконання поверне помилку.

Повернене значення цього методу містить вказівник на прототип http.Server у області пам'яті Node.js. Побачити можна у інструкції:

const ServerInstanceListResult = await client.Runtime.queryObjects({
  prototypeObjectId: ServerPrototypeResult.result.objectId
});
const ServerInstancesResult = await client.Runtime.getProperties({
  objectId: ServerInstanceListResult.objects.objectId
});

Ці два виклики дають нам вказівник на кожен екземпляр http.Server на основі результату попереднього виклику.

ServerPrototypeResult.result.objectId — це рядок, що посилається на значення require('http').Server.prototype. У цьому рядку ми викликаємо Runtime.queryObjects, що повертає вказівник на масив. Цей масив містить список об'єктів, прототипом яких є http.Server.prototype.

Ми викликаємо Runtime.getProperties у масиві для того, щоб отримати список властивостей масиву. Властивість 0 вкаже на екземпляр HTTP-сервера, котрий ми хочемо ідентифікувати (та якщо в процесі є більше одного об'єкта з цим самим прототипом, то буде й більше нумерованих властивостей).

const serverInstance = ServerInstancesResult.result[0].value.objectId;
console.log({ serverInstance });

Ось що реєструє фрагмент коду:

{ serverInstance: '{"injectedScriptId":1,"id":3}' }

serverInstance містить строкове значення рядка. Воно і є нашим вказівником на екземпляр http.Server, запущений у цьому процесі.

Що робити з HTTP-сервером

Припустимо, що ми можемо запустити функцію на HTTP-сервері як аргумент. Що потрібно зробити, аби функція реєструвала кожен вхідний запит? Розглянемо таку функцію:

'use strict';
const Shimmer = require('shimmer');

module.exports.patchListeners = function (emitter) {

    const listeners = emitter.listeners('request');
    emitter.removeAllListeners('request');
    for (let i = 0; i < listeners.length; ++i) {
        const list = listeners[i];
        const holder = { list };
        Shimmer.wrap(holder, 'list', (original) => {

            return function (req, res) {
                console.error(req.method, req.url);

                return original.apply(this, arguments);
            }
        });
        emitter.on('request', holder.list);
    }
};

Це проста функція, яка:

  1. Має список слухачів у події request HTTP-сервера.
  2. Видаляє всі запити слухачів із сервера.
  3. Обгортає слухачів функцією, яка буде реєструвати HTTP-метод і URL для всіх вхідних HTTP-запитів.
  4. Повертає обгорнутих слухачів до сервера.

То як нам внести ці зміни до екземпляра http.Server? Використаємо вказівники, які ми знайшли раніше. Щоб це зробити, додамо в наш скрипт віддаленого налагодження такий код:

await client.Runtime.evaluate({
  expression:"process.patchListeners = require(`./toInject.js`).patchListeners", 
  includeCommandLineAPI: true, 
  returnByValue: false
});

// у введеній функції значення 'this' буде екзепляром сервера
await client.Runtime.callFunctionOn({
  objectId: serverInstance, 
  functionDeclaration: 'function() { process.patchListeners(this) }', 
  returnByValue: true
});
await client.Runtime.evaluate({
  expression:"delete process.patchListeners", 
  includeCommandLineAPI: true, 
  returnByValue: false
});

Перший виклик Runtime.evaluate завантажує функцію patchListeners та приєднує її до process. Ми не можемо використовувати аргумент includeCommandLineAPI на Runtime.callFunctionOn, тому неможливо й визначити require під час його використання.

Runtime.callFunctionOn викличе задану функцію зі значенням для this, визначену аргументом objectId.

Ми викликаємо функцію function() { process.patchListeners(this) }, де this є значенням, що вказує на serverInstance. serverInstance — це вказівник на HTTP-сервер.

Після цього викликаємо скрипт для видалення всього зайвого, що ми додали до process-об'єкта.

Зрештою, ми можемо вимкнути режим налагодження та від'єднатись від екземпляра за допомогою:

await client.Runtime.evaluate({"expression":"require('inspector').close()","includeCommandLineAPI":true});
await client.close();

Час для демо!

У нас є два скрипти: Код вебсервера:

'use strict';
require('http').createServer((req, res) => {
    res.end('ok');
}).listen(8080);

Та інжектор (припустимо, що функція patchListeners розташована у модулі toInject.js):

'use strict';
const CDP = require('chrome-remote-interface');
const main = async function () {

    const client = await CDP({
        port: 9229
    });

    await client.Runtime.enable();
    const ServerPrototypeResult = await client.Runtime.evaluate({expression:"require('http').Server.prototype", includeCommandLineAPI: true, returnByValue: false});

    const ServerInstanceListResult = await client.Runtime.queryObjects({ prototypeObjectId: ServerPrototypeResult.result.objectId });
    const ServerInstancesResult = await client.Runtime.getProperties({ objectId: ServerInstanceListResult.objects.objectId });

    const serverInstance = ServerInstancesResult.result[0].value.objectId;
    await client.Runtime.evaluate({expression:"process.patchListeners = require(`./toInject.js`).patchListeners", includeCommandLineAPI: true, returnByValue: false});

    // у введеній функції значення 'this' буде екзепляром сервера
    await client.Runtime.callFunctionOn({ objectId: serverInstance, functionDeclaration: 'function() { process.patchListeners(this) }', returnByValue: true });
    await client.Runtime.evaluate({expression:"delete process.patchListeners", includeCommandLineAPI: true, returnByValue: false});

    await client.Runtime.evaluate({"expression":"require('inspector').close()","includeCommandLineAPI":true});
    await client.close();
};

main(); // у Node 15 помилка зупинить процес

Коли ми запускаємо сервер та створюємо HTTP-запити до нього, він нічого не реєструє. Тепер, коли ми запустили інжектор скрипта до сервера (після того, як перевели його у режим налагодження), він створює такі записи:

Debugger listening on ws://127.0.0.1:9229/66f3da41-487e-43f3-8048-90c5041871e0
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
GET /aaa
GET /favicon.ico
GET /foo
GET /favicon.ico
GET /bar
GET /favicon.ico

Зверніть увагу, навіть якщо немає записів для відображення, налагоджувач вимкнено — нам потрібно надіслати сигнал USR1 ще раз, якщо ми хочемо підключитися знову.

Підсумки

У цій статті ми взяли запущений HTTP-сервер Node.js та внесли у нього скрипт з іншого локального процесу Node.js — щоб він реєстрував усі вхідні HTTP-запити.

Це підкреслює потужність протоколу Chrome DevTools: у запущеному процесі можна програмно змінити будь-що.

Не факт, що цей метод дійсно би працював у реальному житті. Однак є багато інструментів, які б допомогли під час налагодження і показали б, як працював би Node.js з методами, які наведені у цій статті. Зрештою, просто погратися з віддаленим налагодженням у Node.js може бути доволі цікаво.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.9K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація