Initial vibeproxy implementation
Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ¬Before,
|
||||
NotAfter: ¬After,
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user