initial commit
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s

This commit is contained in:
2026-06-06 07:54:44 -05:00
commit 1cc94f2c99
68 changed files with 14615 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrInvalidToken = errors.New("invalid or expired token")
ErrNoToken = errors.New("no authorization token")
)
// Claims for our JWT (minimal, matching original style).
type Claims struct {
UserID int `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// Context key for user info.
type contextKey string
const UserContextKey contextKey = "user"
// JWTManager handles signing and validation.
type JWTManager struct {
secret []byte
}
// NewJWTManager creates a manager. In real use, load secret from secure store or env (never commit real secret).
// For demo we accept a secret; in production rotate and use env/JWT_SECRET or db meta.
func NewJWTManager(secret string) *JWTManager {
if secret == "" {
secret = "dev-only-insecure-secret-change-in-prod"
}
return &JWTManager{secret: []byte(secret)}
}
// GenerateToken creates a JWT for the given user (1 hour expiry like typical).
func (m *JWTManager) GenerateToken(userID int, email, name string, roles []string) (string, error) {
now := time.Now()
if roles == nil {
roles = []string{}
}
claims := Claims{
UserID: userID,
Email: email,
Name: name,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Issuer: "helix-proxy",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
// ValidateToken parses and validates the token, returns claims.
func (m *JWTManager) ValidateToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil {
return nil, ErrInvalidToken
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, ErrInvalidToken
}
// Middleware returns a chi/http middleware that validates Bearer token and injects user into context.
// Skips if no token (for public endpoints like /login we handle separately).
// On failure returns 401.
func (m *JWTManager) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// No token: let handler decide (some endpoints public)
next.ServeHTTP(w, r)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
http.Error(w, "invalid authorization header", http.StatusUnauthorized)
return
}
claims, err := m.ValidateToken(parts[1])
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Inject into context
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetUserFromContext extracts claims if present (for handlers that require auth).
func GetUserFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(UserContextKey).(*Claims)
return claims, ok
}
// RequireAuth is a simple helper that can be used inside handlers for protected routes
// (alternative to middleware if you want per-route).
func RequireAuth(w http.ResponseWriter, r *http.Request) (*Claims, bool) {
claims, ok := GetUserFromContext(r.Context())
if !ok {
http.Error(w, "authentication required", http.StatusUnauthorized)
return nil, false
}
return claims, true
}
+128
View File
@@ -0,0 +1,128 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestJWTManager_GenerateValidate_Roundtrip(t *testing.T) {
m := NewJWTManager("test-secret-123")
token, err := m.GenerateToken(42, "user@example.com", "User", []string{"admin", "user"})
if err != nil {
t.Fatal(err)
}
if token == "" {
t.Error("empty token")
}
claims, err := m.ValidateToken(token)
if err != nil {
t.Fatalf("validate: %v", err)
}
if claims.UserID != 42 || claims.Email != "user@example.com" || len(claims.Roles) != 2 {
t.Errorf("claims: %+v", claims)
}
if claims.ExpiresAt == nil || time.Until(claims.ExpiresAt.Time) > time.Hour {
t.Error("expiry not ~1h")
}
}
func TestJWTManager_DevSecretFallback(t *testing.T) {
m := NewJWTManager("") // empty -> dev
token, _ := m.GenerateToken(1, "a@b", "A", nil)
_, err := m.ValidateToken(token)
if err != nil {
t.Error("dev secret should allow roundtrip")
}
}
func TestJWTManager_BadToken(t *testing.T) {
m := NewJWTManager("s")
if _, err := m.ValidateToken("not.a.jwt"); err == nil {
t.Error("bad token should err")
}
// wrong sig
m2 := NewJWTManager("other")
tok, _ := m2.GenerateToken(1, "e", "n", nil)
if _, err := m.ValidateToken(tok); err == nil {
t.Error("wrong sig should invalid")
}
}
func TestJWTManager_Middleware(t *testing.T) {
m := NewJWTManager("sec")
token, _ := m.GenerateToken(7, "m@e", "M", []string{"admin"})
// with valid bearer
h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, ok := GetUserFromContext(r.Context())
if !ok || c.UserID != 7 {
t.Error("claims not in ctx")
}
w.WriteHeader(200)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Errorf("valid bearer: %d", rr.Code)
}
// no token: passes through (no 401)
h2 := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}))
req2 := httptest.NewRequest("GET", "/", nil)
rr2 := httptest.NewRecorder()
h2.ServeHTTP(rr2, req2)
if rr2.Code != 204 {
t.Error("no token should pass")
}
// bad token: 401
req3 := httptest.NewRequest("GET", "/", nil)
req3.Header.Set("Authorization", "Bearer bad")
rr3 := httptest.NewRecorder()
h.ServeHTTP(rr3, req3)
if rr3.Code != 401 {
t.Errorf("bad token 401: %d", rr3.Code)
}
// wrong scheme
req4 := httptest.NewRequest("GET", "/", nil)
req4.Header.Set("Authorization", "Basic foo")
rr4 := httptest.NewRecorder()
h.ServeHTTP(rr4, req4)
if rr4.Code != 401 {
t.Errorf("bad scheme: %d", rr4.Code)
}
}
func TestRequireAuth(t *testing.T) {
// direct, needs ctx with claims
m := NewJWTManager("s")
tok, _ := m.GenerateToken(99, "r@a", "R", nil)
claims, _ := m.ValidateToken(tok)
req := httptest.NewRequest("GET", "/", nil)
// without ctx
rr := httptest.NewRecorder()
c, ok := RequireAuth(rr, req)
if ok || c != nil || rr.Code != 401 {
t.Error("require without claims should 401")
}
// with ctx set via context value
req2 := httptest.NewRequest("GET", "/", nil)
ctx := context.WithValue(req2.Context(), UserContextKey, claims)
req2 = req2.WithContext(ctx)
rr2 := httptest.NewRecorder()
c2, ok2 := RequireAuth(rr2, req2)
if !ok2 || c2.UserID != 99 {
t.Error("require with claims failed")
}
}
+365
View File
@@ -0,0 +1,365 @@
package certificate
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/http/webroot"
"github.com/go-acme/lego/v4/registration"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
// Manager handles Let's Encrypt (and future other) certificate issuance and renewal using lego.
// It is the pure-Go certificate issuance manager (using lego for LE, no external certbot).
type Manager struct {
st store.Store
}
// NewManager returns a cert manager bound to the store (for read/update of Certificate records).
func NewManager(st store.Store) *Manager {
return &Manager{st: st}
}
// account represents a lego user/account. We generate ephemeral keys for v1 (new LE account per issue).
// For production persistence of account key, we could save to data/certs/lego-account.<email>.key .
type account struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (a *account) GetEmail() string {
return a.Email
}
func (a *account) GetRegistration() *registration.Resource {
return a.Registration
}
func (a *account) GetPrivateKey() crypto.PrivateKey {
return a.key
}
// Issue obtains (or renews) a certificate for the given store.Certificate record (must have provider=="letsencrypt").
// It writes PEMs into the cert's Meta["cert"] and Meta["key"] (for engine loadCerts compatibility with custom certs),
// updates expires_on, and persists via store.SetCertificate.
// It also optionally writes PEM files under data/certs/<id>/ for inspection/backup.
// email is the contact for LE registration (required for LE); falls back to "admin@example.com" if empty.
// Respects meta["staging"]=true or env LEGO_STAGING=1 to use staging (recommended for testing to avoid rate limits).
// Respects meta["key_type"] = "rsa" | "ecdsa" (default rsa2048).
// If PROXY_MODE=development, uses self-signed test cert for *all* LE certs (no domain tricks).
func (m *Manager) Issue(sc store.Certificate, email string) error {
if sc.Provider != "letsencrypt" {
return fmt.Errorf("not a letsencrypt certificate (provider=%s)", sc.Provider)
}
if len(sc.DomainNames) == 0 {
return fmt.Errorf("no domain_names for certificate %d", sc.ID)
}
if email == "" {
email = "admin@example.com"
}
// In development mode (PROXY_MODE=development), *all* letsencrypt certs use the
// self-signed test path (no real LE). This is for dev/testing only.
// In production (default), real LE http-01 is always attempted.
if config.IsDevelopment() {
slog.Info("using self-signed test cert (LE simulation; all certs in dev mode)", "domains", sc.DomainNames)
return m.issueTestCert(sc)
}
staging := false
if sc.Meta != nil {
if v, ok := sc.Meta["staging"]; ok {
switch t := v.(type) {
case bool:
staging = t
case string:
staging = strings.ToLower(t) == "true" || t == "1"
}
}
}
if os.Getenv("LEGO_STAGING") != "" || os.Getenv("LE_STAGING") != "" {
staging = true
}
keyType := certcrypto.RSA2048
if sc.Meta != nil {
if kt, ok := sc.Meta["key_type"].(string); ok {
switch strings.ToLower(kt) {
case "ecdsa", "ec256", "p256":
keyType = certcrypto.EC256
case "rsa4096":
keyType = certcrypto.RSA4096
}
}
}
// Create lego account (ephemeral key; see note above)
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate account key: %w", err)
}
acc := &account{
Email: email,
key: privateKey,
}
cfg := lego.NewConfig(acc)
if staging {
cfg.CADirURL = lego.LEDirectoryStaging
slog.Info("using Let's Encrypt staging for certificate issue", "certID", sc.ID, "domains", sc.DomainNames)
} else {
cfg.CADirURL = lego.LEDirectoryProduction
}
cfg.Certificate.KeyType = keyType
client, err := lego.NewClient(cfg)
if err != nil {
return fmt.Errorf("lego client: %w", err)
}
// http-01 via our served challenge dir (lego writes token files, our proxy serves them)
webrootPath := config.Resolve("letsencrypt-acme-challenge")
// ensure layout exists
_ = os.MkdirAll(filepath.Join(webrootPath, ".well-known", "acme-challenge"), 0o755)
provider, err := webroot.NewHTTPProvider(webrootPath)
if err != nil {
return fmt.Errorf("webroot provider: %w", err)
}
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return fmt.Errorf("set http01 provider: %w", err)
}
// Register account (or resolve). For ephemeral this always creates a fresh LE account.
reg, err := client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true,
})
if err != nil {
// If already registered for key (rare here), try resolve; otherwise fail
if reg2, err2 := client.Registration.ResolveAccountByKey(); err2 == nil {
reg = reg2
} else {
return fmt.Errorf("le register: %w", err)
}
}
acc.Registration = reg
slog.Info("requesting LE certificate", "certID", sc.ID, "domains", sc.DomainNames, "staging", staging, "email", email)
request := certificate.ObtainRequest{
Domains: sc.DomainNames,
Bundle: true,
}
certs, err := client.Certificate.Obtain(request)
if err != nil {
return fmt.Errorf("obtain cert: %w", err)
}
// Populate PEMs in meta (used by engine for tls.Certificate and by custom cert path)
// Use compat keys "certificate"/"certificate_key" + "cert"/"key" (for data format compatibility)
if sc.Meta == nil {
sc.Meta = map[string]any{}
}
sc.Meta["certificate"] = string(certs.Certificate)
sc.Meta["certificate_key"] = string(certs.PrivateKey)
sc.Meta["cert"] = string(certs.Certificate)
sc.Meta["key"] = string(certs.PrivateKey)
// keep original meta bits like dns_challenge etc.
sc.Meta["letsencrypt"] = map[string]any{
"account": email,
"staging": staging,
"issued_at": time.Now().UTC().Format(time.RFC3339),
}
// Compute real expiry from the returned leaf cert (more reliable than LE response)
expires := ""
if block, _ := pem.Decode(certs.Certificate); block != nil {
if xc, err := x509.ParseCertificate(block.Bytes); err == nil {
expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05")
sc.Meta["cert_info"] = map[string]any{
"not_after": xc.NotAfter.UTC().Format(time.RFC3339),
"issuer": xc.Issuer.String(),
"subject": xc.Subject.String(),
}
}
}
if expires == "" {
expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05") // fallback ~90d
}
sc.ExpiresOn = expires
// Persist files too (under data/certs/<id>/) for serving live certs
certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID))
if err := os.MkdirAll(certDir, 0o755); err == nil {
_ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certs.Certificate, 0o600)
_ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), certs.PrivateKey, 0o600)
}
// Save back to store (updates meta + expires)
if err := m.st.SetCertificate(sc); err != nil {
return fmt.Errorf("store set cert after issue: %w", err)
}
slog.Info("LE certificate obtained and stored", "certID", sc.ID, "domains", sc.DomainNames, "expires", sc.ExpiresOn)
return nil
}
// GetEmailForCert tries to find a reasonable contact email for LE from the cert meta or first admin user.
func (m *Manager) GetEmailForCert(sc store.Certificate) string {
if sc.Meta != nil {
if e, ok := sc.Meta["email"].(string); ok && e != "" {
return e
}
if e, ok := sc.Meta["letsencrypt_email"].(string); ok && e != "" {
return e
}
}
// fallback to global setting or seeded admin
if settings := m.st.GetSettings(); settings != nil {
if e, ok := settings["letsencrypt_email"].(string); ok && e != "" {
return e
}
}
users := m.st.GetUsers()
for _, u := range users {
if u.Email != "" {
return u.Email
}
}
return "admin@example.com"
}
// ShouldRenew reports whether a LE cert is within the renewal window (30 days before expiry).
func (m *Manager) ShouldRenew(sc store.Certificate) bool {
if sc.Provider != "letsencrypt" {
return false
}
if sc.ExpiresOn == "" {
return true
}
t, err := time.Parse("2006-01-02 15:04:05", sc.ExpiresOn)
if err != nil {
// try RFC3339 etc
if t2, err2 := time.Parse(time.RFC3339, sc.ExpiresOn); err2 == nil {
t = t2
} else {
return true
}
}
renewBefore := 30 * 24 * time.Hour
return time.Until(t) < renewBefore
}
// ProcessRenewals finds LE certs due for renewal and re-issues them (sequentially).
// Called by timer and on demand.
func (m *Manager) ProcessRenewals() {
certs := m.st.GetCertificates()
for _, c := range certs {
if c.Provider == "letsencrypt" && m.ShouldRenew(c) {
slog.Info("renewing expiring LE cert", "id", c.ID, "domains", c.DomainNames, "expires", c.ExpiresOn)
email := m.GetEmailForCert(c)
if err := m.Issue(c, email); err != nil {
slog.Error("renewal failed (will retry later)", "id", c.ID, "err", err)
continue
}
}
}
}
// --- dev-mode test helpers (all letsencrypt certs become self-signed test certs when PROXY_MODE=development) ---
func (m *Manager) issueTestCert(sc store.Certificate) error {
certPEM, keyPEM, err := generateSelfSignedTestCert(sc.DomainNames)
if err != nil {
return err
}
if sc.Meta == nil {
sc.Meta = map[string]any{}
}
sc.Meta["certificate"] = string(certPEM)
sc.Meta["certificate_key"] = string(keyPEM)
sc.Meta["cert"] = string(certPEM)
sc.Meta["key"] = string(keyPEM)
sc.Meta["test_cert"] = true // marker that this was dev sim, not real LE
sc.Meta["letsencrypt"] = map[string]any{
"account": "test@localhost",
"staging": true,
"issued_at": time.Now().UTC().Format(time.RFC3339),
"note": "self-signed simulation (only in PROXY_MODE=development)",
}
// parse for expires
expires := ""
if block, _ := pem.Decode(certPEM); block != nil {
if xc, err := x509.ParseCertificate(block.Bytes); err == nil {
expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05")
sc.Meta["cert_info"] = map[string]any{
"not_after": xc.NotAfter.UTC().Format(time.RFC3339),
"issuer": xc.Issuer.String(),
"subject": xc.Subject.String(),
}
}
}
if expires == "" {
expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05")
}
sc.ExpiresOn = expires
// write files
certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID))
_ = os.MkdirAll(certDir, 0o755)
_ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certPEM, 0o600)
_ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), keyPEM, 0o600)
if err := m.st.SetCertificate(sc); err != nil {
return err
}
slog.Info("test self-signed cert stored (LE simulation, dev-only)", "certID", sc.ID, "domains", sc.DomainNames)
return nil
}
func generateSelfSignedTestCert(domains []string) (certPEM, keyPEM []byte, err error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: domains[0], Organization: []string{"Helix Proxy test"}},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: domains,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER := x509.MarshalPKCS1PrivateKey(priv)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
+230
View File
@@ -0,0 +1,230 @@
package certificate
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"strings"
"testing"
"time"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
func TestGenerateSelfSignedTestCert(t *testing.T) {
domains := []string{"foo.example.com", "bar.example.com"}
certPEM, keyPEM, err := generateSelfSignedTestCert(domains)
if err != nil {
t.Fatalf("generate: %v", err)
}
if !strings.Contains(string(certPEM), "BEGIN CERTIFICATE") {
t.Error("no cert pem marker")
}
if !strings.Contains(string(keyPEM), "BEGIN RSA PRIVATE KEY") {
t.Error("no key pem marker")
}
// parse and check
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("decode failed")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if cert.NotAfter.Sub(cert.NotBefore) < 89*24*time.Hour {
t.Error("validity too short")
}
found := false
for _, d := range cert.DNSNames {
if d == "foo.example.com" {
found = true
}
}
if !found {
t.Error("dns names not in cert")
}
}
func TestIssueTestCertPath(t *testing.T) {
// use temp data dir, reset config
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
// ensure dirs so writes don't fail
_ = config.EnsureDataDirs()
// minimal store: use New which is bbolt in the tmp (via reset+env)
st, err := store.New()
if err != nil {
t.Fatalf("new store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
// create a cert record (simulating what API would do)
cert := store.Certificate{
Provider: "letsencrypt",
NiceName: "test local",
DomainNames: []string{"mytest.example.com"}, // any domain; in dev mode all LE become test certs
Meta: map[string]any{},
}
created, err := st.CreateCertificate(cert)
if err != nil {
t.Fatalf("create cert: %v", err)
}
// dev mode => all letsencrypt certs are test/self-signed
t.Setenv("PROXY_MODE", "development")
email := cm.GetEmailForCert(created)
if err := cm.Issue(created, email); err != nil {
t.Fatalf("Issue test cert: %v", err)
}
// reload from store
got, ok := st.GetCertificate(created.ID)
if !ok {
t.Fatal("cert not found after issue")
}
if got.ExpiresOn == "" {
t.Error("no expires set")
}
if v, _ := got.Meta["test_cert"].(bool); !v {
t.Error("test_cert marker not set")
}
if _, ok := got.Meta["certificate"]; !ok {
t.Error("certificate pem not in meta")
}
if _, ok := got.Meta["key"]; !ok {
t.Error("key not in meta")
}
// file written?
if _, err := os.Stat(config.Resolve("certs")); err != nil {
t.Error("certs dir missing")
}
// the per-id dir
perID := fmt.Sprintf("%s/%d", config.Resolve("certs"), got.ID)
if _, err := os.Stat(perID); err != nil {
t.Logf("per-id cert dir %s not present (may be ok): %v", perID, err)
}
// exercise non-local LE path meta parsing (staging/key_type from meta) in *production* mode
// (real LE path; will fail on obtain due to no DNS/reach, but pre-LE branches + meta covered)
t.Setenv("PROXY_MODE", "")
c2 := store.Certificate{
Provider: "letsencrypt",
DomainNames: []string{"nonlocal.example.com"},
Meta: map[string]any{"staging": "true", "key_type": "rsa4096"},
}
created2, _ := st.CreateCertificate(c2)
email2 := cm.GetEmailForCert(created2)
if err2 := cm.Issue(created2, email2); err2 == nil || strings.Contains(err2.Error(), "not a letsencrypt") {
t.Logf("non-local LE Issue err (expected without public reach/DNS; meta parse covered): %v", err2)
}
}
func TestIssueRejectsNonLE(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
c := store.Certificate{Provider: "other", DomainNames: []string{"ex.com"}}
created, _ := st.CreateCertificate(c)
err = cm.Issue(created, "e@e.com")
if err == nil || !strings.Contains(err.Error(), "not a letsencrypt") {
t.Errorf("expected reject non-letsencrypt: got %v", err)
}
}
func TestShouldRenew(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
now := time.Now().UTC()
tests := []struct {
name string
exp string
want bool
}{
{"empty", "", true},
{"past", now.Add(-40 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true},
{"near 29d", now.Add(29 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true},
{"far 31d", now.Add(31 * 24 * time.Hour).Format("2006-01-02 15:04:05"), false},
{"rfc3339 near", now.Add(10 * 24 * time.Hour).Format(time.RFC3339), true},
{"bad format", "not-a-date", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := store.Certificate{Provider: "letsencrypt", ExpiresOn: tt.exp}
if got := cm.ShouldRenew(c); got != tt.want {
t.Errorf("ShouldRenew(%s) = %v want %v", tt.exp, got, tt.want)
}
})
}
}
func TestProcessRenewals(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
// dev mode: *all* letsencrypt certs (any domain) use test/self-signed path in Issue/renew
t.Setenv("PROXY_MODE", "development")
// LE due soon
dueExp := time.Now().Add(5 * 24 * time.Hour).Format("2006-01-02 15:04:05")
leDue := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"due.example.com"}, ExpiresOn: dueExp}
createdDue, _ := st.CreateCertificate(leDue)
// LE far future (no renew)
farExp := time.Now().Add(40 * 24 * time.Hour).Format("2006-01-02 15:04:05")
leFar := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"far.example.com"}, ExpiresOn: farExp}
createdFar, _ := st.CreateCertificate(leFar)
// non-LE (ignored even if "due")
nonLE := store.Certificate{Provider: "other", DomainNames: []string{"x.com"}, ExpiresOn: time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05")}
st.CreateCertificate(nonLE)
cm.ProcessRenewals()
gDue, _ := st.GetCertificate(createdDue.ID)
if tDue, _ := time.Parse("2006-01-02 15:04:05", gDue.ExpiresOn); time.Until(tDue) < 80*24*time.Hour {
t.Error("due LE cert was not renewed (expires not extended)")
}
gFar, _ := st.GetCertificate(createdFar.ID)
if gFar.ExpiresOn != farExp {
t.Error("far LE should not have been touched")
}
}
+110
View File
@@ -0,0 +1,110 @@
package config
import (
"os"
"path/filepath"
"strings"
"sync"
)
var (
once sync.Once
base string // resolved data base dir
)
// DataDir returns the base directory for data (db.bolt, certs, logs, www, etc).
// Respects DATA_DIR env (absolute or relative). Falls back to "data" relative to CWD.
func DataDir() string {
once.Do(func() {
if d := os.Getenv("DATA_DIR"); d != "" {
base = d
return
}
// default relative to cwd
wd, err := os.Getwd()
if err != nil {
wd = "."
}
base = filepath.Join(wd, "data")
})
return base
}
// Resolve returns an absolute or relative path under the data dir for the given subpath (e.g. "db.bolt", "certs", "www").
// If the corresponding *_DIR env is set it is used as base instead.
func Resolve(sub string) string {
// Allow per-subdir override e.g. CERTS_DIR, WWW_DIR, LOGS_DIR, CHALLENGE_DIR
key := ""
switch sub {
case "db.bolt":
key = "DATA_DIR"
case "certs", "custom_ssl":
key = "CERTS_DIR"
case "logs":
key = "LOGS_DIR"
case "letsencrypt-acme-challenge", "challenge":
key = "CHALLENGE_DIR"
case "www":
if d := os.Getenv("WWW_DIR"); d != "" {
return filepath.Join(d, "") // allow override to point directly
}
key = "DATA_DIR"
case "html":
if d := os.Getenv("HTML_DIR"); d != "" {
return filepath.Join(d, "")
}
key = "DATA_DIR"
default:
key = "DATA_DIR"
}
dir := DataDir()
if key != "" {
if override := os.Getenv(key); override != "" {
dir = override
}
}
return filepath.Join(dir, sub)
}
// EnsureDataDirs creates the expected subdirs under the resolved data location (and www inside data by default).
// Also pre-creates the acme-challenge subdir layout that lego webroot + our handler expect.
func EnsureDataDirs() error {
dirs := []string{
Resolve(""),
Resolve("certs"),
Resolve("logs"),
Resolve("letsencrypt-acme-challenge"),
Resolve("www"), // data/www by default
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
// full layout for http-01 challenges written by lego
acme := filepath.Join(Resolve("letsencrypt-acme-challenge"), ".well-known", "acme-challenge")
if err := os.MkdirAll(acme, 0o755); err != nil {
return err
}
return nil
}
// ResetForTest clears the cached data dir (sync.Once) so tests can change
// DATA_DIR / *_DIR envs via t.Setenv and observe fresh resolution.
// Call at the start of such tests. This is the supported way to make
// config testable without globals leaking across tests.
func ResetForTest() {
once = sync.Once{}
base = ""
}
// IsDevelopment reports whether PROXY_MODE=development (or "dev").
// Defaults to false (production mode).
// When true, all letsencrypt certs use self-signed test certs (via the cert manager's
// issueTestCert path). No domain tricks (.localtest etc.) are used. In production
// (default) real LE issuance is always attempted.
func IsDevelopment() bool {
mode := strings.ToLower(os.Getenv("PROXY_MODE"))
return mode == "development" || mode == "dev"
}
+158
View File
@@ -0,0 +1,158 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDataDir(t *testing.T) {
ResetForTest()
t.Setenv("DATA_DIR", "")
// default should be data under cwd
d := DataDir()
if d == "" {
t.Fatal("DataDir empty")
}
// once behavior: change env shouldn't affect without reset
t.Setenv("DATA_DIR", "/tmp/should-not-affect")
d2 := DataDir()
if d != d2 {
t.Error("DataDir ignored Once")
}
ResetForTest()
t.Setenv("DATA_DIR", "/tmp/testdata123")
d3 := DataDir()
if d3 != "/tmp/testdata123" {
t.Errorf("DATA_DIR override failed: got %s", d3)
}
}
func TestResolve(t *testing.T) {
ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
tests := []struct {
sub string
wantBase string // suffix check
}{
{"db.bolt", filepath.Join(tmp, "db.bolt")},
{"certs", filepath.Join(tmp, "certs")},
{"logs", filepath.Join(tmp, "logs")},
{"letsencrypt-acme-challenge", filepath.Join(tmp, "letsencrypt-acme-challenge")},
{"www", filepath.Join(tmp, "www")},
{"unknown", filepath.Join(tmp, "unknown")},
}
for _, tt := range tests {
got := Resolve(tt.sub)
if got != tt.wantBase {
t.Errorf("Resolve(%q) = %q want %q", tt.sub, got, tt.wantBase)
}
}
// override CERTS_DIR (uses as base + join sub per current impl)
ResetForTest()
certsBase := filepath.Join(tmp, "mycerts-base")
t.Setenv("CERTS_DIR", certsBase)
t.Setenv("DATA_DIR", tmp)
wantCerts := filepath.Join(certsBase, "certs")
if got := Resolve("certs"); got != wantCerts {
t.Errorf("CERTS_DIR override: got %s want %s", got, wantCerts)
}
// WWW_DIR / HTML_DIR (special case returns dir directly; overrides conditioned on exact sub to avoid cross-contamination)
ResetForTest()
wwwDir := filepath.Join(tmp, "mywww")
t.Setenv("WWW_DIR", wwwDir)
if got := Resolve("www"); got != wwwDir {
t.Errorf("WWW_DIR: got %s", got)
}
ResetForTest()
htmlDir := filepath.Join(tmp, "myhtml")
t.Setenv("HTML_DIR", htmlDir)
if got := Resolve("html"); got != htmlDir {
t.Errorf("HTML_DIR: got %s", got)
}
// mixed overrides: only HTML set should not affect Resolve("www") (falls to DATA_DIR/www); vice-versa
// (explicitly clear the other *_DIR to counter t.Setenv lingering within one TestResolve func)
ResetForTest()
t.Setenv("WWW_DIR", "")
t.Setenv("HTML_DIR", htmlDir)
t.Setenv("DATA_DIR", tmp)
if got := Resolve("www"); got != filepath.Join(tmp, "www") {
t.Errorf("mixed: only HTML should not affect www: got %s", got)
}
ResetForTest()
t.Setenv("HTML_DIR", "")
t.Setenv("WWW_DIR", wwwDir)
t.Setenv("DATA_DIR", tmp)
if got := Resolve("html"); got != filepath.Join(tmp, "html") {
t.Errorf("mixed: only WWW should not affect html: got %s", got)
}
// CHALLENGE_DIR (uses as base + join per impl)
ResetForTest()
chBase := filepath.Join(tmp, "ch-base")
t.Setenv("CHALLENGE_DIR", chBase)
wantCh := filepath.Join(chBase, "letsencrypt-acme-challenge")
if got := Resolve("letsencrypt-acme-challenge"); got != wantCh {
t.Errorf("CHALLENGE: got %s want %s", got, wantCh)
}
}
func TestEnsureDataDirs(t *testing.T) {
ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
if err := EnsureDataDirs(); err != nil {
t.Fatalf("EnsureDataDirs: %v", err)
}
// verify layout
dirs := []string{
tmp,
filepath.Join(tmp, "certs"),
filepath.Join(tmp, "logs"),
filepath.Join(tmp, "letsencrypt-acme-challenge"),
filepath.Join(tmp, "www"),
filepath.Join(tmp, "letsencrypt-acme-challenge", ".well-known", "acme-challenge"),
}
for _, d := range dirs {
if fi, err := os.Stat(d); err != nil || !fi.IsDir() {
t.Errorf("expected dir %s: %v", d, err)
}
}
}
func TestIsDevelopment(t *testing.T) {
ResetForTest()
t.Setenv("PROXY_MODE", "")
if IsDevelopment() {
t.Error("default should be production (not dev)")
}
t.Setenv("PROXY_MODE", "development")
if !IsDevelopment() {
t.Error("PROXY_MODE=development should be dev")
}
t.Setenv("PROXY_MODE", "dev")
if !IsDevelopment() {
t.Error("PROXY_MODE=dev should be dev")
}
t.Setenv("PROXY_MODE", "production")
if IsDevelopment() {
t.Error("PROXY_MODE=production should not be dev")
}
// case insensitive
t.Setenv("PROXY_MODE", "DEVELOPMENT")
if !IsDevelopment() {
t.Error("case insensitive")
}
}
+155
View File
@@ -0,0 +1,155 @@
package models
// These types are the core domain models for Helix Proxy.
// They are used by the Store interface (any backend), the API, the proxy engine,
// and the certificate manager.
type Root struct {
Version int
Users []User
ProxyHosts []ProxyHost
AccessLists []AccessList
Streams []Stream
Certificates []Certificate
RedirectionHosts []RedirectionHost
DeadHosts []DeadHost
Settings map[string]any
AuditLogs []AuditLog
// meta for internal use by store impls
Meta map[string]any
}
type User struct {
ID int `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password,omitempty"` // bcrypt hash; empty for demo seeded admin. (API responses manually omit the value)
Roles []string `json:"roles"`
TotpSecret string `json:"totpSecret,omitempty"` // include for bbolt persistence (API never returns the secret value)
TotpEnabled bool `json:"totpEnabled"`
}
// HeaderRule is a name/value pair for request or response header overrides.
type HeaderRule struct {
Name string `json:"name"`
Value string `json:"value,omitempty"`
}
type ProxyHost struct {
ID int `json:"id"`
DomainNames []string `json:"domainNames"`
ForwardScheme string `json:"forwardScheme"`
ForwardHost string `json:"forwardHost"`
ForwardPort int `json:"forwardPort"`
AccessListID int `json:"accessListId,omitempty"`
CertificateID int `json:"certificateId,omitempty"`
SslForced bool `json:"sslForced"`
CachingEnabled bool `json:"cachingEnabled"`
BlockExploits bool `json:"blockExploits"`
AllowWebsocketUpgrade bool `json:"allowWebsocketUpgrade"`
Http2Support bool `json:"http2Support"`
HstsEnabled bool `json:"hstsEnabled"`
HstsSubdomains bool `json:"hstsSubdomains"`
TrustForwardedProto bool `json:"trustForwardedProto"`
RequestHeaders []HeaderRule `json:"requestHeaders,omitempty"`
ResponseHeaders []HeaderRule `json:"responseHeaders,omitempty"`
HideHeaders []string `json:"hideHeaders,omitempty"`
AdvancedConfig string `json:"advancedConfig,omitempty"` // legacy; engine falls back when structured headers are empty
Enabled bool `json:"enabled"`
Locations []Location `json:"locations,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type Location struct {
ID int `json:"id,omitempty"`
Path string `json:"path"`
ForwardScheme string `json:"forwardScheme"`
ForwardHost string `json:"forwardHost"`
ForwardPort int `json:"forwardPort"`
ForwardPath string `json:"forwardPath,omitempty"`
AdvancedConfig string `json:"advancedConfig,omitempty"`
}
type AccessList struct {
ID int `json:"id"`
Name string `json:"name"`
SatisfyAny bool `json:"satisfyAny"`
Clients []AccessClient `json:"clients"`
Items []AccessItem `json:"items"`
PassAuth bool `json:"passAuth"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type AccessClient struct {
Address string `json:"address"`
Directive string `json:"directive"` // "allow" or "deny"
}
type AccessItem struct {
Username string `json:"username"`
Password string `json:"password"` // plain for demo access lists (separate from User bcrypt)
}
type Stream struct {
ID int `json:"id"`
IncomingPort int `json:"incomingPort"`
ForwardingHost string `json:"forwardingHost"`
ForwardingPort int `json:"forwardingPort"`
TCPForwarding bool `json:"tcpForwarding"`
UDPForwarding bool `json:"udpForwarding"`
Enabled bool `json:"enabled"`
CertificateID int `json:"certificateId"`
Meta map[string]any `json:"meta,omitempty"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type Certificate struct {
ID int `json:"id"`
Provider string `json:"provider"` // "letsencrypt" or "other"
NiceName string `json:"niceName"`
DomainNames []string `json:"domainNames"`
ExpiresOn string `json:"expiresOn"`
Meta map[string]any `json:"meta"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type RedirectionHost struct {
ID int `json:"id"`
DomainNames []string `json:"domainNames"`
ForwardHTTPCode int `json:"forwardHttpCode"`
ForwardScheme string `json:"forwardScheme"`
ForwardDomainName string `json:"forwardDomainName"`
PreservePath bool `json:"preservePath"`
Enabled bool `json:"enabled"`
AdvancedConfig string `json:"advancedConfig,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type DeadHost struct {
ID int `json:"id"`
DomainNames []string `json:"domainNames"`
Enabled bool `json:"enabled"`
AdvancedConfig string `json:"advancedConfig,omitempty"`
Meta map[string]any `json:"meta,omitempty"`
OwnerUserID int `json:"ownerUserId,omitempty"`
CreatedOn string `json:"createdOn,omitempty"` // server-set UTC on create (via store); preserved on update; empty for legacy DB records
}
type AuditLog struct {
ID int `json:"id"`
UserID int `json:"userId"`
ObjectType string `json:"objectType"`
ObjectID int `json:"objectId"`
Action string `json:"action"`
Meta map[string]any `json:"meta"`
CreatedOn string `json:"createdOn"`
}
File diff suppressed because it is too large Load Diff
+825
View File
@@ -0,0 +1,825 @@
package proxy
import (
"bytes"
"crypto/tls"
"encoding/base64"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
// --- pure helper tests (table driven as recommended) ---
func TestItoa(t *testing.T) {
if itoa(80) != "80" || itoa(0) != "0" || itoa(443) != "443" {
t.Error("itoa wrong")
}
}
func TestIsWebsocketRequest(t *testing.T) {
tests := []struct {
name string
h http.Header
want bool
}{
{"none", http.Header{}, false},
{"upgrade ws conn upgrade", http.Header{"Upgrade": []string{"websocket"}, "Connection": []string{"Upgrade"}}, true},
{"case insen", http.Header{"Upgrade": []string{"WebSocket"}, "Connection": []string{"keep-alive, Upgrade"}}, true},
{"no upgrade header", http.Header{"Connection": []string{"Upgrade"}}, false},
{"no conn upgrade", http.Header{"Upgrade": []string{"websocket"}}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &http.Request{Header: tt.h}
if got := isWebsocketRequest(r); got != tt.want {
t.Errorf("isWebsocketRequest() = %v want %v", got, tt.want)
}
})
}
}
func TestGetRealClientIP(t *testing.T) {
tests := []struct {
name string
r *http.Request
trust bool
want string
}{
{"xff first (trust)", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, true, "1.2.3.4"},
{"xff ignored when !trust", &http.Request{Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, RemoteAddr: "9.9.9.9:1"}, false, "9.9.9.9"},
{"no xff use remote", &http.Request{RemoteAddr: "10.0.0.1:1234"}, true, "10.0.0.1"},
{"remote no port", &http.Request{RemoteAddr: "10.0.0.2"}, false, "10.0.0.2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := getRealClientIP(tt.r, tt.trust); got != tt.want {
t.Errorf("getRealClientIP() = %v want %v", got, tt.want)
}
})
}
}
func TestParseAdvancedConfig(t *testing.T) {
tests := []struct {
name string
adv string
wantSets map[string]string
wantAdds map[string]string
wantHides []string
}{
{"empty", "", map[string]string{}, map[string]string{}, []string{}},
{"comment and blank", "# foo\n\n ", map[string]string{}, map[string]string{}, []string{}},
{"set_header", "proxy_set_header X-Foo bar baz", map[string]string{"X-Foo": "bar baz"}, map[string]string{}, []string{}},
{"add_header quoted", `add_header X-Bar "val with space";`, map[string]string{}, map[string]string{"X-Bar": "val with space"}, []string{}},
{"hide", "proxy_hide_header X-Baz\nproxy_hide_header X-Qux", map[string]string{}, map[string]string{}, []string{"X-Baz", "X-Qux"}},
{"mixed sep ; and nl", "proxy_set_header A 1; add_header B 2; proxy_hide_header C", map[string]string{"A": "1"}, map[string]string{"B": "2"}, []string{"C"}},
{"unknown ignored", "set $foo bar\nproxy_redirect / /", map[string]string{}, map[string]string{}, []string{}},
{"trim quotes in add", `add_header X "v";`, map[string]string{}, map[string]string{"X": "v"}, []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sets, adds, hides := parseAdvancedConfig(tt.adv)
if !mapsEqual(sets, tt.wantSets) {
t.Errorf("reqSets = %v want %v", sets, tt.wantSets)
}
if !mapsEqual(adds, tt.wantAdds) {
t.Errorf("respAdds = %v want %v", adds, tt.wantAdds)
}
if !slicesEqual(hides, tt.wantHides) {
t.Errorf("respHides = %v want %v", hides, tt.wantHides)
}
})
}
}
func TestResolveProxyHeaders(t *testing.T) {
structured := store.ProxyHost{
RequestHeaders: []store.HeaderRule{{Name: "X-Req", Value: "1"}},
ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "2"}},
HideHeaders: []string{"X-Hide"},
}
sets, adds, hides := resolveProxyHeaders(structured)
if !mapsEqual(sets, map[string]string{"X-Req": "1"}) {
t.Errorf("structured reqSets = %v", sets)
}
if !mapsEqual(adds, map[string]string{"X-Resp": "2"}) {
t.Errorf("structured respAdds = %v", adds)
}
if !slicesEqual(hides, []string{"X-Hide"}) {
t.Errorf("structured respHides = %v", hides)
}
legacy := store.ProxyHost{AdvancedConfig: "proxy_set_header A 1\nadd_header B 2;\nproxy_hide_header C"}
sets, adds, hides = resolveProxyHeaders(legacy)
if !mapsEqual(sets, map[string]string{"A": "1"}) || !mapsEqual(adds, map[string]string{"B": "2"}) || !slicesEqual(hides, []string{"C"}) {
t.Errorf("legacy fallback failed: sets=%v adds=%v hides=%v", sets, adds, hides)
}
// structured fields take precedence over legacy advancedConfig
both := store.ProxyHost{
RequestHeaders: []store.HeaderRule{{Name: "New", Value: "v"}},
AdvancedConfig: "proxy_set_header Old 1",
}
sets, _, _ = resolveProxyHeaders(both)
if !mapsEqual(sets, map[string]string{"New": "v"}) {
t.Errorf("structured should win over legacy: %v", sets)
}
}
func mapsEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestCheckAccess(t *testing.T) {
tests := []struct {
name string
clients []store.AccessClient
satisfyAny bool
ip string
want bool
}{
{"no clients allow", nil, false, "1.2.3.4", true},
{"empty allow", []store.AccessClient{}, false, "1.2.3.4", true},
{"deny all", []store.AccessClient{{Address: "all", Directive: "deny"}}, false, "9.9.9.9", false},
{"allow all", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "1.1.1.1", true},
{"exact allow", []store.AccessClient{{Address: "10.0.0.1", Directive: "allow"}}, false, "10.0.0.1:123", true},
{"exact deny", []store.AccessClient{{Address: "10.0.0.1", Directive: "deny"}}, false, "10.0.0.1", false},
{"cidr allow", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "allow"}}, false, "192.168.1.2", true},
{"cidr deny", []store.AccessClient{{Address: "192.168.0.0/16", Directive: "deny"}}, false, "192.168.1.2", false},
{"satisfyAny: one allow sufficient", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "2.2.2.2", Directive: "allow"}}, true, "1.1.1.1", true},
{"satisfyAny: no allow", []store.AccessClient{{Address: "1.1.1.1", Directive: "deny"}}, true, "9.9.9.9", false},
{"satisfy all (default): denies block even if allow present", []store.AccessClient{{Address: "1.1.1.1", Directive: "allow"}, {Address: "1.1.1.1", Directive: "deny"}}, false, "1.1.1.1", false},
{"bad ip", []store.AccessClient{{Address: "all", Directive: "allow"}}, false, "not-an-ip", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := checkAccess(tt.clients, tt.satisfyAny, tt.ip)
if got != tt.want {
t.Errorf("checkAccess(%v, %v, %q) = %v want %v", tt.clients, tt.satisfyAny, tt.ip, got, tt.want)
}
})
}
}
func TestEngineReloadClearsCache(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
eng.cacheMu.Lock()
eng.cache["k"] = struct {
body []byte
headers http.Header
status int
expires time.Time
}{body: []byte("x")}
eng.cacheMu.Unlock()
// use public ReloadFromStore (exercises the cache clear inside it)
eng.ReloadFromStore()
eng.cacheMu.Lock()
if len(eng.cache) != 0 {
t.Error("reload did not clear cache")
}
eng.cacheMu.Unlock()
}
// --- helpers for handler tests ---
func newTestEngine(t *testing.T) (*Engine, store.Store) {
t.Helper()
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("test store: %v", err)
}
eng := NewEngine(st)
eng.ReloadFromStore()
return eng, st
}
func doReq(h http.Handler, method, host, path string, headers http.Header, body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Host = host
if headers != nil {
req.Header = headers.Clone()
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// TestEngineHandler_HostMatchingRedirsDeadsDefaults exercises core routing branches.
func TestEngineHandler_HostMatchingRedirsDeadsDefaults(t *testing.T) {
eng, st := newTestEngine(t)
defer func() {
if c, ok := st.(interface{ Close() error }); ok {
c.Close()
}
}()
h := eng.Handler()
// no hosts: default congratulations (or index if present)
rr := doReq(h, "GET", "nohost.local", "/", nil, nil)
if rr.Code != 200 || !strings.Contains(rr.Body.String(), "Welcome to Helix Proxy") {
t.Errorf("default congrats: code=%d body=%s", rr.Code, rr.Body.String()[:min(100, rr.Body.Len())])
}
// set default 404
st.SetSetting("default_site", "404")
eng.ReloadFromStore()
rr = doReq(h, "GET", "nohost.local", "/", nil, nil)
if rr.Code != 404 {
t.Errorf("default 404: %d", rr.Code)
}
// 444
st.SetSetting("default_site", "444")
eng.ReloadFromStore()
rr = doReq(h, "GET", "nohost.local", "/", nil, nil)
if rr.Code != 444 {
t.Errorf("default 444: %d", rr.Code)
}
// redirect default
st.SetSetting("default_site", "redirect")
st.SetSetting("default_site_redirect", "https://def.example")
eng.ReloadFromStore()
rr = doReq(h, "GET", "nohost.local", "/", nil, nil)
if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "def.example") {
t.Errorf("default redirect: %d %s", rr.Code, rr.Header().Get("Location"))
}
// create a redir host
rh := store.RedirectionHost{
DomainNames: []string{"redir.example"},
ForwardHTTPCode: 301,
ForwardScheme: "https",
ForwardDomainName: "target.example",
PreservePath: true,
Enabled: true,
}
_, _ = st.CreateRedirectionHost(rh)
eng.ReloadFromStore()
rr = doReq(h, "GET", "redir.example", "/foo?x=1", nil, nil)
if rr.Code != 301 || !strings.Contains(rr.Header().Get("Location"), "https://target.example/foo?x=1") {
t.Errorf("redir preserve: %d loc=%s", rr.Code, rr.Header().Get("Location"))
}
// dead host plain
dh := store.DeadHost{DomainNames: []string{"dead.example"}, Enabled: true}
_, _ = st.CreateDeadHost(dh)
eng.ReloadFromStore()
rr = doReq(h, "GET", "dead.example", "/", nil, nil)
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "dead/blocked") {
t.Errorf("dead plain: %d %s", rr.Code, rr.Body.String())
}
// dead with meta.html
dh2 := store.DeadHost{DomainNames: []string{"deadhtml.example"}, Enabled: true, Meta: map[string]any{"html": "<h1>custom dead</h1>"}}
_, _ = st.CreateDeadHost(dh2)
eng.ReloadFromStore()
rr = doReq(h, "GET", "deadhtml.example", "/", nil, nil)
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "custom dead") {
t.Errorf("dead meta html: %d %s", rr.Code, rr.Body.String())
}
// custom 404.html file fallback (not just meta)
wwwDir := config.Resolve("www")
_ = os.MkdirAll(wwwDir, 0o755)
_ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("<h1>CUSTOM 404 FILE</h1>"), 0o644)
dh3 := store.DeadHost{DomainNames: []string{"deadfile.example"}, Enabled: true}
_, _ = st.CreateDeadHost(dh3)
eng.ReloadFromStore()
rr = doReq(h, "GET", "deadfile.example", "/", nil, nil)
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "CUSTOM 404 FILE") {
t.Errorf("dead custom 404.html: %d %s", rr.Code, rr.Body.String())
}
}
// TestEngineHandler_ProxyHostBasic + locations + ssl + block + hsts + ws + xforward
func TestEngineHandler_ProxyBasics(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
// create a simple proxy host (no backend will 502 but we can check early behaviors before proxy)
ph := store.ProxyHost{
DomainNames: []string{"proxy.example"},
ForwardScheme: "http",
ForwardHost: "127.0.0.1",
ForwardPort: 9, // invalid to not actually connect in test
Enabled: true,
SslForced: true,
BlockExploits: true,
HstsEnabled: true,
HstsSubdomains: true,
TrustForwardedProto: true,
RequestHeaders: []store.HeaderRule{{Name: "X-Custom", Value: "reqval"}},
ResponseHeaders: []store.HeaderRule{{Name: "X-Resp", Value: "respval"}},
HideHeaders: []string{"X-HideMe"},
}
created, _ := st.CreateProxyHost(ph)
_ = created
eng.ReloadFromStore()
h := eng.Handler()
// ssl forced on plain
rr := doReq(h, "GET", "proxy.example", "/path", http.Header{"X-Forwarded-Proto": []string{"http"}}, nil)
if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") {
t.Errorf("ssl_forced: %d %s", rr.Code, rr.Header().Get("Location"))
}
// block exploits - use TLS set on req to bypass ssl_forced redirect (which is before block check)
req := httptest.NewRequest("GET", "/.env", nil)
req.Host = "proxy.example"
req.TLS = &tls.ConnectionState{} // simulate https so no ssl redirect
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") {
t.Errorf("block_exploits: %d %s", rr.Code, rr.Body.String())
}
// HSTS set on response (simulate https to reach hsts code)
req = httptest.NewRequest("GET", "/ok", nil)
req.Host = "proxy.example"
req.TLS = &tls.ConnectionState{}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") {
t.Errorf("hsts not set or missing sub: %q", hs)
}
// X-Forwarded injection happens in director, but since we hit error backend we may not see, test via other means later
// for now check advanced req set happens early? but applied in director too.
}
// Test locations forwardPath rewrite
func TestEngineHandler_Locations(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
ph := store.ProxyHost{
DomainNames: []string{"loc.example"},
ForwardScheme: "http",
ForwardHost: "127.0.0.1",
ForwardPort: 80,
Enabled: true,
Locations: []store.Location{
{Path: "/api", ForwardScheme: "http", ForwardHost: "backend", ForwardPort: 9000, ForwardPath: "/v2"},
},
}
_, _ = st.CreateProxyHost(ph)
eng.ReloadFromStore()
h := eng.Handler()
// we can't easily assert the rewritten without real backend or spying, but at least no panic and host match
rr := doReq(h, "GET", "loc.example", "/api/foo", nil, nil)
// expect 502 from revproxy to bad host, but routing happened
if rr.Code == 404 {
t.Error("location host not matched, got 404 default")
}
}
// Test access lists IP + basic auth + passauth
func TestEngineHandler_AccessLists(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
al := store.AccessList{
Name: "acl1",
SatisfyAny: false,
Clients: []store.AccessClient{{Address: "10.0.0.0/8", Directive: "allow"}, {Address: "1.2.3.4", Directive: "deny"}},
Items: []store.AccessItem{{Username: "user", Password: "pass"}},
PassAuth: false,
}
alCreated, _ := st.CreateAccessList(al)
ph := store.ProxyHost{
DomainNames: []string{"acl.example"},
ForwardHost: "127.0.0.1",
ForwardPort: 80,
Enabled: true,
AccessListID: alCreated.ID,
TrustForwardedProto: true, // enable XFF for spoof tests in access list (default false would ignore XFF)
}
_, _ = st.CreateProxyHost(ph)
// ph with default TrustForwardedProto=false for testing XFF spoof ignored in access + clientIP
phNoTrust := store.ProxyHost{
DomainNames: []string{"acl-notrust.example"},
ForwardHost: "127.0.0.1",
ForwardPort: 80,
Enabled: true,
AccessListID: alCreated.ID,
// TrustForwardedProto: false (default) => XFF ignored, use RemoteAddr for access decisions/injection
}
_, _ = st.CreateProxyHost(phNoTrust)
eng.ReloadFromStore()
h := eng.Handler()
// deny via explicit deny client (note: 11.x would be allowed under !satisfyAny if no deny hit; use the deny addr)
rr := doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil)
if rr.Code != 403 {
t.Errorf("access deny exact: %d", rr.Code)
}
// trust=false + spoof XFF (deny addr): should ignore XFF (use Remote which doesn't match deny) => no 403, reaches auth
rr = doReq(h, "GET", "acl-notrust.example", "/", http.Header{"X-Forwarded-For": []string{"1.2.3.4"}}, nil)
if rr.Code != 401 {
t.Errorf("TrustForwardedProto=false should ignore XFF spoof (reaches auth 401); got %d", rr.Code)
}
// allow by cidr via xff
rr = doReq(h, "GET", "acl.example", "/", http.Header{"X-Forwarded-For": []string{"10.1.2.3"}}, nil)
// will 401 because basic auth required next
if rr.Code != 401 {
t.Errorf("access allow but no auth expected 401: %d", rr.Code)
}
// basic auth fail
rr = doReq(h, "GET", "acl.example", "/", http.Header{
"X-Forwarded-For": []string{"10.1.2.3"},
"Authorization": []string{"Basic " + basic("bad", "bad")},
}, nil)
if rr.Code != 401 {
t.Errorf("basic bad: %d", rr.Code)
}
// basic good, passauth false so stripped
rr = doReq(h, "GET", "acl.example", "/", http.Header{
"X-Forwarded-For": []string{"10.1.2.3"},
"Authorization": []string{"Basic " + basic("user", "pass")},
}, nil)
if rr.Code == 401 {
t.Error("basic good should not 401")
}
// (502 later ok)
}
func basic(u, p string) string {
return base64.StdEncoding.EncodeToString([]byte(u + ":" + p))
}
func TestEngineHandler_Caching(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
upstream := NewWhoami(t, WhoamiConfig{
DefaultHeaders: map[string]string{
"Cache-Control": "max-age=3600",
"X-Upstream": "yes",
},
Routes: map[string]WhoamiRoute{
"/cpath": {Body: "CACHED-BODY-FOR-/cpath"},
},
})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"cache.example"},
ForwardScheme: "http",
ForwardHost: upstream.Hostname,
ForwardPort: upstream.Port,
Enabled: true,
CachingEnabled: true,
})
eng.ReloadFromStore()
h := eng.Handler()
rr1 := doReq(h, "GET", "cache.example", "/cpath", nil, nil)
if rr1.Code != 200 || !strings.Contains(rr1.Body.String(), "CACHED-BODY") || rr1.Header().Get("X-Proxy-Cache") != "MISS" {
t.Errorf("first MISS: code=%d cache=%s body=%s", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String())
}
rr2 := doReq(h, "GET", "cache.example", "/cpath", nil, nil)
if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != rr1.Body.String() {
t.Errorf("second HIT: cache=%s body=%s", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String())
}
noStore := NewWhoami(t, WhoamiConfig{
DefaultHeaders: map[string]string{"Cache-Control": "no-store"},
Routes: map[string]WhoamiRoute{"/p": {Body: "NO-STORE-BODY"}},
})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"cache-nostore.example"},
ForwardScheme: "http",
ForwardHost: noStore.Hostname,
ForwardPort: noStore.Port,
Enabled: true,
CachingEnabled: true,
})
eng.ReloadFromStore()
h = eng.Handler()
rr3 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil)
if rr3.Header().Get("X-Proxy-Cache") != "" {
t.Error("no-store should not set X-Proxy-Cache")
}
rr4 := doReq(h, "GET", "cache-nostore.example", "/p", nil, nil)
if rr4.Header().Get("X-Proxy-Cache") == "HIT" {
t.Error("no-store response should not have been cached for HIT")
}
eng.ReloadFromStore()
}
// Test X-Forwarded* injection (respects trust), ws header munging via whoami upstream.
func TestEngineHandler_ForwardedAndWS(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
up := NewWhoami(t, WhoamiConfig{})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"fwd.example"},
ForwardScheme: "http",
ForwardHost: up.Hostname,
ForwardPort: up.Port,
Enabled: true,
TrustForwardedProto: true,
AllowWebsocketUpgrade: true,
})
eng.ReloadFromStore()
h := eng.Handler()
hdrs := http.Header{
"X-Forwarded-For": []string{"10.9.8.7"},
"X-Forwarded-Proto": []string{"https"},
"Connection": []string{"Upgrade"},
"Upgrade": []string{"websocket"},
}
rr := doReq(h, "GET", "fwd.example", "/ws", hdrs, nil)
if rr.Code != 200 {
t.Fatalf("forwarded/ws proxy: code=%d", rr.Code)
}
got, ok := up.LastRequest()
if !ok {
t.Fatal("upstream saw no request")
}
if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") || got.HeaderValue("X-Forwarded-Proto") != "https" {
t.Errorf("X-Forwarded* injection: ff=%s proto=%s", got.HeaderValue("X-Forwarded-For"), got.HeaderValue("X-Forwarded-Proto"))
}
if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" {
t.Errorf("ws munging: conn=%s up=%s", got.HeaderValue("Connection"), got.HeaderValue("Upgrade"))
}
}
// Test ACME challenge serving (before host)
func TestEngineHandler_ACME(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
h := eng.Handler()
chDir := config.Resolve("letsencrypt-acme-challenge")
acmeFileDir := filepath.Join(chDir, ".well-known", "acme-challenge")
_ = os.MkdirAll(acmeFileDir, 0o755)
token := "testtoken123"
content := "challenge-content-xyz"
if err := os.WriteFile(filepath.Join(acmeFileDir, token), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
rr := doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/"+token, nil, nil)
if rr.Code != 200 || rr.Body.String() != content {
t.Errorf("acme serve: code=%d body=%q", rr.Code, rr.Body.String())
}
rr = doReq(h, "GET", "anyhost", "/.well-known/acme-challenge/missing", nil, nil)
if rr.Code != 404 {
t.Errorf("acme missing: %d", rr.Code)
}
}
// Test loadCerts / GetCertificate (SNI, compat keys)
func TestEngine_LoadCertsGetCertificate(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
// create via cert manager's test path (dev mode makes *all* LE certs self-signed test certs)
config.ResetForTest()
t.Setenv("DATA_DIR", config.DataDir()) // already set in newTest
t.Setenv("PROXY_MODE", "development")
cm := certificate.NewManager(st)
certRec := store.Certificate{
Provider: "letsencrypt",
DomainNames: []string{"sni.example"},
Meta: map[string]any{},
}
created, _ := st.CreateCertificate(certRec)
email := cm.GetEmailForCert(created)
if err := cm.Issue(created, email); err != nil {
t.Fatalf("issue test cert for load: %v", err)
}
// now attach to host
ph := store.ProxyHost{
DomainNames: []string{"sni.example"},
ForwardHost: "127.0.0.1",
ForwardPort: 80,
Enabled: true,
CertificateID: created.ID,
}
_, _ = st.CreateProxyHost(ph)
eng.ReloadFromStore()
// loadCerts called in reload; should succeed with real PEMs
cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "sni.example"})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
if cert == nil {
t.Error("nil cert")
}
// fallback any (there is one now)
_, _ = eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown"})
}
// Test streams reload (at least lifecycle no panic)
func TestEngine_StreamsReload(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
strm := store.Stream{
IncomingPort: 0, // use high? but test may bind fail; use invalid port to just test start path without real listen success
ForwardingHost: "127.0.0.1",
ForwardingPort: 9,
TCPForwarding: true,
Enabled: true,
}
// port 0 not good; pick a likely free high port for test? but to avoid bind in CI, just create disabled first
strm.IncomingPort = 19999 + (int(time.Now().UnixNano()) % 1000) // somewhat unique
_, _ = st.CreateStream(strm)
eng.ReloadFromStore()
runtime := eng.StreamStatus(strm.IncomingPort)
if !runtime.Listening {
t.Fatalf("expected stream listening on port %d: %+v", strm.IncomingPort, runtime)
}
// now disable and reload to exercise cancel path
strm.Enabled = false
_, _ = st.UpdateStream(strm)
eng.ReloadFromStore()
// ok if no crash
}
func TestEngine_StreamListenFailure(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
port := 19800 + (int(time.Now().UnixNano()) % 1000)
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
t.Fatalf("bind probe port: %v", err)
}
defer ln.Close()
strm := store.Stream{
IncomingPort: port,
ForwardingHost: "127.0.0.1",
ForwardingPort: 9,
TCPForwarding: true,
Enabled: true,
}
_, _ = st.CreateStream(strm)
eng.ReloadFromStore()
runtime := eng.StreamStatus(port)
if runtime.Listening {
t.Fatal("expected stream not listening when port is taken")
}
if runtime.ListenError == "" {
t.Fatal("expected listen error when port is taken")
}
}
func TestEngine_StreamReloadSamePort(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
port := 19700 + (int(time.Now().UnixNano()) % 1000)
strm := store.Stream{
IncomingPort: port,
ForwardingHost: "127.0.0.1",
ForwardingPort: 9,
TCPForwarding: true,
Enabled: true,
}
created, _ := st.CreateStream(strm)
eng.ReloadFromStore()
runtime := eng.StreamStatus(port)
if !runtime.Listening {
t.Fatalf("expected stream listening after initial reload: %+v", runtime)
}
created.ForwardingPort = 10
_, _ = st.UpdateStream(created)
eng.ReloadFromStore()
runtime = eng.StreamStatus(port)
if !runtime.Listening {
t.Fatalf("expected stream still listening after edit reload on same port: %+v", runtime)
}
}
func TestEngine_StreamReloadLeavesOthersRunning(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
base := 19600 + (int(time.Now().UnixNano()) % 1000)
portA := base
portB := base + 1
a, _ := st.CreateStream(store.Stream{
IncomingPort: portA, ForwardingHost: "127.0.0.1", ForwardingPort: 9,
TCPForwarding: true, Enabled: true,
})
b, _ := st.CreateStream(store.Stream{
IncomingPort: portB, ForwardingHost: "127.0.0.1", ForwardingPort: 9,
TCPForwarding: true, Enabled: true,
})
eng.ReloadFromStore()
if st := eng.StreamStatus(portA); !st.Listening {
t.Fatalf("stream A not listening: %+v", st)
}
if st := eng.StreamStatus(portB); !st.Listening {
t.Fatalf("stream B not listening: %+v", st)
}
a.ForwardingPort = 10
_, _ = st.UpdateStream(a)
eng.ReloadFromStore()
if st := eng.StreamStatus(portA); !st.Listening {
t.Fatalf("stream A not listening after edit: %+v", st)
}
if st := eng.StreamStatus(portB); !st.Listening {
t.Fatalf("stream B should still be listening after editing A: %+v", st)
}
_ = b
}
func TestDuplicateStreamListenPorts(t *testing.T) {
desired := map[int]store.Stream{
1: {ID: 1, IncomingPort: 9000, TCPForwarding: true},
2: {ID: 2, IncomingPort: 9000, TCPForwarding: true},
3: {ID: 3, IncomingPort: 9001, TCPForwarding: true},
}
conflicts := duplicateStreamListenPorts(desired)
if len(conflicts) != 2 {
t.Fatalf("expected 2 conflicts, got %d: %v", len(conflicts), conflicts)
}
if conflicts[1] == "" || conflicts[2] == "" {
t.Fatalf("expected errors for streams 1 and 2, got %v", conflicts)
}
if _, ok := conflicts[3]; ok {
t.Fatalf("stream 3 should not conflict: %v", conflicts)
}
}
func closeStore(s store.Store) {
if c, ok := s.(interface{ Close() error }); ok {
c.Close()
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+546
View File
@@ -0,0 +1,546 @@
package proxy
import (
"crypto/tls"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"helix-proxy/internal/certificate"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
"golang.org/x/net/websocket"
)
// TestEngine_Integration_FullSetup wires proxy hosts, redirections, dead hosts,
// access lists, certificates, locations, and streams against configurable whoami upstreams.
func TestEngine_Integration_FullSetup(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
// --- upstream mocks ---
mainUpstream := NewWhoami(t, WhoamiConfig{
Routes: map[string]WhoamiRoute{
"/health": {Status: 200, Body: `{"status":"ok"}`, Headers: map[string]string{"X-Secret": "hidden"}},
},
})
apiUpstream := NewWhoami(t, WhoamiConfig{
ValidPrefixes: []string{"/v2"},
})
authUpstream := NewWhoami(t, WhoamiConfig{})
tcpEcho := NewTCPEcho(t, "STREAM-OK\n")
certRec := issueDevCert(t, st, "secure.example", "stream.example")
// --- access list ---
al, _ := st.CreateAccessList(store.AccessList{
Name: "integration-acl",
SatisfyAny: false,
Clients: []store.AccessClient{{Address: "all", Directive: "allow"}},
Items: []store.AccessItem{{Username: "testuser", Password: "secret"}},
PassAuth: false,
})
// --- proxy host: headers, location rewrite, access, cert ---
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"proxy-int.example"},
ForwardScheme: "http",
ForwardHost: mainUpstream.Hostname,
ForwardPort: mainUpstream.Port,
CertificateID: certRec.ID,
AccessListID: al.ID,
TrustForwardedProto: true,
RequestHeaders: []store.HeaderRule{{Name: "X-Injected-Req", Value: "yes"}},
ResponseHeaders: []store.HeaderRule{{Name: "X-Injected-Resp", Value: "yes"}},
HideHeaders: []string{"X-Secret"},
Enabled: true,
Locations: []store.Location{{
Path: "/api",
ForwardScheme: "http",
ForwardHost: apiUpstream.Hostname,
ForwardPort: apiUpstream.Port,
ForwardPath: "/v2",
}},
})
// proxy with passAuth=true to verify Authorization reaches upstream
alPass, _ := st.CreateAccessList(store.AccessList{
Name: "pass-auth-acl",
Clients: []store.AccessClient{{Address: "all", Directive: "allow"}},
Items: []store.AccessItem{{Username: "u", Password: "p"}},
PassAuth: true,
})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"passauth.example"},
ForwardScheme: "http",
ForwardHost: authUpstream.Hostname,
ForwardPort: authUpstream.Port,
AccessListID: alPass.ID,
Enabled: true,
})
// --- redirection host ---
_, _ = st.CreateRedirectionHost(store.RedirectionHost{
DomainNames: []string{"redir-int.example"},
ForwardHTTPCode: 302,
ForwardScheme: "https",
ForwardDomainName: "target-int.example",
PreservePath: true,
Enabled: true,
})
// --- dead hosts ---
_, _ = st.CreateDeadHost(store.DeadHost{
DomainNames: []string{"dead-int.example"},
Enabled: true,
Meta: map[string]any{"html": "<h1>integration dead</h1>"},
})
wwwDir := config.Resolve("www")
_ = os.MkdirAll(wwwDir, 0o755)
_ = os.WriteFile(filepath.Join(wwwDir, "404.html"), []byte("<h1>INTEGRATION 404 FILE</h1>"), 0o644)
_, _ = st.CreateDeadHost(store.DeadHost{DomainNames: []string{"deadfile-int.example"}, Enabled: true})
// --- default site modes ---
_ = st.SetSetting("default_site", "404")
// --- stream (TCP) ---
t.Setenv("DISABLE_IPV6", "1")
streamPort := pickFreePort(t)
_, _ = st.CreateStream(store.Stream{
IncomingPort: streamPort,
ForwardingHost: tcpEcho.Host,
ForwardingPort: tcpEcho.Port,
TCPForwarding: true,
Enabled: true,
})
cleanupStreams(t, eng, st)
eng.ReloadFromStore()
h := eng.Handler()
// --- redirection ---
rr := doReq(h, "GET", "redir-int.example", "/foo?bar=1", nil, nil)
if rr.Code != 302 || !strings.Contains(rr.Header().Get("Location"), "https://target-int.example/foo?bar=1") {
t.Errorf("redirection: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
}
// --- dead hosts ---
rr = doReq(h, "GET", "dead-int.example", "/", nil, nil)
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "integration dead") {
t.Errorf("dead meta: code=%d body=%q", rr.Code, rr.Body.String())
}
rr = doReq(h, "GET", "deadfile-int.example", "/", nil, nil)
if rr.Code != 404 || !strings.Contains(rr.Body.String(), "INTEGRATION 404 FILE") {
t.Errorf("dead file: code=%d body=%q", rr.Code, rr.Body.String())
}
// --- default 404 ---
rr = doReq(h, "GET", "unknown-int.example", "/", nil, nil)
if rr.Code != 404 {
t.Errorf("default 404: %d", rr.Code)
}
// --- certificate SNI ---
cert, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "secure.example"})
if err != nil || cert == nil {
t.Errorf("GetCertificate secure.example: err=%v cert=%v", err, cert)
}
// --- proxy: main path with auth + injected headers ---
authHdr := http.Header{
"Authorization": []string{"Basic " + basic("testuser", "secret")},
"X-Forwarded-For": []string{"10.0.0.5"},
"X-Forwarded-Proto": []string{"https"},
}
rr = doReq(h, "GET", "proxy-int.example", "/health", authHdr, nil)
if rr.Code != 200 {
t.Fatalf("proxy /health: code=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("X-Injected-Resp") != "yes" {
t.Error("response header injection missing")
}
if rr.Header().Get("X-Secret") != "" {
t.Error("hide header failed: X-Secret leaked")
}
if got, ok := mainUpstream.LastRequest(); !ok {
t.Fatal("main upstream saw no request")
} else {
if got.Path != "/health" {
t.Errorf("main path: got %q want /health", got.Path)
}
if got.HeaderValue("X-Injected-Req") != "yes" {
t.Errorf("request header injection: got %q", got.HeaderValue("X-Injected-Req"))
}
if got.HeaderValue("Authorization") != "" {
t.Error("passAuth=false should strip Authorization before upstream")
}
if got.HeaderValue("X-Forwarded-Proto") != "https" {
t.Errorf("X-Forwarded-Proto: got %q", got.HeaderValue("X-Forwarded-Proto"))
}
}
// --- location path rewrite ---
apiUpstream.ClearRequests()
rr = doReq(h, "GET", "proxy-int.example", "/api/foo", authHdr, nil)
if rr.Code != 200 {
t.Fatalf("location /api/foo: code=%d", rr.Code)
}
got, ok := apiUpstream.LastRequest()
if !ok {
t.Fatal("api upstream saw no request for location")
}
if got.Path != "/v2/foo" {
t.Errorf("location rewrite: upstream path=%q want /v2/foo", got.Path)
}
if rr.Header().Get("X-Whoami-Path") != "/v2/foo" {
t.Errorf("X-Whoami-Path header: %q", rr.Header().Get("X-Whoami-Path"))
}
// --- passAuth=true keeps Authorization ---
authUpstream.ClearRequests()
rr = doReq(h, "GET", "passauth.example", "/", http.Header{
"Authorization": []string{"Basic " + basic("u", "p")},
}, nil)
if rr.Code != 200 {
t.Fatalf("passauth proxy: code=%d", rr.Code)
}
got, ok = authUpstream.LastRequest()
if !ok {
t.Fatal("auth upstream saw no request")
}
if got.HeaderValue("Authorization") == "" {
t.Error("passAuth=true should forward Authorization to upstream")
}
// --- whoami JSON echo on default route ---
mainUpstream.ClearRequests()
rr = doReq(h, "GET", "proxy-int.example", "/echo-test?q=1", authHdr, nil)
if rr.Code != 200 {
t.Fatalf("echo route: code=%d", rr.Code)
}
var echoed WhoamiRequest
if err := json.Unmarshal(rr.Body.Bytes(), &echoed); err != nil {
t.Fatalf("parse whoami json: %v body=%s", err, rr.Body.String())
}
if echoed.Path != "/echo-test" || echoed.Query != "q=1" {
t.Errorf("whoami json echo: path=%q query=%q", echoed.Path, echoed.Query)
}
// --- stream TCP forwarding ---
waitForTCP(t, net.JoinHostPort("127.0.0.1", itoa(streamPort)), 5*time.Second)
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(streamPort)), 2*time.Second)
if err != nil {
t.Fatalf("dial stream port %d: %v", streamPort, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := io.WriteString(conn, "ping\n"); err != nil {
t.Fatalf("stream write: %v", err)
}
buf := make([]byte, 64)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("stream read: %v", err)
}
if !strings.Contains(string(buf[:n]), "STREAM-OK") {
t.Errorf("stream echo: got %q", string(buf[:n]))
}
// reload lifecycle: cleanupStreams t.Cleanup also exercises disable path
strms := st.GetStreams()
if len(strms) == 0 {
t.Fatal("no streams in store")
}
}
// TestEngine_Integration_Websocket verifies end-to-end websocket upgrade and message echo.
func TestEngine_Integration_Websocket(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
wsUp := NewWhoamiWS(t, "/ws")
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"ws-int.example"},
ForwardScheme: "http",
ForwardHost: wsUp.Hostname,
ForwardPort: wsUp.Port,
Enabled: true,
AllowWebsocketUpgrade: true,
TrustForwardedProto: true,
})
eng.ReloadFromStore()
proxy := newProxyServer(t, eng)
ws := dialWhoamiWS(t, proxy.URL, "ws-int.example", "/ws", http.Header{
"X-Forwarded-For": []string{"10.9.8.7"},
"X-Forwarded-Proto": []string{"https"},
})
if err := websocket.Message.Send(ws, "hello"); err != nil {
t.Fatalf("ws send: %v", err)
}
var reply string
if err := websocket.Message.Receive(ws, &reply); err != nil {
t.Fatalf("ws recv: %v", err)
}
if reply != "echo:hello" {
t.Errorf("ws echo: got %q", reply)
}
got, ok := wsUp.UpgradeRequest()
if !ok {
t.Fatal("upstream missed websocket upgrade request")
}
if got.Path != "/ws" {
t.Errorf("upgrade path: %q", got.Path)
}
if !strings.Contains(got.HeaderValue("X-Forwarded-For"), "10.9.8.7") {
t.Errorf("upgrade X-Forwarded-For: %q", got.HeaderValue("X-Forwarded-For"))
}
if got.HeaderValue("X-Forwarded-Proto") != "https" {
t.Errorf("upgrade X-Forwarded-Proto: %q", got.HeaderValue("X-Forwarded-Proto"))
}
if got.HeaderValue("Connection") != "Upgrade" || got.HeaderValue("Upgrade") != "websocket" {
t.Errorf("upgrade headers: conn=%q up=%q", got.HeaderValue("Connection"), got.HeaderValue("Upgrade"))
}
msgs := wsUp.Messages()
if len(msgs) != 1 || msgs[0] != "hello" {
t.Errorf("upstream messages: %v", msgs)
}
}
// TestEngine_Integration_Caching exercises cache HIT/MISS through whoami upstreams.
func TestEngine_Integration_Caching(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
upstream := NewWhoami(t, WhoamiConfig{
DefaultHeaders: map[string]string{"Cache-Control": "max-age=3600"},
Routes: map[string]WhoamiRoute{
"/asset": {Body: "CACHED-ASSET"},
},
})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"cache-int.example"},
ForwardScheme: "http",
ForwardHost: upstream.Hostname,
ForwardPort: upstream.Port,
Enabled: true,
CachingEnabled: true,
})
eng.ReloadFromStore()
h := eng.Handler()
rr1 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil)
if rr1.Code != 200 || rr1.Header().Get("X-Proxy-Cache") != "MISS" || rr1.Body.String() != "CACHED-ASSET" {
t.Fatalf("cache MISS: code=%d cache=%s body=%q", rr1.Code, rr1.Header().Get("X-Proxy-Cache"), rr1.Body.String())
}
if reqs := upstream.Requests(); len(reqs) != 1 {
t.Fatalf("upstream requests after MISS: %d", len(reqs))
}
rr2 := doReq(h, "GET", "cache-int.example", "/asset", nil, nil)
if rr2.Header().Get("X-Proxy-Cache") != "HIT" || rr2.Body.String() != "CACHED-ASSET" {
t.Errorf("cache HIT: cache=%s body=%q", rr2.Header().Get("X-Proxy-Cache"), rr2.Body.String())
}
if reqs := upstream.Requests(); len(reqs) != 1 {
t.Errorf("upstream should not be hit again on HIT; requests=%d", len(reqs))
}
}
// TestEngine_Integration_ProxyMiddleware exercises ssl_forced, block_exploits, and HSTS
// against a real whoami upstream.
func TestEngine_Integration_ProxyMiddleware(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
upstream := NewWhoami(t, WhoamiConfig{})
_, _ = st.CreateProxyHost(store.ProxyHost{
DomainNames: []string{"middleware.example"},
ForwardScheme: "http",
ForwardHost: upstream.Hostname,
ForwardPort: upstream.Port,
Enabled: true,
SslForced: true,
BlockExploits: true,
HstsEnabled: true,
HstsSubdomains: true,
TrustForwardedProto: true,
})
eng.ReloadFromStore()
h := eng.Handler()
// ssl_forced redirects plain http
rr := doReq(h, "GET", "middleware.example", "/ok", http.Header{
"X-Forwarded-Proto": []string{"http"},
}, nil)
if rr.Code != 301 || !strings.HasPrefix(rr.Header().Get("Location"), "https://") {
t.Errorf("ssl_forced: code=%d loc=%q", rr.Code, rr.Header().Get("Location"))
}
// block_exploits on https path
req := httptest.NewRequest("GET", "/.env", nil)
req.Host = "middleware.example"
req.TLS = &tls.ConnectionState{}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 403 || !strings.Contains(rr.Body.String(), "exploits") {
t.Errorf("block_exploits: code=%d body=%q", rr.Code, rr.Body.String())
}
// HSTS + successful whoami proxy on https
req = httptest.NewRequest("GET", "/ok", nil)
req.Host = "middleware.example"
req.TLS = &tls.ConnectionState{}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("proxy ok: code=%d body=%s", rr.Code, rr.Body.String())
}
if hs := rr.Header().Get("Strict-Transport-Security"); !strings.Contains(hs, "includeSubDomains") {
t.Errorf("hsts: %q", hs)
}
got, ok := upstream.LastRequest()
if !ok || got.Path != "/ok" {
t.Errorf("upstream path: got=%v ok=%v", got.Path, ok)
}
}
// issueDevCert creates and issues a self-signed certificate in development mode.
func issueDevCert(t *testing.T, st store.Store, domains ...string) store.Certificate {
t.Helper()
t.Setenv("PROXY_MODE", "development")
cm := certificate.NewManager(st)
rec, _ := st.CreateCertificate(store.Certificate{
Provider: "letsencrypt",
DomainNames: domains,
Meta: map[string]any{},
})
if err := cm.Issue(rec, cm.GetEmailForCert(rec)); err != nil {
t.Fatalf("issue cert: %v", err)
}
rec, _ = st.GetCertificate(rec.ID)
return rec
}
// TestEngine_Integration_TLSStream verifies TLS-terminated TCP stream forwarding.
func TestEngine_Integration_TLSStream(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
t.Setenv("DISABLE_IPV6", "1")
certRec := issueDevCert(t, st, "stream-tls.example")
tcpEcho := NewTCPEcho(t, "STREAM-TLS-OK\n")
streamPort := 20000 + int(time.Now().UnixNano()%10000)
_, _ = st.CreateStream(store.Stream{
IncomingPort: streamPort,
ForwardingHost: tcpEcho.Host,
ForwardingPort: tcpEcho.Port,
TCPForwarding: true,
Enabled: true,
CertificateID: certRec.ID,
})
cleanupStreams(t, eng, st)
eng.ReloadFromStore()
if eng.getStreamCert(certRec.ID) == nil {
t.Fatalf("stream cert %d not loaded after reload", certRec.ID)
}
trust, err := eng.GetCertificate(&tls.ClientHelloInfo{ServerName: "stream-tls.example"})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
addr := net.JoinHostPort("127.0.0.1", itoa(streamPort))
var waitErr error
for attempt := 0; attempt < 3; attempt++ {
waitErr = tryWaitForTLSStream(addr, "stream-tls.example", trust, 2*time.Second)
if waitErr == nil {
break
}
eng.ReloadFromStore()
}
if waitErr != nil {
t.Fatalf("tls stream listener on %s: %v", addr, waitErr)
}
conn := DialTLSStream(t, addr, "stream-tls.example", trust)
if _, err := io.WriteString(conn, "ping\n"); err != nil {
t.Fatalf("tls stream write: %v", err)
}
got := ReadStreamGreeting(t, conn, 64, 2*time.Second)
if !strings.Contains(got, "STREAM-TLS-OK") {
t.Errorf("tls stream echo: got %q", got)
}
// plain TCP to a TLS listener should fail handshake quickly
raw, err := net.DialTimeout("tcp", addr, time.Second)
if err != nil {
t.Fatalf("raw dial: %v", err)
}
defer raw.Close()
_ = raw.SetDeadline(time.Now().Add(time.Second))
if _, err := io.WriteString(raw, "not-tls\n"); err != nil {
t.Fatalf("raw write: %v", err)
}
buf := make([]byte, 16)
if _, err := raw.Read(buf); err == nil {
t.Error("expected plain TCP to TLS stream to fail read, got data")
}
}
// TestEngine_Integration_UDPStream verifies UDP stream forwarding.
func TestEngine_Integration_UDPStream(t *testing.T) {
eng, st := newTestEngine(t)
defer closeStore(st)
t.Setenv("DISABLE_IPV6", "1") // bind 0.0.0.0 so 127.0.0.1 dials reach the listener
udpEcho := NewUDPEcho(t, "UDP-STREAM-OK\n")
streamPort := pickFreePort(t)
_, _ = st.CreateStream(store.Stream{
IncomingPort: streamPort,
ForwardingHost: udpEcho.Host,
ForwardingPort: udpEcho.Port,
UDPForwarding: true,
Enabled: true,
})
cleanupStreams(t, eng, st)
eng.ReloadFromStore()
addr := net.JoinHostPort("127.0.0.1", itoa(streamPort))
waitForUDP(t, addr, 2*time.Second)
target, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
t.Fatalf("resolve stream udp: %v", err)
}
conn, err := net.DialUDP("udp", nil, target)
if err != nil {
t.Fatalf("dial stream udp: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte("probe")); err != nil {
t.Fatalf("udp write: %v", err)
}
buf := make([]byte, 64)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("udp read: %v", err)
}
if !strings.Contains(string(buf[:n]), "UDP-STREAM-OK") {
t.Errorf("udp stream echo: got %q", string(buf[:n]))
}
}
+618
View File
@@ -0,0 +1,618 @@
package proxy
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"helix-proxy/internal/store"
"golang.org/x/net/websocket"
)
// WhoamiRequest captures what the upstream mock received.
type WhoamiRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Query string `json:"query,omitempty"`
Host string `json:"host"`
Headers map[string][]string `json:"headers"`
Body string `json:"body,omitempty"`
RemoteAddr string `json:"remoteAddr"`
}
// WhoamiRoute configures a response for a specific path (and optionally method).
type WhoamiRoute struct {
Method string
Status int
Body string
Headers map[string]string
}
// WhoamiConfig configures whoami upstream behavior.
type WhoamiConfig struct {
DefaultStatus int
DefaultBody string
DefaultHeaders map[string]string
Routes map[string]WhoamiRoute // key: path or "METHOD path"
ValidPaths []string // exact paths allowed; empty = all
ValidPrefixes []string // path prefixes allowed (checked after ValidPaths)
}
// WhoamiServer is a test upstream that records requests and returns configurable responses.
type WhoamiServer struct {
Server *httptest.Server
Hostname string
Port int
cfg WhoamiConfig
mu sync.Mutex
requests []WhoamiRequest
}
// NewWhoami starts a whoami upstream and registers cleanup on t.
func NewWhoami(t *testing.T, cfg WhoamiConfig) *WhoamiServer {
t.Helper()
if cfg.DefaultStatus == 0 {
cfg.DefaultStatus = http.StatusOK
}
w := &WhoamiServer{cfg: cfg}
w.Server = httptest.NewServer(http.HandlerFunc(w.serve))
t.Cleanup(w.Server.Close)
u, err := url.Parse(w.Server.URL)
if err != nil {
t.Fatalf("whoami url: %v", err)
}
w.Hostname = u.Hostname()
w.Port, err = strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("whoami port: %v", err)
}
return w
}
func (w *WhoamiServer) serve(rw http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
req := WhoamiRequest{
Method: r.Method,
Path: r.URL.Path,
Query: r.URL.RawQuery,
Host: r.Host,
Headers: cloneHeaderMap(r.Header),
Body: string(body),
RemoteAddr: r.RemoteAddr,
}
w.mu.Lock()
w.requests = append(w.requests, req)
w.mu.Unlock()
if !w.pathAllowed(r.URL.Path) {
http.Error(rw, "whoami: path not allowed", http.StatusNotFound)
return
}
route, ok := w.matchRoute(r.Method, r.URL.Path)
status := w.cfg.DefaultStatus
respBody := w.cfg.DefaultBody
respHeaders := map[string]string{}
for k, v := range w.cfg.DefaultHeaders {
respHeaders[k] = v
}
if ok {
if route.Status != 0 {
status = route.Status
}
if route.Body != "" {
respBody = route.Body
}
for k, v := range route.Headers {
respHeaders[k] = v
}
}
// If no custom body, echo the captured request as JSON (whoami-style).
if respBody == "" {
b, err := json.Marshal(req)
if err != nil {
http.Error(rw, "whoami: marshal", http.StatusInternalServerError)
return
}
respBody = string(b)
}
rw.Header().Set("Content-Type", "application/json")
rw.Header().Set("X-Whoami-Method", req.Method)
rw.Header().Set("X-Whoami-Path", req.Path)
rw.Header().Set("X-Whoami-Host", req.Host)
rw.Header().Set("X-Whoami-Query", req.Query)
for name, vals := range req.Headers {
rw.Header().Set("X-Whoami-H-"+name, strings.Join(vals, ", "))
}
for k, v := range respHeaders {
rw.Header().Set(k, v)
}
rw.WriteHeader(status)
_, _ = io.WriteString(rw, respBody)
}
func (w *WhoamiServer) pathAllowed(path string) bool {
if len(w.cfg.ValidPaths) == 0 && len(w.cfg.ValidPrefixes) == 0 {
return true
}
for _, p := range w.cfg.ValidPaths {
if path == p {
return true
}
}
for _, p := range w.cfg.ValidPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
func (w *WhoamiServer) matchRoute(method, path string) (WhoamiRoute, bool) {
if len(w.cfg.Routes) == 0 {
return WhoamiRoute{}, false
}
key := strings.ToUpper(method) + " " + path
if r, ok := w.cfg.Routes[key]; ok {
return r, true
}
if r, ok := w.cfg.Routes[path]; ok {
return r, true
}
return WhoamiRoute{}, false
}
// Requests returns a copy of all recorded upstream requests.
func (w *WhoamiServer) Requests() []WhoamiRequest {
w.mu.Lock()
defer w.mu.Unlock()
out := make([]WhoamiRequest, len(w.requests))
copy(out, w.requests)
return out
}
// LastRequest returns the most recent upstream request, if any.
func (w *WhoamiServer) LastRequest() (WhoamiRequest, bool) {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.requests) == 0 {
return WhoamiRequest{}, false
}
return w.requests[len(w.requests)-1], true
}
// ClearRequests resets the recorded request history.
func (w *WhoamiServer) ClearRequests() {
w.mu.Lock()
defer w.mu.Unlock()
w.requests = nil
}
// ParseWhoamiBody decodes a JSON whoami echo body from a proxied response.
func ParseWhoamiBody(body []byte) (WhoamiRequest, error) {
var req WhoamiRequest
err := json.Unmarshal(body, &req)
return req, err
}
// HeaderValue returns the first value for a header name (case-insensitive).
func (r WhoamiRequest) HeaderValue(name string) string {
for k, vs := range r.Headers {
if strings.EqualFold(k, name) && len(vs) > 0 {
return vs[0]
}
}
return ""
}
func cloneHeaderMap(h http.Header) map[string][]string {
out := make(map[string][]string, len(h))
for k, vs := range h {
cp := make([]string, len(vs))
copy(cp, vs)
out[k] = cp
}
return out
}
// TCPEchoServer accepts connections and echoes a fixed greeting.
type TCPEchoServer struct {
Listener net.Listener
Host string
Port int
Greeting string
}
// NewTCPEcho starts a TCP server that writes Greeting on each accepted connection.
func NewTCPEcho(t *testing.T, greeting string) *TCPEchoServer {
t.Helper()
if greeting == "" {
greeting = "STREAM-ECHO\n"
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("tcp echo listen: %v", err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
s := &TCPEchoServer{Listener: ln, Host: "127.0.0.1", Port: port, Greeting: greeting}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_, _ = io.WriteString(c, s.Greeting)
}(conn)
}
}()
t.Cleanup(func() { _ = ln.Close() })
return s
}
// pickFreePort returns a likely-free TCP port on 127.0.0.1.
func pickFreePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("pick port: %v", err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
_ = ln.Close()
port, _ := strconv.Atoi(portStr)
return port
}
// UDPEchoServer responds to each datagram with a fixed payload.
type UDPEchoServer struct {
Conn *net.UDPConn
Host string
Port int
Response []byte
}
// NewUDPEcho starts a UDP server that writes Response for every datagram received.
func NewUDPEcho(t *testing.T, response string) *UDPEchoServer {
t.Helper()
if response == "" {
response = "UDP-ECHO\n"
}
addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("udp resolve: %v", err)
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
t.Fatalf("udp listen: %v", err)
}
_, portStr, _ := net.SplitHostPort(conn.LocalAddr().String())
port, _ := strconv.Atoi(portStr)
s := &UDPEchoServer{
Conn: conn,
Host: "127.0.0.1",
Port: port,
Response: []byte(response),
}
go func() {
buf := make([]byte, 4096)
for {
n, client, err := conn.ReadFromUDP(buf)
if err != nil {
return
}
_, _ = conn.WriteToUDP(s.Response, client)
_ = n
}
}()
t.Cleanup(func() { _ = conn.Close() })
return s
}
// tryWaitForTLSStream returns nil once a TLS stream listener accepts connections.
func tryWaitForTLSStream(addr, serverName string, trust *tls.Certificate, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
pool := x509.NewCertPool()
if len(trust.Certificate) > 0 {
if leaf, err := x509.ParseCertificate(trust.Certificate[0]); err == nil {
pool.AddCert(leaf)
}
}
cfg := &tls.Config{ServerName: serverName, RootCAs: pool, MinVersion: tls.VersionTLS12}
for time.Now().Before(deadline) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 200 * time.Millisecond}, "tcp", addr, cfg)
if err == nil {
_ = conn.Close()
return nil
}
lastErr = err
time.Sleep(20 * time.Millisecond)
}
return lastErr
}
// waitForTLSStream dials addr with TLS until success or timeout.
func waitForTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate, timeout time.Duration) {
t.Helper()
if err := tryWaitForTLSStream(addr, serverName, trust, timeout); err != nil {
t.Fatalf("wait for tls stream %s: %v", addr, err)
}
}
// tryWaitForTCP returns nil once a TCP listener accepts connections.
func tryWaitForTCP(addr string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
lastErr = err
time.Sleep(20 * time.Millisecond)
}
return lastErr
}
// waitForTCP dials addr until success or timeout (stream listeners start async).
func waitForTCP(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
if err := tryWaitForTCP(addr, timeout); err != nil {
t.Fatalf("wait for tcp %s: %v", addr, err)
}
}
// tlsConfigForCert builds a client TLS config that trusts the given server certificate.
func tlsConfigForCert(t *testing.T, serverName string, cert *tls.Certificate) *tls.Config {
t.Helper()
if len(cert.Certificate) == 0 {
t.Fatal("tls cert has no certificate chain")
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(cert.Certificate[0]) {
// X509KeyPair stores DER; re-parse for the pool
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
t.Fatalf("parse leaf cert: %v", err)
}
pool.AddCert(leaf)
}
return &tls.Config{
ServerName: serverName,
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}
}
// DialTLSStream connects to a TLS-terminated stream listener.
func DialTLSStream(t *testing.T, addr, serverName string, trust *tls.Certificate) net.Conn {
t.Helper()
var cfg *tls.Config
if trust != nil {
cfg = tlsConfigForCert(t, serverName, trust)
} else {
cfg = &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec // test-only self-signed dev certs
MinVersion: tls.VersionTLS12,
}
}
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 2 * time.Second}, "tcp", addr, cfg)
if err != nil {
t.Fatalf("tls dial %s: %v", addr, err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
// waitForUDP sends a probe datagram until a response arrives or timeout.
func waitForUDP(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
target, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
t.Fatalf("resolve udp %s: %v", addr, err)
}
var lastErr error
for time.Now().Before(deadline) {
conn, err := net.DialUDP("udp", nil, target)
if err != nil {
lastErr = err
time.Sleep(20 * time.Millisecond)
continue
}
_ = conn.SetDeadline(time.Now().Add(200 * time.Millisecond))
if _, err := conn.Write([]byte("probe")); err != nil {
lastErr = err
_ = conn.Close()
time.Sleep(20 * time.Millisecond)
continue
}
buf := make([]byte, 8)
_, err = conn.Read(buf)
_ = conn.Close()
if err == nil {
return
}
lastErr = err
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("wait for udp %s: %v", addr, lastErr)
}
// WhoamiWSServer is a websocket upstream that records the upgrade request and echoes messages.
type WhoamiWSServer struct {
Server *httptest.Server
Hostname string
Port int
Path string
mu sync.Mutex
upgradeReq WhoamiRequest
messages []string
}
// NewWhoamiWS starts a websocket echo upstream on path (default /ws).
func NewWhoamiWS(t *testing.T, path string) *WhoamiWSServer {
t.Helper()
if path == "" {
path = "/ws"
}
w := &WhoamiWSServer{Path: path}
mux := http.NewServeMux()
mux.Handle(path, websocket.Handler(w.handleWS))
w.Server = httptest.NewServer(mux)
t.Cleanup(w.Server.Close)
u, err := url.Parse(w.Server.URL)
if err != nil {
t.Fatalf("whoami ws url: %v", err)
}
w.Hostname = u.Hostname()
w.Port, err = strconv.Atoi(u.Port())
if err != nil {
t.Fatalf("whoami ws port: %v", err)
}
return w
}
func (w *WhoamiWSServer) handleWS(ws *websocket.Conn) {
req := ws.Request()
body, _ := io.ReadAll(req.Body)
_ = req.Body.Close()
captured := WhoamiRequest{
Method: req.Method,
Path: req.URL.Path,
Query: req.URL.RawQuery,
Host: req.Host,
Headers: cloneHeaderMap(req.Header),
Body: string(body),
RemoteAddr: req.RemoteAddr,
}
w.mu.Lock()
w.upgradeReq = captured
w.mu.Unlock()
for {
var msg string
if err := websocket.Message.Receive(ws, &msg); err != nil {
return
}
w.mu.Lock()
w.messages = append(w.messages, msg)
w.mu.Unlock()
if err := websocket.Message.Send(ws, "echo:"+msg); err != nil {
return
}
}
}
// UpgradeRequest returns the HTTP request observed at websocket upgrade time.
func (w *WhoamiWSServer) UpgradeRequest() (WhoamiRequest, bool) {
w.mu.Lock()
defer w.mu.Unlock()
if w.upgradeReq.Method == "" {
return WhoamiRequest{}, false
}
return w.upgradeReq, true
}
// Messages returns client messages received after upgrade.
func (w *WhoamiWSServer) Messages() []string {
w.mu.Lock()
defer w.mu.Unlock()
out := make([]string, len(w.messages))
copy(out, w.messages)
return out
}
// cleanupStreams disables all streams and reloads the engine on test end.
func cleanupStreams(t *testing.T, eng *Engine, st store.Store) {
t.Helper()
t.Cleanup(func() {
for _, s := range st.GetStreams() {
if !s.Enabled {
continue
}
s.Enabled = false
_, _ = st.UpdateStream(s)
}
eng.ReloadFromStore()
})
}
// newProxyServer exposes eng.Handler() on an httptest server (needed for websocket hijack).
func newProxyServer(t *testing.T, eng *Engine) *httptest.Server {
t.Helper()
srv := httptest.NewServer(eng.Handler())
t.Cleanup(srv.Close)
return srv
}
// dialWhoamiWS connects through a proxy front to the websocket upstream path.
// The vhost is embedded in the handshake URL (Host header); TCP dials proxyURL directly.
func dialWhoamiWS(t *testing.T, proxyURL, vhost, wsPath string, hdrs http.Header) *websocket.Conn {
t.Helper()
proxyU, err := url.Parse(proxyURL)
if err != nil {
t.Fatalf("proxy url: %v", err)
}
wsURL := "ws://" + vhost + wsPath
cfg, err := websocket.NewConfig(wsURL, "http://"+vhost+"/")
if err != nil {
t.Fatalf("ws config: %v", err)
}
if hdrs != nil {
cfg.Header = hdrs.Clone()
}
conn, err := net.DialTimeout("tcp", proxyU.Host, 2*time.Second)
if err != nil {
t.Fatalf("tcp dial %s: %v", proxyU.Host, err)
}
ws, err := websocket.NewClient(cfg, conn)
if err != nil {
_ = conn.Close()
t.Fatalf("ws handshake %s via %s: %v", wsURL, proxyU.Host, err)
}
t.Cleanup(func() { _ = ws.Close() })
return ws
}
// ReadStreamGreeting reads up to n bytes from conn before deadline.
func ReadStreamGreeting(t *testing.T, conn net.Conn, n int, timeout time.Duration) string {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, n)
got, err := io.ReadAtLeast(conn, buf, 1)
if err != nil {
t.Fatalf("stream read: %v", err)
}
return string(buf[:got])
}
+854
View File
@@ -0,0 +1,854 @@
package store
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"helix-proxy/internal/config"
"helix-proxy/internal/models"
"go.etcd.io/bbolt"
)
const (
bucketProxyHosts = "proxy_hosts"
bucketAccessLists = "access_lists"
bucketStreams = "streams"
bucketCertificates = "certificates"
bucketRedirectionHosts = "redirection_hosts"
bucketDeadHosts = "dead_hosts"
bucketUsers = "users"
bucketAuditLogs = "audit_logs"
bucketSettings = "settings"
bucketMeta = "meta"
)
type bboltStore struct {
db *bbolt.DB
path string
}
// NewBboltStore opens (or creates) the bbolt embedded database at the standard
// location (data/db.bolt by default, overridable via DATA_DIR etc).
func NewBboltStore() (Store, error) {
p := config.Resolve("db.bolt")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return nil, err
}
db, err := bbolt.Open(p, 0o600, &bbolt.Options{Timeout: 0})
if err != nil {
return nil, fmt.Errorf("open bbolt db: %w", err)
}
s := &bboltStore{db: db, path: p}
// Ensure all buckets exist
if err := s.createBuckets(); err != nil {
db.Close()
return nil, err
}
// First-run seed (admin user + default settings) if nothing exists
if !s.hasAnyUsers() {
if err := s.seedDefaults(); err != nil {
db.Close()
return nil, err
}
}
return s, nil
}
func (s *bboltStore) Close() error {
if s.db != nil {
return s.db.Close()
}
return nil
}
func (s *bboltStore) createBuckets() error {
return s.db.Update(func(tx *bbolt.Tx) error {
buckets := []string{
bucketProxyHosts, bucketAccessLists, bucketStreams, bucketCertificates,
bucketRedirectionHosts, bucketDeadHosts, bucketUsers, bucketAuditLogs,
bucketSettings, bucketMeta,
}
for _, name := range buckets {
if _, err := tx.CreateBucketIfNotExists([]byte(name)); err != nil {
return err
}
}
return nil
})
}
func (s *bboltStore) seedDefaults() error {
return s.db.Update(func(tx *bbolt.Tx) error {
// Seed admin user + default settings on first run (empty data dir)
admin := models.User{
ID: 1,
Email: "admin@example.com",
Name: "Admin",
Roles: []string{"admin"},
}
if err := s.putJSON(tx, bucketUsers, admin.ID, admin); err != nil {
return err
}
// Default settings
b := tx.Bucket([]byte(bucketSettings))
defaults := map[string]any{"default_site": "congratulations"}
jb, _ := json.Marshal(defaults)
b.Put([]byte("data"), jb)
// Advance user sequence
bu := tx.Bucket([]byte(bucketUsers))
bu.NextSequence() // advance past 1
return nil
})
}
func (s *bboltStore) hasAnyUsers() bool {
var count int
s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketUsers))
if b != nil {
count = b.Stats().KeyN
}
return nil
})
return count > 0
}
// --- helpers ---
func itob(i int) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(i))
return b
}
func btoi(b []byte) int {
if len(b) < 8 {
return 0
}
return int(binary.BigEndian.Uint64(b))
}
func (s *bboltStore) putJSON(tx *bbolt.Tx, bucket string, id int, v any) error {
b := tx.Bucket([]byte(bucket))
jb, err := json.Marshal(v)
if err != nil {
return err
}
return b.Put(itob(id), jb)
}
func (s *bboltStore) getJSON(tx *bbolt.Tx, bucket string, id int, out any) bool {
b := tx.Bucket([]byte(bucket))
if b == nil {
return false
}
data := b.Get(itob(id))
if data == nil {
return false
}
return json.Unmarshal(data, out) == nil
}
// --- Store interface implementation ---
func (s *bboltStore) IsSetup() bool {
var has bool
s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketUsers))
if b != nil {
has = b.Stats().KeyN > 0
}
return nil
})
return has
}
func (s *bboltStore) GetUsers() []models.User {
var out []models.User
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadUsers(tx)
return nil
})
return out
}
// --- ProxyHost ---
func (s *bboltStore) GetProxyHosts() []models.ProxyHost {
var out []models.ProxyHost
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadProxyHosts(tx)
return nil
})
return out
}
func (s *bboltStore) GetProxyHost(id int) (models.ProxyHost, bool) {
var h models.ProxyHost
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketProxyHosts, id, &h)
return nil
})
return h, h.ID != 0
}
func (s *bboltStore) CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketProxyHosts))
if h.ID == 0 {
seq, _ := b.NextSequence()
h.ID = int(seq)
}
if h.DomainNames == nil {
h.DomainNames = []string{}
}
if h.Meta == nil {
h.Meta = map[string]any{}
}
if h.CreatedOn == "" {
h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketProxyHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
if !s.getJSON(tx, bucketProxyHosts, h.ID, &models.ProxyHost{}) {
return errors.New("not found")
}
if err := validateProxyHostSources(s.loadProxyHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketProxyHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) DeleteProxyHost(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketProxyHosts))
return b.Delete(itob(id))
})
}
func (s *bboltStore) SetProxyHostEnabled(id int, enabled bool) error {
return s.db.Update(func(tx *bbolt.Tx) error {
var h models.ProxyHost
if !s.getJSON(tx, bucketProxyHosts, id, &h) {
return errors.New("not found")
}
h.Enabled = enabled
return s.putJSON(tx, bucketProxyHosts, id, h)
})
}
// --- AccessList ---
func (s *bboltStore) GetAccessLists() []models.AccessList {
var out []models.AccessList
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadAccessLists(tx)
return nil
})
return out
}
func (s *bboltStore) GetAccessList(id int) (models.AccessList, bool) {
var al models.AccessList
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketAccessLists, id, &al)
return nil
})
return al, al.ID != 0
}
func (s *bboltStore) CreateAccessList(al models.AccessList) (models.AccessList, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketAccessLists))
if al.ID == 0 {
seq, _ := b.NextSequence()
al.ID = int(seq)
}
if al.Clients == nil {
al.Clients = []models.AccessClient{}
}
if al.Items == nil {
al.Items = []models.AccessItem{}
}
if al.CreatedOn == "" {
al.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
return s.putJSON(tx, bucketAccessLists, al.ID, al)
})
return al, err
}
func (s *bboltStore) UpdateAccessList(al models.AccessList) (models.AccessList, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
return s.putJSON(tx, bucketAccessLists, al.ID, al)
})
return al, err
}
func (s *bboltStore) DeleteAccessList(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketAccessLists)).Delete(itob(id))
})
}
// --- Stream ---
func (s *bboltStore) GetStreams() []models.Stream {
var out []models.Stream
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadStreams(tx)
return nil
})
return out
}
func (s *bboltStore) GetStream(id int) (models.Stream, bool) {
var st models.Stream
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketStreams, id, &st)
return nil
})
return st, st.ID != 0
}
func (s *bboltStore) CreateStream(st models.Stream) (models.Stream, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketStreams))
if st.ID == 0 {
seq, _ := b.NextSequence()
st.ID = int(seq)
}
if st.Meta == nil {
st.Meta = map[string]any{}
}
if st.CreatedOn == "" {
st.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil {
return err
}
return s.putJSON(tx, bucketStreams, st.ID, st)
})
return st, err
}
func (s *bboltStore) UpdateStream(st models.Stream) (models.Stream, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
if !s.getJSON(tx, bucketStreams, st.ID, &models.Stream{}) {
return errors.New("not found")
}
if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil {
return err
}
return s.putJSON(tx, bucketStreams, st.ID, st)
})
return st, err
}
func (s *bboltStore) DeleteStream(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketStreams)).Delete(itob(id))
})
}
func (s *bboltStore) SetStreamEnabled(id int, enabled bool) error {
return s.db.Update(func(tx *bbolt.Tx) error {
var st models.Stream
if !s.getJSON(tx, bucketStreams, id, &st) {
return errors.New("not found")
}
st.Enabled = enabled
if enabled {
if err := validateStreamIncomingPort(st, s.loadStreams(tx)); err != nil {
return err
}
}
return s.putJSON(tx, bucketStreams, id, st)
})
}
// --- Certificate ---
func (s *bboltStore) GetCertificates() []models.Certificate {
var out []models.Certificate
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadCertificates(tx)
return nil
})
return out
}
func (s *bboltStore) GetCertificate(id int) (models.Certificate, bool) {
var c models.Certificate
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketCertificates, id, &c)
return nil
})
return c, c.ID != 0
}
func (s *bboltStore) CreateCertificate(c models.Certificate) (models.Certificate, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketCertificates))
if c.ID == 0 {
seq, _ := b.NextSequence()
c.ID = int(seq)
}
if c.DomainNames == nil {
c.DomainNames = []string{}
}
if c.Meta == nil {
c.Meta = map[string]any{}
}
if c.CreatedOn == "" {
c.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
return s.putJSON(tx, bucketCertificates, c.ID, c)
})
return c, err
}
func (s *bboltStore) SetCertificate(c models.Certificate) error {
return s.db.Update(func(tx *bbolt.Tx) error {
var existing models.Certificate
if s.getJSON(tx, bucketCertificates, c.ID, &existing) && c.CreatedOn == "" {
c.CreatedOn = existing.CreatedOn
}
return s.putJSON(tx, bucketCertificates, c.ID, c)
})
}
func (s *bboltStore) DeleteCertificate(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketCertificates)).Delete(itob(id))
})
}
// --- RedirectionHost ---
func (s *bboltStore) GetRedirectionHosts() []models.RedirectionHost {
var out []models.RedirectionHost
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadRedirectionHosts(tx)
return nil
})
return out
}
func (s *bboltStore) GetRedirectionHost(id int) (models.RedirectionHost, bool) {
var rh models.RedirectionHost
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketRedirectionHosts, id, &rh)
return nil
})
return rh, rh.ID != 0
}
func (s *bboltStore) CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketRedirectionHosts))
if h.ID == 0 {
seq, _ := b.NextSequence()
h.ID = int(seq)
}
if h.DomainNames == nil {
h.DomainNames = []string{}
}
if h.Meta == nil {
h.Meta = map[string]any{}
}
if h.CreatedOn == "" {
h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketRedirectionHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
if !s.getJSON(tx, bucketRedirectionHosts, h.ID, &models.RedirectionHost{}) {
return errors.New("not found")
}
if err := validateRedirectionHostSources(s.loadRedirectionHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketRedirectionHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) DeleteRedirectionHost(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketRedirectionHosts)).Delete(itob(id))
})
}
func (s *bboltStore) SetRedirectionHostEnabled(id int, enabled bool) error {
return s.db.Update(func(tx *bbolt.Tx) error {
var rh models.RedirectionHost
if !s.getJSON(tx, bucketRedirectionHosts, id, &rh) {
return errors.New("not found")
}
rh.Enabled = enabled
return s.putJSON(tx, bucketRedirectionHosts, id, rh)
})
}
// --- DeadHost ---
func (s *bboltStore) GetDeadHosts() []models.DeadHost {
var out []models.DeadHost
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadDeadHosts(tx)
return nil
})
return out
}
func (s *bboltStore) GetDeadHost(id int) (models.DeadHost, bool) {
var dh models.DeadHost
s.db.View(func(tx *bbolt.Tx) error {
s.getJSON(tx, bucketDeadHosts, id, &dh)
return nil
})
return dh, dh.ID != 0
}
func (s *bboltStore) CreateDeadHost(h models.DeadHost) (models.DeadHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketDeadHosts))
if h.ID == 0 {
seq, _ := b.NextSequence()
h.ID = int(seq)
}
if h.DomainNames == nil {
h.DomainNames = []string{}
}
if h.Meta == nil {
h.Meta = map[string]any{}
}
if h.CreatedOn == "" {
h.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05") // centralized server-set (no longer duplicated in main.go POST handlers)
}
if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketDeadHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) UpdateDeadHost(h models.DeadHost) (models.DeadHost, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
if !s.getJSON(tx, bucketDeadHosts, h.ID, &models.DeadHost{}) {
return errors.New("not found")
}
if err := validateDeadHostSources(s.loadDeadHosts(tx), h); err != nil {
return err
}
return s.putJSON(tx, bucketDeadHosts, h.ID, h)
})
return h, err
}
func (s *bboltStore) DeleteDeadHost(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketDeadHosts)).Delete(itob(id))
})
}
func (s *bboltStore) SetDeadHostEnabled(id int, enabled bool) error {
return s.db.Update(func(tx *bbolt.Tx) error {
var dh models.DeadHost
if !s.getJSON(tx, bucketDeadHosts, id, &dh) {
return errors.New("not found")
}
dh.Enabled = enabled
return s.putJSON(tx, bucketDeadHosts, id, dh)
})
}
// --- Settings ---
func (s *bboltStore) GetSettings() map[string]any {
var m map[string]any
s.db.View(func(tx *bbolt.Tx) error {
m = s.loadSettings(tx)
return nil
})
return m
}
func (s *bboltStore) SetSetting(name string, value any) error {
return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketSettings))
var current map[string]any
if data := b.Get([]byte("data")); data != nil {
json.Unmarshal(data, &current)
}
if current == nil {
current = map[string]any{}
}
current[name] = value
jb, _ := json.Marshal(current)
return b.Put([]byte("data"), jb)
})
}
// --- AuditLog ---
func (s *bboltStore) GetAuditLogs() []models.AuditLog {
var out []models.AuditLog
s.db.View(func(tx *bbolt.Tx) error {
out = s.loadAuditLogs(tx)
return nil
})
return out
}
func (s *bboltStore) AddAuditLog(log models.AuditLog) (models.AuditLog, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketAuditLogs))
if log.ID == 0 {
seq, _ := b.NextSequence()
log.ID = int(seq)
}
if log.Meta == nil {
log.Meta = map[string]any{}
}
if log.CreatedOn == "" {
// set now if caller did not provide (callers in main rarely do)
log.CreatedOn = time.Now().UTC().Format("2006-01-02 15:04:05")
}
return s.putJSON(tx, bucketAuditLogs, log.ID, log)
})
return log, err
}
// --- Users ---
func (s *bboltStore) GetUserByEmail(email string) (models.User, bool) {
var found models.User
s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketUsers))
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
var u models.User
if json.Unmarshal(v, &u) == nil && u.Email == email {
found = u
return nil
}
}
return nil
})
return found, found.ID != 0
}
func (s *bboltStore) CreateUser(u models.User) (models.User, error) {
err := s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte(bucketUsers))
if u.ID == 0 {
seq, _ := b.NextSequence()
u.ID = int(seq)
}
if u.Roles == nil {
u.Roles = []string{}
}
return s.putJSON(tx, bucketUsers, u.ID, u)
})
return u, err
}
func (s *bboltStore) UpdateUser(u models.User) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return s.putJSON(tx, bucketUsers, u.ID, u)
})
}
func (s *bboltStore) UpdateUserFull(u models.User) error {
return s.UpdateUser(u)
}
func (s *bboltStore) DeleteUser(id int) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket([]byte(bucketUsers)).Delete(itob(id))
})
}
// --- load helpers (used by Root and lists) ---
func (s *bboltStore) loadUsers(tx *bbolt.Tx) []models.User {
var out []models.User
b := tx.Bucket([]byte(bucketUsers))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var u models.User
if json.Unmarshal(v, &u) == nil {
out = append(out, u)
}
return nil
})
return out
}
func (s *bboltStore) loadProxyHosts(tx *bbolt.Tx) []models.ProxyHost {
var out []models.ProxyHost
b := tx.Bucket([]byte(bucketProxyHosts))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var h models.ProxyHost
if json.Unmarshal(v, &h) == nil {
out = append(out, h)
}
return nil
})
return out
}
func (s *bboltStore) loadAccessLists(tx *bbolt.Tx) []models.AccessList {
var out []models.AccessList
b := tx.Bucket([]byte(bucketAccessLists))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var al models.AccessList
if json.Unmarshal(v, &al) == nil {
out = append(out, al)
}
return nil
})
return out
}
func (s *bboltStore) loadStreams(tx *bbolt.Tx) []models.Stream {
var out []models.Stream
b := tx.Bucket([]byte(bucketStreams))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var st models.Stream
if json.Unmarshal(v, &st) == nil {
out = append(out, st)
}
return nil
})
return out
}
func (s *bboltStore) loadCertificates(tx *bbolt.Tx) []models.Certificate {
var out []models.Certificate
b := tx.Bucket([]byte(bucketCertificates))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var c models.Certificate
if json.Unmarshal(v, &c) == nil {
out = append(out, c)
}
return nil
})
return out
}
func (s *bboltStore) loadRedirectionHosts(tx *bbolt.Tx) []models.RedirectionHost {
var out []models.RedirectionHost
b := tx.Bucket([]byte(bucketRedirectionHosts))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var rh models.RedirectionHost
if json.Unmarshal(v, &rh) == nil {
out = append(out, rh)
}
return nil
})
return out
}
func (s *bboltStore) loadDeadHosts(tx *bbolt.Tx) []models.DeadHost {
var out []models.DeadHost
b := tx.Bucket([]byte(bucketDeadHosts))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var dh models.DeadHost
if json.Unmarshal(v, &dh) == nil {
out = append(out, dh)
}
return nil
})
return out
}
func (s *bboltStore) loadAuditLogs(tx *bbolt.Tx) []models.AuditLog {
var out []models.AuditLog
b := tx.Bucket([]byte(bucketAuditLogs))
if b == nil {
return out
}
b.ForEach(func(_, v []byte) error {
var a models.AuditLog
if json.Unmarshal(v, &a) == nil {
out = append(out, a)
}
return nil
})
return out
}
func (s *bboltStore) loadSettings(tx *bbolt.Tx) map[string]any {
b := tx.Bucket([]byte(bucketSettings))
if b == nil {
return map[string]any{}
}
data := b.Get([]byte("data"))
if data == nil {
return map[string]any{}
}
var m map[string]any
json.Unmarshal(data, &m)
if m == nil {
return map[string]any{}
}
return m
}
// Compile-time check
var _ Store = (*bboltStore)(nil)
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"errors"
"fmt"
"strings"
"helix-proxy/internal/models"
)
var (
ErrDuplicateProxySource = errors.New("duplicate proxy host source domain")
ErrDuplicateRedirectionSource = errors.New("duplicate redirection host source domain")
ErrDuplicateDeadSource = errors.New("duplicate dead host source domain")
ErrDuplicateStreamPort = errors.New("duplicate stream incoming port")
)
func IsConflictError(err error) bool {
return errors.Is(err, ErrDuplicateProxySource) ||
errors.Is(err, ErrDuplicateRedirectionSource) ||
errors.Is(err, ErrDuplicateDeadSource) ||
errors.Is(err, ErrDuplicateStreamPort)
}
type domainRecord struct {
ID int
DomainNames []string
}
func normalizeSourceDomain(d string) (normalized, display string, ok bool) {
display = strings.TrimSpace(d)
if display == "" {
return "", "", false
}
return strings.ToLower(display), display, true
}
func validateUniqueDomains(domainNames []string, selfID int, existing []domainRecord, errSentinel error) error {
own := make(map[string]string)
for _, d := range domainNames {
norm, display, ok := normalizeSourceDomain(d)
if !ok {
continue
}
if prev, dup := own[norm]; dup {
return fmt.Errorf("%w: duplicate source domain: %s", errSentinel, prev)
}
own[norm] = display
}
used := make(map[string]struct{})
for _, rec := range existing {
if rec.ID == selfID {
continue
}
for _, d := range rec.DomainNames {
norm, _, ok := normalizeSourceDomain(d)
if !ok {
continue
}
used[norm] = struct{}{}
}
}
for norm, display := range own {
if _, conflict := used[norm]; conflict {
return fmt.Errorf("%w: source domain already in use: %s", errSentinel, display)
}
}
return nil
}
func validateProxyHostSources(hosts []models.ProxyHost, h models.ProxyHost) error {
existing := make([]domainRecord, len(hosts))
for i, host := range hosts {
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
}
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateProxySource)
}
func validateRedirectionHostSources(hosts []models.RedirectionHost, h models.RedirectionHost) error {
existing := make([]domainRecord, len(hosts))
for i, host := range hosts {
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
}
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateRedirectionSource)
}
func validateDeadHostSources(hosts []models.DeadHost, h models.DeadHost) error {
existing := make([]domainRecord, len(hosts))
for i, host := range hosts {
existing[i] = domainRecord{ID: host.ID, DomainNames: host.DomainNames}
}
return validateUniqueDomains(h.DomainNames, h.ID, existing, ErrDuplicateDeadSource)
}
func validateStreamIncomingPort(st models.Stream, streams []models.Stream) error {
if st.IncomingPort <= 0 {
return nil
}
for _, other := range streams {
if other.ID == st.ID {
continue
}
if other.IncomingPort == st.IncomingPort {
return fmt.Errorf("%w: incoming port already in use: %d", ErrDuplicateStreamPort, st.IncomingPort)
}
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package store
import "helix-proxy/internal/models"
// Convenience type aliases so that code importing "helix-proxy/internal/store"
// can refer to the domain models as e.g. store.ProxyHost (historical API surface).
type (
Root = models.Root
User = models.User
ProxyHost = models.ProxyHost
HeaderRule = models.HeaderRule
Location = models.Location
AccessList = models.AccessList
AccessClient = models.AccessClient
AccessItem = models.AccessItem
Stream = models.Stream
Certificate = models.Certificate
RedirectionHost = models.RedirectionHost
DeadHost = models.DeadHost
AuditLog = models.AuditLog
)
// New returns the default Store implementation (bbolt, a pure-Go
// embedded transactional database). This is the recommended constructor for
// normal operation.
//
// It provides real ACID transactions and good concurrency while remaining a
// single static binary with no external database process.
func New() (Store, error) {
return NewBboltStore()
}
// Store is the interface for all persistent state in Helix Proxy.
//
// We use bbolt (pure-Go embedded transactional key/value store) as the one and only
// implementation. The interface exists primarily to:
// - Make the rest of the code (engine, cert manager, API handlers) easy to test with fakes.
// - Keep a clean boundary (in case we ever need to evolve the storage layer).
//
// All methods are safe for concurrent use.
//
// Models live in internal/models.
type Store interface {
// --- meta / bootstrap ---
IsSetup() bool
GetUsers() []models.User
// --- ProxyHost ---
GetProxyHosts() []models.ProxyHost
GetProxyHost(id int) (models.ProxyHost, bool)
CreateProxyHost(h models.ProxyHost) (models.ProxyHost, error)
UpdateProxyHost(h models.ProxyHost) (models.ProxyHost, error)
DeleteProxyHost(id int) error
SetProxyHostEnabled(id int, enabled bool) error
// --- AccessList ---
GetAccessLists() []models.AccessList
GetAccessList(id int) (models.AccessList, bool)
CreateAccessList(al models.AccessList) (models.AccessList, error)
UpdateAccessList(al models.AccessList) (models.AccessList, error)
DeleteAccessList(id int) error
// --- Stream ---
GetStreams() []models.Stream
GetStream(id int) (models.Stream, bool)
CreateStream(st models.Stream) (models.Stream, error)
UpdateStream(st models.Stream) (models.Stream, error)
DeleteStream(id int) error
SetStreamEnabled(id int, enabled bool) error
// --- Certificate ---
GetCertificates() []models.Certificate
GetCertificate(id int) (models.Certificate, bool)
CreateCertificate(c models.Certificate) (models.Certificate, error)
SetCertificate(c models.Certificate) error // used after LE issuance/renewal mutates meta+expires
DeleteCertificate(id int) error
// --- RedirectionHost ---
GetRedirectionHosts() []models.RedirectionHost
GetRedirectionHost(id int) (models.RedirectionHost, bool)
CreateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error)
UpdateRedirectionHost(h models.RedirectionHost) (models.RedirectionHost, error)
DeleteRedirectionHost(id int) error
SetRedirectionHostEnabled(id int, enabled bool) error
// --- DeadHost ---
GetDeadHosts() []models.DeadHost
GetDeadHost(id int) (models.DeadHost, bool)
CreateDeadHost(h models.DeadHost) (models.DeadHost, error)
UpdateDeadHost(h models.DeadHost) (models.DeadHost, error)
DeleteDeadHost(id int) error
SetDeadHostEnabled(id int, enabled bool) error
// --- Settings (global) ---
GetSettings() map[string]any
SetSetting(name string, value any) error
// --- Audit ---
GetAuditLogs() []models.AuditLog
AddAuditLog(log models.AuditLog) (models.AuditLog, error)
// --- Users + 2FA ---
GetUserByEmail(email string) (models.User, bool)
CreateUser(u models.User) (models.User, error)
UpdateUser(u models.User) error
UpdateUserFull(u models.User) error // used when updating without changing password
DeleteUser(id int) error
// GetUsers avoids needing to fetch the entire root for user lists.
}
+513
View File
@@ -0,0 +1,513 @@
package store
import (
"errors"
"reflect"
"strconv"
"sync"
"testing"
"helix-proxy/internal/config"
)
// --- test helpers ---
func newTestBbolt(t *testing.T) Store {
t.Helper()
tmp := t.TempDir()
config.ResetForTest()
t.Setenv("DATA_DIR", tmp)
s, err := NewBboltStore()
if err != nil {
t.Fatalf("NewBboltStore: %v", err)
}
return s
}
// closeIfBbolt helps cleanup for bbolt (interface doesn't have Close but impl does)
func closeIfBbolt(s Store) {
if c, ok := s.(interface{ Close() error }); ok {
c.Close()
}
}
func TestNew_SeedsAdminAndSettings(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
if !s.IsSetup() {
t.Error("IsSetup false after new")
}
users := s.GetUsers()
if len(users) != 1 || users[0].Email != "admin@example.com" || users[0].ID != 1 {
t.Errorf("seeded admin wrong: %+v", users)
}
settings := s.GetSettings()
if settings["default_site"] != "congratulations" {
t.Errorf("default settings wrong: %v", settings)
}
}
func TestIDGeneration(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
h1, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.com"}})
h2, _ := s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.com"}})
if h1.ID == 0 || h2.ID == 0 || h2.ID <= h1.ID {
t.Errorf("ids not increasing: %d %d", h1.ID, h2.ID)
}
if h1.CreatedOn == "" {
t.Error("CreateProxyHost did not populate CreatedOn")
}
al1, _ := s.CreateAccessList(AccessList{Name: "t1"})
if al1.CreatedOn == "" {
t.Error("CreateAccessList did not populate CreatedOn")
}
st1, _ := s.CreateStream(Stream{IncomingPort: 1})
if st1.CreatedOn == "" {
t.Error("CreateStream did not populate CreatedOn")
}
// explicit id should be honored? (current impls assign only if 0)
h3, _ := s.CreateProxyHost(ProxyHost{ID: 99, DomainNames: []string{"c.com"}})
if h3.ID != 99 {
t.Errorf("explicit id not kept: %d", h3.ID)
}
}
func TestProxyHostCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
// create
h := ProxyHost{
DomainNames: []string{"example.com", "www.example.com"},
ForwardScheme: "https",
ForwardHost: "10.0.0.1",
ForwardPort: 8443,
Enabled: true,
Meta: map[string]any{"foo": "bar"},
}
created, err := s.CreateProxyHost(h)
if err != nil {
t.Fatal(err)
}
if created.ID == 0 {
t.Fatal("no id assigned")
}
if !reflect.DeepEqual(created.DomainNames, h.DomainNames) || created.Meta["foo"] != "bar" {
t.Error("roundtrip fidelity lost on create")
}
// get list / get
list := s.GetProxyHosts()
if len(list) != 1 {
t.Errorf("list len %d", len(list))
}
got, ok := s.GetProxyHost(created.ID)
if !ok || got.ID != created.ID {
t.Error("get by id failed")
}
// update
created.ForwardHost = "10.0.0.2"
created.Meta["new"] = 42
up, err := s.UpdateProxyHost(created)
if err != nil {
t.Fatal(err)
}
if up.ForwardHost != "10.0.0.2" || up.Meta["new"] != 42 {
t.Error("update not persisted")
}
if up.CreatedOn == "" {
t.Error("UpdateProxyHost did not preserve CreatedOn")
}
// set enabled
if err := s.SetProxyHostEnabled(created.ID, false); err != nil {
t.Fatal(err)
}
g2, _ := s.GetProxyHost(created.ID)
if g2.Enabled {
t.Error("set enabled false failed")
}
// delete
if err := s.DeleteProxyHost(created.ID); err != nil {
t.Fatal(err)
}
if _, ok := s.GetProxyHost(created.ID); ok {
t.Error("delete did not remove")
}
}
func TestProxyHostDuplicateSources(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
h1, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}})
if err != nil {
t.Fatal(err)
}
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"a.example.com"}})
if !errors.Is(err, ErrDuplicateProxySource) {
t.Fatalf("expected duplicate error on cross-host conflict, got %v", err)
}
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"A.EXAMPLE.COM"}})
if !errors.Is(err, ErrDuplicateProxySource) {
t.Fatalf("expected duplicate error on case-insensitive conflict, got %v", err)
}
_, err = s.CreateProxyHost(ProxyHost{DomainNames: []string{"b.example.com", "b.example.com"}})
if !errors.Is(err, ErrDuplicateProxySource) {
t.Fatalf("expected duplicate error within same host, got %v", err)
}
h1.DomainNames = []string{"a.example.com", "c.example.com"}
if _, err = s.UpdateProxyHost(h1); err != nil {
t.Fatalf("update own domains: %v", err)
}
h2, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"d.example.com"}})
if err != nil {
t.Fatal(err)
}
h2.DomainNames = []string{"c.example.com"}
_, err = s.UpdateProxyHost(h2)
if !errors.Is(err, ErrDuplicateProxySource) {
t.Fatalf("expected duplicate error on update conflict, got %v", err)
}
}
func TestRedirectionHostDuplicateSources(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
_, err := s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}})
if err != nil {
t.Fatal(err)
}
_, err = s.CreateRedirectionHost(RedirectionHost{DomainNames: []string{"redir.example.com"}})
if !errors.Is(err, ErrDuplicateRedirectionSource) {
t.Fatalf("expected duplicate error, got %v", err)
}
}
func TestDeadHostDuplicateSources(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
_, err := s.CreateDeadHost(DeadHost{DomainNames: []string{"dead.example.com"}})
if err != nil {
t.Fatal(err)
}
_, err = s.CreateDeadHost(DeadHost{DomainNames: []string{"DEAD.example.com"}})
if !errors.Is(err, ErrDuplicateDeadSource) {
t.Fatalf("expected duplicate error, got %v", err)
}
}
func TestStreamDuplicateIncomingPort(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
_, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
if err != nil {
t.Fatal(err)
}
_, err = s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 8080})
if !errors.Is(err, ErrDuplicateStreamPort) {
t.Fatalf("expected duplicate port error, got %v", err)
}
}
func TestStreamUpdateDuplicateIncomingPort(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
a, err := s.CreateStream(Stream{IncomingPort: 9001, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
if err != nil {
t.Fatal(err)
}
b, err := s.CreateStream(Stream{IncomingPort: 9002, ForwardingHost: "127.0.0.1", ForwardingPort: 80})
if err != nil {
t.Fatal(err)
}
b.IncomingPort = 9001
_, err = s.UpdateStream(b)
if !errors.Is(err, ErrDuplicateStreamPort) {
t.Fatalf("expected duplicate port error on update, got %v", err)
}
_ = a
}
func TestAccessListCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
al := AccessList{
Name: "test al",
SatisfyAny: true,
Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}},
Items: []AccessItem{{Username: "u", Password: "p"}},
PassAuth: true,
}
created, err := s.CreateAccessList(al)
if err != nil || created.ID == 0 {
t.Fatalf("create: %v", err)
}
if len(created.Clients) != 1 || len(created.Items) != 1 {
t.Error("clients/items nilled?")
}
// update
created.Name = "renamed"
up, err := s.UpdateAccessList(created)
if err != nil || up.Name != "renamed" {
t.Fatalf("update: %v", err)
}
if up.CreatedOn == "" {
t.Error("UpdateAccessList did not preserve CreatedOn")
}
if err := s.DeleteAccessList(created.ID); err != nil {
t.Fatal(err)
}
if _, ok := s.GetAccessList(created.ID); ok {
t.Error("not deleted")
}
}
func TestStreamCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
st := Stream{
IncomingPort: 1234,
ForwardingHost: "127.0.0.1",
ForwardingPort: 5678,
TCPForwarding: true,
Enabled: true,
Meta: map[string]any{"k": 1},
}
c, err := s.CreateStream(st)
if err != nil {
t.Fatal(err)
}
if c.ID == 0 || c.Meta == nil {
t.Error("id or meta")
}
c.Enabled = false
up, _ := s.UpdateStream(c)
if up.CreatedOn == "" {
t.Error("UpdateStream did not preserve CreatedOn")
}
if err := s.SetStreamEnabled(c.ID, true); err != nil {
t.Fatal(err)
}
g, _ := s.GetStream(c.ID)
if !g.Enabled {
t.Error("set enabled")
}
_ = s.DeleteStream(c.ID)
}
func TestCertificateCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
c := Certificate{
Provider: "letsencrypt",
DomainNames: []string{"ex.com"},
Meta: map[string]any{"cert": "pem"},
}
created, err := s.CreateCertificate(c)
if err != nil || len(created.DomainNames) == 0 || created.Meta == nil {
t.Fatal(err)
}
created.ExpiresOn = "2099-01-01"
origCreated := created.CreatedOn
if err := s.SetCertificate(created); err != nil {
t.Fatal(err)
}
g, _ := s.GetCertificate(created.ID)
if g.ExpiresOn != "2099-01-01" {
t.Error("set cert")
}
if g.CreatedOn != origCreated {
t.Error("SetCertificate did not preserve CreatedOn")
}
_ = s.DeleteCertificate(created.ID)
}
func TestRedirectionDeadHostCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
rh := RedirectionHost{DomainNames: []string{"r.com"}, ForwardDomainName: "t.com", Enabled: true, Meta: map[string]any{}}
crh, _ := s.CreateRedirectionHost(rh)
crh.ForwardHTTPCode = 301
rup, _ := s.UpdateRedirectionHost(crh)
if rup.CreatedOn == "" {
t.Error("UpdateRedirectionHost did not preserve CreatedOn")
}
_ = s.SetRedirectionHostEnabled(crh.ID, false)
_ = s.DeleteRedirectionHost(crh.ID)
dh := DeadHost{DomainNames: []string{"d.com"}, Enabled: true, Meta: map[string]any{}}
cdh, _ := s.CreateDeadHost(dh)
dup, _ := s.UpdateDeadHost(cdh)
if dup.CreatedOn == "" {
t.Error("UpdateDeadHost did not preserve CreatedOn")
}
_ = s.SetDeadHostEnabled(cdh.ID, false)
_ = s.DeleteDeadHost(cdh.ID)
}
func TestSettingsAudit(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
if err := s.SetSetting("foo", "bar"); err != nil {
t.Fatal(err)
}
m := s.GetSettings()
if m["foo"] != "bar" {
t.Errorf("set/get setting: %v", m)
}
log := AuditLog{UserID: 1, ObjectType: "test", ObjectID: 99, Action: "create", Meta: map[string]any{"x": 1}}
added, err := s.AddAuditLog(log)
if err != nil || added.ID == 0 {
t.Fatalf("add audit: %v", err)
}
if added.CreatedOn == "" {
t.Error("AddAuditLog did not populate CreatedOn")
}
logs := s.GetAuditLogs()
if len(logs) < 1 || logs[len(logs)-1].CreatedOn == "" {
t.Error("no audit logs or missing CreatedOn")
}
}
func TestUsersCRUD(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
u := User{Email: "new@example.com", Name: "New", Roles: []string{"user"}, Password: "pw"}
created, err := s.CreateUser(u)
if err != nil || created.ID == 0 {
t.Fatal(err)
}
created.Name = "Renamed"
if err := s.UpdateUser(created); err != nil {
t.Fatal(err)
}
if err := s.UpdateUserFull(created); err != nil {
t.Fatal(err)
}
by, ok := s.GetUserByEmail("new@example.com")
if !ok || by.Name != "Renamed" {
t.Error("get by email or update")
}
if err := s.DeleteUser(created.ID); err != nil {
t.Fatal(err)
}
}
func TestGetUsersAndIsSetup(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
users := s.GetUsers()
if len(users) == 0 {
t.Error("no users")
}
if !s.IsSetup() {
t.Error("not setup")
}
}
func TestFidelityMetaSlices(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
ph := ProxyHost{DomainNames: nil, Meta: nil}
c, _ := s.CreateProxyHost(ph)
// impls normalize to non-nil
if c.DomainNames == nil || c.Meta == nil {
t.Error("should have non-nil slices/maps after create")
}
g, _ := s.GetProxyHost(c.ID)
if g.DomainNames == nil {
t.Error("get returned nil domains")
}
// audit fidelity (CreatedOn + Meta)
alog := AuditLog{UserID: 1, ObjectType: "t", ObjectID: 1, Action: "a", Meta: map[string]any{"m": 2}}
added, _ := s.AddAuditLog(alog)
if added.CreatedOn == "" || added.Meta["m"] != 2 {
t.Error("audit CreatedOn/Meta fidelity")
}
gots := s.GetAuditLogs()
if len(gots) == 0 || gots[len(gots)-1].Meta == nil {
t.Error("get audit meta nil")
}
// access list nested slices fidelity
al := AccessList{Name: "al", Clients: []AccessClient{{Address: "1.2.3.4", Directive: "allow"}}, Items: []AccessItem{{Username: "u", Password: "p"}}}
ca, _ := s.CreateAccessList(al)
if len(ca.Clients) == 0 || len(ca.Items) == 0 {
t.Error("access nested create")
}
ga, _ := s.GetAccessList(ca.ID)
if len(ga.Items) == 0 || len(ga.Clients) == 0 {
t.Error("access nested get")
}
}
func TestConcurrency(t *testing.T) {
s := newTestBbolt(t)
defer closeIfBbolt(s)
var wg sync.WaitGroup
n := 20
var mu sync.Mutex
ids := []int{}
errs := []error{}
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
h, err := s.CreateProxyHost(ProxyHost{DomainNames: []string{"c" + strconv.Itoa(i) + ".com"}})
_ = s.SetSetting("k", i)
_, _ = s.AddAuditLog(AuditLog{Action: "cc"})
mu.Lock()
ids = append(ids, h.ID)
if err != nil {
errs = append(errs, err)
}
mu.Unlock()
}(i)
}
wg.Wait()
if len(errs) > 0 {
t.Errorf("conc errs: %v", errs)
}
if len(ids) != n {
t.Errorf("expected %d creates, got %d", n, len(ids))
}
seen := map[int]bool{}
for _, id := range ids {
if seen[id] {
t.Errorf("dup ID %d in conc creates", id)
}
seen[id] = true
}
// final fidelity post-conc
final := s.GetProxyHosts()
if len(final) != n {
t.Errorf("post-conc hosts count %d != %d", len(final), n)
}
}
+82
View File
@@ -0,0 +1,82 @@
package version
import (
"strconv"
"strings"
)
// Version and Commit are injected separately at link time via -ldflags (see Makefile / Dockerfile).
// Version is the exact git tag on HEAD (empty if untagged). Commit is the short git hash (empty if unavailable).
var (
Version = ""
Commit = ""
)
func resolve() string {
if isSemverTag(Version) {
return Version
}
if Commit != "" {
return Commit
}
return "dev"
}
func String() string {
return resolve()
}
func Display() string {
return resolve()
}
// isSemverTag reports whether tag looks like semver (optional "v" + major.minor.patch, pre-release suffix allowed).
func isSemverTag(tag string) bool {
if tag == "" {
return false
}
core := tag
if i := strings.IndexAny(core, "-+"); i >= 0 {
core = core[:i]
}
core = strings.TrimPrefix(core, "v")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, c := range p {
if c < '0' || c > '9' {
return false
}
}
}
return true
}
// Parts splits the git tag (Version) into major/minor/revision for NPM-style API responses.
// Returns zeros when there is no semver tag (commit-only or dev builds).
func Parts() (major, minor, revision int) {
if !isSemverTag(Version) {
return 0, 0, 0
}
core := Version
if i := strings.IndexAny(core, "-+"); i >= 0 {
core = core[:i]
}
core = strings.TrimPrefix(core, "v")
parts := strings.Split(core, ".")
if len(parts) != 3 {
return 0, 0, 0
}
major, err0 := strconv.Atoi(parts[0])
minor, err1 := strconv.Atoi(parts[1])
revision, err2 := strconv.Atoi(parts[2])
if err0 != nil || err1 != nil || err2 != nil {
return 0, 0, 0
}
return major, minor, revision
}
+61
View File
@@ -0,0 +1,61 @@
package version
import "testing"
func TestResolve(t *testing.T) {
tests := []struct {
tag, commit, want string
}{
{"v1.0.0", "abc1234", "v1.0.0"},
{"1.2.3", "abc1234", "1.2.3"},
{"v1.0.0-rc.1", "abc1234", "v1.0.0-rc.1"},
{"release-candidate", "abc1234", "abc1234"},
{"latest", "abc1234", "abc1234"},
{"v1.2", "abc1234", "abc1234"},
{"", "abc1234", "abc1234"},
{"", "", "dev"},
}
for _, tc := range tests {
Version = tc.tag
Commit = tc.commit
if got := String(); got != tc.want {
t.Errorf("resolve(tag=%q, commit=%q) = %q; want %q", tc.tag, tc.commit, got, tc.want)
}
}
}
func TestIsSemverTag(t *testing.T) {
ok := []string{"v1.0.0", "1.2.3", "v0.0.1", "2.10.99", "v1.0.0-rc.1"}
bad := []string{"", "latest", "release-1", "v1.2", "1.2", "abc1234", "v1.2.3.4"}
for _, tag := range ok {
if !isSemverTag(tag) {
t.Errorf("isSemverTag(%q) = false; want true", tag)
}
}
for _, tag := range bad {
if isSemverTag(tag) {
t.Errorf("isSemverTag(%q) = true; want false", tag)
}
}
}
func TestParts(t *testing.T) {
tests := []struct {
tag string
major, minor, rev int
}{
{"v1.2.3", 1, 2, 3},
{"1.2.3", 1, 2, 3},
{"v1.2.3-rc1", 1, 2, 3},
{"", 0, 0, 0},
{"abc1234", 0, 0, 0},
{"1.2", 0, 0, 0},
}
for _, tc := range tests {
Version = tc.tag
major, minor, rev := Parts()
if major != tc.major || minor != tc.minor || rev != tc.rev {
t.Errorf("Parts(%q) = %d,%d,%d; want %d,%d,%d", tc.tag, major, minor, rev, tc.major, tc.minor, tc.rev)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404 Not Found</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
line-height: 1.5;
max-width: 42rem;
margin: 4rem auto;
padding: 0 1.5rem;
color: #e4e4e7;
background: #09090b;
}
h1 { font-size: 1.75rem; font-weight: 600; margin: 0 0 0.75rem; color: #fafafa; }
p { margin: 0.5rem 0; color: #a1a1aa; }
.host { color: #f87171; font-weight: 500; }
</style>
</head>
<body>
<h1>404 Not Found</h1>
<p>{{MESSAGE}}</p>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
package www
import "embed"
// Default pages for unmatched hosts and shared error responses.
// Override by placing the same filename under WWW_DIR (default: data/www/).
//
//go:embed index.html 404.html
var defaults embed.FS
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to Helix Proxy</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
line-height: 1.5;
max-width: 42rem;
margin: 4rem auto;
padding: 0 1.5rem;
color: #e4e4e7;
background: #09090b;
}
h1 { font-size: 1.75rem; font-weight: 600; margin: 0 0 0.75rem; color: #fafafa; }
p { margin: 0.5rem 0; color: #a1a1aa; }
code { background: #18181b; padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.9em; }
.host { color: #34d399; font-weight: 500; }
</style>
</head>
<body>
<h1>Welcome to Helix Proxy</h1>
<p>No virtual host is configured for <span class="host">{{HOST}}</span>.</p>
<p>Open the admin UI to add proxy hosts, certificates, and streams.</p>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
package www
import (
"os"
"path/filepath"
"strings"
"helix-proxy/internal/config"
)
// LoadPage returns HTML for name (e.g. "index.html", "404.html").
// A file in WWW_DIR with the same name overrides the embedded default.
// Placeholders use the form {{KEY}} and are replaced from vars.
func LoadPage(name string, vars map[string]string) []byte {
wwwDir := config.Resolve("www")
customPath := filepath.Join(wwwDir, name)
data, err := os.ReadFile(customPath)
if err != nil {
data, err = defaults.ReadFile(name)
if err != nil {
return nil
}
}
if len(vars) == 0 {
return data
}
s := string(data)
for k, v := range vars {
s = strings.ReplaceAll(s, "{{"+k+"}}", v)
}
return []byte(s)
}
+39
View File
@@ -0,0 +1,39 @@
package www
import (
"os"
"path/filepath"
"strings"
"testing"
"helix-proxy/internal/config"
)
func TestLoadPageEmbeddedDefault(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
page := LoadPage("index.html", map[string]string{"HOST": "test.example"})
if page == nil {
t.Fatal("expected embedded index.html")
}
body := string(page)
if !strings.Contains(body, "test.example") || !strings.Contains(body, "Welcome to Helix Proxy") {
t.Fatalf("unexpected page: %s", page)
}
}
func TestLoadPageWWWOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
wwwDir := config.Resolve("www")
if err := os.MkdirAll(wwwDir, 0o755); err != nil {
t.Fatal(err)
}
custom := []byte("<h1>CUSTOM {{HOST}}</h1>")
if err := os.WriteFile(filepath.Join(wwwDir, "index.html"), custom, 0o644); err != nil {
t.Fatal(err)
}
page := LoadPage("index.html", map[string]string{"HOST": "override.example"})
if string(page) != "<h1>CUSTOM override.example</h1>" {
t.Fatalf("override failed: %q", page)
}
}