Tailcat: netcat поверх WireGuard без сервера Tailscale

Логотип Tailcat

«Tailscale без Tailscale, от Tailscale»

Tailcat — это переработка открытых компонентов Tailscale, которая ведёт себя как netcat, но работает поверх плоскости данных (data plane) Tailscale без использования его плоскости управления (control plane). Плоскость данных Tailscale (внутри — magicsock) организует зашифрованные по WireGuard® туннели точка-точка между двумя машинами: DERP выступает вспомогательным каналом для пробоя NAT (NAT hole-punching) и последним резервным ретранслятором, если прямое соединение установить не удалось. Вместо плоскости управления Tailscale все метаданные соединения tailcat передаются внеполосно (out of band) любым удобным способом.

CLI-утилита tailcatcmd/tailcat) построена на Go-библиотеке tailcat, доступной для импорта как github.com/tailscale/tailcat.

Независимо от того, используете ли вы tailcat как CLI-инструмент или как библиотеку, одна сторона запускает сервер tailcat (слушатель) и получает короткий адрес tailcat. Другая сторона передаёт этот адрес клиентской части tailcat для подключения. Весь трафик между двумя сторонами шифруется сквозным образом с помощью WireGuard. Начальное соединение устанавливается через DERP-сервер (см. ниже), после чего magicsock выполняет пробой NAT и при возможности переключается на прямое UDP-соединение между узлами (что обычно и происходит).

Вам не нужна учётная запись Tailscale или права root/администратора на машине — утилита не изменяет таблицы маршрутизации, DNS и прочие системные настройки. Это просто библиотека для пользовательского пространства и CLI-инструмент.

И всё это открытый исходный код.

