2018-05-20

О DynamoDB

А продолжим о DynamoDB.
Краткое содержание предыдущей серии. DynamoDB — одна из старейших облачных NoSQL БД. Живёт в облаке Амазона (aka AWS).
Модель данных у DynamoDB очень напоминает таковую у кассандрового CQL. Есть таблицы. В таблицах хранятся itemы. В таблице определён первичный ключ, по которому ищутся itemы. Первичный ключ состоит из обязательного partition key (он же hash key) и необязательного sort key (он же range key).
Partition key определяет партицию, куда будут помещены данные. Поиск возможен только по полному совпадению значения этого ключа. Получается, что любая операция в БД работает только с единственной партицией.
Sort key отсортирован. Возможен поиск по диапазону значений этого ключа.
Кроме ключей в itemах можно хранить атрибуты. Каждый атрибут имеет имя и значение. Значения бывают разных типов: строки, числа, множества (set), списки (list) и карты (map). Элементами множеств, списков и карт могут быть другие типы. Таким образом можно хранить любые структурированные данные, а ля JSON.
DynamoDB Schema
Посмотрим подробнее, как можно строить запросы. В Spring, как всегда, есть свои обёртки над всякоразными API разных БД. А в стандартном амазоновом SDK вся пляска идёт вокруг объекта Table (если мы не используем мапинг в Java объекты). Для запросов этот объект реализует интерфейс QueryApi. Самый мощный его метод выглядит так:
ItemCollection<QueryOutcome> query(QuerySpec spec)
Соответственно, всё, что может QuerySpec, может и DynamoDB. А ItemCollection результата можно просто засунуть в for-each цикл и вытащить все itemы.
В каждом запросе должны или могут присутствовать:
  • Точное значение partition key.
  • Условие выборки по sort key. Больше, меньше, между, начинается с подстроки.
  • Направление сортировки по sort key. Можно в обе стороны.
  • Список атрибутов, которые нужно извлечь. Проекция.
  • Фильтры для дальнейшего уточнения выборки. По любым атрибутам можно проверить кучу условий. Больше, меньше, между, существует ли, содержит ли подстроку. Важно, что лишь первичный ключ ограничивает набор просматриваемых itemов в хранилище. Фильтры лишь отсеивают itemы, которые нужно вернуть. Поэтому нужно хорошо думать о первичных ключах, чтобы они ограничивали выборку, а фильтры использовать лишь как вспомогательный инструмент.
  • Условие объединения фильтров, если их больше одного. «И» или «ИЛИ».
  • Флаг строгой целостности. Да, DynamoDB пытается предоставить какие-то гарантии, чтобы чтение после записи могло прочитать только что записанные данные.
  • Ограничения на количество просматриваемых записей, размер и количество страниц. Под капотом DynamoDB работает через HTTP, и размер страницы ограничивает размер одного HTTP ответа. Каждая страница возвращается отдельным HTTP ответом. Все эти тонкости хорошо запрятаны в SDK и на уровне итерации по результатам запроса почти незаметны.
DynamoDB Query
У нас есть два взаимно исключающих способа работы с DynamoDB. Это касается условия выборки по ключу, проекций и фильтров.
Первый способ — более старый. Раньше появился в API. Здесь вы указываете точные значения ключей, имена атрибутов, конкретные операции и значения для сравнения.
Такой запрос на Kotlin выглядит примерно так:
val filters = sensors.map { QueryFilter(it).exists() }

val querySpec = QuerySpec()
    .withHashKey("u", location)
    .withRangeKeyCondition(RangeKeyCondition("t").between(
        Instant.parse("2018-04-20T00:00:00Z").epochSecond,
        Instant.parse("2018-04-21T00:00:00Z").epochSecond))
    .withScanIndexForward(false)
    .withAttributesToGet(*sensors.toTypedArray())
    .withConditionalOperator(ConditionalOperator.OR)
    .withQueryFilters(*filters.toTypedArray())
    .withMaxResultSize(1000)

