Self-hosted аналог ngrok на Go за 10 часов с Claude Code | AiManual
AiManual Logo Ai / Manual.
28 Дек 2025 Гайд

Свой ngrok за 10 часов: как я написал self-hosted аналог с помощью Claude Code

Подробный гайд по созданию собственного туннелирующего сервиса на Go с помощью AI-ассистента Claude Code. Разбираем архитектуру, код и деплой.

💡
Важная преамбула: Эта статья — не просто технический гайд, а история о том, как современные AI-инструменты вроде Claude Code меняют подход к разработке. Раньше подобный проект занял бы недели исследований и отладки, сегодня — один вечер продуктивной работы с AI-ассистентом.

Проблема: почему мне перестал хватать 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
}
💡
Совет от Claude: Используйте RWMutex вместо обычного Mutex для map, так как чтений будет значительно больше, чем записей. Это даст прирост производительности до 5-10x при высокой нагрузке.

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, который:

  1. Работает на моих серверах
  2. Не имеет лимитов на трафик
  3. Позволяет кастомизировать под свои нужды
  4. Стоит дешевле $10 в месяц при активном использовании

Но главное — этот проект показал, как изменилась разработка с приходом AI-ассистентов. То, что раньше требовало глубоких знаний сетевого программирования и недель работы, сегодня можно сделать за вечер, сосредоточившись на архитектуре, а не на синтаксисе.

Кстати, если вы работаете с локальными LLM и ищете способы их оптимизации, обязательно посмотрите наши гайды по оптимизации llama.cpp под AMD видеокарты и использованию NPU в AI MAX 395.

🚀
Бонус: Полный исходный код проекта доступен на GitHub. Не стесняйтесь форкать, дорабатывать и адаптировать под свои задачи. Главное — начать и не бояться экспериментировать с AI-ассистентами!