supervisor

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: GPL-3.0 Imports: 12 Imported by: 0

Documentation

Overview

lifecycle.go — цикл подключения (задача F3): то единственное место в супервизоре, где есть время, горутины и внешний мир.

Автомат (state.go) помнит, где мы; политика (restart.go) считает задержки и годность хешей; здесь они связываются с ядром и datapath. Цикл запускает попытку, раздаёт события ядра всем троим, поднимает datapath по конфигу от сервера, опрашивает watchdog своим тикером и ждёт назначенную задержку перед следующей попыткой.

Главное обязательство файла — datapath снимается всегда: при обычном возврате из ядра, при остановке по Ctrl+C и при панике внутри ядра (architecture.md §07).

restart.go — политика перезапуска (задача F2): задержки между попытками, watchdog по трафику и учёт годных хешей звонка.

Как и автомат в state.go, всё здесь бесчасовое: текущее время приходит аргументом, а ждать задержку и опрашивать watchdog — дело цикла подключения (lifecycle.go, F3). Иначе тесты спали бы по минуте и зависели от планировщика.

Package supervisor сводит ядро, datapath и решатель капчи в один туннель: держит состояние подключения, решает, когда перезапускать ядро, и гарантирует, что datapath снимется при остановке (architecture.md §07).

В Android этим занимался TunnelManager.kt — 2162 строки с watchdog, разбором лога и парой десятков состояний UI. Здесь остаётся конечный автомат (этот файл, задача F1), политика перезапуска (restart.go, F2) и завершение (lifecycle.go, F3).

state.go — сам автомат. Он ничего не запускает и не ждёт: принимает события ядра и решения супервизора, а отдаёт состояние и переходы. Таймеров внутри нет намеренно — автомат ведётся событиями, а время живёт в restart.go.

Index

Constants

View Source
const DefaultStallBytes = 1024

DefaultStallBytes — сколько восходящих байт без единого байта в ответ нужно, чтобы watchdog вообще начал отсчёт молчания.

Порог нужен из-за WireGuard: раз в 25 секунд он шлёт keepalive в 32 байта, на который сервер по протоколу не отвечает, а отвечает только на рукопожатие раз в две минуты. Без порога простаивающий туннель выглядел бы молчащим через 25 секунд и перезапускался бы каждые 50 — так и было на живой проверке G2. Между двумя рукопожатиями keepalive набирает не больше 160 байт; живой запрос приложения — SYN с повторами, DNS — набирает килобайт за секунды. Android этого не замечал: там счётчики читались из лога с точностью до 0,01 МБ, и keepalive в них не был виден вовсе.

View Source
const DefaultStallPoll = time.Second

DefaultStallPoll — как часто цикл спрашивает watchdog, жив ли туннель. Чаще секунды незачем: ядро присылает статистику раз в три секунды, а окно терпения измеряется десятками секунд.

View Source
const DefaultStallWindow = 25 * time.Second

DefaultStallWindow — сколько ждать ответа, прежде чем считать туннель неработающим. Та же величина, что у hasRecentTransportActivity на Android.

Variables

View Source
var ErrHashesExhausted = errors.New("годных хешей звонка не осталось")

ErrHashesExhausted — годных хешей звонка не осталось, пробовать больше нечем. Единственная причина, по которой цикл заканчивается сам, а не по просьбе.

Functions

func Backoff

func Backoff(attempt int) time.Duration

Backoff — задержка перед повтором после неудачной попытки номер attempt (нумерация с нуля: первая неудача даёт секунду).

Последовательность перенесена из Android дословно (TunnelManager.kt:1268): 1, 2, 4, 8, 16, 32, 64 секунды и дальше по 64.

func Interrupt

func Interrupt(parent context.Context) (context.Context, context.CancelFunc)

Interrupt возвращает контекст, отменяемый по первому Ctrl+C (SIGINT) или SIGTERM, и функцию снятия обработчика.

Второй Ctrl+C убивает процесс немедленно: по первому сигналу обработчик снимается, и следующий уходит системе, у которой поведение по умолчанию — завершить процесс. Ждать корректной остановки дважды пользователь не обязан, а собственного os.Exit это не требует — выход из библиотеки дело плохое, да и на зависшем ядре он бы не помог: горутина, печатающая «выхожу», может стоять в том же замке, что и всё остальное.

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

