Перейти к основному содержанию

Self-hosted Tailscale: веб-интерфейс и вход без паролей

Tailscale решает одну из самых неприятных проблем в администрировании — построение защищённой сети между разрозненными устройствами. WireGuard-шифрование, NAT traversal, mesh-топология — всё работает из коробки. Но координационный сервер Tailscale — проприетарный.

Headscale позволяет запустить его самостоятельно. Только вот управление остаётся в терминале: создание пользователей, регистрация нод, настройка ACL (Access Control List) — всё через CLI. В этой статье я расскажу, как превратить Headscale в полноценную платформу с веб-интерфейсом и единой аутентификацией через passkey, используя Headplane и Pocket ID.

Кратко: Headscale + Headplane + Pocket ID + Caddy дают self-hosted Tailscale с веб-админкой и регистрацией устройств через passkey (без паролей). Развёртывание — 4 Docker-контейнера, ~30 минут на настройку.

Версии: Headscale 0.28, Headplane 0.6.2, Pocket ID v2.3.0 (проверено март 2026).

Требования: Docker (или Podman), два домена с A-записями на IP сервера (scale.example.org, id.example.org), ~512 MB RAM, базовое знакомство с Docker Compose.

Tailscale: зачем нужна частная сеть

Tailscale1 — mesh VPN на базе WireGuard. Он создаёт защищённую сеть (tailnet) между устройствами, где каждое устройство получает стабильный IP-адрес из диапазона 100.64.0.0/10. Подключение peer-to-peer: трафик идёт напрямую между устройствами, а не через центральный сервер.

Что это даёт на практике:

  • Доступ к домашним сервисам откуда угодно. NAS, мониторинг, git-сервер — всё доступно по стабильным IP без проброса портов и динамического DNS
  • Безопасность. WireGuard шифрует весь трафик между узлами. Сервисы не нужно выставлять в публичный интернет
  • NAT traversal. Tailscale пробивает NAT автоматически — устройства находят друг друга даже за несколькими слоями NAT
  • Кроссплатформенность. Клиенты для Linux, macOS, Windows, iOS, Android

Но у облачного Tailscale есть ограничения:

  • Координационный сервер — у Tailscale Inc. Ключи, маршруты, списки устройств хранятся на их серверах. Для личной инфраструктуры это может быть неприемлемо
  • Лимиты бесплатного плана. Ограничение на количество устройств и пользователей
  • Зависимость от сервиса. Если Tailscale Inc. изменит условия, поднимет цены или прекратит работу — сеть перестанет функционировать
  • Лицензия клиентов. С 2024 года клиенты Tailscale распространяются под BSL (Business Source License), а не MIT2

Headscale: собственный координационный сервер

Headscale3 — open-source реализация координационного сервера Tailscale. Один бинарник на Go, SQLite для хранения состояния, совместимость с официальными клиентами Tailscale. Проект зрелый: 36 000+ звёзд на GitHub, активная разработка, один из мейнтейнеров работает в Tailscale Inc. и координирует совместимость протоколов4.

Headscale решает главные проблемы облачного Tailscale:

  • Полный контроль. Ключи, маршруты, ACL — всё на вашем сервере
  • Нет лимитов. Количество устройств и пользователей не ограничено
  • Приватность. Метаданные сети не покидают вашу инфраструктуру
  • Независимость. Работает автономно, не зависит от доступности внешних сервисов

Но за контроль приходится платить:

  • Управление только через CLI. Нет встроенного веб-интерфейса. Создание пользователей, регистрация нод, просмотр маршрутов — всё в терминале
  • Регистрация нод требует участия администратора. Без OIDC каждое новое устройство регистрируется через pre-auth ключ или одобряется вручную из консоли сервера
  • Нет визуализации. Состояние сети — это вывод headscale nodes list в терминале. Нет карты сети, нет удобного просмотра маршрутов и ACL
  • Конфигурация ACL через JSON-файл. Редактирование политик доступа — ручная правка JSON и перезагрузка сервера

Типичный рабочий процесс с Headscale выглядит так:

# Создание пользователя
headscale users create alice

# Генерация pre-auth ключа
headscale preauthkeys create --user alice --reusable --expiration 1h

# На клиентском устройстве
tailscale up --login-server https://scale.example.org --authkey <KEY>

# Просмотр узлов
headscale nodes list

# Управление маршрутами
headscale routes list
headscale routes enable -r 1

Для одного администратора с парой серверов это терпимо. Но когда устройств становится больше десятка, а за сеть отвечают несколько человек — необходимость SSH-доступа к серверу для каждой операции становится узким местом.

Headplane: веб-интерфейс для Headscale

Для Headscale существует несколько community-проектов веб-интерфейса. Headscale-UI5 — минималистичный SPA, который взаимодействует с Headscale через API. Headplane6 — более функциональный вариант, который позиционируется как «feature-complete Web UI for Headscale» и активно развивается. Других зрелых альтернатив на момент написания нет.

Я выбрал Headplane по нескольким причинам:

  • Управление узлами. Просмотр, переименование, удаление, перемещение между пользователями drag-and-drop
  • Редактор ACL. Визуальный редактор политик доступа прямо в браузере
  • DNS-управление. Настройка MagicDNS, split DNS, extra-записей через UI
  • Управление пользователями. Создание, переименование, удаление пользователей, назначение ролей
  • Управление маршрутами. Просмотр и одобрение subnet routes и exit nodes
  • Поддержка OIDC. Аутентификация администраторов через OpenID Connect
  • Активная разработка. Текущая версия — 0.6.2 (февраль 2026), совместимость с Headscale 0.287

Однако у Headplane без OIDC-интеграции есть существенное ограничение. Для взаимодействия с Headscale ему нужен API-ключ. Ключ генерируется из консоли:

headscale apikeys create --expiration 90d

Этот ключ передаётся в конфигурацию Headplane как ROOT_API_KEY. Ключ имеет срок действия — его нужно периодически обновлять вручную. Headplane снимает часть нагрузки с CLI, но не решает проблему полностью: регистрация новых устройств по-прежнему требует генерации pre-auth ключей из консоли или через API.

Pocket ID: простая аутентификация через passkey

Для полноценной работы связки не хватает одного компонента — аутентификации. Headplane поддерживает OIDC (OpenID Connect — протокол единого входа поверх OAuth 2.0), и Headscale тоже. Нужен OIDC-провайдер.

Существуют мощные решения: Keycloak8, Authentik9, Zitadel10. Но для личной инфраструктуры с одним-двумя пользователями они избыточны. Keycloak — Java-приложение, потребляющее сотни мегабайт RAM. Authentik и Zitadel проще, но всё равно требуют настройки пользовательских баз, политик паролей и MFA.

Pocket ID11 решает задачу иначе. Это минималистичный OIDC-провайдер, построенный вокруг одной идеи: аутентификация только через passkey. Никаких паролей, никакой настройки MFA — passkey уже является вторым фактором (биометрия или PIN устройства). Проект написан на Go, потребляет минимум ресурсов, развёртывается одним контейнером.

Что даёт Pocket ID:

  • Passkey-first. Вход через биометрию (Touch ID, Face ID, Windows Hello) или аппаратный ключ (YubiKey). Пароли не используются и не хранятся. Важно: WebAuthn требует HTTPS — Pocket ID не будет работать по HTTP
  • OIDC-совместимость. Стандартный OpenID Connect — работает с любым сервисом, поддерживающим OIDC
  • Простота. Веб-интерфейс для управления клиентами и пользователями. Минимальная конфигурация
  • PKCE. Поддержка Proof Key for Code Exchange (S256) — дополнительная защита при обмене authorization code12
  • Группы пользователей. Ограничение доступа к OIDC-клиентам по группам
  • LDAP. Опциональная поддержка LDAP для интеграции с legacy-сервисами11

Текущая версия — v2.3.0 (март 2026), 7 100+ звёзд на GitHub13.

