refactor: extract ProxyProvider interface with None and NPM implementations
Replace direct npm.Client usage throughout the codebase with the proxy.Provider interface, enabling pluggable proxy backends. The deployer, API layer, and proxy manager now use provider-agnostic route management (ConfigureRoute/DeleteRoute) instead of NPM-specific API calls. Adds ProxyRouteID (string) to Instance model and ProxyProvider setting to Settings, with SQLite migrations for backward compatibility.
This commit is contained in:
+36
-62
@@ -8,23 +8,22 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// Manager handles the lifecycle of standalone proxy hosts.
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
npm *npm.Client
|
||||
dnsMu sync.RWMutex
|
||||
dns dns.Provider // nil when wildcard DNS is active
|
||||
store *store.Store
|
||||
provider Provider
|
||||
dnsMu sync.RWMutex
|
||||
dns dns.Provider // nil when wildcard DNS is active
|
||||
}
|
||||
|
||||
// NewManager creates a new proxy manager.
|
||||
func NewManager(st *store.Store, npmClient *npm.Client) *Manager {
|
||||
func NewManager(st *store.Store, provider Provider) *Manager {
|
||||
return &Manager{
|
||||
store: st,
|
||||
npm: npmClient,
|
||||
store: st,
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +69,7 @@ type ProxyView struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateProxy validates the destination, creates an NPM proxy host, and saves to the store.
|
||||
// CreateProxy validates the destination, creates a proxy route via the provider, and saves to the store.
|
||||
func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (store.StandaloneProxy, error) {
|
||||
// Validate destination.
|
||||
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
|
||||
@@ -84,29 +83,16 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor
|
||||
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
|
||||
// Build NPM proxy host config.
|
||||
config := npm.ProxyHostConfig{
|
||||
DomainNames: []string{req.Domain},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: req.DestinationURL,
|
||||
ForwardPort: req.DestinationPort,
|
||||
CertificateID: settings.SSLCertificateID,
|
||||
SSLForced: settings.SSLCertificateID > 0,
|
||||
BlockExploits: true,
|
||||
AllowWebsocket: true,
|
||||
HTTP2Support: true,
|
||||
HSTSEnabled: settings.SSLCertificateID > 0,
|
||||
Locations: []any{},
|
||||
}
|
||||
|
||||
// Create NPM proxy host.
|
||||
npmHost, err := m.npm.CreateProxyHost(ctx, config)
|
||||
// Create proxy route via provider.
|
||||
routeID, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StandaloneProxy{}, fmt.Errorf("create NPM proxy host: %w", err)
|
||||
return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("created NPM proxy host for standalone proxy",
|
||||
"domain", req.Domain, "npm_proxy_id", npmHost.ID)
|
||||
slog.Info("created proxy route for standalone proxy",
|
||||
"domain", req.Domain, "route_id", routeID, "provider", m.provider.Name())
|
||||
|
||||
// Save to store.
|
||||
proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{
|
||||
@@ -114,14 +100,13 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor
|
||||
DestinationURL: req.DestinationURL,
|
||||
DestinationPort: req.DestinationPort,
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
NpmProxyID: npmHost.ID,
|
||||
HealthStatus: "unknown",
|
||||
})
|
||||
if err != nil {
|
||||
// Best effort: clean up the NPM host if store insert fails.
|
||||
if delErr := m.npm.DeleteProxyHost(ctx, npmHost.ID); delErr != nil {
|
||||
slog.Error("failed to clean up NPM proxy host after store error",
|
||||
"npm_proxy_id", npmHost.ID, "error", delErr)
|
||||
// Best effort: clean up the proxy route if store insert fails.
|
||||
if delErr := m.provider.DeleteRoute(ctx, routeID); delErr != nil {
|
||||
slog.Error("failed to clean up proxy route after store error",
|
||||
"route_id", routeID, "error", delErr)
|
||||
}
|
||||
return store.StandaloneProxy{}, fmt.Errorf("save standalone proxy: %w", err)
|
||||
}
|
||||
@@ -132,7 +117,7 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// UpdateProxy re-validates the destination, updates the NPM proxy host, and updates the store.
|
||||
// UpdateProxy re-validates the destination, updates the proxy route via the provider, and updates the store.
|
||||
func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyRequest) (store.StandaloneProxy, error) {
|
||||
existing, err := m.store.GetStandaloneProxy(id)
|
||||
if err != nil {
|
||||
@@ -151,23 +136,11 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq
|
||||
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
|
||||
// Update NPM proxy host.
|
||||
config := npm.ProxyHostConfig{
|
||||
DomainNames: []string{req.Domain},
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: req.DestinationURL,
|
||||
ForwardPort: req.DestinationPort,
|
||||
CertificateID: settings.SSLCertificateID,
|
||||
SSLForced: settings.SSLCertificateID > 0,
|
||||
BlockExploits: true,
|
||||
AllowWebsocket: true,
|
||||
HTTP2Support: true,
|
||||
HSTSEnabled: settings.SSLCertificateID > 0,
|
||||
Locations: []any{},
|
||||
}
|
||||
|
||||
if _, err := m.npm.UpdateProxyHost(ctx, existing.NpmProxyID, config); err != nil {
|
||||
return store.StandaloneProxy{}, fmt.Errorf("update NPM proxy host: %w", err)
|
||||
// Update proxy route via provider (ConfigureRoute handles create-or-update).
|
||||
if _, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
}); err != nil {
|
||||
return store.StandaloneProxy{}, fmt.Errorf("update proxy route: %w", err)
|
||||
}
|
||||
|
||||
// Update store.
|
||||
@@ -191,23 +164,24 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq
|
||||
return m.store.GetStandaloneProxy(id)
|
||||
}
|
||||
|
||||
// DeleteProxy removes the NPM proxy host and deletes from the store.
|
||||
// DeleteProxy removes the proxy route via the provider and deletes from the store.
|
||||
func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
|
||||
proxy, err := m.store.GetStandaloneProxy(id)
|
||||
p, err := m.store.GetStandaloneProxy(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get proxy: %w", err)
|
||||
}
|
||||
|
||||
// Delete NPM proxy host.
|
||||
if proxy.NpmProxyID > 0 {
|
||||
if err := m.npm.DeleteProxyHost(ctx, proxy.NpmProxyID); err != nil {
|
||||
slog.Warn("failed to delete NPM proxy host (continuing with store deletion)",
|
||||
"npm_proxy_id", proxy.NpmProxyID, "error", err)
|
||||
// Delete proxy route via provider using the NpmProxyID as a string route ID.
|
||||
if p.NpmProxyID > 0 {
|
||||
routeID := fmt.Sprintf("%d", p.NpmProxyID)
|
||||
if err := m.provider.DeleteRoute(ctx, routeID); err != nil {
|
||||
slog.Warn("failed to delete proxy route (continuing with store deletion)",
|
||||
"route_id", routeID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DNS record.
|
||||
m.removeDNS(ctx, proxy.Domain)
|
||||
m.removeDNS(ctx, p.Domain)
|
||||
|
||||
if err := m.store.DeleteStandaloneProxy(id); err != nil {
|
||||
return fmt.Errorf("delete standalone proxy: %w", err)
|
||||
@@ -257,7 +231,7 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Deploy-managed proxies: instances with npm_proxy_id > 0.
|
||||
// Deploy-managed proxies: instances with a proxy route configured.
|
||||
instances, err := m.store.ListAllInstances()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instances: %w", err)
|
||||
@@ -278,7 +252,7 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
|
||||
}
|
||||
|
||||
for _, inst := range instances {
|
||||
if inst.NpmProxyID <= 0 {
|
||||
if inst.ProxyRouteID == "" && inst.NpmProxyID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package proxy
|
||||
|
||||
import "context"
|
||||
|
||||
// NoneProvider is a no-op proxy provider for deployments that don't use a reverse proxy.
|
||||
type NoneProvider struct{}
|
||||
|
||||
func NewNoneProvider() *NoneProvider { return &NoneProvider{} }
|
||||
|
||||
func (n *NoneProvider) Name() string { return "none" }
|
||||
|
||||
func (n *NoneProvider) ConfigureRoute(_ context.Context, _, _ string, _ int, _ RouteOptions) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (n *NoneProvider) DeleteRoute(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NoneProvider) ContainerLabels(_ string, _ int) map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NoneProvider) Ping(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
)
|
||||
|
||||
// NpmProvider wraps the NPM client behind the Provider interface.
|
||||
// It handles authentication transparently before each operation.
|
||||
type NpmProvider struct {
|
||||
client *npm.Client
|
||||
email string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewNpmProvider creates an NPM-backed proxy provider.
|
||||
// The email and password are the decrypted NPM credentials.
|
||||
func NewNpmProvider(client *npm.Client, email, password string) *NpmProvider {
|
||||
return &NpmProvider{
|
||||
client: client,
|
||||
email: email,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCredentials updates the stored NPM credentials (e.g., after settings change).
|
||||
func (p *NpmProvider) UpdateCredentials(email, password string) {
|
||||
p.email = email
|
||||
p.password = password
|
||||
}
|
||||
|
||||
func (p *NpmProvider) Name() string { return "npm" }
|
||||
|
||||
func (p *NpmProvider) ConfigureRoute(ctx context.Context, domain, targetHost string, targetPort int, opts RouteOptions) (string, error) {
|
||||
if err := p.auth(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
scheme := opts.ForwardScheme
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// Check if a proxy host already exists for this domain.
|
||||
existing, found, err := p.client.FindProxyHostByDomain(ctx, domain)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find existing proxy host: %w", err)
|
||||
}
|
||||
|
||||
config := npm.ProxyHostConfig{
|
||||
DomainNames: []string{domain},
|
||||
ForwardScheme: scheme,
|
||||
ForwardHost: targetHost,
|
||||
ForwardPort: targetPort,
|
||||
BlockExploits: true,
|
||||
AllowWebsocket: true,
|
||||
HTTP2Support: true,
|
||||
Meta: npm.Meta{},
|
||||
Locations: []any{},
|
||||
}
|
||||
|
||||
if opts.SSLCertificateID > 0 {
|
||||
config.CertificateID = opts.SSLCertificateID
|
||||
config.SSLForced = true
|
||||
config.HSTSEnabled = true
|
||||
}
|
||||
|
||||
if found {
|
||||
host, err := p.client.UpdateProxyHost(ctx, existing.ID, config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("update proxy host: %w", err)
|
||||
}
|
||||
return strconv.Itoa(host.ID), nil
|
||||
}
|
||||
|
||||
host, err := p.client.CreateProxyHost(ctx, config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create proxy host: %w", err)
|
||||
}
|
||||
return strconv.Itoa(host.ID), nil
|
||||
}
|
||||
|
||||
func (p *NpmProvider) DeleteRoute(ctx context.Context, routeID string) error {
|
||||
if routeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(routeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid npm proxy host id %q: %w", routeID, err)
|
||||
}
|
||||
|
||||
if err := p.auth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.client.DeleteProxyHost(ctx, id)
|
||||
}
|
||||
|
||||
func (p *NpmProvider) ContainerLabels(_ string, _ int) map[string]string {
|
||||
// NPM configures routing via its API, not Docker labels.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *NpmProvider) Ping(ctx context.Context) error {
|
||||
return p.client.Ping(ctx)
|
||||
}
|
||||
|
||||
// auth authenticates to NPM if credentials are available.
|
||||
func (p *NpmProvider) auth(ctx context.Context) error {
|
||||
if p.email == "" {
|
||||
return fmt.Errorf("NPM credentials not configured")
|
||||
}
|
||||
return p.client.Authenticate(ctx, p.email, p.password)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package proxy
|
||||
|
||||
import "context"
|
||||
|
||||
// RouteOptions holds optional configuration for a proxy route.
|
||||
type RouteOptions struct {
|
||||
SSLCertificateID int
|
||||
ForwardScheme string // "http" or "https", defaults to "http"
|
||||
}
|
||||
|
||||
// Provider is the interface for proxy route management.
|
||||
// Implementations handle the specifics of each proxy system (NPM, Traefik, etc.).
|
||||
// The "None" provider implements all methods as no-ops.
|
||||
type Provider interface {
|
||||
// Name returns the provider identifier (e.g., "npm", "traefik", "none").
|
||||
Name() string
|
||||
|
||||
// ConfigureRoute creates or updates a proxy route for the given domain.
|
||||
// Returns a route ID string that can be used for updates and deletes.
|
||||
// For NPM, the route ID is the proxy host ID (stringified).
|
||||
// For Traefik, the route ID is the router name.
|
||||
// For None, returns empty string.
|
||||
ConfigureRoute(ctx context.Context, domain, targetHost string, targetPort int, opts RouteOptions) (routeID string, err error)
|
||||
|
||||
// DeleteRoute removes a proxy route by its route ID.
|
||||
// Does nothing if routeID is empty.
|
||||
DeleteRoute(ctx context.Context, routeID string) error
|
||||
|
||||
// ContainerLabels returns Docker labels to set on containers at creation time.
|
||||
// Traefik uses labels for auto-discovery; NPM and None return nil.
|
||||
ContainerLabels(domain string, port int) map[string]string
|
||||
|
||||
// Ping checks connectivity to the proxy backend.
|
||||
// Returns nil if healthy or if the provider doesn't need external connectivity (None).
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
Reference in New Issue
Block a user