func ShortHash

func ShortHash(hash string) string

ShortHash — первые восемь символов хеша.

Целиком его печатать нельзя: хеши звонков лежат в хранилище секретов рядом с паролем подключения (задача C2). Различать их в выводе при этом нужно, и ядро в своих логах обрезает их так же (group.go).

Types

type Decision

type Decision struct {
	// Stop — пробовать больше нечем: годных хешей не осталось. Автомат при
	// этом уже переведён в StateStopping.
	Stop bool
	// Delay — сколько ждать до следующей попытки. Ноль при Stop.
	Delay time.Duration
	// Attempt — номер кончившейся попытки, начиная с единицы. Для строки
	// вида «попытка 3, следующая через 4 с».
	Attempt int
	// Transitions — переходы автомата, которые сделало это решение: Reconnect,
	// а при исчерпанных хешах следом Stop. Пусто, когда автомат уже стоял там,
	// куда его переводили.
	//
	// Поле нужно потому, что автомат переводит Restarter, а печатает переходы
	// цикл подключения (lifecycle.go): без него он узнавал бы о смене состояния
	// сравнением State() до и после вызова.
	Transitions []Transition
}

Decision — что делать после того, как попытка кончилась.

type HashPool

type HashPool struct {
	// contains filtered or unexported fields
}

HashPool — хеши звонков VK и их годность.

Группы воркеров ядро раздаёт по хешам: группа, не получившая креды, молча возвращается, а остальные продолжают работать — туннель от одного негодного хеша не падает. Отменять группы пулу поэтому незачем, это ядро уже сделало. Его работа — помнить, какие хеши предлагать следующей попытке, и сказать, когда предлагать стало нечего (architecture.md §07).

Безопасен для параллельного вызова: Observe зовётся из Sink, то есть из горутин воркеров ядра.

func NewHashPool

func NewHashPool(hashes []string) *HashPool

NewHashPool заводит пул из хешей профиля. Порядок сохраняется: ядро раздаёт хеши группам по индексу, и менять их местами между попытками незачем.

func (*HashPool) Alive

func (p *HashPool) Alive() []string

Alive — хеши, которые ещё стоит предлагать ядру, в исходном порядке.

func (*HashPool) DeadReason

func (p *HashPool) DeadReason(hash string) string

DeadReason — почему хеш исключён. Пустая строка означает, что он годен.

func (*HashPool) Exhausted

func (p *HashPool) Exhausted() bool

Exhausted — годных хешей не осталось. Пустой пул исчерпан с самого начала: подключаться без хешей не к чему.

func (*HashPool) MarkDead

func (p *HashPool) MarkDead(hash, reason string) bool

MarkDead исключает хеш. Возвращает false, если хеш уже исключён или пулу не принадлежит: пришедший ниоткуда хеш не повод считать, что туннелю хуже.

func (*HashPool) Observe

func (p *HashPool) Observe(ev core.Event) bool

Observe применяет событие ядра. Исключает хеш только core.HashCheckDead — звонок не найден или закрыт, и ни повтор, ни перезапуск его не вернут. Капча, лимит запросов и сетевая ошибка временные: по ним хеш остаётся в игре, иначе первая же неурядица со связью срезала бы все хеши разом и остановила клиент вместо того, чтобы переподключиться.

Возвращает true, если хеш именно этим событием и исключён — вызывающему есть о чём напечатать строку.

type Machine

type Machine struct {
	// contains filtered or unexported fields
}

Machine — конечный автомат подключения.

Безопасен для параллельного вызова: Observe зовётся из Sink, то есть из горутин воркеров ядра, а остальные методы — из супервизора.

func New

func New() *Machine

New возвращает автомат в состоянии StateIdle.

func (*Machine) BeginAttempt

func (m *Machine) BeginAttempt() (Transition, bool)

BeginAttempt открывает попытку подключения: StateIdle или StateReconnecting переходят в StateResolving, таблица шагов очищается.