Можно использовать наши бесплатные DERP-ретрансляторы с ограничением скорости (карта DERP по умолчанию: https://tailcat.dev/derpmap.json) или развернуть собственные.

Также существует экспериментальная веб-демонстрация прямо в браузере (tailcat, скомпилированный в WebAssembly) по адресу https://tailscale.github.io/tailcat/ — там можно отправлять и получать файлы или текст в связке с CLI. Трафик в браузере передаётся только через DERP, без прямых соединений — до тех пор, пока не появится поддержка WebRTC (#4).

Установка

Подробности по каждому способу, включая заметки для сборщиков пакетов, см. в INSTALL.md:

Способ Linux macOS Windows FreeBSD, OpenBSD Браузер (js/wasm)

Статические бинарники

.deb-пакеты

Debian, Ubuntu, …​

.rpm-пакеты

Red Hat, Fedora, …​

Homebrew

Scoop

Образ контейнера

Nix

AUR

Arch

conda-forge

Сборка из исходников

Использование

Передача stdin/stdout между двумя машинами

Сервер запускается и выводит свой эфемерный адрес:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

После этого клиент может выполнить:

$ echo hello | tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
$

Тогда сервер разблокируется:

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
hello
$

Проброс локальных портов через туннель

Можно также обслуживать локальный TCP-порт, перенаправляя его на localhost:

$ tailcat serve 8080,8443 # или: tailcat serve all
# 🐈 Server listening with new address: tcXXXXXXXXX

И на стороне клиента:

$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....

Проброс локальных портов на tailcat-сервер

Чтобы сделать порты tailcat-сервера доступными как обычные локальные TCP-порты (для браузеров, клиентов баз данных и других инструментов, которые не поддерживают SOCKS или stdio), запустите forward с адресом tailcat-сервера:

$ tailcat serve 8080,3306
# 🐈 Server listening with new address: tcXXXXXXXXX

$ tailcat forward tcXXXXXXXXX 18080:8080 3306

Локальный порт 0 просит операционную систему выделить свободный порт; каждый слушатель выводит свой адрес, как только начинает работать.

Чтобы пробросить локальные порты к ресурсам сети, доступным через сервер в режиме выходного узла (exit node), запустите сервер в этом режиме и укажите в таблице соответствий удалённые IP-адреса и порты:

$ tailcat serve exit-node
# 🐈 Server listening with new address: tcXXXXXXXXX

$ tailcat forward tcXXXXXXXXX \
    3001:172.23.52.30:3001 \
    17170:172.23.52.31:17170

Это перенаправит 127.0.0.1:3001 на 172.23.52.30:3001 и 127.0.0.1:17170 на 172.23.52.31:17170 через сервер в режиме выходного узла.

По умолчанию слушатели привязываются к 127.0.0.1, а диагностические логи скрыты. Передайте --verbose перед подкомандой, чтобы включить подробное логирование сети. Используйте --bind=0.0.0.0 только если к слушателю должны подключаться клиенты с других машин:

$ tailcat forward --bind=0.0.0.0 tcXXXXXXXXX 18080:8080

Нажмите Ctrl-C, чтобы остановить проброс.

Открытие браузера к tailcat-серверу

Чтобы открыть веб-сервер за tailcat-сервером, выполните browse:

$ tailcat serve 80
# 🐈 Server listening with new address: tcXXXXXXXXX

$ tailcat browse tcXXXXXXXXX

Это псевдоним для tailcat forward --open-browser <tc-addr> 0:80: как только локальный слушатель готов, команда открывает http://127.0.0.1:<порт>/ в браузере, затем блокируется, перенаправляя соединения, пока её не прервут. Флаг --open-browser работает с любым одиночным сопоставлением портов в forward.

SSH-сервер с аутентификацией по публичному ключу

Запустите SSH-сервер, принимающий ключи из локальных файлов authorized_keys, явно заданных строк публичных ключей OpenSSH или учётных записей GitHub:

$ tailcat serve --ssh-authorized-keys=~/.ssh/authorized_keys ssh
# 🐈 Server listening with new address: tcXXXXXXXXX

Несколько источников можно перечислить через запятую. Источник вида user@github загружает https://github.com/user.keys один раз, до запуска сервера:

$ tailcat serve --ssh-authorized-keys=bradfitz@github,./contractor.pub ssh

Каждый источник должен существовать, успешно загружаться и содержать допустимые строки публичных ключей — иначе запуск завершится ошибкой. Опции авторизованных ключей, такие как command= и from=, отклоняются, поскольку встроенный сервер их не реализует. Запуск tailcat serve ssh без --ssh-authorized-keys также завершается ошибкой; используйте явный сервис no-auth-ssh, когда достаточно лишь идентичности туннеля.

SSH-сервер без аутентификации

На Linux, macOS и Windows можно явно запустить SSH-сервер без аутентификации клиента. Зашифрованный туннель сам по себе обеспечивает идентификацию клиента.

$ tailcat serve no-auth-ssh
# 🐈 Server listening with new address: tcXXXXXXXXX
Внимание

При использовании no-auth-ssh адрес и есть учётные данные: любой, кто его узнает, получит доступ к оболочке от имени пользователя, запустившего сервер. Передавайте адрес только по закрытым каналам и никогда не публикуйте его — ни в DNS TXT-записях, ни где-либо ещё. Если вам нужен SSH-сервер, доступный по DNS-имени, он обязан требовать аутентификацию клиента: ограничение на уровне туннеля через --allow, на уровне SSH через --ssh-authorized-keys, или оба сразу. Никогда не публикуйте адрес сервера с no-auth-ssh.

На стороне клиента:

$ tailcat ssh tcXXXXXXXXX
$ tailcat ssh tcXXXXXXXXX ls -la

Запуск команды для каждого соединения

Подобно inetd, сервис exec запускает команду для каждого входящего соединения, передавая само соединение как stdin и stdout команды. Команда указывается после --:

$ tailcat serve exec -- /usr/bin/fortune
# 🐈 Server listening with new address: tcXXXXXXXXX
$ tailcat tcXXXXXXXXX 80 < /dev/null

Stderr команды направляется в stderr сервера. Команда получает публичный ключ узла-партнёра в переменной $TAILCAT_PEER_KEY (в формате --allow) и IP:порт партнёра в $TAILCAT_REMOTE_ADDR.

При использовании с сервисом ssh или no-auth-ssh команда заменяет оболочку, как ForceCommand в OpenSSH: каждая SSH-сессия выполняет только эту команду (с PTY, если клиент его запрашивает), а сервер не предоставляет ни оболочки, ни команды по выбору клиента, ни SFTP. Запрошенная клиентом команда, если есть, поступает в $SSH_ORIGINAL_COMMAND.

$ tailcat serve --ssh-authorized-keys=alice@github ssh -- ./deploy.sh
$ tailcat serve no-auth-ssh -- git-upload-pack /srv/repo.git

Отправка и получение файлов

Чтобы принимать файлы, создайте «ящик для приёма» и поделитесь выведенным адресом tailcat:

$ tailcat recv ~/inbox
# 🐈 Server listening with new address: tcXXXXXXXXX

Отправитель выполняет:

$ tailcat cp report.pdf tcXXXXXXXXX:

tailcat cp запускает системный scp, маршрутизируя соединение через tailcat, — вы получаете стандартное отображение прогресса и флаг -r для деревьев каталогов. Ящик для приёма доступен только на запись: отправители не могут просматривать каталог, читать файлы обратно или изменять существующие.

Чтобы предложить файлы, раздайте каталог только для чтения (по умолчанию) или для чтения и записи:

$ tailcat serve files                  # текущий каталог, только чтение
$ tailcat serve --files=/pub:rw files  # указанный каталог, чтение и запись
$ tailcat ls -l tcXXXXXXXXX
$ tailcat cp tcXXXXXXXXX:report.pdf .

tailcat ls работает напрямую по протоколу SFTP, поэтому работает даже без установленного OpenSSH.

Сервер ограничивает все пути обслуживаемым каталогом (через Go-интерфейс os.Root), так что ни .., ни символические ссылки из него не выходят. Файловый сервис использует SFTP, поэтому стандартные клиенты sftp и scp тоже работают с ним при наличии ProxyCommand, пробрасывающего соединение через tailcat (тот же приём, что использует tailcat cp и tailcat ssh). Оба сервера — ssh и no-auth-ssh — тоже раздают SFTP с теми же правами доступа, что и оболочка.

Передача файлов не сжимается: в протоколе SFTP сжатие отсутствует, транспортный уровень SSH здесь его тоже не применяет (стек SSH на Go его не реализует; сжатие на транспортном уровне имеет историю проблем с безопасностью, и TLS от него тоже отказался). Если сжатие важно, архивируйте файлы перед отправкой.

Прочие команды

Ping для проверки связности; каждый «pong» сообщает, пришёл ли он через DERP-ретранслятор или по прямому пути. --until-direct продолжает пинговать (не дольше --timeout, по умолчанию 10 с) до получения прямого пути и завершается с ненулевым кодом, если прямой путь так и не установился:

$ tailcat ping --until-direct <tc-addr>
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

Выполнить команду через SOCKS5-прокси, маршрутизированный через туннель:

$ tailcat socks <tc-addr> curl http://server.tailcat:8081/

Адреса tailcat работают непосредственно как имена хостов в URL: SOCKS-прокси их распознаёт и устанавливает соединение, поэтому аргумент с адресом tailcat необязателен. (Адреса tailcat чувствительны к регистру; это работает с curl и большинством CLI-инструментов, но не с браузерами, которые переводят имена хостов в нижний регистр.)

$ tailcat socks curl http://<tc-addr>:8081/

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

$ tailcat serve exit-node

Разобрать адрес tailcat и вывести его содержимое (публичный ключ WireGuard сервера и информацию о DERP) в формате JSON, не устанавливая соединения:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "RegionID": 302
}

