Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
163 lines
4.1 KiB
Go
163 lines
4.1 KiB
Go
// Package streams implements raw TCP and UDP proxying, independent of the
|
|
// HTTP reverse proxy, for arbitrary protocols (game servers, VPNs, etc).
|
|
package streams
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/owen/vibeproxy/internal/metrics"
|
|
"github.com/owen/vibeproxy/internal/models"
|
|
)
|
|
|
|
// TCPProxy listens for TCP connections and forwards each to the
|
|
// configured destination, optionally prefixing the outbound connection
|
|
// with a PROXY protocol v1 header so the backend can see the real client
|
|
// address.
|
|
type TCPProxy struct {
|
|
model models.Stream
|
|
listener net.Listener
|
|
log *slog.Logger
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func NewTCPProxy(model models.Stream, log *slog.Logger) *TCPProxy {
|
|
return &TCPProxy{model: model, log: log.With("stream", streamLabel(model))}
|
|
}
|
|
|
|
func streamLabel(m models.Stream) string {
|
|
return fmt.Sprintf("tcp:%s:%d", m.ListenIP, m.ListenPort)
|
|
}
|
|
|
|
func (p *TCPProxy) Start(ctx context.Context) error {
|
|
addr := net.JoinHostPort(p.model.ListenIP, strconv.Itoa(p.model.ListenPort))
|
|
lc := net.ListenConfig{}
|
|
ln, err := lc.Listen(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("listen %s: %w", addr, err)
|
|
}
|
|
p.listener = ln
|
|
p.log.Info("tcp stream listening", "addr", addr, "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort))
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
ln.Close()
|
|
}()
|
|
|
|
p.wg.Add(1)
|
|
go p.acceptLoop(ctx)
|
|
return nil
|
|
}
|
|
|
|
func (p *TCPProxy) acceptLoop(ctx context.Context) {
|
|
defer p.wg.Done()
|
|
for {
|
|
conn, err := p.listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
p.log.Warn("accept error", "err", err)
|
|
return
|
|
}
|
|
}
|
|
p.wg.Add(1)
|
|
go func() {
|
|
defer p.wg.Done()
|
|
p.handleConn(ctx, conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (p *TCPProxy) handleConn(ctx context.Context, client net.Conn) {
|
|
defer client.Close()
|
|
metrics.ActiveConnections.WithLabelValues("tcp").Inc()
|
|
defer metrics.ActiveConnections.WithLabelValues("tcp").Dec()
|
|
metrics.TCPConnectionsTotal.WithLabelValues(streamLabel(p.model)).Inc()
|
|
|
|
dest := net.JoinHostPort(p.model.DestinationHost, strconv.Itoa(p.model.DestinationPort))
|
|
connectTimeout := time.Duration(p.model.ConnectTimeoutMS) * time.Millisecond
|
|
if connectTimeout <= 0 {
|
|
connectTimeout = 5 * time.Second
|
|
}
|
|
d := net.Dialer{Timeout: connectTimeout}
|
|
backend, err := d.DialContext(ctx, "tcp", dest)
|
|
if err != nil {
|
|
p.log.Warn("backend dial failed", "dest", dest, "err", err)
|
|
return
|
|
}
|
|
defer backend.Close()
|
|
|
|
if p.model.ProxyProtocol {
|
|
if err := writeProxyProtocolV1(backend, client.RemoteAddr(), backend.LocalAddr()); err != nil {
|
|
p.log.Warn("failed to write PROXY protocol header", "err", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
idleTimeout := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond
|
|
if idleTimeout <= 0 {
|
|
idleTimeout = 5 * time.Minute
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
pipe := func(dst, src net.Conn, direction string) {
|
|
defer wg.Done()
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
src.SetReadDeadline(time.Now().Add(idleTimeout))
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
metrics.BytesTransferred.WithLabelValues(direction, "tcp").Add(float64(n))
|
|
if _, werr := dst.Write(buf[:n]); werr != nil {
|
|
return
|
|
}
|
|
}
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
if ne, ok := dst.(interface{ CloseWrite() error }); ok {
|
|
ne.CloseWrite()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
go pipe(backend, client, "in")
|
|
go pipe(client, backend, "out")
|
|
wg.Wait()
|
|
}
|
|
|
|
func (p *TCPProxy) Stop() {
|
|
if p.listener != nil {
|
|
p.listener.Close()
|
|
}
|
|
p.wg.Wait()
|
|
}
|
|
|
|
// writeProxyProtocolV1 writes a human-readable PROXY protocol v1 header
|
|
// (as used by nginx/HAProxy) so the backend can recover the real client
|
|
// address even though the connection now originates from vibeproxy.
|
|
func writeProxyProtocolV1(w io.Writer, clientAddr, proxyAddr net.Addr) error {
|
|
ct, ok1 := clientAddr.(*net.TCPAddr)
|
|
pt, ok2 := proxyAddr.(*net.TCPAddr)
|
|
if !ok1 || !ok2 {
|
|
_, err := fmt.Fprintf(w, "PROXY UNKNOWN\r\n")
|
|
return err
|
|
}
|
|
family := "TCP4"
|
|
if ct.IP.To4() == nil {
|
|
family = "TCP6"
|
|
}
|
|
_, err := fmt.Fprintf(w, "PROXY %s %s %s %d %d\r\n", family, ct.IP.String(), pt.IP.String(), ct.Port, pt.Port)
|
|
return err
|
|
}
|