Развёртывание Pocket ID — один контейнер с единственной переменной окружения APP_URL. После первого запуска интерфейс предложит создать администратора и зарегистрировать passkey. Все последующие входы — через passkey, без пароля. Подробности — в следующем разделе.

Развёртывание

Все четыре компонента — Caddy, Headscale, Headplane, Pocket ID — запускаются как контейнеры. Ниже — полная конфигурация для Docker Compose. Для Podman файлы совместимы через podman-compose или конвертируются в Quadlet-формат14.

Структура директорий

├── docker-compose.yml
├── .env
├── caddy/
│   └── Caddyfile
├── headscale/
│   └── config.yaml
├── headplane/
│   └── config.yaml
└── pocket-id/
    └── data/

Docker Compose

services:
  caddy:
    image: caddy:2
    container_name: caddy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - frontend

  headscale:
    image: ghcr.io/juanfont/headscale:0.28.0
    container_name: headscale
    restart: always
    command: serve
    labels:
      me.tale.headplane.target: headscale
    ports:
      - "3478:3478/udp"
    volumes:
      - ./headscale/config.yaml:/etc/headscale/config.yaml:ro
      - headscale_data:/var/lib/headscale
    networks:
      - frontend

  headplane:
    image: ghcr.io/tale/headplane:0.6.2
    container_name: headplane
    restart: always
    environment:
      ROOT_API_KEY: "${HEADPLANE_API_KEY}"
      COOKIE_SECRET: "${HEADPLANE_COOKIE_SECRET}"
    volumes:
      - ./headplane/config.yaml:/etc/headplane/config.yaml:ro
      - ./headscale/config.yaml:/etc/headscale/config.yaml
      - headplane_data:/var/lib/headplane
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - frontend

  pocket-id:
    image: ghcr.io/pocket-id/pocket-id:v2.3.0
    container_name: pocket-id
    restart: always
    environment:
      APP_URL: https://id.example.org
    volumes:
      - ./pocket-id/data:/app/backend/data
    networks:
      - frontend

networks:
  frontend:

volumes:
  caddy_data:
  caddy_config:
  headscale_data:
  headplane_data:

Порт 3478/udp — STUN для NAT traversal. Порты 80 и 443 — только у Caddy. Остальные сервисы общаются через внутреннюю сеть frontend по именам контейнеров.

Label me.tale.headplane.target на контейнере Headscale позволяет Headplane находить его через Docker API и отправлять сигнал перезапуска при изменении DNS-настроек15. Headplane также монтирует конфиг Headscale (без :ro — для записи изменений) и Docker-сокет (для обнаружения и перезапуска контейнера).

Секреты вынесены в .env файл, который не коммитится в репозиторий (добавьте .env в .gitignore):

# .env
HEADPLANE_API_KEY=<будет сгенерирован после первого запуска>
HEADPLANE_COOKIE_SECRET=<32 символа, см. ниже>

Caddyfile

