v0.4.2 tls auth improvement, auth-gen and cert-gen sub functions added

This commit is contained in:
2025-09-24 10:51:26 -04:00
parent 45b2093569
commit 94b5f3111e
13 changed files with 787 additions and 168 deletions

View File

@ -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 <username> [-p <password>]\n", os.Args[0])
fmt.Fprintf(os.Stderr, " Generate bearer token: %s -t [-l <length>]\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)
}

139
src/cmd/logwisp/commands.go Normal file
View File

@ -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 <command> --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)"
}

View File

@ -8,39 +8,42 @@ import (
const helpText = `LogWisp: A flexible log transport and processing tool. 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: Application Control:
-c, --config <path> (string) Path to configuration file (default: logwisp.toml). -c, --config <path> Path to configuration file (default: logwisp.toml)
-h, --help Display this help message and exit. -h, --help Display this help message and exit
-v, --version Display version information and exit. -v, --version Display version information and exit
-b, --background Run LogWisp in the background as a daemon. -b, --background Run LogWisp in the background as a daemon
-q, --quiet Suppress all console output, including errors. -q, --quiet Suppress all console output, including errors
Runtime Behavior: Runtime Behavior:
--disable-status-reporter Disable the periodic status reporter. --disable-status-reporter Disable the periodic status reporter
--config-auto-reload Enable config reload and pipeline reconfiguration on config file change. --config-auto-reload Enable config reload on file change
For command-specific help:
logwisp <command> --help
Configuration Sources (Precedence: CLI > Env > File > Defaults): Configuration Sources (Precedence: CLI > Env > File > Defaults):
- CLI flags override all other settings. - CLI flags override all other settings
- Environment variables override file settings. - Environment variables override file settings
- TOML configuration file is the primary method for defining pipelines. - TOML configuration file is the primary method
Logging ([logging] section or LOGWISP_LOGGING_* env vars): Examples:
output = "stderr" (string) Log output: none, stdout, stderr, file, both. # Generate password for admin user
level = "info" (string) Log level: debug, info, warn, error. logwisp auth -u admin
[logging.file] Settings for file logging (directory, name, rotation).
[logging.console] Settings for console logging (target, format).
Pipelines ([[pipelines]] array in TOML): # Start service with custom config
Each pipeline defines a complete data flow from sources to sinks. logwisp -c /etc/logwisp/prod.toml
name = "my_pipeline" (string) Unique name for the pipeline.
sources = [...] (array) Data inputs (e.g., directory, stdin, http, tcp). # Run in background
sinks = [...] (array) Data outputs (e.g., http, tcp, file, stdout, stderr, http_client). logwisp -b --config-auto-reload
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).
For detailed configuration options, please refer to the documentation. For detailed configuration options, please refer to the documentation.
` `

View File

@ -20,12 +20,17 @@ import (
var logger *log.Logger var logger *log.Logger
func main() { 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 // Emulates nohup
signal.Ignore(syscall.SIGHUP) signal.Ignore(syscall.SIGHUP)
// Early check for help flag to avoid unnecessary config loading
CheckAndDisplayHelp(os.Args[1:])
// Load configuration with automatic CLI parsing // Load configuration with automatic CLI parsing
cfg, err := config.Load(os.Args[1:]) cfg, err := config.Load(os.Args[1:])
if err != nil { if err != nil {

View File

@ -4,8 +4,10 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"logwisp/src/internal/config" "logwisp/src/internal/config"
@ -115,7 +117,7 @@ func (rm *ReloadManager) watchLoop(ctx context.Context) {
"action", "keeping current configuration") "action", "keeping current configuration")
continue continue
case "permissions_changed": 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", rm.logger.Error("msg", "Configuration file permissions changed",
"action", "reload blocked for security") "action", "reload blocked for security")
continue 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 // Trigger reload for any pipeline-related change
if rm.shouldReload(changedPath) { if rm.shouldReload(changedPath) {
rm.triggerReload(ctx) 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 // shouldReload determines if a config change requires service reload
func (rm *ReloadManager) shouldReload(path string) bool { func (rm *ReloadManager) shouldReload(path string) bool {
// Pipeline changes always require reload // Pipeline changes always require reload

View File

@ -108,7 +108,7 @@ func New(cfg *config.AuthConfig, logger *log.Logger) (*Authenticator, error) {
if cfg.BearerAuth.JWT.SigningKey != "" { if cfg.BearerAuth.JWT.SigningKey != "" {
// Static key // Static key
key := []byte(cfg.BearerAuth.JWT.SigningKey) 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 return key, nil
} }
} else if cfg.BearerAuth.JWT.JWKSURL != "" { } else if cfg.BearerAuth.JWT.JWKSURL != "" {
@ -378,7 +378,7 @@ func (a *Authenticator) validateBasicAuth(username, password, remoteAddr string)
a.mu.RUnlock() a.mu.RUnlock()
if !exists { 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)) bcrypt.CompareHashAndPassword([]byte("$2a$10$dummy.hash.to.prevent.timing.attacks"), []byte(password))
return nil, fmt.Errorf("invalid credentials") return nil, fmt.Errorf("invalid credentials")
} }
@ -471,7 +471,7 @@ func (a *Authenticator) validateToken(token, remoteAddr string) (*Session, error
switch aud := claims["aud"].(type) { switch aud := claims["aud"].(type) {
case string: case string:
audValid = aud == a.config.BearerAuth.JWT.Audience audValid = aud == a.config.BearerAuth.JWT.Audience
case []interface{}: case []any:
for _, aa := range aud { for _, aa := range aud {
if audStr, ok := aa.(string); ok && audStr == a.config.BearerAuth.JWT.Audience { if audStr, ok := aa.(string); ok && audStr == a.config.BearerAuth.JWT.Audience {
audValid = true audValid = true

View File

@ -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)
}

View File

@ -19,6 +19,9 @@ type SSLConfig struct {
// Option to skip verification for clients // Option to skip verification for clients
InsecureSkipVerify bool `toml:"insecure_skip_verify"` InsecureSkipVerify bool `toml:"insecure_skip_verify"`
// CA file for client to trust specific server certificates
CAFile string `toml:"ca_file"`
// TLS version constraints // TLS version constraints
MinVersion string `toml:"min_version"` // "TLS1.2", "TLS1.3" MinVersion string `toml:"min_version"` // "TLS1.2", "TLS1.3"
MaxVersion string `toml:"max_version"` MaxVersion string `toml:"max_version"`

View File

@ -552,8 +552,7 @@ func (h *HTTPSink) formatEntryForSSE(w *bufio.Writer, entry core.LogEntry) error
// Multi-line content handler // Multi-line content handler
lines := bytes.Split(formatted, []byte{'\n'}) lines := bytes.Split(formatted, []byte{'\n'})
for _, line := range lines { for _, line := range lines {
// SSE needs "data: " prefix for each line // SSE needs "data: " prefix for each line based on W3C spec
// TODO: validate above, is 'data: ' really necessary? make it optional if it works without it?
fmt.Fprintf(w, "data: %s\n", line) fmt.Fprintf(w, "data: %s\n", line)
} }
fmt.Fprintf(w, "\n") // Empty line to terminate event fmt.Fprintf(w, "\n") // Empty line to terminate event

View File

@ -60,6 +60,8 @@ type HTTPClientConfig struct {
// TLS configuration // TLS configuration
InsecureSkipVerify bool InsecureSkipVerify bool
CAFile string CAFile string
CertFile string
KeyFile string
} }
// NewHTTPClientSink creates a new HTTP client sink // 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 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{ h := &HTTPClientSink{
input: make(chan core.LogEntry, cfg.BufferSize), input: make(chan core.LogEntry, cfg.BufferSize),
config: cfg, config: cfg,
@ -163,17 +189,32 @@ func NewHTTPClientSink(options map[string]any, logger *log.Logger, formatter for
InsecureSkipVerify: cfg.InsecureSkipVerify, InsecureSkipVerify: cfg.InsecureSkipVerify,
} }
// Load custom CA if provided // Load custom CA for server verification if provided
if cfg.CAFile != "" { if cfg.CAFile != "" {
caCert, err := os.ReadFile(cfg.CAFile) caCert, err := os.ReadFile(cfg.CAFile)
if err != nil { 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() caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) { 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 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 // Set TLS config directly on the client

View File

@ -4,9 +4,12 @@ package sink
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"os"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -54,6 +57,7 @@ type TCPClientConfig struct {
BufferSize int64 BufferSize int64
DialTimeout time.Duration DialTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
ReadTimeout time.Duration
KeepAlive time.Duration KeepAlive time.Duration
// Reconnection settings // Reconnection settings
@ -71,6 +75,7 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form
BufferSize: int64(1000), BufferSize: int64(1000),
DialTimeout: 10 * time.Second, DialTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
ReadTimeout: 10 * time.Second,
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
ReconnectDelay: time.Second, ReconnectDelay: time.Second,
MaxReconnectDelay: 30 * 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 { if writeTimeout, ok := options["write_timeout_seconds"].(int64); ok && writeTimeout > 0 {
cfg.WriteTimeout = time.Duration(writeTimeout) * time.Second 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 { if keepAlive, ok := options["keep_alive_seconds"].(int64); ok && keepAlive > 0 {
cfg.KeepAlive = time.Duration(keepAlive) * time.Second 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 { if insecure, ok := ssl["insecure_skip_verify"].(bool); ok {
cfg.SSL.InsecureSkipVerify = insecure cfg.SSL.InsecureSkipVerify = insecure
} }
if caFile, ok := ssl["ca_file"].(string); ok {
cfg.SSL.CAFile = caFile
}
} }
t := &TCPClientSink{ t := &TCPClientSink{
@ -145,17 +156,10 @@ func NewTCPClientSink(options map[string]any, logger *log.Logger, formatter form
// Initialize TLS manager if SSL is configured // Initialize TLS manager if SSL is configured
if cfg.SSL != nil && cfg.SSL.Enabled { if cfg.SSL != nil && cfg.SSL.Enabled {
tlsManager, err := tlspkg.NewManager(cfg.SSL, logger) // Build custom TLS config for client
if err != nil { t.tlsConfig = &tls.Config{
return nil, fmt.Errorf("failed to create TLS manager: %w", err) 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 // Extract server name from address for SNI
host, _, err := net.SplitHostPort(cfg.Address) 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 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", logger.Info("msg", "TLS enabled for TCP client",
"component", "tcp_client_sink", "component", "tcp_client_sink",
"address", cfg.Address, "address", cfg.Address,
"server_name", host, "server_name", host,
"insecure", cfg.SSL.InsecureSkipVerify) "insecure", cfg.SSL.InsecureSkipVerify,
"mtls", cfg.SSL.CertFile != "")
} }
return t, nil return t, nil
} }
@ -381,8 +420,7 @@ func (t *TCPClientSink) monitorConnection(conn net.Conn) {
return return
case <-ticker.C: case <-ticker.C:
// Set read deadline // Set read deadline
// TODO: Add t.config.ReadTimeout and after addition use it instead of static value if err := conn.SetReadDeadline(time.Now().Add(t.config.ReadTimeout)); err != nil {
if err := conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil {
t.logger.Debug("msg", "Failed to set read deadline", "error", err) t.logger.Debug("msg", "Failed to set read deadline", "error", err)
return return
} }
@ -481,3 +519,19 @@ func tlsVersionString(version uint16) string {
return fmt.Sprintf("0x%04x", version) 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
}
}

View File

@ -110,7 +110,21 @@ func NewHTTPSource(options map[string]any, logger *log.Logger) (*HTTPSource, err
if keyFile, ok := ssl["key_file"].(string); ok { if keyFile, ok := ssl["key_file"].(string); ok {
h.sslConfig.KeyFile = keyFile 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 // Create TLS manager
if h.sslConfig.Enabled { if h.sslConfig.Enabled {
@ -146,6 +160,7 @@ func (h *HTTPSource) Start() error {
// Start server in background // Start server in background
h.wg.Add(1) h.wg.Add(1)
errChan := make(chan error, 1)
go func() { go func() {
defer h.wg.Done() defer h.wg.Done()
h.logger.Info("msg", "HTTP source server starting", h.logger.Info("msg", "HTTP source server starting",
@ -168,13 +183,20 @@ func (h *HTTPSource) Start() error {
"component", "http_source", "component", "http_source",
"port", h.port, "port", h.port,
"error", err) "error", err)
errChan <- err
} }
}() }()
// Give server time to start // Robust server startup check with timeout
time.Sleep(100 * time.Millisecond) // TODO: standardize and better manage timers 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 return nil
} }
}
func (h *HTTPSource) Stop() { func (h *HTTPSource) Stop() {
h.logger.Info("msg", "Stopping HTTP source") h.logger.Info("msg", "Stopping HTTP source")
@ -331,7 +353,8 @@ func (h *HTTPSource) parseEntries(body []byte) ([]core.LogEntry, error) {
// Try to parse as JSON array // Try to parse as JSON array
var array []core.LogEntry var array []core.LogEntry
if err := json.Unmarshal(body, &array); err == nil { 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)) approxSizePerEntry := int64(len(body) / len(array))
for i, entry := range array { for i, entry := range array {
if entry.Message == "" { if entry.Message == "" {
@ -343,7 +366,6 @@ func (h *HTTPSource) parseEntries(body []byte) ([]core.LogEntry, error) {
if entry.Source == "" { if entry.Source == "" {
array[i].Source = "http" array[i].Source = "http"
} }
// NOTE: Placeholder
array[i].RawSize = approxSizePerEntry array[i].RawSize = approxSizePerEntry
} }
return array, nil return array, nil

View File

@ -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")
}