val items = table.query(querySpec)
for (item in items) {
    // ...
}
Второй способ — более новый. Здесь появляется понятие выражений, expressions. Для ключей, проекций и фильтров. Текстовые читабельные выражения. Примерно такие же, что стоят после WHERE в SQL. И в эти выражения можно подставлять параметры.
Почему-то выделяют два вида параметров. Параметры для имён используются для подстановки имён атрибутов, и выглядят они в выражениях как «#name». Параметры для значений используются для подстановки значений, с которыми будут сравниваться ключи или атрибуты, и выглядят они как «:value». Задаются имена и значения обычными мапами.
Запрос с выражениями на Kotlin выглядит примерно так:
val querySpec = QuerySpec()
    .withKeyConditionExpression(
        "u = :location AND t BETWEEN :start AND :end")
    .withScanIndexForward(false)
    .withProjectionExpression(sensors.mapIndexed { index, _ -> "#attr$index" }
        .joinToString(", "))
    .withFilterExpression(sensors.mapIndexed { index, _ -> "attribute_exists(#attr$index)" }
        .joinToString(" OR "))
    .withMaxResultSize(1000)
    .withNameMap(sensors.mapIndexed { index, name -> "#attr$index" to name }.toMap())
    .withValueMap(mapOf(
        ":location" to location,
        ":start" to Instant.parse("2018-04-20T00:00:00Z").epochSecond,
        ":end" to Instant.parse("2018-04-21T00:00:00Z").epochSecond
    ))

val items = table.query(querySpec)
for (item in items) {
    // ...
}
Я тут замутил, запрашиваю произвольный набор атрибутов и выискиваю itemы, где эти атрибуты присутствуют.
Казалось бы, зачем эта морока с составлением строковых выражений (где ещё и некоторые слова зарезервированы). Объектами ведь проще. Для запросов оно, наверное, и так. Но для апдейтов выражения дают некоторые уникальные возможности.
Conditional Update
Апдейты делаются через интерфейс UpdateItemApi, который, конечно же, реализуется нашим Table.
Дело в том, что я считаю метрики. И интенсивно использую возможность атомарного инкремента числовых значений. Ну и атомарного добавления элементов во множества тоже.
Оно работает так. Я говорю, что хочу добавить в item с данным первичным ключом, к атрибуту с конкретным именем, такое-то число. Если атрибута или даже всего itemа не существует, он создаётся автоматически. Начальным значением считается нуль, и он атомарно инкрементируется на указанное число. Если атрибут уже существует (и содержит число), его значение просто инкрементируется. Можно даже одним запросом обновить так несколько атрибутов одного itemа. Очень удобно.
Выглядит это примерно так:
val key = PrimaryKey("u_period", hashKey, "t", rangeKey)

val updateOps = listOf(
    AttributeUpdate("${sensor}_count").addNumeric(aggregate.count),
    AttributeUpdate("${sensor}_sum").addNumeric(aggregate.sum)
)

table.updateItem(key, *updateOps.toTypedArray())
AttributeUpdate задаёт операции над конкретным атрибутом. А затем список операций выполняется над конкретным itemом.
И теперь добавляем две магии. Их присутствие сильно подняло крутизну DynamoDB в моих глазах.
Магия первая. Перед апдейтом можно проверить выполнение некоторого условия на любых атрибутах itemа. Если условие выполняется, апдейт происходит. Если условие не выполняется, но наш код получит ConditionalCheckFailedException и может что-то с этим сделать. Условие можно добавить, передав в updateItem помимо AttributeUpdate ещё и коллекцию объектов Expected.
Магия вторая. Оказывается, выражения позволяют обращаться к вложенным атрибутам с указанием пути (path). Если у вас есть атрибут с именем «map», содержащий мапу, а в этой мапе есть поле с именем «field», то можно использовать путь «map.field», чтобы обратиться с значению в мапе. Если где-то там есть список, то можно использовать числовой индекс элемента в списке в квадратных скобках: «list[0]». Как в JSONPath. Появление специальных символов типа точки вносит некоторую неоднозначность, поэтому настоятельно рекомендуется такие символы не использовать в именах атрибутов. Эти вложенные пути работают только в выражениях, и это хороший повод перейти на выражения.
На самом деле, любой апдейт должен или может содержать:
  • Точное значение partition key.
  • Точное значение sort key, если он есть. Как видите, нельзя проапдейтить несколько itemов одной операцией.
  • Набор операций обновления данного itemа или update expression.
  • Набор условий для проверки возможности обновления данного itemа или condition expression.
