From ff6d74c020fbe7dc0a51951aa3095b6a3495a7a7 Mon Sep 17 00:00:00 2001 From: Alexander Renz Date: Fri, 29 Nov 2024 07:54:28 +0100 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.MD | 216 +++++ cmd/server/config.toml | 67 ++ cmd/server/main.go | 1923 +++++++++++++++++++++++++++++++++++++ dashboard/dashboard.json | 888 +++++++++++++++++ go.mod | 52 + go.sum | 122 +++ test/hmac-file-server.log | 0 test/hmac_icon.png | Bin 0 -> 61498 bytes test/hmac_test.go | 80 ++ 10 files changed, 3352 insertions(+) create mode 100644 .gitignore create mode 100644 README.MD create mode 100644 cmd/server/config.toml create mode 100644 cmd/server/main.go create mode 100644 dashboard/dashboard.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 test/hmac-file-server.log create mode 100644 test/hmac_icon.png create mode 100644 test/hmac_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d996008 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..e169cb6 --- /dev/null +++ b/README.MD @@ -0,0 +1,216 @@ +# HMAC File Server Release Notes + +**HMAC File Server** is a secure, scalable, and feature-rich file server with advanced capabilities like HMAC authentication, resumable uploads, chunked uploads, file versioning, and optional ClamAV scanning for file integrity and security. This server is built with extensibility and operational monitoring in mind, including Prometheus metrics support and Redis integration. + +## Features + +- **HMAC Authentication:** Secure file uploads and downloads with HMAC tokens. +- **File Versioning:** Enable versioning for uploaded files with configurable retention. +- **Chunked and Resumable Uploads:** Handle large files efficiently with support for resumable and chunked uploads. +- **ClamAV Scanning:** Optional virus scanning for uploaded files. +- **Prometheus Metrics:** Monitor system and application-level metrics. +- **Redis Integration:** Use Redis for caching or storing application states. +- **File Expiration:** Automatically delete files after a specified TTL. +- **Graceful Shutdown:** Handles signals and ensures proper cleanup. +- **Deduplication:** Remove duplicate files based on hashing for storage efficiency. + +--- + +## Installation + +### Prerequisites + +- Go 1.20+ +- Redis (optional, if Redis integration is enabled) +- ClamAV (optional, if file scanning is enabled) + +### Clone and Build + +```bash +git clone https://github.com/your-repo/hmac-file-server.git +cd hmac-file-server +go build -o hmac-file-server main.go +``` + +--- + +## Configuration + +The server configuration is managed through a `config.toml` file. Below are the supported configuration options: + +### **Server Configuration** + +| Key | Description | Example | +|------------------------|-----------------------------------------------------|---------------------------------| +| `ListenPort` | Port or Unix socket to listen on | `":8080"` | +| `UnixSocket` | Use a Unix socket (`true`/`false`) | `false` | +| `Secret` | Secret key for HMAC authentication | `"your-secret-key"` | +| `StoragePath` | Directory to store uploaded files | `"/mnt/storage/hmac-file-server"` | +| `LogLevel` | Logging level (`info`, `debug`, etc.) | `"info"` | +| `LogFile` | Log file path (optional) | `"/var/log/hmac-file-server.log"` | +| `MetricsEnabled` | Enable Prometheus metrics (`true`/`false`) | `true` | +| `MetricsPort` | Prometheus metrics server port | `"9090"` | +| `FileTTL` | File Time-to-Live duration | `"168h0m0s"` | +| `DeduplicationEnabled` | Enable file deduplication based on hashing | `true` | +| `MinFreeBytes` | Minimum free space required on storage path (in bytes) | `104857600` | + +### **Uploads** + +| Key | Description | Example | +|----------------------------|-----------------------------------------------|-------------| +| `ResumableUploadsEnabled` | Enable resumable uploads | `true` | +| `ChunkedUploadsEnabled` | Enable chunked uploads | `true` | +| `ChunkSize` | Chunk size for chunked uploads (bytes) | `1048576` | +| `AllowedExtensions` | Allowed file extensions for uploads | `[".png", ".jpg"]` | + +### **Time Settings** + +| Key | Description | Example | +|------------------|--------------------------------|----------| +| `ReadTimeout` | HTTP server read timeout | `"2h"` | +| `WriteTimeout` | HTTP server write timeout | `"2h"` | +| `IdleTimeout` | HTTP server idle timeout | `"2h"` | + +### **ClamAV Configuration** + +| Key | Description | Example | +|--------------------|-------------------------------------------|----------------------------------| +| `ClamAVEnabled` | Enable ClamAV virus scanning (`true`) | `true` | +| `ClamAVSocket` | Path to ClamAV Unix socket | `"/var/run/clamav/clamd.ctl"` | +| `NumScanWorkers` | Number of workers for file scanning | `2` | + +### **Redis Configuration** + +| Key | Description | Example | +|----------------------------|----------------------------------|-------------------| +| `RedisEnabled` | Enable Redis integration | `true` | +| `RedisDBIndex` | Redis database index | `0` | +| `RedisAddr` | Redis server address | `"localhost:6379"`| +| `RedisPassword` | Password for Redis authentication| `""` | +| `RedisHealthCheckInterval` | Health check interval for Redis | `"30s"` | + +### **Workers and Connections** + +| Key | Description | Example | +|------------------------|------------------------------------|-------------------| +| `NumWorkers` | Number of upload workers | `2` | +| `UploadQueueSize` | Size of the upload queue | `50` | + +--- + +## Running the Server + +### Basic Usage + +Run the server with a configuration file: + +```bash +./hmac-file-server -config ./config.toml +``` + +### Metrics Server + +If `MetricsEnabled` is `true`, the Prometheus metrics server will run on the port specified in `MetricsPort` (default: `9090`). + +--- + +## Development Notes + +- **Versioning:** Enabled via `EnableVersioning`. Ensure `MaxVersions` is set appropriately to prevent storage issues. +- **File Cleaner:** The file cleaner runs hourly and deletes files older than the configured `FileTTL`. +- **Redis Health Check:** Automatically monitors Redis connectivity and logs warnings on failure. + +--- + +## Testing + +To run the server locally for development: + +```bash +go run main.go -config ./config.toml +``` + +Use tools like **cURL** or **Postman** to test file uploads and downloads. + +### Example File Upload with HMAC Token + +```bash +curl -X PUT -H "Authorization: Bearer " -F "file=@example.txt" http://localhost:8080/uploads/example.txt +``` + +Replace `` with a valid HMAC signature generated using the configured `Secret`. + +--- + +## Monitoring + +Prometheus metrics include: +- File upload/download durations +- Memory usage +- CPU usage +- Active connections +- HTTP requests metrics (total, method, path) + +--- + +## Example `config.toml` + +```toml +[server] +listenport = "8080" +unixsocket = false +storagepath = "/mnt/storage/" +loglevel = "info" +logfile = "/var/log/file-server.log" +metricsenabled = true +metricsport = "9090" +DeduplicationEnabled = true +filettl = "336h" # 14 days +minfreebytes = 104857600 # 100 MB in bytes + +[timeouts] +readtimeout = "4800s" +writetimeout = "4800s" +idletimeout = "24h" + +[security] +secret = "example-secret-key" + +[versioning] +enableversioning = false +maxversions = 1 + +[uploads] +resumableuploadsenabled = true +chunkeduploadsenabled = true +chunksize = 8192 +allowedextensions = [".txt", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".svg", ".webp", ".wav", ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".mpg", ".m4v", ".3gp", ".3g2", ".mp3", ".ogg"] + +[clamav] +clamavenabled = true +clamavsocket = "/var/run/clamav/clamd.ctl" +numscanworkers = 2 + +[redis] +redisenabled = true +redisdbindex = 0 +redisaddr = "localhost:6379" +redispassword = "" +redishealthcheckinterval = "120s" + +[workers] +numworkers = 2 +uploadqueuesize = 50 +``` + +This configuration file is set up with essential features like Prometheus integration, ClamAV scanning, and file handling with deduplication and versioning options. Adjust the settings according to your infrastructure needs. + +### Additional Features + +- **Deduplication**: Automatically remove duplicate files based on hashing. +- **Versioning**: Store multiple versions of files and keep a maximum of `MaxVersions` versions. +- **ClamAV Integration**: Scan uploaded files for viruses using ClamAV. +- **Redis Caching**: Utilize Redis for caching file metadata for faster access. + +This release ensures an efficient and secure file management system, suited for environments requiring high levels of data security and availability. +``` \ No newline at end of file diff --git a/cmd/server/config.toml b/cmd/server/config.toml new file mode 100644 index 0000000..5587289 --- /dev/null +++ b/cmd/server/config.toml @@ -0,0 +1,67 @@ +# Server Settings +[server] +ListenPort = "8080" +UnixSocket = false +StoreDir = "./testupload" +LogLevel = "info" +LogFile = "./hmac-file-server.log" +MetricsEnabled = true +MetricsPort = "9090" +FileTTL = "8760h" + +# Workers and Connections +[workers] +NumWorkers = 2 +UploadQueueSize = 500 + +# Timeout Settings +[timeouts] +ReadTimeout = "600s" +WriteTimeout = "600s" +IdleTimeout = "600s" + +# Security Settings +[security] +Secret = "a-orc-and-a-humans-is-drinking-ale" + +# Versioning Settings +[versioning] +EnableVersioning = false +MaxVersions = 1 + +# Upload/Download Settings +[uploads] +ResumableUploadsEnabled = true +ChunkedUploadsEnabled = true +ChunkSize = 16777216 +AllowedExtensions = [ + # Document formats + ".txt", ".pdf", + + # Image formats + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".svg", ".webp", + + # Video formats + ".wav", ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".mpg", ".m4v", ".3gp", ".3g2", + + # Audio formats + ".mp3", ".ogg" +] + +# ClamAV Settings +[clamav] +ClamAVEnabled = false +ClamAVSocket = "/var/run/clamav/clamd.ctl" +NumScanWorkers = 4 + +# Redis Settings +[redis] +RedisEnabled = false +RedisAddr = "localhost:6379" +RedisPassword = "" +RedisDBIndex = 0 +RedisHealthCheckInterval = "120s" + +# Deduplication +[deduplication] +enabled = false diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..65039c2 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,1923 @@ +// main.go + +package main + +import ( + "bufio" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "sync" + + "github.com/dutchcoders/go-clamd" // ClamAV integration + "github.com/go-redis/redis/v8" // Redis integration + "github.com/patrickmn/go-cache" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/disk" + "github.com/shirou/gopsutil/host" + "github.com/shirou/gopsutil/mem" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +// Configuration structure +type ServerConfig struct { + ListenPort string `mapstructure:"ListenPort"` + UnixSocket bool `mapstructure:"UnixSocket"` + StoragePath string `mapstructure:"StoragePath"` + LogLevel string `mapstructure:"LogLevel"` + LogFile string `mapstructure:"LogFile"` + MetricsEnabled bool `mapstructure:"MetricsEnabled"` + MetricsPort string `mapstructure:"MetricsPort"` + FileTTL string `mapstructure:"FileTTL"` + MinFreeBytes int64 `mapstructure:"MinFreeBytes"` // Minimum free bytes required + DeduplicationEnabled bool `mapstructure:"DeduplicationEnabled"` +} + +type TimeoutConfig struct { + ReadTimeout string `mapstructure:"ReadTimeout"` + WriteTimeout string `mapstructure:"WriteTimeout"` + IdleTimeout string `mapstructure:"IdleTimeout"` +} + +type SecurityConfig struct { + Secret string `mapstructure:"Secret"` +} + +type VersioningConfig struct { + EnableVersioning bool `mapstructure:"EnableVersioning"` + MaxVersions int `mapstructure:"MaxVersions"` +} + +type UploadsConfig struct { + ResumableUploadsEnabled bool `mapstructure:"ResumableUploadsEnabled"` + ChunkedUploadsEnabled bool `mapstructure:"ChunkedUploadsEnabled"` + ChunkSize int64 `mapstructure:"ChunkSize"` + AllowedExtensions []string `mapstructure:"AllowedExtensions"` +} + +type ClamAVConfig struct { + ClamAVEnabled bool `mapstructure:"ClamAVEnabled"` + ClamAVSocket string `mapstructure:"ClamAVSocket"` + NumScanWorkers int `mapstructure:"NumScanWorkers"` +} + +type RedisConfig struct { + RedisEnabled bool `mapstructure:"RedisEnabled"` + RedisDBIndex int `mapstructure:"RedisDBIndex"` + RedisAddr string `mapstructure:"RedisAddr"` + RedisPassword string `mapstructure:"RedisPassword"` + RedisHealthCheckInterval string `mapstructure:"RedisHealthCheckInterval"` +} + +type WorkersConfig struct { + NumWorkers int `mapstructure:"NumWorkers"` + UploadQueueSize int `mapstructure:"UploadQueueSize"` +} + +type FileConfig struct { + FileRevision int `mapstructure:"FileRevision"` +} + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Timeouts TimeoutConfig `mapstructure:"timeouts"` + Security SecurityConfig `mapstructure:"security"` + Versioning VersioningConfig `mapstructure:"versioning"` + Uploads UploadsConfig `mapstructure:"uploads"` + ClamAV ClamAVConfig `mapstructure:"clamav"` + Redis RedisConfig `mapstructure:"redis"` + Workers WorkersConfig `mapstructure:"workers"` + File FileConfig `mapstructure:"file"` +} + +// UploadTask represents a file upload task +type UploadTask struct { + AbsFilename string + Request *http.Request + Result chan error +} + +// ScanTask represents a file scan task +type ScanTask struct { + AbsFilename string + Result chan error +} + +// NetworkEvent represents a network-related event +type NetworkEvent struct { + Type string + Details string +} + +var ( + conf Config + versionString string = "v2.0-dev" + log = logrus.New() + uploadQueue chan UploadTask + networkEvents chan NetworkEvent + fileInfoCache *cache.Cache + clamClient *clamd.Clamd // Added for ClamAV integration + redisClient *redis.Client // Redis client + redisConnected bool // Redis connection status + mu sync.RWMutex + + // Prometheus metrics + uploadDuration prometheus.Histogram + uploadErrorsTotal prometheus.Counter + uploadsTotal prometheus.Counter + downloadDuration prometheus.Histogram + downloadsTotal prometheus.Counter + downloadErrorsTotal prometheus.Counter + memoryUsage prometheus.Gauge + cpuUsage prometheus.Gauge + activeConnections prometheus.Gauge + requestsTotal *prometheus.CounterVec + goroutines prometheus.Gauge + uploadSizeBytes prometheus.Histogram + downloadSizeBytes prometheus.Histogram + + // Constants for worker pool + MinWorkers = 5 // Increased from 10 to 20 for better concurrency + UploadQueueSize = 10000 // Increased from 5000 to 10000 + + // Channels + scanQueue chan ScanTask + ScanWorkers = 5 // Number of ClamAV scan workers +) + +func main() { + // Set default configuration values + setDefaults() + + // Flags for configuration file + var configFile string + flag.StringVar(&configFile, "config", "./config.toml", "Path to configuration file \"config.toml\".") + flag.Parse() + + // Load configuration + err := readConfig(configFile, &conf) + if err != nil { + log.Fatalf("Error reading config: %v", err) // Fatal: application cannot proceed + } + log.Info("Configuration loaded successfully.") + + // Initialize file info cache + fileInfoCache = cache.New(5*time.Minute, 10*time.Minute) + + // Create store directory + err = os.MkdirAll(conf.Server.StoragePath, os.ModePerm) + if err != nil { + log.Fatalf("Error creating store directory: %v", err) + } + log.WithField("directory", conf.Server.StoragePath).Info("Store directory is ready") + + // Setup logging + setupLogging() + + // Log system information + logSystemInfo() + + // Initialize Prometheus metrics + initMetrics() + log.Info("Prometheus metrics initialized.") + + // Initialize upload and scan queues + uploadQueue = make(chan UploadTask, conf.Workers.UploadQueueSize) + scanQueue = make(chan ScanTask, conf.Workers.UploadQueueSize) + networkEvents = make(chan NetworkEvent, 100) + log.Info("Upload, scan, and network event channels initialized.") + + // Context for goroutines + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start network monitoring + go monitorNetwork(ctx) + go handleNetworkEvents(ctx) + + // Update system metrics + go updateSystemMetrics(ctx) + + // Initialize ClamAV client if enabled + if conf.ClamAV.ClamAVEnabled { + clamClient, err = initClamAV(conf.ClamAV.ClamAVSocket) + if err != nil { + log.WithFields(logrus.Fields{ + "error": err.Error(), + }).Warn("ClamAV client initialization failed. Continuing without ClamAV.") + } else { + log.Info("ClamAV client initialized successfully.") + } + } + + // Initialize Redis client if enabled + if conf.Redis.RedisEnabled { + initRedis() + } + + // Redis Initialization + initRedis() + log.Info("Redis client initialized and connected successfully.") + + // ClamAV Initialization + if conf.ClamAV.ClamAVEnabled { + clamClient, err = initClamAV(conf.ClamAV.ClamAVSocket) + if err != nil { + log.WithFields(logrus.Fields{ + "error": err.Error(), + }).Warn("ClamAV client initialization failed. Continuing without ClamAV.") + } else { + log.Info("ClamAV client initialized successfully.") + } + } + + // Initialize worker pools + initializeUploadWorkerPool(ctx) + if conf.ClamAV.ClamAVEnabled && clamClient != nil { + initializeScanWorkerPool(ctx) + } + + // Start Redis health monitor if Redis is enabled + if conf.Redis.RedisEnabled && redisClient != nil { + go MonitorRedisHealth(ctx, redisClient, parseDuration(conf.Redis.RedisHealthCheckInterval)) + } + + // Setup router + router := setupRouter() + + // Start file cleaner + fileTTL, err := time.ParseDuration(conf.Server.FileTTL) + if err != nil { + log.Fatalf("Invalid FileTTL: %v", err) + } + go runFileCleaner(ctx, conf.Server.StoragePath, fileTTL) + + // Parse timeout durations + readTimeout, err := time.ParseDuration(conf.Timeouts.ReadTimeout) + if err != nil { + log.Fatalf("Invalid ReadTimeout: %v", err) + } + + writeTimeout, err := time.ParseDuration(conf.Timeouts.WriteTimeout) + if err != nil { + log.Fatalf("Invalid WriteTimeout: %v", err) + } + + idleTimeout, err := time.ParseDuration(conf.Timeouts.IdleTimeout) + if err != nil { + log.Fatalf("Invalid IdleTimeout: %v", err) + } + + // Configure HTTP server + server := &http.Server{ + Addr: ":" + conf.Server.ListenPort, // Prepend colon to ListenPort + Handler: router, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + + // Start metrics server if enabled + if conf.Server.MetricsEnabled { + go func() { + http.Handle("/metrics", promhttp.Handler()) + log.Infof("Metrics server started on port %s", conf.Server.MetricsPort) + if err := http.ListenAndServe(":"+conf.Server.MetricsPort, nil); err != nil { + log.Fatalf("Metrics server failed: %v", err) + } + }() + } + + // Setup graceful shutdown + setupGracefulShutdown(server, cancel) + + // Start server + log.Infof("Starting HMAC file server %s...", versionString) + if conf.Server.UnixSocket { + // Listen on Unix socket + if err := os.RemoveAll(conf.Server.ListenPort); err != nil { + log.Fatalf("Failed to remove existing Unix socket: %v", err) + } + listener, err := net.Listen("unix", conf.Server.ListenPort) + if err != nil { + log.Fatalf("Failed to listen on Unix socket %s: %v", conf.Server.ListenPort, err) + } + defer listener.Close() + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + } else { + // Listen on TCP port + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + } +} + +// Function to load configuration using Viper +func readConfig(configFilename string, conf *Config) error { + viper.SetConfigFile(configFilename) + viper.SetConfigType("toml") + + // Read in environment variables that match + viper.AutomaticEnv() + viper.SetEnvPrefix("HMAC") // Prefix for environment variables + + // Read the config file + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("error reading config file: %w", err) + } + + // Unmarshal the config into the Config struct + if err := viper.Unmarshal(conf); err != nil { + return fmt.Errorf("unable to decode into struct: %w", err) + } + + // Debug log the loaded configuration + log.Debugf("Loaded Configuration: %+v", conf.Server) + + // Validate the configuration + if err := validateConfig(conf); err != nil { + return fmt.Errorf("configuration validation failed: %w", err) + } + + // Set Deduplication Enabled + conf.Server.DeduplicationEnabled = viper.GetBool("deduplication.Enabled") + + return nil +} + +// Set default configuration values +func setDefaults() { + // Server defaults + viper.SetDefault("server.ListenPort", "8080") + viper.SetDefault("server.UnixSocket", false) + viper.SetDefault("server.StoragePath", "./uploads") + viper.SetDefault("server.LogLevel", "info") + viper.SetDefault("server.LogFile", "") + viper.SetDefault("server.MetricsEnabled", true) + viper.SetDefault("server.MetricsPort", "9090") + viper.SetDefault("server.FileTTL", "8760h") // 365d -> 8760h + viper.SetDefault("server.MinFreeBytes", 100<<20) // 100 MB + + // Timeout defaults + viper.SetDefault("timeouts.ReadTimeout", "4800s") // supports 's' + viper.SetDefault("timeouts.WriteTimeout", "4800s") + viper.SetDefault("timeouts.IdleTimeout", "4800s") + + // Security defaults + viper.SetDefault("security.Secret", "changeme") + + // Versioning defaults + viper.SetDefault("versioning.EnableVersioning", false) + viper.SetDefault("versioning.MaxVersions", 1) + + // Uploads defaults + viper.SetDefault("uploads.ResumableUploadsEnabled", true) + viper.SetDefault("uploads.ChunkedUploadsEnabled", true) + viper.SetDefault("uploads.ChunkSize", 8192) + viper.SetDefault("uploads.AllowedExtensions", []string{ + ".txt", ".pdf", + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".svg", ".webp", + ".wav", ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".mpeg", ".mpg", ".m4v", ".3gp", ".3g2", + ".mp3", ".ogg", + }) + + // ClamAV defaults + viper.SetDefault("clamav.ClamAVEnabled", true) + viper.SetDefault("clamav.ClamAVSocket", "/var/run/clamav/clamd.ctl") + viper.SetDefault("clamav.NumScanWorkers", 2) + + // Redis defaults + viper.SetDefault("redis.RedisEnabled", true) + viper.SetDefault("redis.RedisAddr", "localhost:6379") + viper.SetDefault("redis.RedisPassword", "") + viper.SetDefault("redis.RedisDBIndex", 0) + viper.SetDefault("redis.RedisHealthCheckInterval", "120s") + + // Workers defaults + viper.SetDefault("workers.NumWorkers", 2) + viper.SetDefault("workers.UploadQueueSize", 50) + + // Deduplication defaults + viper.SetDefault("deduplication.Enabled", true) +} + +// Validate configuration fields +func validateConfig(conf *Config) error { + if conf.Server.ListenPort == "" { + return fmt.Errorf("ListenPort must be set") + } + if conf.Security.Secret == "" { + return fmt.Errorf("secret must be set") + } + if conf.Server.StoragePath == "" { + return fmt.Errorf("StoragePath must be set") + } + if conf.Server.FileTTL == "" { + return fmt.Errorf("FileTTL must be set") + } + + // Validate timeouts + if _, err := time.ParseDuration(conf.Timeouts.ReadTimeout); err != nil { + return fmt.Errorf("invalid ReadTimeout: %v", err) + } + if _, err := time.ParseDuration(conf.Timeouts.WriteTimeout); err != nil { + return fmt.Errorf("invalid WriteTimeout: %v", err) + } + if _, err := time.ParseDuration(conf.Timeouts.IdleTimeout); err != nil { + return fmt.Errorf("invalid IdleTimeout: %v", err) + } + + // Validate Redis configuration if enabled + if conf.Redis.RedisEnabled { + if conf.Redis.RedisAddr == "" { + return fmt.Errorf("RedisAddr must be set when Redis is enabled") + } + } + + // Add more validations as needed + + return nil +} + +// Setup logging +func setupLogging() { + level, err := logrus.ParseLevel(conf.Server.LogLevel) + if err != nil { + log.Fatalf("Invalid log level: %s", conf.Server.LogLevel) + } + log.SetLevel(level) + + if conf.Server.LogFile != "" { + logFile, err := os.OpenFile(conf.Server.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + log.SetOutput(io.MultiWriter(os.Stdout, logFile)) + } else { + log.SetOutput(os.Stdout) + } + + // Use Text formatter for human-readable logs + log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + // You can customize the format further if needed + }) +} + +// Log system information +func logSystemInfo() { + log.Info("========================================") + log.Infof(" HMAC File Server - %s ", versionString) + log.Info(" Secure File Handling with HMAC Auth ") + log.Info("========================================") + + log.Info("Features: Prometheus Metrics, Chunked Uploads, ClamAV Scanning") + log.Info("Build Date: 2024-10-28") + + log.Infof("Operating System: %s", runtime.GOOS) + log.Infof("Architecture: %s", runtime.GOARCH) + log.Infof("Number of CPUs: %d", runtime.NumCPU()) + log.Infof("Go Version: %s", runtime.Version()) + + v, _ := mem.VirtualMemory() + log.Infof("Total Memory: %v MB", v.Total/1024/1024) + log.Infof("Free Memory: %v MB", v.Free/1024/1024) + log.Infof("Used Memory: %v MB", v.Used/1024/1024) + + cpuInfo, _ := cpu.Info() + for _, info := range cpuInfo { + log.Infof("CPU Model: %s, Cores: %d, Mhz: %f", info.ModelName, info.Cores, info.Mhz) + } + + partitions, _ := disk.Partitions(false) + for _, partition := range partitions { + usage, _ := disk.Usage(partition.Mountpoint) + log.Infof("Disk Mountpoint: %s, Total: %v GB, Free: %v GB, Used: %v GB", + partition.Mountpoint, usage.Total/1024/1024/1024, usage.Free/1024/1024/1024, usage.Used/1024/1024/1024) + } + + hInfo, _ := host.Info() + log.Infof("Hostname: %s", hInfo.Hostname) + log.Infof("Uptime: %v seconds", hInfo.Uptime) + log.Infof("Boot Time: %v", time.Unix(int64(hInfo.BootTime), 0)) + log.Infof("Platform: %s", hInfo.Platform) + log.Infof("Platform Family: %s", hInfo.PlatformFamily) + log.Infof("Platform Version: %s", hInfo.PlatformVersion) + log.Infof("Kernel Version: %s", hInfo.KernelVersion) +} + +// Initialize Prometheus metrics +// Duplicate initMetrics function removed +func initMetrics() { + uploadDuration = prometheus.NewHistogram(prometheus.HistogramOpts{Namespace: "hmac", Name: "file_server_upload_duration_seconds", Help: "Histogram of file upload duration in seconds.", Buckets: prometheus.DefBuckets}) + uploadErrorsTotal = prometheus.NewCounter(prometheus.CounterOpts{Namespace: "hmac", Name: "file_server_upload_errors_total", Help: "Total number of file upload errors."}) + uploadsTotal = prometheus.NewCounter(prometheus.CounterOpts{Namespace: "hmac", Name: "file_server_uploads_total", Help: "Total number of successful file uploads."}) + downloadDuration = prometheus.NewHistogram(prometheus.HistogramOpts{Namespace: "hmac", Name: "file_server_download_duration_seconds", Help: "Histogram of file download duration in seconds.", Buckets: prometheus.DefBuckets}) + downloadsTotal = prometheus.NewCounter(prometheus.CounterOpts{Namespace: "hmac", Name: "file_server_downloads_total", Help: "Total number of successful file downloads."}) + downloadErrorsTotal = prometheus.NewCounter(prometheus.CounterOpts{Namespace: "hmac", Name: "file_server_download_errors_total", Help: "Total number of file download errors."}) + memoryUsage = prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "hmac", Name: "memory_usage_bytes", Help: "Current memory usage in bytes."}) + cpuUsage = prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "hmac", Name: "cpu_usage_percent", Help: "Current CPU usage as a percentage."}) + activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "hmac", Name: "active_connections_total", Help: "Total number of active connections."}) + requestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "hmac", Name: "http_requests_total", Help: "Total number of HTTP requests received, labeled by method and path."}, []string{"method", "path"}) + goroutines = prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "hmac", Name: "goroutines_count", Help: "Current number of goroutines."}) + uploadSizeBytes = prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "hmac", + Name: "file_server_upload_size_bytes", + Help: "Histogram of uploaded file sizes in bytes.", + Buckets: prometheus.ExponentialBuckets(100, 10, 8), + }) + downloadSizeBytes = prometheus.NewHistogram(prometheus.HistogramOpts{ + Namespace: "hmac", + Name: "file_server_download_size_bytes", + Help: "Histogram of downloaded file sizes in bytes.", + Buckets: prometheus.ExponentialBuckets(100, 10, 8), + }) + + if conf.Server.MetricsEnabled { + prometheus.MustRegister(uploadDuration, uploadErrorsTotal, uploadsTotal) + prometheus.MustRegister(downloadDuration, downloadsTotal, downloadErrorsTotal) + prometheus.MustRegister(memoryUsage, cpuUsage, activeConnections, requestsTotal, goroutines) + prometheus.MustRegister(uploadSizeBytes, downloadSizeBytes) + } +} + +// Update system metrics +func updateSystemMetrics(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info("Stopping system metrics updater.") + return + case <-ticker.C: + v, _ := mem.VirtualMemory() + memoryUsage.Set(float64(v.Used)) + + cpuPercent, _ := cpu.Percent(0, false) + if len(cpuPercent) > 0 { + cpuUsage.Set(cpuPercent[0]) + } + + goroutines.Set(float64(runtime.NumGoroutine())) + } + } +} + +// Function to check if a file exists and return its size +func fileExists(filePath string) (bool, int64) { + if cachedInfo, found := fileInfoCache.Get(filePath); found { + if info, ok := cachedInfo.(os.FileInfo); ok { + return !info.IsDir(), info.Size() + } + } + + fileInfo, err := os.Stat(filePath) + if os.IsNotExist(err) { + return false, 0 + } else if err != nil { + log.Error("Error checking file existence:", err) + return false, 0 + } + + fileInfoCache.Set(filePath, fileInfo, cache.DefaultExpiration) + return !fileInfo.IsDir(), fileInfo.Size() +} + +// Function to check file extension +func isExtensionAllowed(filename string) bool { + if len(conf.Uploads.AllowedExtensions) == 0 { + return true // No restrictions if the list is empty + } + ext := strings.ToLower(filepath.Ext(filename)) + for _, allowedExt := range conf.Uploads.AllowedExtensions { + if strings.ToLower(allowedExt) == ext { + return true + } + } + return false +} + +// Version the file by moving the existing file to a versioned directory +func versionFile(absFilename string) error { + versionDir := absFilename + "_versions" + + err := os.MkdirAll(versionDir, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create version directory: %v", err) + } + + timestamp := time.Now().Format("20060102-150405") + versionedFilename := filepath.Join(versionDir, filepath.Base(absFilename)+"."+timestamp) + + err = os.Rename(absFilename, versionedFilename) + if err != nil { + return fmt.Errorf("failed to version the file: %v", err) + } + + log.WithFields(logrus.Fields{ + "original": absFilename, + "versioned_as": versionedFilename, + }).Info("Versioned old file") + return cleanupOldVersions(versionDir) +} + +// Clean up older versions if they exceed the maximum allowed +func cleanupOldVersions(versionDir string) error { + files, err := os.ReadDir(versionDir) + if err != nil { + return fmt.Errorf("failed to list version files: %v", err) + } + + if conf.Versioning.MaxVersions > 0 && len(files) > conf.Versioning.MaxVersions { + excessFiles := len(files) - conf.Versioning.MaxVersions + for i := 0; i < excessFiles; i++ { + err := os.Remove(filepath.Join(versionDir, files[i].Name())) + if err != nil { + return fmt.Errorf("failed to remove old version: %v", err) + } + log.WithField("file", files[i].Name()).Info("Removed old version") + } + } + + return nil +} + +// Process the upload task +func processUpload(task UploadTask) error { + absFilename := task.AbsFilename + tempFilename := absFilename + ".tmp" + r := task.Request + + log.Infof("Processing upload for file: %s", absFilename) + startTime := time.Now() + + // Handle uploads and write to a temporary file + if conf.Uploads.ChunkedUploadsEnabled { + log.Debugf("Chunked uploads enabled. Handling chunked upload for %s", tempFilename) + err := handleChunkedUpload(tempFilename, r) + if err != nil { + uploadDuration.Observe(time.Since(startTime).Seconds()) + log.WithFields(logrus.Fields{ + "file": tempFilename, + "error": err, + }).Error("Failed to handle chunked upload") + return err + } + } else { + log.Debugf("Handling standard upload for %s", tempFilename) + err := createFile(tempFilename, r) + if err != nil { + log.WithFields(logrus.Fields{ + "file": tempFilename, + "error": err, + }).Error("Error creating file") + uploadDuration.Observe(time.Since(startTime).Seconds()) + return err + } + } + + // Perform ClamAV scan on the temporary file + if clamClient != nil { + log.Debugf("Scanning %s with ClamAV", tempFilename) + err := scanFileWithClamAV(tempFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "file": tempFilename, + "error": err, + }).Warn("ClamAV detected a virus or scan failed") + os.Remove(tempFilename) + uploadErrorsTotal.Inc() + return err + } + log.Infof("ClamAV scan passed for file: %s", tempFilename) + } + + // Handle file versioning if enabled + if conf.Versioning.EnableVersioning { + existing, _ := fileExists(absFilename) + if existing { + log.Infof("File %s exists. Initiating versioning.", absFilename) + err := versionFile(absFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "file": absFilename, + "error": err, + }).Error("Error versioning file") + os.Remove(tempFilename) + return err + } + log.Infof("File versioned successfully: %s", absFilename) + } + } + + // Rename temporary file to final destination + err := os.Rename(tempFilename, absFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "temp_file": tempFilename, + "final_file": absFilename, + "error": err, + }).Error("Failed to move file to final destination") + os.Remove(tempFilename) + return err + } + log.Infof("File moved to final destination: %s", absFilename) + + // Handle deduplication if enabled + if conf.Server.DeduplicationEnabled { + log.Debugf("Deduplication enabled. Checking duplicates for %s", absFilename) + err = handleDeduplication(context.Background(), absFilename) + if err != nil { + log.WithError(err).Error("Deduplication failed") + uploadErrorsTotal.Inc() + return err + } + log.Infof("Deduplication handled successfully for file: %s", absFilename) + } + + log.WithFields(logrus.Fields{ + "file": absFilename, + }).Info("File uploaded and processed successfully") + + uploadDuration.Observe(time.Since(startTime).Seconds()) + uploadsTotal.Inc() + return nil +} + +// uploadWorker processes upload tasks from the uploadQueue +func uploadWorker(ctx context.Context, workerID int) { + log.Infof("Upload worker %d started.", workerID) + defer log.Infof("Upload worker %d stopped.", workerID) + for { + select { + case <-ctx.Done(): + return + case task, ok := <-uploadQueue: + if !ok { + log.Warnf("Upload queue closed. Worker %d exiting.", workerID) + return + } + log.Infof("Worker %d processing upload for file: %s", workerID, task.AbsFilename) + err := processUpload(task) + if err != nil { + log.Errorf("Worker %d failed to process upload for %s: %v", workerID, task.AbsFilename, err) + uploadErrorsTotal.Inc() + } else { + log.Infof("Worker %d successfully processed upload for %s", workerID, task.AbsFilename) + } + task.Result <- err + close(task.Result) + } + } +} + +// Initialize upload worker pool +func initializeUploadWorkerPool(ctx context.Context) { + for i := 0; i < MinWorkers; i++ { + go uploadWorker(ctx, i) + } + log.Infof("Initialized %d upload workers", MinWorkers) +} + +// Worker function to process scan tasks +func scanWorker(ctx context.Context, workerID int) { + log.WithField("worker_id", workerID).Info("Scan worker started") + for { + select { + case <-ctx.Done(): + log.WithField("worker_id", workerID).Info("Scan worker stopping") + return + case task, ok := <-scanQueue: + if !ok { + log.WithField("worker_id", workerID).Info("Scan queue closed") + return + } + log.WithFields(logrus.Fields{ + "worker_id": workerID, + "file": task.AbsFilename, + }).Info("Processing scan task") + err := scanFileWithClamAV(task.AbsFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "worker_id": workerID, + "file": task.AbsFilename, + "error": err, + }).Error("Failed to scan file") + } else { + log.WithFields(logrus.Fields{ + "worker_id": workerID, + "file": task.AbsFilename, + }).Info("Successfully scanned file") + } + task.Result <- err + close(task.Result) + } + } +} + +// Initialize scan worker pool +func initializeScanWorkerPool(ctx context.Context) { + for i := 0; i < ScanWorkers; i++ { + go scanWorker(ctx, i) + } + log.Infof("Initialized %d scan workers", ScanWorkers) +} + +// Setup router with middleware +func setupRouter() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", handleRequest) + if conf.Server.MetricsEnabled { + mux.Handle("/metrics", promhttp.Handler()) + } + + // Apply middleware + handler := loggingMiddleware(mux) + handler = recoveryMiddleware(handler) + handler = corsMiddleware(handler) + return handler +} + +// Middleware for logging +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestsTotal.WithLabelValues(r.Method, r.URL.Path).Inc() + next.ServeHTTP(w, r) + }) +} + +// Middleware for panic recovery +func recoveryMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.WithFields(logrus.Fields{ + "method": r.Method, + "url": r.URL.String(), + "error": rec, + }).Error("Panic recovered in HTTP handler") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +// corsMiddleware handles CORS by setting appropriate headers +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-File-MAC") + w.Header().Set("Access-Control-Max-Age", "86400") // Cache preflight response for 1 day + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + // Proceed to the next handler + next.ServeHTTP(w, r) + }) +} + +// Handle file uploads and downloads +func handleRequest(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { + absFilename, err := sanitizeFilePath(conf.Server.StoragePath, strings.TrimPrefix(r.URL.Path, "/")) + if err != nil { + log.WithError(err).Error("Invalid file path") + http.Error(w, "Invalid file path", http.StatusBadRequest) + return + } + err = handleMultipartUpload(w, r, absFilename) + if err != nil { + log.WithError(err).Error("Failed to handle multipart upload") + http.Error(w, "Failed to handle multipart upload", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + return + } + + // Get client IP address + clientIP := r.Header.Get("X-Real-IP") + if clientIP == "" { + clientIP = r.Header.Get("X-Forwarded-For") + } + if clientIP == "" { + // Fallback to RemoteAddr + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.WithError(err).Warn("Failed to parse RemoteAddr") + clientIP = r.RemoteAddr + } else { + clientIP = host + } + } + + // Log the request with the client IP + log.WithFields(logrus.Fields{ + "method": r.Method, + "url": r.URL.String(), + "remote": clientIP, + }).Info("Incoming request") + + // Parse URL and query parameters + p := r.URL.Path + a, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + log.Warn("Failed to parse query parameters") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + fileStorePath := strings.TrimPrefix(p, "/") + if fileStorePath == "" || fileStorePath == "/" { + log.Warn("Access to root directory is forbidden") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } else if fileStorePath[0] == '/' { + fileStorePath = fileStorePath[1:] + } + + absFilename, err := sanitizeFilePath(conf.Server.StoragePath, fileStorePath) + if err != nil { + log.WithFields(logrus.Fields{ + "file": fileStorePath, + "error": err, + }).Warn("Invalid file path") + http.Error(w, "Invalid file path", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPut: + handleUpload(w, r, absFilename, fileStorePath, a) + case http.MethodHead, http.MethodGet: + handleDownload(w, r, absFilename, fileStorePath) + case http.MethodOptions: + // Handled by NGINX; no action needed + w.Header().Set("Allow", "OPTIONS, GET, PUT, HEAD") + return + default: + log.WithField("method", r.Method).Warn("Invalid HTTP method for upload directory") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } +} + +// Handle file uploads with extension restrictions and HMAC validation +func handleUpload(w http.ResponseWriter, r *http.Request, absFilename, fileStorePath string, a url.Values) { + // Log the storage path being used + log.Infof("Using storage path: %s", conf.Server.StoragePath) + + // Determine protocol version based on query parameters + var protocolVersion string + if a.Get("v2") != "" { + protocolVersion = "v2" + } else if a.Get("token") != "" { + protocolVersion = "token" + } else if a.Get("v") != "" { + protocolVersion = "v" + } else { + log.Warn("No HMAC attached to URL. Expecting 'v', 'v2', or 'token' parameter as MAC") + http.Error(w, "No HMAC attached to URL. Expecting 'v', 'v2', or 'token' parameter as MAC", http.StatusForbidden) + return + } + log.Debugf("Protocol version determined: %s", protocolVersion) + + // Initialize HMAC + mac := hmac.New(sha256.New, []byte(conf.Security.Secret)) + + // Calculate MAC based on protocolVersion + if protocolVersion == "v" { + mac.Write([]byte(fileStorePath + "\x20" + strconv.FormatInt(r.ContentLength, 10))) + } else if protocolVersion == "v2" || protocolVersion == "token" { + contentType := mime.TypeByExtension(filepath.Ext(fileStorePath)) + if contentType == "" { + contentType = "application/octet-stream" + } + mac.Write([]byte(fileStorePath + "\x00" + strconv.FormatInt(r.ContentLength, 10) + "\x00" + contentType)) + } + + calculatedMAC := mac.Sum(nil) + log.Debugf("Calculated MAC: %x", calculatedMAC) + + // Decode provided MAC from hex + providedMACHex := a.Get(protocolVersion) + providedMAC, err := hex.DecodeString(providedMACHex) + if err != nil { + log.Warn("Invalid MAC encoding") + http.Error(w, "Invalid MAC encoding", http.StatusForbidden) + return + } + log.Debugf("Provided MAC: %x", providedMAC) + + // Validate the HMAC + if !hmac.Equal(calculatedMAC, providedMAC) { + log.Warn("Invalid MAC") + http.Error(w, "Invalid MAC", http.StatusForbidden) + return + } + log.Debug("HMAC validation successful") + + // Validate file extension + if !isExtensionAllowed(fileStorePath) { + log.WithFields(logrus.Fields{ + // No need to sanitize and validate the file path here since absFilename is already sanitized in handleRequest + "file": fileStorePath, + "error": err, + }).Warn("Invalid file path") + http.Error(w, "Invalid file path", http.StatusBadRequest) + uploadErrorsTotal.Inc() + return + } + // absFilename = sanitizedFilename + + // Check if there is enough free space + err = checkStorageSpace(conf.Server.StoragePath, conf.Server.MinFreeBytes) + if err != nil { + log.WithFields(logrus.Fields{ + "storage_path": conf.Server.StoragePath, + "error": err, + }).Warn("Not enough free space") + http.Error(w, "Not enough free space", http.StatusInsufficientStorage) + uploadErrorsTotal.Inc() + return + } + + // Create an UploadTask with a result channel + result := make(chan error) + task := UploadTask{ + AbsFilename: absFilename, + Request: r, + Result: result, + } + + // Submit task to the upload queue + select { + case uploadQueue <- task: + // Successfully added to the queue + log.Debug("Upload task enqueued successfully") + default: + // Queue is full + log.Warn("Upload queue is full. Rejecting upload") + http.Error(w, "Server busy. Try again later.", http.StatusServiceUnavailable) + uploadErrorsTotal.Inc() + return + } + + // Wait for the worker to process the upload + err = <-result + if err != nil { + // The worker has already logged the error; send an appropriate HTTP response + http.Error(w, fmt.Sprintf("Upload failed: %v", err), http.StatusInternalServerError) + return + } + + // Upload was successful + w.WriteHeader(http.StatusCreated) +} + +// Handle file downloads +func handleDownload(w http.ResponseWriter, r *http.Request, absFilename, fileStorePath string) { + fileInfo, err := getFileInfo(absFilename) + if err != nil { + log.WithError(err).Error("Failed to get file information") + http.Error(w, "Not Found", http.StatusNotFound) + downloadErrorsTotal.Inc() + return + } else if fileInfo.IsDir() { + log.Warn("Directory listing forbidden") + http.Error(w, "Forbidden", http.StatusForbidden) + downloadErrorsTotal.Inc() + return + } + + contentType := mime.TypeByExtension(filepath.Ext(fileStorePath)) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + + // Handle resumable downloads + if conf.Uploads.ResumableUploadsEnabled { + handleResumableDownload(absFilename, w, r, fileInfo.Size()) + return + } + + if r.Method == http.MethodHead { + w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) + downloadsTotal.Inc() + return + } else { + // Measure download duration + startTime := time.Now() + log.Infof("Initiating download for file: %s", absFilename) + http.ServeFile(w, r, absFilename) + downloadDuration.Observe(time.Since(startTime).Seconds()) + downloadSizeBytes.Observe(float64(fileInfo.Size())) + downloadsTotal.Inc() + log.Infof("File downloaded successfully: %s", absFilename) + return + } +} + +// Create the file for upload with buffered Writer +func createFile(tempFilename string, r *http.Request) error { + absDirectory := filepath.Dir(tempFilename) + err := os.MkdirAll(absDirectory, os.ModePerm) + if err != nil { + log.WithError(err).Errorf("Failed to create directory %s", absDirectory) + return fmt.Errorf("failed to create directory %s: %w", absDirectory, err) + } + + // Open the file for writing + targetFile, err := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + log.WithError(err).Errorf("Failed to create file %s", tempFilename) + return fmt.Errorf("failed to create file %s: %w", tempFilename, err) + } + defer targetFile.Close() + + // Use a large buffer for efficient file writing + bufferSize := 4 * 1024 * 1024 // 4 MB buffer + writer := bufio.NewWriterSize(targetFile, bufferSize) + buffer := make([]byte, bufferSize) + + totalBytes := int64(0) + for { + n, readErr := r.Body.Read(buffer) + if n > 0 { + totalBytes += int64(n) + _, writeErr := writer.Write(buffer[:n]) + if writeErr != nil { + log.WithError(writeErr).Errorf("Failed to write to file %s", tempFilename) + return fmt.Errorf("failed to write to file %s: %w", tempFilename, writeErr) + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + log.WithError(readErr).Error("Failed to read request body") + return fmt.Errorf("failed to read request body: %w", readErr) + } + } + + err = writer.Flush() + if err != nil { + log.WithError(err).Errorf("Failed to flush buffer to file %s", tempFilename) + return fmt.Errorf("failed to flush buffer to file %s: %w", tempFilename, err) + } + + log.WithFields(logrus.Fields{ + "temp_file": tempFilename, + "total_bytes": totalBytes, + }).Info("File uploaded successfully") + + uploadSizeBytes.Observe(float64(totalBytes)) + return nil +} + +// Scan the uploaded file with ClamAV (Optional) +func scanFileWithClamAV(filePath string) error { + log.WithField("file", filePath).Info("Scanning file with ClamAV") + + scanResultChan, err := clamClient.ScanFile(filePath) + if err != nil { + log.WithError(err).Error("Failed to initiate ClamAV scan") + return fmt.Errorf("failed to initiate ClamAV scan: %w", err) + } + + // Receive scan result + scanResult := <-scanResultChan + if scanResult == nil { + log.Error("Failed to receive scan result from ClamAV") + return fmt.Errorf("failed to receive scan result from ClamAV") + } + + // Handle scan result + switch scanResult.Status { + case clamd.RES_OK: + log.WithField("file", filePath).Info("ClamAV scan passed") + return nil + case clamd.RES_FOUND: + log.WithFields(logrus.Fields{ + "file": filePath, + "description": scanResult.Description, + }).Warn("ClamAV detected a virus") + return fmt.Errorf("virus detected: %s", scanResult.Description) + default: + log.WithFields(logrus.Fields{ + "file": filePath, + "status": scanResult.Status, + "description": scanResult.Description, + }).Warn("ClamAV scan returned unexpected status") + return fmt.Errorf("ClamAV scan returned unexpected status: %s", scanResult.Description) + } +} + +// initClamAV initializes the ClamAV client and logs the status +func initClamAV(socket string) (*clamd.Clamd, error) { + if socket == "" { + log.Error("ClamAV socket path is not configured.") + return nil, fmt.Errorf("ClamAV socket path is not configured") + } + + clamClient := clamd.NewClamd("unix:" + socket) + err := clamClient.Ping() + if err != nil { + log.Errorf("Failed to connect to ClamAV at %s: %v", socket, err) + return nil, fmt.Errorf("failed to connect to ClamAV: %w", err) + } + + log.Info("Connected to ClamAV successfully.") + return clamClient, nil +} + +// Handle resumable downloads +func handleResumableDownload(absFilename string, w http.ResponseWriter, r *http.Request, fileSize int64) { + rangeHeader := r.Header.Get("Range") + if rangeHeader == "" { + // If no Range header, serve the full file + startTime := time.Now() + http.ServeFile(w, r, absFilename) + downloadDuration.Observe(time.Since(startTime).Seconds()) + downloadSizeBytes.Observe(float64(fileSize)) + downloadsTotal.Inc() + return + } + + // Parse Range header + ranges := strings.Split(strings.TrimPrefix(rangeHeader, "bytes="), "-") + if len(ranges) != 2 { + http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) + downloadErrorsTotal.Inc() + return + } + + start, err := strconv.ParseInt(ranges[0], 10, 64) + if err != nil { + http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) + downloadErrorsTotal.Inc() + return + } + + // Calculate end byte + end := fileSize - 1 + if ranges[1] != "" { + end, err = strconv.ParseInt(ranges[1], 10, 64) + if err != nil || end >= fileSize { + http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable) + downloadErrorsTotal.Inc() + return + } + } + + // Set response headers for partial content + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize)) + w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + + // Serve the requested byte range + file, err := os.Open(absFilename) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + downloadErrorsTotal.Inc() + return + } + defer file.Close() + + // Seek to the start byte + _, err = file.Seek(start, 0) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + downloadErrorsTotal.Inc() + return + } + + // Create a buffer and copy the specified range to the response writer + buffer := make([]byte, 32*1024) // 32KB buffer + remaining := end - start + 1 + startTime := time.Now() + for remaining > 0 { + if int64(len(buffer)) > remaining { + buffer = buffer[:remaining] + } + n, err := file.Read(buffer) + if n > 0 { + if _, writeErr := w.Write(buffer[:n]); writeErr != nil { + log.WithError(writeErr).Error("Failed to write to response") + downloadErrorsTotal.Inc() + return + } + remaining -= int64(n) + } + if err != nil { + if err != io.EOF { + log.WithError(err).Error("Error reading file during resumable download") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + downloadErrorsTotal.Inc() + } + break + } + } + downloadDuration.Observe(time.Since(startTime).Seconds()) + downloadSizeBytes.Observe(float64(end - start + 1)) + downloadsTotal.Inc() +} + +// Handle chunked uploads with bufio.Writer +func handleChunkedUpload(tempFilename string, r *http.Request) error { + log.WithField("file", tempFilename).Info("Handling chunked upload to temporary file") + + // Ensure the directory exists + absDirectory := filepath.Dir(tempFilename) + err := os.MkdirAll(absDirectory, os.ModePerm) + if err != nil { + log.WithError(err).Errorf("Failed to create directory %s for chunked upload", absDirectory) + return fmt.Errorf("failed to create directory %s: %w", absDirectory, err) + } + + targetFile, err := os.OpenFile(tempFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + log.WithError(err).Error("Failed to open temporary file for chunked upload") + return err + } + defer targetFile.Close() + + writer := bufio.NewWriterSize(targetFile, int(conf.Uploads.ChunkSize)) + buffer := make([]byte, conf.Uploads.ChunkSize) + + totalBytes := int64(0) + for { + n, err := r.Body.Read(buffer) + if n > 0 { + totalBytes += int64(n) + _, writeErr := writer.Write(buffer[:n]) + if writeErr != nil { + log.WithError(writeErr).Error("Failed to write chunk to temporary file") + return writeErr + } + } + if err != nil { + if err == io.EOF { + break // Finished reading the body + } + log.WithError(err).Error("Error reading from request body") + return err + } + } + + err = writer.Flush() + if err != nil { + log.WithError(err).Error("Failed to flush buffer to temporary file") + return err + } + + log.WithFields(logrus.Fields{ + "temp_file": tempFilename, + "total_bytes": totalBytes, + }).Info("Chunked upload completed successfully") + + uploadSizeBytes.Observe(float64(totalBytes)) + return nil +} + +// Get file information with caching +func getFileInfo(absFilename string) (os.FileInfo, error) { + if cachedInfo, found := fileInfoCache.Get(absFilename); found { + if info, ok := cachedInfo.(os.FileInfo); ok { + return info, nil + } + } + + fileInfo, err := os.Stat(absFilename) + if err != nil { + return nil, err + } + + fileInfoCache.Set(absFilename, fileInfo, cache.DefaultExpiration) + return fileInfo, nil +} + +// Monitor network changes +func monitorNetwork(ctx context.Context) { + currentIP := getCurrentIPAddress() // Placeholder for the current IP address + + for { + select { + case <-ctx.Done(): + log.Info("Stopping network monitor.") + return + case <-time.After(10 * time.Second): + newIP := getCurrentIPAddress() + if newIP != currentIP && newIP != "" { + currentIP = newIP + select { + case networkEvents <- NetworkEvent{Type: "IP_CHANGE", Details: currentIP}: + log.WithField("new_ip", currentIP).Info("Queued IP_CHANGE event") + default: + log.Warn("Network event channel is full. Dropping IP_CHANGE event.") + } + } + } + } +} + +// Handle network events +func handleNetworkEvents(ctx context.Context) { + for { + select { + case <-ctx.Done(): + log.Info("Stopping network event handler.") + return + case event, ok := <-networkEvents: + if !ok { + log.Info("Network events channel closed.") + return + } + switch event.Type { + case "IP_CHANGE": + log.WithField("new_ip", event.Details).Info("Network change detected") + // Example: Update Prometheus gauge or trigger alerts + // activeConnections.Set(float64(getActiveConnections())) + } + // Additional event types can be handled here + } + } +} + +// Get current IP address (example) +func getCurrentIPAddress() string { + interfaces, err := net.Interfaces() + if err != nil { + log.WithError(err).Error("Failed to get network interfaces") + return "" + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue // Skip interfaces that are down or loopback + } + addrs, err := iface.Addrs() + if err != nil { + log.WithError(err).Errorf("Failed to get addresses for interface %s", iface.Name) + continue + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.IsGlobalUnicast() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "" +} + +// setupGracefulShutdown sets up handling for graceful server shutdown +func setupGracefulShutdown(server *http.Server, cancel context.CancelFunc) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-quit + log.Infof("Received signal %s. Initiating shutdown...", sig) + + // Create a deadline to wait for. + ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + // Attempt graceful shutdown + if err := server.Shutdown(ctxShutdown); err != nil { + log.Errorf("Server shutdown failed: %v", err) + } else { + log.Info("Server shutdown gracefully.") + } + + // Signal other goroutines to stop + cancel() + + // Close the upload, scan, and network event channels + close(uploadQueue) + log.Info("Upload queue closed.") + close(scanQueue) + log.Info("Scan queue closed.") + close(networkEvents) + log.Info("Network events channel closed.") + + log.Info("Shutdown process completed. Exiting application.") + os.Exit(0) + }() +} + +// Initialize Redis client +func initRedis() { + if !conf.Redis.RedisEnabled { + log.Info("Redis is disabled in configuration.") + return + } + + redisClient = redis.NewClient(&redis.Options{ + Addr: conf.Redis.RedisAddr, + Password: conf.Redis.RedisPassword, + DB: conf.Redis.RedisDBIndex, + }) + + // Test the Redis connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := redisClient.Ping(ctx).Result() + if err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + log.Info("Connected to Redis successfully") + + // Set initial connection status + mu.Lock() + redisConnected = true + mu.Unlock() + + // Start monitoring Redis health + go MonitorRedisHealth(context.Background(), redisClient, parseDuration(conf.Redis.RedisHealthCheckInterval)) +} + +// MonitorRedisHealth periodically checks Redis connectivity and updates redisConnected status. +func MonitorRedisHealth(ctx context.Context, client *redis.Client, checkInterval time.Duration) { + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info("Stopping Redis health monitor.") + return + case <-ticker.C: + err := client.Ping(ctx).Err() + mu.Lock() + if err != nil { + if redisConnected { + log.Errorf("Redis health check failed: %v", err) + } + redisConnected = false + } else { + if !redisConnected { + log.Info("Redis reconnected successfully") + } + redisConnected = true + log.Debug("Redis health check succeeded.") + } + mu.Unlock() + } + } +} + +// Helper function to parse duration strings +func parseDuration(durationStr string) time.Duration { + duration, err := time.ParseDuration(durationStr) + if err != nil { + log.WithError(err).Warn("Invalid duration format, using default 30s") + return 30 * time.Second + } + return duration +} + +// RunFileCleaner periodically deletes files that exceed the FileTTL duration. +func runFileCleaner(ctx context.Context, storeDir string, ttl time.Duration) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info("Stopping file cleaner.") + return + case <-ticker.C: + now := time.Now() + err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if now.Sub(info.ModTime()) > ttl { + err := os.Remove(path) + if err != nil { + log.WithError(err).Errorf("Failed to remove expired file: %s", path) + } else { + log.Infof("Removed expired file: %s", path) + } + } + return nil + }) + if err != nil { + log.WithError(err).Error("Error walking store directory for file cleaning") + } + } + } +} + +// DeduplicateFiles scans the store directory and removes duplicate files based on SHA256 hash. +// It retains one copy of each unique file and replaces duplicates with hard links. +func DeduplicateFiles(storeDir string) error { + hashMap := make(map[string]string) // map[hash]filepath + var mu sync.Mutex + var wg sync.WaitGroup + fileChan := make(chan string, 100) + + // Worker to process files + numWorkers := 10 + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for filePath := range fileChan { + hash, err := computeFileHash(filePath) + if err != nil { + logrus.WithError(err).Errorf("Failed to compute hash for %s", filePath) + continue + } + + mu.Lock() + original, exists := hashMap[hash] + if !exists { + hashMap[hash] = filePath + mu.Unlock() + continue + } + mu.Unlock() + + // Duplicate found + err = os.Remove(filePath) + if err != nil { + logrus.WithError(err).Errorf("Failed to remove duplicate file %s", filePath) + continue + } + + // Create hard link to the original file + err = os.Link(original, filePath) + if err != nil { + logrus.WithError(err).Errorf("Failed to create hard link from %s to %s", original, filePath) + continue + } + + logrus.Infof("Removed duplicate %s and linked to %s", filePath, original) + } + }() + } + + // Walk through the store directory + err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + logrus.WithError(err).Errorf("Error accessing path %s", path) + return nil + } + if !info.Mode().IsRegular() { + return nil + } + fileChan <- path + return nil + }) + if err != nil { + return fmt.Errorf("error walking the path %s: %w", storeDir, err) + } + + close(fileChan) + wg.Wait() + return nil +} + +// computeFileHash computes the SHA256 hash of the given file. +func computeFileHash(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("unable to open file %s: %w", filePath, err) + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", fmt.Errorf("error hashing file %s: %w", filePath, err) + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// Handle multipart uploads +func handleMultipartUpload(w http.ResponseWriter, r *http.Request, absFilename string) error { + err := r.ParseMultipartForm(32 << 20) // 32MB is the default used by FormFile + if err != nil { + log.WithError(err).Error("Failed to parse multipart form") + http.Error(w, "Failed to parse multipart form", http.StatusBadRequest) + return err + } + + file, handler, err := r.FormFile("file") + if err != nil { + log.WithError(err).Error("Failed to retrieve file from form data") + http.Error(w, "Failed to retrieve file from form data", http.StatusBadRequest) + return err + } + defer file.Close() + + // Validate file extension + if !isExtensionAllowed(handler.Filename) { + log.WithFields(logrus.Fields{ + "filename": handler.Filename, + "extension": filepath.Ext(handler.Filename), + }).Warn("Attempted upload with disallowed file extension") + http.Error(w, "Disallowed file extension. Allowed extensions are: "+strings.Join(conf.Uploads.AllowedExtensions, ", "), http.StatusForbidden) + uploadErrorsTotal.Inc() + return fmt.Errorf("disallowed file extension") + } + + // Create a temporary file + tempFilename := absFilename + ".tmp" + tempFile, err := os.OpenFile(tempFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) + if err != nil { + log.WithError(err).Error("Failed to create temporary file") + http.Error(w, "Failed to create temporary file", http.StatusInternalServerError) + return err + } + defer tempFile.Close() + + // Copy the uploaded file to the temporary file + _, err = io.Copy(tempFile, file) + if err != nil { + log.WithError(err).Error("Failed to copy uploaded file to temporary file") + http.Error(w, "Failed to copy uploaded file", http.StatusInternalServerError) + return err + } + + // Perform ClamAV scan on the temporary file + if clamClient != nil { + err := scanFileWithClamAV(tempFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "file": tempFilename, + "error": err, + }).Warn("ClamAV detected a virus or scan failed") + os.Remove(tempFilename) + uploadErrorsTotal.Inc() + return err + } + } + + // Handle file versioning if enabled + if conf.Versioning.EnableVersioning { + existing, _ := fileExists(absFilename) + if existing { + err := versionFile(absFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "file": absFilename, + "error": err, + }).Error("Error versioning file") + os.Remove(tempFilename) + return err + } + } + } + + // Move the temporary file to the final destination + err = os.Rename(tempFilename, absFilename) + if err != nil { + log.WithFields(logrus.Fields{ + "temp_file": tempFilename, + "final_file": absFilename, + "error": err, + }).Error("Failed to move file to final destination") + os.Remove(tempFilename) + return err + } + + log.WithFields(logrus.Fields{ + "file": absFilename, + }).Info("File uploaded and scanned successfully") + + uploadsTotal.Inc() + return nil +} + +// sanitizeFilePath ensures that the file path is within the designated storage directory +func sanitizeFilePath(baseDir, filePath string) (string, error) { + // Resolve the absolute path + absBaseDir, err := filepath.Abs(baseDir) + if err != nil { + return "", fmt.Errorf("failed to resolve base directory: %w", err) + } + + absFilePath, err := filepath.Abs(filepath.Join(absBaseDir, filePath)) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + + // Check if the resolved file path is within the base directory + if !strings.HasPrefix(absFilePath, absBaseDir) { + return "", fmt.Errorf("invalid file path: %s", filePath) + } + + return absFilePath, nil +} + +// checkStorageSpace ensures that there is enough free space in the storage path +func checkStorageSpace(storagePath string, minFreeBytes int64) error { + var stat syscall.Statfs_t + err := syscall.Statfs(storagePath, &stat) + if err != nil { + return fmt.Errorf("failed to get filesystem stats: %w", err) + } + + // Calculate available bytes + availableBytes := stat.Bavail * uint64(stat.Bsize) + if int64(availableBytes) < minFreeBytes { + return fmt.Errorf("not enough free space: %d bytes available, %d bytes required", availableBytes, minFreeBytes) + } + + return nil +} + +// Function to compute SHA256 checksum of a file +func computeSHA256(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open file for checksum: %w", err) + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", fmt.Errorf("failed to compute checksum: %w", err) + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// handleDeduplication handles file deduplication using SHA256 checksum and hard links +func handleDeduplication(ctx context.Context, absFilename string) error { + // Compute checksum of the uploaded file + checksum, err := computeSHA256(absFilename) + if err != nil { + log.Errorf("Failed to compute SHA256 for %s: %v", absFilename, err) + return fmt.Errorf("checksum computation failed: %w", err) + } + log.Debugf("Computed checksum for %s: %s", absFilename, checksum) + + // Check Redis for existing checksum + existingPath, err := redisClient.Get(ctx, checksum).Result() + if err != nil && err != redis.Nil { + log.Errorf("Redis error while fetching checksum %s: %v", checksum, err) + return fmt.Errorf("redis error: %w", err) + } + + if err != redis.Nil { + // Duplicate found, create hard link + log.Infof("Duplicate detected: %s already exists at %s", absFilename, existingPath) + err = os.Link(existingPath, absFilename) + if err != nil { + log.Errorf("Failed to create hard link from %s to %s: %v", existingPath, absFilename, err) + return fmt.Errorf("failed to create hard link: %w", err) + } + log.Infof("Created hard link from %s to %s", existingPath, absFilename) + return nil + } + + // No duplicate found, store checksum in Redis + err = redisClient.Set(ctx, checksum, absFilename, 0).Err() + if err != nil { + log.Errorf("Failed to store checksum %s in Redis: %v", checksum, err) + return fmt.Errorf("failed to store checksum in Redis: %w", err) + } + + log.Infof("Stored new file checksum in Redis: %s -> %s", checksum, absFilename) + return nil +} diff --git a/dashboard/dashboard.json b/dashboard/dashboard.json new file mode 100644 index 0000000..b17d0fc --- /dev/null +++ b/dashboard/dashboard.json @@ -0,0 +1,888 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 70, + "links": [], + "panels": [ + { + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 5, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "
\n

