diff --git a/.gitignore b/.gitignore index d0dad786..7ab9abf5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,10 @@ go.work ui/node_modules/ ui/dist/ +# Compiled vite config artifacts +ui/vite.config.js +ui/vite.config.d.ts + # SQLite database *.db diff --git a/api/handlers/transaction.go b/api/handlers/transaction.go index 7bdae8e3..154c56b6 100644 --- a/api/handlers/transaction.go +++ b/api/handlers/transaction.go @@ -101,8 +101,24 @@ func ExecuteInTransactionWithResult(c *gin.Context, db *sql.DB, operation string c.JSON(http.StatusOK, response.Data) } +// allowedTables is the whitelist of table names that can be used in dynamic SQL. +var allowedTables = map[string]bool{ + "resources": true, + "services": true, + "middlewares": true, + "resource_services": true, + "mtls_clients": true, + "mtls_config": true, +} + // DeleteInTransaction is a specialized helper for delete operations func DeleteInTransaction(c *gin.Context, db *sql.DB, table string, id string, additionalDeletes ...func(*sql.Tx) error) { + if !allowedTables[table] { + log.Printf("Error: attempted delete from disallowed table %q", table) + ResponseWithError(c, http.StatusBadRequest, "Invalid table name") + return + } + err := WithTransaction(db, func(tx *sql.Tx) error { // Execute any additional deletes first (e.g., related records) for _, deleteFn := range additionalDeletes { diff --git a/database/transaction.go b/database/transaction.go index bb21bd1f..11807423 100644 --- a/database/transaction.go +++ b/database/transaction.go @@ -82,43 +82,3 @@ func (db *DB) BatchTransaction(operations []TxFn) error { }) } -// UpdateInTransaction updates a record in a transaction -func (db *DB) UpdateInTransaction(table string, id string, updates map[string]interface{}) error { - return db.WithTransaction(func(tx *sql.Tx) error { - // Build the update statement - query := fmt.Sprintf("UPDATE %s SET ", table) - var params []interface{} - - i := 0 - for field, value := range updates { - if i > 0 { - query += ", " - } - query += field + " = ?" - params = append(params, value) - i++ - } - - // Add the WHERE clause and updated_at - query += ", updated_at = ? WHERE id = ?" - params = append(params, time.Now(), id) - - // Execute the update - result, err := tx.Exec(query, params...) - if err != nil { - return fmt.Errorf("update failed: %w", err) - } - - // Check if any rows were affected - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %w", err) - } - - if rowsAffected == 0 { - return fmt.Errorf("no rows affected, record with ID %s not found", id) - } - - return nil - }) -} \ No newline at end of file diff --git a/models/mtls.go b/models/mtls.go index fc0fce39..aa933326 100644 --- a/models/mtls.go +++ b/models/mtls.go @@ -47,6 +47,7 @@ type CreateClientRequest struct { Name string `json:"name" binding:"required"` ValidityDays int `json:"validity_days"` // Default: 730 (2 years) P12Password string `json:"p12_password" binding:"required"` + LegacyP12 bool `json:"legacy_p12"` // Use legacy encryption for iOS/older device compatibility } // UpdateMTLSConfigRequest represents the request to update resource mTLS settings diff --git a/services/cert_generator.go b/services/cert_generator.go index 90afe2b2..d8283925 100644 --- a/services/cert_generator.go +++ b/services/cert_generator.go @@ -246,7 +246,11 @@ func (cg *CertGenerator) GenerateClientCert(req models.CreateClientRequest) (*mo } // Generate PKCS#12 (.p12) file - p12Data, err := pkcs12.Modern.Encode(clientKey, clientCert, []*x509.Certificate{caCert}, req.P12Password) + encoder := pkcs12.Modern + if req.LegacyP12 { + encoder = pkcs12.Legacy + } + p12Data, err := encoder.Encode(clientKey, clientCert, []*x509.Certificate{caCert}, req.P12Password) if err != nil { return nil, fmt.Errorf("failed to generate PKCS#12: %w", err) } diff --git a/services/config_generator.go b/services/config_generator.go index 98e47749..cda1c95e 100644 --- a/services/config_generator.go +++ b/services/config_generator.go @@ -628,7 +628,7 @@ func (cg *ConfigGenerator) processMTLSOptions(config *TraefikConfig) error { // Helper to fetch service names from Traefik API func (cg *ConfigGenerator) fetchTraefikServiceNames() map[string]string { serviceMap := make(map[string]string) - client := &http.Client{Timeout: 5 * time.Second} + client := HTTPClientWithTimeout(5 * time.Second) // Get Traefik API URL from data source config dsConfig, err := cg.configManager.GetActiveDataSourceConfig() diff --git a/services/config_manager.go b/services/config_manager.go index 039ba030..2335b842 100644 --- a/services/config_manager.go +++ b/services/config_manager.go @@ -132,7 +132,7 @@ func (cm *ConfigManager) EnsureDefaultDataSources(pangolinURL, traefikURL string // Try to determine if Traefik is available if cm.config.ActiveDataSource == "pangolin" { - client := &http.Client{Timeout: 2 * time.Second} + client := HTTPClientWithTimeout(2 * time.Second) traefikConfig := cm.config.DataSources["traefik"] // Try the Traefik URL @@ -282,9 +282,7 @@ func (cm *ConfigManager) UpdateDataSource(name string, config models.DataSourceC // testDataSourceConnection tests the connection to a data source func (cm *ConfigManager) testDataSourceConnection(ctx context.Context, config models.DataSourceConfig) error { - client := &http.Client{ - Timeout: 5 * time.Second, - } + client := HTTPClientWithTimeout(5 * time.Second) var url string switch config.Type { diff --git a/services/config_proxy.go b/services/config_proxy.go index 3c0061e6..d7037ea2 100644 --- a/services/config_proxy.go +++ b/services/config_proxy.go @@ -146,9 +146,7 @@ func NewConfigProxy(db *database.DB, configManager *ConfigManager, pangolinURL s db: db, configManager: configManager, pangolinURL: pangolinURL, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + httpClient: HTTPClientWithTimeout(10 * time.Second), cacheDuration: 5 * time.Second, // Match typical Traefik poll interval } } @@ -161,29 +159,21 @@ func (cp *ConfigProxy) GetMergedConfig() (*ProxiedTraefikConfig, error) { defer cp.cacheMutex.RUnlock() return cp.cache, nil } + staleCache := cp.cache cp.cacheMutex.RUnlock() - // Acquire write lock for cache update - cp.cacheMutex.Lock() - defer cp.cacheMutex.Unlock() - - // Double-check after acquiring write lock - if cp.cache != nil && time.Now().Before(cp.cacheExpiry) { - return cp.cache, nil - } - - // Fetch fresh config from Pangolin + // Fetch fresh config OUTSIDE the lock to avoid blocking readers config, err := cp.fetchPangolinConfig() if err != nil { // Return stale cache on error if available - if cp.cache != nil { + if staleCache != nil { log.Printf("Warning: Pangolin fetch failed, using stale cache: %v", err) - return cp.cache, nil + return staleCache, nil } return nil, fmt.Errorf("failed to fetch Pangolin config: %w", err) } - // Merge MW-manager additions + // Merge MW-manager additions (no lock needed, operates on local config) if err := cp.mergeMiddlewareManagerConfig(config); err != nil { return nil, fmt.Errorf("failed to merge MW-manager config: %w", err) } @@ -197,9 +187,11 @@ func (cp *ConfigProxy) GetMergedConfig() (*ProxiedTraefikConfig, error) { // Normalize middleware field ordering to match Pangolin's JSON format cp.normalizeMiddlewareOrder(config) - // Update cache + // Lock only to swap the cache + cp.cacheMutex.Lock() cp.cache = config cp.cacheExpiry = time.Now().Add(cp.cacheDuration) + cp.cacheMutex.Unlock() return config, nil } @@ -245,7 +237,7 @@ func (cp *ConfigProxy) fetchPangolinConfig() (*ProxiedTraefikConfig, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) // 1MB limit for error body return nil, fmt.Errorf("Pangolin returned status %d: %s", resp.StatusCode, string(body)) } @@ -1164,6 +1156,8 @@ func (cp *ConfigProxy) SetPangolinURL(url string) { // SetCacheDuration updates the cache duration func (cp *ConfigProxy) SetCacheDuration(duration time.Duration) { + cp.cacheMutex.Lock() + defer cp.cacheMutex.Unlock() cp.cacheDuration = duration } diff --git a/services/resource_watcher.go b/services/resource_watcher.go index 73655c9e..2b0be3f1 100644 --- a/services/resource_watcher.go +++ b/services/resource_watcher.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "strings" + "sync/atomic" "time" "github.com/google/uuid" @@ -23,7 +24,7 @@ type ResourceWatcher struct { fetcher ResourceFetcher configManager *ConfigManager stopChan chan struct{} - isRunning bool + isRunning atomic.Bool httpClient *http.Client } @@ -49,18 +50,15 @@ func NewResourceWatcher(db *database.DB, configManager *ConfigManager) (*Resourc fetcher: fetcher, configManager: configManager, stopChan: make(chan struct{}), - isRunning: false, httpClient: httpClient, }, nil } // Start begins watching for resources func (rw *ResourceWatcher) Start(interval time.Duration) { - if rw.isRunning { + if !rw.isRunning.CompareAndSwap(false, true) { return } - - rw.isRunning = true log.Printf("Resource watcher started, checking every %v", interval) ticker := time.NewTicker(interval) @@ -109,12 +107,11 @@ func (rw *ResourceWatcher) refreshFetcher() error { // Stop stops the resource watcher func (rw *ResourceWatcher) Stop() { - if !rw.isRunning { + if !rw.isRunning.CompareAndSwap(true, false) { return } - + close(rw.stopChan) - rw.isRunning = false } // checkResources fetches resources from the configured data source and updates the database diff --git a/services/service_fetcher.go b/services/service_fetcher.go index 1db196ab..197522e0 100644 --- a/services/service_fetcher.go +++ b/services/service_fetcher.go @@ -39,10 +39,8 @@ type PangolinServiceFetcher struct { // NewPangolinServiceFetcher creates a new Pangolin API fetcher for services func NewPangolinServiceFetcher(config models.DataSourceConfig) *PangolinServiceFetcher { return &PangolinServiceFetcher{ - config: config, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + config: config, + httpClient: GetHTTPClient(), } } @@ -72,7 +70,7 @@ func (f *PangolinServiceFetcher) FetchServices(ctx context.Context) (*models.Ser } // Process response - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB limit if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } @@ -122,7 +120,11 @@ func (f *PangolinServiceFetcher) FetchServices(ctx context.Context) (*models.Ser } // Create new service - configJSON, _ := json.Marshal(serviceConfig) + configJSON, err := json.Marshal(serviceConfig) + if err != nil { + log.Printf("Failed to marshal service config for %s: %v", id, err) + continue + } newService := models.Service{ ID: id, @@ -188,10 +190,8 @@ type TraefikServiceFetcher struct { // NewTraefikServiceFetcher creates a new Traefik API fetcher for services func NewTraefikServiceFetcher(config models.DataSourceConfig) *TraefikServiceFetcher { return &TraefikServiceFetcher{ - config: config, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + config: config, + httpClient: GetHTTPClient(), } } @@ -313,7 +313,7 @@ func (f *TraefikServiceFetcher) fetchHTTPServices(ctx context.Context, baseURL s } // Read and parse response body - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB limit if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } @@ -392,7 +392,7 @@ func (f *TraefikServiceFetcher) fetchTCPServices(ctx context.Context, baseURL st } // Read and parse response body - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB limit if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } @@ -435,7 +435,11 @@ func (f *TraefikServiceFetcher) fetchTCPServices(ctx context.Context, baseURL st } // Create service - configJSON, _ := json.Marshal(config) + configJSON, err := json.Marshal(config) + if err != nil { + log.Printf("Failed to marshal service config for %s: %v", name, err) + continue + } services = append(services, models.Service{ ID: name, @@ -476,7 +480,11 @@ func (f *TraefikServiceFetcher) fetchTCPServices(ctx context.Context, baseURL st } // Create service - configJSON, _ := json.Marshal(config) + configJSON, err := json.Marshal(config) + if err != nil { + log.Printf("Failed to marshal service config for %s: %v", name, err) + continue + } services = append(services, models.Service{ ID: name, @@ -522,7 +530,7 @@ func (f *TraefikServiceFetcher) fetchUDPServices(ctx context.Context, baseURL st } // Read and parse response body - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB limit if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } @@ -563,7 +571,11 @@ func (f *TraefikServiceFetcher) fetchUDPServices(ctx context.Context, baseURL st } // Create service - configJSON, _ := json.Marshal(config) + configJSON, err := json.Marshal(config) + if err != nil { + log.Printf("Failed to marshal service config for %s: %v", name, err) + continue + } services = append(services, models.Service{ ID: name, @@ -604,7 +616,11 @@ func (f *TraefikServiceFetcher) fetchUDPServices(ctx context.Context, baseURL st } // Create service - configJSON, _ := json.Marshal(config) + configJSON, err := json.Marshal(config) + if err != nil { + log.Printf("Failed to marshal service config for %s: %v", name, err) + continue + } services = append(services, models.Service{ ID: name, diff --git a/ui/src/components/security/ClientCertList.tsx b/ui/src/components/security/ClientCertList.tsx index d8e41533..0fda631a 100644 --- a/ui/src/components/security/ClientCertList.tsx +++ b/ui/src/components/security/ClientCertList.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' import { Alert, AlertDescription } from '@/components/ui/alert' import { @@ -110,7 +111,7 @@ export function ClientCertList() { if (client) { setNewClientId(client.id) setIsCreateOpen(false) - setFormData({ name: '', validity_days: 730, p12_password: '' }) + setFormData({ name: '', validity_days: 730, p12_password: '', legacy_p12: false }) setConfirmPassword('') } } @@ -243,6 +244,18 @@ export function ClientCertList() { Default: 730 days (2 years)

+
+ + setFormData({ ...formData, legacy_p12: checked === true }) + } + /> + +