Итак. А что, если у нас много счётчиков. И они образуют развесистую иерархию. Например, мы хотим подсчитать, сколько раз какой-то сенсор принимал определённые значения. При этом мы знаем название сенсора, и это может быть именем атрибута. Но мы не хотим вдаваться в детали, какие именно значения принимал каждый сенсор. Мы просто засовываем в атрибут мапу, где имена полей будут значениями, а хранить мы будем счётчики, сколько раз это значение встречалось.
И теперь мы хотим атомарно инкрементировать числа, вложенные в мапы. Как мы делали это с числами, которые непосредственно хранились в атрибутах. С вложенными путями в выражениях это возможно. Нужно задать update expression вида «SET map.nested = map.nested + 1». Это будет работать.
Но беда в другом. Сама мапа, если её не существует, нет такого атрибута, не будет создана автоматически. И то же касается всех вложенных полей мапы. Нельзя прибавить число к тому, чего нет.
Проблему можно решить таким алгоритмом. Добавляем condition expression, который проверяет наличие вложенного поля для инкремента. Если условие не срабатывает, ловим исключение и делаем уже другой апдейт: создаём нужное поле мапы, сразу с начальным значением. И ставим здесь другое условие: на существование самой мапы. Если условие не срабатывает, ловим исключение и уже создаём мапу.
Как-то так:
try {
    table.updateItem(key,
        "SET #sensor.#state.#name = #sensor.#state.#name + :inc",
        "attribute_exists(#sensor.#state.#name)",
        mapOf("#sensor" to sensor, "#state" to state, "#name" to name),
        mapOf(":inc" to 1))
} catch (e: ConditionalCheckFailedException) {
    try {
        table.updateItem(key,
            "SET #sensor.#state.#name = :newName",
            "attribute_exists(#sensor.#state)",
            mapOf("#sensor" to sensor, "#state" to state, "#name" to name),
            mapOf(":newName" to 1))
    } catch (e: ConditionalCheckFailedException) {
        // ...
    }
}
Тут получается рекурсивный отлов исключений, в зависимости от глубины вложенности нашей мапы. В худшем случае, когда это совершенно свежий item, придётся сделать соответствующее число неудачных попыток, прежде чем создать сразу вложенную мапу нужной глубины. Зато последующий инкремент того же поля сразу сделает нужный атомарный апдейт.
Не вполне ясно, как тут будет с гонками, сериализуются ли апдейты в одной партиции. Пока вроде работает, но одиночные ошибки, когда разные клиенты будут одновременно пытаться создать мапу, поди ещё отлови.
Обратите внимание, что вложенные пути, которые с точками, должны присутствовать именно в выражении. Если передать строку с точками в качестве параметра, это не будет работать как вложенный путь.
fun UpdateItemApi.tryToIncrement(key: PrimaryKey, path: List<String>, increment: Int) {
    val pathExpression = path.toPathExpression()
    val attributesMap = path.toNameMap()

    try {
        updateItem(
            key,
            "SET $pathExpression = $pathExpression + :inc",
            "attribute_exists($pathExpression)",
            attributesMap,
            mapOf(":inc" to increment)
        )
    } catch (e: ConditionalCheckFailedException) {
        tryToCreateMap(key, path, increment)
    }
}

private fun UpdateItemApi.tryToCreateMap(key: PrimaryKey, path: List<String>, value: Any) {
    val pathExpression = path.toPathExpression()
    val attributesMap = path.toNameMap()

    val upperPath = path.dropLast(1)
    val upperPathExpression = upperPath.toPathExpression()

    if (upperPath.isNotEmpty()) {
        try {
            updateItem(
                key,
                "SET $pathExpression = :new",
                "attribute_exists($upperPathExpression)",
                attributesMap,
                mapOf(":new" to value)
            )
        } catch (e: ConditionalCheckFailedException) {
            val lastName = path.last()
            tryToCreateMap(key, upperPath, mapOf(lastName to value))
        }
    } else {
        updateItem(
            key,
            "SET $pathExpression = :new",
            attributesMap,
            mapOf(":new" to value)
        )
    }
}
С такими мощными условными атомарными обновлениями вложенных полей DynamoDB изрядно приближается по удобству к той же MongoDB. При этом все соглашения по поводу стоимости и производительности остаются в силе. DynamoDB вполне можно использовать для счётчиков realtime подсчёта агрегатов (да, я сам не понял, что сказал :).

2018-05-02

