Обратный прокси: архитектура Bridge + Portal
Обратный прокси Xray позволяет предоставить доступ к сервису, находящемуся за NAT или файрволом, через публичный интернет. Bridge (на стороне приватной сети) инициирует исходящие соединения к Portal (на стороне публичной сети), который затем мультиплексирует входящие клиентские соединения через эти туннели с помощью mux.
Общая архитектура
flowchart LR
subgraph Private Network
S[Local Service] <-->|direct| BW[BridgeWorker]
BW <-->|mux tunnel| B[Bridge]
end
subgraph Public Network
B <-->|outbound connection| P[Portal]
P <-->|Outbound handler| PW[PortalWorker]
PW <-->|mux dispatch| C[External Client]
endКлючевая идея: Bridge инициирует соединение, но Portal управляет мультиплексированием. С точки зрения протокола mux:
- На стороне Bridge работает
mux.ServerWorker(он принимает субсоединения) - На стороне Portal работает
mux.ClientWorker(он создаёт субсоединения)
Эта инверсия и обеспечивает работу обратного прокси — сторона, принимающая TCP-соединение (Portal), отправляет новые потоки, тогда как сторона, инициировавшая TCP-соединение (Bridge), принимает и обрабатывает их.
Основные типы
Reverse
Файл: app/reverse/reverse.go
Компонент верхнего уровня, содержащий все мосты и порталы:
type Reverse struct {
bridges []*Bridge
portals []*Portal
}
func (r *Reverse) Init(config *Config, d routing.Dispatcher, ohm outbound.Manager) error {
for _, bConfig := range config.BridgeConfig {
b, _ := NewBridge(bConfig, d)
r.bridges = append(r.bridges, b)
}
for _, pConfig := range config.PortalConfig {
p, _ := NewPortal(pConfig, ohm)
r.portals = append(r.portals, p)
}
}Требует как routing.Dispatcher (для Bridge), так и outbound.Manager (для Portal) через core.RequireFeatures().
Bridge
Файл: app/reverse/bridge.go
Bridge находится на стороне приватной сети экземпляра Xray. Он управляет пулом соединений BridgeWorker к Portal.
type Bridge struct {
dispatcher routing.Dispatcher
tag string
domain string
workers []*BridgeWorker
monitorTask *task.Periodic
}Цикл мониторинга: Запускается каждые 2 секунды. Если нет активных воркеров или среднее количество соединений на воркер превышает 16, создаётся новый BridgeWorker.
func (b *Bridge) monitor() error {
b.cleanup() // удаление закрытых воркеров
var numConnections uint32
var numWorker uint32
for _, w := range b.workers {
if w.IsActive() {
numConnections += w.Connections()
numWorker++
}
}
// Создание нового воркера при необходимости
if numWorker == 0 || numConnections/numWorker > 16 {
worker, _ := NewBridgeWorker(b.domain, b.tag, b.dispatcher)
b.workers = append(b.workers, worker)
}
}BridgeWorker
Файл: app/reverse/bridge.go
Каждый BridgeWorker устанавливает один mux-туннель к Portal:
type BridgeWorker struct {
Tag string
Worker *mux.ServerWorker
Dispatcher routing.Dispatcher
State Control_State
Timer *signal.ActivityTimer
}Процесс создания:
func NewBridgeWorker(domain string, tag string, d routing.Dispatcher) (*BridgeWorker, error) {
ctx := session.ContextWithInbound(context.Background(), &session.Inbound{Tag: tag})
// 1. Отправка запроса к домену Portal (маршрутизация через outbound)
link, _ := d.Dispatch(ctx, net.Destination{
Network: net.Network_TCP,
Address: net.DomainAddress(domain),
Port: 0,
})
// 2. Создание mux.ServerWorker на основе этого соединения
worker, _ := mux.NewServerWorker(context.Background(), w, link)
// 3. Установка таймаута неактивности (60 секунд)
w.Timer = signal.CancelAfterInactivity(ctx, terminate, 60*time.Second)
}BridgeWorker реализует интерфейс routing.Dispatcher, поэтому mux.ServerWorker направляет входящие субсоединения через него:
func (w *BridgeWorker) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) {
if !isInternalDomain(dest) {
// Реальный трафик: локальная маршрутизация
return w.Dispatcher.Dispatch(ctx, dest)
}
// Канал управления: внутренняя обработка
go w.handleInternalConn(link)
return link, nil
}Внутренний домен ("reverse"): Используется для управляющих сообщений между Bridge и Portal. Bridge читает protobuf-сообщения Control из внутреннего соединения для отслеживания состояния:
func (w *BridgeWorker) handleInternalConn(link *transport.Link) {
for {
mb, err := reader.ReadMultiBuffer()
for _, b := range mb {
var ctl Control
proto.Unmarshal(b.Bytes(), &ctl)
if ctl.State != w.State {
w.State = ctl.State // ACTIVE или DRAIN
}
}
}
}Portal
Файл: app/reverse/portal.go
Portal находится на стороне публичной сети экземпляра Xray. Он регистрирует обработчик исходящих соединений и управляет mux-клиентскими воркерами.
type Portal struct {
ohm outbound.Manager
tag string
domain string
picker *StaticMuxPicker
client *mux.ClientManager
}Запуск: Добавляет пользовательский обработчик Outbound в менеджер исходящих соединений:
func (p *Portal) Start() error {
return p.ohm.AddHandler(context.Background(), &Outbound{
portal: p,
tag: p.tag,
})
}HandleConnection: Вызывается при поступлении трафика на outbound портала:
func (p *Portal) HandleConnection(ctx context.Context, link *transport.Link) error {
if isDomain(ob.Target, p.domain) {
// Соединение от Bridge: создание mux.ClientWorker
muxClient, _ := mux.NewClientWorker(*link, mux.ClientStrategy{})
worker, _ := NewPortalWorker(muxClient)
p.picker.AddWorker(worker)
return nil
}
// Клиентское соединение: отправка через mux к Bridge
return p.client.Dispatch(ctx, link)
}Два типа соединений поступают на Portal:
- Соединения от Bridge (адрес назначения совпадает с настроенным доменом): создаётся новый
PortalWorker, оборачивающийmux.ClientWorker - Клиентские соединения (любой другой адрес назначения): мультиплексируются через существующие mux-туннели к Bridge
PortalWorker
Файл: app/reverse/portal.go
Управляет одним mux-соединением к Bridge, включая heartbeat и процесс вывода из эксплуатации:
type PortalWorker struct {
client *mux.ClientWorker
control *task.Periodic
writer buf.Writer
reader buf.Reader
draining bool
counter uint32
timer *signal.ActivityTimer
}Heartbeat: Запускается каждые 2 секунды, отправляет сообщение Control каждый 5-й тик (10 секунд):
func (w *PortalWorker) heartbeat() error {
msg := &Control{}
msg.FillInRandom()
// Автоматический вывод из эксплуатации после 256 суммарных соединений
if w.client.TotalConnections() > 256 {
w.draining = true
msg.State = Control_DRAIN
}
w.counter = (w.counter + 1) % 5
if w.draining || w.counter == 1 {
b, _ := proto.Marshal(msg)
return w.writer.WriteMultiBuffer(buf.MergeBytes(nil, b))
}
return nil
}Метод FillInRandom() добавляет случайное заполнение (1–65 байт) к управляющему сообщению для маскировки трафика:
func (c *Control) FillInRandom() {
randomLength := dice.Roll(64) + 1
c.Random = make([]byte, randomLength)
io.ReadFull(rand.Reader, c.Random)
}StaticMuxPicker
Файл: app/reverse/portal.go
Выбирает наименее загруженный PortalWorker, не находящийся в режиме вывода из эксплуатации, для новых соединений:
func (p *StaticMuxPicker) PickAvailable() (*mux.ClientWorker, error) {
// 1. Сначала попробовать активные воркеры, выбрать с минимумом соединений
// 2. Если все выводятся из эксплуатации, выбрать из них
// 3. Пропустить заполненные воркеры
}Очистка выполняется каждые 30 секунд для удаления закрытых воркеров.
Протокол управления
Bridge и Portal обмениваются состоянием через protobuf-сообщения Control по каналу внутреннего домена:
message Control {
enum State {
ACTIVE = 0;
DRAIN = 1;
}
State state = 1;
bytes random = 99; // случайное заполнение
}- ACTIVE: Туннель доступен для новых соединений
- DRAIN: Туннель выводится из эксплуатации (слишком много суммарных соединений)
Когда Portal отправляет DRAIN, Bridge обновляет своё State, и bridge-воркер перестаёт считаться «активным», что приводит к созданию замены монитором.
Диаграмма потока соединений
sequenceDiagram
participant Client
participant PortalOutbound as Portal Outbound
participant Portal
participant MuxTunnel as Mux Tunnel
participant Bridge as BridgeWorker
participant LocalService as Local Service
Note over Bridge, Portal: Bridge инициирует туннель
Bridge->>Portal: Подключение к домену "example.reverse"
Portal->>Portal: Совпадение isDomain -> создание PortalWorker
Portal->>Bridge: Mux установлен
Note over Portal, Bridge: Цикл heartbeat
loop Каждые 10 секунд
Portal->>Bridge: Control{State: ACTIVE}
Bridge->>Bridge: Обновление состояния
end
Note over Client, LocalService: Запрос клиента
Client->>PortalOutbound: Подключение к target.com:80
PortalOutbound->>Portal: HandleConnection
Portal->>MuxTunnel: Отправка через mux.ClientManager
MuxTunnel->>Bridge: Новый mux-субпоток
Bridge->>Bridge: BridgeWorker.Dispatch(target.com:80)
Bridge->>LocalService: Пересылка к локальному сервису
LocalService-->>Bridge: Ответ
Bridge-->>MuxTunnel: Ответ через mux
MuxTunnel-->>Portal: Ответ
Portal-->>Client: ОтветКонфигурация
{
"reverse": {
"bridges": [
{ "tag": "bridge", "domain": "test.example.com" }
],
"portals": [
{ "tag": "portal", "domain": "test.example.com" }
]
}
}tag на стороне Bridge задаёт тег входящего трафика для маршрутизации. domain должен совпадать в конфигурациях Bridge и Portal. Правила маршрутизации должны направлять трафик, предназначенный для данного домена, к соответствующему outbound на стороне Bridge, а тег Portal должен использоваться как outbound в правилах маршрутизации для клиентского трафика.
Заметки по реализации
Bridge создаёт воркеров лениво через задачу монитора. При первом запуске монитор немедленно обнаруживает
numWorker == 0и создаёт первый BridgeWorker.Структура
Outbound, зарегистрированная Portal, реализует интерфейсoutbound.Handlerс минимальным набором методов (Tag(),Dispatch(),Start(),Close()). Также имеются заглушкиSenderSettings()иProxySettings(), возвращающие nil.Таймаут неактивности BridgeWorker составляет 60 секунд. Если mux-активность отсутствует, воркер завершается. Таймер увеличивается до 24 часов, когда активно внутреннее управляющее соединение.
Таймер PortalWorker составляет 24 часа, в основном для предотвращения утечек горутин, а не для управления трафиком.
Bridge-воркеры очищаются методом
cleanup()монитора, который проверяет какIsActive()(состояние ACTIVE и mux не закрыт), так иClosed()(mux-воркер полностью завершён).Реализация mux поддерживает как TCP-, так и UDP-субпотоки. Для UDP Portal применяет
EndpointOverrideReader/EndpointOverrideWriterдля переназначения адресов между исходными и целевыми назначениями.Константа
internalDomain = "reverse"жёстко задана в коде и используется как маркер для трафика канала управления. Любое назначение с адресом"reverse"перехватывается для внутреннего использования.