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,113 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/database"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
type running struct {
|
||||
model models.Stream
|
||||
cancel context.CancelFunc
|
||||
tcp *TCPProxy
|
||||
udp *UDPProxy
|
||||
}
|
||||
|
||||
// Manager owns the set of currently-listening TCP/UDP streams and can
|
||||
// reconcile it against the database without disturbing streams that
|
||||
// haven't changed, so existing connections on unaffected streams are never
|
||||
// dropped by a reload.
|
||||
type Manager struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
active map[int64]*running // stream ID -> running listener
|
||||
baseCtx context.Context
|
||||
}
|
||||
|
||||
func NewManager(db *sql.DB, log *slog.Logger) *Manager {
|
||||
return &Manager{db: db, log: log, active: map[int64]*running{}, baseCtx: context.Background()}
|
||||
}
|
||||
|
||||
// Reload fetches the current stream configuration and starts/stops
|
||||
// listeners to match it. Streams whose configuration is unchanged are left
|
||||
// running untouched.
|
||||
func (m *Manager) Reload(ctx context.Context) error {
|
||||
streams, err := database.ListStreams(m.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desired := map[int64]models.Stream{}
|
||||
for _, s := range streams {
|
||||
if s.Enabled {
|
||||
desired[s.ID] = s
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Stop streams that were removed or disabled.
|
||||
for id, r := range m.active {
|
||||
if _, ok := desired[id]; !ok {
|
||||
m.stopLocked(id, r)
|
||||
}
|
||||
}
|
||||
|
||||
for id, s := range desired {
|
||||
if r, ok := m.active[id]; ok {
|
||||
if reflect.DeepEqual(r.model, s) {
|
||||
continue // unchanged, leave the listener (and its connections) alone
|
||||
}
|
||||
m.stopLocked(id, r)
|
||||
}
|
||||
m.startLocked(id, s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startLocked(id int64, s models.Stream) {
|
||||
ctx, cancel := context.WithCancel(m.baseCtx)
|
||||
r := &running{model: s, cancel: cancel}
|
||||
var err error
|
||||
switch s.Protocol {
|
||||
case models.StreamTCP:
|
||||
r.tcp = NewTCPProxy(s, m.log)
|
||||
err = r.tcp.Start(ctx)
|
||||
case models.StreamUDP:
|
||||
r.udp = NewUDPProxy(s, m.log)
|
||||
err = r.udp.Start(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
m.log.Error("failed to start stream", "id", id, "protocol", s.Protocol, "err", err)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
m.active[id] = r
|
||||
}
|
||||
|
||||
func (m *Manager) stopLocked(id int64, r *running) {
|
||||
r.cancel()
|
||||
if r.tcp != nil {
|
||||
r.tcp.Stop()
|
||||
}
|
||||
if r.udp != nil {
|
||||
r.udp.Stop()
|
||||
}
|
||||
delete(m.active, id)
|
||||
}
|
||||
|
||||
// StopAll shuts down every running stream (used on process shutdown).
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, r := range m.active {
|
||||
m.stopLocked(id, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package streams
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
)
|
||||
|
||||
func TestTCPProxyEndToEnd(t *testing.T) {
|
||||
// Echo backend.
|
||||
backendLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backendLn.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := backendLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
io.Copy(c, c)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
backendAddr := backendLn.Addr().(*net.TCPAddr)
|
||||
model := models.Stream{
|
||||
Protocol: models.StreamTCP, ListenIP: "127.0.0.1", ListenPort: 0,
|
||||
DestinationHost: "127.0.0.1", DestinationPort: backendAddr.Port,
|
||||
ConnectTimeoutMS: 2000, IdleTimeoutMS: 5000,
|
||||
}
|
||||
|
||||
// Bind an ephemeral port ourselves so we know it up front, matching
|
||||
// what NewTCPProxy will do.
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model.ListenPort = probe.Addr().(*net.TCPAddr).Port
|
||||
probe.Close()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
proxy := NewTCPProxy(model, log)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := proxy.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer proxy.Stop()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", itoa(model.ListenPort)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.Write([]byte("ping")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(buf) != "ping" {
|
||||
t.Fatalf("expected echo of ping, got %q", buf)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
b = append([]byte{'-'}, b...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -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