v0.13.0 doc update, refactor, http/tcp chain source and sink added

This commit is contained in:
2026-07-17 05:58:44 -04:00
parent fa7f41c059
commit a48514a2eb
35 changed files with 2211 additions and 1292 deletions
+9 -8
View File
@@ -1,13 +1,14 @@
.idea .idea
data data/
dev dev/
log log/
logs logs/
cert cert/
bin bin/
script script/
build build/
*.log *.log
*.toml *.toml
build.sh build.sh
catalog.txt catalog.txt
combined.txt
-372
View File
@@ -1,372 +0,0 @@
###############################################################################
### LogWisp Configuration
### Default location: ~/.config/logwisp/logwisp.toml
### Configuration Precedence: CLI flags > Environment > File > Defaults
### Default values shown - uncommented lines represent active configuration
###############################################################################
###############################################################################
### Global Settings
###############################################################################
quiet = false # Enable quiet mode, suppress console output
status_reporter = true # Enable periodic status logging
auto_reload = false # Enable config auto-reload on file change
###############################################################################
### Logging Configuration (LogWisp's internal operational logging)
###############################################################################
[logging]
output = "stdout" # file|stdout|stderr|split|all|none
level = "info" # debug|info|warn|error
# [logging.file]
# directory = "./log" # Log directory path
# name = "logwisp" # Base filename
# max_size_mb = 100 # Rotation threshold
# max_total_size_mb = 1000 # Total size limit
# retention_hours = 168.0 # Delete logs older than (7 days)
[logging.console]
target = "stdout" # stdout|stderr|split
format = "txt" # txt|json
###############################################################################
### Pipeline Configuration
### Each pipeline: sources -> rate_limit -> filters -> format -> sinks
###############################################################################
[[pipelines]]
name = "default" # Pipeline identifier
###============================================================================
### Rate Limiting (Pipeline-level)
###============================================================================
# [pipelines.rate_limit]
# rate = 1000.0 # Entries per second (0=disabled)
# burst = 2000.0 # Burst capacity (defaults to rate)
# policy = "drop" # pass|drop
# max_entry_size_bytes = 0 # Max entry size (0=unlimited)
###============================================================================
### Filters (Sequential pattern matching)
###============================================================================
### ⚠️ Example: Include only ERROR and WARN logs
## [[pipelines.filters]]
## type = "include" # include|exclude
## logic = "or" # or|and
## patterns = [".*ERROR.*", ".*WARN.*"]
### ⚠️ Example: Exclude debug logs
## [[pipelines.filters]]
## type = "exclude"
## patterns = [".*DEBUG.*"]
###============================================================================
### Format (Log transformation)
###============================================================================
# [pipelines.format]
# type = "raw" # raw|json|txt
## JSON formatting
# [pipelines.format.json]
# pretty = false # Pretty-print JSON
# timestamp_field = "timestamp" # Field name for timestamp
# level_field = "level" # Field name for log level
# message_field = "message" # Field name for message
# source_field = "source" # Field name for source
## Text templating
# [pipelines.format.txt]
# template = "{{.Timestamp | FmtTime}} [{{.Level}}] {{.Message}}"
# timestamp_format = "2006-01-02 15:04:05"
## Raw templating
# [pipelines.format.raw]
# add_new_line = true # Preserve new line delimiter between log entries
###============================================================================
### SOURCES (Inputs)
### Architecture: Pipeline can have multiple sources
###============================================================================
###----------------------------------------------------------------------------
### File Source (File monitoring)
[[pipelines.sources]]
type = "file"
[pipelines.sources.file]
directory = "./" # Directory to monitor
pattern = "*.log" # Glob pattern
check_interval_ms = 100 # File check interval
recursive = false # Recursive monitoring (TODO)
###----------------------------------------------------------------------------
### Console Source
# [[pipelines.sources]]
# type = "console"
# [pipelines.sources.console]
# buffer_size = 1000
###----------------------------------------------------------------------------
### HTTP Source (Server mode - receives logs via HTTP POST)
# [[pipelines.sources]]
# type = "http"
# [pipelines.sources.http]
# host = "0.0.0.0" # Listen interface
# port = 8081 # Listen port
# ingest_path = "/ingest" # Ingestion endpoint
# buffer_size = 1000
# max_body_size = 1048576 # 1MB
# read_timeout_ms = 10000
# write_timeout_ms = 10000
### Network access control
# [pipelines.sources.http.acl]
# enabled = false
# max_connections_per_ip = 10 # Max simultaneous connections from a single IP
# max_connections_total = 100 # Max simultaneous connections for this component
# requests_per_second = 100.0 # Per-IP request rate limit
# burst_size = 200 # Per-IP request burst limit
# response_message = "Rate limit exceeded"
# response_code = 429
# ip_whitelist = ["192.168.1.0/24"]
# ip_blacklist = ["10.0.0.100"]
### TLS configuration (mTLS support)
# [pipelines.sources.http.tls]
# enabled = false
# cert_file = "/path/to/server.pem" # Server certificate
# key_file = "/path/to/server.key" # Server private key
# client_auth = false # Enable mTLS
# client_ca_file = "/path/to/ca.pem" # CA for client verification
# verify_client_cert = true # Verify client certificates
# min_version = "TLS1.2" # TLS1.0|TLS1.1|TLS1.2|TLS1.3
# max_version = "TLS1.3"
# cipher_suites = "" # Comma-separated cipher list
###----------------------------------------------------------------------------
### TCP Source (Server mode - receives logs via TCP)
# [[pipelines.sources]]
# type = "tcp"
# [pipelines.sources.tcp]
# host = "0.0.0.0"
# port = 9091
# buffer_size = 1000
# read_timeout_ms = 10000
# keep_alive = true
# keep_alive_period_ms = 30000
### Network access control
# [pipelines.sources.tcp.acl]
# enabled = false
# max_connections_per_ip = 10 # Max simultaneous connections from a single IP
# max_connections_total = 100 # Max simultaneous connections for this component
# requests_per_second = 100.0 # Per-IP request rate limit
# burst_size = 200 # Per-IP request burst limit
# response_message = "Rate limit exceeded"
# response_code = 429
# ip_whitelist = ["192.168.1.0/24"]
# ip_blacklist = ["10.0.0.100"]
### ⚠️ IMPORTANT: TCP does NOT support TLS/mTLS (gnet limitation)
### Use HTTP Source with TLS for encrypted transport
###============================================================================
### SINKS (Outputs)
### Architecture: Pipeline can have multiple sinks (fan-out)
###============================================================================
###----------------------------------------------------------------------------
### Console Sink
# [[pipelines.sinks]]
# type = "console"
# [pipelines.sinks.console]
# target = "stdout" # stdout|stderr|split
# colorize = false # Colorized output
# buffer_size = 100
###----------------------------------------------------------------------------
### File Sink (Rotating logs)
# [[pipelines.sinks]]
# type = "file"
# [pipelines.sinks.file]
# directory = "./logs"
# name = "output"
# max_size_mb = 100
# max_total_size_mb = 1000
# min_disk_free_mb = 100
# retention_hours = 168.0 # 7 days
# buffer_size = 1000
# flush_interval_ms = 1000
###----------------------------------------------------------------------------
### HTTP Sink (Server mode - SSE streaming for clients)
[[pipelines.sinks]]
type = "http"
[pipelines.sinks.http]
host = "0.0.0.0"
port = 8080
stream_path = "/stream" # SSE streaming endpoint
status_path = "/status" # Status endpoint
buffer_size = 1000
write_timeout_ms = 10000
### Heartbeat configuration (keep connections alive)
[pipelines.sinks.http.heartbeat]
enabled = true
interval_ms = 30000 # 30 seconds
include_timestamp = true
include_stats = false
format = "comment" # comment|event|json
### Network access control
# [pipelines.sinks.http.acl]
# enabled = false
# max_connections_per_ip = 10 # Max simultaneous connections from a single IP
# max_connections_total = 100 # Max simultaneous connections for this component
# requests_per_second = 100.0 # Per-IP request rate limit
# burst_size = 200 # Per-IP request burst limit
# response_message = "Rate limit exceeded"
# response_code = 429
# ip_whitelist = ["192.168.1.0/24"]
# ip_blacklist = ["10.0.0.100"]
### TLS configuration (mTLS support)
# [pipelines.sinks.http.tls]
# enabled = false
# cert_file = "/path/to/server.pem" # Server certificate
# key_file = "/path/to/server.key" # Server private key
# client_auth = false # Enable mTLS
# client_ca_file = "/path/to/ca.pem" # CA for client verification
# verify_client_cert = true # Verify client certificates
# min_version = "TLS1.2" # TLS1.0|TLS1.1|TLS1.2|TLS1.3
# max_version = "TLS1.3"
# cipher_suites = "" # Comma-separated cipher list
###----------------------------------------------------------------------------
### TCP Sink (Server mode - TCP streaming for clients)
# [[pipelines.sinks]]
# type = "tcp"
# [pipelines.sinks.tcp]
# host = "0.0.0.0"
# port = 9090
# buffer_size = 1000
# write_timeout_ms = 10000
# keep_alive = true
# keep_alive_period_ms = 30000
### Heartbeat configuration
# [pipelines.sinks.tcp.heartbeat]
# enabled = false
# interval_ms = 30000
# include_timestamp = true
# include_stats = false
# format = "json" # json|txt
### Network access control
# [pipelines.sinks.tcp.acl]
# enabled = false
# max_connections_per_ip = 10 # Max simultaneous connections from a single IP
# max_connections_total = 100 # Max simultaneous connections for this component
# requests_per_second = 100.0 # Per-IP request rate limit
# burst_size = 200 # Per-IP request burst limit
# response_message = "Rate limit exceeded"
# response_code = 429
# ip_whitelist = ["192.168.1.0/24"]
# ip_blacklist = ["10.0.0.100"]
### ⚠️ IMPORTANT: TCP does NOT support TLS/mTLS (gnet limitation)
### Use HTTP Sink with TLS for encrypted transport
###----------------------------------------------------------------------------
### HTTP Client Sink (Forward to remote HTTP endpoint)
# [[pipelines.sinks]]
# type = "http_client"
# [pipelines.sinks.http_client]
# url = "https://logs.example.com/ingest"
# buffer_size = 1000
# batch_size = 100 # Entries per batch
# batch_delay_ms = 1000 # Max wait before sending
# timeout_seconds = 30
# max_retries = 3
# retry_delay_ms = 1000
# retry_backoff = 2.0 # Exponential backoff multiplier
# insecure_skip_verify = false # Skip TLS verification
### TLS configuration for client
# [pipelines.sinks.http_client.tls]
# enabled = false # Enable TLS for the outgoing connection
# server_ca_file = "/path/to/ca.pem" # CA for verifying the remote server's certificate
# server_name = "logs.example.com" # For server certificate validation (SNI)
# insecure_skip_verify = false # Skip server verification, use with caution
# client_cert_file = "/path/to/client.pem" # Client's certificate to present to the server for mTLS
# client_key_file = "/path/to/client.key" # Client's private key for mTLS
# min_version = "TLS1.2"
# max_version = "TLS1.3"
# cipher_suites = ""
### ⚠️ Example: HTTP Client Sink → HTTP Source with mTLS
## HTTP Source with mTLS:
## [pipelines.sources.http.tls]
## enabled = true
## cert_file = "/path/to/server.pem"
## key_file = "/path/to/server.key"
## client_auth = true # Enable client cert verification
## client_ca_file = "/path/to/ca.pem"
## verify_client_cert = true
## HTTP Client with client cert:
## [pipelines.sinks.http_client.tls]
## enabled = true
## server_ca_file = "/path/to/ca.pem" # Verify server
## client_cert_file = "/path/to/client.pem" # Client certificate
## client_key_file = "/path/to/client.key"
###----------------------------------------------------------------------------
### TCP Client Sink (Forward to remote TCP endpoint)
# [[pipelines.sinks]]
# type = "tcp_client"
# [pipelines.sinks.tcp_client]
# host = "logs.example.com"
# port = 9090
# buffer_size = 1000
# dial_timeout_seconds = 10 # Connection timeout
# write_timeout_seconds = 30 # Write timeout
# read_timeout_seconds = 10 # Read timeout
# keep_alive_seconds = 30 # TCP keep-alive
# reconnect_delay_ms = 1000 # Initial reconnect delay
# max_reconnect_delay_ms = 30000 # Max reconnect delay
# reconnect_backoff = 1.5 # Exponential backoff
### ⚠️ WARNING: TCP Client has NO TLS support
### Use HTTP Client with TLS for encrypted transport
###############################################################################
### Common Usage Patterns
###############################################################################
### Pattern 1: Log Aggregation (Client → Server)
### - HTTP Client Sink → HTTP Source (with optional TLS/mTLS)
### - TCP Client Sink → TCP Source (unencrypted only)
### Pattern 2: Live Monitoring
### - HTTP Sink: Browser-based SSE streaming (https://host:8080/stream)
### - TCP Sink: Debug interface (telnet/netcat to port 9090)
### Pattern 3: Log Collection & Distribution
### - File Source → Multiple Sinks (fan-out)
### - Multiple Sources → Single Pipeline → Multiple Sinks
+14 -17
View File
@@ -6,24 +6,20 @@ A pipeline-based log transport and processing system built in Go. LogWisp provid
### Core Capabilities ### Core Capabilities
- **Pipeline Architecture**: Independent processing pipelines with source(s) → filter → format → sink(s) flow - **Pipeline Architecture**: Independent processing pipelines with source(s) → filter → format → sink(s) flow
- **Multiple Input Sources**: Directory monitoring, stdin, HTTP, TCP - **Multiple Input Sources**: File monitoring, console (stdin), random log generation, null
- **Flexible Output Sinks**: Console, file, HTTP SSE, TCP streaming, HTTP/TCP forwarding - **Flexible Output Sinks**: Console, file, HTTP SSE, TCP streaming, null
- **Real-time Processing**: Sub-millisecond latency with configurable buffering - **Real-time Processing**: Sub-millisecond latency with configurable buffering
- **Hot Configuration Reload**: Update pipelines without service restart - **Hot Configuration Reload**: Update pipelines without service restart
- **Session Management**: Built-in session tracking for multiple client connections
### Data Processing ### Data Processing
- **Pattern-based Filtering**: Chainable include/exclude filters with regex support - **Pattern-based Filtering**: Chainable include/exclude filters with regex support
- **Multiple Formatters**: Raw, JSON, and template-based text formatting - **Multiple Formatters**: Raw, JSON, and text formatting with integrated sanitizer policies
- **Rate Limiting**: Pipeline rate controls - **Rate Limiting**: Pipeline rate controls
- **Heartbeat Generation**: Flow-level heartbeat events for keep-alives
### Security & Reliability ### Security & Reliability
- **Authentication**: mTLS support
- **Access Control**: IP whitelisting/blacklisting, connection limits
- **TLS Encryption**: Full TLS 1.2/1.3 support for HTTP connections
- **Automatic Reconnection**: Resilient client connections with exponential backoff
- **File Rotation**: Size-based rotation with retention policies - **File Rotation**: Size-based rotation with retention policies
### Operational Features
- **Status Monitoring**: Real-time statistics and health endpoints - **Status Monitoring**: Real-time statistics and health endpoints
- **Signal Handling**: Graceful shutdown and configuration reload via signals - **Signal Handling**: Graceful shutdown and configuration reload via signals
- **Background Mode**: Daemon operation with proper signal handling - **Background Mode**: Daemon operation with proper signal handling
@@ -38,8 +34,7 @@ A pipeline-based log transport and processing system built in Go. LogWisp provid
- [Output Sinks](sinks.md) - Sink types and output options - [Output Sinks](sinks.md) - Sink types and output options
- [Filters](filters.md) - Pattern-based log filtering - [Filters](filters.md) - Pattern-based log filtering
- [Formatters](formatters.md) - Log formatting and transformation - [Formatters](formatters.md) - Log formatting and transformation
- [Security](security.md) - IP-based access control configuration and mTLS - [Networking & Security](networking.md) - Network features (Note: TLS and Auth are currently placeholders in the new architecture)
- [Networking](networking.md) - TLS, rate limiting, and network features
- [Command Line Interface](cli.md) - CLI flags and subcommands - [Command Line Interface](cli.md) - CLI flags and subcommands
- [Operations Guide](operations.md) - Running and maintaining LogWisp - [Operations Guide](operations.md) - Running and maintaining LogWisp
@@ -51,15 +46,17 @@ Install LogWisp and create a basic configuration:
[[pipelines]] [[pipelines]]
name = "default" name = "default"
[[pipelines.sources]] [[pipelines.plugin_sources]]
type = "directory" id = "default_source"
[pipelines.sources.directory] type = "file"
path = "./" [pipelines.plugin_sources.config]
directory = "./"
pattern = "*.log" pattern = "*.log"
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "default_sink"
type = "console" type = "console"
[pipelines.sinks.console] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout"
``` ```
+26 -23
View File
@@ -13,13 +13,15 @@ Each pipeline operates independently with a source → filter → format → sin
``` ```
Service (Main Process) Service (Main Process)
├── Pipeline 1 ├── Pipeline 1
│ ├── Sources (1 or more) │ ├── Plugin Sources (1 or more)
│ ├── Rate Limiter (optional) │ ├── Flow
│ ├── Filter Chain (optional) │ ├── Heartbeat Generator (optional)
│ ├── Formatter (optional) │ ├── Rate Limiter (optional)
└── Sinks (1 or more) │ ├── Filter Chain (optional)
│ │ └── Formatter (optional)
│ └── Plugin Sinks (1 or more)
├── Pipeline 2 ├── Pipeline 2
│ └── [Same structure] │ └── [Similar structure]
└── Status Reporter (optional) └── Status Reporter (optional)
``` ```
@@ -27,11 +29,11 @@ Service (Main Process)
### Processing Stages ### Processing Stages
1. **Source Stage**: Sources monitor inputs and generate log entries 1. **Source Stage**: Plugin sources monitor inputs and generate log entries
2. **Rate Limiting**: Optional pipeline-level rate control 2. **Flow - Rate Limiting**: Optional pipeline-level rate control
3. **Filtering**: Pattern-based inclusion/exclusion 3. **Flow - Filtering**: Pattern-based inclusion/exclusion
4. **Formatting**: Transform entries to desired output format 4. **Flow - Formatting**: Transform entries to desired output format with sanitization
5. **Distribution**: Fan-out to multiple sinks 5. **Distribution**: Fan-out to multiple plugin sinks
### Entry Lifecycle ### Entry Lifecycle
@@ -50,14 +52,16 @@ Each component maintains internal buffers to handle burst traffic:
- Sinks: Independent buffers per sink - Sinks: Independent buffers per sink
- Network components: Additional TCP/HTTP buffers - Network components: Additional TCP/HTTP buffers
*"Sink dispatch uses non-blocking sends. When a sink's input buffer is full, the event is dropped for that sink only and counted in pipeline statistics (`total_dropped_by_sink`). The policy is uniform and not configurable; a slow sink does not stall the pipeline or other sinks."*
## Component Types ## Component Types
### Sources (Input) ### Sources (Input)
- **Directory Source**: File system monitoring with rotation detection - **File Source**: File system directory monitoring with rotation detection
- **Stdin Source**: Standard input processing - **Console Source**: Standard input processing (stdin)
- **HTTP Source**: REST endpoint for log ingestion - **Random Source**: Generates random log entries for testing
- **TCP Source**: Raw TCP socket listener - **Null Source**: Discards logs, used for testing
### Sinks (Output) ### Sinks (Output)
@@ -65,14 +69,13 @@ Each component maintains internal buffers to handle burst traffic:
- **File Sink**: Rotating file writer - **File Sink**: Rotating file writer
- **HTTP Sink**: Server-Sent Events (SSE) streaming - **HTTP Sink**: Server-Sent Events (SSE) streaming
- **TCP Sink**: TCP server for client connections - **TCP Sink**: TCP server for client connections
- **HTTP Client Sink**: Forward to remote HTTP endpoints - **Null Sink**: Discards all received events
- **TCP Client Sink**: Forward to remote TCP servers
### Processing Components ### Processing Components
- **Rate Limiter**: Token bucket algorithm for flow control - **Rate Limiter**: Token bucket algorithm for flow control
- **Filter Chain**: Sequential pattern matching - **Filter Chain**: Sequential pattern matching
- **Formatters**: Raw, JSON, or template-based text transformation - **Formatters**: Raw, JSON, or text transformation with sanitizer policies
## Concurrency Model ## Concurrency Model
@@ -95,8 +98,7 @@ Each component maintains internal buffers to handle burst traffic:
### Connection Patterns ### Connection Patterns
**Chaining Design**: **Chaining Design**:
- TCP Client Sink → TCP Source: Direct TCP forwarding - Future plan
- HTTP Client Sink → HTTP Source: HTTP-based forwarding
**Monitoring Design**: **Monitoring Design**:
- TCP Sink: Debugging interface - TCP Sink: Debugging interface
@@ -106,8 +108,6 @@ Each component maintains internal buffers to handle burst traffic:
- HTTP/1.1 and HTTP/2 for HTTP connections - HTTP/1.1 and HTTP/2 for HTTP connections
- Raw TCP connections - Raw TCP connections
- TLS 1.2/1.3 for HTTPS connections (HTTP only)
- Server-Sent Events for real-time streaming
## Resource Management ## Resource Management
@@ -125,6 +125,10 @@ Each component maintains internal buffers to handle burst traffic:
### Connection Management ### Connection Management
- HTTP sink silenty drops IPv6 connections (deliberate IPv4-only enforcement)
*Note:* Placeholder; below features are removed in restructuring and will be added in the future release.
- Per-IP connection limits - Per-IP connection limits
- Global connection caps - Global connection caps
- Automatic reconnection with exponential backoff - Automatic reconnection with exponential backoff
@@ -136,7 +140,6 @@ Each component maintains internal buffers to handle burst traffic:
- Panic recovery in pipeline processing - Panic recovery in pipeline processing
- Independent pipeline operation - Independent pipeline operation
- Automatic source restart on failure
- Sink failure isolation - Sink failure isolation
### Data Integrity ### Data Integrity
+12 -56
View File
@@ -15,30 +15,8 @@ logwisp [options]
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `tls` | Generate TLS certificates | | `--version` | Display version information |
| `version` | Display version information | | `--help` | Show help information |
| `help` | Show help information |
### tls Command
Generate TLS certificates.
```bash
logwisp tls [options]
```
**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `-ca` | Generate CA certificate | - |
| `-server` | Generate server certificate | - |
| `-client` | Generate client certificate | - |
| `-host` | Comma-separated hosts/IPs | localhost |
| `-o` | Output file prefix | Required |
| `-ca-cert` | CA certificate file | Required for server/client |
| `-ca-key` | CA key file | Required for server/client |
| `-days` | Certificate validity days | 365 |
### version Command ### version Command
@@ -63,10 +41,9 @@ Output includes:
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `-c, --config` | Configuration file path | `./logwisp.toml` | | `-c, --config` | Configuration file path | `./logwisp.toml` |
| `-b, --background` | Run as daemon | false |
| `-q, --quiet` | Suppress console output | false | | `-q, --quiet` | Suppress console output | false |
| `--disable-status-reporter` | Disable status logging | false | | `--status-reporter` | Status logging | true |
| `--config-auto-reload` | Enable config hot reload | false | | `--auto-reload` | Enable config hot reload | false |
### Logging Options ### Logging Options
@@ -91,9 +68,9 @@ Configure pipelines via CLI (N = array index, 0-based).
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `--pipelines.N.name` | Pipeline name | | `--pipelines.N.name` | Pipeline name |
| `--pipelines.N.sources.N.type` | Source type | | `--pipelines.N.plugin_sources.N.type` | Source type |
| `--pipelines.N.filters.N.type` | Filter type | | `--pipelines.N.flow.filters.N.type` | Filter type |
| `--pipelines.N.sinks.N.type` | Sink type | | `--pipelines.N.plugin_sinks.N.type` | Sink type |
## Flag Formats ## Flag Formats
@@ -102,7 +79,7 @@ Configure pipelines via CLI (N = array index, 0-based).
```bash ```bash
logwisp --quiet logwisp --quiet
logwisp --quiet=true logwisp --quiet=true
logwisp --quiet=false logwisp --pipelines.0.plugin_sources.0.type=console
``` ```
### String Flags ### String Flags
@@ -117,13 +94,13 @@ logwisp -c config.toml
```bash ```bash
logwisp --logging.level=debug logwisp --logging.level=debug
logwisp --pipelines.0.name=myapp logwisp --pipelines.0.name=myapp
logwisp --pipelines.0.sources.0.type=stdin logwisp --pipelines.0.sources.0.type=console
``` ```
### Array Values (JSON) ### Array Values (JSON)
```bash ```bash
logwisp --pipelines.0.filters.0.patterns='["ERROR","WARN"]' logwisp --pipelines.0.flow.filters.0.patterns='["ERROR","WARN"]'
``` ```
## Environment Variables ## Environment Variables
@@ -171,7 +148,7 @@ export LOGWISP_PIPELINES_0_NAME=myapp
logwisp --logging.output=stderr --logging.level=debug logwisp --logging.output=stderr --logging.level=debug
# Quick test with stdin # Quick test with stdin
logwisp --pipelines.0.sources.0.type=stdin --pipelines.0.sinks.0.type=console logwisp --pipelines.0.plugin_sources.0.type=console --pipelines.0.plugin_sinks.0.type=console
``` ```
### Production Deployment ### Production Deployment
@@ -194,19 +171,6 @@ logwisp --config test.toml --logging.level=debug --disable-status-reporter
logwisp --config test.toml --quiet logwisp --config test.toml --quiet
``` ```
### Quick Commands
```bash
# Generate admin password
logwisp auth -u admin -b
# Create self-signed certs
logwisp tls -server -host localhost -o server
# Check version
logwisp version
```
## Help System ## Help System
### General Help ### General Help
@@ -217,14 +181,6 @@ logwisp -h
logwisp help logwisp help
``` ```
### Command Help
```bash
logwisp auth --help
logwisp tls --help
logwisp help auth
```
## Special Flags ## Special Flags
### Internal Flags ### Internal Flags
@@ -235,6 +191,6 @@ These flags are for internal use:
### Hidden Behaviors ### Hidden Behaviors
- SIGHUP ignored by default (nohup behavior) - SIGHUP ignored ignored during startup (after startup triggers config reload)
- Automatic panic recovery in pipelines - Automatic panic recovery in pipelines
- Resource cleanup on shutdown - Resource cleanup on shutdown
+28 -23
View File
@@ -24,10 +24,9 @@ Top-level configuration options:
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---------|------|---------|-------------| |---------|------|---------|-------------|
| `background` | bool | false | Run as daemon process |
| `quiet` | bool | false | Suppress console output | | `quiet` | bool | false | Suppress console output |
| `disable_status_reporter` | bool | false | Disable periodic status logging | | `status_reporter` | bool | true | Periodic status logging |
| `config_auto_reload` | bool | false | Enable file watch for auto-reload | | `auto_reload` | bool | false | Enable file watch for auto-reload |
## Logging Configuration ## Logging Configuration
@@ -47,7 +46,6 @@ retention_hours = 168.0
[logging.console] [logging.console]
target = "stdout" # stdout|stderr|split target = "stdout" # stdout|stderr|split
format = "txt" # txt|json
``` ```
### Output Modes ### Output Modes
@@ -68,30 +66,34 @@ Each `[[pipelines]]` section defines an independent processing pipeline:
name = "pipeline-name" name = "pipeline-name"
# Rate limiting (optional) # Rate limiting (optional)
[pipelines.rate_limit] [pipelines.flow.rate_limit]
rate = 1000.0 rate = 1000.0
burst = 2000.0 burst = 2000.0
policy = "drop" # pass|drop policy = "drop" # pass|drop
max_entry_size_bytes = 0 # 0=unlimited max_entry_size_bytes = 0 # 0=unlimited
# Format configuration (optional) # Format configuration (optional)
[pipelines.format] [pipelines.flow.format]
type = "json" # raw|json|txt type = "json" # raw|json|txt
sanitizer_policy = "json"
# Sources (required, 1+) [[pipelines.plugin_sources]]
[[pipelines.sources]] id = "my_source"
type = "directory" type = "file"
[pipelines.plugin_sources.config]
# ... source-specific config # ... source-specific config
# Filters (optional) # Filters (optional)
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
logic = "or" logic = "or"
patterns = ["ERROR", "WARN"] patterns = ["ERROR", "WARN"]
# Sinks (required, 1+) # Sinks (required, 1+)
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "my_sink"
type = "http" type = "http"
[pipelines.plugin_sinks.config]
# ... sink-specific config # ... sink-specific config
``` ```
@@ -113,7 +115,7 @@ All configuration options support environment variable overrides:
| `quiet` | `LOGWISP_QUIET` | | `quiet` | `LOGWISP_QUIET` |
| `logging.level` | `LOGWISP_LOGGING_LEVEL` | | `logging.level` | `LOGWISP_LOGGING_LEVEL` |
| `pipelines[0].name` | `LOGWISP_PIPELINES_0_NAME` | | `pipelines[0].name` | `LOGWISP_PIPELINES_0_NAME` |
| `pipelines[0].sources[0].type` | `LOGWISP_PIPELINES_0_SOURCES_0_TYPE` | | `pipelines[0].plugin_sources[0].type` | `LOGWISP_PIPELINES_0_PLUGIN_SOURCES_0_TYPE` |
## Command-Line Overrides ## Command-Line Overrides
@@ -123,13 +125,15 @@ All configuration options can be overridden via CLI flags:
logwisp --quiet \ logwisp --quiet \
--logging.level=debug \ --logging.level=debug \
--pipelines.0.name=myapp \ --pipelines.0.name=myapp \
--pipelines.0.sources.0.type=stdin --pipelines.0.plugin_sources.0.type=console
``` ```
## Configuration Validation ## Configuration Validation
LogWisp validates configuration at startup: LogWisp validates configuration at startup:
- Required fields presence - Rpipelines non-empty, name non-empty, ≥1 source, ≥1 sink, logging enum values.equired fields presence
Partial check in plugin constructor:
- Type correctness - Type correctness
- Port conflicts - Port conflicts
- Path accessibility - Path accessibility
@@ -141,12 +145,12 @@ LogWisp validates configuration at startup:
Enable configuration hot reload: Enable configuration hot reload:
```toml ```toml
config_auto_reload = true auto_reload = true
``` ```
Or via command line: Or via command line:
```bash ```bash
logwisp --config-auto-reload logwisp --auto-reload
``` ```
Reload triggers: Reload triggers:
@@ -161,7 +165,6 @@ Reloadable items:
Non-reloadable (requires restart): Non-reloadable (requires restart):
- Logging configuration - Logging configuration
- Background mode
- Global settings - Global settings
## Default Configuration ## Default Configuration
@@ -172,15 +175,17 @@ Minimal working configuration:
[[pipelines]] [[pipelines]]
name = "default" name = "default"
[[pipelines.sources]] [[pipelines.plugin_sources]]
type = "directory" id = "default_source"
[pipelines.sources.directory] type = "file"
path = "./" [pipelines.plugin_sources.config]
directory = "./"
pattern = "*.log" pattern = "*.log"
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "default_sink"
type = "console" type = "console"
[pipelines.sinks.console] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout"
``` ```
+10 -10
View File
@@ -9,7 +9,7 @@ LogWisp filters control which log entries pass through the pipeline using patter
Only entries matching patterns pass through. Only entries matching patterns pass through.
```toml ```toml
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
logic = "or" # or|and logic = "or" # or|and
patterns = [ patterns = [
@@ -24,7 +24,7 @@ patterns = [
Entries matching patterns are dropped. Entries matching patterns are dropped.
```toml ```toml
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = [ patterns = [
"DEBUG", "DEBUG",
@@ -89,12 +89,12 @@ Multiple filters execute sequentially:
```toml ```toml
# First filter: Include errors and warnings # First filter: Include errors and warnings
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["ERROR", "WARN"] patterns = ["ERROR", "WARN"]
# Second filter: Exclude test environments # Second filter: Exclude test environments
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = ["test-env", "staging"] patterns = ["test-env", "staging"]
``` ```
@@ -137,14 +137,14 @@ patterns = ["ERROR", "WARN", "FATAL", "CRITICAL"]
### Application Filtering ### Application Filtering
```toml ```toml
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["app1", "app2", "app3"] patterns = ["app1", "app2", "app3"]
``` ```
### Noise Reduction ### Noise Reduction
```toml ```toml
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = [ patterns = [
"health-check", "health-check",
@@ -156,7 +156,7 @@ patterns = [
### Security Filtering ### Security Filtering
```toml ```toml
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = [ patterns = [
"password", "password",
@@ -169,17 +169,17 @@ patterns = [
### Multi-stage Filtering ### Multi-stage Filtering
```toml ```toml
# Include production logs # Include production logs
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["prod-", "production"] patterns = ["prod-", "production"]
# Include only errors # Include only errors
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["ERROR", "EXCEPTION", "FATAL"] patterns = ["ERROR", "EXCEPTION", "FATAL"]
# Exclude known issues # Exclude known issues
[[pipelines.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = ["ECONNRESET", "broken pipe"] patterns = ["ECONNRESET", "broken pipe"]
``` ```
+16 -51
View File
@@ -9,11 +9,10 @@ LogWisp formatters transform log entries before output to sinks.
Outputs the log message as-is with optional newline. Outputs the log message as-is with optional newline.
```toml ```toml
[pipelines.format] [pipelines.flow.format]
type = "raw" type = "raw"
sanitizer_policy = "raw"
[pipelines.format.raw] flags = 1
add_new_line = true
``` ```
**Configuration Options:** **Configuration Options:**
@@ -21,6 +20,9 @@ add_new_line = true
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `add_new_line` | bool | true | Append newline to messages | | `add_new_line` | bool | true | Append newline to messages |
| `type` | string | "raw" | raw, json, or txt |
| `flags` | int64 | 0 | log/formatter flags override |
| `sanitizer_policy` | string | | Sanitizer policy (e.g. "json", "raw", "txt", "shell") |
### JSON Formatter ### JSON Formatter
@@ -30,24 +32,11 @@ Produces structured JSON output.
[pipelines.format] [pipelines.format]
type = "json" type = "json"
[pipelines.format.json] +[pipelines.flow.format]
pretty = false type = "json"
timestamp_field = "timestamp" sanitizer_policy = "json"
level_field = "level"
message_field = "message"
source_field = "source"
``` ```
**Configuration Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `pretty` | bool | false | Pretty print JSON |
| `timestamp_field` | string | "timestamp" | Field name for timestamp |
| `level_field` | string | "level" | Field name for log level |
| `message_field` | string | "message" | Field name for message |
| `source_field` | string | "source" | Field name for source |
**Output Structure:** **Output Structure:**
```json ```json
{ {
@@ -63,11 +52,9 @@ source_field = "source"
Template-based text formatting. Template-based text formatting.
```toml ```toml
[pipelines.format] [pipelines.flow.format]
type = "txt" type = "txt"
sanitizer_policy = "txt"
[pipelines.format.txt]
template = "[{{.Timestamp | FmtTime}}] [{{.Level | ToUpper}}] {{.Source}} - {{.Message}}"
timestamp_format = "2006-01-02T15:04:05.000Z07:00" timestamp_format = "2006-01-02T15:04:05.000Z07:00"
``` ```
@@ -75,8 +62,7 @@ timestamp_format = "2006-01-02T15:04:05.000Z07:00"
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `template` | string | See below | Go template string | | `timestamp_format` | string | "" | Time format override |
| `timestamp_format` | string | RFC3339 | Go time format string |
**Default Template:** **Default Template:**
``` ```
@@ -134,12 +120,12 @@ Each pipeline can have its own formatter:
```toml ```toml
[[pipelines]] [[pipelines]]
name = "json-pipeline" name = "json-pipeline"
[pipelines.format] [pipelines.flow.format]
type = "json" type = "json"
[[pipelines]] [[pipelines]]
name = "text-pipeline" name = "text-pipeline"
[pipelines.format] [pipelines.flow.format]
type = "txt" type = "txt"
``` ```
@@ -182,34 +168,13 @@ Relative performance (fastest to slowest):
### Structured Logging ### Structured Logging
```toml ```toml
[pipelines.format] [pipelines.flow.format]
type = "json" type = "json"
[pipelines.format.json]
pretty = false
``` ```
### Human-Readable Logs ### Human-Readable Logs
```toml ```toml
[pipelines.format] [pipelines.flow.format]
type = "txt" type = "txt"
[pipelines.format.txt]
template = "{{.Timestamp | FmtTime}} [{{.Level}}] {{.Message}}"
timestamp_format = "15:04:05" timestamp_format = "15:04:05"
``` ```
### Syslog Format
```toml
[pipelines.format]
type = "txt"
[pipelines.format.txt]
template = "{{.Timestamp | FmtTime}} {{.Source}} {{.Level}}: {{.Message}}"
timestamp_format = "Jan 2 15:04:05"
```
### Minimal Output
```toml
[pipelines.format]
type = "txt"
[pipelines.format.txt]
template = "{{.Message}}"
```
+14 -208
View File
@@ -1,173 +1,40 @@
# Networking # Networking
Network configuration for LogWisp connections, including TLS, rate limiting, and access control. *Note: Under redesign*
## TLS Configuration ## TLS Configuration
### TLS Support Matrix *Note: As of the latest architecture updates, network TLS, mTLS, and Rate Limiting features are undergoing a redesign and are currently acting as placeholders. The documentation below details the structure for future updates.*
| Component | TLS Support | Notes |
|-----------|-------------|-------|
| HTTP Source | ✓ | Full TLS 1.2/1.3 |
| HTTP Sink | ✓ | Full TLS 1.2/1.3 |
| HTTP Client | ✓ | Client certificates |
| TCP Source | ✗ | No encryption |
| TCP Sink | ✗ | No encryption |
| TCP Client | ✗ | No encryption |
### Server TLS Configuration
```toml
[pipelines.sources.http.tls]
enabled = true
cert_file = "/path/to/server.pem"
key_file = "/path/to/server.key"
min_version = "TLS1.2" # TLS1.2|TLS1.3
client_auth = false
client_ca_file = "/path/to/client-ca.pem"
verify_client_cert = true
```
### Client TLS Configuration
```toml
[pipelines.sinks.http_client.tls]
enabled = true
server_ca_file = "/path/to/ca.pem" # For server verification
server_name = "logs.example.com"
insecure_skip_verify = false
client_cert_file = "/path/to/client.pem" # For mTLS
client_key_file = "/path/to/client.key" # For mTLS
```
### TLS Certificate Generation
Using the `tls` command:
```bash
# Generate CA certificate
logwisp tls -ca -o myca
# Generate server certificate
logwisp tls -server -ca-cert myca.pem -ca-key myca.key -host localhost,server.example.com -o server
# Generate client certificate
logwisp tls -client -ca-cert myca.pem -ca-key myca.key -o client
```
Command options:
| Flag | Description |
|------|-------------|
| `-ca` | Generate CA certificate |
| `-server` | Generate server certificate |
| `-client` | Generate client certificate |
| `-host` | Comma-separated hostnames/IPs |
| `-o` | Output file prefix |
| `-days` | Certificate validity (default: 365) |
## Network Rate Limiting
### Configuration Options
```toml
[pipelines.sources.http.net_limit]
enabled = true
max_connections_per_ip = 10
max_connections_total = 100
requests_per_second = 100.0
burst_size = 200
response_code = 429
response_message = "Rate limit exceeded"
ip_whitelist = ["192.168.1.0/24"]
ip_blacklist = ["10.0.0.0/8"]
```
### Rate Limiting Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `enabled` | bool | Enable rate limiting |
| `max_connections_per_ip` | int | Per-IP connection limit |
| `max_connections_total` | int | Global connection limit |
| `requests_per_second` | float | Request rate limit |
| `burst_size` | int | Token bucket burst capacity |
| `response_code` | int | HTTP response code when limited |
| `response_message` | string | Response message when limited |
### IP Access Control
**Whitelist**: Only specified IPs/networks allowed
```toml
ip_whitelist = [
"192.168.1.0/24", # Local network
"10.0.0.0/8", # Private network
"203.0.113.5" # Specific IP
]
```
**Blacklist**: Specified IPs/networks denied
```toml
ip_blacklist = [
"192.168.1.100", # Blocked host
"10.0.0.0/16" # Blocked subnet
]
```
Processing order:
1. Blacklist (immediate deny if matched)
2. Whitelist (must match if configured)
3. Rate limiting
4. Authentication
## Connection Management ## Connection Management
### TCP Keep-Alive ### TCP Keep-Alive
```toml ```toml
[pipelines.sources.tcp] [[pipelines.plugin_sinks]]
id = "tcp_out"
type = "tcp"
[pipelines.plugin_sinks.config]
keep_alive = true keep_alive = true
keep_alive_period_ms = 30000 # 30 seconds keep_alive_period_ms = 30000 # 30 seconds
``` ```
Benefits:
- Detect dead connections
- Prevent connection timeout
- Maintain NAT mappings
### Connection Timeouts ### Connection Timeouts
```toml ```toml
[pipelines.sources.http] [[pipelines.plugin_sinks]]
read_timeout_ms = 10000 # 10 seconds id = "http_out"
type = "http"
[pipelines.plugin_sinks.config]
write_timeout_ms = 10000 # 10 seconds write_timeout_ms = 10000 # 10 seconds
[pipelines.sinks.tcp_client]
dial_timeout = 10 # Connection timeout
write_timeout = 30 # Write timeout
read_timeout = 10 # Read timeout
```
### Connection Limits
Global limits:
```toml
max_connections = 100 # Total concurrent connections
```
Per-IP limits:
```toml
max_connections_per_ip = 10
``` ```
## Heartbeat Configuration ## Heartbeat Configuration
Keep connections alive with periodic heartbeats: Keep connections alive with periodic heartbeats. Note that Heartbeat is a flow-level feature in the new architecture.
### HTTP Sink Heartbeat
```toml ```toml
[pipelines.sinks.http.heartbeat] [pipelines.flow.heartbeat]
enabled = true enabled = true
interval_ms = 30000 interval_ms = 30000
include_timestamp = true include_timestamp = true
@@ -175,37 +42,18 @@ include_stats = false
format = "comment" # comment|event|json format = "comment" # comment|event|json
``` ```
Formats:
- **comment**: SSE comment (`: heartbeat`)
- **event**: SSE event with data
- **json**: JSON-formatted heartbeat
### TCP Sink Heartbeat
```toml
[pipelines.sinks.tcp.heartbeat]
enabled = true
interval_ms = 30000
include_timestamp = true
include_stats = false
format = "json" # json|txt
```
## Network Protocols ## Network Protocols
### HTTP/HTTPS ### HTTP/HTTPS
- HTTP/1.1 and HTTP/2 support - HTTP/1.1 support
- Persistent connections - Persistent connections
- Chunked transfer encoding
- Server-Sent Events (SSE) - Server-Sent Events (SSE)
### TCP ### TCP
- Raw TCP sockets - Raw TCP sockets
- Newline-delimited protocol - Newline-delimited protocol
- Binary-safe transmission
- No encryption available
## Port Configuration ## Port Configuration
@@ -213,9 +61,7 @@ format = "json" # json|txt
| Service | Default Port | Protocol | | Service | Default Port | Protocol |
|---------|--------------|----------| |---------|--------------|----------|
| HTTP Source | 8081 | HTTP/HTTPS | | HTTP Sink | 8080 | HTTP |
| HTTP Sink | 8080 | HTTP/HTTPS |
| TCP Source | 9091 | TCP |
| TCP Sink | 9090 | TCP | | TCP Sink | 9090 | TCP |
### Port Conflict Prevention ### Port Conflict Prevention
@@ -223,46 +69,6 @@ format = "json" # json|txt
LogWisp validates port usage at startup: LogWisp validates port usage at startup:
- Detects port conflicts across pipelines - Detects port conflicts across pipelines
- Prevents duplicate bindings - Prevents duplicate bindings
- Suggests alternative ports
## Network Security
### Best Practices
1. **Use TLS for HTTP** connections when possible
2. **Implement rate limiting** to prevent DoS
3. **Configure IP whitelists** for restricted access
4. **Enable authentication** for all network endpoints
5. **Use non-standard ports** to reduce scanning exposure
6. **Monitor connection metrics** for anomalies
7. **Set appropriate timeouts** to prevent resource exhaustion
### Security Warnings
- TCP connections are **always unencrypted**
- HTTP Basic/Token auth **requires TLS**
- Avoid `skip_verify` in production
- Never expose unauthenticated endpoints publicly
## Load Balancing
### Client-Side Load Balancing
Configure multiple endpoints (future feature):
```toml
[[pipelines.sinks.http_client]]
urls = [
"https://log1.example.com/ingest",
"https://log2.example.com/ingest"
]
strategy = "round-robin" # round-robin|random|least-conn
```
### Server-Side Considerations
- Use reverse proxy for load distribution
- Configure session affinity if needed
- Monitor individual instance health
## Troubleshooting ## Troubleshooting
+8 -24
View File
@@ -2,6 +2,8 @@
Running, monitoring, and maintaining LogWisp in production. Running, monitoring, and maintaining LogWisp in production.
*Note: TLS, acccess control under redesign*
## Starting LogWisp ## Starting LogWisp
### Manual Start ### Manual Start
@@ -60,7 +62,7 @@ kill -USR1 $(pidof logwisp)
Test configuration without starting: Test configuration without starting:
```bash ```bash
logwisp --config test.toml --quiet --disable-status-reporter logwisp --config test.toml --quiet --status-reporter=false
``` ```
Check for errors: Check for errors:
@@ -168,18 +170,10 @@ Production recommendation: `info` or `warn`
Adjust buffers based on load: Adjust buffers based on load:
```toml ```toml
# High-volume source [[pipelines.plugin_sources]]
[[pipelines.sources]] id = "file_in"
type = "http" type = "file"
[pipelines.sources.http] [pipelines.plugin_sources.config]
buffer_size = 5000 # Increase for burst traffic
# Slow consumer sink
[[pipelines.sinks]]
type = "http_client"
[pipelines.sinks.http_client]
buffer_size = 10000 # Larger buffer for slow endpoints
batch_size = 500 # Larger batches
``` ```
### Rate Limiting ### Rate Limiting
@@ -187,22 +181,12 @@ batch_size = 500 # Larger batches
Protect against overload: Protect against overload:
```toml ```toml
[pipelines.rate_limit] [pipelines.flow.rate_limit]
rate = 1000.0 # Entries per second rate = 1000.0 # Entries per second
burst = 2000.0 # Burst capacity burst = 2000.0 # Burst capacity
policy = "drop" # Drop excess entries policy = "drop" # Drop excess entries
``` ```
### Connection Limits
Prevent resource exhaustion:
```toml
[pipelines.sources.http.net_limit]
max_connections_total = 1000
max_connections_per_ip = 50
```
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
+1 -55
View File
@@ -1,58 +1,4 @@
# Security # Security
## mTLS (Mutual TLS) *Note: Security features like mTLS and IP-Based Access Control are currently under redesign and act as placeholders in the new architecture. Future versions will reintroduce full security capabilities.*
Certificate-based authentication for HTTPS.
### Server Configuration
```toml
[pipelines.sources.http.tls]
enabled = true
cert_file = "/path/to/server.pem"
key_file = "/path/to/server.key"
client_auth = true
client_ca_file = "/path/to/ca.pem"
verify_client_cert = true
```
### Client Configuration
```toml
[pipelines.sinks.http_client.tls]
enabled = true
cert_file = "/path/to/client.pem"
key_file = "/path/to/client.key"
```
### Certificate Generation
Use the `tls` command:
```bash
# Generate CA
logwisp tls -ca -o ca
# Generate server certificate
logwisp tls -server -ca-cert ca.pem -ca-key ca.key -host localhost -o server
# Generate client certificate
logwisp tls -client -ca-cert ca.pem -ca-key ca.key -o client
```
## Access Control
ogWisp provides IP-based access control for network connections.
+## IP-Based Access Control
Configure IP-based access control for sources:
```toml
[pipelines.sources.http.net_limit]
enabled = true
ip_whitelist = ["192.168.1.0/24", "10.0.0.0/8"]
ip_blacklist = ["192.168.1.100"]
```
Priority order:
1. Blacklist (checked first, immediate deny)
2. Whitelist (if configured, must match)
+21 -150
View File
@@ -9,13 +9,12 @@ LogWisp sinks deliver processed log entries to various destinations.
Output to stdout/stderr. Output to stdout/stderr.
```toml ```toml
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "console_out"
type = "console" type = "console"
[pipelines.plugin_sinks.config]
[pipelines.sinks.console]
target = "stdout" # stdout|stderr|split target = "stdout" # stdout|stderr|split
colorize = false buffer_size = 1000
buffer_size = 100
``` ```
**Configuration Options:** **Configuration Options:**
@@ -23,8 +22,7 @@ buffer_size = 100
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `target` | string | "stdout" | Output target (stdout/stderr/split) | | `target` | string | "stdout" | Output target (stdout/stderr/split) |
| `colorize` | bool | false | Enable colored output | | `buffer_size` | int | 1000 | Internal buffer size |
| `buffer_size` | int | 100 | Internal buffer size |
**Target Modes:** **Target Modes:**
- **stdout**: All output to standard output - **stdout**: All output to standard output
@@ -36,10 +34,10 @@ buffer_size = 100
Write logs to rotating files. Write logs to rotating files.
```toml ```toml
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "file_out"
type = "file" type = "file"
[pipelines.plugin_sinks.config]
[pipelines.sinks.file]
directory = "./logs" directory = "./logs"
name = "output" name = "output"
max_size_mb = 100 max_size_mb = 100
@@ -74,17 +72,15 @@ flush_interval_ms = 1000
SSE (Server-Sent Events) streaming server. SSE (Server-Sent Events) streaming server.
```toml ```toml
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "http_out"
type = "http" type = "http"
[pipelines.plugin_sinks.config]
[pipelines.sinks.http]
host = "0.0.0.0" host = "0.0.0.0"
port = 8080 port = 8080
stream_path = "/stream" stream_path = "/stream"
status_path = "/status" status_path = "/status"
buffer_size = 1000 buffer_size = 1000
max_connections = 100
read_timeout_ms = 10000
write_timeout_ms = 10000 write_timeout_ms = 10000
``` ```
@@ -97,34 +93,20 @@ write_timeout_ms = 10000
| `stream_path` | string | "/stream" | SSE stream endpoint | | `stream_path` | string | "/stream" | SSE stream endpoint |
| `status_path` | string | "/status" | Status endpoint | | `status_path` | string | "/status" | Status endpoint |
| `buffer_size` | int | 1000 | Internal buffer size | | `buffer_size` | int | 1000 | Internal buffer size |
| `max_connections` | int | 100 | Maximum concurrent clients |
| `read_timeout_ms` | int | 10000 | Read timeout |
| `write_timeout_ms` | int | 10000 | Write timeout | | `write_timeout_ms` | int | 10000 | Write timeout |
**Heartbeat Configuration:**
```toml
[pipelines.sinks.http.heartbeat]
enabled = true
interval_ms = 30000
include_timestamp = true
include_stats = false
format = "comment" # comment|event|json
```
### TCP Sink ### TCP Sink
TCP streaming server for debugging. TCP streaming server for debugging.
```toml ```toml
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
id = "tcp_out"
type = "tcp" type = "tcp"
[pipelines.plugin_sinks.config]
[pipelines.sinks.tcp]
host = "0.0.0.0" host = "0.0.0.0"
port = 9090 port = 9090
buffer_size = 1000 buffer_size = 1000
max_connections = 100
keep_alive = true keep_alive = true
keep_alive_period_ms = 30000 keep_alive_period_ms = 30000
``` ```
@@ -136,132 +118,21 @@ keep_alive_period_ms = 30000
| `host` | string | "0.0.0.0" | Bind address | | `host` | string | "0.0.0.0" | Bind address |
| `port` | int | Required | Listen port | | `port` | int | Required | Listen port |
| `buffer_size` | int | 1000 | Internal buffer size | | `buffer_size` | int | 1000 | Internal buffer size |
| `max_connections` | int | 100 | Maximum concurrent clients |
| `keep_alive` | bool | true | Enable TCP keep-alive | | `keep_alive` | bool | true | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | 30000 | Keep-alive interval | | `keep_alive_period_ms` | int | 30000 | Keep-alive interval |
| `write_timeout_ms` | int | 10000 | Write timeout |
**Note:** TCP Sink has no authentication support (debugging only). ### Null Sink
### HTTP Client Sink
Forward logs to remote HTTP endpoints.
```toml ```toml
[[pipelines.sinks]] [[pipelines.plugin_sinks]]
type = "http_client" id = "null_out"
type = "null"
[pipelines.sinks.http_client]
url = "https://logs.example.com/ingest"
buffer_size = 1000
batch_size = 100
batch_delay_ms = 1000
timeout_seconds = 30
max_retries = 3
retry_delay_ms = 1000
retry_backoff = 2.0
insecure_skip_verify = false
``` ```
**Configuration Options:** ## Buffer Management
| Option | Type | Default | Description | - Full input buffer: entry dropped for that sink only (counted per pipeline as `total_dropped_by_sink`)"
|--------|------|---------|-------------|
| `url` | string | Required | Target URL |
| `buffer_size` | int | 1000 | Internal buffer size |
| `batch_size` | int | 100 | Logs per request |
| `batch_delay_ms` | int | 1000 | Max wait before sending |
| `timeout_seconds` | int | 30 | Request timeout |
| `max_retries` | int | 3 | Retry attempts |
| `retry_delay_ms` | int | 1000 | Initial retry delay |
| `retry_backoff` | float | 2.0 | Exponential backoff multiplier |
| `insecure_skip_verify` | bool | false | Skip TLS verification |
### TCP Client Sink
Forward logs to remote TCP servers.
```toml
[[pipelines.sinks]]
type = "tcp_client"
[pipelines.sinks.tcp_client]
host = "logs.example.com"
port = 9090
buffer_size = 1000
dial_timeout = 10
write_timeout = 30
read_timeout = 10
keep_alive = 30
reconnect_delay_ms = 1000
max_reconnect_delay_ms = 30000
reconnect_backoff = 1.5
```
**Configuration Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `host` | string | Required | Target host |
| `port` | int | Required | Target port |
| `buffer_size` | int | 1000 | Internal buffer size |
| `dial_timeout` | int | 10 | Connection timeout (seconds) |
| `write_timeout` | int | 30 | Write timeout (seconds) |
| `read_timeout` | int | 10 | Read timeout (seconds) |
| `keep_alive` | int | 30 | TCP keep-alive (seconds) |
| `reconnect_delay_ms` | int | 1000 | Initial reconnect delay |
| `max_reconnect_delay_ms` | int | 30000 | Maximum reconnect delay |
| `reconnect_backoff` | float | 1.5 | Backoff multiplier |
## Network Sink Features
### Network Rate Limiting
Available for HTTP and TCP sinks:
```toml
[pipelines.sinks.http.net_limit]
enabled = true
max_connections_per_ip = 10
max_connections_total = 100
ip_whitelist = ["192.168.1.0/24"]
ip_blacklist = ["10.0.0.0/8"]
```
### TLS Configuration (HTTP Only)
```toml
[pipelines.sinks.http.tls]
enabled = true
cert_file = "/path/to/cert.pem"
key_file = "/path/to/key.pem"
ca_file = "/path/to/ca.pem"
min_version = "TLS1.2"
client_auth = false
```
HTTP Client TLS:
```toml
[pipelines.sinks.http_client.tls]
enabled = true
server_ca_file = "/path/to/ca.pem" # For server verification
server_name = "logs.example.com"
insecure_skip_verify = false
client_cert_file = "/path/to/client.pem" # For mTLS
client_key_file = "/path/to/client.key" # For mTLS
```
## Sink Chaining
Designed connection patterns:
### Log Aggregation
- **HTTP Client Sink → HTTP Source**: HTTP/HTTPS (optional mTLS for HTTPS)
- **TCP Client Sink → TCP Source**: Raw TCP
### Live Monitoring
- **HTTP Sink**: Browser-based SSE streaming
- **TCP Sink**: Debug interface (telnet/netcat)
## Sink Statistics ## Sink Statistics
+37 -107
View File
@@ -6,31 +6,29 @@ LogWisp sources monitor various inputs and generate log entries for pipeline pro
### Directory Source ### Directory Source
Monitors a directory for log files matching a pattern. Monitors a directory for log files matching a pattern. (type: `file`)
```toml ```toml
[[pipelines.sources]] [[pipelines.plugin_sources]]
type = "directory" id = "file_in"
type = "file"
[pipelines.sources.directory] [pipelines.plugin_sources.config]
path = "/var/log/myapp" directory = "/var/log/myapp"
pattern = "*.log" # Glob pattern pattern = "*.log" # Glob pattern
check_interval_ms = 100 # Poll interval check_interval_ms = 100 # Poll interval
recursive = false # Scan subdirectories
``` ```
**Configuration Options:** **Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `path` | string | Required | Directory to monitor | | `directory` | string | Required | Directory to monitor |
| `pattern` | string | "*" | File pattern (glob) | | `pattern` | string | "*" | File pattern (glob) |
| `check_interval_ms` | int | 100 | File check interval in milliseconds | | `check_interval_ms` | int | 100 | File check interval in milliseconds |
| `recursive` | bool | false | Include subdirectories |
**Features:** **Features:**
- Automatic file rotation detection - Automatic rotation detection (inode + size tracking)
- Position tracking (resume after restart) - In-memory position tracking; on restart, monitoring resumes from the current end of each file (offsets are not persisted)
- Concurrent file monitoring - Concurrent file monitoring
- Pattern-based file selection - Pattern-based file selection
@@ -39,10 +37,10 @@ recursive = false # Scan subdirectories
Reads log entries from standard input. Reads log entries from standard input.
```toml ```toml
[[pipelines.sources]] [[pipelines.plugin_sources]]
id = "console_in"
type = "console" type = "console"
[pipelines.plugin_sources.config]
[pipelines.sources.stdin]
buffer_size = 1000 buffer_size = 1000
``` ```
@@ -57,106 +55,29 @@ buffer_size = 1000
- Automatic level detection - Automatic level detection
- Non-blocking reads - Non-blocking reads
### HTTP Source ### Random Source
REST endpoint for log ingestion.
```toml ```toml
[[pipelines.sources]] [[pipelines.plugin_sources]]
type = "http" id = "random_in"
type = "random"
[pipelines.sources.http] [pipelines.plugin_sources.config]
host = "0.0.0.0" interval_ms = 500
port = 8081 jitter_ms = 0
ingest_path = "/ingest" format = "txt"
buffer_size = 1000 length = 20
max_body_size = 1048576 # 1MB special = false
read_timeout_ms = 10000
write_timeout_ms = 10000
``` ```
**Configuration Options:** **Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `host` | string | "0.0.0.0" | Bind address | | `interval_ms` | int | 500 | Generation interval |
| `port` | int | Required | Listen port | | `jitter_ms` | int | 0 | Random jitter interval |
| `ingest_path` | string | "/ingest" | Ingestion endpoint path | | `format` | string | "txt" | "txt", "json", "raw" |
| `buffer_size` | int | 1000 | Internal buffer size | | `length` | int | 20 | Log length |
| `max_body_size` | int | 1048576 | Maximum request body size | | `special` | bool | false | Include special characters |
| `read_timeout_ms` | int | 10000 | Read timeout |
| `write_timeout_ms` | int | 10000 | Write timeout |
**Input Formats:**
- Single JSON object
- JSON array
- Newline-delimited JSON (NDJSON)
- Plain text (one entry per line)
### TCP Source
Raw TCP socket listener for log ingestion.
```toml
[[pipelines.sources]]
type = "tcp"
[pipelines.sources.tcp]
host = "0.0.0.0"
port = 9091
buffer_size = 1000
read_timeout_ms = 10000
keep_alive = true
keep_alive_period_ms = 30000
```
**Configuration Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `host` | string | "0.0.0.0" | Bind address |
| `port` | int | Required | Listen port |
| `buffer_size` | int | 1000 | Internal buffer size |
| `read_timeout_ms` | int | 10000 | Read timeout |
| `keep_alive` | bool | true | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | 30000 | Keep-alive interval |
**Protocol:**
- Newline-delimited JSON
- One log entry per line
- UTF-8 encoding
## Network Source Features
### Network Rate Limiting
Available for HTTP and TCP sources:
```toml
[pipelines.sources.http.net_limit]
enabled = true
max_connections_per_ip = 10
max_connections_total = 100
requests_per_second = 100.0
burst_size = 200
response_code = 429
response_message = "Rate limit exceeded"
ip_whitelist = ["192.168.1.0/24"]
ip_blacklist = ["10.0.0.0/8"]
```
### TLS Configuration (HTTP Only)
```toml
[pipelines.sources.http.tls]
enabled = true
cert_file = "/path/to/cert.pem"
key_file = "/path/to/key.pem"
min_version = "TLS1.2"
client_auth = true
client_ca_file = "/path/to/client-ca.pem"
verify_client_cert = true
```
## Source Statistics ## Source Statistics
@@ -168,6 +89,15 @@ All sources track:
- Active connections (network sources) - Active connections (network sources)
- Source-specific metrics - Source-specific metrics
### Null Source
```toml
[[pipelines.plugin_sources]]
id = "null_in"
type = "null"
[pipelines.plugin_sources.config]
```
## Buffer Management ## Buffer Management
Each source maintains internal buffers: Each source maintains internal buffers:
+12 -12
View File
@@ -1,26 +1,26 @@
module logwisp module logwisp
go 1.25.4 go 1.26.0
require ( require (
github.com/lixenwraith/config v0.1.1-0.20251114180219-f7875023a51b github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98
github.com/lixenwraith/log v0.1.1-0.20251115213227-55d2c92d483f github.com/lixenwraith/log v0.1.1-0.20251117213308-9ae1b6669bf3
github.com/panjf2000/gnet/v2 v2.9.7 github.com/panjf2000/gnet/v2 v2.10.0
github.com/valyala/fasthttp v1.68.0 github.com/valyala/fasthttp v1.72.0
) )
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.19.0 // indirect
github.com/panjf2000/ants/v2 v2.11.4 // indirect github.com/panjf2000/ants/v2 v2.12.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.28.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.47.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+48
View File
@@ -2,24 +2,50 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/lixenwraith/config v0.1.1-0.20251114180219-f7875023a51b h1:TzTV0ArJ+nzVGPN8aiEJ2MknUqJdmHRP/0/RSfov2Qw= github.com/lixenwraith/config v0.1.1-0.20251114180219-f7875023a51b h1:TzTV0ArJ+nzVGPN8aiEJ2MknUqJdmHRP/0/RSfov2Qw=
github.com/lixenwraith/config v0.1.1-0.20251114180219-f7875023a51b/go.mod h1:roNPTSCT5HSV9dru/zi/Catwc3FZVCFf7vob2pSlNW0= github.com/lixenwraith/config v0.1.1-0.20251114180219-f7875023a51b/go.mod h1:roNPTSCT5HSV9dru/zi/Catwc3FZVCFf7vob2pSlNW0=
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98 h1:VEFo2WsYgM5YYfzAfvXRKigCWr3zEz6h4M59N5TXMpk=
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98/go.mod h1:J9ydxY7he4Dz+S59xKs/kDQ2YAv+zOLF05b3Rr2/ogE=
github.com/lixenwraith/log v0.1.1-0.20251115213227-55d2c92d483f h1:X2LX5FQEuWYGBS3qp5z7XxBB1sWAlqumf/oW7n/f9c0= github.com/lixenwraith/log v0.1.1-0.20251115213227-55d2c92d483f h1:X2LX5FQEuWYGBS3qp5z7XxBB1sWAlqumf/oW7n/f9c0=
github.com/lixenwraith/log v0.1.1-0.20251115213227-55d2c92d483f/go.mod h1:XcRPRuijAs+43Djk8VmioUJhcK8irRzUjCZaZqkd3gg= github.com/lixenwraith/log v0.1.1-0.20251115213227-55d2c92d483f/go.mod h1:XcRPRuijAs+43Djk8VmioUJhcK8irRzUjCZaZqkd3gg=
github.com/lixenwraith/log v0.1.1-0.20251117213308-9ae1b6669bf3 h1:brSUhER7EZ28aMRFTSovZskiIoobUiAWlx+DYXYvWXQ=
github.com/lixenwraith/log v0.1.1-0.20251117213308-9ae1b6669bf3/go.mod h1:MY59N65ltw/9uTqJKwCRKjxO6w3CApXsLXCVuaekbu0=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/panjf2000/ants/v2 v2.11.4 h1:UJQbtN1jIcI5CYNocTj0fuAUYvsLjPoYi0YuhqV/Y48= github.com/panjf2000/ants/v2 v2.11.4 h1:UJQbtN1jIcI5CYNocTj0fuAUYvsLjPoYi0YuhqV/Y48=
github.com/panjf2000/ants/v2 v2.11.4/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/panjf2000/ants/v2 v2.11.4/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/panjf2000/ants/v2 v2.11.5 h1:a7LMnMEeux/ebqTux140tRiaqcFTV0q2bEHF03nl6Rg=
github.com/panjf2000/ants/v2 v2.11.5/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZnu10=
github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY=
github.com/panjf2000/gnet/v2 v2.9.7 h1:6zW7Jl3oAfXwSuh1PxHLndoL2MQRWx0AJR6aaQjxUgA= github.com/panjf2000/gnet/v2 v2.9.7 h1:6zW7Jl3oAfXwSuh1PxHLndoL2MQRWx0AJR6aaQjxUgA=
github.com/panjf2000/gnet/v2 v2.9.7/go.mod h1:WQTxDWYuQ/hz3eccH0FN32IVuvZ19HewEWx0l62fx7E= github.com/panjf2000/gnet/v2 v2.9.7/go.mod h1:WQTxDWYuQ/hz3eccH0FN32IVuvZ19HewEWx0l62fx7E=
github.com/panjf2000/gnet/v2 v2.9.8 h1:OzVnIKm4UMoGphWgypcVZMq8roVjXzXOZvLdyZHxvkE=
github.com/panjf2000/gnet/v2 v2.9.8/go.mod h1:f9wdbOFsdbZqlSvXctWbPRW5bB/W++q8Zqz+D7tQIVQ=
github.com/panjf2000/gnet/v2 v2.10.0 h1:rC4jNF+jtXj/FH+8JOIQ3XxjD+yBunYBLKg9TE3dc4g=
github.com/panjf2000/gnet/v2 v2.10.0/go.mod h1:f9wdbOFsdbZqlSvXctWbPRW5bB/W++q8Zqz+D7tQIVQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -28,6 +54,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -38,14 +70,30 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+13
View File
@@ -6,20 +6,25 @@ import (
_ "logwisp/src/internal/source/console" _ "logwisp/src/internal/source/console"
_ "logwisp/src/internal/source/file" _ "logwisp/src/internal/source/file"
_ "logwisp/src/internal/source/httpchain"
_ "logwisp/src/internal/source/null" _ "logwisp/src/internal/source/null"
_ "logwisp/src/internal/source/random" _ "logwisp/src/internal/source/random"
_ "logwisp/src/internal/source/tcpchain"
_ "logwisp/src/internal/sink/console" _ "logwisp/src/internal/sink/console"
_ "logwisp/src/internal/sink/file" _ "logwisp/src/internal/sink/file"
_ "logwisp/src/internal/sink/http" _ "logwisp/src/internal/sink/http"
_ "logwisp/src/internal/sink/httpchain"
_ "logwisp/src/internal/sink/null" _ "logwisp/src/internal/sink/null"
_ "logwisp/src/internal/sink/tcp" _ "logwisp/src/internal/sink/tcp"
_ "logwisp/src/internal/sink/tcpchain"
"logwisp/src/internal/config" "logwisp/src/internal/config"
"logwisp/src/internal/service" "logwisp/src/internal/service"
"logwisp/src/internal/version" "logwisp/src/internal/version"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
"github.com/lixenwraith/log/sanitizer"
) )
// bootstrapInitial handles initial service startup with status reporter // bootstrapInitial handles initial service startup with status reporter
@@ -132,6 +137,14 @@ func initializeLogger(cfg *config.Config) error {
} }
logCfg.Level = levelValue logCfg.Level = levelValue
// Configure log format
if cfg.Logging.Format != "" {
logCfg.Format = cfg.Logging.Format
}
if cfg.Logging.Sanitization != "" {
logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization)
}
// Configure based on output mode // Configure based on output mode
switch cfg.Logging.Output { switch cfg.Logging.Output {
case "none": case "none":
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"logwisp/src/internal/version"
"os"
)
// helpText is the CLI usage reference. Flags map 1:1 to TOML config paths.
const helpText = `LogWisp %s - log collection, processing, and distribution
Usage:
logwisp [options]
logwisp help | -h | --help
logwisp --version
Any configuration key is settable as a flag using its TOML path:
--<path>=<value> e.g. --logging.level=debug
Common options:
-c, --config <path> Configuration file (default: ./logwisp.toml)
--quiet Suppress console output
--status_reporter=<bool> Periodic status logging (default: true)
--auto_reload=<bool> Config hot reload on file change (default: false)
Logging:
--logging.output=<mode> file|stdout|stderr|split|all|none
--logging.level=<level> debug|info|warn|error
--logging.file.directory=<path>
--logging.console.target=<target> stdout|stderr|split
Pipelines (N = 0-based index):
--pipelines.N.name=<name>
--pipelines.N.plugin_sources.N.type=<type> file|console|random|null
--pipelines.N.plugin_sinks.N.type=<type> console|file|http|tcp|null
--pipelines.N.flow.filters.N.patterns='["ERROR","WARN"]'
Environment:
LOGWISP_<PATH> Config path, '.' -> '_', uppercase
e.g. LOGWISP_LOGGING_LEVEL=debug
LOGWISP_CONFIG_FILE Configuration file path
LOGWISP_CONFIG_DIR Configuration directory
Signals:
SIGINT, SIGTERM Graceful shutdown
SIGHUP, SIGUSR1 Reload configuration
Exit codes:
0 success
1 general error
2 configuration file not found
`
// handleHelp prints usage and exits if a help request is present in args
func handleHelp(args []string) {
if len(args) > 0 && args[0] == "help" {
printHelp()
}
for _, arg := range args {
if arg == "--" {
break // end of flags
}
if arg == "-h" || arg == "--help" {
printHelp()
}
}
}
// printHelp writes usage to stdout and exits with success
func printHelp() {
fmt.Printf(helpText, version.Short())
os.Exit(0)
}
+4 -3
View File
@@ -7,7 +7,6 @@ import (
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"logwisp/src/internal/config" "logwisp/src/internal/config"
"logwisp/src/internal/core" "logwisp/src/internal/core"
@@ -25,6 +24,10 @@ func main() {
// Emulates nohup // Emulates nohup
signal.Ignore(syscall.SIGHUP) signal.Ignore(syscall.SIGHUP)
// Help handled before config parsing; loader has no help flag.
// Also the future dispatch point for subcommands (tls, etc.)
handleHelp(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 {
@@ -64,8 +67,6 @@ func main() {
"status_reporter", cfg.StatusReporter, "status_reporter", cfg.StatusReporter,
"auto_reload", cfg.ConfigAutoReload) "auto_reload", cfg.ConfigAutoReload)
time.Sleep(time.Second)
// Create context for shutdown // Create context for shutdown
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
+98
View File
@@ -0,0 +1,98 @@
package chain
import (
"encoding/json"
"fmt"
"logwisp/src/internal/core"
"math/rand/v2"
"time"
)
// ProtocolVersion is declared in the hello preamble
const ProtocolVersion = 1
// Hello is the first NDJSON line sent by the dialing side after connect.
// Reserved for future revisions: auth credential, feature flags (ack, compression).
type Hello struct {
LogWisp int `json:"logwisp"`
Node string `json:"node,omitempty"`
// Auth string `json:"auth,omitempty"`
// Features []string `json:"features,omitempty"`
}
// HTTP transport mapping of the chain protocol.
// Hello preamble equivalent: protocol + node carried as request headers.
// Reserved extension point: Authorization header for auth, TLS at transport.
const (
HeaderProtocol = "X-Logwisp-Protocol"
HeaderNode = "X-Logwisp-Node"
HeaderAccepted = "X-Logwisp-Accepted"
ContentTypeNDJSON = "application/x-ndjson"
)
// EncodeHello serializes a newline-terminated hello preamble
func EncodeHello(node string) ([]byte, error) {
b, err := json.Marshal(Hello{LogWisp: ProtocolVersion, Node: node})
if err != nil {
return nil, err
}
return append(b, '\n'), nil
}
// DecodeHello parses and validates a hello preamble line
func DecodeHello(line []byte) (Hello, error) {
var h Hello
if err := json.Unmarshal(line, &h); err != nil {
return h, fmt.Errorf("malformed hello: %w", err)
}
if h.LogWisp != ProtocolVersion {
return h, fmt.Errorf("unsupported protocol version: %d", h.LogWisp)
}
return h, nil
}
// DecodeEntry parses a canonical LogEntry line and applies the node trust policy
func DecodeEntry(line []byte, connNode string, trustNode bool) (core.LogEntry, error) {
var entry core.LogEntry
if err := json.Unmarshal(line, &entry); err != nil {
return core.LogEntry{}, err
}
if entry.Time.IsZero() {
entry.Time = time.Now()
}
if entry.Node == "" || !trustNode {
entry.Node = connNode
}
entry.RawSize = int64(len(line))
return entry, nil
}
// EntryFromEvent extracts the structured entry, stamping node identity at
// first hop. Second return is true when synthesized from a formatted payload.
func EntryFromEvent(event core.TransportEvent, node, fallbackSource string) (core.LogEntry, bool) {
entry := event.Entry
synthesized := false
if entry.Time.IsZero() {
synthesized = true
entry = core.LogEntry{
Time: event.Time,
Source: fallbackSource,
Message: string(event.Payload),
}
}
if entry.Node == "" {
entry.Node = node
}
return entry, synthesized
}
// BackoffDelay computes exponential backoff with ±20% jitter
func BackoffDelay(minD, maxD time.Duration, failures int) time.Duration {
d := maxD
if failures < 63 {
if v := minD << uint(failures-1); v > 0 && v < maxD {
d = v
}
}
return d - d/5 + time.Duration(rand.Int64N(int64(2*d/5)+1))
}
+62 -3
View File
@@ -205,6 +205,32 @@ type ConsoleSourceOptions struct {
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
} }
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
// NDJSON entries from upstream logwisp tcp_chain sinks
type TCPChainSourceOptions struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
// Future: TLS/auth options
}
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
// NDJSON batches from upstream logwisp http_chain sinks
type HTTPChainSourceOptions struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"`
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
// Future: TLS/auth options
}
// --- Sink Options --- // --- Sink Options ---
// PluginSinkConfig represents a sink plugin instance configuration // PluginSinkConfig represents a sink plugin instance configuration
@@ -251,16 +277,49 @@ type TCPSinkOptions struct {
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
WriteTimeout int64 `toml:"write_timeout_ms"` WriteTimeout int64 `toml:"write_timeout_ms"`
KeepAlive bool `toml:"keep_alive"`
KeepAlivePeriod int64 `toml:"keep_alive_period_ms"` KeepAlivePeriod int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"`
} }
// HTTPSinkOptions defines settings for an HTTP SSE server sink // HTTPSinkOptions defines settings for an HTTP SSE server sink
type HTTPSinkOptions struct { type HTTPSinkOptions struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
StreamPath string `toml:"stream_path"` StreamPath string `toml:"stream_path"`
StatusPath string `toml:"status_path"` StatusPath string `toml:"status_path"`
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
WriteTimeout int64 `toml:"write_timeout_ms"` WriteTimeout int64 `toml:"write_timeout_ms"`
} }
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
// entries to a downstream logwisp tcp_chain source
type TCPChainSinkOptions struct {
Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"`
Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"`
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"`
// Future: TLS/auth options
}
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
// NDJSON batches to a downstream logwisp http_chain source
type HTTPChainSinkOptions struct {
Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"`
Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"`
BufferSize int64 `toml:"buffer_size"`
MaxBatchCount int64 `toml:"max_batch_count"`
MaxBatchBytes int64 `toml:"max_batch_bytes"`
FlushIntervalMS int64 `toml:"flush_interval_ms"`
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
// Future: TLS/auth options
}
+7
View File
@@ -65,6 +65,12 @@ func Load(args []string) (*Config, error) {
// Store the manager for hot reload // Store the manager for hot reload
configManager = cfg configManager = cfg
// Surface typo'd flags (e.g. --status-reporter vs --status_reporter);
// pre-logger phase, stderr only, suppressed in quiet mode
if unknown := cfg.UnknownCLIKeys(); len(unknown) > 0 && !finalConfig.Quiet {
fmt.Fprintf(os.Stderr, "Warning: unrecognized flags ignored: %v\n", unknown)
}
// Start watcher if auto-reload is enabled // Start watcher if auto-reload is enabled
if finalConfig.ConfigAutoReload { if finalConfig.ConfigAutoReload {
watchOpts := lconfig.WatchOptions{ watchOpts := lconfig.WatchOptions{
@@ -99,6 +105,7 @@ func defaults() *Config {
Logging: &LogConfig{ Logging: &LogConfig{
Output: "stdout", Output: "stdout",
Level: "info", Level: "info",
Format: "txt",
File: &LogFileConfig{ File: &LogFileConfig{
Directory: "./log", Directory: "./log",
Name: "logwisp", Name: "logwisp",
+20
View File
@@ -17,6 +17,15 @@ func ValidateConfig(cfg *Config) error {
return fmt.Errorf("no pipelines configured") return fmt.Errorf("no pipelines configured")
} }
// Reject duplicate pipeline names (service map is keyed by name)
names := make(map[string]struct{}, len(cfg.Pipelines))
for i, p := range cfg.Pipelines {
if _, dup := names[p.Name]; dup {
return fmt.Errorf("pipeline[%d]: duplicate name %q", i, p.Name)
}
names[p.Name] = struct{}{}
}
if err := validateLogConfig(cfg.Logging); err != nil { if err := validateLogConfig(cfg.Logging); err != nil {
return fmt.Errorf("logging: %w", err) return fmt.Errorf("logging: %w", err)
} }
@@ -52,6 +61,17 @@ func validateLogConfig(cfg *LogConfig) error {
return fmt.Errorf("level: %w", err) return fmt.Errorf("level: %w", err)
} }
if cfg.Format != "" {
if err := lconfig.OneOf("raw", "txt", "json")(cfg.Format); err != nil {
return fmt.Errorf("format: %w", err)
}
}
if cfg.Sanitization != "" {
if err := lconfig.OneOf("raw", "json", "txt", "shell")(cfg.Sanitization); err != nil {
return fmt.Errorf("sanitization: %w", err)
}
}
if cfg.Console != nil { if cfg.Console != nil {
validateTarget := lconfig.OneOf("stdout", "stderr", "split") validateTarget := lconfig.OneOf("stdout", "stderr", "split")
if err := validateTarget(cfg.Console.Target); err != nil { if err := validateTarget(cfg.Console.Target); err != nil {
+5 -1
View File
@@ -5,9 +5,10 @@ import (
"time" "time"
) )
// Represents a single log record flowing through the pipeline // LogEntry represents a single log record flowing through the pipeline
type LogEntry struct { type LogEntry struct {
Time time.Time `json:"time"` Time time.Time `json:"time"`
Node string `json:"node,omitempty"` // origin node identity for chained topologies; first hop stamps, relays preserve
Source string `json:"source"` Source string `json:"source"`
Level string `json:"level,omitempty"` Level string `json:"level,omitempty"`
Message string `json:"message"` Message string `json:"message"`
@@ -20,4 +21,7 @@ type TransportEvent struct {
Time time.Time Time time.Time
// Formatted, serialized log payload // Formatted, serialized log payload
Payload []byte Payload []byte
// Structured entry for re-serializing sinks (chain links). Zero Time => absent
Entry LogEntry
} }
+2
View File
@@ -119,6 +119,7 @@ func (f *Flow) Process(entry core.LogEntry) (core.TransportEvent, bool) {
event := core.TransportEvent{ event := core.TransportEvent{
Time: entry.Time, Time: entry.Time,
Payload: formatted, Payload: formatted,
Entry: entry, // Carry structured entry so chain sinks are format-independent
} }
return event, true return event, true
@@ -160,3 +161,4 @@ func (f *Flow) GetStats() map[string]any {
return stats return stats
} }
+4 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -127,7 +128,8 @@ func (hg *HeartbeatGenerator) generateHeartbeat(t time.Time) core.TransportEvent
// SSE comment format - bypass formatter for this special case // SSE comment format - bypass formatter for this special case
if hg.config.IncludeStats { if hg.config.IncludeStats {
beatNum := hg.beatCount.Load() beatNum := hg.beatCount.Load()
payload = []byte(": heartbeat " + t.Format(time.RFC3339) + " [#" + string(beatNum) + "]\n") payload = []byte(": heartbeat " + t.Format(time.RFC3339) +
" [#" + strconv.FormatUint(beatNum, 10) + "]\n")
} else { } else {
payload = []byte(": heartbeat " + t.Format(time.RFC3339) + "\n") payload = []byte(": heartbeat " + t.Format(time.RFC3339) + "\n")
} }
@@ -159,6 +161,7 @@ func (hg *HeartbeatGenerator) generateHeartbeat(t time.Time) core.TransportEvent
return core.TransportEvent{ return core.TransportEvent{
Time: t, Time: t,
Payload: payload, Payload: payload,
Entry: entry, // heartbeats traverse chain links as structured entries
} }
} }
+15 -3
View File
@@ -89,6 +89,8 @@ func NewFormatterAdapter(cfg *config.FormatConfig) (*FormatterAdapter, error) {
func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) { func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) {
// Map logwisp LogEntry to formatter args // Map logwisp LogEntry to formatter args
level := mapLevel(entry.Level) level := mapLevel(entry.Level)
// syslog-style origin prefix for chained entries
src := sourceLabel(entry)
// Build args based on whether we have structured fields // Build args based on whether we have structured fields
var args []any var args []any
@@ -101,18 +103,19 @@ func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) {
args = []any{entry.Message, fields} args = []any{entry.Message, fields}
// Add structured flag to properly format fields as JSON object // Add structured flag to properly format fields as JSON object
effectiveFlags := a.flags | formatter.FlagStructuredJSON effectiveFlags := a.flags | formatter.FlagStructuredJSON
return a.formatter.Format(effectiveFlags, entry.Time, level, entry.Source, args), nil return a.formatter.Format(effectiveFlags, entry.Time, level, src, args), nil
} }
} }
// Simple message without fields // Simple message without fields
args = []any{entry.Message} args = []any{entry.Message}
return a.formatter.Format(a.flags, entry.Time, level, entry.Source, args), nil return a.formatter.Format(a.flags, entry.Time, level, src, args), nil
} }
// FormatWithFlags allows custom flags for specific formatting needs // FormatWithFlags allows custom flags for specific formatting needs
func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) { func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) {
level := mapLevel(entry.Level) level := mapLevel(entry.Level)
src := sourceLabel(entry)
var args []any var args []any
if len(entry.Fields) > 0 { if len(entry.Fields) > 0 {
@@ -127,7 +130,7 @@ func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int6
args = []any{entry.Message} args = []any{entry.Message}
} }
return a.formatter.Format(customFlags, entry.Time, level, entry.Source, args), nil return a.formatter.Format(customFlags, entry.Time, level, src, args), nil
} }
// Name returns formatter type // Name returns formatter type
@@ -150,3 +153,12 @@ func mapLevel(level string) int64 {
return 0 return 0
} }
} }
// sourceLabel prefixes origin node onto source (syslog HOSTNAME + TAG convention)
func sourceLabel(entry core.LogEntry) string {
if entry.Node == "" {
return entry.Source
}
return entry.Node + "/" + entry.Source
}
+87 -97
View File
@@ -41,13 +41,11 @@ type Pipeline struct {
// PipelineStats contains runtime statistics for a pipeline // PipelineStats contains runtime statistics for a pipeline
type PipelineStats struct { type PipelineStats struct {
StartTime time.Time StartTime time.Time
TotalEntriesProcessed atomic.Uint64 TotalEntriesDroppedBySink atomic.Uint64
TotalEntriesDroppedByRateLimit atomic.Uint64 SourceStats []source.SourceStats
TotalEntriesFiltered atomic.Uint64 SinkStats []sink.SinkStats
SourceStats []source.SourceStats FlowStats map[string]any
SinkStats []sink.SinkStats
FlowStats map[string]any
} }
// NewPipeline creates a new pipeline with registry support // NewPipeline creates a new pipeline with registry support
@@ -74,7 +72,6 @@ func NewPipeline(
cancel: pipelineCancel, cancel: pipelineCancel,
} }
// Create flow processor
// Create flow processor // Create flow processor
flowProcessor, err := flow.NewFlow(cfg.Flow, logger) flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
if err != nil { if err != nil {
@@ -177,7 +174,7 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
// initSinkCapabilities checks and injects optional capabilities // initSinkCapabilities checks and injects optional capabilities
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error { func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
// Initiate and activate source capabilities // Initiate and activate sink capabilities
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
// Network capabilities // Network capabilities
@@ -203,48 +200,36 @@ func (p *Pipeline) run() {
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name) defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
var componentWg sync.WaitGroup var componentWg sync.WaitGroup
// Start a goroutine for each source to fan-in data // Start a goroutine for each source to fan-in data
for _, src := range p.Sources { for _, src := range p.Sources {
componentWg.Add(1) componentWg.Add(1)
go func(s source.Source) { go func(s source.Source) {
defer componentWg.Done() defer componentWg.Done()
ch := s.Subscribe() ch := s.Subscribe()
for { // Range allows in-flight data to drain cleanly once Source.Stop() closes the channel
select { for entry := range ch {
case entry, ok := <-ch: if event, passed := p.Flow.Process(entry); passed {
if !ok { // Use non-blocking dispatcher
return p.dispatch(event)
}
// Process and distribute the log entry
if event, passed := p.Flow.Process(entry); passed {
// Fan-out to all sinks
for _, snk := range p.Sinks {
snk.Input() <- event
}
}
case <-p.ctx.Done():
return
} }
} }
}(src) }(src)
} }
var hbWg sync.WaitGroup
// Start heartbeat generator if enabled // Start heartbeat generator if enabled
if heartbeatCh := p.Flow.StartHeartbeat(p.ctx); heartbeatCh != nil { if heartbeatCh := p.Flow.StartHeartbeat(p.ctx); heartbeatCh != nil {
componentWg.Add(1) hbWg.Add(1)
go func() { go func() {
defer componentWg.Done() defer hbWg.Done()
for { for {
select { select {
case event, ok := <-heartbeatCh: case event, ok := <-heartbeatCh:
if !ok { if !ok {
return return
} }
// Fan-out heartbeat to all sinks // Use non-blocking dispatcher
for _, snk := range p.Sinks { p.dispatch(event)
snk.Input() <- event
}
case <-p.ctx.Done(): case <-p.ctx.Done():
return return
} }
@@ -253,6 +238,23 @@ func (p *Pipeline) run() {
} }
componentWg.Wait() componentWg.Wait()
// Terminate internal contexts (heartbeat) once flow is complete
p.cancel()
hbWg.Wait()
}
// dispatch performs a non-blocking send to all sinks.
// A full/stalled sink must never block the run loop or starve sibling sinks.
func (p *Pipeline) dispatch(event core.TransportEvent) {
for _, snk := range p.Sinks {
select {
case snk.Input() <- event:
default:
// Buffer full - drop to avoid deadlocking the pipeline
p.Stats.TotalEntriesDroppedBySink.Add(1)
}
}
} }
// Start starts the pipeline operation and all its components including flow, sources, and sinks // Start starts the pipeline operation and all its components including flow, sources, and sinks
@@ -294,10 +296,7 @@ func (p *Pipeline) Stop() error {
p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name) p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name)
// Signal all components and the run loop to stop // 1. Stop all sources concurrently to halt new data ingress and close their channels
p.cancel()
// Stop all sources concurrently to halt new data ingress
var sourceWg sync.WaitGroup var sourceWg sync.WaitGroup
for _, src := range p.Sources { for _, src := range p.Sources {
sourceWg.Add(1) sourceWg.Add(1)
@@ -308,10 +307,11 @@ func (p *Pipeline) Stop() error {
} }
sourceWg.Wait() sourceWg.Wait()
// Wait for the run loop to finish processing and sending all in-flight data // 2. Wait for the run loop to finish processing and sending all in-flight data
// run() inherently calls p.cancel() when the source channels are empty
p.wg.Wait() p.wg.Wait()
// Stop all sinks concurrently now that no new data will be sent // 3. Stop all sinks concurrently now that no new data will be sent
var sinkWg sync.WaitGroup var sinkWg sync.WaitGroup
for _, s := range p.Sinks { for _, s := range p.Sinks {
sinkWg.Add(1) sinkWg.Add(1)
@@ -361,92 +361,82 @@ func (p *Pipeline) GetStats() map[string]any {
} }
}() }()
// Collect source stats // 1. Live collect source stats
sourceStats := make([]map[string]any, 0, len(p.Sources)) sources := make([]map[string]any, 0, len(p.Sources))
for _, src := range p.Sources { for _, src := range p.Sources {
if src == nil { if src == nil {
continue // Skip nil sources continue
} }
s := src.GetStats()
stats := src.GetStats() sources = append(sources, map[string]any{
sourceStats = append(sourceStats, map[string]any{ "id": s.ID,
"id": stats.ID, "type": s.Type,
"type": stats.Type, "total_entries": s.TotalEntries,
"total_entries": stats.TotalEntries, "dropped_entries": s.DroppedEntries,
"dropped_entries": stats.DroppedEntries, "start_time": s.StartTime,
"start_time": stats.StartTime, "last_entry_time": s.LastEntryTime,
"last_entry_time": stats.LastEntryTime, "details": s.Details,
"details": stats.Details,
}) })
} }
// Collect sink stats // 2. Live collect sink stats
sinkStats := make([]map[string]any, 0, len(p.Sinks)) sinks := make([]map[string]any, 0, len(p.Sinks))
for _, s := range p.Sinks { for _, snk := range p.Sinks {
if s == nil { if snk == nil {
continue // Skip nil sinks continue
} }
s := snk.GetStats()
stats := s.GetStats() sinks = append(sinks, map[string]any{
sinkStats = append(sinkStats, map[string]any{ "id": s.ID,
"id": stats.ID, "type": s.Type,
"type": stats.Type, "total_processed": s.TotalProcessed,
"total_processed": stats.TotalProcessed, "active_connections": s.ActiveConnections,
"active_connections": stats.ActiveConnections, "start_time": s.StartTime,
"start_time": stats.StartTime, "last_processed": s.LastProcessed,
"last_processed": stats.LastProcessed, "details": s.Details,
"details": stats.Details,
}) })
} }
// Get flow stats // 3. Collect flow stats and calculate filtered total
var flowStats map[string]any var flowStats map[string]any
var totalFiltered uint64 var totalFiltered uint64
var totalProcessed uint64
if p.Flow != nil { if p.Flow != nil {
flowStats = p.Flow.GetStats() flowStats = p.Flow.GetStats()
// Extract total_filtered from flow for top-level visibility
// Map the top-level processed counter directly from Flow's source of truth
if tp, ok := flowStats["total_processed"].(uint64); ok {
totalProcessed = tp
}
// Calculate total dropped specifically by the filter chain
if filters, ok := flowStats["filters"].(map[string]any); ok { if filters, ok := flowStats["filters"].(map[string]any); ok {
if totalPassed, ok := filters["total_passed"].(uint64); ok { if totalPassed, ok := filters["total_passed"].(uint64); ok {
if totalProcessed, ok := filters["total_processed"].(uint64); ok { if tProc, ok := filters["total_processed"].(uint64); ok {
totalFiltered = totalProcessed - totalPassed totalFiltered = tProc - totalPassed
} }
} }
} }
} }
// 4. Calculate Uptime
var uptime int var uptime int
if p.running.Load() && !p.Stats.StartTime.IsZero() { if p.running.Load() && !p.Stats.StartTime.IsZero() {
uptime = int(time.Since(p.Stats.StartTime).Seconds()) uptime = int(time.Since(p.Stats.StartTime).Seconds())
} }
return map[string]any{ return map[string]any{
"name": p.Config.Name, "name": p.Config.Name,
"running": p.running.Load(), "running": p.running.Load(),
"uptime_seconds": uptime, "uptime_seconds": uptime,
"total_processed": p.Stats.TotalEntriesProcessed.Load(), "total_processed": totalProcessed,
"total_filtered": totalFiltered, "total_filtered": totalFiltered,
"source_count": len(p.Sources), "total_dropped_by_sink": p.Stats.TotalEntriesDroppedBySink.Load(),
"sources": sourceStats, "source_count": len(p.Sources),
"sink_count": len(p.Sinks), "sources": sources,
"sinks": sinkStats, "sink_count": len(p.Sinks),
"flow": flowStats, "sinks": sinks,
"flow": flowStats,
} }
} }
// TODO: incomplete implementation
// startStatsUpdater runs a periodic stats updater
func (p *Pipeline) startStatsUpdater(ctx context.Context) {
go func() {
ticker := time.NewTicker(core.ServiceStatsUpdateInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Periodic stats updates if needed
}
}
}()
}
-15
View File
@@ -34,16 +34,6 @@ type PluginMetadata struct {
MaxInstances int // 0 = unlimited, 1 = single instance only MaxInstances int // 0 = unlimited, 1 = single instance only
} }
// // global variables holding available source and sink plugins
// var (
// sourceFactories map[string]SourceFactory
// sinkFactories map[string]SinkFactory
// sourceMetadata map[string]*PluginMetadata
// sinkMetadata map[string]*PluginMetadata
// mu sync.RWMutex
// // once sync.Once
// )
// registry encapsulates all plugin factories with lazy initialization // registry encapsulates all plugin factories with lazy initialization
type registry struct { type registry struct {
sourceFactories map[string]SourceFactory sourceFactories map[string]SourceFactory
@@ -71,11 +61,6 @@ func getRegistry() *registry {
return globalRegistry return globalRegistry
} }
// func init() {
// sourceFactories = make(map[string]SourceFactory)
// sinkFactories = make(map[string]SinkFactory)
// }
// RegisterSource registers a source factory function // RegisterSource registers a source factory function
func RegisterSource(name string, constructor SourceFactory) error { func RegisterSource(name string, constructor SourceFactory) error {
r := getRegistry() r := getRegistry()
+17 -24
View File
@@ -170,16 +170,16 @@ func (h *HTTPSink) Start(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port) addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port)
errChan := make(chan error, 1) ln, err := net.Listen("tcp4", addr)
if err != nil {
return fmt.Errorf("http sink bind %s: %w", addr, err)
}
go func() { go func() {
h.logger.Info("msg", "HTTP server starting", if err := h.server.Serve(ln); err != nil {
"component", "http_sink", h.logger.Error("msg", "HTTP server terminated",
"instance_id", h.id, "component", "http_sink",
"address", addr) "instance_id", h.id,
"error", err)
err := h.server.ListenAndServe(addr)
if err != nil {
errChan <- err
} }
}() }()
@@ -193,18 +193,12 @@ func (h *HTTPSink) Start(ctx context.Context) error {
} }
}() }()
// Check if server started h.logger.Info("msg", "HTTP server started",
select { "component", "http_sink",
case err := <-errChan: "instance_id", h.id,
return err "host", h.config.Host,
case <-time.After(HttpServerStartTimeout): "port", h.config.Port)
h.logger.Info("msg", "HTTP server started", return nil
"component", "http_sink",
"instance_id", h.id,
"host", h.config.Host,
"port", h.config.Port)
return nil
}
} }
// Stop gracefully shuts down the HTTP server and all client connections // Stop gracefully shuts down the HTTP server and all client connections
@@ -356,6 +350,8 @@ func (h *HTTPSink) requestHandler(ctx *fasthttp.RequestCtx) {
remoteAddr := ctx.RemoteAddr() remoteAddr := ctx.RemoteAddr()
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok { if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
if tcpAddr.IP.To4() == nil { if tcpAddr.IP.To4() == nil {
h.logger.Debug("msg", "IPv6 connection rejected",
"component", "http_sink", "remote_addr", remoteAddr.String())
ctx.SetConnectionClose() ctx.SetConnectionClose()
return return
} }
@@ -414,8 +410,6 @@ func (h *HTTPSink) handleStream(ctx *fasthttp.RequestCtx) {
"client_id", clientID, "client_id", clientID,
"active_clients", connectCount) "active_clients", connectCount)
h.wg.Add(1)
defer func() { defer func() {
disconnectCount := h.activeClients.Add(-1) disconnectCount := h.activeClients.Add(-1)
h.logger.Debug("msg", "HTTP client disconnected", h.logger.Debug("msg", "HTTP client disconnected",
@@ -431,7 +425,6 @@ func (h *HTTPSink) handleStream(ctx *fasthttp.RequestCtx) {
} }
h.proxy.RemoveSession(sess.ID) h.proxy.RemoveSession(sess.ID)
h.wg.Done()
}() }()
// Send connected event with metadata // Send connected event with metadata
+422
View File
@@ -0,0 +1,422 @@
package httpchain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"logwisp/src/internal/chain"
"logwisp/src/internal/config"
"logwisp/src/internal/core"
"logwisp/src/internal/plugin"
"logwisp/src/internal/session"
"logwisp/src/internal/sink"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
func init() {
if err := plugin.RegisterSink("http_chain", NewHTTPChainSinkPlugin); err != nil {
panic(fmt.Sprintf("failed to register http_chain sink: %v", err))
}
}
const (
DefaultHTTPChainSinkBufferSize = 1000
DefaultHTTPChainSinkIngestPath = "/ingest"
DefaultHTTPChainSinkMaxBatchCount = 100
DefaultHTTPChainSinkMaxBatchBytes = 1024 * 1024
DefaultHTTPChainSinkFlushIntervalMS = 1000
DefaultHTTPChainSinkRequestTimeoutMS = 10000
DefaultHTTPChainSinkBackoffMinMS = 500
DefaultHTTPChainSinkBackoffMaxMS = 30000
)
// HTTPChainSink batches structured entries and posts NDJSON to a downstream
// http_chain source. Delivery is at-least-once per batch.
type HTTPChainSink struct {
id string
proxy *session.Proxy
session *session.Session
config *config.HTTPChainSinkOptions
node string
url string
client *http.Client
input chan core.TransportEvent
logger *log.Logger
// Batch state owned exclusively by run loop goroutine
batch bytes.Buffer
batchCount int64
reqTimeout time.Duration
done chan struct{}
wg sync.WaitGroup
startTime time.Time
totalProcessed atomic.Uint64
batchesSent atomic.Uint64
requestErrors atomic.Uint64
droppedBatches atomic.Uint64
synthesized atomic.Uint64
lastProcessed atomic.Value // time.Time
}
// NewHTTPChainSinkPlugin creates an http_chain sink through plugin factory
func NewHTTPChainSinkPlugin(
id string,
configMap map[string]any,
logger *log.Logger,
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.HTTPChainSinkOptions{}
if err := lconfig.ScanMap(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.NonEmpty(opts.Host); err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
if opts.IngestPath == "" {
opts.IngestPath = DefaultHTTPChainSinkIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSinkBufferSize
}
if opts.MaxBatchCount <= 0 {
opts.MaxBatchCount = DefaultHTTPChainSinkMaxBatchCount
}
if opts.MaxBatchBytes <= 0 {
opts.MaxBatchBytes = DefaultHTTPChainSinkMaxBatchBytes
}
if opts.FlushIntervalMS <= 0 {
opts.FlushIntervalMS = DefaultHTTPChainSinkFlushIntervalMS
}
if opts.RequestTimeoutMS <= 0 {
opts.RequestTimeoutMS = DefaultHTTPChainSinkRequestTimeoutMS
}
if opts.BackoffMinMS <= 0 {
opts.BackoffMinMS = DefaultHTTPChainSinkBackoffMinMS
}
if opts.BackoffMaxMS < opts.BackoffMinMS {
opts.BackoffMaxMS = DefaultHTTPChainSinkBackoffMaxMS
}
node := opts.Node
if node == "" {
if hn, err := os.Hostname(); err == nil {
node = hn
} else {
node = "unknown"
}
}
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
transport := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
// IPv4-only, aligns with tcp/http sinks
d := net.Dialer{}
return d.DialContext(ctx, "tcp4", address)
},
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
// Future: TLSClientConfig; HTTP/2 via ALPN once TLS lands
}
t := &HTTPChainSink{
id: id,
proxy: proxy,
config: opts,
node: node,
// Future: "https" scheme with TLS
url: "http://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport},
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
reqTimeout: time.Duration(opts.RequestTimeoutMS) * time.Millisecond,
}
t.lastProcessed.Store(time.Time{})
t.session = proxy.CreateSession(
"http_chain://"+addr,
map[string]any{
"instance_id": id,
"type": "http_chain",
"target": t.url,
"node": node,
},
)
logger.Info("msg", "HTTP chain sink initialized",
"component", "http_chain_sink",
"instance_id", id,
"target", t.url,
"node", node)
return t, nil
}
// Capabilities returns supported capabilities
func (t *HTTPChainSink) Capabilities() []core.Capability {
// CapTLS/CapAuth added when transport security lands
return []core.Capability{
core.CapSessionAware,
}
}
// Input returns the channel for sending transport events
func (t *HTTPChainSink) Input() chan<- core.TransportEvent {
return t.input
}
// Start launches the batching loop; downstream availability is not required
func (t *HTTPChainSink) Start(ctx context.Context) error {
t.startTime = time.Now()
t.wg.Add(1)
go t.runLoop(ctx)
t.logger.Info("msg", "HTTP chain sink started",
"component", "http_chain_sink",
"instance_id", t.id,
"target", t.url)
return nil
}
// Stop terminates the loop. Worst case: one in-flight request timeout plus
// one final-flush request timeout.
func (t *HTTPChainSink) Stop() {
t.logger.Info("msg", "Stopping HTTP chain sink",
"component", "http_chain_sink",
"instance_id", t.id)
close(t.done)
t.wg.Wait()
t.client.CloseIdleConnections()
if t.session != nil {
t.proxy.RemoveSession(t.session.ID)
}
t.logger.Info("msg", "HTTP chain sink stopped",
"component", "http_chain_sink",
"instance_id", t.id,
"total_processed", t.totalProcessed.Load())
}
// GetStats returns sink statistics
func (t *HTTPChainSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time)
return sink.SinkStats{
ID: t.id,
Type: "http_chain",
TotalProcessed: t.totalProcessed.Load(),
StartTime: t.startTime,
LastProcessed: lastProc,
Details: map[string]any{
"target": t.url,
"node": t.node,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
},
}
}
// runLoop batches events and flushes on size or interval
func (t *HTTPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done()
// Fold done channel into a context for request/backoff interruption
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-t.done:
cancel()
case <-runCtx.Done():
}
}()
ticker := time.NewTicker(time.Duration(t.config.FlushIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
t.finalFlush()
return
case <-ticker.C:
if t.batchCount > 0 && !t.flush(runCtx) {
t.finalFlush()
return
}
case event, ok := <-t.input:
if !ok {
t.finalFlush()
return
}
t.append(event)
if t.batchCount >= t.config.MaxBatchCount ||
int64(t.batch.Len()) >= t.config.MaxBatchBytes {
if !t.flush(runCtx) {
t.finalFlush()
return
}
}
}
}
}
// append serializes one event into the pending batch
func (t *HTTPChainSink) append(event core.TransportEvent) {
entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
if synthesized {
t.synthesized.Add(1)
}
line, err := json.Marshal(entry)
if err != nil {
// Non-transient: drop entry
t.logger.Error("msg", "Failed to marshal chain entry",
"component", "http_chain_sink",
"error", err)
return
}
t.batch.Write(line)
t.batch.WriteByte('\n')
t.batchCount++
}
// flush delivers the pending batch, retrying transient failures with backoff.
// Returns false when shutdown interrupts delivery; undelivered batch is dropped
// by finalFlush semantics (batch already consumed here).
func (t *HTTPChainSink) flush(ctx context.Context) bool {
body := bytes.Clone(t.batch.Bytes())
count := t.batchCount
t.batch.Reset()
t.batchCount = 0
failures := 0
for {
if failures > 0 && !t.waitBackoff(ctx, failures) {
t.droppedBatches.Add(1)
return false
}
transient, err := t.post(ctx, body)
if err == nil {
t.batchesSent.Add(1)
t.totalProcessed.Add(uint64(count))
t.lastProcessed.Store(time.Now())
t.proxy.UpdateActivity(t.session.ID)
return true
}
t.requestErrors.Add(1)
if !transient {
t.droppedBatches.Add(1)
t.logger.Error("msg", "Chain batch rejected, dropping",
"component", "http_chain_sink",
"target", t.url,
"entries", count,
"error", err)
return true
}
if ctx.Err() != nil {
t.droppedBatches.Add(1)
return false
}
failures++
t.logger.Warn("msg", "Chain batch delivery failed",
"component", "http_chain_sink",
"target", t.url,
"attempt", failures,
"error", err)
}
}
// finalFlush best-effort delivers the pending batch during shutdown (single attempt)
func (t *HTTPChainSink) finalFlush() {
if t.batchCount == 0 {
return
}
fctx, cancel := context.WithTimeout(context.Background(), t.reqTimeout)
defer cancel()
count := t.batchCount
if _, err := t.post(fctx, t.batch.Bytes()); err != nil {
t.droppedBatches.Add(1)
t.logger.Warn("msg", "Final chain batch dropped on shutdown",
"component", "http_chain_sink",
"entries", count,
"error", err)
return
}
t.batchesSent.Add(1)
t.totalProcessed.Add(uint64(count))
}
// post sends one NDJSON batch; transient=true marks retryable failures
func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool, err error) {
reqCtx, cancel := context.WithTimeout(ctx, t.reqTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, t.url, bytes.NewReader(body))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", chain.ContentTypeNDJSON)
req.Header.Set(chain.HeaderProtocol, strconv.Itoa(chain.ProtocolVersion))
req.Header.Set(chain.HeaderNode, t.node)
// Future: Authorization header for auth
resp, err := t.client.Do(req)
if err != nil {
return true, err
}
defer resp.Body.Close()
// Drain for connection reuse
io.Copy(io.Discard, resp.Body)
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return false, nil
case resp.StatusCode == http.StatusRequestTimeout,
resp.StatusCode == http.StatusTooManyRequests,
resp.StatusCode >= 500:
return true, fmt.Errorf("status %s", resp.Status)
default:
return false, fmt.Errorf("status %s", resp.Status)
}
}
// waitBackoff sleeps for the computed delay, interruptible by shutdown
func (t *HTTPChainSink) waitBackoff(ctx context.Context, failures int) bool {
minD := time.Duration(t.config.BackoffMinMS) * time.Millisecond
maxD := time.Duration(t.config.BackoffMaxMS) * time.Millisecond
timer := time.NewTimer(chain.BackoffDelay(minD, maxD, failures))
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
}
+25 -4
View File
@@ -40,6 +40,7 @@ type TCPSink struct {
server *tcpServer server *tcpServer
engine *gnet.Engine engine *gnet.Engine
engineMu sync.Mutex engineMu sync.Mutex
booted chan struct{}
// Application // Application
input chan core.TransportEvent input chan core.TransportEvent
@@ -63,7 +64,7 @@ type TCPSink struct {
const ( const (
// Server lifecycle // Server lifecycle
TCPServerStartTimeout = 100 * time.Millisecond TCPServerStartTimeout = 2 * time.Second
TCPServerShutdownTimeout = 2 * time.Second TCPServerShutdownTimeout = 2 * time.Second
// Connection management // Connection management
@@ -151,6 +152,8 @@ func (t *TCPSink) Start(ctx context.Context) error {
sink: t, sink: t,
clients: make(map[gnet.Conn]*tcpClient), clients: make(map[gnet.Conn]*tcpClient),
} }
// Fresh channel per Start
t.booted = make(chan struct{})
t.startTime = time.Now() t.startTime = time.Now()
@@ -213,12 +216,25 @@ func (t *TCPSink) Start(ctx context.Context) error {
close(t.done) close(t.done)
t.wg.Wait() t.wg.Wait()
return err return err
case <-time.After(TCPServerStartTimeout): // Bind confirmation via OnBoot
case <-t.booted:
t.logger.Info("msg", "TCP server started", t.logger.Info("msg", "TCP server started",
"component", "tcp_sink", "component", "tcp_sink",
"instance_id", t.id, "instance_id", t.id,
"port", t.config.Port) "port", t.config.Port)
return nil return nil
// Timeout failure
case <-time.After(TCPServerStartTimeout):
t.engineMu.Lock()
if t.engine != nil {
stopCtx, cancel := context.WithTimeout(context.Background(), TCPServerShutdownTimeout)
(*t.engine).Stop(stopCtx)
cancel()
}
t.engineMu.Unlock()
close(t.done)
t.wg.Wait()
return fmt.Errorf("tcp sink start timeout on %s", addr)
} }
} }
@@ -309,6 +325,9 @@ func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
s.sink.engine = &eng s.sink.engine = &eng
s.sink.engineMu.Unlock() s.sink.engineMu.Unlock()
// Listener is bound at this point; unblock Start
close(s.sink.booted)
s.sink.logger.Debug("msg", "TCP server booted", s.sink.logger.Debug("msg", "TCP server booted",
"component", "tcp_sink", "component", "tcp_sink",
"instance_id", s.sink.id) "instance_id", s.sink.id)
@@ -409,8 +428,10 @@ func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
s.sink.proxy.UpdateActivity(client.sessionID) s.sink.proxy.UpdateActivity(client.sessionID)
} }
// TCP sink doesn't expect data from clients, discard // TCP sink doesn't expect data from clients, discard safely
c.Discard(-1) if bufLen := c.InboundBuffered(); bufLen > 0 {
c.Next(bufLen)
}
return gnet.None return gnet.None
} }
+398
View File
@@ -0,0 +1,398 @@
package tcpchain
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"net"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"logwisp/src/internal/chain"
"logwisp/src/internal/config"
"logwisp/src/internal/core"
"logwisp/src/internal/plugin"
"logwisp/src/internal/session"
"logwisp/src/internal/sink"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
func init() {
if err := plugin.RegisterSink("tcp_chain", NewTCPChainSinkPlugin); err != nil {
panic(fmt.Sprintf("failed to register tcp_chain sink: %v", err))
}
}
const (
DefaultChainSinkBufferSize = 1000
DefaultChainSinkDialTimeoutMS = 5000
DefaultChainSinkWriteTimeoutMS = 5000
DefaultChainSinkBackoffMinMS = 500
DefaultChainSinkBackoffMaxMS = 30000
DefaultChainSinkKeepAlivePeriodMS = 30000
)
// TCPChainSink forwards structured entries to a downstream tcp_chain source
type TCPChainSink struct {
id string
proxy *session.Proxy
session *session.Session
config *config.TCPChainSinkOptions
node string
addr string
helloLine []byte
input chan core.TransportEvent
logger *log.Logger
// conn owned exclusively by run loop goroutine
conn net.Conn
everConnected bool
dialTimeout time.Duration
writeTimeout time.Duration
done chan struct{}
wg sync.WaitGroup
startTime time.Time
totalProcessed atomic.Uint64
writeErrors atomic.Uint64
reconnects atomic.Uint64
synthesized atomic.Uint64
connected atomic.Bool
lastProcessed atomic.Value // time.Time
}
// NewTCPChainSinkPlugin creates a tcp_chain sink through plugin factory
func NewTCPChainSinkPlugin(
id string,
configMap map[string]any,
logger *log.Logger,
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.TCPChainSinkOptions{
KeepAlive: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.NonEmpty(opts.Host); err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultChainSinkBufferSize
}
if opts.DialTimeoutMS <= 0 {
opts.DialTimeoutMS = DefaultChainSinkDialTimeoutMS
}
if opts.WriteTimeoutMS <= 0 {
opts.WriteTimeoutMS = DefaultChainSinkWriteTimeoutMS
}
if opts.BackoffMinMS <= 0 {
opts.BackoffMinMS = DefaultChainSinkBackoffMinMS
}
if opts.BackoffMaxMS < opts.BackoffMinMS {
opts.BackoffMaxMS = DefaultChainSinkBackoffMaxMS
}
if opts.KeepAlivePeriodMS <= 0 {
opts.KeepAlivePeriodMS = DefaultChainSinkKeepAlivePeriodMS
}
node := opts.Node
if node == "" {
if hn, err := os.Hostname(); err == nil {
node = hn
} else {
node = "unknown"
}
}
helloLine, err := chain.EncodeHello(node)
if err != nil {
return nil, fmt.Errorf("hello: %w", err)
}
t := &TCPChainSink{
id: id,
proxy: proxy,
config: opts,
node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
helloLine: helloLine,
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
dialTimeout: time.Duration(opts.DialTimeoutMS) * time.Millisecond,
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
}
t.lastProcessed.Store(time.Time{})
t.session = proxy.CreateSession(
"tcp_chain://"+t.addr,
map[string]any{
"instance_id": id,
"type": "tcp_chain",
"target": t.addr,
"node": node,
},
)
logger.Info("msg", "TCP chain sink initialized",
"component", "tcp_chain_sink",
"instance_id", id,
"target", t.addr,
"node", node)
return t, nil
}
// Capabilities returns supported capabilities
func (t *TCPChainSink) Capabilities() []core.Capability {
// CapTLS/CapAuth added when transport security lands
return []core.Capability{
core.CapSessionAware,
}
}
// Input returns the channel for sending transport events
func (t *TCPChainSink) Input() chan<- core.TransportEvent {
return t.input
}
// Start launches the forwarding loop; connection is established lazily so
// pipeline start does not depend on downstream availability
func (t *TCPChainSink) Start(ctx context.Context) error {
t.startTime = time.Now()
t.wg.Add(1)
go t.runLoop(ctx)
t.logger.Info("msg", "TCP chain sink started",
"component", "tcp_chain_sink",
"instance_id", t.id,
"target", t.addr)
return nil
}
// Stop terminates the forwarding loop. Worst-case latency: one write timeout
// plus one backoff wait (both interruptible or bounded).
func (t *TCPChainSink) Stop() {
t.logger.Info("msg", "Stopping TCP chain sink",
"component", "tcp_chain_sink",
"instance_id", t.id)
close(t.done)
t.wg.Wait()
if t.session != nil {
t.proxy.RemoveSession(t.session.ID)
}
t.logger.Info("msg", "TCP chain sink stopped",
"component", "tcp_chain_sink",
"instance_id", t.id,
"total_processed", t.totalProcessed.Load())
}
// GetStats returns sink statistics
func (t *TCPChainSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time)
var active int64
if t.connected.Load() {
active = 1
}
return sink.SinkStats{
ID: t.id,
Type: "tcp_chain",
TotalProcessed: t.totalProcessed.Load(),
ActiveConnections: active,
StartTime: t.startTime,
LastProcessed: lastProc,
Details: map[string]any{
"target": t.addr,
"node": t.node,
"connected": t.connected.Load(),
"reconnects": t.reconnects.Load(),
"write_errors": t.writeErrors.Load(),
"synthesized": t.synthesized.Load(),
},
}
}
// runLoop consumes transport events and forwards them downstream
func (t *TCPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done()
defer t.closeConn()
for {
select {
case <-ctx.Done():
return
case <-t.done:
return
case event, ok := <-t.input:
if !ok {
return
}
entry, synthesized := chain.EntryFromEvent(event, t.node, t.id)
if synthesized {
t.synthesized.Add(1)
}
line, err := json.Marshal(entry)
if err != nil {
// Non-transient: drop
t.logger.Error("msg", "Failed to marshal chain entry",
"component", "tcp_chain_sink",
"error", err)
continue
}
if !t.deliver(ctx, append(line, '\n')) {
return // shutdown during retry
}
t.totalProcessed.Add(1)
t.lastProcessed.Store(time.Now())
t.proxy.UpdateActivity(t.session.ID)
}
}
}
// toEntry extracts the structured entry, stamping node identity at first hop
func (t *TCPChainSink) toEntry(event core.TransportEvent) core.LogEntry {
entry := event.Entry
if entry.Time.IsZero() {
// Defensive: event without structured entry, wrap formatted payload
t.synthesized.Add(1)
entry = core.LogEntry{
Time: event.Time,
Source: t.id,
Message: string(event.Payload),
}
}
if entry.Node == "" {
entry.Node = t.node
}
return entry
}
// deliver writes one line, holding it across reconnects until sent or shutdown.
// Backpressure during outage propagates to the pipeline dispatch drop counter.
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
failures := 0
for {
if t.conn == nil {
if failures > 0 && !t.waitBackoff(ctx, failures) {
return false
}
if err := t.connect(ctx); err != nil {
if ctx.Err() != nil {
return false
}
failures++
t.logger.Debug("msg", "Chain connect failed",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", failures,
"error", err)
continue
}
}
t.conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := t.conn.Write(line); err != nil {
t.writeErrors.Add(1)
failures++
t.logger.Warn("msg", "Chain write failed",
"component", "tcp_chain_sink",
"target", t.addr,
"error", err)
t.closeConn()
continue
}
return true
}
}
// connect performs a single dial + hello attempt
func (t *TCPChainSink) connect(ctx context.Context) error {
d := net.Dialer{Timeout: t.dialTimeout}
if t.config.KeepAlive {
d.KeepAliveConfig = net.KeepAliveConfig{
Enable: true,
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
}
}
// IPv4-only
conn, err := d.DialContext(ctx, "tcp4", t.addr)
if err != nil {
return err
}
conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := conn.Write(t.helloLine); err != nil {
conn.Close()
return fmt.Errorf("hello: %w", err)
}
t.conn = conn
t.connected.Store(true)
if t.everConnected {
t.reconnects.Add(1)
}
t.everConnected = true
t.logger.Info("msg", "Chain link established",
"component", "tcp_chain_sink",
"target", t.addr,
"node", t.node)
return nil
}
// closeConn tears down the current connection (run loop goroutine only)
func (t *TCPChainSink) closeConn() {
if t.conn != nil {
t.conn.Close()
t.conn = nil
}
t.connected.Store(false)
}
// waitBackoff sleeps for the computed delay, interruptible by shutdown
func (t *TCPChainSink) waitBackoff(ctx context.Context, failures int) bool {
minD := time.Duration(t.config.BackoffMinMS) * time.Millisecond
maxD := time.Duration(t.config.BackoffMaxMS) * time.Millisecond
timer := time.NewTimer(chain.BackoffDelay(minD, maxD, failures))
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
case <-t.done:
return false
}
}
// backoffDelay computes exponential backoff with ±20% jitter
func (t *TCPChainSink) backoffDelay(failures int) time.Duration {
minD := time.Duration(t.config.BackoffMinMS) * time.Millisecond
maxD := time.Duration(t.config.BackoffMaxMS) * time.Millisecond
d := maxD
if failures < 63 {
if v := minD << uint(failures-1); v > 0 && v < maxD {
d = v
}
}
return d - d/5 + time.Duration(rand.Int64N(int64(2*d/5)+1))
}
+2 -1
View File
@@ -161,7 +161,7 @@ func (fs *FileSource) Stop() {
} }
fs.wg.Wait() fs.wg.Wait()
fs.proxy.RemoveSession(fs.id) fs.proxy.RemoveSession(fs.session.ID)
fs.mu.Lock() fs.mu.Lock()
for _, w := range fs.watchers { for _, w := range fs.watchers {
@@ -361,3 +361,4 @@ func globToRegex(glob string) string {
regex = strings.ReplaceAll(regex, `\?`, `.`) regex = strings.ReplaceAll(regex, `\?`, `.`)
return "^" + regex + "$" return "^" + regex + "$"
} }
+324
View File
@@ -0,0 +1,324 @@
package httpchain
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"logwisp/src/internal/chain"
"logwisp/src/internal/config"
"logwisp/src/internal/core"
"logwisp/src/internal/plugin"
"logwisp/src/internal/session"
"logwisp/src/internal/source"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
func init() {
if err := plugin.RegisterSource("http_chain", NewHTTPChainSourcePlugin); err != nil {
panic(fmt.Sprintf("failed to register http_chain source: %v", err))
}
}
const (
DefaultHTTPChainSourceBufferSize = 1000
DefaultHTTPChainSourceIngestPath = "/ingest"
DefaultHTTPChainSourceMaxBodyBytes = 8 * 1024 * 1024
DefaultHTTPChainSourceReadTimeoutMS = 30000
HTTPChainReadHeaderTimeout = 10 * time.Second
HTTPChainServerShutdownTimeout = 2 * time.Second
)
// HTTPChainSource accepts NDJSON batches from upstream http_chain sinks
type HTTPChainSource struct {
id string
proxy *session.Proxy
config *config.HTTPChainSourceOptions
subscribers []chan core.LogEntry
server *http.Server
logger *log.Logger
// Session cache: one session per remote host + declared node
sessions map[string]string // key -> sessionID
sessionsMu sync.Mutex
mu sync.RWMutex
startTime time.Time
totalEntries atomic.Uint64
droppedEntries atomic.Uint64
parseErrors atomic.Uint64
totalRequests atomic.Uint64
rejectedRequests atomic.Uint64
lastEntryTime atomic.Value // time.Time
}
// NewHTTPChainSourcePlugin creates an http_chain source through plugin factory
func NewHTTPChainSourcePlugin(
id string,
configMap map[string]any,
logger *log.Logger,
proxy *session.Proxy,
) (source.Source, error) {
opts := &config.HTTPChainSourceOptions{
Host: "0.0.0.0",
TrustNode: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
if opts.IngestPath == "" {
opts.IngestPath = DefaultHTTPChainSourceIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSourceBufferSize
}
if opts.MaxBodyBytes <= 0 {
opts.MaxBodyBytes = DefaultHTTPChainSourceMaxBodyBytes
}
if opts.ReadTimeoutMS <= 0 {
opts.ReadTimeoutMS = DefaultHTTPChainSourceReadTimeoutMS
}
s := &HTTPChainSource{
id: id,
proxy: proxy,
config: opts,
subscribers: make([]chan core.LogEntry, 0),
sessions: make(map[string]string),
logger: logger,
}
s.lastEntryTime.Store(time.Time{})
logger.Info("msg", "HTTP chain source initialized",
"component", "http_chain_source",
"instance_id", id,
"host", opts.Host,
"port", opts.Port,
"ingest_path", opts.IngestPath)
return s, nil
}
// Capabilities returns supported capabilities
func (s *HTTPChainSource) Capabilities() []core.Capability {
// CapTLS/CapAuth added when transport security lands
return []core.Capability{
core.CapSessionAware,
core.CapMultiSession,
}
}
// Subscribe returns a channel for receiving log entries
func (s *HTTPChainSource) Subscribe() <-chan core.LogEntry {
s.mu.Lock()
defer s.mu.Unlock()
ch := make(chan core.LogEntry, s.config.BufferSize)
s.subscribers = append(s.subscribers, ch)
return ch
}
// Start binds the listener and serves the ingest endpoint
func (s *HTTPChainSource) Start() error {
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only, aligns with tcp/http sinks
ln, err := net.Listen("tcp4", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
mux := http.NewServeMux()
// Method-scoped pattern: mux answers 405 with Allow header on non-POST
mux.HandleFunc(http.MethodPost+" "+s.config.IngestPath, s.handleIngest)
s.server = &http.Server{
Handler: mux,
ReadTimeout: time.Duration(s.config.ReadTimeoutMS) * time.Millisecond,
ReadHeaderTimeout: HTTPChainReadHeaderTimeout,
// Future: TLSConfig for transport security
}
s.startTime = time.Now()
go func() {
if err := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("msg", "HTTP chain server terminated",
"component", "http_chain_source",
"instance_id", s.id,
"error", err)
}
}()
s.logger.Info("msg", "HTTP chain source started",
"component", "http_chain_source",
"instance_id", s.id,
"addr", addr)
return nil
}
// Stop shuts down the server, sessions, and subscriber channels
func (s *HTTPChainSource) Stop() {
if s.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), HTTPChainServerShutdownTimeout)
defer cancel()
s.server.Shutdown(ctx)
}
s.sessionsMu.Lock()
for _, id := range s.sessions {
s.proxy.RemoveSession(id)
}
s.sessions = make(map[string]string)
s.sessionsMu.Unlock()
s.mu.Lock()
for _, ch := range s.subscribers {
close(ch)
}
s.mu.Unlock()
s.logger.Info("msg", "HTTP chain source stopped",
"component", "http_chain_source",
"instance_id", s.id)
}
// GetStats returns the source's statistics
func (s *HTTPChainSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time)
s.sessionsMu.Lock()
cachedSessions := len(s.sessions)
s.sessionsMu.Unlock()
return source.SourceStats{
ID: s.id,
Type: "http_chain",
TotalEntries: s.totalEntries.Load(),
DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime,
LastEntryTime: lastEntry,
Details: map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"ingest_path": s.config.IngestPath,
"total_requests": s.totalRequests.Load(),
"rejected_requests": s.rejectedRequests.Load(),
"parse_errors": s.parseErrors.Load(),
"cached_sessions": cachedSessions,
"trust_node": s.config.TrustNode,
},
}
}
// handleIngest validates protocol headers and ingests one NDJSON batch.
// Batch acceptance is atomic: entries publish only after a clean full read.
func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
s.totalRequests.Add(1)
if r.Header.Get(chain.HeaderProtocol) != strconv.Itoa(chain.ProtocolVersion) {
s.rejectedRequests.Add(1)
http.Error(w, "unsupported protocol version", http.StatusBadRequest)
return
}
remoteHost := r.RemoteAddr
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteHost = host
}
connNode := r.Header.Get(chain.HeaderNode)
if connNode == "" || !s.config.TrustNode {
connNode = remoteHost
}
body := http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes)
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
entries := make([]core.LogEntry, 0, 128)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
if err != nil {
// Content error within a clean transfer: skip line, keep batch
s.parseErrors.Add(1)
continue
}
entries = append(entries, entry)
}
if err := scanner.Err(); err != nil {
// Transfer error: reject batch without partial ingestion, sender retries
s.rejectedRequests.Add(1)
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
s.logger.Debug("msg", "Chain batch read failed",
"component", "http_chain_source",
"remote_addr", r.RemoteAddr,
"error", err)
http.Error(w, "malformed body", http.StatusBadRequest)
return
}
for _, entry := range entries {
s.publish(entry)
}
s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode))
w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries)))
w.WriteHeader(http.StatusNoContent)
}
// sessionFor returns the cached session for a remote+node, recreating after idle expiry
func (s *HTTPChainSource) sessionFor(remoteHost, node string) string {
key := remoteHost + "|" + node
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
if id, ok := s.sessions[key]; ok {
if _, exists := s.proxy.GetSession(id); exists {
return id
}
}
sess := s.proxy.CreateSession(remoteHost, map[string]any{
"type": "http_chain",
"node": node,
})
s.sessions[key] = sess.ID
return sess.ID
}
// publish sends a log entry to all subscribers
func (s *HTTPChainSource) publish(entry core.LogEntry) {
s.mu.RLock()
defer s.mu.RUnlock()
s.totalEntries.Add(1)
s.lastEntryTime.Store(entry.Time)
for _, ch := range s.subscribers {
select {
case ch <- entry:
default:
s.droppedEntries.Add(1)
}
}
}
+353
View File
@@ -0,0 +1,353 @@
package tcpchain
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
"logwisp/src/internal/chain"
"logwisp/src/internal/config"
"logwisp/src/internal/core"
"logwisp/src/internal/plugin"
"logwisp/src/internal/session"
"logwisp/src/internal/source"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log"
)
func init() {
if err := plugin.RegisterSource("tcp_chain", NewTCPChainSourcePlugin); err != nil {
panic(fmt.Sprintf("failed to register tcp_chain source: %v", err))
}
}
const (
DefaultChainSourceBufferSize = 1000
DefaultChainSourceHelloTimeoutMS = 10000
)
// TCPChainSource accepts connections from upstream tcp_chain sinks and ingests NDJSON entries
type TCPChainSource struct {
id string
proxy *session.Proxy
config *config.TCPChainSourceOptions
subscribers []chan core.LogEntry
listener net.Listener
conns map[net.Conn]struct{}
logger *log.Logger
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
startTime time.Time
totalEntries atomic.Uint64
droppedEntries atomic.Uint64
parseErrors atomic.Uint64
rejectedConns atomic.Uint64
activeConns atomic.Int64
lastEntryTime atomic.Value // time.Time
}
// NewTCPChainSourcePlugin creates a tcp_chain source through plugin factory
func NewTCPChainSourcePlugin(
id string,
configMap map[string]any,
logger *log.Logger,
proxy *session.Proxy,
) (source.Source, error) {
opts := &config.TCPChainSourceOptions{
Host: "0.0.0.0",
TrustNode: true,
}
if err := lconfig.ScanMap(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultChainSourceBufferSize
}
if opts.HelloTimeoutMS <= 0 {
opts.HelloTimeoutMS = DefaultChainSourceHelloTimeoutMS
}
s := &TCPChainSource{
id: id,
proxy: proxy,
config: opts,
subscribers: make([]chan core.LogEntry, 0),
conns: make(map[net.Conn]struct{}),
logger: logger,
}
s.lastEntryTime.Store(time.Time{})
logger.Info("msg", "TCP chain source initialized",
"component", "tcp_chain_source",
"instance_id", id,
"host", opts.Host,
"port", opts.Port)
return s, nil
}
// Capabilities returns supported capabilities
func (s *TCPChainSource) Capabilities() []core.Capability {
// CapTLS/CapAuth added when transport security lands
return []core.Capability{
core.CapSessionAware,
core.CapMultiSession,
}
}
// Subscribe returns a channel for receiving log entries
func (s *TCPChainSource) Subscribe() <-chan core.LogEntry {
s.mu.Lock()
defer s.mu.Unlock()
ch := make(chan core.LogEntry, s.config.BufferSize)
s.subscribers = append(s.subscribers, ch)
return ch
}
// Start binds the listener and begins accepting connections
func (s *TCPChainSource) Start() error {
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only
ln, err := net.Listen("tcp4", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
s.listener = ln
s.ctx, s.cancel = context.WithCancel(context.Background())
s.startTime = time.Now()
s.wg.Add(1)
go s.acceptLoop()
s.logger.Info("msg", "TCP chain source started",
"component", "tcp_chain_source",
"instance_id", s.id,
"addr", addr)
return nil
}
// Stop closes the listener, all connections, and subscriber channels
func (s *TCPChainSource) Stop() {
if s.cancel != nil {
s.cancel()
}
if s.listener != nil {
s.listener.Close()
}
s.mu.Lock()
for conn := range s.conns {
conn.Close() // unblocks per-connection reads
}
s.mu.Unlock()
s.wg.Wait()
s.mu.Lock()
for _, ch := range s.subscribers {
close(ch)
}
s.mu.Unlock()
s.logger.Info("msg", "TCP chain source stopped",
"component", "tcp_chain_source",
"instance_id", s.id)
}
// GetStats returns the source's statistics
func (s *TCPChainSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time)
return source.SourceStats{
ID: s.id,
Type: "tcp_chain",
TotalEntries: s.totalEntries.Load(),
DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime,
LastEntryTime: lastEntry,
Details: map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
},
}
}
// acceptLoop accepts upstream connections until listener close
func (s *TCPChainSource) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || s.ctx.Err() != nil {
return
}
s.logger.Warn("msg", "Accept error",
"component", "tcp_chain_source",
"error", err)
continue
}
if s.config.MaxConnections > 0 && s.activeConns.Load() >= s.config.MaxConnections {
s.rejectedConns.Add(1)
conn.Close()
continue
}
s.mu.Lock()
s.conns[conn] = struct{}{}
s.mu.Unlock()
s.wg.Add(1)
go s.handleConn(conn)
}
}
// handleConn validates the hello preamble, then streams entries until EOF/error
func (s *TCPChainSource) handleConn(conn net.Conn) {
defer s.wg.Done()
remote := conn.RemoteAddr().String()
s.activeConns.Add(1)
var sessID string
defer func() {
conn.Close()
s.mu.Lock()
delete(s.conns, conn)
s.mu.Unlock()
if sessID != "" {
s.proxy.RemoveSession(sessID)
}
s.activeConns.Add(-1)
}()
scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
// Hello preamble
conn.SetReadDeadline(time.Now().Add(time.Duration(s.config.HelloTimeoutMS) * time.Millisecond))
if !scanner.Scan() {
s.logger.Warn("msg", "Connection closed before hello",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", scanner.Err())
return
}
hello, err := chain.DecodeHello(scanner.Bytes())
if err != nil {
s.logger.Warn("msg", "Rejected chain connection",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
return
}
connNode := hello.Node
if connNode == "" || !s.config.TrustNode {
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
connNode = host
} else {
connNode = remote
}
}
sess := s.proxy.CreateSession(remote, map[string]any{
"type": "tcp_chain",
"node": connNode,
})
sessID = sess.ID
s.logger.Info("msg", "Chain connection established",
"component", "tcp_chain_source",
"remote_addr", remote,
"node", connNode)
idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond
for {
if idle > 0 {
conn.SetReadDeadline(time.Now().Add(idle))
} else {
conn.SetReadDeadline(time.Time{})
}
if !scanner.Scan() {
if err := scanner.Err(); err != nil && !errors.Is(err, net.ErrClosed) {
s.logger.Debug("msg", "Chain read terminated",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
}
return
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
s.proxy.UpdateActivity(sessID)
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
if err != nil {
s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry",
"component", "tcp_chain_source",
"error", err)
continue
}
s.publish(entry)
}
}
// parseEntry decodes a canonical LogEntry line and applies the node policy
func (s *TCPChainSource) parseEntry(line []byte, connNode string) (core.LogEntry, bool) {
var entry core.LogEntry
if err := json.Unmarshal(line, &entry); err != nil {
s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry",
"component", "tcp_chain_source",
"error", err)
return core.LogEntry{}, false
}
if entry.Time.IsZero() {
entry.Time = time.Now()
}
if entry.Node == "" || !s.config.TrustNode {
entry.Node = connNode
}
entry.RawSize = int64(len(line))
return entry, true
}
// publish sends a log entry to all subscribers
func (s *TCPChainSource) publish(entry core.LogEntry) {
s.mu.RLock()
defer s.mu.RUnlock()
s.totalEntries.Add(1)
s.lastEntryTime.Store(entry.Time)
for _, ch := range s.subscribers {
select {
case ch <- entry:
default:
s.droppedEntries.Add(1)
}
}
}