Преобразовать короткий адрес tailcat (который ссылается на DERP-регион по ID, требуя от клиентов загрузки карты DERP) в более длинный самодостаточный адрес со встроенными данными DERP-сервера — это позволяет клиентам подключаться быстрее:

$ tailcat resolve tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

Разбор этого развёрнутого адреса tailcat показывает встроенные данные DERP:

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA
{
    "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "tc302a.ipn.dev",
                    "IPv4": "208.111.39.38",
                    "IPv6": "2607:f740:0:3f::720"
                }
            ]
        }
    ]
}

Сервер может вывести длинный самодостаточный адрес напрямую с помощью флага tailcat serve --full-address.

Управление ключами

Адрес tailcat-сервера содержит его публичный ключ WireGuard и независимый предварительно распределённый ключ (pre-shared key) WireGuard, поэтому именно сохранённые ключи определяют, кто может к вам подключиться:

  • Эфемерные ключи (по умолчанию): при каждом запуске сервер генерирует новый ключ в памяти и выводит адрес, который никто прежде не видел. Когда процесс завершается, ключ уничтожается, а адрес перестаёт работать навсегда. Это безопасный вариант по умолчанию: переданный адрес относится лишь к тому конкретному запуску.

  • Сохранённые ключи: tailcat genkey генерирует ключ, сохраняемый на диске, — адрес остаётся стабильным между перезапусками. Обратная сторона: любой, кому вы когда-либо передавали этот адрес, сможет подключиться к любому будущему серверу с этим ключом — если только вы не ограничите клиентов через tailcat serve --allow (см. tailcat genkey --client).

