diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..ba1bce4 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,45 @@ +name: image build +on: + push: + branches: + - master + - docker + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@418e4b98bf2841bd337d0b24fe63cb36dc8afa55 + with: + images: ${{ env.REGISTRY }}/${{ github.repository }} + + - name: Build and push Docker image + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6912976 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1-alpine AS builder + +WORKDIR /app + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -o lnc-server ./cmd/lnc-server + +# Create a minimal runtime image +FROM alpine:3 + +RUN apk --no-cache add ca-certificates tzdata && \ + adduser -D -h /app lnproxy + +WORKDIR /app +USER lnproxy + +# Copy the binary from the builder stage +COPY --from=builder --chown=lnproxy:lnproxy /app/lnc-server /app/ + +# Expose the default port +EXPOSE 4747 + +# Set the entrypoint +ENTRYPOINT ["/app/lnc-server"] diff --git a/README.md b/README.md index 0c95035..36a16ba 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ node's pubkey. Automatically checks the payment hashes, amount, and destination, on wrapped invoices, so you get privacy without trusting anyone with your funds. +## Usage + ``` usage: address [flags] address.macaroon https://example.com address.macaroon @@ -34,4 +36,46 @@ usage: address [flags] address.macaroon https://example.com http port over which to expose api (default "4747") -username string lud6 username (default "_") + -log-level string + Log level (DEBUG, INFO, WARNING, ERROR, FATAL) (default "DEBUG") + -log-file string + Log file path (logs to stderr if not specified) +``` + +## Logging + +The application includes a comprehensive logging system with the following features: + +- **Log Levels**: Supports DEBUG, INFO, WARNING, ERROR, and FATAL log levels +- **Component-based Logging**: Different components log with their specific tags +- **Colored Output**: Terminal output includes colors for better readability +- **File Logging**: Option to log to a file instead of stderr +- **Customizable**: Log level can be adjusted via command-line flag + +### Examples + +Basic usage with default DEBUG logging to console: + +```bash +./address -username satoshi address.macaroon https://example.com +``` + +Using INFO level logging to a file: + +```bash +./address -log-level INFO -log-file server.log -username satoshi address.macaroon https://example.com +``` + +## Sample Log Output + +``` +[INFO][main] Starting lnproxy-address server +[INFO][main] Using domain: https://example.com +[INFO][main] Using LND host: https://127.0.0.1:8080 +[INFO][main] Using LNProxy at https://lnproxy.org/spec/ (base fee: 2000 msat, ppm: 10000) +[INFO][main] LNURL-pay configured for user satoshi@example.com +[INFO][main] Starting HTTP server on 0.0.0.0:4747 +[INFO][main] Lightning Address server is ready at satoshi@example.com +[DEBUG][lnurl-handler] Received request: /.well-known/lnurlp/satoshi +[DEBUG][lnurl-handler] No amount provided, returning LNURL-pay info ``` diff --git a/address.go b/address.go index 5a03e39..98bad07 100644 --- a/address.go +++ b/address.go @@ -4,11 +4,11 @@ import ( "crypto/sha256" "encoding/json" "fmt" - "log" "net/http" "net/url" "strconv" + "github.com/lnproxy/lnproxy-address/logger" "github.com/lnproxy/lnproxy-client" ) @@ -48,37 +48,100 @@ func (lnurl *LNURLP) JSONResponse() []byte { } func MakeLUD6Handler(lnurl *LNURLP, invoiceMaker InvoiceMaker, lnproxy *client.LNProxy) func(w http.ResponseWriter, r *http.Request) { + log := logger.WithComponent("lnurl-handler") + return func(w http.ResponseWriter, r *http.Request) { + log.Debug("Received request: %s", r.URL.String()) + + // Extract requested username from path + path := r.URL.Path + username := "" + if len(path) > len("/.well-known/lnurlp/") { + username = path[len("/.well-known/lnurlp/"):] + log.Debug("Requested username: %s", username) + } + + // Check if username matches or is empty + if username != "" && username != lnurl.UserName { + log.Warning("Invalid username requested: %s (expected: %s)", username, lnurl.UserName) + fmt.Fprintf(w, `{"status": "ERROR", "reason": "unknown user"}`) + return + } + amount_string := r.URL.Query().Get("amount") if amount_string == "" { + log.Debug("No amount provided, returning LNURL-pay info") + w.Header().Set("Content-Type", "application/json") w.Write(lnurl.JSONResponse()) return } + + log.Debug("Payment request with amount: %s", amount_string) amount_msat, err := strconv.ParseUint(amount_string, 10, 64) - if err != nil || amount_msat < lnurl.MinAmtMsat || amount_msat > lnurl.MaxAmtMsat { - fmt.Fprintf(w, `{"status": "ERROR", "reason": "invalid amount"}`) + if err != nil { + log.Warning("Invalid amount format: %s", amount_string) + fmt.Fprintf(w, `{"status": "ERROR", "reason": "invalid amount format"}`) + return + } + + if amount_msat < lnurl.MinAmtMsat || amount_msat > lnurl.MaxAmtMsat { + log.Warning("Amount out of range: %d (min: %d, max: %d)", + amount_msat, lnurl.MinAmtMsat, lnurl.MaxAmtMsat) + fmt.Fprintf(w, `{"status": "ERROR", "reason": "amount out of allowed range"}`) return } + + log.Debug("Calculating description hash from metadata") h := sha256.New() h.Write([]byte(lnurl.Metadata())) + description_hash := h.Sum(nil) + var routing_msat uint64 if lnproxy != nil { + // Calculate routing_msat ensuring final_amount is a multiple of 1000 msat (1 sat) routing_msat = lnproxy.BaseMsat + (lnproxy.Ppm*amount_msat)/1_000_000 + remainder := (amount_msat - routing_msat) % 1000 + if remainder != 0 { + // Adjust routing_msat to make final_amount a whole satoshi + routing_msat += remainder // This increases the fee slightly to round down final_amount + } } - inv, err := invoiceMaker.MakeInvoice(amount_msat-routing_msat, h.Sum(nil)) + + + final_amount := amount_msat - routing_msat + log.Debug("Creating invoice for %d msat (original amount: %d msat)", final_amount, amount_msat) + + inv, err := invoiceMaker.MakeInvoice(final_amount, description_hash) if err != nil { - log.Println("error requesting invoice:", err) + log.Error("Error requesting invoice: %v", err) fmt.Fprintf(w, `{"status": "ERROR", "reason": "error requesting invoice"}`) return } + log.Debug("Invoice created successfully") + if lnproxy != nil { - inv, err = lnproxy.RequestProxy(inv, routing_msat) + log.Debug("Proxying invoice through lnproxy") + originalInv := inv // Save the original invoice before proxying + inv, err = lnproxy.RequestProxy(originalInv, routing_msat) if err != nil { - log.Println("error wrapping invoice:", err) + log.Error("Error wrapping invoice: %v", err) fmt.Fprintf(w, `{"status": "ERROR", "reason": "error wrapping invoice"}`) return } + log.Debug("Successfully proxied invoice") + + // Validate the proxy invoice against the original + valid, validateErr := client.ValidateProxyInvoice(originalInv, inv, routing_msat) + if validateErr != nil || !valid { + log.Error("Proxy invoice validation failed: %v", validateErr) + fmt.Fprintf(w, `{"status": "ERROR", "reason": "proxy invoice validation failed: %v"}`, validateErr) + return + } + log.Debug("Proxy invoice validated successfully") } + + log.Info("Successfully created invoice for %d msat", amount_msat) + w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"pr": "%s", "routes": []}`, inv) } } diff --git a/cmd/lnc-server/main.go b/cmd/lnc-server/main.go index 1d585bd..682dbcc 100644 --- a/cmd/lnc-server/main.go +++ b/cmd/lnc-server/main.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "flag" "fmt" - "log" "net/http" "net/url" "os" @@ -14,21 +13,31 @@ import ( "github.com/lnproxy/lnc" "github.com/lnproxy/lnproxy-address" + "github.com/lnproxy/lnproxy-address/logger" "github.com/lnproxy/lnproxy-client" ) type LncInvoicer struct { lnc.LN + log *logger.Logger } func (invoicer *LncInvoicer) MakeInvoice(amount_msat uint64, description_hash []byte) (string, error) { - return invoicer.AddInvoice(lnc.InvoiceParameters{ + invoicer.log.Debug("Creating invoice for %d msat with description hash %x", amount_msat, description_hash) + invoice, err := invoicer.AddInvoice(lnc.InvoiceParameters{ DescriptionHash: description_hash, ValueMsat: amount_msat, }) + if err != nil { + invoicer.log.Error("Failed to create invoice: %v", err) + return "", err + } + invoicer.log.Debug("Successfully created invoice: %s", invoice[:20]+"...") + return invoice, nil } func main() { + // Define command line flags username := flag.String("username", "_", "lud6 username") maxAmtMsat := flag.Uint64("max-msat", 10_000_000_000, "max msat per payment") minAmtMsat := flag.Uint64("min-msat", 10_000, "min msat per payment") @@ -42,6 +51,8 @@ func main() { lnproxyUrlString := flag.String("lnproxy-url", "https://lnproxy.org/spec/", "REST API url for lnproxy relay, empty string for no proxying") lnproxyBaseMsat := flag.Uint64("lnproxy-routing-base", 2_000, "base routing budget for lnproxy relay") lnproxyPpm := flag.Uint64("lnproxy-routing-ppm", 10_000, "ppm routing budget for lnproxy relay") + logLevelStr := flag.String("log-level", "DEBUG", "Log level (DEBUG, INFO, WARNING, ERROR, FATAL)") + logFile := flag.String("log-file", "", "Log file path (logs to stderr if not specified)") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), `usage: %s [flags] address.macaroon https://example.com @@ -62,30 +73,69 @@ func main() { os.Exit(2) } - macaroonBytes, err := os.ReadFile(flag.Args()[0]) + // Configure logging + logLevel, err := logger.ParseLogLevel(*logLevelStr) if err != nil { - log.Fatalln("unable to read lnproxy macaroon file:", err) + fmt.Fprintf(os.Stderr, "Invalid log level: %v\n", err) + os.Exit(1) } - macaroon := hex.EncodeToString(macaroonBytes) + + // Set up log output + if *logFile != "" { + file, err := os.OpenFile(*logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err) + os.Exit(1) + } + defer file.Close() + logger.SetDefaultOutput(file) + } + + logger.SetDefaultLevel(logLevel) + log := logger.WithComponent("main") + + log.Info("Starting lnproxy-address server") + log.Debug("Log level set to %s", *logLevelStr) + macaroonPath := flag.Args()[0] domain := flag.Args()[1] + + log.Debug("Reading macaroon from %s", macaroonPath) + macaroonBytes, err := os.ReadFile(macaroonPath) + if err != nil { + log.Fatal("Unable to read lnproxy macaroon file: %v", err) + } + macaroon := hex.EncodeToString(macaroonBytes) + log.Debug("Successfully loaded macaroon (%d bytes)", len(macaroonBytes)) + log.Debug("Parsing domain URL: %s", domain) + domainURL, err := url.Parse(domain) + if err != nil { + log.Fatal("Unable to parse domain url: %v", err) + } + log.Info("Using domain: %s", domainURL.String()) + + log.Debug("Parsing LND host URL: %s", *lndHostString) lndHost, err := url.Parse(*lndHostString) if err != nil { - log.Fatalln("unable to parse lnd host url:", err) + log.Fatal("Unable to parse lnd host url: %v", err) } + log.Info("Using LND host: %s", lndHost.String()) var lndTlsConfig *tls.Config if *lndCertPath == "" { + log.Info("TLS certificate path not provided, using insecure connection") lndTlsConfig = &tls.Config{} } else { + log.Debug("Reading LND TLS certificate from %s", *lndCertPath) lndCert, err := os.ReadFile(*lndCertPath) if err != nil { - log.Fatalln("unable to read lnd tls certificate file:", err) + log.Fatal("Unable to read lnd tls certificate file: %v", err) } caCertPool := x509.NewCertPool() caCertPool.AppendCertsFromPEM(lndCert) lndTlsConfig = &tls.Config{RootCAs: caCertPool} + log.Debug("Successfully loaded LND TLS certificate (%d bytes)", len(lndCert)) } lndClient := &http.Client{ @@ -94,32 +144,34 @@ func main() { }, } - domainURL, err := url.Parse(domain) - if err != nil { - log.Fatalln("unable to parse domain url:", err) - } - var lnproxy *client.LNProxy if *lnproxyUrlString != "" { + log.Debug("Parsing LNProxy URL: %s", *lnproxyUrlString) lnproxyUrl, err := url.Parse(*lnproxyUrlString) if err != nil { - log.Fatalln("unable to parse lnproxy url:", err) - } - lnproxy = &client.LNProxy{ - URL: *lnproxyUrl, - Client: http.Client{Timeout: 15 * time.Second}, - BaseMsat: *lnproxyBaseMsat, - Ppm: *lnproxyPpm, + log.Fatal("Unable to parse lnproxy url: %v", err) } + lnproxy = client.NewLNProxy(*lnproxyUrl, *lnproxyBaseMsat, *lnproxyPpm) + lnproxy.Client = http.Client{Timeout: 15 * time.Second} + log.Info("Using LNProxy at %s (base fee: %d msat, ppm: %d)", lnproxyUrl.String(), *lnproxyBaseMsat, *lnproxyPpm) + } else { + log.Info("LNProxy is disabled") + } + + lnurlp := &address.LNURLP{ + Domain: *domainURL, + UserName: *username, + MaxAmtMsat: *maxAmtMsat, + MinAmtMsat: *minAmtMsat, } + + log.Info("LNURL-pay configured for user %s@%s", lnurlp.UserName, lnurlp.Domain.Host) + log.Debug("Payment limits: min=%d msat, max=%d msat", lnurlp.MinAmtMsat, lnurlp.MaxAmtMsat) + invoicerLog := logger.WithComponent("invoicer") + http.HandleFunc("/.well-known/lnurlp/", address.MakeLUD6Handler( - &address.LNURLP{ - Domain: *domainURL, - UserName: *username, - MaxAmtMsat: *maxAmtMsat, - MinAmtMsat: *minAmtMsat, - }, + lnurlp, &LncInvoicer{ LN: &lnc.Lnd{ Host: lndHost, @@ -127,8 +179,15 @@ func main() { TlsConfig: lndTlsConfig, Macaroon: macaroon, }, + log: invoicerLog, }, lnproxy, )) - log.Fatalln(http.ListenAndServe("localhost:"+*httpPort, nil)) + + listenAddr := "0.0.0.0:" + *httpPort + log.Info("Starting HTTP server on %s", listenAddr) + log.Info("Lightning Address server is ready at %s@%s", *username, domainURL.Host) + + err = http.ListenAndServe(listenAddr, nil) + log.Fatal("HTTP server error: %v", err) } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..476f4bb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + lnproxy-address: + build: + context: . + dockerfile: Dockerfile + container_name: lnproxy-address + restart: unless-stopped + ports: + - "4747:4747" + volumes: + - ./address.macaroon:/app/address.macaroon:ro + - ${LND_CERT_PATH:-~/.lnd/tls.cert}:/app/tls.cert:ro + environment: + - TZ=UTC + command: > + -lnd=${LND_HOST:-https://127.0.0.1:8080} + -lnd-cert=/app/tls.cert + -username=${LIGHTNING_USERNAME:-_} + -port=4747 + -lnproxy-url=${LNPROXY_URL:-https://lnproxy.org/spec/} + -lnproxy-routing-base=${LNPROXY_BASE:-10} + -lnproxy-routing-ppm=${LNPROXY_PPM:-1000} + -min-msat=${MIN_MSAT:-10000} + -max-msat=${MAX_MSAT:-10000000000} + /app/address.macaroon ${DOMAIN_URL:-https://example.com} diff --git a/go.mod b/go.mod index 04af5bb..76988d4 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,5 @@ require ( ) require golang.org/x/net v0.12.0 // indirect + +replace github.com/lnproxy/lnproxy-client => github.com/m0wer/lnproxy-client v0.0.0-20250513104607-cf4696ea9ea9 diff --git a/go.sum b/go.sum index 8ed833f..3c8362d 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ github.com/lnproxy/lnc v0.0.0-20230717031658-19e704e85a5f h1:GcmoU6m1acK18McMD/EYTjO4iThnDgVU3ke7FRXLd3E= github.com/lnproxy/lnc v0.0.0-20230717031658-19e704e85a5f/go.mod h1:G5r26LzotKe++whzqIs2LeL00DzQihZDQxBbI6bNlgg= -github.com/lnproxy/lnproxy-client v0.0.0-20230712183300-66175c377dc4 h1:RKybPsiyKDGZKoa7lm8+UpP8D0pJ15nuYNZn3POb6D8= -github.com/lnproxy/lnproxy-client v0.0.0-20230712183300-66175c377dc4/go.mod h1:AWXqCmcDBgFNDExEiw/eG3D0vvYdlNzs38EbRhfMlxA= +github.com/m0wer/lnproxy-client v0.0.0-20250513104607-cf4696ea9ea9 h1:7NDBJv9droua5Dnq7QCOg47Rqk85ttiQiyIcAq6uIvM= +github.com/m0wer/lnproxy-client v0.0.0-20250513104607-cf4696ea9ea9/go.mod h1:AWXqCmcDBgFNDExEiw/eG3D0vvYdlNzs38EbRhfMlxA= golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..d95e754 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,226 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "strings" + "sync" +) + +// LogLevel represents the severity level of a log message +type LogLevel int + +const ( + // LogLevelDebug is the most verbose logging level + LogLevelDebug LogLevel = iota + // LogLevelInfo is for general operational information + LogLevelInfo + // LogLevelWarning is for important but non-critical issues + LogLevelWarning + // LogLevelError is for errors that affect functionality + LogLevelError + // LogLevelFatal is for critical errors that prevent the program from continuing + LogLevelFatal +) + +var levelNames = map[LogLevel]string{ + LogLevelDebug: "DEBUG", + LogLevelInfo: "INFO", + LogLevelWarning: "WARNING", + LogLevelError: "ERROR", + LogLevelFatal: "FATAL", +} + +var levelColors = map[LogLevel]string{ + LogLevelDebug: "\033[36m", // Cyan + LogLevelInfo: "\033[32m", // Green + LogLevelWarning: "\033[33m", // Yellow + LogLevelError: "\033[31m", // Red + LogLevelFatal: "\033[35m", // Magenta +} + +// Logger is a custom logger with support for log levels +type Logger struct { + mu sync.Mutex + level LogLevel + logger *log.Logger + useColors bool + component string + resetColor string +} + +var defaultLogger *Logger + +func init() { + defaultLogger = NewLogger(os.Stderr, LogLevelDebug) +} + +// ParseLogLevel converts a string log level to LogLevel type +func ParseLogLevel(level string) (LogLevel, error) { + switch strings.ToUpper(level) { + case "DEBUG": + return LogLevelDebug, nil + case "INFO": + return LogLevelInfo, nil + case "WARNING", "WARN": + return LogLevelWarning, nil + case "ERROR": + return LogLevelError, nil + case "FATAL": + return LogLevelFatal, nil + default: + return LogLevelDebug, fmt.Errorf("unknown log level: %s", level) + } +} + +// NewLogger creates a new logger with the specified output and level +func NewLogger(out io.Writer, level LogLevel) *Logger { + return &Logger{ + logger: log.New(out, "", log.LstdFlags), + level: level, + useColors: isTTY(out), + resetColor: "\033[0m", + } +} + +// WithComponent returns a new logger with the specified component name +func (l *Logger) WithComponent(component string) *Logger { + newLogger := &Logger{ + logger: l.logger, + level: l.level, + useColors: l.useColors, + component: component, + resetColor: l.resetColor, + } + return newLogger +} + +// SetLevel sets the log level +func (l *Logger) SetLevel(level LogLevel) { + l.mu.Lock() + defer l.mu.Unlock() + l.level = level +} + +// SetOutput sets the output writer +func (l *Logger) SetOutput(w io.Writer) { + l.mu.Lock() + defer l.mu.Unlock() + l.logger.SetOutput(w) + l.useColors = isTTY(w) +} + +// logf logs a message at the specified level +func (l *Logger) logf(level LogLevel, format string, args ...interface{}) { + if level < l.level { + return + } + + l.mu.Lock() + defer l.mu.Unlock() + + // Format the message with level and component info + var prefix string + if l.useColors { + if l.component != "" { + prefix = fmt.Sprintf("%s[%s][%s]%s ", levelColors[level], levelNames[level], l.component, l.resetColor) + } else { + prefix = fmt.Sprintf("%s[%s]%s ", levelColors[level], levelNames[level], l.resetColor) + } + } else { + if l.component != "" { + prefix = fmt.Sprintf("[%s][%s] ", levelNames[level], l.component) + } else { + prefix = fmt.Sprintf("[%s] ", levelNames[level]) + } + } + + msg := fmt.Sprintf(format, args...) + l.logger.Print(prefix + msg) + + // Exit on fatal error + if level == LogLevelFatal { + os.Exit(1) + } +} + +// Debug logs a debug message +func (l *Logger) Debug(format string, args ...interface{}) { + l.logf(LogLevelDebug, format, args...) +} + +// Info logs an info message +func (l *Logger) Info(format string, args ...interface{}) { + l.logf(LogLevelInfo, format, args...) +} + +// Warning logs a warning message +func (l *Logger) Warning(format string, args ...interface{}) { + l.logf(LogLevelWarning, format, args...) +} + +// Error logs an error message +func (l *Logger) Error(format string, args ...interface{}) { + l.logf(LogLevelError, format, args...) +} + +// Fatal logs a fatal message and exits +func (l *Logger) Fatal(format string, args ...interface{}) { + l.logf(LogLevelFatal, format, args...) +} + +// Global logger functions + +// SetDefaultLevel sets the log level for the default logger +func SetDefaultLevel(level LogLevel) { + defaultLogger.SetLevel(level) +} + +// SetDefaultOutput sets the output for the default logger +func SetDefaultOutput(w io.Writer) { + defaultLogger.SetOutput(w) +} + +// Debug logs a debug message to the default logger +func Debug(format string, args ...interface{}) { + defaultLogger.Debug(format, args...) +} + +// Info logs an info message to the default logger +func Info(format string, args ...interface{}) { + defaultLogger.Info(format, args...) +} + +// Warning logs a warning message to the default logger +func Warning(format string, args ...interface{}) { + defaultLogger.Warning(format, args...) +} + +// Error logs an error message to the default logger +func Error(format string, args ...interface{}) { + defaultLogger.Error(format, args...) +} + +// Fatal logs a fatal message to the default logger and exits +func Fatal(format string, args ...interface{}) { + defaultLogger.Fatal(format, args...) +} + +// WithComponent returns a new logger with the specified component name +func WithComponent(component string) *Logger { + return defaultLogger.WithComponent(component) +} + +// Helper to detect if output is a TTY (for color output) +func isTTY(w io.Writer) bool { + if f, ok := w.(*os.File); ok { + // Try to get file info + if stat, err := f.Stat(); err == nil { + // Check if it's a character device (TTY) + return (stat.Mode() & os.ModeCharDevice) != 0 + } + } + return false +}