HMAC Dashboard

\n \"HMAC\n

\n This dashboard monitors key metrics for the \n HMAC File Server.\n

\n
\n", + "mode": "html" + }, + "pluginVersion": "11.3.1", + "title": "HMAC Dashboard", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 6 + }, + "id": 14, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_cpu_usage_percent", + "format": "table", + "legendFormat": "Downloads", + "range": true, + "refId": "A" + } + ], + "title": "HMAC CPU Usage", + "type": "bargauge" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 6 + }, + "id": 18, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_memory_usage_bytes", + "legendFormat": "Download Errors", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Memory Usage", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 6 + }, + "id": 17, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_goroutines_count", + "format": "table", + "legendFormat": "Download Errors", + "range": true, + "refId": "A" + } + ], + "title": "HMAC GoRoutines", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 18, + "y": 6 + }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_file_server_uploads_total", + "format": "table", + "legendFormat": "Uploads", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Uploads", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 21, + "y": 6 + }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_file_server_downloads_total", + "legendFormat": "Downloads", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Downloads", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 0, + "y": 11 + }, + "id": 15, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "bduehd5vqv1moa" + }, + "editorMode": "code", + "expr": "hmac_file_server_upload_duration_seconds_sum + hmac_file_server_download_duration_seconds_sum", + "hide": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B" + } + ], + "title": "HMAC Up/Down Duration", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 3, + "y": 11 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "go_threads", + "format": "table", + "legendFormat": "{{hmac-file-server}}", + "range": true, + "refId": "A" + } + ], + "title": "HMAC GO Threads", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 6, + "y": 11 + }, + "id": 21, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_file_deletions_total", + "format": "table", + "legendFormat": "{{hmac-file-server}}", + "range": true, + "refId": "A" + } + ], + "title": "HMAC FileTTL Deletion(s)", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 9, + "y": 11 + }, + "id": 20, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_cache_misses_total", + "format": "table", + "legendFormat": "{{hmac-file-server}}", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Cache Misses", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 12, + "y": 11 + }, + "id": 16, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "exemplar": false, + "expr": "hmac_active_connections_total", + "format": "table", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Active Connections", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 15, + "y": 11 + }, + "id": 19, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_infected_files_total", + "format": "table", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "HMAC infected file(s)", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 18, + "y": 11 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_file_server_upload_errors_total", + "legendFormat": "Upload Errors", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Upload Errors", + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "prometheus" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 3, + "x": 21, + "y": 11 + }, + "id": 13, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.3.1", + "targets": [ + { + "editorMode": "code", + "expr": "hmac_file_server_download_errors_total", + "legendFormat": "Download Errors", + "range": true, + "refId": "A" + } + ], + "title": "HMAC Download Errors", + "type": "stat" + } + ], + "preload": false, + "schemaVersion": 40, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "HMAC File Server Metrics", + "uid": "de0ye5t0hzq4ge", + "version": 129, + "weekStart": "" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ce5202a --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module github.com/PlusOne/hmac-file-server + +go 1.21 + +require ( + github.com/go-redis/redis/v8 v8.11.5 + github.com/prometheus/client_golang v1.20.5 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/sirupsen/logrus v1.9.3 +) + +require ( + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.60.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.9.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.26.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..df4c6d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ= +github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e/go.mod h1:Byz7q8MSzSPkouskHJhX0er2mZY/m0Vj5bMeMCkkyY4= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= +github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= +github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/hmac-file-server.log b/test/hmac-file-server.log new file mode 100644 index 0000000..e69de29 diff --git a/test/hmac_icon.png b/test/hmac_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d5843c22d8eb0acc89df172d9895a865fbb5006 GIT binary patch literal 61498 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