При запуске CLI сообщает, какой вид ключа используется, — так вы всегда знаете, запускаете ли вы свежий одноразовый сервер или снова прослушиваете адрес, который могли передавать в прошлом.

Предварительно распределённые ключи WireGuard включены по умолчанию и настоятельно рекомендуются. Для совместимости с клиентами tailcat версии v0.5.0 и более ранних флаг --psk=false в serve или genkey создаёт более короткие адреса, однако лишает постквантовой защиты и защиты от публичных DERP-операторов, наблюдающих публичные ключи узлов.

$ tailcat genkey --key=default --region=nyc
# выводит адрес tailcat; ключ сохраняется в ~/.config/tailcat/keys/default.private.json

# позже; ключ с именем "default" используется автоматически, как только существует:
$ tailcat serve 8080
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

# ... если только не форсировать разовый эфемерный ключ:
$ tailcat serve --key=new 8080
# 🐈 Server listening with new address: tcXXXXXXXXX

Иными словами, default — это магическое имя ключа: как только оно существует, обычный tailcat молча использует его вместо генерации эфемерного ключа, и строка запуска выше сообщает вам, что именно произошло. Используйте --key=new, чтобы принудительно получить эфемерный ключ, --key=<имя> — для использования другого сохранённого ключа, или tailcat genkey --delete --key=default — для удаления сохранённого ключа по умолчанию. tailcat genkey --list выводит список ваших сохранённых ключей.

Адреса tailcat также можно публиковать как DNS TXT-записи и обращаться к ним по имени; DNS-имя работает везде, где CLI принимает адрес tailcat:

# Если у example.com есть TXT-запись "tailcat=tc..."
$ tailcat example.com 8080
$ tailcat ssh example.com
$ tailcat ping example.com
Внимание

Адрес tailcat — это, как правило, секрет: именно знание адреса позволяет клиенту подключиться. DNS TXT-запись не является секретом. Она публична, доступна для чтения всем и активно сканируется. Публикация адреса в DNS открывает его всему интернету, поэтому сервер за ним должен аутентифицировать клиентов иным способом, а не по знанию адреса: ограничивайте туннель известными ключами клиентов через tailcat serve --allow=…​, или для SSH требуйте публичные ключи через tailcat serve --ssh-authorized-keys=…​ ssh. Никогда не публикуйте адрес сервера с no-auth-ssh (и любого другого сервера, доверяющего любому подключившемуся): это оболочка на вашей машине, опубликованная в TXT-записи. Безопасная схема описана в разделе «Защищённый SSH-сервер через DNS».

Примеры

Защищённый SSH-сервер через DNS

Зачем нужны проброс портов или port knocking? Здесь SSH-сервер доступен из любой точки по имени, без открытых входящих портов на сервере, а WireGuard аутентифицирует клиента прежде, чем SSH-сервер вообще увидит первый пакет.

Внимание

Флаг --allow ниже — не необязательное украшение. DNS TXT-запись делает адрес tailcat публичным, поэтому само обладание адресом больше ничего не доказывает: сервер должен аутентифицировать клиентов самостоятельно — здесь разрешён только один ключ клиентского узла. Без --allow (или --ssh-authorized-keys на уровне SSH) любой в интернете, кто прочитал TXT-запись, сможет подключиться.

На клиентской машине сгенерируйте пару ключей клиентской идентичности. Команда выводит публичный ключ — это всё, что нужно знать серверу:

client$ tailcat genkey --client --key=client-default
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

На сервере сгенерируйте серверную пару ключей, привязанную к ближайшему DERP-региону (причина — ниже), затем запустите SSH с доступом только для этого клиента:

server$ tailcat genkey --key=default --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX

server$ tailcat serve --allow=nodekey:cfb6bf...ddfd16 22
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

Опубликуйте адрес tailcat в DNS как TXT-запись:

my-server.example.com. 300 IN TXT "tailcat=tcXXXXXXXXX"