Из остальных состояний — ничего: попытка уже идёт, а из StateStopping переходов нет вовсе.

func (*Machine) Degrade

func (m *Machine) Degrade() (Transition, bool)

Degrade отмечает, что нисходящего трафика нет дольше разрешённого: туннель формально поднят, но не работает. Осмысленно только из StateConnected — остальные состояния и так не обещают трафика.

func (*Machine) FailedStage

func (m *Machine) FailedStage() core.Stage

FailedStage — шаг, отказ которого ещё не перекрыт успехом. Пусто, если такого нет. Шаг считается перекрытым, когда по нему приходит core.StageOK: TURN и DTLS каждый воркер проходит самостоятельно, и отказ одного из ста не означает, что шаг не прошёл.

func (*Machine) LastError

func (m *Machine) LastError() error

LastError — причина последнего ухода с линии, переданная в Reconnect. Сбрасывается началом новой попытки.

func (*Machine) Observe

func (m *Machine) Observe(ev core.Event) (Transition, bool)

Observe применяет событие ядра. События, кроме core.EventStage, автомат не трогают — их разбирают другие части супервизора.

Шаг двигает состояние только вперёд по линии и только пока автомат на ней: в StateDegraded и StateReconnecting событие попадёт в таблицу шагов, но состояния не изменит. Событие, пришедшее в StateStopping, отбрасывается целиком: попытка кончилась, и умирающие воркеры не должны перерисовывать картину.

Отказ шага состояния тоже не меняет. Ядро повторяет шаги само (капчу — трижды, TURN и DTLS — каждым воркером), и закончилась попытка или нет, знает не шаг, а возврат из core.Run; супервизор скажет об этом через Reconnect.

func (*Machine) Reconnect

func (m *Machine) Reconnect(cause error) (Transition, bool)

Reconnect уводит автомат с линии: попытка кончилась, ядро останавливается, следующая попытка ждёт задержки. Когда начинать её — решает restart.go (F2), автомат остаётся в StateReconnecting до BeginAttempt.

cause может быть нулевым: перезапуск бывает намеренным, например при смене сети. Из StateIdle и StateStopping — ничего: в первом нечего переподключать, второе конечное.

func (*Machine) Recover

func (m *Machine) Recover() (Transition, bool)

Recover возвращает туннель из StateDegraded в StateConnected: трафик пошёл сам, перезапускать нечего.

Схема в §07 такой стрелки не рисует — там из Degraded только перезапуск или остановка. Но watchdog судит по окну в 25 секунд, а молчание такой длины бывает и у живого туннеля; считать его отказом значило бы рвать рабочее соединение.

func (*Machine) StageStatus

func (m *Machine) StageStatus(stage core.Stage) core.StageStatus

StageStatus — чем кончился шаг в текущей попытке. Пустая строка означает, что о шаге ничего не известно: он не начинался или ядро о нём не сообщает (например, core.StageVK о неудаче молчит — молчание и есть неудача).

func (*Machine) State

func (m *Machine) State() State

State — текущее состояние.

func (*Machine) Stop

func (m *Machine) Stop() (Transition, bool)

Stop переводит автомат в конечное StateStopping: дальше только снятие datapath и выход (F3). Работает из любого состояния, включая StateIdle: Ctrl+C до первой попытки — тоже остановка.

type Options

