Files
owenandClaude Sonnet 5 ba1f7c4c03 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
2026-08-17 10:28:59 -05:00

105 lines
2.6 KiB
Go

// Package health runs periodic TCP/HTTP checks against backends and keeps
// their healthy state in loadbalancer.Backend up to date.
package health
import (
"context"
"fmt"
"net"
"net/http"
"time"
"github.com/owen/vibeproxy/internal/loadbalancer"
"github.com/owen/vibeproxy/internal/models"
)
// OnChange is invoked whenever a backend transitions healthy<->unhealthy.
type OnChange func(hostID int64, b *loadbalancer.Backend, healthy bool)
type Checker struct {
client *http.Client
onChange OnChange
cancels map[int64]context.CancelFunc
}
func NewChecker(onChange OnChange) *Checker {
return &Checker{
client: &http.Client{Timeout: 5 * time.Second},
onChange: onChange,
cancels: map[int64]context.CancelFunc{},
}
}
// Watch starts a background goroutine checking b at its configured interval
// until ctx is cancelled. hostID is used only for the OnChange callback.
func (c *Checker) Watch(ctx context.Context, hostID int64, b *loadbalancer.Backend) {
if b.Model.HealthCheckType == models.HealthCheckNone || b.Model.HealthCheckType == "" {
return
}
interval := time.Duration(b.Model.IntervalSeconds) * time.Second
if interval <= 0 {
interval = 10 * time.Second
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
c.check(ctx, hostID, b)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.check(ctx, hostID, b)
}
}
}()
}
func (c *Checker) check(ctx context.Context, hostID int64, b *loadbalancer.Backend) {
ok := false
switch b.Model.HealthCheckType {
case models.HealthCheckTCP:
ok = c.checkTCP(ctx, b)
case models.HealthCheckHTTP:
ok = c.checkHTTP(ctx, b)
default:
ok = true
}
if changed, healthy := b.RecordCheck(ok); changed && c.onChange != nil {
c.onChange(hostID, b, healthy)
}
}
func (c *Checker) checkTCP(ctx context.Context, b *loadbalancer.Backend) bool {
d := net.Dialer{Timeout: 3 * time.Second}
addr := net.JoinHostPort(b.Model.Host, fmt.Sprintf("%d", b.Model.Port))
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return false
}
conn.Close()
return true
}
func (c *Checker) checkHTTP(ctx context.Context, b *loadbalancer.Backend) bool {
path := b.Model.HealthCheckPath
if path == "" {
path = "/"
}
url := fmt.Sprintf("http://%s:%d%s", b.Model.Host, b.Model.Port, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false
}
resp, err := c.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
want := b.Model.HealthCheckStatus
if want == 0 {
want = 200
}
return resp.StatusCode == want
}