И тогда на стороне клиента достаточно:

client$ tailcat ssh my-server.example.com

Клиентские режимы автоматически используют сохранённый ключ client-default, когда он существует, — дополнительные флаги для предъявления разрешённой идентичности не нужны. Рукопожатия от посторонних молча игнорируются: они не достигнут SSH-сервера и не узнают, что он вообще работает.

В качестве дополнительной защиты tailcat ssh перед подключением к адресу с DNS-именем выполняет проверку: пробует войти по SSH как посторонний — со свежесгенерированным клиентским ключом и без SSH-учётных данных. Если сервер принимает такой вход, значит, любой, кто прочитает TXT-запись, может сделать то же самое, — поэтому tailcat отказывается подключаться и объясняет причину. Проверка выявляет неправильную конфигурацию при первом же тестировании собственного сервера; флаг --skip-dns-safety-check её пропускает — если вы действительно хотите публичный сервер или просто хотите обойтись без лишних круговых задержек.

Зачем --fixed-region: он один раз определяет ближайший DERP-регион в момент генерации ключа и вписывает его ID как в выведенный адрес tailcat, так и в сохранённый файл ключа, — благодаря этому при перезапусках сервер привязывается к тому же региону (сохраняя опубликованный адрес действительным) без повторного зондирования. В противном случае genkey по умолчанию использует --region=auto, что означает «выбрать при запуске»: подходит для разового использования, но адрес tailcat, опубликованный в DNS, должен указывать на фиксированный регион, чтобы клиенты и будущие перезапуски сервера встречались в одном месте. (--region=<имя> явно привязывает конкретный регион; --region=list показывает варианты.)

TODO: сделать клиент более устойчивым, если карта DERP изменится со временем: https://github.com/tailscale/tailcat/issues/7

Собственный DERP-ретранслятор

Ничто не обязывает использовать ретрансляторы Tailscale: разверните собственный DERP-сервер (ему нужно имя хоста с TLS-сертификатом — derper может получить его сам через Let’s Encrypt), затем сгенерируйте серверный ключ, который будет его использовать, передав имя хоста (или несколько, через запятую) как регион:

server$ tailcat genkey --key=default --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ

server$ tailcat serve 22

Адрес tailcat встраивает имя хоста вашего ретранслятора:

$ tailcat parse tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
{
    "ServerPublic": "nodekey:8022c28ea8f52ec7a0a51b644ce00fef3aae150731a01c61a3abd3ac26e14a49",
    "Region": [
        {
            "Nodes": [
                {
                    "HostName": "derp.example.com"
                }
            ]
        }
    ]
}

Клиентам не нужны никакие дополнительные флаги, они никогда не обращаются к серверу карты DERP или ретрансляторам Tailscale, а единственные ограничения скорости — ваши собственные. Если же вы эксплуатируете целый парк ретрансляторов, раздайте собственный JSON карты DERP и укажите его обеим сторонам через --derpmap-url.

Библиотека Go

Минимальный сервер, который отвечает на любой TCP-порт через туннель и выводит свой адрес tailcat. Нулевое значение Server подставляет умолчания для всего незаданного: свежий эфемерный ключ, ближайший регион карты DERP по умолчанию и логирование через log.Printf (задайте Logf в logger.Discard для тишины):

package main

import (
	"fmt"
	"log"
	"net"

	"github.com/tailscale/tailcat"
)

func main() {
	s := &tailcat.Server{
		OnTCP: func(port uint16) func(net.Conn) {
			return func(c net.Conn) {
				fmt.Fprintf(c, "hello from port %v\n", port)
				c.Close()
			}
		},
	}
	if err := s.Start(); err != nil {
		log.Fatal(err)
	}
	fmt.Println(s.TailcatAddr())
	select {}
}

И минимальный клиент, который к нему подключается, получив адрес tailcat в качестве аргумента. Как и Server, нулевое значение Client работает с одним лишь заполненным полем Server (адрес tailcat); tailcat.NewClient — это сокращение именно для такого случая. Туннель устанавливается лениво при первом вызове dial:

package main

import (
	"context"
	"io"
	"log"
	"os"

	"github.com/tailscale/tailcat"
)

