Русский
Русский
English
Статистика
Реклама

Namespaces

Namespaces в JavaScript

12.11.2020 00:07:35 | Автор: admin

Мне очень сильно импонируют namespace'ы в таких языках программирования, как Java и PHP. Настолько сильно, что я даже как-то запилил о них статью на Хабре. С тех пор прошло уже почти два года, но namespace'ы в JavaScript за это время так и не появились. "А если бы я делал namespace'ы в JS для самого себя, то какими бы они были?" - подумалось мне. Под катом - мои соображения, какие же namespace'ы мне нужны в JavaScript'е.

Вводная

Все мои рассуждения ниже применяются к ES6-модулям и не касаются других форматов (AMD, UMD, CommonJS) просто потому, что мне интересно смотреть, куда движется JavaScript, а не где он был. Также я в своей практике как-то достаточно плотно столкнулся с GWT, после чего у меня образовалось стойкое неприятие различных транспиляторов (а также, до кучи, минификаторов и обфускаторов). Поэтому vanilla JS и никакого TS. Ну вот есть у меня такие пункты.

ES6-модули

ES-модуль - это отдельный файл с исходным кодом, в котором в явном виде определены элементы, доступные снаружи модуля:

export function fn() {/*...*/}

Таким образом, для начала, нужно каким-то образом адресовать отдельные ES-модули в рамках всего приложения.

Пакеты

Современные приложения состоят из отдельных пакетов, зависимостями между которыми управляют менеджеры пакетов. А уже сами пакеты состоят из отдельных модулей. Отдельный разработчик (vendor) может разложить свой код по множеству пакетов, используя одни и те же пакеты в различных своих приложениях. Исходный код модулей обычно помещают в отдельный каталог внутри пакета (например, ./src).

Менеджеры пакетов помещают все пакеты одного приложения в каталог node_modules. Таким образом, структура nodejs-приложения, собранного из пакетов определённого разработчика, может выглядеть примерно в таком виде:

* node_modules    * @vendor        * package1            * src                * module1.js                * ...                * moduleN.js        * ...        * packageN            * src                * module1.mjs                * ...                * moduleN.mjs

Адресация модулей

В файловой структуре исходный код любого ES-модуля адресуется путём к файлу относительно корня приложения:

./node_modules/@vendor/package1/src/module1.js..../node_modules/@vendor/packageN/src/moduleN.mjs

В рамках загрузчика модулей nodejs-приложения часть./node_modules/уходит:

import SomeThing from '@vendor/package1/src/module1.js';

Если обращаться к модулям в рамках одного пакета, то отпадает и имя пакета, а адресация задаётся относительно точки вызова импорта:

import SomeThing from './module1.js';

Если развернуть web-сервер так, чтобы корень web-приложения указывал на папкуnode_modules, то с индексной страницы web-приложения можно будет загружать ES-модули, используя почти такие же адреса, как и в nodejs:

<script type="module">    import {fn} from './@vendor/package1/src/module1.js'    fn();</script>

или при помощи динамического импорта:

<script>    import('./@vendor/package1/src/module1.js').then((mod) => {        mod.fn();    });</script>

Текущая проблема адресации

Проблема в том, что для web'а нужно использовать./в самом начале. Если попробовать использовать просто:

import {fn} from '@vendor/package1/src/module1.js'

то браузер выдаст ошибку:

Uncaught TypeError: Failed to resolve module specifier "@vendor/package1/src/module1.js". Relative references must start with either "/", "./", or "../".

Таким образом, при импорте имеется три варианта адресации одного и того же ES-модуля:

  • локальный (внутри пакета):./module1.js

  • серверный (nodejs):@vendor/package1/src/module1.js

  • браузерный (web):./@vendor/package1/src/module1.js

Мы должны при импорте применять адреса модулей без ./ в nodejs-приложениях, и адреса с ./ для браузеров.

Другими словами, чтобы один и тот же JS-код мог быть использован и на сервере, и в браузере без изменений, нужно применять динамический импорт и вычислять адреса модулей (и лучше всего - абсолютные) на лету, в зависимости от среды выполнения (сервер или браузер).

Логическая адресация