О tinc

Непонятно почему, заинтересовался я VPNами. Оказывается, VPNом называют всё что ни попадя. Совершенно разные технологии, созданные для разных целей.
Путаницы вносит ещё наверное то, что в Android VPNом называют довесок к сетевым настройкам. Приложение, которое может гонять через себя трафик любого другого приложения. Как оно гоняет трафик, через настоящий VPN, через прокси, через /dev/astral — не важно. В Android всё это будет называться VPN.
Typical VPN Vision
Начнём с простого. HTTP proxy. Старый добрый Squid. Или даже маленький его карманный «аналог» Polipo. Как правило это кэширующие прокси. Это значит, что если много пользователей будут запрашивать одну и ту же страницу, эта страница может быть сохранена в кэше и выдана из кэша. Можно сэкономить на трафике. Существенно, кстати. Поэтому раньше провайдеры частенько принудительно заруливали клиентов на прокси. Это называлось transparent proxy.
Но с HTTPS так не выйдет. Потому что тут есть сертификаты, и клиент проверяет идентичность сервера. Поэтому HTTPS трафик пропускается через прокси с помощью метода CONNECT. Получается, что браузер разговаривает с прокси по протоколу HTTP (или HTTPS), и говорит ему: «А соедини-ка меня с тем вот сервером на таком-то порту». И дальше шлёт через прокси произвольный трафик на этот порт. Как правило, прокси сконфигурированы пускать клиентов только на 443 порт.
Ребята пошли дальше и подумали, что вовсе не нужен HTTP, чтобы общаться с прокси. И придумали специальный протокол под названием SOCKS. Это именно протокол общения с прокси. И в текущей его версии под номером пять можно даже попросить прокси перенаправлять UDP датаграммы на нужный сервер.
SOCKS изначально придумывался как способ договориться с межсетевым экраном. Мол, пусти меня туда, потому что. Можно рассматривать это как некий аналог UPnP, только не ограниченный локальной сетью.
SOCKS оказался достаточно удобным, чтобы направить трафик конкретного приложения (которое умеет быть SOCKS клиентом) через некоторое другое приложение-прокси, чтобы это прокси сделало с этим трафиком что-нибудь хитрое. Именно таким образом организуется вход в туннель Shadowsocks или Тёмный Интернет Tor. Обратите внимание, что соответствующие прокси вы запускаете локально, максимум, в той же локальной сети. А вот вход в них, перенаправление, например, трафика браузера, делается через SOCKS.
Можете побаловаться вот такой командой, если у вас есть ssh доступ к какому-нибудь серверу:
$ ssh -D 1080 -f -C -q -N [email protected]
У вас получится маленький прокси «для бедных» на localhost:1080, который будет гонять трафик по ssh через сервер, к которому вы подключились. Можете направить на него трафик того же браузера и посмотреть, будет ли тормозить. В принципе, с помощью программок, называемых общим словом proxifier, можно заслать в SOCKS прокси трафик почти любого приложения, даже которое не знает, что такое SOCKS.
SOCKS прокси — это лишь точка входа в какой-то туннель. И запускать прокси где-то там — не очень хорошая идея. Сам по себе SOCKS не занимается шифрованием данных. И даже пароли аутентификации в SOCKS5 идут открытым текстом.
SOCKS Proxy Usage
И вот тут наконец-то появляется слово VPN. Virtual Private Network. Задача состояла в том, чтобы обеспечить подключение удалённых сотрудников к локальной (частной) сети компании. Или связь нескольких удалённых филиалов. Это — бизнес. Серьёзные ребята вообще прокладывают свой личный кабель между офисами. А те, кто не может, вынуждены пользоваться публичным Интернетом. А чтобы нехорошие конкуренты не подслушали, нужно шифрование.
VPNов такого рода весьма много. Большинство — проприетарные. vpnc, OpenConnect — придумали Cisco, и подключаться там нужно к их железкам. pptp придумали Microsoft, и он лучше всего поддерживался в Windows. Единственный свободный — OpenVPN.
Все эти VPNы работают схожим образом. По принципу старых добрых аналоговых модемов. Клиент явно подключается к серверу, создаётся соединение point-to-point. А дальше весь (или не весь, как настроен клиент) трафик идёт через это соединение. Соединение разорвалось? Надо переподключаться. И всё такое.
А как же связь двух филиалов? Нет, не так. Как же связь двух датацентров? Есть у нас такая задача на одном проекте. Есть несколько географически распределённых датацентров. В каждом сидит несколько серверов. И серверам в разных датацентрах нужно интенсивно общаться. БД там реплицировать. За эталонными данными, чтобы тут закэшировать, сходить. Нужно.
Обычно для такого рода вещей используют туннели. Давным давно я использовал GRE. Просто на машрутизаторе появляется ещё один интерфейс. И нужные пакеты мы пропихиваем через него. И они магическим образом вылезают через подобный интерфейс на другом маршрутизаторе. Хоть пройдя полинтернета между ними. Туннель. И никакого специального соединения не требуется, ибо это всего лишь ещё одна обёртка пакетов перед отправкой.
На самом деле можно даже без специальных протоколов. IP можно заворачивать в IP. IPv4 в IPv6 и наоборот. Это то, что называется ipip, ipipv6, ipip6 и тому подобное. Все эти прелести давно поддерживаются ядром Linux.
Но эти туннели — без шифрования. Просто обёртка пакетов. Можно добавить шифрование IPsec. Если разберётесь, как настроить. И почему-то с этого момента эти самые туннели снова начинают называть VPN.
Но самая крутая штука из этой серии шифрованных туннелей — WireGuard. Написано с нуля. Модные шифры. Работает на уровне модулей ядра, и использует шифрование ядра. Вроде как поддерживается в Android и OpenWrt. Отлично, но дополнительные модули ядра не загрузишь в контейнер OpenVZ. И все эти туннели, как правило, требуют, чтобы оба конца туннеля имели статический адрес, что не всегда возможно.
В общем, встречайте, Tinc. Чудесная штука, ведущая родословную аж с 1998 года. (OpenVPN — с 2001). Шифрованный (aka VPN) многоконечный туннель (aka mesh сеть).
Network
Допустим, у нас есть два датацентра. В одном имеется сеть 10.10.1.0/24 и некий шлюз с публичным интернет адресом 1.2.3.4. В другом имеется сеть 10.10.2.0/24 и некий шлюз с публичным интернет адресом 3.4.5.6. Мы хотим, чтобы сервера в одной сети успешно могли подключаться к серверам в другой сети, и наоборот. Как всегда в таких случаях, сети в двух датацентрах не должны пересекаться.
Демон, который делает tinc, конечно же называется tincd. И у него очень интересная конфигурация. Один демон может обслуживать несколько различных mesh сетей. А для каждой сети нужна отдельная папочка конфигурации. Вот и создадим /etc/tinc/vpntun/hosts. «vpntun» — это название нашей сети. Теоретически, оно может быть любым, но по умолчанию tincd создаст tun интерфейс с этим именем, и лучше, чтобы это было одно слово, без дефисов и подчёркиваний.
В подкаталоге «hosts» нужно сложить файлы хостов. Каждый демон tincd должен знать всех других демонов tincd в той же mesh сети «в лицо». Иначе он откажется иметь с ними дело. Поэтому на наших обоих гейтвеях должно лежать по два файла. Это составляет некоторую сложность в конфигурации tinc. Файлы хостов нужно распространить по всем хостам.
Пусть один файл называется netA:
Address = 1.2.3.4