func main() {
	cl := tailcat.NewClient(tailcat.Addr(os.Args[1]))
	defer cl.Close()
	c, err := cl.DialTCPPort(context.Background(), 80)
	if err != nil {
		log.Fatal(err)
	}
	io.Copy(os.Stdout, c)
}
$ ./client tcomFwWCAWf933BLELdzd3RkHiOufJ...
hello from port 80

UDP использует подключённый пакетный коннект (connected packet connection) для каждого клиентского потока, сохраняя границы дейтаграмм и адреса обоих концов:

s.OnUDP = func(port uint16) func(tailcat.ConnPacketConn) {
	if port != 53 {
		return nil
	}
	return func(c tailcat.ConnPacketConn) {
		defer c.Close()
		buf := make([]byte, tailcat.MaxUDPPayload)
		for {
			n, err := c.Read(buf)
			if err != nil {
				return
			}
			c.Write(buf[:n])
		}
	}
}

pc, err := cl.DialUDPPort(context.Background(), 53)

ConnPacketConn реализует одновременно net.Conn и net.PacketConn. Держите полезную нагрузку на уровне tailcat.MaxUDPPayload (1232 байта) или ниже, чтобы вписаться в MTU IPv6-туннеля без фрагментации. Для трафика через выходной узел используйте OnUDPForward и DialUDP; ProxyPacketConns обеспечивает безопасную двунаправленную пересылку дейтаграмм. Неактивные серверные UDP-потоки закрываются по истечении tailcat.DefaultUDPIdleTimeout (две минуты); чтобы изменить тайм-аут, задайте Server.UDPIdleTimeout.

Как это работает

Адреса tailcat

Сервер tailcat идентифицируется адресом tailcat — Go-типом tailcat.Addr. Адрес выглядит как tcXYZ…​ и представляет собой префикс "tc", за которым следует base64-кодированный CBOR, содержащий:

  • Публичный ключ WireGuard сервера (Curve25519, 32 байта)

  • Отдельный публичный ключ обнаружения пути (path-discovery key) (Curve25519, 32 байта)

  • По умолчанию — независимый предварительно распределённый ключ WireGuard (256 случайных бит), который не позволяет DERP-оператору, наблюдающему публичные ключи обоих узлов, влиться в туннель, и обеспечивает постквантовую защиту записанного трафика

  • Информацию о DERP — в одном из двух видов:

    1. небольшое целое число, ссылающееся на один из управляемых Tailscale tailcat-серверов по умолчанию, или

    2. полные метаданные DERP-сервера — для использования собственного DERP-сервера или чтобы избавить клиента от потенциального круговорота за актуальной картой DERP (эту форму производят флаг tailcat serve --full-address и подкоманда tailcat resolve)

Типичный адрес tailcat с одним лишь целочисленным ID региона занимает около 140 байт. Со встроенными данными DERP-узла он длиннее, но самодостаточен.

Адрес по умолчанию является секретной возможностью доступа (secret bearer capability), поскольку содержит предварительно распределённый ключ. Передавайте его только клиентам, которым следует подключаться. Публикация адреса — в публичной DNS TXT-записи или где-либо ещё — открывает эту возможность всему интернету. Это безопасно лишь тогда, когда сервер также аутентифицирует клиентов: serve --allow ограничивает туннель перечисленными ключами клиентских узлов, а сервис ssh требует --ssh-authorized-keys.

Сетевой стек

Tailcat повторно использует клиентские сетевые компоненты Tailscale, но без плоскости управления.

  • WireGuard — реализация WireGuard в пользовательском пространстве для шифрования всего туннельного трафика. Не использует ядерное устройство TUN/TAP и не настраивает никаких сетевых маршрутов или DNS, поэтому права root не требуются.

  • magicsock — транспортный уровень Tailscale, мультиплексирующий трафик через прямой UDP и DERP-ретрансляторы. Обрабатывает обнаружение конечных точек на основе STUN и пробой UDP-NAT для обхода трансляции адресов.

  • Netstack (gVisor) — стек TCP/IP в пользовательском пространстве, завершающий TCP-соединения внутри процесса. Именно он позволяет Tailcat принимать входящие соединения и устанавливать исходящие без какой-либо настройки сети в ОС.

  • DERP-ретранслятор — зашифрованный протокол ретрансляции Tailscale, используемый как канал встречи (rendezvous) и резервный путь данных при невозможности прямого соединения.