Если "натянуть сову на глобус" (провести аналогию с другими ЯП, в которых есть namespace'ы), то каждому ES-модулю в структуре приложения можно поставить в соответствие некоторую строку (логический адрес), которая могла бы однозначно адресовать положение ES-модуля в пространстве исходных файлов приложения, одинаковую, как для nodejs, так и для браузера.

Например, мы можем опустить символы./, которые требуются в браузере, а также расширение (как правило, внутри пакета используется одно и то же расширение для исходников):

@vendor/package1/src/module1

Обычно в пакете исходники находятся в каком-то каталоге:./src/,./lib/,./dist/. Эту часть также можно опустить из адреса модуля - очень редко, когда все исходники в пакете размазывают по разным каталогам, а не помещают в один:

@vendor/package1/module1

Я не вижу, что ещё можно удалить из этой строки, поэтому пусть она и будет являться логическим адресом модуля.

Namespace mapping

Чтобы подобная адресация заработала, нужно составить карту соответствия логических адресов физическим адресам пакетов и расширениям, в них используемым. Можно даже добавить какой-нибудь промежуточный каталог для web-карты, если наnode_modulesуказывает не корневой каталог web-сервера (в примере -./packages/):

const node = {    '@vendor/package1': {path: '/.../node_modules/@vendor/package1/src', ext: 'js'},    '@vendor/packageN': {path: '/.../node_modules/@vendor/packageN/src', ext: 'mjs'},};const browser = {    '@vendor/package1': {path: 'https://.../packages/@vendor/package1/src', ext: 'js'},    '@vendor/packageN': {path: 'https://.../packages/@vendor/packageN/src', ext: 'mjs'},};

Module loader

Далее нужен загрузчик, который бы сопоставлял 'логические' адреса модулей (типа@vendor/package1/module1) их физическим адресам (абсолютным или относительным - лучше абсолютным) в зависимости от среды использования (node или браузер):

@vendor/package1/module1 => /.../node_modules/@vendor/package1/src/module1.js       // node@vendor/packageN/moduleN => https://.../packages/@vendor/packageN/src/moduleN.mjs   // browser

и использовать его для динамического импорта модулей. Разумеется, нет нужды маппить каждый модуль в пакете - достаточно замаппить корень пакета. На выходе получаем нечто подобное:

const loader = new ModuleLoader();loader.addNamespace('@vendor/package1', {path: '/.../node_modules/@vendor/package1/src', ext: 'js'});// ...loader.addNamespace('@vendor/packageN', {path: '/.../node_modules/@vendor/packageN/src', ext: 'js'});const module1 = await loader.import('@vendor/package1/module1');

Импорт модулей должен быть асинхронный, т.к. внутри будет использоваться асинхронная функция import().

Резюме

Вот таким элегантным образом можно было бы перейти от физической адресации ES-модулей при импорте к их логической адресации (namespace'ам) и использовать одни и те же модули как для nodejs-приложений, так и в браузере. Ничего нового тут не придумано (нечто подобное уже сделано в PHP, откуда эта идея и спёрта).

Подробнее..
Категории: Javascript , Es6 , Esmodules , Import , Namespaces

Перевод Глубокое погружение в Linux namespaces, часть 4

31.03.2021 04:09:55 | Автор: admin

В завершающем посте этой серии мы рассмотрим Network namespaces. Как мы упоминали в вводном посте, network namespace изолирует ресурсы, связанные с сетью: процесс, работающий в отдельном network namespace, имеет собственные сетевые устройства, таблицы маршрутизации, правила фаервола и т.д. Мы можем непосредственно увидеть это на практике, рассмотрев наше текущее сетевое окружение.


Команда ip


Поскольку в этом посте мы будем взаимодействовать с сетевыми устройствами, мы вернем жесткое требование наличия прав суперпользователя, которое мы смягчили в предыдущих постах. С этого момента мы будем предполагать, что как ip, так и isolate будут запускаться с sudo.

$ ip link list1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:002: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000    link/ether 00:0c:29:96:2e:3b brd ff:ff:ff:ff:ff:ff

Звездой шоу здесь является команда ip швейцарский армейский нож для работы с сетью в Linux и мы будем активно использовать её в этом посте. Прямо сейчас мы только что выполнили подкоманду link list, чтобы увидеть, какие сетевые устройства в настоящее время доступны в системе (здесь есть lo loopback-интерфес и `ens33, ethernet-интерфейс LAN.


Как и со всеми другими пространствами имён, система стартует с начальным network namespace, которому принадлежат все процесс процессы, если не задано иное. Выполнение команды ip link list как есть показывает нам сетевые устройства, принадлежащие изначальному пространству имён (поскольку и наш шелл, и команда ip принадлежат этому пространству имён).


Именованные пространства имён Network


Давайте создадим новый network namespace:


$ ip netns add coke$ ip netns listcoke

И снова мы использовали команду ip. Подкоманда netns позволяет нам играться с пространствами имён network: например, мы можем создавать новые сетевые пространства network с помощью подкоманды add команды netns и использовать list для их вывода.
Вы могли заметить, что list возвращал только наш вновь созданный namespace. Разве он не должен возвращать по крайней мере два, одним из которых был бы исходным namespace, о котором мы упоминали ранее? Причина этого в том, что ip создаёт то, что называется named network namespace, который является просто network namespace, идентифицируемый уникальным именем (в нашем случае coke). Только именованные пространства имён network отображаются подкомандой list, а изначальный network namespace не именованный.


Проще всего получить именованные пространства имён network. Например, в каждом именованном network namespace создаётся файл в каталоге /var/run/netns и им сможет воспользоваться процесс, который хочет переключиться на свой namespace. Другое свойство именованных пространств имён network заключается в том, что они могут существовать без наличия какого-либо процесса в отличие от неименованных, которые будут удалены как только завершатся все принадлежащие им процессы.


Теперь, когда у нас есть дочерний network namespace, мы можем взглянуть на сеть с его точки зрения.


Мы будем использовать приглашение командной строки C$ для обозначения шелла, работающего в дочернем network namespace.

$ ip netns exec coke bashC$ ip link list1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Запуск субкоманды exec $namespace $command выполняет $command в именованном network namespace $namespace. Здесь мы запустили шелл внутри пространства имён coke и посмотрели доступные сетевые устройства. Мы видим, что, по крайней мере, наше устройство ens33 исчезло. Единственное устройство, которое видно, это лупбек и даже этот интерфейс погашен.


C$ ping 127.0.0.1connect: Network is unreachable

Мы должны теперь свыкнуться с тем, что настройки по умолчанию для пространств имён обычно очень строгие. Для пространств имён network, как мы видим, никаких устройств, помимо loopback, не будет доступно. Мы можем поднять интерфейс loopback без всяких формальностей:


C$ ip link set dev lo upC$ ping 127.0.0.1PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.034 ms...

Сетевое изолирование


Мы уже начинаем понимать, что запустив процесс во вложенном network namespace, таком как coke, мы можем быть уверены, что он изолирован от остальной системы в том, что касается сети. Наш шелл-процесс, работающий в coke, может общаться только через loopback. Это означает, что он может общаться только с процессами, которые также являются членами пространства имён coke, но в настоящее время других таких процессов нет (и, во имя изолированности, мы хотели бы, чтобы так и оставалось), так что он немного одинок. Давайте попробуем несколько ослабить эту изолированность. Мы создадим туннель, через который процессы в coke смогут общаться с процессами в нашем исходном пространстве имён.


Сейчас любое сетевое общение должно происходить через какое-то сетевое устройство, а устройство может существовать ровно в одном network namespace в данный конкретный момент времени, поэтому связь между любыми двумя процессами в разных пространствах имён должна осуществляться как минимум через два сетевых устройства по одному в каждом network namespace.


Устройства veth


Для выполнения этого нашего требования, мы будем использовать сетевое устройство virtual ethernet (или сокращённо veth). Устройства veth всегда создаются как пара устройств, связанных по принципу туннеля, так что сообщения, отправленные на одном конце, выходят из устройства на другом. Вы могли бы предположить, что мы могли бы легко иметь один конец в исходном network namespace, а другой в нашем дочернем network namespace, а всё общение между пространствами имён network проходило бы через соответствующее оконечное устройство veth (и вы были бы правы).


# Создание пары veth (veth0 <=> veth1)$ ip link add veth0 type veth peer name veth1# Перемещение veth1 в новое пространство имён$ ip link set veth1 netns coke# Просмотр сетевых устройств в новом пространстве имёнC$ ip link list1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:007: veth1@if8: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000    link/ether ee:16:0c:23:f3:af brd ff:ff:ff:ff:ff:ff link-netnsid 0

Наше устройство veth1 теперь появилось в пространстве имён coke. Но чтобы заставить пару veth работать, нам нужно назначить там IP-адреса и поднять интерфейсы. Мы сделаем это в каждом соответствующем network namespace.


# В исходном пространстве имён$ ip addr add 10.1.1.1/24 dev veth0$ ip link set dev veth0 up# В пространстве имён cokeC$ ip addr add 10.1.1.2/24 dev veth1C$ ip link set dev veth1 upC$ ip addr show veth17: veth1@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000    link/ether ee:16:0c:23:f3:af brd ff:ff:ff:ff:ff:ff link-netnsid 0    inet 10.1.1.2/24 scope global veth1       valid_lft forever preferred_lft forever    inet6 fe80::ec16:cff:fe23:f3af/64 scope link       valid_lft forever preferred_lft forever

Мы должны увидеть, что интерфейс veth1 поднят и имеет назначенный нами адрес 10.1.1.2. Тоже самое должно произойти с veth0 в исходном пространстве имён. Теперь у нас должна быть возможность сделать интер-namespace ping между двумя процессами, запущенными в обоих пространствах имён.


$ ping -I veth0 10.1.1.2PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.64 bytes from 10.1.1.2: icmp_seq=1 ttl=64 time=0.041 ms...C$ ping 10.1.1.1PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=0.067 ms...

Реализация


Исходный код к этому посту можно найти здесь.

Как обычно, теперь мы попытаемся воспроизвести то, что мы рассматривали до сих пор, в коде. В частности, нам нужно будет сделать следующее:


  1. Выполнить команду в новом network namespace.
  2. Создать пару veth (veth0 <=> veth1).
  3. Переместить устройство veth1 в новый namespace.
  4. Назначить IP-адреса обоим устройствам и поднять их.

Шаг 1 прост: мы создаём наш командный процесс в новом пространстве имён network путём добавления флага CLONE_NEWNET к clone:


int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWNET;


Для остальных шагов мы будем преимущественно использовать Netlink интерфейс чтобы общаться с Linux. Netlink в основном используется для связи между обычными приложениями (вроде isolate) и ядром Linux. Он предоставляет API поверх сокетов на основе протокола, который определяет структуру и содержание сообщения. Используя этот протокол, мы можем отправлять сообщения, которые получает Linux и преобразует в запросы вроде создать пару veth с именами veth0 и veth1.


Давайте начнем с создания нашего сокета netlink. При этом мы укажем, что хотим использовать протокол NETLINK_ROUTE этот протокол охватывает реализации сетевой маршрутизации и управления устройствами.


int create_socket(int domain, int type, int protocol){    int sock_fd = socket(domain, type, protocol);    if (sock_fd < 0)        die("cannot open socket: %m\n");    return sock_fd;}int sock_fd = create_socket(  PF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);


Сообщение в netlink это четырехбайтовый выровненный блок данный, содержащий заголовок (struct nlmsghdr) и полезную нагрузку. Формат заголовка описан здесь. Модуль The Network Interface Service (NIS) определяет формат (struct ifinfomsg), с которого должна начинаться полезная нагрузка, относящаяся к управлению сетевым интерфейсом.


Наш следующий запрос будет представлен следующей структурой C:


#define MAX_PAYLOAD 1024struct nl_req {    struct nlmsghdr n;     // Заголовок сообщения Netlink    struct ifinfomsg i;    // Полезная нагрузка начинается с информации модуля NIS    char buf[MAX_PAYLOAD]; // Остальная полезная нагрузка};


Модуль NIS требует, чтобы полезная нагрузка была закодирована как атрибуты Netlink. Атрибуты обеспечивают способ сегментировать полезную нагрузку на подсекции. Атрибут имеет тип и длину в дополнение к полезной нагрузке, содержащей сами данные.


Полезная нагрузка в сообщении Netlink будет закодирована как список атрибутов (где любой такой атрибут, в свою очередь, может иметь вложенные атрибуты), а у нас будет несколько вспомогательных функций для заполнения его атрибутами. В коде атрибут представлен в заголовочном файле linux/rtnetlink.h структурой rtattr как:


struct rtattr {  unsigned short  rta_len;  unsigned short  rta_type;};

rta_len это длина полезной нагрузки атрибута, что следует в памяти сразу за структурой rt_attr struct (то есть следующие rta_len байты). Как интерпретируется содержимое этой полезной нагрузки, задается rta_type, а возможные значения полностью зависят от реализации получателя и отправляемого запроса.


В попытке собрать всё это вместе, давайте посмотрим, как isolate делает netlink запрос для создания для создания пары veth с помощью следующей функции create_veth, которая выполняет шаг 2:


// ip link add ifname type veth ifname name peernamevoid create_veth(int sock_fd, char *ifname, char *peername){    __u16 flags =            NLM_F_REQUEST  // Это сообщение запроса            | NLM_F_CREATE // Создание устройства, если оно не существует            | NLM_F_EXCL   // Если оно уже существует, ничего не делать            | NLM_F_ACK;   // Ответ с подтверждением или ошибкой    // Инициализация сообщения запроса.    struct nl_req req = {            .n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)),            .n.nlmsg_flags = flags,            .n.nlmsg_type = RTM_NEWLINK, // Это сообщение netlink            .i.ifi_family = PF_NETLINK,    };    struct nlmsghdr *n = &req.n;    int maxlen = sizeof(req);    /*     * Создание атрибута r0 с информацией о veth. Например, если ifname - veth0,     * тогда нижеследующее будет добавлено к сообщению     * {     *   rta_type: IFLA_IFNAME     *   rta_len: 5 (len(veth0) + 1)     *   data: veth0\0     * }     */    addattr_l(n, maxlen, IFLA_IFNAME, ifname, strlen(ifname) + 1);    // Добавление вложенного атрибута r1 в r0, содержащего информацию iface    struct rtattr *linfo =            addattr_nest(n, maxlen, IFLA_LINKINFO);    // Указание типа устройства veth    addattr_l(&req.n, sizeof(req), IFLA_INFO_KIND, "veth", 5);    // Добавление еще одного вложенного атрибута r2    struct rtattr *linfodata =            addattr_nest(n, maxlen, IFLA_INFO_DATA);    // Следующий вложенный атрибут r3 содержит имя соседнего устройства, например veth1    struct rtattr *peerinfo =            addattr_nest(n, maxlen, VETH_INFO_PEER);    n->nlmsg_len += sizeof(struct ifinfomsg);    addattr_l(n, maxlen, IFLA_IFNAME, peername, strlen(peername) + 1);    addattr_nest_end(n, peerinfo); // конец вложенного атрибута r3    addattr_nest_end(n, linfodata); // конец вложенного атрибута r2    addattr_nest_end(n, linfo); // конец вложенного атрибута r1    // Отправка сообщения    send_nlmsg(sock_fd, n);}

Как мы видим, нам нужно быть точными в том, что мы отправляем сюда: нам нужно было закодировать сообщение точно так, как оно будет интерпретироваться реализацией ядра, и здесь нам потребовалось три вложенных атрибута для этого. Я уверен, что это где-то задокументировано, хотя, немного погуглив, мне не удалось этого найти в основном я разобрался с помощью strace и исходного кода команды ip.


Далее, для шага 3, этот метод, который, учитывая имя интерфейса ifname и network namespace файлового дескриптора netns, перемещает устройство, связанное с этим интерфейсом, в указанный network namespace.


// $ ip link set veth1 netns cokevoid move_if_to_pid_netns(int sock_fd, char *ifname, int netns){    struct nl_req req = {            .n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)),            .n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK,            .n.nlmsg_type = RTM_NEWLINK,            .i.ifi_family = PF_NETLINK,    };    addattr_l(&req.n, sizeof(req), IFLA_NET_NS_FD, &netns, 4);    addattr_l(&req.n, sizeof(req), IFLA_IFNAME,              ifname, strlen(ifname) + 1);    send_nlmsg(sock_fd, &req.n);}

После создания пары veth и перемещения одного конца в наш целевой network namespace, на шаге 4 мы назначаем IP-адреса обоим конечным устройствам и поднимаем их интерфейсы. Для этого у нас есть вспомогательная функция if_up, которая, учитывая имя интерфейса ifname и IP-адрес ip, назначает ip устройству ifname и поднимает его. Для краткости мы не показываем их тут, но вместо этого они могут быть найдены здесь.


Наконец, мы объединяем эти методы, чтобы подготовить наш network namespace для нашего командного процесса.


static void prepare_netns(int child_pid){    char *veth = "veth0";    char *vpeer = "veth1";    char *veth_addr = "10.1.1.1";    char *vpeer_addr = "10.1.1.2";    char *netmask = "255.255.255.0";    // Создание нашего сокета netlink    int sock_fd = create_socket(            PF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE);    // ... и нашей пары veth veth0 <=> veth1.    create_veth(sock_fd, veth, vpeer);    // veth0 находится в нашем текущем (исходном) namespace    // так что мы можем сразу поднять его.    if_up(veth, veth_addr, netmask);    // ... veth1 будет перемещен в namespace команды.    // Для этого нам нужно получить файловый дескриптор    // и перейти в namespace команды, но сначала мы должны    // запомнить наш текущий namespace, чтобы мы могли вернуться в него    // когда закончим.    int mynetns = get_netns_fd(getpid());    int child_netns = get_netns_fd(child_pid);    // Перемещение veth1 в network namespace команды.    move_if_to_pid_netns(sock_fd, vpeer, child_netns);    // ... и переход туда    if (setns(child_netns, CLONE_NEWNET)) {        die("cannot setns for child at pid %d: %m\n", child_pid);    }    // ... и поднятие veth1-интерфейса    if_up(vpeer, vpeer_addr, netmask);    // ... перед возвращением в наш исходный network namespace.    if (setns(mynetns, CLONE_NEWNET)) {        die("cannot restore previous netns: %m\n");    }    close(sock_fd);}

Затем мы можем вызвать prepare_netns сразу после того, как мы закончим настройку нашего user namespace.


    ...    // Получение записываемого конца пайпа.    int pipe = params.fd[1];    prepare_userns(cmd_pid);    prepare_netns(cmd_pid);    // Сигнал командному процессу, что мы закончили настройку.    ...

Давайте попробуем!


$ sudo ./isolate sh===========sh============$ ip link list1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:0031: veth1@if32: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP qlen 1000    link/ether 2a:e8:d9:df:b4:3d brd ff:ff:ff:ff:ff:ff# Проверим связность между пространствами имён$ ping 10.1.1.1PING 10.1.1.1 (10.1.1.1): 56 data bytes64 bytes from 10.1.1.1: seq=0 ttl=64 time=0.145 ms
Подробнее..

Перевод Глубокое погружение в Linux namespaces, часть 3

31.03.2021 04:09:55 | Автор: admin

Mount namespaces изолируют ресурсы файловых систем. Это по большей части включает всё, что имеет отношение к файлам в системе. Среди охватываемых ресурсов есть файл, содержащий список точек монтирования, которые видны процессу, и, как мы намекали во вступительном посте, изолирование может обеспечить такое поведение, что изменение списка (или любого другого файла) в пределах некоторого mount namespace инстанса M не будет влиять на этот список в другом инстансе (так что только процессы в M увидят изменения)


Точки монтирования


Вам может быть интересно, почему мы так сфокусировались на кажущимся произвольно выбранном файле, содержащим в себе список точек монтрирования. Что в нём такого особенного? Список точек монтирования даёт процессу полное описание доступных файловых систем в системе и, поскольку мы пребываем на территории Linux с мантрой всё есть файл, видимость почти каждого ресурса диктуется этим описанием: от фактических файлов и устройств до информации о том, какие другие процессы также запущены в системе. Таким образом, это даёт огромный выигрыш в безопасности для isolate, позволяющий точно указывать о каких именно частях системы будут в курсе команды, которые мы хотим выполнить. Пространства имён mount в сочетании с точками монтирования являются очень мощным инструментом, который позволит нам этого достичь.


Мы можем видеть точки монтирования, видимые для процесса с id $pid посредством файла /proc/$pid/mounts его содержимое одинаково для всех процессов, принадлежащих к тому же mount namespace, что и $pid:


$ cat /proc/$$/mounts.../dev/sda1 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0...

В списке, полученном на моей системе, видно устройство /dev/sda1, смонтированное в / (ваше может быть другим). Это дисковое устройство, на котором размещена корневая файловая система, которая содержит всё что нужно для запуска и правильной работы системы, поэтому было бы здорово, если бы isolate запускала команды без ведома о таких файловых системах.


Давайте начнём с запуска терминала в его собственном mount namespace:


Строго говоря, нам не понадобится доступ уровня суперпользователя для работы с новыми пространствами имён mount, поскольку мы добавим процедуры настройки user namespace из предыдущего поста. В результате в этом посте мы предполагаем, что только команды unshare в терминале выполняются от суперпользователя. Для isolate в таком предположении необходимости нет.

# Флаг -m создаёт новый mount namespace.$ unshare -m bash$ cat /proc/$$/mounts.../dev/sda1 / ext4 rw,relatime,errors=remount-ro,data=ordered 0 0...

Хммм, мы всё еще можем видеть тот же самый список, что и в корневом mount namespace. Особенно после того, как в предыдущем посте стало ясно, что новый user namepace начинается с чистого листа, может показаться, что флаг -m, который мы передали unshare, не дал никакого эффекта.
Процесс шелла фактически выполняется в другом mount namespace (мы можем убедиться в этом, сравнив файл симлинка ls -l /proc/$$/mnt с файлом другой копии шелла, работающей в корневом mount namespace). Причина, по которой мы все еще видим тот же список, заключается в том, что всякий раз, когда мы создаем новый mount namespace (дочерний), в качестве дочернего списка используется копия точек монтирования mount namespace, в котором происходило создание (родительского). Теперь любые изменения, которые мы вносим в этот файл (например, путём монтирования файловой системы), будут невидимы для всех других процессов.
Однако изменение практически любого другого файла на этом этапе будет влиять на другие процессы, поскольку мы всё ещё ссылаемся на те же самые файлы (Linux только делает копии особых файлов, таких как список точек монтирования). Это означает, что сейчас у нас минимальная изолированность. Если мы хотим ограничить то, что будет видеть наш командный процесс, мы должны сами обновить этот список.


Теперь, с одной стороны, поскольку мы пытаемся позаботиться о безопасности, мы могли бы просто сказать нах* всё и сделать в isolate полную очистку содержимого этого списка перед выполнение команды. Но это сделает запуск команды бесполезным, поскольку каждая программа, по крайней мере, зависит от ресурсов, вроде файлов операционной системы, которые, в свою очередь, обеспеченны какой-то файловой системой. С другой стороны, мы могли бы просто выполнить команду как есть, расшарив на неё те же файловые системы, что содержат необходимые системные файлы. Но это сводит на нет цель этого производимого нами дальше изолирования.


Лучшее решение предоставить программе собственную копию зависимостей и системных файлов, которые требуются для запуска целиком в "песочнице", чтобы она могла вносить в них какие-либо изменения, не влияя на другие программы в системе. По лучшему сценарию мы можем поместить эти файлы в файловую систему и смонтировать её как корневую файловую систему (в корневой каталог /) до выполнения ничего не подозревающей программы. Идея заключается в том, что поскольку всё, что доступно процессу, должно достигаться через корневую файловую систему, и поскольку мы будем точно знать, какие файлы мы туда помещаем для командного процесса, мы будем спокойны, зная, что он должным образом изолирован от остальной системы.


Хорошо, в теории это звучит хорошо и для реализации этого мы сделаем следующее:


  1. Создадим копию зависимостей и системных файлов, необходимых команде.
  2. Создадим новый mount namespace.
  3. Заменим корневую файловую систему в новом mount namespace на ту, которая содержит копии наших системных файлов.
  4. Выполним программу в новом mount namespace.

Корневые файловые системы


Уже на шаге 1 возникает вопрос: какие системные файлы нужны команде, которую мы хотим запустить? Мы могли бы порыться в нашей собственной корневой файловой системе и, задаваясь этим вопросом по каждому файлу, с которым мы столкнемся, брать только те, на которые ответ положительный, но это выглядит больно и излишне. Кроме того, какую команду будет выполнять isolate, мы изначально не знаем.


Вот если бы только у людей уже была такая же проблема и они собрали набор системных файлов, в целом достаточный, чтобы служить базой прямо из коробки для большинства программ? К счастью, есть много проектов, что реализовали это! Одним из них является проект Alpine Linux (его основное предназначение, это когда вы начинаете свой Dockerfile с FROM alpine:xxx). Alpine предоставляет корневые файловые системы, которые мы можем использовать для наших целей. Если вы последуете инструкциями, то сможете получить копию их минимальной корневой файловой системы (MINI ROOT FILESYSTEM) для x86_64 здесь. Последней версией на момент написания поста и которую мы будем использовать, является v3.10.1.


$ wget http://dl-cdn.alpinelinux.org/alpine/v3.10/releases/x86_64/alpine-minirootfs-3.10.1-x86_64.tar.gz$ mkdir rootfs$ tar -xzf alpine-minirootfs-3.10.1-x86_64.tar.gz -C rootfs$ ls rootfsbin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

В каталоге rootfs есть знакомые файлы, прям как в нашей собственной корневой файловой системе в /, но убедитесь, насколько он минимален многие из этих каталогов пусты:


$ ls rootfs/{mnt,dev,proc,home,sys}# пусто

Отлично! Мы можем дать команду, которая запустится в копии этого окружения, и она может быть даже sudo rm -rf /, но нас это не будет волновать, а никто другой не пострадает.


Pivot root


Имея наш новый mount namespace и копию системных файлов, мы хотели бы смонтировать эти файлы в корневом каталоге нового mount namespace не выбивая землю из под наших ног. Linux предлагает нам системный вызов pivot_root (есть соответствующая команда), который позволяет нам контролировать то, что именно процессы видят как корневую файловую систему.
Команда принимает два аргумента: pivot_root new_root put_old, где new_root это путь к файловой системе, будущей вскоре корневой файловой системой, а put_old путь к каталогу. Это работает так:


  1. Монтирование корневой файловой системы вызывающего процесса в put_old.
  2. Монтирование new_root в качестве корневой файловой системы в /.

Давайте посмотрим на это в действии. В нашем новом mount namespace мы начинаем с создания файловой системы из наших файлов alpine:


$ unshare -m bash$ mount --bind rootfs rootfs

Затем мы делаем pivot root:


$ cd rootfs$ mkdir put_old$ pivot_root . put_old$ cd /# Теперь у нас должен быть новый корневой каталог. Например, если мы сделаем:$ ls proc# proc пуст# И старый корневой каталог теперь в put_old$ ls put_oldbin   dev  home        lib    lost+found  mnt  proc  run   srv  tmp  varboot  etc  initrd.img  lib64  media       opt  root  sbin  sys  usr  vmlinuz

Наконец, мы размонтируем старую файловую систему из put_old, так что вложенный шелл не сможет получить к ней доступ.


$ umount -l put_old

При этом мы можем запускать любую команду в нашем шелле, и она будут работать с использованием нашей специфичной корневой файловой системы alpine, пребывая в неведении об инструментарии, что привел к её запуску. И наши драгоценные файлы в старой файловой системе находятся вне пределов досягаемости.


Реализация


Исходный код к этому посту можно найти здесь.

Мы можем повторить в коде то, что делали выше, заменив команду pivot_root соответствующим системным вызовом. Сначала мы создаем наш команды процесс в новом mount namespace, добавляя флаг CLONE_NEWNS для clone.


int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER | CLONE_NEWNS;

Затем мы создаём функцию prepare_mntns которая, получив путь до каталога, содержащего системные файлы (rootfs), настраивает текущий mount namespace посредством pivoting'а корневого каталога текущего процесса на rootfs, что мы создали ранее.


static void prepare_mntns(char *rootfs){    const char *mnt = rootfs;    if (mount(rootfs, mnt, "ext4", MS_BIND, ""))        die("Failed to mount %s at %s: %m\n", rootfs, mnt);    if (chdir(mnt))        die("Failed to chdir to rootfs mounted at %s: %m\n", mnt);    const char *put_old = ".put_old";    if (mkdir(put_old, 0777) && errno != EEXIST)        die("Failed to mkdir put_old %s: %m\n", put_old);    if (syscall(SYS_pivot_root, ".", put_old))        die("Failed to pivot_root from %s to %s: %m\n", rootfs, put_old);    if (chdir("/"))        die("Failed to chdir to new root: %m\n");    if (umount2(put_old, MNT_DETACH))        die("Failed to umount put_old %s: %m\n", put_old);}

Нам нужно вызвать эту функцию из нашего кода и это должно быть выполнено нашим командным процессом в cmd_exec (поскольку он работает в новом mount namespace) до фактического начала выполнения команды.


    ...    // Ожидание сигнала 'настройка завершена' от основного процесса.    await_setup(params->fd[0]);    prepare_mntns("rootfs");    ...

Давайте попробуем это:


$ ./isolate sh===========sh============$ ls put_old# put_old пуст. Ура!# Как выглядит наш новый список монтирования?$ cat /proc/$$/mountscat: cant open '/proc/1431/mounts': No such file or directory# Хммм, а какие ещё процессы запущены?$ ps auxPID   USER     TIME  COMMAND# Пусто! А?

Этот вывод показывает что-то странное: мы не можем проверить список монтирования, за который так тяжело боролись, и ps говорит нам, что нет процессов, запущенных в системе (нет даже текущего процесса или самого ps?). Более вероятно, что мы что-то сломали при настройке mount namespace.


PID Namespaces


Мы уже несколько раз упоминали каталог /proc в этой серии постов, и если вы были знакомы с ним, то, вероятно, не будете удивлены тому, что вывод ps оказался пустым, поскольку мы видели ранее, что каталог был пуст в этом mount namespace (когда мы получили его из корневой файловой системы alpine).


Каталог /proc в Linux обычно используется для доступа к специальной файловой системе (называемой файловой системой proc), которой управляет сам Linux. Linux использует его для предоставления информации обо всех процессах, запущенных в системе, а также другой системной информации, касающейся устройств, прерываний и так далее. Всякий раз, когда мы запускаем такую команду, как ps, выдающую сведения о процессах в системе, она обращается к этой файловой системе для получения информации.
Другими словами, нам нужно завести файловую систему proc. К счастью, в основном для это потребуется лишь сообщить Linux, что она нам нужна, причем желательно смонтированная в /proc. Но пока мы не можем этого сделать, поскольку наш командный процесс всё ещё зависит от той же файловой системы proc, что и isolate и любой другой обычный процесс в системе. Чтобы избавиться от этой зависимости, нам нужно запустить его внутри собственного PID namespace.


PID namespace изолирует ID процессов в системе. Одним из следствий тут является то, что выполняющиеся в разных пространствах имён PID процессы могут иметь одинаковые идентификаторы процесса, не конфликтуя друг с другом. Допусти, мы изолируем это пространство имён потому, что мы хотим обеспечить как можно большую изолированность нашей запущенной команде. Однако более интересная причина, по которой мы рассматриваем это здесь, заключается в том, что монтирование файловой системы proc требует привилегий пользователя root, а текущий PID namespace принадлежит пользователю root, где у нас нет достаточных привилегий (если вы помните из предыдущего поста, root у командного процесса на самом деле не root). Итак, мы должны работать в PID namespace, владельцем которого является пользователь пространства имён, которое считает наш командный процесс запущенным от root.


Мы можем создать новый PID namespace, передав CLONE_NEWPID для clone:


int clone_flags = SIGCHLD | CLONE_NEWUTS | CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID;

Затем мы добавляем функцию prepare_procfs, которая настраивает файловую систему proc, монтируя её в текущих пространствах имён mount и pid.


static void prepare_procfs(){    if (mkdir("/proc", 0555) && errno != EEXIST)        die("Failed to mkdir /proc: %m\n");    if (mount("proc", "/proc", "proc", 0, ""))        die("Failed to mount proc: %m\n");}

Наконец, мы вызываем функцию прямо перед размонтированием put_old в нашей функции prepare_mntns, после того, как мы настроили mount namespace и перешли в корневой каталог.


static void prepare_mntns(char *rootfs){  ...    prepare_procfs();    if (umount2(put_old, MNT_DETACH))        die("Failed to umount put_old %s: %m\n", put_old);  ...}

Мы можем воспользоваться isolate для очередного запуска:


$ ./isolate sh===========sh============$ psPID   USER     TIME  COMMAND    1 root      0:00 sh    2 root      0:00 ps

Это выглядит намного лучше! Шелл считает себя единственным процессом, запущенным в системе и работающем с PID 1(поскольку это был первый процесс, запущенный в этом новом PID namespace)


В этом посте были рассмотрены два пространства имён и isoated получил в итоге две новые фичи. В следующем посте мы посмотрим на изолирование в пространствах имён Network. Там нам придётся иметь дело с некоторой сложной низкоуровневой сетевой конфигурацией, чтобы попытаться включить сетевое взаимодействие между процессами в разных пространствах имён network.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru