При заказе VPS/VPS (уж не знаю, для каких вам нужд) - стандартная схема - при минимальной комплектации вам дают сервер, к которому можно подключиться только по SSH, и нельзя открыть никакие другие порты. Сам порт SSH может отличаться от стардартного 22-го (обычно так и происходит в начале).
За дополнительную плату можно подключить опцию “статический IP” - и тогда уже можно открывать какие угодно порты и вешать на них какие угодно сервисы, веб-серверы, прокси-серверы.
В целом, деньги то есть, и тут пробуждается внутренний маленький хакерский интерес: а обойти то это можно?) Можно устроить, например, HTTP-сервер себе, или любимый прокси сервер поставить, чтобы подключаться через этот единственный данный порт? Особенно, если более не для чего этот самый VPS и не нужен) Обычно, как раз, прокси серверы и располагаются на каком-то случайном порту.
Хорошо, у меня есть виртуалка, с одним открытым портом - для подключения по SSH. По сути, это же обычное TCP соединение, на этот порт идет поток каких-то байт… чем он отличается от подключения, к тому же Xray?..
Собственно тем, что этот порт слушает служба sshd (демон SSH входящих соединений), и у неё есть стандарты RFC с описаниями конкретных форматов байт, пакетов, входящих и исходящих сообщений…
Куча протоколов поверх TCP начинаются с некоторого стандартного известного заголовка (сигнатур), и быстро детектируются в таких программах/сканерах/анализаторах траффика/фильтрах, как, например, Wireshark.
В разных протоколах по-разному, после установления TCP-соединения, либо клиент, либо сервер посылает первым стартовое сообщение. Общеизвестные примеры стандартных протоколов:
SSH - сервер сразу посылает SSH identification string, строка вида
SSH-[version], и ожидает похожую строку в ответHTTP - клиент шлёт строки с именами методов
GET/POST/PUT/DELETE…HTTPS - клиент шлет байты
16 03… (Handshake - TLS Record Type)SMTP - клиент шлёт строку
EHLOSOCKS5 - клиент шлет байт
05(SOCKS version 5)
По сути, по первым байтам нового входящего соединения мы можем с 99.99% уверенностью назвать общеизвестный протокол (только если это не коннектится какой-нибудь хакерок с кастомным протоколом - т.е. мы). В целом, т.к. мы будем орудовать на своём серваке - мы рассчитываем что и коннектиться будем мы сами.
Вот мой реальный случай - арендовал VPS, мне выдают порт 13562 для SSH подключения, рутового пользователя root и пароль.
Коннекчусь неткатом к выданному порту 13562 своей арендованной VPS и сразу получаю в ответ SSH identification string с версией SSH на сервере (сервер шлёт свой identification string первым)
# nc <IP моего сервера> 13562 -- SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.16 -- сервер отвечает версией QWE -- ввожу белиберду с клавиатуры Invalid SSH identification string. -- сервер говорит, что ему не нравится, и разрывает соединение
Арендованный VPS на 13562 порту ожидает такой же SSH identification string от меня. Все остальное он будет трактовать как ошибку протокола и будет отбрасывать соединения.
Супер. Получается есть 2 варианта - либо это корректное SSH-соединение, либо кто-то что-то делает неладное/не туда залез.
А что, если вместо демона sshd самим слушать этот единственный порт для SSH-соединений, “прикинувшись им”; если начинается соединение, похожее на SSH (по первым, условно 4м символам-байтам), роутить его на настроящий (другой) SSH-порт, а если там что-то другое, то роутить на нужный мне порт? Например, если я подниму веб-сервер на порту 3000 (который будет закрыт для интернета), то запрос GET будет перенаправлен моим “SSH фейковым сервером” на порт 3000 и веб-сервер уже в свою очередь, вернет HTTP-ответ.
Т.е. нужно поменять прослушиваемый порт sshd с 13562 на какой-то другой, написать маленький сервис, который будет слушать 13562, и в зависимости от начальных байтов подключения роутить соединение к другим портам.
Посмотреть порт, который слушает sshd-демон, можно в его конфинге - обычно это файл /etc/ssh/sshd_config. Как ни странно, открыв его на арендованном VPS я увидел там параметр Port 22 - то, что нужно, но это не 13562. Это значит, что извне есть какой-то маршутизатор, который принимает соединения на порту 13562 и пробрасывает его на мою VPS уже на порт 22. В целом - мне всё равно.
Окей, можно поменять в конфинге порт на 2222, и тогда sshd будет слушать его (и мы уже не достучимся к серверу!).
Соответственно, порт 22 будет прослушивать моя маленькая программа, которая по началу строки SSH- будет перенаправлять соединение либо на настроящий sshd на новом порту 2222 либо всё остальное роутить на нужный порт, пускай, 3000. А уже на 3000 я включу - да, что угодно, хоть любимый Xray.
Чтобы всё заработало, буду юзать systemd - тогда при рестарте виртуалки она сама заботливо поднимет фейк-сервер на 22-м порту и настоящий sshd демон на 2222-м (плюс, что нам нужно на 3000-м порту, если об этом позаботиться). Никаких конфликтов.
Есть тонкий момент - порты до 1024 считаются системными, и, чтобы их слушать, нужно запускать программу от root. Благо, почти всегда на VPS именно такой доступ и выдают.
Важно: перед тем, как применять этот подход на своём VPS, убедись, что это соответствует условиям пользования твоего хостера. Некоторые провайдеры запрещают в своём Terms of Service технику маскировки протоколов или использование нескольких сервисов за одним портом, рассматривая это как попытку обхода предоставленных ограничений. Данный способ — это скорее образовательный пример и решение для личных экспериментов на тестовом сервере, а не способ обойти коммерческие ограничения хостера.
Хорошо, план такой:
пишем ту самую программку-роутер - берём
go, чтобы собрать единственный бинарник и только его закинуть на VPSсобераем бинарник
заливаем бинарник на VPS
пишем конфигурацию сервиса для
systemd, которая будет стартовать нашу программку на порту 13562поправим порт sshd с 22-го на 2222-й
ребутнем всё это дело и надеемся на лучшее!
Итак, программка принимает 3 аргумента через environment variables (мне так удобнее)
порт, на котором будет слушать входящие соединения - LISTEN_PORT - ставим 22 (прикидываемся что мы sshd)
настоящий порт sshd, куда будем роутить SSH-соединения - SSH_REAL_PORT - у нас это 2222
порт, куда будем отправлять все остальные соединения, которые не прислали в начале строку
SSH-- т.е. все соединения на наш условный прокси-сервер или веб-сервер - FORWARD_PORT - пускай будет 3000
Программируем, создадим сущность server и прочитаем входящие аргументы в конструкторе newServer:
type server struct { listenPort int sshRealPort int forwardPort int } func newServer() (*server, error) { // порт, на котором раньше сервер слушал ssh, скорее всего 22 port := os.Getenv("LISTEN_PORT") if port == "" { port = "22" } portInt, err := strconv.Atoi(port) if err != nil { return nil, err } // текущий реальный порт для подключения по ssh sshPort := os.Getenv("SSH_REAL_PORT") if sshPort == "" { sshPort = "2222" } sshPortInt, err := strconv.Atoi(sshPort) if err != nil { return nil, err } // порт, куда будем роутить весь остальной траффик, там будет слушать // какой нибудь xray или другой прокси-сервер или что нам нужно forwardPort := os.Getenv("FORWARD_PORT") if forwardPort == "" { forwardPort = "3000" } forwardPortInt, err := strconv.Atoi(forwardPort) if err != nil { return nil, err } return &server{ listenPort: portInt, sshRealPort: sshPortInt, forwardPort: forwardPortInt, }, nil }
Отлично, слушаем все входящие соединения на listenPort:
func (s *server) Listen() error { listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.listenPort)) if err != nil { return err } defer listener.Close() log.Printf("server listening on port %d, route SSH-connections to port %d and other connections to port %d", s.listenPort, s.sshRealPort, s.forwardPort) for { conn, err := listener.Accept() if err != nil { log.Printf("error accepting connection: %v", err) continue } go s.handleConnection(conn) } }
Для каждого нового TCP-соединения:
читаем его первые 4 байта
сверяем их со строкой
SSH-в зависимости от совпадения/различия - выбираем порт
sshRealPortилиforwardPortсоздаем соединение до этого порта
пишем туда эти 4 байта
проксируем весь остальной траффик
func (s *server) handleConnection(conn net.Conn) { defer conn.Close() log.Printf("got connection from %s", conn.RemoteAddr().String()) // любое ssh соединение начинается с этой строки - 4 байта, далее следует версия const sshPrefix = "SSH-" buf := make([]byte, len(sshPrefix)) // устанавливаем таймаут на чтение, чтобы избежать DoS от зависания соединения const readTimeout = 5 * time.Second conn.SetReadDeadline(time.Now().Add(readTimeout)) // читаем ровно 4 байта, чтобы опознать ssh это соединение, или какое-то другое _, err := io.ReadFull(conn, buf) if errors.Is(err, io.EOF) { log.Printf("connection from %s closed", conn.RemoteAddr().String()) return } if err != nil { log.Printf("error reading header from connection %s: %v", conn.RemoteAddr().String(), err) return } var targetPort int if bytes.Equal(buf, []byte(sshPrefix)) { targetPort = s.sshRealPort log.Printf("connection from %s forward to real ssh port %d", conn.RemoteAddr().String(), targetPort) } else { targetPort = s.forwardPort log.Printf("connection from %s forward to port %d", conn.RemoteAddr().String(), targetPort) } // sshd и другие сервисы должны быть на той же машине и слушать 127.0.0.1:[PORT] targetAddr := fmt.Sprintf("127.0.0.1:%d", targetPort) targetConn, err := net.Dial("tcp", targetAddr) if err != nil { log.Printf("error connecting to %s: %v", targetAddr, err) return } defer targetConn.Close() // пишем прочитанные уже данные (4 байта) в реальный порт _, err = targetConn.Write(buf) if err != nil { log.Printf("error writing to target: %v", err) return } // роутим соединение в targetConn, 2 направления (дуплекс) proxyConnection(conn, targetConn) log.Printf("connection from %s to %s closed", conn.RemoteAddr().String(), targetAddr) }
Пишем реализацию функции proxyConnection, которая будет проксировать наше дуплексное TCP-соединение на нужный порт, это достаточно классическая маленькая задачка на Go:
func proxyConnection(conn, targetConn net.Conn) { var wg sync.WaitGroup pipe := func(dst, src net.Conn, label string) { _, err := io.Copy(dst, src) if err != nil && !isAcceptableError(err) { log.Printf("proxy %s: %v", label, err) } // отдельная обработка полузакрытия для net.TCPConn if tcp, ok := dst.(*net.TCPConn); ok { tcp.CloseWrite() } else { dst.Close() } } wg.Go(func() { pipe(targetConn, conn, "client -> target") }) wg.Go(func() { pipe(conn, targetConn, "target -> client") }) wg.Wait() conn.Close() targetConn.Close() } func isAcceptableError(err error) bool { return errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.ECONNRESET) }
Запускаем сервер на прослушку:
func main() { log.SetPrefix("[ssh-router]: ") server, err := newServer() if err != nil { log.Fatalf("error creating server: %v", err) } if err := server.Listen(); err != nil { log.Fatalf("server error: %v", err) } }
ssh-router 2026/09/17 12:58:59 server listening on port 22, route SSH-connections to port 2222 and other connections to port 3000
Пробуем отправить HTTP-запрос (на 3000 порту поднял простой веб-сервер)
curl http://localhost:22
ssh-router 2026/09/17 12:59:05 got connection from [::1]:52753 ssh-router 2026/09/17 12:59:05 connection from [::1]:52753 forward to port 3000 ssh-router 2026/09/17 12:59:05 connection from [::1]:52753 to 127.0.0.1:3000 closed
Надеемся на лучшее, что с SSH-соединением тоже сработает, собираем бинарник в файл ssh-router под Linux
GOOS=linux GOARCH=amd64 go build -o ssh-router main.go
Закидываем его на VPS (я использую scp)
scp -P 13562 ssh-router root@[VPS IP]:/root/ssh-router
Теперь нужно написать файл-описание сервиса, чтобы наша программка запускалась на старте VPS, создаем файл ssh-router.service в /etc/systemd/system/
[Unit] Description=SSH Router Service After=network.target [Service] Type=simple User=root WorkingDirectory=/root Environment="LISTEN_PORT=22" Environment="SSH_REAL_PORT=2222" Environment="FORWARD_PORT=3000" ExecStart=/root/ssh-router Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
Меняем порт (Port 22) в /etc/ssh/sshd_config на 2222-й
Делаем приседания, чтобы сервис работал на старте
systemctl daemon-reload systemctl enable ssh-router
…и (надеясь на лучшее!) выполняем долгожданный reboot!
После ребута, если вам удалось подключиться по SSH (ха-ха!), то это значит, что всё работает)
В целом, можно расширить логику программки: в зависимости от разных входных строк (детектирования разных протоколов нужных), можно роутить соединения до нужных разных портов. Например, веб сервер, SOCKS-прокси, XRAY-какой-нибудь, да что хотите.
Шалость удалась!
А теперь по-взрослому
По сути, мы тут немного помечтали и поделали велосипедов, а вообще, такая задача решается применением уже готовых штук, таких как HAProxy или Nginx stream module.
HAProxy — это мощный reverse proxy и load balancer, который может инспектировать содержимое TCP-пакетов и роутить трафик на основе этого анализа дальше. Предоставлю маленький конфинг для нашей задачи:
frontend ssh_or_http # слушаем входящий порт 22 (в нашем примере это порт, который выдал хостер) bind *:22 mode tcp # таймаут для анализа входящих данных tcp-request inspect-delay 5s # принимаем соединение tcp-request content accept if WAIT_END # если первые 4 байта — это "SSH-", отправляем на backend с реальным sshd ssh_backend use_backend ssh_backend if { req.payload(0,4) -m str "SSH-" } # все остальные соединения идут на http_backend с нашим прокси-сервером default_backend http_backend backend ssh_backend mode tcp # реальный SSH демон слушает на порту 2222 server ssh_server 127.0.0.1:2222 backend http_backend mode tcp # прокси-сервер (Xray, Trojan или что-то ещё) слушает на порту 3000 server http_server 127.0.0.1:3000
Ну вот как-то так, но мне всегда приятнее и понятнее разбираться в байтах, когда я их сам перекладываю. А если серьёзно, то HAProxy — отличный выбор для production, если нужна надёжность и масштабируемость.
Спасибо за прочитанную статью и удачи в программировании!