Схема установки соединения

  1. Сервер запускается. Генерирует (или загружает) пару ключей WireGuard и по умолчанию предварительно распределённый ключ, подключается к DERP-ретранслятору и выводит свой адрес tailcat в stderr. Затем ждёт клиентов.

  2. Клиент разбирает адрес tailcat, чтобы узнать публичный ключ сервера, ключ обнаружения пути, опциональный предварительно распределённый ключ и DERP-регион. Генерирует собственную эфемерную пару ключей и подключается к тому же DERP-ретранслятору. Отдельный ключ обнаружения пути может присутствовать в открытом виде в фреймах прямого пути (disco frames) без раскрытия публичного ключа WireGuard. Предварительно распределённый ключ остаётся секретной возможностью доступа к соединению даже тогда, когда оператор ретранслятора наблюдает публичные ключи обоих узлов.

  3. Рукопожатие обнаружения. Клиент отправляет серверу через DERP-ретранслятор сообщение-пинг «Meow». Оно несёт публичный ключ узла клиента. Сервер получает его, добавляет клиента в список узлов WireGuard и сетевую карту, перенастраивает движок WireGuard и отвечает подтверждением «Meowed».

  4. Туннель WireGuard. Когда обе стороны настроены как узлы WireGuard с использованием предварительно распределённого ключа из адреса (при его наличии), выполняется рукопожатие WireGuard (первоначально маршрутизируемое через DERP). После его завершения туннель поднят и зашифрованный трафик может течь.

  5. Пробой NAT. Параллельно каждая сторона сообщает другой свои UDP-конечные точки (публичный IP:порт, полученный через STUN, плюс локальные адреса интерфейсов) в disco-сообщениях call-me-maybe через DERP, повторно сообщая при их изменении. Затем обе стороны запускают disco-протокол Tailscale и предпринимают попытку пробоя UDP. В случае успеха трафик переключается с DERP-ретранслятора на прямой путь точка-точка. Если пробой не удался, DERP продолжает работать как резервный вариант — соединение сохраняется, хотя пропускная способность на публичных DERP-ретрансляторах будет ограничена.

  6. Передача данных. Клиент устанавливает TCP-соединение с портом сервера через туннель. Стек TCP/IP gVisor на обеих сторонах обрабатывает установку соединения. На сервере входящее соединение диспетчеризируется обработчику по порту: проброс на localhost, вывод в stdout, SSH-сессия и т.д.

Адресация

Каждый узел в настоящее время получает детерминированный IPv6-адрес из своего публичного ключа WireGuard, однако это деталь реализации, не раскрываемая конечным пользователям, которая может измениться. (Например, эти байты могут быть полностью удалены из IP-заголовков, возвращая потерянный MTU.)

Безопасность

О том, как сообщать об уязвимостях, а также о текущей модели угроз tailcat — см. SECURITY.md.

Стабильность

Tailcat можно использовать бесплатно, однако без каких-либо гарантий стабильности API или CLI: Go-API, флаги CLI и выводимый текст, а также формат протокола могут изменяться. Публичные DERP-ретрансляторы tailcat с ограничением скорости не имеют SLA по доступности или целевых показателей пропускной способности, и мы можем в любой момент по любой причине закрыть доступ к ним. Всё предоставляется на принципе «наилучших усилий» без договорных обязательств (например, выделенных DERP-ретрансляторов и/или поддержки).

Обратиться к продажам?

Если вы не хотите самостоятельно запускать и поддерживать инфраструктуру или вам нужна помощь — свяжитесь с отделом продаж, и мы обменяем деньги на товары и услуги.

История

Tailcat зародился в сентябре 2023 года под именем «derpcat» — написан на борту длинного рейса за просмотром плохих фильмов: первый набросок — коммит 9e4d925cc («cmd/dc: start of derpcat tool»), первый рабочий вариант — коммит 911915fbb («derpcat: it’s alive!», в сообщении к которому указано «UA 605 PDX-ORD en route to Ireland. yay not buying the wifi.»). Тогда проект жил внутри форка репозитория tailscale.com и несколько раз деградировал по мере развития внутренностей Tailscale. С тех пор мы возродили его и преобразовали в обычный Go-модуль, использующий репозиторий tailscale.com как зависимость, а не форкающий его.

Проект был открыт в августе 2026 года на конференции TailscaleUp.

© 2026 meganuke