Browse Source
- Define Transport interface (Name, Start, Stop, Addresses) at pkg/interfaces/transport/ - Add Transport Manager with ordered startup/shutdown at pkg/transport/ - Create TCP transport (pkg/transport/tcp/) wrapping plain http.Server - Create TLS transport (pkg/transport/tls/) with ACME + manual certs - Create Tor transport (pkg/transport/tor/) wrapping existing pkg/tor service - Replace ~220 lines of inline transport code in app/main.go with manager - Simplify shutdown handler to single transportMgr.StopAll() call - Use transportMgr.Addresses() for NIP-11 relay info (replaces torService) Files modified: - pkg/interfaces/transport/transport.go: New transport interface - pkg/transport/manager.go: New transport manager - pkg/transport/tcp/tcp.go: New TCP transport - pkg/transport/tls/tls.go: New TLS/ACME transport (moved from app/tls.go) - pkg/transport/tor/tor.go: New Tor transport wrapper - app/main.go: Use transport manager for all networking - app/server.go: Replace torService field with transportMgr - app/handle-relayinfo.go: Use transportMgr.Addresses() - app/tls.go: Deleted (moved to pkg/transport/tls/) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>imwald-v0.58.10
9 changed files with 557 additions and 258 deletions
@ -1,132 +0,0 @@
@@ -1,132 +0,0 @@
|
||||
package app |
||||
|
||||
import ( |
||||
"crypto/tls" |
||||
"crypto/x509" |
||||
"fmt" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"golang.org/x/crypto/acme/autocert" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// TLSConfig returns a TLS configuration that works with LetsEncrypt automatic SSL cert issuer
|
||||
// as well as any provided certificate files from providers.
|
||||
//
|
||||
// The certs are provided in the form of paths where .pem and .key files exist
|
||||
func TLSConfig(m *autocert.Manager, certs ...string) (tc *tls.Config) { |
||||
certMap := make(map[string]*tls.Certificate) |
||||
var mx sync.Mutex |
||||
|
||||
for _, certPath := range certs { |
||||
if certPath == "" { |
||||
continue |
||||
} |
||||
|
||||
var err error |
||||
var c tls.Certificate |
||||
|
||||
// Load certificate and key files
|
||||
if c, err = tls.LoadX509KeyPair( |
||||
certPath+".pem", certPath+".key", |
||||
); chk.E(err) { |
||||
log.E.F("failed to load certificate from %s: %v", certPath, err) |
||||
continue |
||||
} |
||||
|
||||
// Extract domain names from certificate
|
||||
if len(c.Certificate) > 0 { |
||||
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil { |
||||
// Use the common name as the primary domain
|
||||
if x509Cert.Subject.CommonName != "" { |
||||
certMap[x509Cert.Subject.CommonName] = &c |
||||
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName) |
||||
} |
||||
// Also add any subject alternative names
|
||||
for _, san := range x509Cert.DNSNames { |
||||
if san != "" { |
||||
certMap[san] = &c |
||||
log.I.F("loaded certificate for SAN domain: %s", san) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if m == nil { |
||||
// Create a basic TLS config without autocert
|
||||
tc = &tls.Config{ |
||||
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||
mx.Lock() |
||||
defer mx.Unlock() |
||||
|
||||
// Check for exact match first
|
||||
if cert, exists := certMap[helo.ServerName]; exists { |
||||
return cert, nil |
||||
} |
||||
|
||||
// Check for wildcard matches
|
||||
for domain, cert := range certMap { |
||||
if strings.HasPrefix(domain, "*.") { |
||||
baseDomain := domain[2:] // Remove "*."
|
||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||
return cert, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName) |
||||
}, |
||||
} |
||||
} else { |
||||
tc = m.TLSConfig() |
||||
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||
mx.Lock() |
||||
|
||||
// Check for exact match first
|
||||
if cert, exists := certMap[helo.ServerName]; exists { |
||||
mx.Unlock() |
||||
return cert, nil |
||||
} |
||||
|
||||
// Check for wildcard matches
|
||||
for domain, cert := range certMap { |
||||
if strings.HasPrefix(domain, "*.") { |
||||
baseDomain := domain[2:] // Remove "*."
|
||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||
mx.Unlock() |
||||
return cert, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
mx.Unlock() |
||||
|
||||
// Fall back to autocert for domains not in our certificate map
|
||||
return m.GetCertificate(helo) |
||||
} |
||||
} |
||||
|
||||
return tc |
||||
} |
||||
|
||||
// ValidateTLSConfig checks if the TLS configuration is valid
|
||||
func ValidateTLSConfig(domains []string, certs []string) (err error) { |
||||
if len(domains) == 0 { |
||||
return fmt.Errorf("no TLS domains specified") |
||||
} |
||||
|
||||
// Validate domain names
|
||||
for _, domain := range domains { |
||||
if domain == "" { |
||||
continue |
||||
} |
||||
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") { |
||||
return fmt.Errorf("invalid domain name: %s", domain) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
// Package transport defines the interface for pluggable network transports.
|
||||
package transport |
||||
|
||||
import "context" |
||||
|
||||
// Transport represents a network transport that serves the relay.
|
||||
type Transport interface { |
||||
// Name returns the transport identifier (e.g., "tcp", "tls", "tor").
|
||||
Name() string |
||||
// Start begins accepting connections through this transport.
|
||||
Start(ctx context.Context) error |
||||
// Stop gracefully shuts down the transport.
|
||||
Stop(ctx context.Context) error |
||||
// Addresses returns the addresses this transport is reachable on.
|
||||
Addresses() []string |
||||
} |
||||
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
// Package transport provides a manager for pluggable network transports.
|
||||
package transport |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"sync" |
||||
|
||||
"lol.mleku.dev/log" |
||||
|
||||
iface "next.orly.dev/pkg/interfaces/transport" |
||||
) |
||||
|
||||
// Manager manages multiple transports and coordinates their lifecycle.
|
||||
type Manager struct { |
||||
mu sync.RWMutex |
||||
transports []iface.Transport |
||||
} |
||||
|
||||
// NewManager creates a new transport manager.
|
||||
func NewManager() *Manager { |
||||
return &Manager{} |
||||
} |
||||
|
||||
// Add registers a transport with the manager.
|
||||
func (m *Manager) Add(t iface.Transport) { |
||||
m.mu.Lock() |
||||
defer m.mu.Unlock() |
||||
m.transports = append(m.transports, t) |
||||
} |
||||
|
||||
// StartAll starts all registered transports in order.
|
||||
// If any transport fails to start, previously started transports are stopped.
|
||||
func (m *Manager) StartAll(ctx context.Context) error { |
||||
m.mu.RLock() |
||||
defer m.mu.RUnlock() |
||||
|
||||
for i, t := range m.transports { |
||||
log.I.F("starting transport: %s", t.Name()) |
||||
if err := t.Start(ctx); err != nil { |
||||
// Stop previously started transports in reverse order
|
||||
for j := i - 1; j >= 0; j-- { |
||||
if stopErr := m.transports[j].Stop(ctx); stopErr != nil { |
||||
log.E.F("failed to stop transport %s during rollback: %v", |
||||
m.transports[j].Name(), stopErr) |
||||
} |
||||
} |
||||
return fmt.Errorf("transport %s failed to start: %w", t.Name(), err) |
||||
} |
||||
log.I.F("transport started: %s", t.Name()) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// StopAll stops all transports in reverse order.
|
||||
func (m *Manager) StopAll(ctx context.Context) error { |
||||
m.mu.RLock() |
||||
defer m.mu.RUnlock() |
||||
|
||||
var firstErr error |
||||
for i := len(m.transports) - 1; i >= 0; i-- { |
||||
t := m.transports[i] |
||||
log.I.F("stopping transport: %s", t.Name()) |
||||
if err := t.Stop(ctx); err != nil { |
||||
log.E.F("failed to stop transport %s: %v", t.Name(), err) |
||||
if firstErr == nil { |
||||
firstErr = err |
||||
} |
||||
} else { |
||||
log.I.F("transport stopped: %s", t.Name()) |
||||
} |
||||
} |
||||
return firstErr |
||||
} |
||||
|
||||
// Addresses returns all addresses from all transports.
|
||||
func (m *Manager) Addresses() []string { |
||||
m.mu.RLock() |
||||
defer m.mu.RUnlock() |
||||
|
||||
var addrs []string |
||||
for _, t := range m.transports { |
||||
addrs = append(addrs, t.Addresses()...) |
||||
} |
||||
return addrs |
||||
} |
||||
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
// Package tcp provides a plain HTTP transport for the relay.
|
||||
package tcp |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"net/http" |
||||
"sync" |
||||
|
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// Config holds TCP transport configuration.
|
||||
type Config struct { |
||||
// Addr is the listen address (e.g., "0.0.0.0:3334").
|
||||
Addr string |
||||
// Handler is the HTTP handler to serve.
|
||||
Handler http.Handler |
||||
} |
||||
|
||||
// Transport serves HTTP over plain TCP.
|
||||
type Transport struct { |
||||
cfg *Config |
||||
server *http.Server |
||||
mu sync.Mutex |
||||
} |
||||
|
||||
// New creates a new TCP transport.
|
||||
func New(cfg *Config) *Transport { |
||||
return &Transport{cfg: cfg} |
||||
} |
||||
|
||||
func (t *Transport) Name() string { return "tcp" } |
||||
|
||||
func (t *Transport) Start(ctx context.Context) error { |
||||
t.mu.Lock() |
||||
defer t.mu.Unlock() |
||||
|
||||
t.server = &http.Server{ |
||||
Addr: t.cfg.Addr, |
||||
Handler: t.cfg.Handler, |
||||
} |
||||
|
||||
log.I.F("starting listener on http://%s", t.cfg.Addr) |
||||
|
||||
errCh := make(chan error, 1) |
||||
go func() { |
||||
if err := t.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
||||
errCh <- err |
||||
} |
||||
close(errCh) |
||||
}() |
||||
|
||||
// Give the server a moment to fail on bind errors
|
||||
select { |
||||
case err := <-errCh: |
||||
if err != nil { |
||||
return fmt.Errorf("tcp listen on %s: %w", t.cfg.Addr, err) |
||||
} |
||||
default: |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *Transport) Stop(ctx context.Context) error { |
||||
t.mu.Lock() |
||||
defer t.mu.Unlock() |
||||
|
||||
if t.server == nil { |
||||
return nil |
||||
} |
||||
return t.server.Shutdown(ctx) |
||||
} |
||||
|
||||
func (t *Transport) Addresses() []string { |
||||
return []string{"ws://" + t.cfg.Addr + "/"} |
||||
} |
||||
@ -0,0 +1,247 @@
@@ -0,0 +1,247 @@
|
||||
// Package tls provides a TLS/ACME transport for the relay.
|
||||
package tls |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/tls" |
||||
"crypto/x509" |
||||
"fmt" |
||||
"net/http" |
||||
"os" |
||||
"path/filepath" |
||||
"strings" |
||||
"sync" |
||||
|
||||
"golang.org/x/crypto/acme/autocert" |
||||
"lol.mleku.dev/chk" |
||||
"lol.mleku.dev/log" |
||||
) |
||||
|
||||
// Config holds TLS transport configuration.
|
||||
type Config struct { |
||||
// Domains is the list of domains for ACME auto-cert.
|
||||
Domains []string |
||||
// Certs is a list of manual certificate paths (without extension).
|
||||
// For each path, .pem and .key files are loaded.
|
||||
Certs []string |
||||
// DataDir is the base data directory for the autocert cache.
|
||||
DataDir string |
||||
// Handler is the HTTP handler to serve.
|
||||
Handler http.Handler |
||||
} |
||||
|
||||
// Transport serves HTTPS with automatic or manual TLS certificates.
|
||||
// It runs two servers: HTTPS on :443 and HTTP on :80 for ACME challenges.
|
||||
type Transport struct { |
||||
cfg *Config |
||||
tlsServer *http.Server |
||||
httpServer *http.Server |
||||
mu sync.Mutex |
||||
} |
||||
|
||||
// New creates a new TLS transport.
|
||||
func New(cfg *Config) *Transport { |
||||
return &Transport{cfg: cfg} |
||||
} |
||||
|
||||
func (t *Transport) Name() string { return "tls" } |
||||
|
||||
func (t *Transport) Start(ctx context.Context) error { |
||||
t.mu.Lock() |
||||
defer t.mu.Unlock() |
||||
|
||||
if err := ValidateConfig(t.cfg.Domains, t.cfg.Certs); err != nil { |
||||
return fmt.Errorf("invalid TLS configuration: %w", err) |
||||
} |
||||
|
||||
// Create cache directory for autocert
|
||||
cacheDir := filepath.Join(t.cfg.DataDir, "autocert") |
||||
if err := os.MkdirAll(cacheDir, 0700); err != nil { |
||||
return fmt.Errorf("failed to create autocert cache directory: %w", err) |
||||
} |
||||
|
||||
// Set up autocert manager
|
||||
m := &autocert.Manager{ |
||||
Prompt: autocert.AcceptTOS, |
||||
Cache: autocert.DirCache(cacheDir), |
||||
HostPolicy: autocert.HostWhitelist(t.cfg.Domains...), |
||||
} |
||||
|
||||
// Create TLS server on port 443
|
||||
t.tlsServer = &http.Server{ |
||||
Addr: ":443", |
||||
Handler: t.cfg.Handler, |
||||
TLSConfig: tlsConfig(m, t.cfg.Certs...), |
||||
} |
||||
|
||||
// Create HTTP server for ACME challenges and redirects on port 80
|
||||
t.httpServer = &http.Server{ |
||||
Addr: ":80", |
||||
Handler: m.HTTPHandler(nil), |
||||
} |
||||
|
||||
log.I.F("TLS enabled for domains: %v", t.cfg.Domains) |
||||
|
||||
// Start TLS server
|
||||
go func() { |
||||
log.I.F("starting TLS listener on https://:443") |
||||
if err := t.tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { |
||||
log.E.F("TLS server error: %v", err) |
||||
} |
||||
}() |
||||
|
||||
// Start HTTP server for ACME challenges
|
||||
go func() { |
||||
log.I.F("starting HTTP listener on http://:80 for ACME challenges") |
||||
if err := t.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
||||
log.E.F("HTTP server error: %v", err) |
||||
} |
||||
}() |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *Transport) Stop(ctx context.Context) error { |
||||
t.mu.Lock() |
||||
defer t.mu.Unlock() |
||||
|
||||
var firstErr error |
||||
|
||||
if t.tlsServer != nil { |
||||
if err := t.tlsServer.Shutdown(ctx); err != nil { |
||||
log.E.F("TLS server shutdown error: %v", err) |
||||
firstErr = err |
||||
} else { |
||||
log.I.F("TLS server shutdown completed") |
||||
} |
||||
} |
||||
|
||||
if t.httpServer != nil { |
||||
if err := t.httpServer.Shutdown(ctx); err != nil { |
||||
log.E.F("HTTP server shutdown error: %v", err) |
||||
if firstErr == nil { |
||||
firstErr = err |
||||
} |
||||
} else { |
||||
log.I.F("HTTP server shutdown completed") |
||||
} |
||||
} |
||||
|
||||
return firstErr |
||||
} |
||||
|
||||
func (t *Transport) Addresses() []string { |
||||
var addrs []string |
||||
for _, domain := range t.cfg.Domains { |
||||
addrs = append(addrs, "wss://"+domain+"/") |
||||
} |
||||
return addrs |
||||
} |
||||
|
||||
// ValidateConfig checks if the TLS configuration is valid.
|
||||
func ValidateConfig(domains []string, certs []string) error { |
||||
if len(domains) == 0 { |
||||
return fmt.Errorf("no TLS domains specified") |
||||
} |
||||
|
||||
for _, domain := range domains { |
||||
if domain == "" { |
||||
continue |
||||
} |
||||
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") { |
||||
return fmt.Errorf("invalid domain name: %s", domain) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// tlsConfig returns a TLS configuration that works with LetsEncrypt automatic
|
||||
// SSL cert issuer as well as any provided certificate files.
|
||||
//
|
||||
// Certs are provided as paths where .pem and .key files exist.
|
||||
func tlsConfig(m *autocert.Manager, certs ...string) *tls.Config { |
||||
certMap := make(map[string]*tls.Certificate) |
||||
var mx sync.Mutex |
||||
|
||||
for _, certPath := range certs { |
||||
if certPath == "" { |
||||
continue |
||||
} |
||||
|
||||
var err error |
||||
var c tls.Certificate |
||||
|
||||
if c, err = tls.LoadX509KeyPair( |
||||
certPath+".pem", certPath+".key", |
||||
); chk.E(err) { |
||||
log.E.F("failed to load certificate from %s: %v", certPath, err) |
||||
continue |
||||
} |
||||
|
||||
if len(c.Certificate) > 0 { |
||||
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil { |
||||
if x509Cert.Subject.CommonName != "" { |
||||
certMap[x509Cert.Subject.CommonName] = &c |
||||
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName) |
||||
} |
||||
for _, san := range x509Cert.DNSNames { |
||||
if san != "" { |
||||
certMap[san] = &c |
||||
log.I.F("loaded certificate for SAN domain: %s", san) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if m == nil { |
||||
return &tls.Config{ |
||||
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||
mx.Lock() |
||||
defer mx.Unlock() |
||||
|
||||
if cert, exists := certMap[helo.ServerName]; exists { |
||||
return cert, nil |
||||
} |
||||
|
||||
for domain, cert := range certMap { |
||||
if strings.HasPrefix(domain, "*.") { |
||||
baseDomain := domain[2:] |
||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||
return cert, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName) |
||||
}, |
||||
} |
||||
} |
||||
|
||||
tc := m.TLSConfig() |
||||
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||
mx.Lock() |
||||
|
||||
if cert, exists := certMap[helo.ServerName]; exists { |
||||
mx.Unlock() |
||||
return cert, nil |
||||
} |
||||
|
||||
for domain, cert := range certMap { |
||||
if strings.HasPrefix(domain, "*.") { |
||||
baseDomain := domain[2:] |
||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||
mx.Unlock() |
||||
return cert, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
mx.Unlock() |
||||
|
||||
return m.GetCertificate(helo) |
||||
} |
||||
|
||||
return tc |
||||
} |
||||
@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
|
||||
// Package tor provides a Tor hidden service transport for the relay.
|
||||
// It wraps the existing pkg/tor service as a pluggable transport.
|
||||
package tor |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
|
||||
"lol.mleku.dev/log" |
||||
|
||||
torservice "next.orly.dev/pkg/tor" |
||||
) |
||||
|
||||
// Config holds Tor transport configuration.
|
||||
type Config struct { |
||||
// Port is the internal port for the hidden service.
|
||||
Port int |
||||
// DataDir is the directory for Tor data (torrc, keys, hostname, etc.).
|
||||
DataDir string |
||||
// Binary is the path to the tor executable.
|
||||
Binary string |
||||
// SOCKSPort is the port for outbound SOCKS connections (0 = disabled).
|
||||
SOCKSPort int |
||||
// Handler is the HTTP handler to serve.
|
||||
Handler http.Handler |
||||
} |
||||
|
||||
// Transport serves the relay as a Tor hidden service.
|
||||
type Transport struct { |
||||
cfg *Config |
||||
service *torservice.Service |
||||
} |
||||
|
||||
// New creates a new Tor transport.
|
||||
func New(cfg *Config) *Transport { |
||||
return &Transport{cfg: cfg} |
||||
} |
||||
|
||||
func (t *Transport) Name() string { return "tor" } |
||||
|
||||
func (t *Transport) Start(ctx context.Context) error { |
||||
svcCfg := &torservice.Config{ |
||||
Port: t.cfg.Port, |
||||
DataDir: t.cfg.DataDir, |
||||
Binary: t.cfg.Binary, |
||||
SOCKSPort: t.cfg.SOCKSPort, |
||||
Handler: t.cfg.Handler, |
||||
} |
||||
|
||||
var err error |
||||
t.service, err = torservice.New(svcCfg) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err = t.service.Start(); err != nil { |
||||
t.service = nil |
||||
return err |
||||
} |
||||
|
||||
if addr := t.service.OnionWSAddress(); addr != "" { |
||||
log.I.F("Tor hidden service listening on port %d, address: %s", t.cfg.Port, addr) |
||||
} else { |
||||
log.I.F("Tor hidden service listening on port %d (waiting for .onion address)", t.cfg.Port) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (t *Transport) Stop(ctx context.Context) error { |
||||
if t.service == nil { |
||||
return nil |
||||
} |
||||
return t.service.Stop() |
||||
} |
||||
|
||||
func (t *Transport) Addresses() []string { |
||||
if t.service == nil { |
||||
return nil |
||||
} |
||||
if addr := t.service.OnionWSAddress(); addr != "" { |
||||
return []string{addr} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// Service returns the underlying Tor service for access to Tor-specific
|
||||
// functionality (e.g., OnionAddress, DataDir).
|
||||
func (t *Transport) Service() *torservice.Service { |
||||
return t.service |
||||
} |
||||
Loading…
Reference in new issue