type Options struct {
	// Config — настройки ядра. Поле Hashes заполняет сам цикл, из пула перед
	// каждой попыткой: негодный хеш не должен попасть в следующую.
	Config core.Config

	// Hashes — хеши звонков VK, как их дал профиль. Порядок сохраняется.
	Hashes []string

	// NewDatapath строит datapath для одной попытки. Обязателен.
	//
	// Именно конструктор, а не готовый экземпляр: реализация одноразовая —
	// Start у неё зовётся один раз, Close закрывает её навсегда, — а конфиг
	// WireGuard сервер выдаёт новый на каждое подключение. Проверить Requires
	// и объяснить нехватку прав нужно до входа в цикл, и это дело CLI.
	//
	// Исключение — datapath.Persistent (tun): такой экземпляр цикл
	// строит один раз, на первой попытке, зовёт его Start на каждую
	// попытку, Suspend — после каждой неудачной и Close — при выходе из Run.
	NewDatapath func() (datapath.Datapath, error)

	// DatapathStage — какой шаг закрывает подъём datapath: core.StageSocks
	// или core.StageVPN. Пусто — core.StageSocks.
	//
	// Сам datapath своего имени не знает и знать не должен (D1); какой из двух
	// шагов показать пользователю, известно тому, кто выбрал реализацию.
	DatapathStage core.Stage

	// ProbeDNS проверяет резолвер до запуска ядра и закрывает шаг «dns» —
	// единственный шаг линии, который идёт до core.Run (core/events.go, «Кто
	// какие шаги отправляет»). Отказ означает отказ попытки: без резолвера
	// ядру не за чем идти, а сеть, где его нет, обычно возвращается — и
	// следующая попытка это увидит. nil — шаг не проверяется и не отправляется.
	//
	// Срок ожидания задаёт сама функция: цикл передаёт ей контекст попытки,
	// который живёт до её конца. Готовая реализация — core.ProbeDNS.
	ProbeDNS func(ctx context.Context) error

	// Control — ручка управления ядром: пауза, ответ на капчу, ответ на запрос
	// TURN-кредов. nil допустим и означает, что управлять не понадобится.
	Control *core.Control

	// OnEvent получает все события ядра, включая те, что супервизор собрал сам
	// (шаги datapath и done). nil — молчать.
	//
	// Зовётся из горутин воркеров ядра, но не одновременно: цикл сериализует
	// OnEvent и OnTransition между собой, чтобы получателю — а это вывод в
	// терминал — не понадобился собственный замок.
	OnEvent func(core.Event)

	// OnTransition получает состоявшуюся смену состояния: §07 требует печатать
	// её строкой. nil — молчать.
	OnTransition func(Transition)

	// Logf получает строки самого цикла, которые не события ядра и не смена
	// состояния: что маршруты сняты на время переподключения и что они
	// возвращены (plan-e2.md, решение 3). Сериализован с OnEvent и
	// OnTransition. nil — молчать.
	Logf func(format string, args ...any)

	// KillSwitch — стоит ли сейчас kill-switch, набор «закрыто» (plan-e3.md,
	// K3): с ним datapath, снимая защиту DNS, закрывает трафик системы, а не
	// отпускает его мимо туннеля. Цикл спрашивает после конца попытки, чтобы
	// строка говорила правду: kill-switch мог и не встать (-allow-leak).
	// nil — kill-switch нет.
	KillSwitch func() bool

	// StallWindow — сколько молчания терпит watchdog, прежде чем объявить
	// туннель деградировавшим. 0 — DefaultStallWindow (25 с).
	StallWindow time.Duration

	// StallPoll — как часто опрашивается watchdog. 0 — DefaultStallPoll.
	StallPoll time.Duration

	// StallBytes — сколько восходящих байт без ответа считается отправкой,
	// а не keepalive WireGuard. 0 — DefaultStallBytes.
	StallBytes int64

	// StallRestartAfter — сколько ещё длится молчание в состоянии «трафика
	// нет», прежде чем попытка обрывается и начинается новая. 0 — столько же,
	// сколько StallWindow: деградация в 25 с, перезапуск на пятидесятой.
	//
	// Две величины, а не одна, потому что молчание в двадцать пять секунд
	// бывает и у живого туннеля: сначала мы говорим об этом пользователю
	// и только потом рвём соединение.
	StallRestartAfter time.Duration
}

Options — из чего собирается цикл подключения.

type Restarter

type Restarter struct {
	// contains filtered or unexported fields
}

Restarter — политика перезапуска: ведёт счётчик неудач и держит автомат с пулом хешей в согласии друг с другом.

Порядок вызовов у F3 такой: Failed, когда core.Run вернулся; Connected, когда туннель поднялся. Автомат переводит Failed — иначе счётчик попыток и состояние расходились бы при любом пропущенном вызове.

func NewRestarter

func NewRestarter(machine *Machine, hashes *HashPool) *Restarter

NewRestarter связывает автомат с пулом хешей.

