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

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