Initial vibeproxy implementation
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
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/metrics"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
// UDPProxy is a connectionless proxy that tracks per-client "sessions" (a
|
||||
// backend UDP socket per source address) so replies are routed back to the
|
||||
// correct client, with idle sessions reaped after the configured timeout.
|
||||
type UDPProxy struct {
|
||||
model models.Stream
|
||||
log *slog.Logger
|
||||
conn *net.UDPConn
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*udpSession
|
||||
}
|
||||
|
||||
type udpSession struct {
|
||||
clientAddr *net.UDPAddr
|
||||
backend *net.UDPConn
|
||||
lastActive time.Time
|
||||
}
|
||||
|
||||
func NewUDPProxy(model models.Stream, log *slog.Logger) *UDPProxy {
|
||||
return &UDPProxy{model: model, log: log.With("stream", udpLabel(model)), sessions: map[string]*udpSession{}}
|
||||
}
|
||||
|
||||
func udpLabel(m models.Stream) string { return fmt.Sprintf("udp:%s:%d", m.ListenIP, m.ListenPort) }
|
||||
|
||||
func (p *UDPProxy) Start(ctx context.Context) error {
|
||||
addr := &net.UDPAddr{IP: net.ParseIP(p.model.ListenIP), Port: p.model.ListenPort}
|
||||
if addr.IP == nil {
|
||||
addr.IP = net.IPv4zero
|
||||
}
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen udp %s:%d: %w", p.model.ListenIP, p.model.ListenPort, err)
|
||||
}
|
||||
p.conn = conn
|
||||
p.log.Info("udp stream listening", "addr", addr.String(), "destination", fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort))
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
go p.reaper(ctx)
|
||||
go p.readLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *UDPProxy) idleTimeout() time.Duration {
|
||||
d := time.Duration(p.model.IdleTimeoutMS) * time.Millisecond
|
||||
if d <= 0 {
|
||||
return 2 * time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (p *UDPProxy) readLoop(ctx context.Context) {
|
||||
buf := make([]byte, 65535)
|
||||
dest := fmt.Sprintf("%s:%d", p.model.DestinationHost, p.model.DestinationPort)
|
||||
destAddr, err := net.ResolveUDPAddr("udp", dest)
|
||||
if err != nil {
|
||||
p.log.Error("failed to resolve destination", "dest", dest, "err", err)
|
||||
return
|
||||
}
|
||||
for {
|
||||
n, clientAddr, err := p.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "in").Inc()
|
||||
metrics.BytesTransferred.WithLabelValues("in", "udp").Add(float64(n))
|
||||
|
||||
sess := p.getOrCreateSession(ctx, clientAddr, destAddr)
|
||||
if sess == nil {
|
||||
continue
|
||||
}
|
||||
if _, err := sess.backend.Write(buf[:n]); err != nil {
|
||||
p.log.Warn("write to backend failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UDPProxy) getOrCreateSession(ctx context.Context, clientAddr, destAddr *net.UDPAddr) *udpSession {
|
||||
key := clientAddr.String()
|
||||
p.mu.Lock()
|
||||
sess, ok := p.sessions[key]
|
||||
if ok {
|
||||
sess.lastActive = time.Now()
|
||||
p.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
backendConn, err := net.DialUDP("udp", nil, destAddr)
|
||||
if err != nil {
|
||||
p.log.Warn("failed to dial backend", "err", err)
|
||||
return nil
|
||||
}
|
||||
sess = &udpSession{clientAddr: clientAddr, backend: backendConn, lastActive: time.Now()}
|
||||
|
||||
p.mu.Lock()
|
||||
p.sessions[key] = sess
|
||||
p.mu.Unlock()
|
||||
|
||||
metrics.ActiveConnections.WithLabelValues("udp").Inc()
|
||||
|
||||
go p.backendReadLoop(ctx, key, sess)
|
||||
return sess
|
||||
}
|
||||
|
||||
func (p *UDPProxy) backendReadLoop(ctx context.Context, key string, sess *udpSession) {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
sess.backend.SetReadDeadline(time.Now().Add(p.idleTimeout()))
|
||||
n, err := sess.backend.Read(buf)
|
||||
if err != nil {
|
||||
p.closeSession(key)
|
||||
return
|
||||
}
|
||||
sess.lastActive = time.Now()
|
||||
metrics.UDPPacketsTotal.WithLabelValues(udpLabel(p.model), "out").Inc()
|
||||
metrics.BytesTransferred.WithLabelValues("out", "udp").Add(float64(n))
|
||||
if _, err := p.conn.WriteToUDP(buf[:n], sess.clientAddr); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UDPProxy) closeSession(key string) {
|
||||
p.mu.Lock()
|
||||
sess, ok := p.sessions[key]
|
||||
if ok {
|
||||
delete(p.sessions, key)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if ok {
|
||||
sess.backend.Close()
|
||||
metrics.ActiveConnections.WithLabelValues("udp").Dec()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UDPProxy) reaper(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cutoff := time.Now().Add(-p.idleTimeout())
|
||||
p.mu.Lock()
|
||||
for key, sess := range p.sessions {
|
||||
if sess.lastActive.Before(cutoff) {
|
||||
sess.backend.Close()
|
||||
delete(p.sessions, key)
|
||||
metrics.ActiveConnections.WithLabelValues("udp").Dec()
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *UDPProxy) Stop() {
|
||||
if p.conn != nil {
|
||||
p.conn.Close()
|
||||
}
|
||||
p.mu.Lock()
|
||||
for key, sess := range p.sessions {
|
||||
sess.backend.Close()
|
||||
delete(p.sessions, key)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
Reference in New Issue
Block a user