func (*Restarter) Attempts

func (r *Restarter) Attempts() int

Attempts — сколько попыток подряд кончилось неудачей.

func (*Restarter) Connected

func (r *Restarter) Connected()

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

func (*Restarter) Failed

func (r *Restarter) Failed(cause error) Decision

Failed объявляет, что попытка кончилась: уводит автомат с линии и говорит, ждать ли следующую и сколько.

Когда годных хешей не осталось, автомат переводится в StateStopping: ждать нечего, подключаться не к чему.

func (*Restarter) Reset

func (r *Restarter) Reset()

Reset обнуляет счётчик задержек без смены состояния: сеть сменилась, пока цикл ждал задержку.

func (*Restarter) Restart

func (r *Restarter) Restart(cause error) Decision

Restart объявляет, что попытку оборвали намеренно — сменилась сеть (S2 Э2). Автомат уходит с линии так же, как при отказе, но следующая попытка идёт сразу, а счётчик задержек обнуляется: неудачи прошлой сети к новой отношения не имеют.

type StallReason

type StallReason string

StallReason — чем именно плох туннель. Пустая строка означает, что он здоров.

const (
	// StallNone — претензий нет.
	StallNone StallReason = ""
	// StallNoDownlink — мы отправляли, ответа нет.
	StallNoDownlink StallReason = "нет ответа от сервера"
	// StallNoStats — ядро перестало присылать счётчики, хотя работает.
	StallNoStats StallReason = "ядро не присылает статистику"
	// StallNoUplink — datapath поднят, а в ядро от него не пришло ни байта.
	// Здоровый WireGuard так не молчит: сразу после подъёма он шлёт
	// рукопожатие, дальше keepalive раз в 25 секунд. Найдено живой проверкой
	// H2: служба штатного WireGuard упала при запуске, а туннель числился
	// работающим — неотвеченной отправки, по которой судит StallNoDownlink,
	// не было, потому что не было отправки вовсе.
	StallNoUplink StallReason = "datapath не передал в туннель ни байта"
)

type State

type State string

State — состояние туннеля. Набор повторяет схему из architecture.md §07.

const (
	// StateIdle — ничего не запущено: до первой попытки подключения.
	StateIdle State = "idle"
	// StateResolving — проверяется резолвер до VK. Шаг наш, не ядра: он идёт
	// до core.Run (см. «Кто какие шаги отправляет» в core/events.go).
	StateResolving State = "resolving"
	// StateCaptcha — VK показал Smart Captcha и она решается.
	StateCaptcha State = "captcha"
	// StateVK — получены TURN-креды.
	StateVK State = "vk"
	// StateTURN — получен relay на TURN-сервере VK.
	StateTURN State = "turn"
	// StateHandshake — рукопожатие DTLS с сервером.
	StateHandshake State = "handshake"
	// StateConfiguring — воркеры на линии, поднимается datapath.
	StateConfiguring State = "configuring"
	// StateConnected — туннель работает.
	StateConnected State = "connected"
	// StateDegraded — воркеры живы, но нисходящего трафика нет дольше 25 с.
	// Выставляется watchdog'ом из restart.go, а не событием ядра.
	StateDegraded State = "degraded"
	// StateReconnecting — попытка закончилась, ядро останавливается, следующая
	// попытка ждёт своей задержки (F2).
	StateReconnecting State = "reconnecting"
	// StateStopping — останавливаемся окончательно: по Ctrl+C или потому, что
	// годных хешей не осталось. Состояние конечное, из него переходов нет.
	StateStopping State = "stopping"
)

func (State) Label

func (s State) Label() string

Label — название состояния по-русски. У неизвестного состояния — его же код: печатать пустую строку вместо состояния хуже, чем печатать «idle».

type Transition

type Transition struct {
	From State
	To   State
	// Stage — шаг, вызвавший переход. Пусто, если переход сделал сам
	// супервизор: начало попытки, watchdog, остановка.
	Stage core.Stage
	// Cause — причина ухода с линии. Заполнена только у перехода
	// в StateReconnecting, и то не всегда: перезапуск бывает намеренным,
	// например при смене сети.
	Cause error
}

