Проблема: почему мне перестал хватать ngrok?
Как разработчик, я постоянно использую ngrok для демонстрации локальных проектов клиентам и тестирования веб-хуков. Но с ростом проектов появились проблемы:
- Лимиты бесплатного тарифа — 40 соединений в минуту, динамические домены меняются
- Проблемы с приватностью — трафик проходит через сторонние серверы
- Зависимость от доступности сервиса — бывают простои
- Стоимость self-hosted версии — от $50 в месяц для бизнеса
Важно: ngrok — отличный продукт, и этот гайд не призывает отказываться от него. Речь о создании собственного решения для специфических потребностей и обучения.
Решение: Go + Claude Code = свой туннель за вечер
Go оказался идеальным выбором для сетевого приложения: статическая компиляция, горутины для конкурентности, богатая стандартная библиотека. А Claude Code стал моим полноценным напарником — от проектирования архитектуры до отладки edge cases.
Кстати, если вы только знакомитесь с AI-ассистентами для разработки, рекомендую прочитать нашу статью о проблемах SOTA-моделей в Claude Code, чтобы избежать типичных ошибок.
1 Архитектура проекта
Перед тем как писать код, я потратил час на проектирование с Claude. Вот что у нас получилось:
| Компонент | Назначение | Технология |
|---|---|---|
| Сервер (tunnel-server) | Принимает публичные соединения, перенаправляет клиентам | Go, HTTP/WebSocket |
| Клиент (tunnel-client) | Подключается к серверу, принимает локальные соединения | Go, HTTP reverse proxy |
| Балансировщик | Распределяет соединения по субдоменам | Go + конфиг |
2 Настройка окружения и первый код
Сначала подготовим рабочее окружение. Убедитесь, что у вас установлены Go 1.21+ и Claude Code (или аналогичный AI-ассистент).
# Создаем структуру проекта
mkdir my-ngrok
cd my-ngrok
mkdir -p cmd/{server,client} internal/pkg configs
go mod init github.com/yourname/my-ngrok
touch cmd/server/main.go cmd/client/main.go
Вот базовый конфиг для сервера (configs/server.yaml):
server:
host: "0.0.0.0"
port: 8080
domain: "tunnel.yourdomain.com"
subdomain_length: 8
auth:
enabled: true
tokens:
- "your-secret-token-here"
logging:
level: "info"
format: "json"
3 Ядро сервера: обработка туннелей
Самый интересный момент — реализация маппинга субдоменов на активные туннели. С Claude Code это оказалось проще, чем я думал:
// internal/pkg/tunnel/tunnel_manager.go
package tunnel
import (
"sync"
"time"
"crypto/rand"
"encoding/hex"
"net/http"
"net/http/httputil"
"net/url"
)
type Tunnel struct {
ID string
Subdomain string
ClientURL *url.URL
CreatedAt time.Time
LastUsed time.Time
Active bool
}
type TunnelManager struct {
mu sync.RWMutex
tunnels map[string]*Tunnel // subdomain -> tunnel
clients map[string]string // clientID -> subdomain
proxy *httputil.ReverseProxy
}
func NewTunnelManager() *TunnelManager {
return &TunnelManager{
tunnels: make(map[string]*Tunnel),
clients: make(map[string]string),
}
}
func (tm *TunnelManager) CreateTunnel(clientURL string) (*Tunnel, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
// Генерируем уникальный субдомен
subdomain, err := generateSubdomain(8)
if err != nil {
return nil, err
}
// Парсим URL клиента
targetURL, err := url.Parse(clientURL)
if err != nil {
return nil, err
}
tunnel := &Tunnel{
ID: generateID(),
Subdomain: subdomain,
ClientURL: targetURL,
CreatedAt: time.Now(),
LastUsed: time.Now(),
Active: true,
}
tm.tunnels[subdomain] = tunnel
return tunnel, nil
}
func generateSubdomain(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
4 WebSocket для real-time соединений
Для поддержки long-lived соединений и передачи данных в реальном времени реализуем WebSocket хендлер:
// internal/pkg/websocket/manager.go
package websocket
import (
"github.com/gorilla/websocket"
"log"
"net/http"
"sync"
"time"
)
type Client struct {
ID string
Conn *websocket.Conn
Tunnel string
Send chan []byte
}
type Manager struct {
clients map[string]*Client
mu sync.RWMutex
upgrader websocket.Upgrader
}
func NewManager() *Manager {
return &Manager{
clients: make(map[string]*Client),
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // В продакшене нужно реализовать проверку!
},
},
}
}
func (m *Manager) HandleConnection(w http.ResponseWriter, r *http.Request) {
conn, err := m.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
client := &Client{
ID: generateClientID(),
Conn: conn,
Send: make(chan []byte, 256),
}
m.mu.Lock()
m.clients[client.ID] = client
m.mu.Unlock()
go m.writePump(client)
go m.readPump(client)
}
5 Клиентская часть: локальный прокси
Клиент должен подключаться к серверу и перенаправлять трафик с локального порта:
// cmd/client/main.go
package main
import (
"flag"
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
"github.com/gorilla/websocket"
)
func main() {
serverAddr := flag.String("server", "ws://tunnel.yourdomain.com:8080/ws", "Server address")
localAddr := flag.String("local", "http://localhost:3000", "Local service address")
subdomain := flag.String("subdomain", "", "Request specific subdomain")
token := flag.String("token", "", "Authentication token")
flag.Parse()
// Устанавливаем WebSocket соединение
headers := http.Header{}
if *token != "" {
headers.Set("Authorization", "Bearer "+*token)
}
conn, _, err := websocket.DefaultDialer.Dial(*serverAddr, headers)
if err != nil {
log.Fatal("Failed to connect to server:", err)
}
defer conn.Close()
// Регистрируем туннель
registration := map[string]string{
"action": "register",
"local": *localAddr,
"subdomain": *subdomain,
}
if err := conn.WriteJSON(registration); err != nil {
log.Fatal("Failed to register tunnel:", err)
}
// Обрабатываем входящие запросы
go handleIncomingRequests(conn, *localAddr)
// Поддерживаем соединение
for {
time.Sleep(30 * time.Second)
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Println("Ping failed, reconnecting...")
break
}
}
}
6 Деплой на свой сервер
После разработки нужно развернуть решение. Я использовал VPS с Ubuntu 22.04. Вот Dockerfile для сервера:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o tunnel-server ./cmd/server
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/tunnel-server .
COPY configs/server.yaml ./config.yaml
EXPOSE 8080 8081
CMD ["./tunnel-server", "-config", "./config.yaml"]
И docker-compose.yml для удобства:
version: '3.8'
services:
tunnel-server:
build:
context: .
dockerfile: Dockerfile.server
ports:
- "8080:8080" # WebSocket/HTTP
- "8081:8081" # Admin API
volumes:
- ./configs:/root/configs
restart: unless-stopped
networks:
- tunnel-network
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- tunnel-server
restart: unless-stopped
networks:
- tunnel-network
networks:
tunnel-network:
driver: bridge
Наиболее сложные моменты и как их преодолеть
В процессе разработки возникло несколько нетривиальных проблем:
1. Graceful shutdown
При завершении работы сервера нужно корректно закрыть все соединения. Claude помог с элегантным решением:
func (s *Server) Start() error {
// ... инициализация ...
// Канал для graceful shutdown
stopChan := make(chan os.Signal, 1)
signal.Notify(stopChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-stopChan
log.Println("Shutting down server...")
// Даем 30 секунд на завершение работы
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
// Закрываем все туннели
s.tunnelManager.Shutdown()
}()
return s.ListenAndServe()
}
2. Балансировка и health checks
Когда клиентов много, нужно мониторить их состояние. Реализовали простую систему health checks:
func (tm *TunnelManager) StartHealthChecks() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
tm.checkAllTunnels()
case <-tm.ctx.Done():
return
}
}
}
func (tm *TunnelManager) checkAllTunnels() {
tm.mu.RLock()
defer tm.mu.RUnlock()
for subdomain, tunnel := range tm.tunnels {
if time.Since(tunnel.LastUsed) > 5*time.Minute {
go tm.healthCheckTunnel(subdomain, tunnel)
}
}
}
Предупреждение: Не забывайте про безопасность! В этом примере проверка origin отключена для простоты, но в продакшене обязательно реализуйте валидацию доменов и SSL.
Сравнение с оригинальным ngrok
| Функция | ngrok (бесплатный) | Наше решение |
|---|---|---|
| Трафик в месяц | 1 ГБ | Неограниченно |
| Одновременные туннели | 1 | Настраивается |
| Субдомены | Динамические | Статические/динамические |
| Приватность | Трафик через ngrok | Полностью ваш сервер |
| Web UI | Есть | Нужно дописать |
Что дальше? Планы по развитию
Базовый функционал готов, но есть куда расти:
- Панель управления — веб-интерфейс для мониторинга туннелей
- Аналитика трафика — сбор метрик по использованию
- Кластеризация — несколько серверов для отказоустойчивости
- Плагины — middleware для аутентификации, rate limiting
Если вам интересна тема кластеризации и распределенных систем, рекомендую нашу статью про запуск Llama.cpp в LXC-контейнерах Proxmox, где разбираются похожие концепции изоляции и управления ресурсами.
FAQ: ответы на частые вопросы
Стоит ли использовать это в продакшене?
Для личных проектов и тестирования — да. Для критически важных бизнес-процессов рекомендую использовать проверенные решения или серьезно доработать систему безопасности и мониторинга.
Какой сервер лучше выбрать для хостинга?
Минимум 1 ГБ RAM, 1 CPU, хороший канал. Для 10-20 одновременных пользователей хватит самого дешевого VPS за $5-10 в месяц.
Можно ли использовать с локальными AI-моделями?
Отлично! Туннель позволяет демонстрировать локально запущенные модели типа неазиатских open-source моделей для агентов коллегам или клиентам без деплоя в облако.
Как обрабатывать SSL сертификаты?
Используйте Let's Encrypt через certbot или автоматическое обновление в nginx. Для wildcard сертификатов потребуется DNS-провайдер с API.
Выводы
За 10 часов активной работы с Claude Code я создал полностью функциональный аналог ngrok, который:
- Работает на моих серверах
- Не имеет лимитов на трафик
- Позволяет кастомизировать под свои нужды
- Стоит дешевле $10 в месяц при активном использовании
Но главное — этот проект показал, как изменилась разработка с приходом AI-ассистентов. То, что раньше требовало глубоких знаний сетевого программирования и недель работы, сегодня можно сделать за вечер, сосредоточившись на архитектуре, а не на синтаксисе.
Кстати, если вы работаете с локальными LLM и ищете способы их оптимизации, обязательно посмотрите наши гайды по оптимизации llama.cpp под AMD видеокарты и использованию NPU в AI MAX 395.