Subnet = 10.10.1.0/24

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----
А другой называется netB:
Address = 3.4.5.6

Subnet = 10.10.2.0/24

ConnectTo = netA

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----
В этих файлах описана вся конфигурация нашей сети.
Address — это публичный статический адрес, по которому можно подключиться к данному tincd. Если, конечно, он есть. Если нет, ну что ж, значит, этот tincd будет работать в «клиентском» режиме, сам принимать подключения не будет, но может подключаться в другим серверам. Для подключения нужно открыть порт 655 (по умолчанию), и tcp, и udp.
Subnet — сети (можно указать несколько), обслуживаемые данным tincd. Это очень важный параметр. Дело в том, что после того, как пакет будет направлен ОС в tun интерфейс, tincd должен решить, на какой узел mesh сети он должен его направить. Для этого и используются эти сведения. Маршрутизация. Если вы хотите «выйти» из mesh сети в интернеты, вам нужно объявить, что один из узлов «обслуживает» сеть 0.0.0.0/0.
ConnectTo — к какому узлу подключаться. Mesh сеть надо с чего-то начать. И в данном случае сеть B подключается к сети A. Если сетей больше двух, вероятно, имеет смысл настроить так, чтобы все сети подключались к некоторому «центральному» узлу. Дальнейший трафик, согласно идеологии tinc, может идти и напрямую между ближайшими tincd демонами. Но для начала они должны как-то все узнать друг о друге.
А дальше идёт публичный ключ. Тут как в ssh, аутентификация узлов происходит по RSA ключу. Публичные известны всем. А приватные каждый узел хранит у себя. Сгенерить ключи просто. Команда tincd -n vpntun -K4096 создаст файлы /etc/tinc/vpntun/rsa_key.priv и /etc/tinc/vpntun/rsa_key.pub. А ещё допишет публичный ключ в «свой» файл в каталоге hosts.
Какой хост «свой» задаётся в следующем файле конфигурации /etc/tinc/vpntun/tinc.conf. Тут достаточно написать кто мы есть и какую версию IP будем использовать.
Name = netA
AddressFamily = ipv4
Соответственно, на другом хосте вместо «netA» должно быть «netB».
И этого ещё недостаточно. Нужно настроить tun интерфейс, и запульнуть в него нужные маршруты. Делается это двумя скриптами, которые tincd запускает, когда создаёт или гасит интерфейс. Эти файлы должны быть исполняемыми (chmod +x tinc-*).
Файл /etc/tinc/vpntun/tinc-up:
#!/bin/sh

