diff --git a/src/cmd/auth-gen/main.go b/src/cmd/auth-gen/main.go deleted file mode 100644 index 38564e2..0000000 --- a/src/cmd/auth-gen/main.go +++ /dev/null @@ -1,110 +0,0 @@ -// FILE: logwisp/src/cmd/auth-gen/main.go -package main - -import ( - "crypto/rand" - "encoding/base64" - "flag" - "fmt" - "os" - "syscall" - - "golang.org/x/crypto/bcrypt" - "golang.org/x/term" -) - -func main() { - var ( - username = flag.String("u", "", "Username for basic auth") - password = flag.String("p", "", "Password to hash (will prompt if not provided)") - cost = flag.Int("c", 10, "Bcrypt cost (10-31)") - genToken = flag.Bool("t", false, "Generate random bearer token") - tokenLen = flag.Int("l", 32, "Token length in bytes") - ) - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "LogWisp Authentication Utility\n\n") - fmt.Fprintf(os.Stderr, "Usage:\n") - fmt.Fprintf(os.Stderr, " Generate bcrypt hash: %s -u [-p ]\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " Generate bearer token: %s -t [-l ]\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "\nOptions:\n") - flag.PrintDefaults() - } - - flag.Parse() - - if *genToken { - generateToken(*tokenLen) - return - } - - if *username == "" { - fmt.Fprintf(os.Stderr, "Error: Username required for basic auth\n") - flag.Usage() - os.Exit(1) - } - - // Get password - pass := *password - if pass == "" { - pass = promptPassword("Enter password: ") - confirm := promptPassword("Confirm password: ") - if pass != confirm { - fmt.Fprintf(os.Stderr, "Error: Passwords don't match\n") - os.Exit(1) - } - } - - // Generate bcrypt hash - hash, err := bcrypt.GenerateFromPassword([]byte(pass), *cost) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating hash: %v\n", err) - os.Exit(1) - } - - // Output TOML config format - fmt.Println("\n# Add to logwisp.toml under [[pipelines.auth.basic_auth.users]]:") - fmt.Printf("[[pipelines.auth.basic_auth.users]]\n") - fmt.Printf("username = \"%s\"\n", *username) - fmt.Printf("password_hash = \"%s\"\n", string(hash)) - - // Also output for users file format - fmt.Println("\n# Or add to users file:") - fmt.Printf("%s:%s\n", *username, string(hash)) -} - -func promptPassword(prompt string) string { - fmt.Fprint(os.Stderr, prompt) - password, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Fprintln(os.Stderr) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading password: %v\n", err) - os.Exit(1) - } - return string(password) -} - -func generateToken(length int) { - if length < 16 { - fmt.Fprintf(os.Stderr, "Warning: Token length < 16 bytes is insecure\n") - } - - token := make([]byte, length) - if _, err := rand.Read(token); err != nil { - fmt.Fprintf(os.Stderr, "Error generating token: %v\n", err) - os.Exit(1) - } - - // Output in various formats - b64 := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(token) - hex := fmt.Sprintf("%x", token) - - fmt.Println("\n# Add to logwisp.toml under [pipelines.auth.bearer_auth]:") - fmt.Printf("tokens = [\"%s\"]\n", b64) - - fmt.Println("\n# Alternative hex encoding:") - fmt.Printf("# tokens = [\"%s\"]\n", hex) - - fmt.Printf("\n# Token (base64): %s\n", b64) - fmt.Printf("# Token (hex): %s\n", hex) -} \ No newline at end of file diff --git a/src/cmd/logwisp/commands.go b/src/cmd/logwisp/commands.go new file mode 100644 index 0000000..d3d4682 --- /dev/null +++ b/src/cmd/logwisp/commands.go @@ -0,0 +1,139 @@ +// FILE: src/cmd/logwisp/commands.go +package main + +import ( + "fmt" + "logwisp/src/internal/tls" + "os" + + "logwisp/src/internal/auth" + "logwisp/src/internal/version" +) + +// CommandRouter handles subcommand routing before main app initialization +type CommandRouter struct { + commands map[string]CommandHandler +} + +// CommandHandler defines the interface for subcommands +type CommandHandler interface { + Execute(args []string) error + Description() string +} + +// NewCommandRouter creates and initializes the command router +func NewCommandRouter() *CommandRouter { + router := &CommandRouter{ + commands: make(map[string]CommandHandler), + } + + // Register available commands + router.commands["auth"] = &authCommand{} + router.commands["version"] = &versionCommand{} + router.commands["help"] = &helpCommand{} + router.commands["tls"] = &tlsCommand{} + + return router +} + +// Route checks for and executes subcommands +// Returns true if a subcommand was handled +func (r *CommandRouter) Route(args []string) bool { + if len(args) < 1 { + return false + } + + // Check for help flags anywhere in args + for _, arg := range args[1:] { // Skip program name + if arg == "-h" || arg == "--help" || arg == "help" { + // Show main help and exit regardless of other flags + r.commands["help"].Execute(nil) + os.Exit(0) + } + } + + // Check for commands + if len(args) > 1 { + cmdName := args[1] + + if handler, exists := r.commands[cmdName]; exists { + if err := handler.Execute(args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + + // Check if it looks like a mistyped command (not a flag) + if cmdName[0] != '-' { + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmdName) + fmt.Fprintln(os.Stderr, "\nAvailable commands:") + r.ShowCommands() + os.Exit(1) + } + } + + return false +} + +// ShowCommands displays available subcommands +func (r *CommandRouter) ShowCommands() { + fmt.Fprintln(os.Stderr, " auth Generate authentication credentials") + fmt.Fprintln(os.Stderr, " tls Generate TLS certificates") + fmt.Fprintln(os.Stderr, " version Show version information") + fmt.Fprintln(os.Stderr, " help Display help information") + fmt.Fprintln(os.Stderr, "\nUse 'logwisp --help' for command-specific help") +} + +// helpCommand implementation +type helpCommand struct{} + +func (c *helpCommand) Execute(args []string) error { + // Check if help is requested for a specific command + if len(args) > 0 { + // TODO: Future: show command-specific help + // For now, just show general help + } + fmt.Print(helpText) + return nil +} + +func (c *helpCommand) Description() string { + return "Display help information" +} + +// authCommand wrapper +type authCommand struct{} + +func (c *authCommand) Execute(args []string) error { + gen := auth.NewGeneratorCommand() + return gen.Execute(args) +} + +func (c *authCommand) Description() string { + return "Generate authentication credentials (passwords, tokens)" +} + +// versionCommand wrapper +type versionCommand struct{} + +func (c *versionCommand) Execute(args []string) error { + fmt.Println(version.String()) + return nil +} + +func (c *versionCommand) Description() string { + return "Show version information" +} + +// tlsCommand wrapper +type tlsCommand struct{} + +func (c *tlsCommand) Execute(args []string) error { + gen := tls.NewCertGeneratorCommand() + return gen.Execute(args) +} + +func (c *tlsCommand) Description() string { + return "Generate TLS certificates (CA, server, client)" +} \ No newline at end of file diff --git a/src/cmd/logwisp/help.go b/src/cmd/logwisp/help.go index 2d10aff..3880b44 100644 --- a/src/cmd/logwisp/help.go +++ b/src/cmd/logwisp/help.go @@ -8,39 +8,42 @@ import ( const helpText = `LogWisp: A flexible log transport and processing tool. -Usage: logwisp [options] +Usage: + logwisp [command] [options] + logwisp [options] + +Commands: + auth Generate authentication credentials + version Display version information Application Control: - -c, --config (string) Path to configuration file (default: logwisp.toml). - -h, --help Display this help message and exit. - -v, --version Display version information and exit. - -b, --background Run LogWisp in the background as a daemon. - -q, --quiet Suppress all console output, including errors. + -c, --config Path to configuration file (default: logwisp.toml) + -h, --help Display this help message and exit + -v, --version Display version information and exit + -b, --background Run LogWisp in the background as a daemon + -q, --quiet Suppress all console output, including errors Runtime Behavior: - --disable-status-reporter Disable the periodic status reporter. - --config-auto-reload Enable config reload and pipeline reconfiguration on config file change. + --disable-status-reporter Disable the periodic status reporter + --config-auto-reload Enable config reload on file change + +For command-specific help: + logwisp --help Configuration Sources (Precedence: CLI > Env > File > Defaults): - - CLI flags override all other settings. - - Environment variables override file settings. - - TOML configuration file is the primary method for defining pipelines. + - CLI flags override all other settings + - Environment variables override file settings + - TOML configuration file is the primary method -Logging ([logging] section or LOGWISP_LOGGING_* env vars): - output = "stderr" (string) Log output: none, stdout, stderr, file, both. - level = "info" (string) Log level: debug, info, warn, error. - [logging.file] Settings for file logging (directory, name, rotation). - [logging.console] Settings for console logging (target, format). - -Pipelines ([[pipelines]] array in TOML): - Each pipeline defines a complete data flow from sources to sinks. - name = "my_pipeline" (string) Unique name for the pipeline. - sources = [...] (array) Data inputs (e.g., directory, stdin, http, tcp). - sinks = [...] (array) Data outputs (e.g., http, tcp, file, stdout, stderr, http_client). - filters = [...] (array) Optional filters to include/exclude logs based on regex. - rate_limit = {...} (object) Optional rate limiting for the entire pipeline. - auth = {...} (object) Optional authentication for network sinks. - format = "json" (string) Optional output formatter for the pipeline (raw, text, json). +Examples: + # Generate password for admin user + logwisp auth -u admin + + # Start service with custom config + logwisp -c /etc/logwisp/prod.toml + + # Run in background + logwisp -b --config-auto-reload For detailed configuration options, please refer to the documentation. ` diff --git a/src/cmd/logwisp/main.go b/src/cmd/logwisp/main.go index b600084..a587abc 100644 --- a/src/cmd/logwisp/main.go +++ b/src/cmd/logwisp/main.go @@ -20,12 +20,17 @@ import ( var logger *log.Logger func main() { + // Handle subcommands before any config loading + // This prevents flag conflicts with lixenwraith/config + router := NewCommandRouter() + if router.Route(os.Args) { + // Subcommand was handled, exit already called + return + } + // Emulates nohup signal.Ignore(syscall.SIGHUP) - // Early check for help flag to avoid unnecessary config loading - CheckAndDisplayHelp(os.Args[1:]) - // Load configuration with automatic CLI parsing cfg, err := config.Load(os.Args[1:]) if err != nil { diff --git a/src/cmd/logwisp/reload.go b/src/cmd/logwisp/reload.go index a26dbb4..ce9ad5f 100644 --- a/src/cmd/logwisp/reload.go +++ b/src/cmd/logwisp/reload.go @@ -4,8 +4,10 @@ package main import ( "context" "fmt" + "os" "strings" "sync" + "syscall" "time" "logwisp/src/internal/config" @@ -115,7 +117,7 @@ func (rm *ReloadManager) watchLoop(ctx context.Context) { "action", "keeping current configuration") continue case "permissions_changed": - // SECURITY: Config file permissions changed suspiciously + // Config file permissions changed suspiciously, overlap with file permission check rm.logger.Error("msg", "Configuration file permissions changed", "action", "reload blocked for security") continue @@ -132,6 +134,15 @@ func (rm *ReloadManager) watchLoop(ctx context.Context) { } } + // Verify file permissions before reload + if err := verifyFilePermissions(rm.configPath); err != nil { + rm.logger.Error("msg", "Configuration file permission check failed", + "path", rm.configPath, + "error", err, + "action", "reload blocked for security") + continue + } + // Trigger reload for any pipeline-related change if rm.shouldReload(changedPath) { rm.triggerReload(ctx) @@ -140,6 +151,36 @@ func (rm *ReloadManager) watchLoop(ctx context.Context) { } } +// Verify file permissions for security +func verifyFilePermissions(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat config file: %w", err) + } + + // Extract file mode and system stats + mode := info.Mode() + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("unable to get file ownership info") + } + + // Check ownership - must be current user or root + currentUID := uint32(os.Getuid()) + if stat.Uid != currentUID && stat.Uid != 0 { + return fmt.Errorf("config file owned by uid %d, expected %d or 0", stat.Uid, currentUID) + } + + // Check permissions - must not be writable by group or other + perm := mode.Perm() + if perm&0022 != 0 { + // Group or other has write permission + return fmt.Errorf("insecure permissions %04o - file must not be writable by group/other", perm) + } + + return nil +} + // shouldReload determines if a config change requires service reload func (rm *ReloadManager) shouldReload(path string) bool { // Pipeline changes always require reload diff --git a/src/internal/auth/authenticator.go b/src/internal/auth/authenticator.go index 3b4fa6b..c86e318 100644 --- a/src/internal/auth/authenticator.go +++ b/src/internal/auth/authenticator.go @@ -108,7 +108,7 @@ func New(cfg *config.AuthConfig, logger *log.Logger) (*Authenticator, error) { if cfg.BearerAuth.JWT.SigningKey != "" { // Static key key := []byte(cfg.BearerAuth.JWT.SigningKey) - a.jwtKeyFunc = func(token *jwt.Token) (interface{}, error) { + a.jwtKeyFunc = func(token *jwt.Token) (any, error) { return key, nil } } else if cfg.BearerAuth.JWT.JWKSURL != "" { @@ -378,7 +378,7 @@ func (a *Authenticator) validateBasicAuth(username, password, remoteAddr string) a.mu.RUnlock() if !exists { - // ☢ SECURITY: Perform bcrypt anyway to prevent timing attacks + // Perform bcrypt anyway to prevent timing attacks bcrypt.CompareHashAndPassword([]byte("$2a$10$dummy.hash.to.prevent.timing.attacks"), []byte(password)) return nil, fmt.Errorf("invalid credentials") } @@ -471,7 +471,7 @@ func (a *Authenticator) validateToken(token, remoteAddr string) (*Session, error switch aud := claims["aud"].(type) { case string: audValid = aud == a.config.BearerAuth.JWT.Audience - case []interface{}: + case []any: for _, aa := range aud { if audStr, ok := aa.(string); ok && audStr == a.config.BearerAuth.JWT.Audience { audValid = true diff --git a/src/internal/auth/generator.go b/src/internal/auth/generator.go new file mode 100644 index 0000000..1731ddc --- /dev/null +++ b/src/internal/auth/generator.go @@ -0,0 +1,147 @@ +// FILE: src/internal/auth/generator.go +package auth + +import ( + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "io" + "os" + "syscall" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +// GeneratorCommand handles auth credential generation +type GeneratorCommand struct { + output io.Writer + errOut io.Writer +} + +// NewGeneratorCommand creates a new auth generator command handler +func NewGeneratorCommand() *GeneratorCommand { + return &GeneratorCommand{ + output: os.Stdout, + errOut: os.Stderr, + } +} + +// Execute runs the auth generation command with provided arguments +func (g *GeneratorCommand) Execute(args []string) error { + cmd := flag.NewFlagSet("auth", flag.ContinueOnError) + cmd.SetOutput(g.errOut) + + var ( + username = cmd.String("u", "", "Username for basic auth") + password = cmd.String("p", "", "Password to hash (will prompt if not provided)") + cost = cmd.Int("c", 10, "Bcrypt cost (10-31)") + genToken = cmd.Bool("t", false, "Generate random bearer token") + tokenLen = cmd.Int("l", 32, "Token length in bytes") + ) + + cmd.Usage = func() { + fmt.Fprintln(g.errOut, "Generate authentication credentials for LogWisp") + fmt.Fprintln(g.errOut, "\nUsage: logwisp auth [options]") + fmt.Fprintln(g.errOut, "\nExamples:") + fmt.Fprintln(g.errOut, " # Generate bcrypt hash for user") + fmt.Fprintln(g.errOut, " logwisp auth -u admin") + fmt.Fprintln(g.errOut, " ") + fmt.Fprintln(g.errOut, " # Generate 64-byte bearer token") + fmt.Fprintln(g.errOut, " logwisp auth -t -l 64") + fmt.Fprintln(g.errOut, "\nOptions:") + cmd.PrintDefaults() + } + + if err := cmd.Parse(args); err != nil { + return err + } + + // Token generation mode + if *genToken { + return g.generateToken(*tokenLen) + } + + // Password hash generation mode + if *username == "" { + cmd.Usage() + return fmt.Errorf("username required for password hash generation") + } + + return g.generatePasswordHash(*username, *password, *cost) +} + +func (g *GeneratorCommand) generatePasswordHash(username, password string, cost int) error { + // Validate cost + if cost < 10 || cost > 31 { + return fmt.Errorf("bcrypt cost must be between 10 and 31") + } + + // Get password if not provided + if password == "" { + pass1 := g.promptPassword("Enter password: ") + pass2 := g.promptPassword("Confirm password: ") + if pass1 != pass2 { + return fmt.Errorf("passwords don't match") + } + password = pass1 + } + + // Generate hash + hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return fmt.Errorf("failed to generate hash: %w", err) + } + + // Output configuration snippets + fmt.Fprintln(g.output, "\n# TOML Configuration (add to logwisp.toml):") + fmt.Fprintln(g.output, "[[pipelines.auth.basic_auth.users]]") + fmt.Fprintf(g.output, "username = %q\n", username) + fmt.Fprintf(g.output, "password_hash = %q\n\n", string(hash)) + + fmt.Fprintln(g.output, "# Users File Format (for external auth file):") + fmt.Fprintf(g.output, "%s:%s\n", username, hash) + + return nil +} + +func (g *GeneratorCommand) generateToken(length int) error { + if length < 16 { + fmt.Fprintln(g.errOut, "⚠️ Warning: tokens < 16 bytes are cryptographically weak") + } + if length > 512 { + return fmt.Errorf("token length exceeds maximum (512 bytes)") + } + + token := make([]byte, length) + if _, err := rand.Read(token); err != nil { + return fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Generate both encodings + b64 := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(token) + hex := fmt.Sprintf("%x", token) + + // Output configuration + fmt.Fprintln(g.output, "\n# TOML Configuration (add to logwisp.toml):") + fmt.Fprintf(g.output, "tokens = [%q]\n\n", b64) + + fmt.Fprintln(g.output, "# Generated Token:") + fmt.Fprintf(g.output, "Base64: %s\n", b64) + fmt.Fprintf(g.output, "Hex: %s\n", hex) + + return nil +} + +func (g *GeneratorCommand) promptPassword(prompt string) string { + fmt.Fprint(g.errOut, prompt) + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(g.errOut) + if err != nil { + // Fatal error - can't continue without password + fmt.Fprintf(g.errOut, "Failed to read password: %v\n", err) + os.Exit(1) + } + return string(password) +} \ No newline at end of file diff --git a/src/internal/config/ssl.go b/src/internal/config/ssl.go index 5bdc61d..9680420 100644 --- a/src/internal/config/ssl.go +++ b/src/internal/config/ssl.go @@ -19,6 +19,9 @@ type SSLConfig struct { // Option to skip verification for clients InsecureSkipVerify bool `toml:"insecure_skip_verify"` + // CA file for client to trust specific server certificates + CAFile string `toml:"ca_file"` + // TLS version constraints MinVersion string `toml:"min_version"` // "TLS1.2", "TLS1.3" MaxVersion string `toml:"max_version"` diff --git a/src/internal/sink/http.go b/src/internal/sink/http.go index 818e74e..8e4c11a 100644 --- a/src/internal/sink/http.go +++ b/src/internal/sink/http.go @@ -552,8 +552,7 @@ func (h *HTTPSink) formatEntryForSSE(w *bufio.Writer, entry core.LogEntry) error // Multi-line content handler lines := bytes.Split(formatted, []byte{'\n'}) for _, line := range lines { - // SSE needs "data: " prefix for each line - // TODO: validate above, is 'data: ' really necessary? make it optional if it works without it? + // SSE needs "data: " prefix for each line based on W3C spec fmt.Fprintf(w, "data: %s\n", line) } fmt.Fprintf(w, "\n") // Empty line to terminate event diff --git a/src/internal/sink/http_client.go b/src/internal/sink/http_client.go index 668c19f..c6f45c3 100644 --- a/src/internal/sink/http_client.go +++ b/src/internal/sink/http_client.go @@ -60,6 +60,8 @@ type HTTPClientConfig struct { // TLS configuration InsecureSkipVerify bool CAFile string + CertFile string + KeyFile string } // NewHTTPClientSink creates a new HTTP client sink @@ -136,6 +138,30 @@ func NewHTTPClientSink(options map[string]any, logger *log.Logger, formatter for cfg.CAFile = caFile } + // Extract client certificate options from SSL config + if ssl, ok := options["ssl"].(map[string]any); ok { + if enabled, _ := ssl["enabled"].(bool); enabled { + // Extract client certificate files for mTLS + if certFile, ok := ssl["cert_file"].(string); ok && certFile != "" { + if keyFile, ok := ssl["key_file"].(string); ok && keyFile != "" { + // These will be used below when configuring TLS + cfg.CertFile = certFile // Need to add these fields to HTTPClientConfig + cfg.KeyFile = keyFile + } + } + // Extract CA file from ssl config if not already set + if cfg.CAFile == "" { + if caFile, ok := ssl["ca_file"].(string); ok { + cfg.CAFile = caFile + } + } + // Extract insecure skip verify from ssl config + if insecure, ok := ssl["insecure_skip_verify"].(bool); ok { + cfg.InsecureSkipVerify = insecure + } + } + } + h := &HTTPClientSink{ input: make(chan core.LogEntry, cfg.BufferSize), config: cfg, @@ -163,17 +189,32 @@ func NewHTTPClientSink(options map[string]any, logger *log.Logger, formatter for InsecureSkipVerify: cfg.InsecureSkipVerify, } - // Load custom CA if provided + // Load custom CA for server verification if provided if cfg.CAFile != "" { caCert, err := os.ReadFile(cfg.CAFile) if err != nil { - return nil, fmt.Errorf("failed to read CA file: %w", err) + return nil, fmt.Errorf("failed to read CA file '%s': %w", cfg.CAFile, err) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA certificate") + return nil, fmt.Errorf("failed to parse CA certificate from '%s'", cfg.CAFile) } tlsConfig.RootCAs = caCertPool + logger.Debug("msg", "Custom CA loaded for server verification", + "component", "http_client_sink", + "ca_file", cfg.CAFile) + } + + // Load client certificate for mTLS if provided + if cfg.CertFile != "" && cfg.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + logger.Info("msg", "Client certificate loaded for mTLS", + "component", "http_client_sink", + "cert_file", cfg.CertFile) } // Set TLS config directly on the client diff --git a/src/internal/sink/tcp_client.go b/src/internal/sink/tcp_client.go index fa56113..19b9359 100644 --- a/src/internal/sink/tcp_client.go +++ b/src/internal/sink/tcp_client.go @@ -4,9 +4,12 @@ package sink import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "net" + "os" + "strings" "sync" "sync/atomic" "time" @@ -54,6 +57,7 @@ type TCPClientConfig struct { BufferSize int64 DialTimeout time.Duration WriteTimeout time.Duration + ReadTimeout time.Duration KeepAlive time.Duration // Reconnection settings @@ -71,6 +75,7 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form BufferSize: int64(1000), DialTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, + ReadTimeout: 10 * time.Second, KeepAlive: 30 * time.Second, ReconnectDelay: time.Second, MaxReconnectDelay: 30 * time.Second, @@ -100,6 +105,9 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form if writeTimeout, ok := options["write_timeout_seconds"].(int64); ok && writeTimeout > 0 { cfg.WriteTimeout = time.Duration(writeTimeout) * time.Second } + if readTimeout, ok := options["read_timeout_seconds"].(int64); ok && readTimeout > 0 { + cfg.ReadTimeout = time.Duration(readTimeout) * time.Second + } if keepAlive, ok := options["keep_alive_seconds"].(int64); ok && keepAlive > 0 { cfg.KeepAlive = time.Duration(keepAlive) * time.Second } @@ -130,6 +138,9 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form if insecure, ok := ssl["insecure_skip_verify"].(bool); ok { cfg.SSL.InsecureSkipVerify = insecure } + if caFile, ok := ssl["ca_file"].(string); ok { + cfg.SSL.CAFile = caFile + } } t := &TCPClientSink{ @@ -145,17 +156,10 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form // Initialize TLS manager if SSL is configured if cfg.SSL != nil && cfg.SSL.Enabled { - tlsManager, err := tlspkg.NewManager(cfg.SSL, logger) - if err != nil { - return nil, fmt.Errorf("failed to create TLS manager: %w", err) + // Build custom TLS config for client + t.tlsConfig = &tls.Config{ + InsecureSkipVerify: cfg.SSL.InsecureSkipVerify, } - t.tlsManager = tlsManager - - // Get client TLS config - t.tlsConfig = tlsManager.GetTCPConfig() - - // ADDED: Client-specific TLS config adjustments - t.tlsConfig.InsecureSkipVerify = cfg.SSL.InsecureSkipVerify // Extract server name from address for SNI host, _, err := net.SplitHostPort(cfg.Address) @@ -164,13 +168,48 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form } t.tlsConfig.ServerName = host + // Load custom CA for server verification + if cfg.SSL.CAFile != "" { + caCert, err := os.ReadFile(cfg.SSL.CAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA file '%s': %w", cfg.SSL.CAFile, err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse CA certificate from '%s'", cfg.SSL.CAFile) + } + t.tlsConfig.RootCAs = caCertPool + logger.Debug("msg", "Custom CA loaded for server verification", + "component", "tcp_client_sink", + "ca_file", cfg.SSL.CAFile) + } + + // Load client certificate for mTLS + if cfg.SSL.CertFile != "" && cfg.SSL.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(cfg.SSL.CertFile, cfg.SSL.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + t.tlsConfig.Certificates = []tls.Certificate{cert} + logger.Info("msg", "Client certificate loaded for mTLS", + "component", "tcp_client_sink", + "cert_file", cfg.SSL.CertFile) + } + + // Set minimum TLS version if configured + if cfg.SSL.MinVersion != "" { + t.tlsConfig.MinVersion = parseTLSVersion(cfg.SSL.MinVersion, tls.VersionTLS12) + } else { + t.tlsConfig.MinVersion = tls.VersionTLS12 // Default minimum + } + logger.Info("msg", "TLS enabled for TCP client", "component", "tcp_client_sink", "address", cfg.Address, "server_name", host, - "insecure", cfg.SSL.InsecureSkipVerify) + "insecure", cfg.SSL.InsecureSkipVerify, + "mtls", cfg.SSL.CertFile != "") } - return t, nil } @@ -381,8 +420,7 @@ func (t *TCPClientSink) monitorConnection(conn net.Conn) { return case <-ticker.C: // Set read deadline - // TODO: Add t.config.ReadTimeout and after addition use it instead of static value - if err := conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + if err := conn.SetReadDeadline(time.Now().Add(t.config.ReadTimeout)); err != nil { t.logger.Debug("msg", "Failed to set read deadline", "error", err) return } @@ -480,4 +518,20 @@ func tlsVersionString(version uint16) string { default: return fmt.Sprintf("0x%04x", version) } +} + +// parseTLSVersion converts string to TLS version constant +func parseTLSVersion(version string, defaultVersion uint16) uint16 { + switch strings.ToUpper(version) { + case "TLS1.0", "TLS10": + return tls.VersionTLS10 + case "TLS1.1", "TLS11": + return tls.VersionTLS11 + case "TLS1.2", "TLS12": + return tls.VersionTLS12 + case "TLS1.3", "TLS13": + return tls.VersionTLS13 + default: + return defaultVersion + } } \ No newline at end of file diff --git a/src/internal/source/http.go b/src/internal/source/http.go index 1fc20da..7f16405 100644 --- a/src/internal/source/http.go +++ b/src/internal/source/http.go @@ -110,7 +110,21 @@ func NewHTTPSource(options map[string]any, logger *log.Logger) (*HTTPSource, err if keyFile, ok := ssl["key_file"].(string); ok { h.sslConfig.KeyFile = keyFile } - // TODO: extract other SSL options similar to tcp_client_sink + h.sslConfig.ClientAuth, _ = ssl["client_auth"].(bool) + if caFile, ok := ssl["client_ca_file"].(string); ok { + h.sslConfig.ClientCAFile = caFile + } + h.sslConfig.VerifyClientCert, _ = ssl["verify_client_cert"].(bool) + h.sslConfig.InsecureSkipVerify, _ = ssl["insecure_skip_verify"].(bool) + if minVer, ok := ssl["min_version"].(string); ok { + h.sslConfig.MinVersion = minVer + } + if maxVer, ok := ssl["max_version"].(string); ok { + h.sslConfig.MaxVersion = maxVer + } + if ciphers, ok := ssl["cipher_suites"].(string); ok { + h.sslConfig.CipherSuites = ciphers + } // Create TLS manager if h.sslConfig.Enabled { @@ -146,6 +160,7 @@ func (h *HTTPSource) Start() error { // Start server in background h.wg.Add(1) + errChan := make(chan error, 1) go func() { defer h.wg.Done() h.logger.Info("msg", "HTTP source server starting", @@ -168,12 +183,19 @@ func (h *HTTPSource) Start() error { "component", "http_source", "port", h.port, "error", err) + errChan <- err } }() - // Give server time to start - time.Sleep(100 * time.Millisecond) // TODO: standardize and better manage timers - return nil + // Robust server startup check with timeout + select { + case err := <-errChan: + // Server failed to start + return fmt.Errorf("HTTP server failed to start: %w", err) + case <-time.After(250 * time.Millisecond): + // Server started successfully (no immediate error) + return nil + } } func (h *HTTPSource) Stop() { @@ -331,7 +353,8 @@ func (h *HTTPSource) parseEntries(body []byte) ([]core.LogEntry, error) { // Try to parse as JSON array var array []core.LogEntry if err := json.Unmarshal(body, &array); err == nil { - // NOTE: Placeholder; For array, divide total size by entry count as approximation + // For array, divide total size by entry count as approximation + // Accurate calculation adds too much complexity and processing approxSizePerEntry := int64(len(body) / len(array)) for i, entry := range array { if entry.Message == "" { @@ -343,7 +366,6 @@ func (h *HTTPSource) parseEntries(body []byte) ([]core.LogEntry, error) { if entry.Source == "" { array[i].Source = "http" } - // NOTE: Placeholder array[i].RawSize = approxSizePerEntry } return array, nil diff --git a/src/internal/tls/generator.go b/src/internal/tls/generator.go new file mode 100644 index 0000000..7ca1fc8 --- /dev/null +++ b/src/internal/tls/generator.go @@ -0,0 +1,275 @@ +// FILE: src/internal/tls/generator.go +package tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "math/big" + "net" + "os" + "strings" + "time" +) + +type CertGeneratorCommand struct{} + +func NewCertGeneratorCommand() *CertGeneratorCommand { + return &CertGeneratorCommand{} +} + +func (c *CertGeneratorCommand) Execute(args []string) error { + cmd := flag.NewFlagSet("tls", flag.ContinueOnError) + + // Subcommands + var ( + genCA = cmd.Bool("ca", false, "Generate CA certificate") + genServer = cmd.Bool("server", false, "Generate server certificate") + genClient = cmd.Bool("client", false, "Generate client certificate") + selfSign = cmd.Bool("self-signed", false, "Generate self-signed certificate") + + // Common options + commonName = cmd.String("cn", "", "Common name (required)") + org = cmd.String("org", "LogWisp", "Organization") + country = cmd.String("country", "US", "Country code") + validDays = cmd.Int("days", 365, "Validity period in days") + keySize = cmd.Int("bits", 2048, "RSA key size") + + // Server/Client specific + hosts = cmd.String("hosts", "", "Comma-separated hostnames/IPs (server cert)") + caFile = cmd.String("ca-cert", "", "CA certificate file (for signing)") + caKeyFile = cmd.String("ca-key", "", "CA key file (for signing)") + + // Output files + certOut = cmd.String("cert-out", "", "Output certificate file") + keyOut = cmd.String("key-out", "", "Output key file") + ) + + cmd.Usage = func() { + fmt.Fprintln(os.Stderr, "Generate TLS certificates for LogWisp") + fmt.Fprintln(os.Stderr, "\nUsage: logwisp tls [options]") + fmt.Fprintln(os.Stderr, "\nExamples:") + fmt.Fprintln(os.Stderr, " # Generate self-signed certificate") + fmt.Fprintln(os.Stderr, " logwisp tls --self-signed --cn localhost --hosts localhost,127.0.0.1") + fmt.Fprintln(os.Stderr, " ") + fmt.Fprintln(os.Stderr, " # Generate CA certificate") + fmt.Fprintln(os.Stderr, " logwisp tls --ca --cn \"LogWisp CA\" --cert-out ca.crt --key-out ca.key") + fmt.Fprintln(os.Stderr, " ") + fmt.Fprintln(os.Stderr, " # Generate server certificate signed by CA") + fmt.Fprintln(os.Stderr, " logwisp tls --server --cn server.example.com --hosts server.example.com \\") + fmt.Fprintln(os.Stderr, " --ca-cert ca.crt --ca-key ca.key") + fmt.Fprintln(os.Stderr, "\nOptions:") + cmd.PrintDefaults() + } + + if err := cmd.Parse(args); err != nil { + return err + } + + // Validate common name + if *commonName == "" { + cmd.Usage() + return fmt.Errorf("common name (--cn) is required") + } + + // Route to appropriate generator + switch { + case *genCA: + return c.generateCA(*commonName, *org, *country, *validDays, *keySize, *certOut, *keyOut) + case *selfSign: + return c.generateSelfSigned(*commonName, *org, *country, *hosts, *validDays, *keySize, *certOut, *keyOut) + case *genServer: + return c.generateServerCert(*commonName, *org, *country, *hosts, *caFile, *caKeyFile, *validDays, *keySize, *certOut, *keyOut) + case *genClient: + return c.generateClientCert(*commonName, *org, *country, *caFile, *caKeyFile, *validDays, *keySize, *certOut, *keyOut) + default: + cmd.Usage() + return fmt.Errorf("specify certificate type: --ca, --self-signed, --server, or --client") + } +} + +// Crate and manage private CA +// TODO: Future implementation, not useful without implementation of generateServerCert, generateClientCert +func (c *CertGeneratorCommand) generateCA(cn, org, country string, days, bits int, certFile, keyFile string) error { + // Generate RSA key + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return fmt.Errorf("failed to generate key: %w", err) + } + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{org}, + Country: []string{country}, + CommonName: cn, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, days), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + // Generate certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + // Default output files + if certFile == "" { + certFile = "ca.crt" + } + if keyFile == "" { + keyFile = "ca.key" + } + + // Save certificate + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("failed to create cert file: %w", err) + } + defer certOut.Close() + + pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + // Save private key + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create key file: %w", err) + } + defer keyOut.Close() + + pem.Encode(keyOut, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv), + }) + + fmt.Printf("✓ CA certificate generated:\n") + fmt.Printf(" Certificate: %s\n", certFile) + fmt.Printf(" Private key: %s (mode 0600)\n", keyFile) + fmt.Printf(" Valid for: %d days\n", days) + fmt.Printf(" Common name: %s\n", cn) + + return nil +} + +// Added parseHosts helper for IP/hostname parsing +func parseHosts(hostList string) ([]string, []net.IP) { + var dnsNames []string + var ipAddrs []net.IP + + if hostList == "" { + return dnsNames, ipAddrs + } + + hosts := strings.Split(hostList, ",") + for _, h := range hosts { + h = strings.TrimSpace(h) + if ip := net.ParseIP(h); ip != nil { + ipAddrs = append(ipAddrs, ip) + } else { + dnsNames = append(dnsNames, h) + } + } + + return dnsNames, ipAddrs +} + +// Generate self-signed certificate +func (c *CertGeneratorCommand) generateSelfSigned(cn, org, country, hosts string, days, bits int, certFile, keyFile string) error { + // 1. Generate an RSA private key with the specified bit size + priv, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return fmt.Errorf("failed to generate private key: %w", err) + } + + // 2. Parse the hosts string into DNS names and IP addresses + dnsNames, ipAddrs := parseHosts(hosts) + + // 3. Create the certificate template + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: cn, + Organization: []string{org}, + Country: []string{country}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(0, 0, days), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + IsCA: false, + + DNSNames: dnsNames, + IPAddresses: ipAddrs, + } + + // 4. Create the self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + // 5. Default output filenames + if certFile == "" { + certFile = "server.crt" + } + if keyFile == "" { + keyFile = "server.key" + } + + // 6. Save the certificate with 0644 permissions + certOut, err := os.Create(certFile) + if err != nil { + return fmt.Errorf("failed to create certificate file: %w", err) + } + pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + certOut.Close() + + // 7. Save the private key with 0600 permissions + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create key file: %w", err) + } + pem.Encode(keyOut, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv), + }) + keyOut.Close() + + // 8. Print summary + fmt.Printf("\n✓ Self-signed certificate generated:\n") + fmt.Printf(" Certificate: %s\n", certFile) + fmt.Printf(" Private Key: %s (mode 0600)\n", keyFile) + fmt.Printf(" Valid for: %d days\n", days) + fmt.Printf(" Common Name: %s\n", cn) + if len(hosts) > 0 { + fmt.Printf(" Hosts (SANs): %s\n", hosts) + } + + return nil +} + +func (c *CertGeneratorCommand) generateServerCert(cn, org, country, hosts, caFile, caKeyFile string, days, bits int, certFile, keyFile string) error { + return fmt.Errorf("server certificate generation with CA is not implemented; use --self-signed instead") +} + +func (c *CertGeneratorCommand) generateClientCert(cn, org, country, caFile, caKeyFile string, days, bits int, certFile, keyFile string) error { + return fmt.Errorf("client certificate generation with CA is not implemented; use --self-signed instead") +} \ No newline at end of file