Skip to content

Обратный прокси: архитектура Bridge + Portal

Обратный прокси Xray позволяет предоставить доступ к сервису, находящемуся за NAT или файрволом, через публичный интернет. Bridge (на стороне приватной сети) инициирует исходящие соединения к Portal (на стороне публичной сети), который затем мультиплексирует входящие клиентские соединения через эти туннели с помощью mux.

Общая архитектура

mermaid
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

Компонент верхнего уровня, содержащий все мосты и порталы:

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.

go
type Bridge struct {
    dispatcher  routing.Dispatcher
    tag         string
    domain      string
    workers     []*BridgeWorker
    monitorTask *task.Periodic
}

Цикл мониторинга: Запускается каждые 2 секунды. Если нет активных воркеров или среднее количество соединений на воркер превышает 16, создаётся новый BridgeWorker.

go
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:

go
type BridgeWorker struct {
    Tag        string
    Worker     *mux.ServerWorker
    Dispatcher routing.Dispatcher
    State      Control_State
    Timer      *signal.ActivityTimer
}

Процесс создания:

go
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 направляет входящие субсоединения через него:

go
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 из внутреннего соединения для отслеживания состояния:

go
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-клиентскими воркерами.

go
type Portal struct {
    ohm    outbound.Manager
    tag    string
    domain string
    picker *StaticMuxPicker
    client *mux.ClientManager
}

Запуск: Добавляет пользовательский обработчик Outbound в менеджер исходящих соединений:

go
func (p *Portal) Start() error {
    return p.ohm.AddHandler(context.Background(), &Outbound{
        portal: p,
        tag:    p.tag,
    })
}

HandleConnection: Вызывается при поступлении трафика на outbound портала:

go
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:

  1. Соединения от Bridge (адрес назначения совпадает с настроенным доменом): создаётся новый PortalWorker, оборачивающий mux.ClientWorker
  2. Клиентские соединения (любой другой адрес назначения): мультиплексируются через существующие mux-туннели к Bridge

PortalWorker

Файл: app/reverse/portal.go

Управляет одним mux-соединением к Bridge, включая heartbeat и процесс вывода из эксплуатации:

go
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 секунд):

go
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 байт) к управляющему сообщению для маскировки трафика:

go
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, не находящийся в режиме вывода из эксплуатации, для новых соединений:

go
func (p *StaticMuxPicker) PickAvailable() (*mux.ClientWorker, error) {
    // 1. Сначала попробовать активные воркеры, выбрать с минимумом соединений
    // 2. Если все выводятся из эксплуатации, выбрать из них
    // 3. Пропустить заполненные воркеры
}

Очистка выполняется каждые 30 секунд для удаления закрытых воркеров.

Протокол управления

Bridge и Portal обмениваются состоянием через protobuf-сообщения Control по каналу внутреннего домена:

protobuf
message Control {
    enum State {
        ACTIVE = 0;
        DRAIN = 1;
    }
    State state = 1;
    bytes random = 99;  // случайное заполнение
}
  • ACTIVE: Туннель доступен для новых соединений
  • DRAIN: Туннель выводится из эксплуатации (слишком много суммарных соединений)

Когда Portal отправляет DRAIN, Bridge обновляет своё State, и bridge-воркер перестаёт считаться «активным», что приводит к созданию замены монитором.

Диаграмма потока соединений

mermaid
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: Ответ

Конфигурация

json
{
    "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" перехватывается для внутреннего использования.

Технический анализ для целей повторной реализации.