v0.4.2 tls auth improvement, auth-gen and cert-gen sub functions added
This commit is contained in:
@ -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
139
src/cmd/logwisp/commands.go
Normal 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)"
|
||||||
|
}
|
||||||
@ -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.
|
||||||
`
|
`
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
147
src/internal/auth/generator.go
Normal file
147
src/internal/auth/generator.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,12 +183,19 @@ 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() {
|
||||||
@ -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
|
||||||
|
|||||||
275
src/internal/tls/generator.go
Normal file
275
src/internal/tls/generator.go
Normal 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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user