Initial vibeproxy implementation

Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
2026-08-17 10:28:59 -05:00
co-authored by Claude Sonnet 5
commit ba1f7c4c03
105 changed files with 9142 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
package certificates
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"github.com/go-acme/lego/v4/registration"
)
// acmeUser implements lego's registration.User for our single ACME
// account. The private key never leaves this process and is stored on
// disk with 0600 permissions, separate from any issued certificate keys.
type acmeUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func newACMEUser(email string, key crypto.PrivateKey) *acmeUser {
return &acmeUser{Email: email, key: key}
}
func (u *acmeUser) GetEmail() string { return u.Email }
func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func newACMEAccountKey() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
func writeECDSAKeyPEM(path string, key *ecdsa.PrivateKey) error {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return err
}
block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}
return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
}
func readECDSAKeyPEM(path string) (*ecdsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("invalid PEM account key")
}
return x509.ParseECPrivateKey(block.Bytes)
}
+56
View File
@@ -0,0 +1,56 @@
package certificates
import (
"net/http"
"strings"
"sync"
)
// httpChallengeProvider implements lego's challenge.Provider for HTTP-01,
// backed by an in-memory token map served by our own HTTP listener at
// /.well-known/acme-challenge/. This avoids spinning up a second server on
// port 80 — the main proxy HTTP listener delegates to ServeHTTP below.
type httpChallengeProvider struct {
mu sync.RWMutex
tokens map[string]string // token -> key authorization
}
func newHTTPChallengeProvider() *httpChallengeProvider {
return &httpChallengeProvider{tokens: map[string]string{}}
}
func (p *httpChallengeProvider) Present(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.tokens[token] = keyAuth
return nil
}
func (p *httpChallengeProvider) CleanUp(domain, token, keyAuth string) error {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.tokens, token)
return nil
}
const acmeChallengePrefix = "/.well-known/acme-challenge/"
// ServeHTTP handles ACME HTTP-01 challenge requests. Returns true if the
// request was an ACME challenge (handled or not found), false if the
// caller should continue normal routing.
func (p *httpChallengeProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasPrefix(r.URL.Path, acmeChallengePrefix) {
return false
}
token := strings.TrimPrefix(r.URL.Path, acmeChallengePrefix)
p.mu.RLock()
keyAuth, ok := p.tokens[token]
p.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return true
}
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(keyAuth))
return true
}
+335
View File
@@ -0,0 +1,335 @@
// Package certificates manages TLS certificates: Let's Encrypt issuance
// and renewal via lego (a proper ACME client library — we never speak the
// ACME protocol ourselves), plus custom uploaded certificates. It serves
// SNI lookups for the HTTPS listener from an in-memory cache and persists
// metadata to SQLite via the database package. Private key material only
// ever touches disk (0600) and is never logged.
package certificates
import (
"context"
"crypto/ecdsa"
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
"github.com/owen/vibeproxy/internal/database"
"github.com/owen/vibeproxy/internal/models"
)
const (
letsEncryptProdURL = "https://acme-v02.api.letsencrypt.org/directory"
letsEncryptStagingURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
)
type Manager struct {
db *sql.DB
storage *storage
log *slog.Logger
httpProvider *httpChallengeProvider
mu sync.RWMutex
byDom map[string]*tls.Certificate // lowercase domain -> cert
onChange func()
}
func NewManager(db *sql.DB, storagePath string, log *slog.Logger) (*Manager, error) {
st, err := newStorage(storagePath)
if err != nil {
return nil, err
}
return &Manager{
db: db,
storage: st,
log: log,
httpProvider: newHTTPChallengeProvider(),
byDom: map[string]*tls.Certificate{},
}, nil
}
// ServeChallenge handles ACME HTTP-01 requests on the plain HTTP listener.
// Returns true if the request was handled (was an ACME challenge path).
func (m *Manager) ServeChallenge(w http.ResponseWriter, r *http.Request) bool {
return m.httpProvider.ServeHTTP(w, r)
}
func (m *Manager) SetOnChange(f func()) { m.onChange = f }
// GetCertificateForDomain implements SNI lookup for tls.Config.GetCertificate.
func (m *Manager) GetCertificateForDomain(domain string) (*tls.Certificate, error) {
domain = strings.ToLower(domain)
m.mu.RLock()
defer m.mu.RUnlock()
if c, ok := m.byDom[domain]; ok {
return c, nil
}
if idx := strings.IndexByte(domain, '.'); idx >= 0 {
wildcard := "*" + domain[idx:]
if c, ok := m.byDom[wildcard]; ok {
return c, nil
}
}
return nil, fmt.Errorf("no certificate for domain %q", domain)
}
// LoadAll reads every stored certificate row, loads its key material from
// disk, and rebuilds the SNI cache. Call at startup and after any
// issuance/upload/deletion.
func (m *Manager) LoadAll() error {
certs, err := database.ListCertificates(m.db)
if err != nil {
return err
}
newMap := map[string]*tls.Certificate{}
for _, c := range certs {
if c.CertPath == "" || c.KeyPath == "" {
continue
}
tlsCert, err := loadTLSCertificate(c.CertPath, c.KeyPath)
if err != nil {
m.log.Warn("failed to load certificate", "name", c.Name, "err", err)
continue
}
for _, d := range c.Domains {
newMap[strings.ToLower(d)] = tlsCert
}
}
m.mu.Lock()
m.byDom = newMap
m.mu.Unlock()
if m.onChange != nil {
m.onChange()
}
return nil
}
// UploadCustom validates and stores a custom certificate/key pair.
func (m *Manager) UploadCustom(name string, domains []string, certPEM, keyPEM []byte) (*models.Certificate, error) {
leaf, err := ValidateKeyPair(certPEM, keyPEM)
if err != nil {
return nil, err
}
certPath, keyPath, err := m.storage.SavePair(name, certPEM, keyPEM)
if err != nil {
return nil, err
}
notBefore, notAfter := leaf.NotBefore, leaf.NotAfter
rec := &models.Certificate{
Name: name,
Type: models.CertTypeCustom,
Domains: domains,
CertPath: certPath,
KeyPath: keyPath,
NotBefore: &notBefore,
NotAfter: &notAfter,
AutoRenew: false,
}
if _, err := database.SaveCertificate(m.db, rec); err != nil {
return nil, err
}
if err := m.LoadAll(); err != nil {
return nil, err
}
return rec, nil
}
// IssueLetsEncryptOptions configures a new ACME issuance.
type IssueLetsEncryptOptions struct {
Name string
Domains []string
Email string
Staging bool
ChallengeType models.ChallengeType
DNSProvider string // e.g. "cloudflare"; required for dns-01
}
// IssueLetsEncrypt requests a new certificate from Let's Encrypt and
// persists it. HTTP-01 challenges are served via ServeChallenge on our own
// HTTP listener (port 80 must be reachable from the internet); DNS-01 uses
// the configured DNS provider and also supports wildcard domains.
func (m *Manager) IssueLetsEncrypt(ctx context.Context, opts IssueLetsEncryptOptions) (*models.Certificate, error) {
if opts.Email == "" {
return nil, errors.New("an ACME contact email is required (configure it under Settings)")
}
accountKey, err := m.loadOrCreateAccountKey()
if err != nil {
return nil, err
}
user := newACMEUser(opts.Email, accountKey)
cfg := lego.NewConfig(user)
if opts.Staging {
cfg.CADirURL = letsEncryptStagingURL
} else {
cfg.CADirURL = letsEncryptProdURL
}
cfg.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(cfg)
if err != nil {
return nil, err
}
if opts.ChallengeType == models.ChallengeDNS01 {
provider, err := m.dnsProvider(opts.DNSProvider)
if err != nil {
return nil, err
}
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
return nil, err
}
} else {
if err := client.Challenge.SetHTTP01Provider(m.httpProvider); err != nil {
return nil, err
}
}
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("acme registration: %w", err)
}
user.Registration = reg
res, err := client.Certificate.Obtain(certificate.ObtainRequest{
Domains: opts.Domains,
Bundle: true,
})
if err != nil {
return nil, fmt.Errorf("acme obtain: %w", err)
}
certPath, keyPath, err := m.storage.SavePair(opts.Name, res.Certificate, res.PrivateKey)
if err != nil {
return nil, err
}
var notBefore, notAfter *time.Time
if block, _ := pem.Decode(res.Certificate); block != nil {
if leaf, err := x509.ParseCertificate(block.Bytes); err == nil {
nb, na := leaf.NotBefore, leaf.NotAfter
notBefore, notAfter = &nb, &na
}
}
now := time.Now()
rec := &models.Certificate{
Name: opts.Name,
Type: models.CertTypeLetsEncrypt,
Domains: opts.Domains,
ChallengeType: opts.ChallengeType,
DNSProvider: opts.DNSProvider,
CertPath: certPath,
KeyPath: keyPath,
NotBefore: notBefore,
NotAfter: notAfter,
LastRenewedAt: &now,
AutoRenew: true,
}
if _, err := database.SaveCertificate(m.db, rec); err != nil {
return nil, err
}
if err := m.LoadAll(); err != nil {
return nil, err
}
return rec, nil
}
func (m *Manager) dnsProvider(name string) (challenge.Provider, error) {
switch name {
case "cloudflare":
return cloudflare.NewDNSProvider()
default:
return nil, fmt.Errorf("unsupported DNS provider %q (supported: cloudflare; set its env vars, or use HTTP-01 instead)", name)
}
}
// RenewDue re-issues any Let's Encrypt certificate whose expiry falls
// inside the configured renewal window. Returns the number renewed.
func (m *Manager) RenewDue(ctx context.Context, renewalWindowDays int, email string, staging bool) (int, error) {
certs, err := database.ListCertificates(m.db)
if err != nil {
return 0, err
}
renewed := 0
cutoff := time.Now().Add(time.Duration(renewalWindowDays) * 24 * time.Hour)
for _, c := range certs {
if c.Type != models.CertTypeLetsEncrypt || !c.AutoRenew {
continue
}
if c.NotAfter == nil || c.NotAfter.After(cutoff) {
continue
}
if _, err := m.IssueLetsEncrypt(ctx, IssueLetsEncryptOptions{
Name: c.Name,
Domains: c.Domains,
Email: email,
Staging: staging,
ChallengeType: c.ChallengeType,
DNSProvider: c.DNSProvider,
}); err != nil {
c.LastError = err.Error()
database.SaveCertificate(m.db, &c)
m.log.Error("certificate renewal failed", "name", c.Name, "err", err)
continue
}
renewed++
}
return renewed, nil
}
// StartRenewalLoop runs RenewDue once a day until ctx is cancelled.
func (m *Manager) StartRenewalLoop(ctx context.Context, renewalWindowDays int, email string, staging bool) {
ticker := time.NewTicker(24 * time.Hour)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := m.RenewDue(ctx, renewalWindowDays, email, staging); err != nil {
m.log.Error("renewal loop error", "err", err)
} else if n > 0 {
m.log.Info("renewed certificates", "count", n)
}
}
}
}()
}
func (m *Manager) loadOrCreateAccountKey() (*ecdsa.PrivateKey, error) {
path := m.storage.accountKeyPath()
if _, err := os.Stat(path); err == nil {
return readECDSAKeyPEM(path)
}
key, err := newACMEAccountKey()
if err != nil {
return nil, err
}
if err := writeECDSAKeyPEM(path, key); err != nil {
return nil, err
}
return key, nil
}
func DeleteCertificate(db *sql.DB, id int64) error {
return database.DeleteCertificate(db, id)
}
+104
View File
@@ -0,0 +1,104 @@
package certificates
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
)
// storage manages certificate/key material on disk. Private keys are
// written with 0600 permissions and their directories with 0700; nothing
// under here is ever written to a log.
type storage struct {
root string
}
func newStorage(root string) (*storage, error) {
if err := os.MkdirAll(filepath.Join(root, "accounts"), 0o700); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(root, "certs"), 0o700); err != nil {
return nil, err
}
return &storage{root: root}, nil
}
func (s *storage) certDir(name string) string {
return filepath.Join(s.root, "certs", safeName(name))
}
func safeName(name string) string {
out := make([]rune, 0, len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) == 0 {
return "cert"
}
return string(out)
}
// SavePair writes fullchain.pem and privkey.pem for the given logical
// certificate name and returns their paths.
func (s *storage) SavePair(name string, certPEM, keyPEM []byte) (certPath, keyPath string, err error) {
dir := s.certDir(name)
if err = os.MkdirAll(dir, 0o700); err != nil {
return
}
certPath = filepath.Join(dir, "fullchain.pem")
keyPath = filepath.Join(dir, "privkey.pem")
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
return
}
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return
}
return
}
func (s *storage) accountKeyPath() string {
return filepath.Join(s.root, "accounts", "account.key")
}
func loadTLSCertificate(certPath, keyPath string) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
if len(cert.Certificate) == 0 {
return nil, errors.New("empty certificate chain")
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
cert.Leaf = leaf
return &cert, nil
}
// ValidateKeyPair checks that certPEM/keyPEM parse and form a matching
// pair before anything is written to disk.
func ValidateKeyPair(certPEM, keyPEM []byte) (*x509.Certificate, error) {
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("certificate/key do not match: %w", err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, errors.New("no PEM data found in certificate")
}
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return nil, err
}
return leaf, nil
}