scale.example.org {
    # Headplane ожидает запросы с /admin — используем path, а не handle_path.
    # handle_path стрипает префикс, и Headplane не найдёт свои маршруты.
    @admin path /admin /admin/*
    reverse_proxy @admin headplane:3000

    # stream_close_delay предотвращает разрыв streaming-соединений
    # Tailscale-клиентов при graceful reload Caddyfile.
    reverse_proxy headscale:8080 {
        stream_close_delay 5s
    }
}

id.example.org {
    reverse_proxy pocket-id:80
}

Caddy автоматически получит TLS-сертификаты для обоих доменов через Let’s Encrypt16. Оба домена должны иметь A-записи, указывающие на IP сервера.

@admin — именованный matcher17: запросы к /admin уходят на Headplane, всё остальное — на Headscale. Caddy нативно поддерживает WebSocket18, что критично для протокола Tailscale — координация между клиентом и сервером использует long-polling через WebSocket.

Если Headscale использует встроенный DERP-сервер, STUN-порт (UDP/3478) публикуется отдельно — он не проксируется через Caddy18.

Headscale config.yaml

Базовая конфигурация без OIDC — OIDC добавляется на этапе интеграции:

server_url: https://scale.example.org
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090

# TLS отключён — терминация на уровне Caddy
tls_cert_path: ""
tls_key_path: ""

noise:
  private_key_path: /var/lib/headscale/noise_private.key

database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite
    write_ahead_log: true

prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48

derp:
  server:
    enabled: false
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 3h

Ключ noise_private.key генерируется автоматически при первом запуске3. При миграции с существующего Headscale этот файл нужно перенести — без него все клиенты потеряют связь с сервером.

Встроенный DERP-сервер (DERP — Designated Encrypted Relay for Peers, relay для устройств, которые не могут установить прямое соединение) отключён для простоты. Headscale будет использовать публичные DERP-серверы Tailscale. Для продакшена можно включить собственный DERP — это уменьшит задержки при relay-соединениях18. Полный список параметров — в официальной документации19.

Headplane config.yaml

headscale:
  url: http://headscale:8080
  config_path: /etc/headscale/config.yaml

integration:
  docker:
    enabled: true

Параметр config_path указывает путь к конфигу Headscale внутри контейнера Headplane — через него Headplane читает и записывает DNS-настройки. Секция integration.docker включает обнаружение контейнера Headscale по label и его перезапуск после изменения конфигурации.

Headplane подключается к Headscale по внутренней сети Docker. Два секрета передаются через переменные окружения:

  • ROOT_API_KEY — ключ доступа к Headscale API
  • COOKIE_SECRET — ключ шифрования сессионных cookies, ровно 32 символа

Генерация COOKIE_SECRET:

openssl rand -base64 24

Полная конфигурация описана в документации Headplane15.

Запуск

# 1. Генерация cookie secret
echo "HEADPLANE_COOKIE_SECRET=$(openssl rand -base64 24)" >> .env

# 2. Запуск без Headplane (ему пока нет API-ключа)
docker compose up -d caddy headscale pocket-id

# 3. Создание первого пользователя Headscale
docker exec headscale headscale users create admin

# 4. Генерация API-ключа для Headplane
docker exec headscale headscale apikeys create --expiration 90d

Полученный ключ записываем в .env как HEADPLANE_API_KEY, затем:

# 5. Запуск Headplane
docker compose up -d headplane

Проверка

# Headscale health
curl -s https://scale.example.org/health
# Ожидаемый ответ: пустое тело, HTTP 200

# Headplane
curl -s -o /dev/null -w "%{http_code}" https://scale.example.org/admin
# Ожидаемый ответ: 200

# Pocket ID
curl -s -o /dev/null -w "%{http_code}" https://id.example.org
# Ожидаемый ответ: 200

После проверки:

  1. Открыть https://id.example.org — создать администратора Pocket ID и зарегистрировать passkey
  2. Открыть https://scale.example.org/admin — убедиться, что Headplane показывает пользователя admin
  3. Подключить первое устройство:
# Генерация pre-auth ключа
docker exec headscale headscale preauthkeys create --user admin --expiration 1h

# На клиентском устройстве
tailscale up --login-server https://scale.example.org --authkey <KEY>

На этом этапе система работает без OIDC: Headplane использует API-ключ, регистрация нод — через pre-auth ключи. Следующий шаг — интеграция с Pocket ID.

Интеграция: как всё связать

Интеграция с Pocket ID добавляет OIDC-аутентификацию в два места: Headplane (вход администратора в веб-интерфейс) и Headscale (регистрация нод и привязка пользователей).

                          ┌─────────────────────────────────┐
                          │          Caddy (TLS)            │
                          │                                 │
  Браузер / Tailscale ──▶ │  /admin/*  ──▶  Headplane:3000  │
                          │  /*        ──▶  Headscale:8080  │
                          └─────────────────────────────────┘
                                    ▲             ▲
                                    │  OIDC       │  OIDC
                                    ▼             ▼
                              ┌───────────────────────┐
                              │   Pocket ID (OIDC)    │
                              │   id.example.org      │
                              └───────────────────────┘

Регистрация OIDC-клиента в Pocket ID

Важно: Headscale и Headplane должны использовать один и тот же OIDC-клиент в Pocket ID. Два разных client_id сломают маппинг пользователей между сервисами.

Headscale и Headplane могут использовать один OIDC-клиент в Pocket ID20. Это не просто экономия — это необходимость: когда оба сервиса используют один client_id, claim sub (subject) совпадает, и Headplane автоматически связывает OIDC-пользователя с пользователем Headscale.

В веб-интерфейсе Pocket ID создаём клиент с двумя callback URL:

СервисCallback URL
Headscalehttps://scale.example.org/oidc/callback
Headplanehttps://scale.example.org/admin/oidc/callback

Рекомендуется включить PKCE (S256) в настройках клиента — это защищает от перехвата authorization code.

Настройка OIDC в Headscale

В config.yaml Headscale добавляется секция oidc21:

oidc:
  issuer: "https://id.example.org"
  client_id: "<client_id_from_pocket_id>"
  client_secret: "<client_secret_from_pocket_id>"
  pkce:
    enabled: true
    method: S256
  scope:
    - openid
    - profile
    - email
  only_start_if_oidc_is_available: true
  allowed_domains:
    - example.org

Параметр allowed_domains ограничивает регистрацию — только пользователи с email из указанного домена смогут добавлять устройства.

Настройка OIDC в Headplane

В config.yaml Headplane добавляется секция oidc15. Полный файл после изменений:

headscale:
  url: http://headscale:8080
  config_path: /etc/headscale/config.yaml

oidc:
  issuer: "https://id.example.org"
  client_id: "<client_id_from_pocket_id>"
  client_secret: "<client_secret_from_pocket_id>"
  headscale_api_key: "<same_key_as_ROOT_API_KEY>"
  use_pkce: true

integration:
  docker:
    enabled: true

Обратите внимание на два момента:

  • headscale_api_key — обязательное поле в секции oidc для Headplane v0.6+. Это тот же API-ключ, что и ROOT_API_KEY. Без него Headplane не сможет управлять Headscale после переключения на OIDC.
  • Разный синтаксис PKCE. В Headscale — pkce.enabled: true и pkce.method: S256, в Headplane — use_pkce: true. Это разные проекты с разными форматами конфигурации. При включении или отключении PKCE в Pocket ID нужно обновить конфиги обоих сервисов.

Применение изменений

После редактирования конфигурационных файлов необходим перезапуск обоих сервисов:

docker compose restart headscale headplane

Проверка:

# Headplane должен показать форму входа через Pocket ID
curl -s -o /dev/null -w "%{http_code}" https://scale.example.org/admin
# Ожидаемый ответ: 302 (редирект на Pocket ID)

Откройте https://scale.example.org/admin в браузере — вместо прямого доступа появится кнопка входа через Pocket ID.

Как работает регистрация ноды через OIDC

После настройки OIDC в Headscale регистрация новых устройств происходит через браузер — без участия администратора22:

ШагУчастникДействие
1Пользовательtailscale up --login-server https://scale.example.org
2Tailscale-клиентГенерирует RegistrationID, открывает браузер
3БраузерПереход на https://scale.example.org/register/<id>
4HeadscaleРедирект на Pocket ID
5ПользовательАутентификация passkey в Pocket ID
6Pocket IDВозвращает authorization code на /oidc/callback
7HeadscaleОбмен кода на токен, создание/обновление пользователя, регистрация ноды
8Tailscale-клиентПолучает конфигурацию, подключается к tailnet

Весь процесс занимает секунды. Кэш регистрации действует 15 минут — если пользователь не завершит аутентификацию за это время, нужно повторить tailscale up22.

При этом pre-auth ключи никуда не исчезают. OIDC-регистрация и pre-auth ключи — взаимодополняющие механизмы:

ХарактеристикаPre-Auth KeyOIDC
ИнтерактивностьНет (автоматическая)Да (браузер + passkey)
Участие администратораСоздаёт ключ заранееНет (self-service)
Лучше дляСерверы, CI/CD, автоматизацияПользовательские устройства
Профиль пользователяНетДа (имя, email, аватар из Pocket ID)

Два пути для уже работающего Headscale

Если Headscale уже работает с пользователями, созданными через CLI, интеграция OIDC создаёт проблему: OIDC-аутентификация создаёт новых пользователей, а существующие устройства привязаны к CLI-пользователям. Есть два подхода.

Путь 1: Чистая миграция. Настроить OIDC, перелогинить все клиентские устройства:

# На каждом устройстве
tailscale logout
tailscale up --login-server https://scale.example.org

При логине откроется браузер с Pocket ID. После аутентификации Headscale создаст нового OIDC-пользователя с заполненным профилем (имя, email, аватар из Pocket ID). Устройства перепривяжутся к новому пользователю. Этот путь прост, но требует перелогина каждого устройства и приводит к появлению пустого старого CLI-пользователя.

Путь 2: Привязка через базу данных. Если нужно сохранить существующего пользователя и избежать перерегистрации десятков устройств — можно вручную прописать OIDC-идентификатор в базе Headscale.

Сначала узнаём sub (subject) пользователя в Pocket ID:

docker exec pocket-id sqlite3 /app/backend/data/pocket-id.db \
  'SELECT id, username, email FROM users WHERE username="admin"'

Затем обновляем запись в базе Headscale:

docker exec headscale sqlite3 /var/lib/headscale/db.sqlite \
  'UPDATE users SET
     provider_identifier="https://id.example.org/<user-id>",
     email="admin@example.org",
     display_name="Admin"
   WHERE name="admin"'

Формат provider_identifier — <issuer>/<sub>, где issuer — URL вашего Pocket ID, а sub — ID пользователя из предыдущего шага23.

Рекомендуемый путь — комбинированный:

  1. Настроить OIDC в Headscale и Headplane
  2. Вручную прописать provider_identifier для существующих CLI-пользователей
  3. Перезапустить Headscale
  4. Перелогинить устройства — Headscale найдёт существующего пользователя по provider_identifier и обновит профиль из OIDC

Так устройства останутся у того же пользователя, появятся аватар и имя из Pocket ID, и Headplane перестанет показывать пользователя как «unmanaged»24.

Onboarding в Headplane

При первом входе в Headplane через OIDC интерфейс покажет onboarding-диалог: список пользователей Headscale с предложением выбрать, какой из них соответствует текущему OIDC-аккаунту20. Этот выбор сохраняется на уровне Headplane и повторное сопоставление не требуется.

Если provider_identifier уже прописан в базе Headscale (путь 2), сопоставление произойдёт автоматически по совпадению sub — onboarding-диалог не появится.

Результат: сеть без консоли

После настройки связки рабочий процесс полностью меняется.

Доступ к админке только через passkey. Вход в Headplane — через Pocket ID. Никаких паролей, только биометрия или аппаратный ключ. Компрометация пароля невозможна — паролей нет.

Регистрация устройств через браузер. Пользователь выполняет tailscale up --login-server https://scale.example.org, открывается браузер с формой входа Pocket ID. После аутентификации passkey устройство автоматически регистрируется в сети. Администратору не нужно генерировать pre-auth ключи или одобрять устройства из консоли.

Управление из веба. ACL, DNS, маршруты, пользователи, узлы — всё доступно в Headplane. Редактирование политик доступа — визуальный редактор вместо ручной правки JSON. Одобрение subnet routes и exit nodes — кнопка в интерфейсе вместо headscale routes enable.

Профили пользователей. Имя, email и аватар подтягиваются из Pocket ID. Эта информация отображается в клиентах Tailscale — каждый участник сети видит, кому принадлежат устройства25.

Было (Headscale CLI)Стало (Headscale + Headplane + Pocket ID)
SSH на сервер для любой операцииВеб-интерфейс Headplane
headscale users createКнопка в UI
Pre-auth ключи для регистрацииOIDC через Pocket ID в браузере
Ручная правка ACL JSONВизуальный редактор
headscale nodes listДашборд с фильтрами и поиском
Нет аутентификации админаPasskey через Pocket ID
Нет аватаров и профилейПрофили из OIDC

Что остаётся в CLI

Связка не устраняет CLI полностью. Несколько операций по-прежнему требуют терминала:

  • Pre-auth ключи для серверов и CI/CD. Автоматическая регистрация устройств без браузера — только через headscale preauthkeys create
  • Обновление сервисов. Обновление контейнеров Headscale, Headplane, Pocket ID — через SSH
  • Бэкапы. Резервное копирование SQLite-базы Headscale: docker exec headscale sqlite3 /var/lib/headscale/db.sqlite ".backup '/tmp/headscale.db'" && docker cp headscale:/tmp/headscale.db ./backup-$(date +%Y%m%d).db
  • Диагностика. При проблемах с OIDC или сетевой связностью — логи через journalctl

Но повседневное управление сетью — добавление пользовательских устройств, настройка маршрутов, редактирование ACL — полностью переходит в браузер.

Подводные камни

Несколько практических моментов, на которые стоит обратить внимание при настройке.

Внимание: PKCE должен быть включён или выключен одинаково в Headscale и Headplane. Рассинхронизация приводит к ошибке Invalid code verifier.

PKCE — синхронно для всех потребителей. Если PKCE включается или выключается в Pocket ID, нужно обновить конфигурацию и Headscale, и Headplane. Рассинхронизация приведёт к ошибке Invalid code verifier при попытке аутентификации.

Issuer без trailing slash. Pocket ID возвращает issuer как https://id.example.org (без / в конце). Во всех конфигурациях нужно указывать URL в том же формате. Несовпадение приведёт к ошибке валидации токена.

Pocket ID секреты — bcrypt. В базе данных Pocket ID client secret хранится как bcrypt-хэш. Восстановить его нельзя — только перегенерировать в веб-интерфейсе Pocket ID. При перегенерации нужно обновить secret во всех сервисах, использующих этот клиент.

Группы пользователей в Pocket ID. Если к OIDC-клиенту привязаны группы пользователей, только пользователи из этих групп смогут аутентифицироваться. Без явной привязки к группе — вход доступен всем пользователям Pocket ID.

Недоступность Pocket ID. Если Pocket ID не отвечает при запуске Headscale, поведение зависит от параметра only_start_if_oidc_is_available в конфигурации Headscale. По умолчанию true — Headscale не стартует, пока OIDC-провайдер недоступен. Это защищает от ситуации, когда ноды подключаются к серверу с нерабочей аутентификацией. Если Pocket ID падает после старта Headscale — существующие подключения не разрываются, но новые OIDC-регистрации невозможны (pre-auth ключи продолжат работать).

Label на правильном контейнере. Label me.tale.headplane.target должен быть на контейнере Headscale, а не Headplane. Headplane ищет контейнер с этим label через Docker API для отправки сигнала перезапуска. Если label на Headplane — изменение DNS-настроек через UI будет возвращать 502/503.

Для пользователей Podman: rootless Podman монтирует файлы по inode. Команды sed -i, cat > создают новый файл — контейнер продолжает видеть старое содержимое. После изменения config.yaml Headscale или Headplane через такие команды необходим полный рестарт контейнера. В Docker эта проблема не возникает.

Podman Quadlet и Docker-интеграция Headplane. Headplane кеширует container ID контейнера Headscale при старте и использует его для отправки команды restart через Docker API. В Docker это работает — container ID сохраняется при рестарте. Но Podman Quadlet при каждом рестарте удаляет и пересоздаёт контейнер с новым ID. После первого restart через API кешированный ID устаревает, и все последующие попытки завершаются ошибкой 404 no such container.

Решение для Podman Quadlet — systemd .path юнит, который следит за конфигом Headscale и перезапускает его через systemd:

# ~/.config/systemd/user/headscale-config.path
[Unit]
Description=Watch Headscale config for changes

[Path]
PathModified=%h/.config/containers/systemd/configs/headscale/config.yaml
Unit=headscale-config-reload.service

[Install]
WantedBy=default.target
# ~/.config/systemd/user/headscale-config-reload.service
[Unit]
Description=Restart Headscale after config change

[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl --user restart headscale.service

Активация: systemctl --user enable --now headscale-config.path. После этого Headplane записывает изменения в конфиг, а systemd автоматически перезапускает Headscale — без Docker API и без проблем с container ID.

Устранение неполадок

СимптомВозможная причинаДействие
Invalid code verifierPKCE включён только в одном из сервисовСинхронно включить PKCE в Headscale и Headplane, проверить Pocket ID
Headscale не запускаетсяPocket ID недоступен при стартеПроверить only_start_if_oidc_is_available, временно отключить или убедиться, что id.example.org отвечает
Ошибка валидации токенаIssuer с trailing slashУказать https://id.example.org без / в конце во всех конфигах
Headplane показывает пользователя как «unmanaged»provider_identifier не прописанВыполнить привязку через базу (путь 2 в разделе миграции)
502/503 при изменении DNS в HeadplaneLabel me.tale.headplane.target на контейнере Headplane вместо HeadscaleПереместить label на контейнер Headscale, перезапустить оба сервиса
404 no such container при изменении DNS (Podman Quadlet)Headplane кеширует container ID, Quadlet пересоздаёт контейнер с новым IDИспользовать systemd .path юнит для рестарта (см. «Подводные камни»)
После правки конфига изменения не применяются (Podman)Монтирование по inodedocker compose down && docker compose up -d
Браузер не открывается при tailscale upКлиент ждёт ручного переходаОткрыть вручную https://scale.example.org/register/<id> из вывода команды

Совет: при проблемах с OIDC смотрите логи: docker logs headscale и docker logs pocket-id. Ошибки обычно указывают на неверный issuer, client_id или redirect_uri.

Часто задаваемые вопросы

Нужен ли отдельный OIDC-клиент для Headscale и Headplane?
Нет. Один клиент с двумя callback URL: для Headscale и для Headplane. Совпадение client_id обеспечивает корректный маппинг пользователей.

Работают ли pre-auth ключи после включения OIDC?
Да. OIDC и pre-auth ключи сосуществуют. Pre-auth остаётся оптимальным способом для серверов и CI/CD.

Можно ли использовать один домен?
Технически да (разные path), но раздельные домены (scale.* и id.*) упрощают настройку и изоляцию.

Совместимы ли клиенты Tailscale с Headscale?
Да. Используются официальные клиенты Tailscale, меняется только --login-server.

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

Связка закрывает три основных вектора атак, характерных для self-hosted инфраструктуры.

Фишинг и кража паролей. Pocket ID не хранит пароли — аутентификация через WebAuthn. Passkey привязан к конкретному домену, что исключает фишинг: даже если пользователь попадёт на поддельную страницу, браузер не предложит passkey для чужого домена11.

Перехват authorization code. PKCE (S256) привязывает authorization code к конкретной сессии. Перехваченный code бесполезен без code_verifier, который не покидает клиент12.

Несанкционированная регистрация устройств. Параметр allowed_domains в Headscale ограничивает регистрацию по домену email. Можно дополнительно использовать allowed_users для списка конкретных пользователей или allowed_groups для групп из Pocket ID21.

Заключение

Связка Headscale + Headplane + Pocket ID превращает самохостный координационный сервер из CLI-инструмента для энтузиастов в полноценную платформу управления частной сетью. Headscale обеспечивает координацию и совместимость с клиентами Tailscale. Headplane добавляет визуальное управление: узлы, пользователи, ACL, DNS, маршруты. Pocket ID замыкает цепочку — аутентификация через passkey без паролей и без внешних зависимостей.

Каждый компонент решает свою задачу, все три — open source, все три легковесны и развёртываются в контейнерах. Конфигурация требует внимания к деталям (PKCE-синхронизация, формат provider_identifier, callback URL), но результат оправдывает усилия: управление сетью из браузера, регистрация устройств без участия администратора, аутентификация без паролей.

Источники