ip link set $INTERFACE up
ip addr add 10.10.1.1/16 dev $INTERFACE

ip route add 10.10.0.0/16 dev $INTERFACE
Что мы тут делаем? Мы подымаем интерфейс. Это понятно. И назначаем этому интерфейсу такой же адрес, что на уже существующем eth1. Это возможно, пока маски на eth1 и vpntun различаются, а они различаются. На самом деле без разницы, какой IP вы навесите на интерфейс туннеля. И лучше навесить адрес из локальной сети, чтобы не запутаться. Однако иметь на интерфейсе туннеля некий уникальный адрес имеет смысл, когда вы подключаете к VPN один единственный хост. Тогда tinc нет нужды знать, в какой локальной сети этот хост сейчас находится, он просто будет слать пакеты на этот единственный уникальный туннельный адрес.
И добавляем маршрут. Если мы знаем, что все наши последующие датацентры будут в сетях 10.10.2.0/24, 10.10.3.0/24 и так далее, мы можем просто зарулить всю сеть 10.10.0.0/16 в tinc. Тогда локалка будет локально. А остальные датацентры будут где-то там, за tinc. Очень удобно.
На другом tincd tinc-up будет таким же, только изменится адрес интерфейса. Он будет 10.10.2.1/16.
Файл /etc/tinc/vpntun/tinc-down:
#!/bin/sh

ip addr del 10.10.1.1/16 dev $INTERFACE
ip link set $INTERFACE down

ip route del 10.10.0.0/16
Если tinс погашен, все следы нужно замести. Хотя не обязательно.
Ну вроде и всё. Много файлов? Ну зато они все на месте и выполняют свою функцию. Как сисадмин говорю, что конфигурация tinc очень удобна. Единственная сложность: синхронизировать содержимое hosts на все узлы. Вероятно, имеет смысл эти файлы вообще держать где-то в системе контроля версий, и рассылать через Ansible. Таки там зашита топология сети.
После создания всех файлов можно запустить tincd в дебаге:
$ sudo tincd -n vpntun -D -d3
И посмотеть, что выйдет. Завершить демона можно по Ctrl + \.
Если всё хорошо, то заставляем эту нашу сеть vpntun подыматься самостоятельно при старте. В файл /etc/tinc/nets.boot нужно добавить строчку vpntun. Ну и убедиться, что сервис tinc стартует при запуске системы.
Tinc Logo
Итак, у нас есть разные задачи. Направить трафик одного приложения (или даже отдельных запросов) куда-нибудь туда, чтобы что-нибудь обойти. Подключить один хост к закрытой сети где-то там. Соединить несколько сетей безопасным образом. И для этого у нас есть прокси, VPN, туннели. А ещё есть tinc, который не просто зашифрованный туннель из одной точки в другую, а волшебный телепорт для передачи пакетов в несколько связанных точек.