Transition — состоявшийся переход. Возвращается каждым методом, меняющим состояние, чтобы супервизор напечатал строку (§07) и не восстанавливал причину перехода из состояния постфактум.

type Tunnel

type Tunnel struct {
	// contains filtered or unexported fields
}

Tunnel — цикл подключения: ядро, datapath, автомат и политика перезапуска, связанные вместе.

Экземпляр одноразовый: Run зовётся один раз и возвращается, когда туннель остановлен окончательно.

func NewTunnel

func NewTunnel(opts Options) (*Tunnel, error)

NewTunnel собирает цикл. Ошибку возвращает только на неполных настройках: сеть до Run не трогается.

func (*Tunnel) Attempts

func (t *Tunnel) Attempts() int

Attempts — сколько попыток подряд кончилось неудачей.

func (*Tunnel) Hashes

func (t *Tunnel) Hashes() *HashPool

Hashes — пул хешей: какие ещё годны и почему исключены остальные.

func (*Tunnel) Machine

func (t *Tunnel) Machine() *Machine

Machine — автомат: таблица шагов текущей попытки и причина последнего отказа.

func (*Tunnel) Restart

func (t *Tunnel) Restart(reason error)

Restart обрывает идущую попытку с причиной reason и начинает новую сразу, без задержки и с обнулённым счётчиком задержек (Restarter.Restart). Если цикл ждёт задержку, ожидание прерывается. Зовётся при смене сети (S2 Э2) из любой горутины; до Run и после него ничего не делает.

Маршруты на время перезапуска снимаются, как после любой неудачной попытки: на новой сети может понадобиться капча.

func (*Tunnel) Run

func (t *Tunnel) Run(ctx context.Context) error

Run держит туннель, пока не отменят ctx или пока не кончатся годные хеши.

Остановка по ctx ошибкой не считается и даёт nil — как и у core.Run. Ошибка возвращается только вместе с ErrHashesExhausted: другого способа кончиться сам у цикла нет, отказ попытки означает следующую попытку.

К возврату datapath снят, горутины цикла закончились, а автомат стоит в StateStopping.

func (*Tunnel) State

func (t *Tunnel) State() State

State — текущее состояние подключения. Нужно статусной строке (G4), которая перерисовывается по таймеру, а не по переходам.

type Watchdog

type Watchdog struct {
	// contains filtered or unexported fields
}

Watchdog следит, что туннель не только поднят, но и работает.

Судит по core.EventStats — счётчикам, которые ядро присылает раз в три секунды. Времени внутри нет: текущее время приходит аргументом, а опрашивать Stall своим тикером — дело F3.

Безопасен для параллельного вызова: Observe зовётся из Sink.

func NewWatchdog

func NewWatchdog(window time.Duration) *Watchdog

NewWatchdog заводит watchdog с окном терпения. Ноль означает DefaultStallWindow.

func (*Watchdog) Observe

func (w *Watchdog) Observe(ev core.Event, now time.Time)

Observe применяет событие ядра. Нужны ему два: core.EventStats и шаг «туннель готов» (core.StageDone), который отправляет сам супервизор; остальные события он не трогает.

func (*Watchdog) Reset

func (w *Watchdog) Reset()

Reset забывает всё: вызывается перед новой попыткой, иначе счётчики прошлой выглядели бы как молчание этой.

func (*Watchdog) Stall

func (w *Watchdog) Stall(now time.Time) StallReason

Stall — что не так с туннелем прямо сейчас.

Молчание считается не от последнего нисходящего байта, а от начала неотвеченной отправки. Оба других способа врут: от последнего нисходящего — туннель, через который полчаса никто ничего не посылал, объявлялся бы сломанным первым же запросом; от последнего восходящего — пока приложение продолжает отправлять, отсчёт сбрасывался бы каждые три секунды и молчание сервера не обнаружилось бы никогда. В Android складывали вторым способом, прикрывшись отдельной проверкой на живость статистики (TunnelManager.kt:1345).

Простаивающий туннель здоров: не отправляли — нечего и ждать. Keepalive WireGuard за отправку не считается — см. DefaultStallBytes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL