golang

Разворачиваем порты VPS'а: маленькая шалость программиста

  • суббота, 17 октября 2026 г. в 00:00:07
https://habr.com/ru/articles/1083402/

При заказе 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 - клиент шлёт строку EHLO

  • SOCKS5 - клиент шлет байт 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, если нужна надёжность и масштабируемость.

Спасибо за прочитанную статью и удачи в программировании!