v0.1.5 multi-target support, package refactoring
This commit is contained in:
502
README.md
502
README.md
@ -1,18 +1,22 @@
|
|||||||
|
# LogWisp - Multi-Stream Log Monitoring Service
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/logwisp-logo.svg" alt="LogWisp Logo" width="200"/>
|
<img src="assets/logwisp-logo.svg" alt="LogWisp Logo" width="200"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# LogWisp - Dual-Stack Log Streaming
|
A high-performance log streaming service with multi-stream architecture, supporting both TCP and HTTP/SSE protocols with real-time file monitoring and rotation detection.
|
||||||
|
|
||||||
A high-performance log streaming service with dual-stack architecture: raw TCP streaming via gnet and HTTP/SSE streaming via fasthttp.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dual streaming modes**: TCP (gnet) and HTTP/SSE (fasthttp)
|
- **Multi-Stream Architecture**: Run multiple independent log streams, each with its own configuration
|
||||||
- **Fan-out architecture**: Multiple independent consumers
|
- **Dual Protocol Support**: TCP (raw streaming) and HTTP/SSE (browser-friendly)
|
||||||
- **Real-time updates**: File monitoring with rotation detection
|
- **Real-time Monitoring**: Instant updates with configurable check intervals
|
||||||
- **Zero dependencies**: Only gnet and fasthttp beyond stdlib
|
- **File Rotation Detection**: Automatic detection and handling of log rotation
|
||||||
- **High performance**: Non-blocking I/O throughout
|
- **Path-based Routing**: Optional HTTP router for consolidated access
|
||||||
|
- **Per-Stream Configuration**: Independent settings for each log stream
|
||||||
|
- **Connection Statistics**: Real-time monitoring of active connections
|
||||||
|
- **Flexible Targets**: Monitor individual files or entire directories
|
||||||
|
- **Zero Dependencies**: Only gnet and fasthttp beyond stdlib
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@ -20,200 +24,334 @@ A high-performance log streaming service with dual-stack architecture: raw TCP s
|
|||||||
# Build
|
# Build
|
||||||
go build -o logwisp ./src/cmd/logwisp
|
go build -o logwisp ./src/cmd/logwisp
|
||||||
|
|
||||||
# Run with HTTP only (default)
|
# Run with default configuration
|
||||||
./logwisp
|
./logwisp
|
||||||
|
|
||||||
# Enable both TCP and HTTP
|
# Run with custom config
|
||||||
./logwisp --enable-tcp --tcp-port 9090
|
./logwisp --config /etc/logwisp/production.toml
|
||||||
|
|
||||||
# Monitor specific paths
|
# Run with HTTP router (path-based routing)
|
||||||
./logwisp /var/log:*.log /app/logs:error*.log
|
./logwisp --router
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
LogWisp uses a service-oriented architecture where each stream is an independent pipeline:
|
||||||
|
|
||||||
```
|
```
|
||||||
Monitor (Publisher) → [Subscriber Channels] → TCP Server (default port 9090)
|
LogStream Service
|
||||||
↘ HTTP Server (default port 8080)
|
├── Stream["app-logs"]
|
||||||
```
|
│ ├── Monitor (watches files)
|
||||||
|
│ ├── TCP Server (optional)
|
||||||
## Command Line Options
|
│ └── HTTP Server (optional)
|
||||||
|
├── Stream["system-logs"]
|
||||||
```bash
|
│ ├── Monitor
|
||||||
logwisp [OPTIONS] [TARGET...]
|
│ └── HTTP Server
|
||||||
|
└── HTTP Router (optional, for path-based routing)
|
||||||
OPTIONS:
|
|
||||||
--config FILE Config file path
|
|
||||||
--check-interval MS File check interval (default: 100)
|
|
||||||
|
|
||||||
# TCP Server
|
|
||||||
--enable-tcp Enable TCP server
|
|
||||||
--tcp-port PORT TCP port (default: 9090)
|
|
||||||
--tcp-buffer-size SIZE TCP buffer size (default: 1000)
|
|
||||||
|
|
||||||
# HTTP Server
|
|
||||||
--enable-http Enable HTTP server (default: true)
|
|
||||||
--http-port PORT HTTP port (default: 8080)
|
|
||||||
--http-buffer-size SIZE HTTP buffer size (default: 1000)
|
|
||||||
|
|
||||||
TARGET:
|
|
||||||
path[:pattern[:isfile]] Path to monitor
|
|
||||||
pattern: glob pattern for directories
|
|
||||||
isfile: true/false (auto-detected if omitted)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Config file location: `~/.config/logwisp.toml`
|
Configuration file location: `~/.config/logwisp.toml`
|
||||||
|
|
||||||
|
### Basic Multi-Stream Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
# Global defaults
|
||||||
[monitor]
|
[monitor]
|
||||||
check_interval_ms = 100
|
check_interval_ms = 100
|
||||||
|
|
||||||
[[monitor.targets]]
|
# Application logs stream
|
||||||
path = "./"
|
[[streams]]
|
||||||
pattern = "*.log"
|
name = "app"
|
||||||
is_file = false
|
|
||||||
|
|
||||||
[tcpserver]
|
[streams.monitor]
|
||||||
enabled = false
|
targets = [
|
||||||
port = 9090
|
{ path = "/var/log/myapp", pattern = "*.log", is_file = false },
|
||||||
buffer_size = 1000
|
{ path = "/var/log/myapp/app.log", is_file = true }
|
||||||
|
]
|
||||||
|
|
||||||
[httpserver]
|
[streams.httpserver]
|
||||||
enabled = true
|
enabled = true
|
||||||
port = 8080
|
port = 8080
|
||||||
buffer_size = 1000
|
buffer_size = 2000
|
||||||
|
stream_path = "/stream"
|
||||||
|
status_path = "/status"
|
||||||
|
|
||||||
|
# System logs stream
|
||||||
|
[[streams]]
|
||||||
|
name = "system"
|
||||||
|
|
||||||
|
[streams.monitor]
|
||||||
|
check_interval_ms = 50 # Override global default
|
||||||
|
targets = [
|
||||||
|
{ path = "/var/log/syslog", is_file = true },
|
||||||
|
{ path = "/var/log/auth.log", is_file = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
[streams.tcpserver]
|
||||||
|
enabled = true
|
||||||
|
port = 9090
|
||||||
|
buffer_size = 5000
|
||||||
|
|
||||||
|
[streams.httpserver]
|
||||||
|
enabled = true
|
||||||
|
port = 8443
|
||||||
|
stream_path = "/logs"
|
||||||
|
status_path = "/health"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Clients
|
### Target Configuration
|
||||||
|
|
||||||
|
Monitor targets support both files and directories:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Directory monitoring with pattern
|
||||||
|
{ path = "/var/log", pattern = "*.log", is_file = false }
|
||||||
|
|
||||||
|
# Specific file monitoring
|
||||||
|
{ path = "/var/log/app.log", is_file = true }
|
||||||
|
|
||||||
|
# All .log files in a directory
|
||||||
|
{ path = "./logs", pattern = "*.log", is_file = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Modes
|
||||||
|
|
||||||
|
### 1. Standalone Mode (Default)
|
||||||
|
|
||||||
|
Each stream runs on its configured ports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./logwisp
|
||||||
|
# Stream endpoints:
|
||||||
|
# - app: http://localhost:8080/stream
|
||||||
|
# - system: tcp://localhost:9090 and https://localhost:8443/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Router Mode
|
||||||
|
|
||||||
|
All HTTP streams share ports with path-based routing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./logwisp --router
|
||||||
|
# Routed endpoints:
|
||||||
|
# - app: http://localhost:8080/app/stream
|
||||||
|
# - system: http://localhost:8080/system/logs
|
||||||
|
# - global: http://localhost:8080/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Examples
|
||||||
|
|
||||||
|
### HTTP/SSE Stream
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to a stream
|
||||||
|
curl -N http://localhost:8080/stream
|
||||||
|
|
||||||
|
# Check stream status
|
||||||
|
curl http://localhost:8080/status
|
||||||
|
|
||||||
|
# With authentication (when implemented)
|
||||||
|
curl -u admin:password -N https://localhost:8443/logs
|
||||||
|
```
|
||||||
|
|
||||||
### TCP Stream
|
### TCP Stream
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Simple TCP client
|
# Using netcat
|
||||||
nc localhost 9090
|
nc localhost 9090
|
||||||
|
|
||||||
# Using telnet
|
# Using telnet
|
||||||
telnet localhost 9090
|
telnet localhost 9090
|
||||||
|
|
||||||
# Using socat
|
# With TLS (when implemented)
|
||||||
socat - TCP:localhost:9090
|
openssl s_client -connect localhost:9443
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTTP/SSE Stream
|
### JavaScript Client
|
||||||
```bash
|
|
||||||
# Stream logs
|
|
||||||
curl -N http://localhost:8080/stream
|
|
||||||
|
|
||||||
# Check status
|
```javascript
|
||||||
curl http://localhost:8080/status
|
const eventSource = new EventSource('http://localhost:8080/stream');
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
console.log('Connected with ID:', data.client_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('message', (e) => {
|
||||||
|
const logEntry = JSON.parse(e.data);
|
||||||
|
console.log(`[${logEntry.time}] ${logEntry.level}: ${logEntry.message}`);
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
All config values can be set via environment:
|
|
||||||
- `LOGWISP_MONITOR_CHECK_INTERVAL_MS`
|
|
||||||
- `LOGWISP_MONITOR_TARGETS` (format: "path:pattern:isfile,...")
|
|
||||||
- `LOGWISP_TCPSERVER_ENABLED`
|
|
||||||
- `LOGWISP_TCPSERVER_PORT`
|
|
||||||
- `LOGWISP_HTTPSERVER_ENABLED`
|
|
||||||
- `LOGWISP_HTTPSERVER_PORT`
|
|
||||||
|
|
||||||
## Log Entry Format
|
## Log Entry Format
|
||||||
|
|
||||||
|
All log entries are streamed as JSON:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"time": "2024-01-01T12:00:00.123456Z",
|
"time": "2024-01-01T12:00:00.123456Z",
|
||||||
"source": "app.log",
|
"source": "app.log",
|
||||||
"level": "error",
|
"level": "ERROR",
|
||||||
"message": "Something went wrong",
|
"message": "Connection timeout",
|
||||||
"fields": {"key": "value"}
|
"fields": {
|
||||||
|
"user_id": "12345",
|
||||||
|
"request_id": "abc-def-ghi"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### TCP Protocol
|
### Stream Endpoints (per stream)
|
||||||
- Raw JSON lines, one entry per line
|
|
||||||
- No headers or authentication
|
|
||||||
- Instant connection, streaming starts immediately
|
|
||||||
|
|
||||||
### HTTP Endpoints
|
- `GET {stream_path}` - SSE log stream
|
||||||
- `GET /stream` - SSE stream of log entries
|
- `GET {status_path}` - Stream statistics and configuration
|
||||||
- `GET /status` - Service status JSON
|
|
||||||
|
|
||||||
### SSE Events
|
### Global Endpoints (router mode)
|
||||||
- `connected` - Initial connection with client_id
|
|
||||||
- `data` - Log entry JSON
|
|
||||||
- `:` - Heartbeat comment (30s interval)
|
|
||||||
|
|
||||||
## Heartbeat Configuration
|
- `GET /status` - Aggregated status for all streams
|
||||||
|
- `GET /{stream_name}/{path}` - Stream-specific endpoints
|
||||||
|
|
||||||
LogWisp supports configurable heartbeat messages for both HTTP/SSE and TCP streams to detect stale connections and provide server statistics.
|
### Status Response
|
||||||
|
|
||||||
**HTTP/SSE Heartbeat:**
|
|
||||||
- **Format Options:**
|
|
||||||
- `comment`: SSE comment format (`: heartbeat ...`)
|
|
||||||
- `json`: Standard data message with JSON payload
|
|
||||||
- **Content Options:**
|
|
||||||
- `include_timestamp`: Add current UTC timestamp
|
|
||||||
- `include_stats`: Add active clients count and server uptime
|
|
||||||
|
|
||||||
**TCP Heartbeat:**
|
|
||||||
- Always uses JSON format
|
|
||||||
- Same content options as HTTP
|
|
||||||
- Useful for detecting disconnected clients
|
|
||||||
|
|
||||||
**⚠️ SECURITY:** Heartbeat statistics expose minimal server state (connection count, uptime). If this is sensitive in your environment, disable `include_stats`.
|
|
||||||
|
|
||||||
**Example Heartbeat Messages:**
|
|
||||||
|
|
||||||
HTTP Comment format:
|
|
||||||
```
|
|
||||||
: heartbeat 2024-01-01T12:00:00Z clients=5 uptime=3600s
|
|
||||||
```
|
|
||||||
|
|
||||||
JSON format:
|
|
||||||
```json
|
```json
|
||||||
{"type":"heartbeat","timestamp":"2024-01-01T12:00:00Z","active_clients":5,"uptime_seconds":3600}
|
{
|
||||||
|
"service": "LogWisp",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"server": {
|
||||||
|
"type": "http",
|
||||||
|
"port": 8080,
|
||||||
|
"active_clients": 5,
|
||||||
|
"uptime_seconds": 3600
|
||||||
|
},
|
||||||
|
"monitor": {
|
||||||
|
"active_watchers": 3,
|
||||||
|
"total_entries": 15420,
|
||||||
|
"dropped_entries": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration:**
|
## Real-time Statistics
|
||||||
|
|
||||||
|
LogWisp provides comprehensive statistics at multiple levels:
|
||||||
|
|
||||||
|
- **Per-Stream Stats**: Monitor performance, connection counts, data throughput
|
||||||
|
- **Per-Watcher Stats**: File size, position, entries read, rotation count
|
||||||
|
- **Global Stats**: Aggregated view of all streams (in router mode)
|
||||||
|
|
||||||
|
Access statistics via status endpoints or watch the console output:
|
||||||
|
|
||||||
|
```
|
||||||
|
[15:04:05] Active streams: 2
|
||||||
|
app: watchers=3 entries=1542 tcp_conns=2 http_conns=5
|
||||||
|
system: watchers=2 entries=8901 tcp_conns=0 http_conns=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### File Rotation Detection
|
||||||
|
|
||||||
|
LogWisp automatically detects log rotation through multiple methods:
|
||||||
|
- Inode change detection
|
||||||
|
- File size decrease
|
||||||
|
- Modification time anomalies
|
||||||
|
- Position beyond file size
|
||||||
|
|
||||||
|
When rotation is detected, a special log entry is generated:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"level": "INFO",
|
||||||
|
"message": "Log rotation detected (#1): inode change"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Buffer Management
|
||||||
|
|
||||||
|
- **Non-blocking delivery**: Messages are dropped rather than blocking when buffers fill
|
||||||
|
- **Per-client buffers**: Each client has independent buffer space
|
||||||
|
- **Configurable sizes**: Adjust buffer sizes based on expected load
|
||||||
|
|
||||||
|
### Heartbeat Messages
|
||||||
|
|
||||||
|
Keep connections alive and detect stale clients:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[httpserver.heartbeat]
|
[streams.httpserver.heartbeat]
|
||||||
enabled = true
|
enabled = true
|
||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
include_timestamp = true
|
include_timestamp = true
|
||||||
include_stats = true
|
include_stats = true
|
||||||
format = "json"
|
format = "json" # or "comment" for SSE comments
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variables:**
|
## Performance Tuning
|
||||||
- `LOGWISP_HTTPSERVER_HEARTBEAT_ENABLED`
|
|
||||||
- `LOGWISP_HTTPSERVER_HEARTBEAT_INTERVAL_SECONDS`
|
### Monitor Settings
|
||||||
- `LOGWISP_TCPSERVER_HEARTBEAT_ENABLED`
|
- `check_interval_ms`: Lower values = faster detection, higher CPU usage
|
||||||
- `LOGWISP_TCPSERVER_HEARTBEAT_INTERVAL_SECONDS`
|
- `buffer_size`: Larger buffers handle bursts better but use more memory
|
||||||
|
|
||||||
|
### File Watcher Optimization
|
||||||
|
- Use specific file paths when possible (more efficient than directory scanning)
|
||||||
|
- Adjust patterns to minimize unnecessary file checks
|
||||||
|
- Consider separate streams for different update frequencies
|
||||||
|
|
||||||
|
### Network Optimization
|
||||||
|
- TCP: Best for high-volume, low-latency requirements
|
||||||
|
- HTTP/SSE: Best for browser compatibility and firewall traversal
|
||||||
|
- Router mode: Reduces port usage but adds slight routing overhead
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/yourusername/logwisp
|
||||||
|
cd logwisp
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
go mod init logwisp
|
||||||
|
go get github.com/panjf2000/gnet/v2
|
||||||
|
go get github.com/valyala/fasthttp
|
||||||
|
go get github.com/lixenwraith/config
|
||||||
|
|
||||||
|
# Build
|
||||||
|
go build -o logwisp ./src/cmd/logwisp
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Systemd Service
|
### Systemd Service
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LogWisp Log Streaming
|
Description=LogWisp Multi-Stream Log Monitor
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/logwisp --enable-tcp --enable-http
|
ExecStart=/usr/local/bin/logwisp --config /etc/logwisp/production.toml
|
||||||
Restart=always
|
Restart=always
|
||||||
Environment="LOGWISP_TCPSERVER_PORT=9090"
|
User=logwisp
|
||||||
Environment="LOGWISP_HTTPSERVER_PORT=8080"
|
Group=logwisp
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadOnlyPaths=/var/log
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM golang:1.24 AS builder
|
FROM golang:1.24 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -221,56 +359,88 @@ COPY . .
|
|||||||
RUN go build -o logwisp ./src/cmd/logwisp
|
RUN go build -o logwisp ./src/cmd/logwisp
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
RUN useradd -r -s /bin/false logwisp
|
||||||
COPY --from=builder /app/logwisp /usr/local/bin/
|
COPY --from=builder /app/logwisp /usr/local/bin/
|
||||||
|
USER logwisp
|
||||||
EXPOSE 8080 9090
|
EXPOSE 8080 9090
|
||||||
CMD ["logwisp", "--enable-tcp", "--enable-http"]
|
CMD ["logwisp"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Tuning
|
### Docker Compose
|
||||||
|
|
||||||
- **Buffer Size**: Increase for burst traffic (5000+)
|
```yaml
|
||||||
- **Check Interval**: Decrease for lower latency (10-50ms)
|
version: '3.8'
|
||||||
- **TCP**: Best for high-volume system consumers
|
services:
|
||||||
- **HTTP**: Best for web browsers and REST clients
|
logwisp:
|
||||||
|
build: .
|
||||||
### Message Dropping and Client Behavior
|
volumes:
|
||||||
|
- /var/log:/var/log:ro
|
||||||
LogWisp uses non-blocking message delivery to maintain system stability. When a client cannot keep up with the log stream, messages are dropped rather than blocking other clients or the monitor.
|
- ./config.toml:/etc/logwisp/config.toml:ro
|
||||||
|
ports:
|
||||||
**Common causes of dropped messages:**
|
- "8080:8080"
|
||||||
- **Browser throttling**: Browsers may throttle background tabs, reducing JavaScript execution frequency
|
- "9090:9090"
|
||||||
- **Network congestion**: Slow connections or high latency can cause client buffers to fill
|
restart: unless-stopped
|
||||||
- **Client processing**: Heavy client-side processing (parsing, rendering) can create backpressure
|
command: ["logwisp", "--config", "/etc/logwisp/config.toml"]
|
||||||
- **System resources**: CPU/memory constraints on client machines affect consumption rate
|
|
||||||
|
|
||||||
**TCP vs HTTP behavior:**
|
|
||||||
- **TCP**: Raw stream with kernel-level buffering. Drops occur when TCP send buffer fills
|
|
||||||
- **HTTP/SSE**: Application-level buffering. Each client has a dedicated channel (default: 1000 entries)
|
|
||||||
|
|
||||||
**Mitigation strategies:**
|
|
||||||
1. Increase buffer sizes for burst tolerance: `--tcp-buffer-size 5000` or `--http-buffer-size 5000`
|
|
||||||
2. Implement client-side flow control (pause/resume based on queue depth)
|
|
||||||
3. Use TCP for high-volume consumers that need guaranteed delivery
|
|
||||||
4. Keep browser tabs in foreground for real-time monitoring
|
|
||||||
5. Consider log aggregation/filtering at source for high-volume scenarios
|
|
||||||
|
|
||||||
**Monitoring drops:**
|
|
||||||
- HTTP: Check `/status` endpoint for drop statistics
|
|
||||||
- TCP: Monitor connection count and system TCP metrics
|
|
||||||
- Both: Watch for "channel full" indicators in client implementations
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/yourusername/logwisp
|
|
||||||
cd logwisp
|
|
||||||
go mod init logwisp
|
|
||||||
go get github.com/panjf2000/gnet/v2
|
|
||||||
go get github.com/valyala/fasthttp
|
|
||||||
go get github.com/lixenwraith/config
|
|
||||||
go build -o logwisp ./src/cmd/logwisp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- Read-only file access
|
||||||
|
- No authentication (placeholder configuration only)
|
||||||
|
- No TLS/SSL support (placeholder configuration only)
|
||||||
|
|
||||||
|
### Planned Security Features
|
||||||
|
- **Authentication**: Basic, Bearer/JWT, mTLS
|
||||||
|
- **TLS/SSL**: For both HTTP and TCP streams
|
||||||
|
- **Rate Limiting**: Per-client request limits
|
||||||
|
- **IP Filtering**: Whitelist/blacklist support
|
||||||
|
- **Audit Logging**: Access and authentication events
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
1. Run with minimal privileges (read-only access to log files)
|
||||||
|
2. Use network-level security until authentication is implemented
|
||||||
|
3. Place behind a reverse proxy for production HTTPS
|
||||||
|
4. Monitor access logs for unusual patterns
|
||||||
|
5. Regularly update dependencies
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No Log Entries Appearing
|
||||||
|
1. Check file permissions (LogWisp needs read access)
|
||||||
|
2. Verify file paths in configuration
|
||||||
|
3. Ensure files match the specified patterns
|
||||||
|
4. Check monitor statistics in status endpoint
|
||||||
|
|
||||||
|
### High Memory Usage
|
||||||
|
1. Reduce buffer sizes in configuration
|
||||||
|
2. Lower the number of concurrent watchers
|
||||||
|
3. Increase check interval for less critical logs
|
||||||
|
4. Use TCP instead of HTTP for high-volume streams
|
||||||
|
|
||||||
|
### Connection Drops
|
||||||
|
1. Check heartbeat configuration
|
||||||
|
2. Verify network stability
|
||||||
|
3. Monitor client-side errors
|
||||||
|
4. Review dropped entry statistics
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
BSD-3-Clause
|
BSD-3-Clause
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] Multi-stream architecture
|
||||||
|
- [x] File and directory monitoring
|
||||||
|
- [x] TCP and HTTP/SSE streaming
|
||||||
|
- [x] Path-based HTTP routing
|
||||||
|
- [ ] Authentication (Basic, JWT, mTLS)
|
||||||
|
- [ ] TLS/SSL support
|
||||||
|
- [ ] Rate limiting
|
||||||
|
- [ ] Prometheus metrics export
|
||||||
|
- [ ] WebSocket support
|
||||||
|
- [ ] Log filtering and transformation
|
||||||
@ -1,133 +1,121 @@
|
|||||||
# LogWisp Configuration Template
|
# LogWisp Multi-Stream Configuration
|
||||||
# Default location: ~/.config/logwisp.toml
|
# Location: ~/.config/logwisp.toml
|
||||||
#
|
|
||||||
# Configuration precedence (highest to lowest):
|
|
||||||
# 1. Command-line arguments
|
|
||||||
# 2. Environment variables (LOGWISP_ prefix)
|
|
||||||
# 3. This configuration file
|
|
||||||
# 4. Built-in defaults
|
|
||||||
|
|
||||||
|
# Global monitor defaults
|
||||||
[monitor]
|
[monitor]
|
||||||
# File check interval (milliseconds)
|
|
||||||
# Lower = more responsive, higher CPU usage
|
|
||||||
# Environment: LOGWISP_MONITOR_CHECK_INTERVAL_MS
|
|
||||||
# CLI: --check-interval MS
|
|
||||||
check_interval_ms = 100
|
check_interval_ms = 100
|
||||||
|
|
||||||
# Monitor targets
|
# Stream 1: Application logs (public access)
|
||||||
# Environment: LOGWISP_MONITOR_TARGETS="path:pattern:isfile,path2:pattern2:isfile"
|
[[streams]]
|
||||||
# CLI: logwisp [path[:pattern[:isfile]]] ...
|
name = "app"
|
||||||
[[monitor.targets]]
|
|
||||||
path = "./" # Directory or file path
|
|
||||||
pattern = "*.log" # Glob pattern (ignored for files)
|
|
||||||
is_file = false # true = file, false = directory
|
|
||||||
|
|
||||||
# # Example: Specific file
|
[streams.monitor]
|
||||||
# [[monitor.targets]]
|
targets = [
|
||||||
# path = "/var/log/app.log"
|
{ path = "/var/log/myapp", pattern = "*.log", is_file = false },
|
||||||
# pattern = ""
|
{ path = "/var/log/myapp/app.log", pattern = "", is_file = true }
|
||||||
# is_file = true
|
]
|
||||||
|
|
||||||
# # Example: System logs
|
[streams.httpserver]
|
||||||
# [[monitor.targets]]
|
|
||||||
# path = "/var/log"
|
|
||||||
# pattern = "*.log"
|
|
||||||
# is_file = false
|
|
||||||
|
|
||||||
[tcpserver]
|
|
||||||
# Raw TCP streaming server (gnet)
|
|
||||||
# Environment: LOGWISP_TCPSERVER_ENABLED
|
|
||||||
# CLI: --enable-tcp
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
# TCP port
|
|
||||||
# Environment: LOGWISP_TCPSERVER_PORT
|
|
||||||
# CLI: --tcp-port PORT
|
|
||||||
port = 9090
|
|
||||||
|
|
||||||
# Per-client buffer size
|
|
||||||
# Environment: LOGWISP_TCPSERVER_BUFFER_SIZE
|
|
||||||
# CLI: --tcp-buffer-size SIZE
|
|
||||||
buffer_size = 1000
|
|
||||||
|
|
||||||
# TLS/SSL settings (not implemented in PoC)
|
|
||||||
ssl_enabled = false
|
|
||||||
ssl_cert_file = ""
|
|
||||||
ssl_key_file = ""
|
|
||||||
|
|
||||||
[tcpserver.heartbeat]
|
|
||||||
# Enable/disable heartbeat messages
|
|
||||||
# Environment: LOGWISP_TCPSERVER_HEARTBEAT_ENABLED
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
# Heartbeat interval in seconds
|
|
||||||
# Environment: LOGWISP_TCPSERVER_HEARTBEAT_INTERVAL_SECONDS
|
|
||||||
interval_seconds = 30
|
|
||||||
|
|
||||||
# Include timestamp in heartbeat
|
|
||||||
# Environment: LOGWISP_TCPSERVER_HEARTBEAT_INCLUDE_TIMESTAMP
|
|
||||||
include_timestamp = true
|
|
||||||
|
|
||||||
# Include server statistics (active connections, uptime)
|
|
||||||
# Environment: LOGWISP_TCPSERVER_HEARTBEAT_INCLUDE_STATS
|
|
||||||
include_stats = false
|
|
||||||
|
|
||||||
# Format: "json" only for TCP
|
|
||||||
# Environment: LOGWISP_TCPSERVER_HEARTBEAT_FORMAT
|
|
||||||
format = "json"
|
|
||||||
|
|
||||||
[httpserver]
|
|
||||||
# HTTP/SSE streaming server (fasthttp)
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_ENABLED
|
|
||||||
# CLI: --enable-http
|
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# HTTP port
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_PORT
|
|
||||||
# CLI: --http-port PORT (or legacy --port)
|
|
||||||
port = 8080
|
port = 8080
|
||||||
|
buffer_size = 2000
|
||||||
|
stream_path = "/stream"
|
||||||
|
status_path = "/status"
|
||||||
|
|
||||||
# Per-client buffer size
|
[streams.httpserver.heartbeat]
|
||||||
# Environment: LOGWISP_HTTPSERVER_BUFFER_SIZE
|
|
||||||
# CLI: --http-buffer-size SIZE (or legacy --buffer-size)
|
|
||||||
buffer_size = 1000
|
|
||||||
|
|
||||||
# TLS/SSL settings (not implemented in PoC)
|
|
||||||
ssl_enabled = false
|
|
||||||
ssl_cert_file = ""
|
|
||||||
ssl_key_file = ""
|
|
||||||
|
|
||||||
[httpserver.heartbeat]
|
|
||||||
# Enable/disable heartbeat messages
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_HEARTBEAT_ENABLED
|
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# Heartbeat interval in seconds
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_HEARTBEAT_INTERVAL_SECONDS
|
|
||||||
interval_seconds = 30
|
interval_seconds = 30
|
||||||
|
|
||||||
# Include timestamp in heartbeat
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_HEARTBEAT_INCLUDE_TIMESTAMP
|
|
||||||
include_timestamp = true
|
|
||||||
|
|
||||||
# Include server statistics (active clients, uptime)
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_HEARTBEAT_INCLUDE_STATS
|
|
||||||
include_stats = false
|
|
||||||
|
|
||||||
# Format: "comment" (SSE comment) or "json" (data message)
|
|
||||||
# Environment: LOGWISP_HTTPSERVER_HEARTBEAT_FORMAT
|
|
||||||
format = "comment"
|
format = "comment"
|
||||||
|
|
||||||
# Production example:
|
# Stream 2: System logs (authenticated)
|
||||||
# [tcpserver]
|
[[streams]]
|
||||||
# enabled = true
|
name = "system"
|
||||||
# port = 9090
|
|
||||||
# buffer_size = 5000
|
[streams.monitor]
|
||||||
|
check_interval_ms = 50 # More frequent checks
|
||||||
|
targets = [
|
||||||
|
{ path = "/var/log", pattern = "syslog*", is_file = false },
|
||||||
|
{ path = "/var/log/auth.log", pattern = "", is_file = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
[streams.httpserver]
|
||||||
|
enabled = true
|
||||||
|
port = 8443
|
||||||
|
buffer_size = 5000
|
||||||
|
stream_path = "/logs"
|
||||||
|
status_path = "/health"
|
||||||
|
|
||||||
|
# SSL placeholder
|
||||||
|
[streams.httpserver.ssl]
|
||||||
|
enabled = true
|
||||||
|
cert_file = "/etc/logwisp/certs/server.crt"
|
||||||
|
key_file = "/etc/logwisp/certs/server.key"
|
||||||
|
min_version = "TLS1.2"
|
||||||
|
|
||||||
|
# Authentication placeholder
|
||||||
|
[streams.auth]
|
||||||
|
type = "basic"
|
||||||
|
|
||||||
|
[streams.auth.basic_auth]
|
||||||
|
realm = "System Logs"
|
||||||
|
users = [
|
||||||
|
{ username = "admin", password_hash = "$2y$10$..." }
|
||||||
|
]
|
||||||
|
ip_whitelist = ["10.0.0.0/8", "192.168.0.0/16"]
|
||||||
|
|
||||||
|
# TCP server also available
|
||||||
|
[streams.tcpserver]
|
||||||
|
enabled = true
|
||||||
|
port = 9443
|
||||||
|
buffer_size = 5000
|
||||||
|
|
||||||
|
[streams.tcpserver.heartbeat]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# Stream 3: Debug logs (high-volume, no heartbeat)
|
||||||
|
[[streams]]
|
||||||
|
name = "debug"
|
||||||
|
|
||||||
|
[streams.monitor]
|
||||||
|
targets = [
|
||||||
|
{ path = "./debug", pattern = "*.debug", is_file = false }
|
||||||
|
]
|
||||||
|
|
||||||
|
[streams.httpserver]
|
||||||
|
enabled = true
|
||||||
|
port = 8082
|
||||||
|
buffer_size = 10000
|
||||||
|
stream_path = "/stream"
|
||||||
|
status_path = "/status"
|
||||||
|
|
||||||
|
[streams.httpserver.heartbeat]
|
||||||
|
enabled = false # Disable for high-volume
|
||||||
|
|
||||||
|
# Rate limiting placeholder
|
||||||
|
[streams.httpserver.rate_limit]
|
||||||
|
enabled = true
|
||||||
|
requests_per_second = 100.0
|
||||||
|
burst_size = 1000
|
||||||
|
limit_by = "ip"
|
||||||
|
|
||||||
|
# Usage Examples:
|
||||||
#
|
#
|
||||||
# [httpserver]
|
# 1. Standard mode (each stream on its own port):
|
||||||
# enabled = true
|
# ./logwisp
|
||||||
# port = 443
|
# - App logs: http://localhost:8080/stream
|
||||||
# buffer_size = 5000
|
# - System logs: https://localhost:8443/logs (with auth)
|
||||||
# ssl_enabled = true
|
# - Debug logs: http://localhost:8082/stream
|
||||||
# ssl_cert_file = "/etc/ssl/certs/logwisp.crt"
|
#
|
||||||
# ssl_key_file = "/etc/ssl/private/logwisp.key"
|
# 2. Router mode (shared port with path routing):
|
||||||
|
# ./logwisp --router
|
||||||
|
# - App logs: http://localhost:8080/app/stream
|
||||||
|
# - System logs: http://localhost:8080/system/logs
|
||||||
|
# - Debug logs: http://localhost:8080/debug/stream
|
||||||
|
# - Global status: http://localhost:8080/status
|
||||||
|
#
|
||||||
|
# 3. Override config file:
|
||||||
|
# ./logwisp --config /etc/logwisp/production.toml
|
||||||
|
#
|
||||||
|
# 4. Environment variables:
|
||||||
|
# LOGWISP_MONITOR_CHECK_INTERVAL_MS=50
|
||||||
|
# LOGWISP_STREAMS_0_HTTPSERVER_PORT=8090
|
||||||
@ -7,27 +7,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"logwisp/src/internal/config"
|
"logwisp/src/internal/config"
|
||||||
"logwisp/src/internal/monitor"
|
"logwisp/src/internal/logstream"
|
||||||
"logwisp/src/internal/stream"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse CLI flags
|
// Parse CLI flags
|
||||||
var (
|
var (
|
||||||
configFile = flag.String("config", "", "Config file path")
|
configFile = flag.String("config", "", "Config file path")
|
||||||
// Flags
|
useRouter = flag.Bool("router", false, "Use HTTP router for path-based routing")
|
||||||
httpPort = flag.Int("http-port", 0, "HTTP server port")
|
// routerPort = flag.Int("router-port", 0, "Override router port (default: first HTTP port)")
|
||||||
httpBuffer = flag.Int("http-buffer-size", 0, "HTTP server buffer size")
|
|
||||||
tcpPort = flag.Int("tcp-port", 0, "TCP server port")
|
|
||||||
tcpBuffer = flag.Int("tcp-buffer-size", 0, "TCP server buffer size")
|
|
||||||
enableTCP = flag.Bool("enable-tcp", false, "Enable TCP server")
|
|
||||||
enableHTTP = flag.Bool("enable-http", false, "Enable HTTP server")
|
|
||||||
checkInterval = flag.Int("check-interval", 0, "File check interval in ms")
|
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@ -35,39 +27,8 @@ func main() {
|
|||||||
os.Setenv("LOGWISP_CONFIG_FILE", *configFile)
|
os.Setenv("LOGWISP_CONFIG_FILE", *configFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build CLI args for config
|
|
||||||
var cliArgs []string
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
if *httpPort > 0 {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.port=%d", *httpPort))
|
|
||||||
}
|
|
||||||
if *httpBuffer > 0 {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.buffer_size=%d", *httpBuffer))
|
|
||||||
}
|
|
||||||
if *tcpPort > 0 {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.port=%d", *tcpPort))
|
|
||||||
}
|
|
||||||
if *tcpBuffer > 0 {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.buffer_size=%d", *tcpBuffer))
|
|
||||||
}
|
|
||||||
if flag.Lookup("enable-tcp").DefValue != flag.Lookup("enable-tcp").Value.String() {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.enabled=%v", *enableTCP))
|
|
||||||
}
|
|
||||||
if flag.Lookup("enable-http").DefValue != flag.Lookup("enable-http").Value.String() {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.enabled=%v", *enableHTTP))
|
|
||||||
}
|
|
||||||
if *checkInterval > 0 {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--monitor.check_interval_ms=%d", *checkInterval))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse monitor targets from remaining args
|
|
||||||
for _, arg := range flag.Args() {
|
|
||||||
cliArgs = append(cliArgs, fmt.Sprintf("--monitor.targets.add=%s", arg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
cfg, err := config.LoadWithCLI(cliArgs)
|
cfg, err := config.LoadWithCLI(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -81,147 +42,154 @@ func main() {
|
|||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// Create monitor
|
// Create log stream service
|
||||||
mon := monitor.New()
|
service := logstream.New(ctx)
|
||||||
mon.SetCheckInterval(time.Duration(cfg.Monitor.CheckIntervalMs) * time.Millisecond)
|
|
||||||
|
|
||||||
// Add targets
|
// Create HTTP router if requested
|
||||||
for _, target := range cfg.Monitor.Targets {
|
var router *logstream.HTTPRouter
|
||||||
if err := mon.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
if *useRouter {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to add target %s: %v\n", target.Path, err)
|
router = logstream.NewHTTPRouter(service)
|
||||||
|
fmt.Println("HTTP router mode enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize streams
|
||||||
|
successCount := 0
|
||||||
|
for _, streamCfg := range cfg.Streams {
|
||||||
|
fmt.Printf("Initializing stream '%s'...\n", streamCfg.Name)
|
||||||
|
|
||||||
|
// Set router mode BEFORE creating stream
|
||||||
|
if *useRouter && streamCfg.HTTPServer != nil && streamCfg.HTTPServer.Enabled {
|
||||||
|
// Temporarily disable standalone server startup
|
||||||
|
originalEnabled := streamCfg.HTTPServer.Enabled
|
||||||
|
streamCfg.HTTPServer.Enabled = false
|
||||||
|
|
||||||
|
if err := service.CreateStream(streamCfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to create stream '%s': %v\n", streamCfg.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the created stream and configure for router mode
|
||||||
|
stream, _ := service.GetStream(streamCfg.Name)
|
||||||
|
if stream.HTTPServer != nil {
|
||||||
|
stream.HTTPServer.SetRouterMode()
|
||||||
|
// Restore enabled state
|
||||||
|
stream.Config.HTTPServer.Enabled = originalEnabled
|
||||||
|
|
||||||
|
if err := router.RegisterStream(stream); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to register stream '%s' with router: %v\n",
|
||||||
|
streamCfg.Name, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Stream '%s' registered with router\n", streamCfg.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard standalone mode
|
||||||
|
if err := service.CreateStream(streamCfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to create stream '%s': %v\n", streamCfg.Name, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start monitor
|
successCount++
|
||||||
if err := mon.Start(ctx); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to start monitor: %v\n", err)
|
// Display endpoints
|
||||||
|
displayStreamEndpoints(streamCfg, *useRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if successCount == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No streams successfully started")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var tcpServer *stream.TCPStreamer
|
fmt.Printf("\n%d stream(s) running. Press Ctrl+C to stop.\n", successCount)
|
||||||
var httpServer *stream.HTTPStreamer
|
|
||||||
|
|
||||||
// Start TCP server if enabled
|
// Start periodic status display
|
||||||
if cfg.TCPServer.Enabled {
|
go statusReporter(service)
|
||||||
tcpChan := mon.Subscribe()
|
|
||||||
tcpServer = stream.NewTCPStreamer(tcpChan, cfg.TCPServer)
|
|
||||||
|
|
||||||
// Start TCP server in separate goroutine without blocking wg.Wait()
|
|
||||||
tcpStarted := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
tcpStarted <- tcpServer.Start()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Check if TCP server started successfully
|
|
||||||
select {
|
|
||||||
case err := <-tcpStarted:
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "TCP server failed to start: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
case <-time.After(1 * time.Second):
|
|
||||||
// Server is running
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("TCP streaming on port %d\n", cfg.TCPServer.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start HTTP server if enabled
|
|
||||||
if cfg.HTTPServer.Enabled {
|
|
||||||
httpChan := mon.Subscribe()
|
|
||||||
httpServer = stream.NewHTTPStreamer(httpChan, cfg.HTTPServer)
|
|
||||||
|
|
||||||
// Start HTTP server in separate goroutine without blocking wg.Wait()
|
|
||||||
httpStarted := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
httpStarted <- httpServer.Start()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Check if HTTP server started successfully
|
|
||||||
select {
|
|
||||||
case err := <-httpStarted:
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "HTTP server failed to start: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
case <-time.After(1 * time.Second):
|
|
||||||
// Server is running
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("HTTP/SSE streaming on http://localhost:%d/stream\n", cfg.HTTPServer.Port)
|
|
||||||
fmt.Printf("Status available at http://localhost:%d/status\n", cfg.HTTPServer.Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.TCPServer.Enabled && !cfg.HTTPServer.Enabled {
|
|
||||||
fmt.Fprintln(os.Stderr, "No servers enabled. Enable at least one server in config.")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for shutdown
|
// Wait for shutdown
|
||||||
<-sigChan
|
<-sigChan
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
|
|
||||||
// Create shutdown group for concurrent server stops
|
// Shutdown router first if using it
|
||||||
var shutdownWg sync.WaitGroup
|
if router != nil {
|
||||||
|
fmt.Println("Shutting down HTTP router...")
|
||||||
// Stop servers first (concurrently)
|
router.Shutdown()
|
||||||
if tcpServer != nil {
|
|
||||||
shutdownWg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer shutdownWg.Done()
|
|
||||||
tcpServer.Stop()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if httpServer != nil {
|
|
||||||
shutdownWg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer shutdownWg.Done()
|
|
||||||
httpServer.Stop()
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel context to stop monitor
|
// Shutdown service (handles all streams)
|
||||||
cancel()
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
// Wait for servers to stop with timeout
|
done := make(chan struct{})
|
||||||
serversDone := make(chan struct{})
|
|
||||||
go func() {
|
go func() {
|
||||||
shutdownWg.Wait()
|
service.Shutdown()
|
||||||
close(serversDone)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Stop monitor after context cancellation
|
|
||||||
monitorDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
mon.Stop()
|
|
||||||
close(monitorDone)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for all components with proper timeout
|
|
||||||
shutdownTimeout := 5 * time.Second
|
|
||||||
shutdownTimer := time.NewTimer(shutdownTimeout)
|
|
||||||
defer shutdownTimer.Stop()
|
|
||||||
|
|
||||||
serversShutdown := false
|
|
||||||
monitorShutdown := false
|
|
||||||
|
|
||||||
for !serversShutdown || !monitorShutdown {
|
|
||||||
select {
|
select {
|
||||||
case <-serversDone:
|
case <-done:
|
||||||
serversShutdown = true
|
fmt.Println("Shutdown complete")
|
||||||
case <-monitorDone:
|
case <-shutdownCtx.Done():
|
||||||
monitorShutdown = true
|
fmt.Println("Shutdown timeout - forcing exit")
|
||||||
case <-shutdownTimer.C:
|
|
||||||
if !serversShutdown {
|
|
||||||
fmt.Println("Warning: Server shutdown timeout")
|
|
||||||
}
|
|
||||||
if !monitorShutdown {
|
|
||||||
fmt.Println("Warning: Monitor shutdown timeout")
|
|
||||||
}
|
|
||||||
fmt.Println("Forcing exit")
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Shutdown complete")
|
func displayStreamEndpoints(cfg config.StreamConfig, routerMode bool) {
|
||||||
|
if cfg.TCPServer != nil && cfg.TCPServer.Enabled {
|
||||||
|
fmt.Printf(" TCP: port %d\n", cfg.TCPServer.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HTTPServer != nil && cfg.HTTPServer.Enabled {
|
||||||
|
if routerMode {
|
||||||
|
fmt.Printf(" HTTP: /%s%s (stream), /%s%s (status)\n",
|
||||||
|
cfg.Name, cfg.HTTPServer.StreamPath,
|
||||||
|
cfg.Name, cfg.HTTPServer.StatusPath)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" HTTP: http://localhost:%d%s (stream), http://localhost:%d%s (status)\n",
|
||||||
|
cfg.HTTPServer.Port, cfg.HTTPServer.StreamPath,
|
||||||
|
cfg.HTTPServer.Port, cfg.HTTPServer.StatusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Auth != nil && cfg.Auth.Type != "none" {
|
||||||
|
fmt.Printf(" Auth: %s\n", cfg.Auth.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusReporter(service *logstream.Service) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
stats := service.GetGlobalStats()
|
||||||
|
totalStreams := stats["total_streams"].(int)
|
||||||
|
if totalStreams == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n[%s] Active streams: %d\n",
|
||||||
|
time.Now().Format("15:04:05"), totalStreams)
|
||||||
|
|
||||||
|
for name, streamStats := range stats["streams"].(map[string]interface{}) {
|
||||||
|
s := streamStats.(map[string]interface{})
|
||||||
|
fmt.Printf(" %s: ", name)
|
||||||
|
|
||||||
|
if monitor, ok := s["monitor"].(map[string]interface{}); ok {
|
||||||
|
fmt.Printf("watchers=%d entries=%d ",
|
||||||
|
monitor["active_watchers"],
|
||||||
|
monitor["total_entries"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if tcp, ok := s["tcp"].(map[string]interface{}); ok && tcp["enabled"].(bool) {
|
||||||
|
fmt.Printf("tcp_conns=%d ", tcp["connections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if http, ok := s["http"].(map[string]interface{}); ok && http["enabled"].(bool) {
|
||||||
|
fmt.Printf("http_conns=%d ", http["connections"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
56
src/internal/config/auth.go
Normal file
56
src/internal/config/auth.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// FILE: src/internal/config/auth.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
// Authentication type: "none", "basic", "bearer", "mtls"
|
||||||
|
Type string `toml:"type"`
|
||||||
|
|
||||||
|
// Basic auth
|
||||||
|
BasicAuth *BasicAuthConfig `toml:"basic_auth"`
|
||||||
|
|
||||||
|
// Bearer token auth
|
||||||
|
BearerAuth *BearerAuthConfig `toml:"bearer_auth"`
|
||||||
|
|
||||||
|
// IP-based access control
|
||||||
|
IPWhitelist []string `toml:"ip_whitelist"`
|
||||||
|
IPBlacklist []string `toml:"ip_blacklist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasicAuthConfig struct {
|
||||||
|
// Static users (for simple deployments)
|
||||||
|
Users []BasicAuthUser `toml:"users"`
|
||||||
|
|
||||||
|
// External auth file
|
||||||
|
UsersFile string `toml:"users_file"`
|
||||||
|
|
||||||
|
// Realm for WWW-Authenticate header
|
||||||
|
Realm string `toml:"realm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasicAuthUser struct {
|
||||||
|
Username string `toml:"username"`
|
||||||
|
// Password hash (bcrypt)
|
||||||
|
PasswordHash string `toml:"password_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BearerAuthConfig struct {
|
||||||
|
// Static tokens
|
||||||
|
Tokens []string `toml:"tokens"`
|
||||||
|
|
||||||
|
// JWT validation
|
||||||
|
JWT *JWTConfig `toml:"jwt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTConfig struct {
|
||||||
|
// JWKS URL for key discovery
|
||||||
|
JWKSURL string `toml:"jwks_url"`
|
||||||
|
|
||||||
|
// Static signing key (if not using JWKS)
|
||||||
|
SigningKey string `toml:"signing_key"`
|
||||||
|
|
||||||
|
// Expected issuer
|
||||||
|
Issuer string `toml:"issuer"`
|
||||||
|
|
||||||
|
// Expected audience
|
||||||
|
Audience string `toml:"audience"`
|
||||||
|
}
|
||||||
@ -1,245 +1,14 @@
|
|||||||
// FILE: src/internal/config/config.go
|
// FILE: src/internal/config/config.go
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
lconfig "github.com/lixenwraith/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// Global monitor settings
|
||||||
Monitor MonitorConfig `toml:"monitor"`
|
Monitor MonitorConfig `toml:"monitor"`
|
||||||
TCPServer TCPConfig `toml:"tcpserver"`
|
|
||||||
HTTPServer HTTPConfig `toml:"httpserver"`
|
// Stream configurations
|
||||||
|
Streams []StreamConfig `toml:"streams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MonitorConfig struct {
|
type MonitorConfig struct {
|
||||||
CheckIntervalMs int `toml:"check_interval_ms"`
|
CheckIntervalMs int `toml:"check_interval_ms"`
|
||||||
Targets []MonitorTarget `toml:"targets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MonitorTarget struct {
|
|
||||||
Path string `toml:"path"`
|
|
||||||
Pattern string `toml:"pattern"`
|
|
||||||
IsFile bool `toml:"is_file"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPConfig struct {
|
|
||||||
Enabled bool `toml:"enabled"`
|
|
||||||
Port int `toml:"port"`
|
|
||||||
BufferSize int `toml:"buffer_size"`
|
|
||||||
SSLEnabled bool `toml:"ssl_enabled"`
|
|
||||||
SSLCertFile string `toml:"ssl_cert_file"`
|
|
||||||
SSLKeyFile string `toml:"ssl_key_file"`
|
|
||||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTPConfig struct {
|
|
||||||
Enabled bool `toml:"enabled"`
|
|
||||||
Port int `toml:"port"`
|
|
||||||
BufferSize int `toml:"buffer_size"`
|
|
||||||
SSLEnabled bool `toml:"ssl_enabled"`
|
|
||||||
SSLCertFile string `toml:"ssl_cert_file"`
|
|
||||||
SSLKeyFile string `toml:"ssl_key_file"`
|
|
||||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
|
||||||
Enabled bool `toml:"enabled"`
|
|
||||||
IntervalSeconds int `toml:"interval_seconds"`
|
|
||||||
IncludeTimestamp bool `toml:"include_timestamp"`
|
|
||||||
IncludeStats bool `toml:"include_stats"`
|
|
||||||
Format string `toml:"format"` // "comment" or "json"
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaults() *Config {
|
|
||||||
return &Config{
|
|
||||||
Monitor: MonitorConfig{
|
|
||||||
CheckIntervalMs: 100,
|
|
||||||
Targets: []MonitorTarget{
|
|
||||||
{Path: "./", Pattern: "*.log", IsFile: false},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TCPServer: TCPConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Port: 9090,
|
|
||||||
BufferSize: 1000,
|
|
||||||
Heartbeat: HeartbeatConfig{
|
|
||||||
Enabled: false,
|
|
||||||
IntervalSeconds: 30,
|
|
||||||
IncludeTimestamp: true,
|
|
||||||
IncludeStats: false,
|
|
||||||
Format: "json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HTTPServer: HTTPConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Port: 8080,
|
|
||||||
BufferSize: 1000,
|
|
||||||
Heartbeat: HeartbeatConfig{
|
|
||||||
Enabled: true,
|
|
||||||
IntervalSeconds: 30,
|
|
||||||
IncludeTimestamp: true,
|
|
||||||
IncludeStats: false,
|
|
||||||
Format: "comment",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadWithCLI(cliArgs []string) (*Config, error) {
|
|
||||||
configPath := GetConfigPath()
|
|
||||||
|
|
||||||
cfg, err := lconfig.NewBuilder().
|
|
||||||
WithDefaults(defaults()).
|
|
||||||
WithEnvPrefix("LOGWISP_").
|
|
||||||
WithFile(configPath).
|
|
||||||
WithArgs(cliArgs).
|
|
||||||
WithEnvTransform(customEnvTransform).
|
|
||||||
WithSources(
|
|
||||||
lconfig.SourceCLI,
|
|
||||||
lconfig.SourceEnv,
|
|
||||||
lconfig.SourceFile,
|
|
||||||
lconfig.SourceDefault,
|
|
||||||
).
|
|
||||||
Build()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if !strings.Contains(err.Error(), "not found") {
|
|
||||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := handleMonitorTargetsEnv(cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
finalConfig := &Config{}
|
|
||||||
if err := cfg.Scan("", finalConfig); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalConfig, finalConfig.validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func customEnvTransform(path string) string {
|
|
||||||
env := strings.ReplaceAll(path, ".", "_")
|
|
||||||
env = strings.ToUpper(env)
|
|
||||||
env = "LOGWISP_" + env
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetConfigPath() string {
|
|
||||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
|
||||||
if filepath.IsAbs(configFile) {
|
|
||||||
return configFile
|
|
||||||
}
|
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
|
||||||
return filepath.Join(configDir, configFile)
|
|
||||||
}
|
|
||||||
return configFile
|
|
||||||
}
|
|
||||||
|
|
||||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
|
||||||
return filepath.Join(configDir, "logwisp.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
||||||
return filepath.Join(homeDir, ".config", "logwisp.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
return "logwisp.toml"
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleMonitorTargetsEnv(cfg *lconfig.Config) error {
|
|
||||||
if targetsStr := os.Getenv("LOGWISP_MONITOR_TARGETS"); targetsStr != "" {
|
|
||||||
cfg.Set("monitor.targets", []MonitorTarget{})
|
|
||||||
|
|
||||||
parts := strings.Split(targetsStr, ",")
|
|
||||||
for i, part := range parts {
|
|
||||||
targetParts := strings.Split(part, ":")
|
|
||||||
if len(targetParts) >= 1 && targetParts[0] != "" {
|
|
||||||
path := fmt.Sprintf("monitor.targets.%d.path", i)
|
|
||||||
cfg.Set(path, targetParts[0])
|
|
||||||
|
|
||||||
if len(targetParts) >= 2 && targetParts[1] != "" {
|
|
||||||
pattern := fmt.Sprintf("monitor.targets.%d.pattern", i)
|
|
||||||
cfg.Set(pattern, targetParts[1])
|
|
||||||
} else {
|
|
||||||
pattern := fmt.Sprintf("monitor.targets.%d.pattern", i)
|
|
||||||
cfg.Set(pattern, "*.log")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(targetParts) >= 3 {
|
|
||||||
isFile := fmt.Sprintf("monitor.targets.%d.is_file", i)
|
|
||||||
cfg.Set(isFile, targetParts[2] == "true")
|
|
||||||
} else {
|
|
||||||
isFile := fmt.Sprintf("monitor.targets.%d.is_file", i)
|
|
||||||
cfg.Set(isFile, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
|
||||||
if c.Monitor.CheckIntervalMs < 10 {
|
|
||||||
return fmt.Errorf("check interval too small: %d ms", c.Monitor.CheckIntervalMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.Monitor.Targets) == 0 {
|
|
||||||
return fmt.Errorf("no monitor targets specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, target := range c.Monitor.Targets {
|
|
||||||
if target.Path == "" {
|
|
||||||
return fmt.Errorf("target %d: empty path", i)
|
|
||||||
}
|
|
||||||
if strings.Contains(target.Path, "..") {
|
|
||||||
return fmt.Errorf("target %d: path contains directory traversal", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.TCPServer.Enabled {
|
|
||||||
if c.TCPServer.Port < 1 || c.TCPServer.Port > 65535 {
|
|
||||||
return fmt.Errorf("invalid TCP port: %d", c.TCPServer.Port)
|
|
||||||
}
|
|
||||||
if c.TCPServer.BufferSize < 1 {
|
|
||||||
return fmt.Errorf("TCP buffer size must be positive: %d", c.TCPServer.BufferSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.HTTPServer.Enabled {
|
|
||||||
if c.HTTPServer.Port < 1 || c.HTTPServer.Port > 65535 {
|
|
||||||
return fmt.Errorf("invalid HTTP port: %d", c.HTTPServer.Port)
|
|
||||||
}
|
|
||||||
if c.HTTPServer.BufferSize < 1 {
|
|
||||||
return fmt.Errorf("HTTP buffer size must be positive: %d", c.HTTPServer.BufferSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.TCPServer.Enabled && c.TCPServer.Heartbeat.Enabled {
|
|
||||||
if c.TCPServer.Heartbeat.IntervalSeconds < 1 {
|
|
||||||
return fmt.Errorf("TCP heartbeat interval must be positive: %d", c.TCPServer.Heartbeat.IntervalSeconds)
|
|
||||||
}
|
|
||||||
if c.TCPServer.Heartbeat.Format != "json" && c.TCPServer.Heartbeat.Format != "comment" {
|
|
||||||
return fmt.Errorf("TCP heartbeat format must be 'json' or 'comment': %s", c.TCPServer.Heartbeat.Format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.HTTPServer.Enabled && c.HTTPServer.Heartbeat.Enabled {
|
|
||||||
if c.HTTPServer.Heartbeat.IntervalSeconds < 1 {
|
|
||||||
return fmt.Errorf("HTTP heartbeat interval must be positive: %d", c.HTTPServer.Heartbeat.IntervalSeconds)
|
|
||||||
}
|
|
||||||
if c.HTTPServer.Heartbeat.Format != "json" && c.HTTPServer.Heartbeat.Format != "comment" {
|
|
||||||
return fmt.Errorf("HTTP heartbeat format must be 'json' or 'comment': %s", c.HTTPServer.Heartbeat.Format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
102
src/internal/config/loader.go
Normal file
102
src/internal/config/loader.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// FILE: src/internal/config/loader.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
lconfig "github.com/lixenwraith/config"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaults() *Config {
|
||||||
|
return &Config{
|
||||||
|
Monitor: MonitorConfig{
|
||||||
|
CheckIntervalMs: 100,
|
||||||
|
},
|
||||||
|
Streams: []StreamConfig{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Monitor: &StreamMonitorConfig{
|
||||||
|
Targets: []MonitorTarget{
|
||||||
|
{Path: "./", Pattern: "*.log", IsFile: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HTTPServer: &HTTPConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Port: 8080,
|
||||||
|
BufferSize: 1000,
|
||||||
|
StreamPath: "/stream",
|
||||||
|
StatusPath: "/status",
|
||||||
|
Heartbeat: HeartbeatConfig{
|
||||||
|
Enabled: true,
|
||||||
|
IntervalSeconds: 30,
|
||||||
|
IncludeTimestamp: true,
|
||||||
|
IncludeStats: false,
|
||||||
|
Format: "comment",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadWithCLI(cliArgs []string) (*Config, error) {
|
||||||
|
configPath := GetConfigPath()
|
||||||
|
|
||||||
|
cfg, err := lconfig.NewBuilder().
|
||||||
|
WithDefaults(defaults()).
|
||||||
|
WithEnvPrefix("LOGWISP_").
|
||||||
|
WithFile(configPath).
|
||||||
|
WithArgs(cliArgs).
|
||||||
|
WithEnvTransform(customEnvTransform).
|
||||||
|
WithSources(
|
||||||
|
lconfig.SourceCLI,
|
||||||
|
lconfig.SourceEnv,
|
||||||
|
lconfig.SourceFile,
|
||||||
|
lconfig.SourceDefault,
|
||||||
|
).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "not found") {
|
||||||
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalConfig := &Config{}
|
||||||
|
if err := cfg.Scan("", finalConfig); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalConfig, finalConfig.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func customEnvTransform(path string) string {
|
||||||
|
env := strings.ReplaceAll(path, ".", "_")
|
||||||
|
env = strings.ToUpper(env)
|
||||||
|
env = "LOGWISP_" + env
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConfigPath() string {
|
||||||
|
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||||
|
if filepath.IsAbs(configFile) {
|
||||||
|
return configFile
|
||||||
|
}
|
||||||
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
|
return filepath.Join(configDir, configFile)
|
||||||
|
}
|
||||||
|
return configFile
|
||||||
|
}
|
||||||
|
|
||||||
|
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||||
|
return filepath.Join(configDir, "logwisp.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(homeDir, ".config", "logwisp.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "logwisp.toml"
|
||||||
|
}
|
||||||
62
src/internal/config/server.go
Normal file
62
src/internal/config/server.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// FILE: src/internal/config/server.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
type TCPConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
BufferSize int `toml:"buffer_size"`
|
||||||
|
|
||||||
|
// SSL/TLS Configuration
|
||||||
|
SSL *SSLConfig `toml:"ssl"`
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
BufferSize int `toml:"buffer_size"`
|
||||||
|
|
||||||
|
// Endpoint paths
|
||||||
|
StreamPath string `toml:"stream_path"`
|
||||||
|
StatusPath string `toml:"status_path"`
|
||||||
|
|
||||||
|
// SSL/TLS Configuration
|
||||||
|
SSL *SSLConfig `toml:"ssl"`
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeartbeatConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
IntervalSeconds int `toml:"interval_seconds"`
|
||||||
|
IncludeTimestamp bool `toml:"include_timestamp"`
|
||||||
|
IncludeStats bool `toml:"include_stats"`
|
||||||
|
Format string `toml:"format"` // "comment" or "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
// Enable rate limiting
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
|
||||||
|
// Requests per second per client
|
||||||
|
RequestsPerSecond float64 `toml:"requests_per_second"`
|
||||||
|
|
||||||
|
// Burst size (token bucket)
|
||||||
|
BurstSize int `toml:"burst_size"`
|
||||||
|
|
||||||
|
// Rate limit by: "ip", "user", "token"
|
||||||
|
LimitBy string `toml:"limit_by"`
|
||||||
|
|
||||||
|
// Response when rate limited
|
||||||
|
ResponseCode int `toml:"response_code"` // Default: 429
|
||||||
|
ResponseMessage string `toml:"response_message"` // Default: "Rate limit exceeded"
|
||||||
|
}
|
||||||
20
src/internal/config/ssl.go
Normal file
20
src/internal/config/ssl.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// FILE: src/internal/config/ssl.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
type SSLConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
CertFile string `toml:"cert_file"`
|
||||||
|
KeyFile string `toml:"key_file"`
|
||||||
|
|
||||||
|
// Client certificate authentication
|
||||||
|
ClientAuth bool `toml:"client_auth"`
|
||||||
|
ClientCAFile string `toml:"client_ca_file"`
|
||||||
|
VerifyClientCert bool `toml:"verify_client_cert"`
|
||||||
|
|
||||||
|
// TLS version constraints
|
||||||
|
MinVersion string `toml:"min_version"` // "TLS1.2", "TLS1.3"
|
||||||
|
MaxVersion string `toml:"max_version"`
|
||||||
|
|
||||||
|
// Cipher suites (comma-separated list)
|
||||||
|
CipherSuites string `toml:"cipher_suites"`
|
||||||
|
}
|
||||||
42
src/internal/config/stream.go
Normal file
42
src/internal/config/stream.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// FILE: src/internal/config/stream.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
type StreamConfig struct {
|
||||||
|
// Stream identifier (used in logs and metrics)
|
||||||
|
Name string `toml:"name"`
|
||||||
|
|
||||||
|
// Monitor configuration for this stream
|
||||||
|
Monitor *StreamMonitorConfig `toml:"monitor"`
|
||||||
|
|
||||||
|
// Server configurations
|
||||||
|
TCPServer *TCPConfig `toml:"tcpserver"`
|
||||||
|
HTTPServer *HTTPConfig `toml:"httpserver"`
|
||||||
|
|
||||||
|
// Authentication/Authorization
|
||||||
|
Auth *AuthConfig `toml:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamMonitorConfig struct {
|
||||||
|
CheckIntervalMs *int `toml:"check_interval_ms"`
|
||||||
|
Targets []MonitorTarget `toml:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitorTarget struct {
|
||||||
|
Path string `toml:"path"`
|
||||||
|
Pattern string `toml:"pattern"`
|
||||||
|
IsFile bool `toml:"is_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StreamConfig) GetTargets(defaultTargets []MonitorTarget) []MonitorTarget {
|
||||||
|
if s.Monitor != nil && len(s.Monitor.Targets) > 0 {
|
||||||
|
return s.Monitor.Targets
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StreamConfig) GetCheckInterval(defaultInterval int) int {
|
||||||
|
if s.Monitor != nil && s.Monitor.CheckIntervalMs != nil {
|
||||||
|
return *s.Monitor.CheckIntervalMs
|
||||||
|
}
|
||||||
|
return defaultInterval
|
||||||
|
}
|
||||||
187
src/internal/config/validation.go
Normal file
187
src/internal/config/validation.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// FILE: src/internal/config/validation.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if c.Monitor.CheckIntervalMs < 10 {
|
||||||
|
return fmt.Errorf("check interval too small: %d ms", c.Monitor.CheckIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Streams) == 0 {
|
||||||
|
return fmt.Errorf("no streams configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each stream
|
||||||
|
streamNames := make(map[string]bool)
|
||||||
|
streamPorts := make(map[int]string)
|
||||||
|
|
||||||
|
for i, stream := range c.Streams {
|
||||||
|
if stream.Name == "" {
|
||||||
|
return fmt.Errorf("stream %d: missing name", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamNames[stream.Name] {
|
||||||
|
return fmt.Errorf("stream %d: duplicate name '%s'", i, stream.Name)
|
||||||
|
}
|
||||||
|
streamNames[stream.Name] = true
|
||||||
|
|
||||||
|
// Stream must have targets
|
||||||
|
if stream.Monitor == nil || len(stream.Monitor.Targets) == 0 {
|
||||||
|
return fmt.Errorf("stream '%s': no monitor targets specified", stream.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for j, target := range stream.Monitor.Targets {
|
||||||
|
if target.Path == "" {
|
||||||
|
return fmt.Errorf("stream '%s' target %d: empty path", stream.Name, j)
|
||||||
|
}
|
||||||
|
if strings.Contains(target.Path, "..") {
|
||||||
|
return fmt.Errorf("stream '%s' target %d: path contains directory traversal", stream.Name, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TCP server
|
||||||
|
if stream.TCPServer != nil && stream.TCPServer.Enabled {
|
||||||
|
if stream.TCPServer.Port < 1 || stream.TCPServer.Port > 65535 {
|
||||||
|
return fmt.Errorf("stream '%s': invalid TCP port: %d", stream.Name, stream.TCPServer.Port)
|
||||||
|
}
|
||||||
|
if existing, exists := streamPorts[stream.TCPServer.Port]; exists {
|
||||||
|
return fmt.Errorf("stream '%s': TCP port %d already used by stream '%s'",
|
||||||
|
stream.Name, stream.TCPServer.Port, existing)
|
||||||
|
}
|
||||||
|
streamPorts[stream.TCPServer.Port] = stream.Name + "-tcp"
|
||||||
|
|
||||||
|
if stream.TCPServer.BufferSize < 1 {
|
||||||
|
return fmt.Errorf("stream '%s': TCP buffer size must be positive: %d",
|
||||||
|
stream.Name, stream.TCPServer.BufferSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateHeartbeat("TCP", stream.Name, &stream.TCPServer.Heartbeat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSSL("TCP", stream.Name, stream.TCPServer.SSL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate HTTP server
|
||||||
|
if stream.HTTPServer != nil && stream.HTTPServer.Enabled {
|
||||||
|
if stream.HTTPServer.Port < 1 || stream.HTTPServer.Port > 65535 {
|
||||||
|
return fmt.Errorf("stream '%s': invalid HTTP port: %d", stream.Name, stream.HTTPServer.Port)
|
||||||
|
}
|
||||||
|
if existing, exists := streamPorts[stream.HTTPServer.Port]; exists {
|
||||||
|
return fmt.Errorf("stream '%s': HTTP port %d already used by stream '%s'",
|
||||||
|
stream.Name, stream.HTTPServer.Port, existing)
|
||||||
|
}
|
||||||
|
streamPorts[stream.HTTPServer.Port] = stream.Name + "-http"
|
||||||
|
|
||||||
|
if stream.HTTPServer.BufferSize < 1 {
|
||||||
|
return fmt.Errorf("stream '%s': HTTP buffer size must be positive: %d",
|
||||||
|
stream.Name, stream.HTTPServer.BufferSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate paths
|
||||||
|
if stream.HTTPServer.StreamPath == "" {
|
||||||
|
stream.HTTPServer.StreamPath = "/stream"
|
||||||
|
}
|
||||||
|
if stream.HTTPServer.StatusPath == "" {
|
||||||
|
stream.HTTPServer.StatusPath = "/status"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stream.HTTPServer.StreamPath, "/") {
|
||||||
|
return fmt.Errorf("stream '%s': stream path must start with /: %s",
|
||||||
|
stream.Name, stream.HTTPServer.StreamPath)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(stream.HTTPServer.StatusPath, "/") {
|
||||||
|
return fmt.Errorf("stream '%s': status path must start with /: %s",
|
||||||
|
stream.Name, stream.HTTPServer.StatusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateHeartbeat("HTTP", stream.Name, &stream.HTTPServer.Heartbeat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSSL("HTTP", stream.Name, stream.HTTPServer.SSL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one server must be enabled
|
||||||
|
tcpEnabled := stream.TCPServer != nil && stream.TCPServer.Enabled
|
||||||
|
httpEnabled := stream.HTTPServer != nil && stream.HTTPServer.Enabled
|
||||||
|
if !tcpEnabled && !httpEnabled {
|
||||||
|
return fmt.Errorf("stream '%s': no servers enabled", stream.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auth if present
|
||||||
|
if err := validateAuth(stream.Name, stream.Auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHeartbeat(serverType, streamName string, hb *HeartbeatConfig) error {
|
||||||
|
if hb.Enabled {
|
||||||
|
if hb.IntervalSeconds < 1 {
|
||||||
|
return fmt.Errorf("stream '%s' %s: heartbeat interval must be positive: %d",
|
||||||
|
streamName, serverType, hb.IntervalSeconds)
|
||||||
|
}
|
||||||
|
if hb.Format != "json" && hb.Format != "comment" {
|
||||||
|
return fmt.Errorf("stream '%s' %s: heartbeat format must be 'json' or 'comment': %s",
|
||||||
|
streamName, serverType, hb.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSSL(serverType, streamName string, ssl *SSLConfig) error {
|
||||||
|
if ssl != nil && ssl.Enabled {
|
||||||
|
if ssl.CertFile == "" || ssl.KeyFile == "" {
|
||||||
|
return fmt.Errorf("stream '%s' %s: SSL enabled but cert/key files not specified",
|
||||||
|
streamName, serverType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssl.ClientAuth && ssl.ClientCAFile == "" {
|
||||||
|
return fmt.Errorf("stream '%s' %s: client auth enabled but CA file not specified",
|
||||||
|
streamName, serverType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate TLS versions
|
||||||
|
validVersions := map[string]bool{"TLS1.0": true, "TLS1.1": true, "TLS1.2": true, "TLS1.3": true}
|
||||||
|
if ssl.MinVersion != "" && !validVersions[ssl.MinVersion] {
|
||||||
|
return fmt.Errorf("stream '%s' %s: invalid min TLS version: %s",
|
||||||
|
streamName, serverType, ssl.MinVersion)
|
||||||
|
}
|
||||||
|
if ssl.MaxVersion != "" && !validVersions[ssl.MaxVersion] {
|
||||||
|
return fmt.Errorf("stream '%s' %s: invalid max TLS version: %s",
|
||||||
|
streamName, serverType, ssl.MaxVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAuth(streamName string, auth *AuthConfig) error {
|
||||||
|
if auth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validTypes := map[string]bool{"none": true, "basic": true, "bearer": true, "mtls": true}
|
||||||
|
if !validTypes[auth.Type] {
|
||||||
|
return fmt.Errorf("stream '%s': invalid auth type: %s", streamName, auth.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Type == "basic" && auth.BasicAuth == nil {
|
||||||
|
return fmt.Errorf("stream '%s': basic auth type specified but config missing", streamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Type == "bearer" && auth.BearerAuth == nil {
|
||||||
|
return fmt.Errorf("stream '%s': bearer auth type specified but config missing", streamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
107
src/internal/logstream/httprouter.go
Normal file
107
src/internal/logstream/httprouter.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// FILE: src/internal/logstream/httprouter.go
|
||||||
|
package logstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPRouter struct {
|
||||||
|
service *Service
|
||||||
|
servers map[int]*routerServer // port -> server
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPRouter(service *Service) *HTTPRouter {
|
||||||
|
return &HTTPRouter{
|
||||||
|
service: service,
|
||||||
|
servers: make(map[int]*routerServer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPRouter) RegisterStream(stream *LogStream) error {
|
||||||
|
if stream.HTTPServer == nil || stream.Config.HTTPServer == nil {
|
||||||
|
return nil // No HTTP server configured
|
||||||
|
}
|
||||||
|
|
||||||
|
port := stream.Config.HTTPServer.Port
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
rs, exists := r.servers[port]
|
||||||
|
if !exists {
|
||||||
|
// Create new server for this port
|
||||||
|
rs = &routerServer{
|
||||||
|
port: port,
|
||||||
|
routes: make(map[string]*LogStream),
|
||||||
|
}
|
||||||
|
rs.server = &fasthttp.Server{
|
||||||
|
Handler: rs.requestHandler,
|
||||||
|
DisableKeepalive: false,
|
||||||
|
StreamRequestBody: true,
|
||||||
|
}
|
||||||
|
r.servers[port] = rs
|
||||||
|
|
||||||
|
// Start server in background
|
||||||
|
go func() {
|
||||||
|
addr := fmt.Sprintf(":%d", port)
|
||||||
|
if err := rs.server.ListenAndServe(addr); err != nil {
|
||||||
|
// Log error but don't crash
|
||||||
|
fmt.Printf("Router server on port %d failed: %v\n", port, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Register routes for this stream
|
||||||
|
rs.routeMu.Lock()
|
||||||
|
defer rs.routeMu.Unlock()
|
||||||
|
|
||||||
|
// Use stream name as path prefix
|
||||||
|
pathPrefix := "/" + stream.Name
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
for existingPath, existingStream := range rs.routes {
|
||||||
|
if strings.HasPrefix(pathPrefix, existingPath) || strings.HasPrefix(existingPath, pathPrefix) {
|
||||||
|
return fmt.Errorf("path conflict: '%s' conflicts with existing stream '%s' at '%s'",
|
||||||
|
pathPrefix, existingStream.Name, existingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rs.routes[pathPrefix] = stream
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPRouter) UnregisterStream(streamName string) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, rs := range r.servers {
|
||||||
|
rs.routeMu.Lock()
|
||||||
|
for path, stream := range rs.routes {
|
||||||
|
if stream.Name == streamName {
|
||||||
|
delete(rs.routes, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs.routeMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *HTTPRouter) Shutdown() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for port, rs := range r.servers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p int, s *routerServer) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := s.server.Shutdown(); err != nil {
|
||||||
|
fmt.Printf("Error shutting down router server on port %d: %v\n", p, err)
|
||||||
|
}
|
||||||
|
}(port, rs)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
124
src/internal/logstream/logstream.go
Normal file
124
src/internal/logstream/logstream.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// FILE: src/internal/logstream/logstream.go
|
||||||
|
package logstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"logwisp/src/internal/config"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ls *LogStream) Shutdown() {
|
||||||
|
// Stop servers first
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
if ls.TCPServer != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ls.TCPServer.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ls.HTTPServer != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ls.HTTPServer.Stop()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel context
|
||||||
|
ls.cancel()
|
||||||
|
|
||||||
|
// Wait for servers
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Stop monitor
|
||||||
|
ls.Monitor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ls *LogStream) GetStats() map[string]interface{} {
|
||||||
|
monStats := ls.Monitor.GetStats()
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"name": ls.Name,
|
||||||
|
"uptime_seconds": int(time.Since(ls.Stats.StartTime).Seconds()),
|
||||||
|
"monitor": monStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ls.TCPServer != nil {
|
||||||
|
currentConnections := ls.TCPServer.GetActiveConnections()
|
||||||
|
|
||||||
|
stats["tcp"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"port": ls.Config.TCPServer.Port,
|
||||||
|
"connections": currentConnections, // Use current value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ls.HTTPServer != nil {
|
||||||
|
currentConnections := ls.HTTPServer.GetActiveConnections()
|
||||||
|
|
||||||
|
stats["http"] = map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"port": ls.Config.HTTPServer.Port,
|
||||||
|
"connections": currentConnections, // Use current value
|
||||||
|
"stream_path": ls.Config.HTTPServer.StreamPath,
|
||||||
|
"status_path": ls.Config.HTTPServer.StatusPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ls *LogStream) UpdateTargets(targets []config.MonitorTarget) error {
|
||||||
|
// Clear existing targets
|
||||||
|
for _, watcher := range ls.Monitor.GetActiveWatchers() {
|
||||||
|
ls.Monitor.RemoveTarget(watcher.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new targets
|
||||||
|
for _, target := range targets {
|
||||||
|
if err := ls.Monitor.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ls *LogStream) startStatsUpdater(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Update cached values
|
||||||
|
if ls.TCPServer != nil {
|
||||||
|
oldTCP := ls.Stats.TCPConnections
|
||||||
|
ls.Stats.TCPConnections = ls.TCPServer.GetActiveConnections()
|
||||||
|
if oldTCP != ls.Stats.TCPConnections {
|
||||||
|
// This debug should now show changes
|
||||||
|
fmt.Printf("[STATS DEBUG] %s TCP: %d -> %d\n",
|
||||||
|
ls.Name, oldTCP, ls.Stats.TCPConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ls.HTTPServer != nil {
|
||||||
|
oldHTTP := ls.Stats.HTTPConnections
|
||||||
|
ls.Stats.HTTPConnections = ls.HTTPServer.GetActiveConnections()
|
||||||
|
if oldHTTP != ls.Stats.HTTPConnections {
|
||||||
|
// This debug should now show changes
|
||||||
|
fmt.Printf("[STATS DEBUG] %s HTTP: %d -> %d\n",
|
||||||
|
ls.Name, oldHTTP, ls.Stats.HTTPConnections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
118
src/internal/logstream/routerserver.go
Normal file
118
src/internal/logstream/routerserver.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// FILE: src/internal/config/routerserver.go
|
||||||
|
package logstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type routerServer struct {
|
||||||
|
port int
|
||||||
|
server *fasthttp.Server
|
||||||
|
routes map[string]*LogStream // path prefix -> stream
|
||||||
|
routeMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *routerServer) requestHandler(ctx *fasthttp.RequestCtx) {
|
||||||
|
path := string(ctx.Path())
|
||||||
|
|
||||||
|
// Special case: global status at /status
|
||||||
|
if path == "/status" {
|
||||||
|
rs.handleGlobalStatus(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching stream
|
||||||
|
rs.routeMu.RLock()
|
||||||
|
var matchedStream *LogStream
|
||||||
|
var matchedPrefix string
|
||||||
|
var remainingPath string
|
||||||
|
|
||||||
|
for prefix, stream := range rs.routes {
|
||||||
|
if strings.HasPrefix(path, prefix) {
|
||||||
|
// Use longest prefix match
|
||||||
|
if len(prefix) > len(matchedPrefix) {
|
||||||
|
matchedPrefix = prefix
|
||||||
|
matchedStream = stream
|
||||||
|
remainingPath = strings.TrimPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs.routeMu.RUnlock()
|
||||||
|
|
||||||
|
if matchedStream == nil {
|
||||||
|
rs.handleNotFound(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to stream's handler
|
||||||
|
if matchedStream.HTTPServer != nil {
|
||||||
|
// Rewrite path to remove stream prefix
|
||||||
|
ctx.URI().SetPath(remainingPath)
|
||||||
|
matchedStream.HTTPServer.RouteRequest(ctx)
|
||||||
|
} else {
|
||||||
|
ctx.SetStatusCode(fasthttp.StatusServiceUnavailable)
|
||||||
|
ctx.SetContentType("application/json")
|
||||||
|
json.NewEncoder(ctx).Encode(map[string]string{
|
||||||
|
"error": "Stream HTTP server not available",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *routerServer) handleGlobalStatus(ctx *fasthttp.RequestCtx) {
|
||||||
|
ctx.SetContentType("application/json")
|
||||||
|
|
||||||
|
rs.routeMu.RLock()
|
||||||
|
streams := make(map[string]interface{})
|
||||||
|
for prefix, stream := range rs.routes {
|
||||||
|
streams[stream.Name] = map[string]interface{}{
|
||||||
|
"path_prefix": prefix,
|
||||||
|
"config": map[string]interface{}{
|
||||||
|
"stream_path": stream.Config.HTTPServer.StreamPath,
|
||||||
|
"status_path": stream.Config.HTTPServer.StatusPath,
|
||||||
|
},
|
||||||
|
"stats": stream.GetStats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs.routeMu.RUnlock()
|
||||||
|
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"service": "LogWisp Router",
|
||||||
|
"port": rs.port,
|
||||||
|
"streams": streams,
|
||||||
|
"total_streams": len(streams),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(status)
|
||||||
|
ctx.SetBody(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *routerServer) handleNotFound(ctx *fasthttp.RequestCtx) {
|
||||||
|
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||||
|
ctx.SetContentType("application/json")
|
||||||
|
|
||||||
|
rs.routeMu.RLock()
|
||||||
|
availableRoutes := make([]string, 0, len(rs.routes)*2+1)
|
||||||
|
availableRoutes = append(availableRoutes, "/status (global status)")
|
||||||
|
|
||||||
|
for prefix, stream := range rs.routes {
|
||||||
|
if stream.Config.HTTPServer != nil {
|
||||||
|
availableRoutes = append(availableRoutes,
|
||||||
|
fmt.Sprintf("%s%s (stream: %s)", prefix, stream.Config.HTTPServer.StreamPath, stream.Name),
|
||||||
|
fmt.Sprintf("%s%s (status: %s)", prefix, stream.Config.HTTPServer.StatusPath, stream.Name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs.routeMu.RUnlock()
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"error": "Not Found",
|
||||||
|
"available_routes": availableRoutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(response)
|
||||||
|
ctx.SetBody(data)
|
||||||
|
}
|
||||||
235
src/internal/logstream/service.go
Normal file
235
src/internal/logstream/service.go
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// FILE: src/internal/logstream/service.go
|
||||||
|
package logstream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"logwisp/src/internal/config"
|
||||||
|
"logwisp/src/internal/monitor"
|
||||||
|
"logwisp/src/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
streams map[string]*LogStream
|
||||||
|
mu sync.RWMutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogStream struct {
|
||||||
|
Name string
|
||||||
|
Config config.StreamConfig
|
||||||
|
Monitor monitor.Monitor
|
||||||
|
TCPServer *stream.TCPStreamer
|
||||||
|
HTTPServer *stream.HTTPStreamer
|
||||||
|
Stats *StreamStats
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamStats struct {
|
||||||
|
StartTime time.Time
|
||||||
|
MonitorStats monitor.Stats
|
||||||
|
TCPConnections int32
|
||||||
|
HTTPConnections int32
|
||||||
|
TotalBytesServed uint64
|
||||||
|
TotalEntriesServed uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ctx context.Context) *Service {
|
||||||
|
serviceCtx, cancel := context.WithCancel(ctx)
|
||||||
|
return &Service{
|
||||||
|
streams: make(map[string]*LogStream),
|
||||||
|
ctx: serviceCtx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateStream(cfg config.StreamConfig) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.streams[cfg.Name]; exists {
|
||||||
|
return fmt.Errorf("stream '%s' already exists", cfg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stream context
|
||||||
|
streamCtx, streamCancel := context.WithCancel(s.ctx)
|
||||||
|
|
||||||
|
// Create monitor
|
||||||
|
mon := monitor.New()
|
||||||
|
mon.SetCheckInterval(time.Duration(cfg.GetCheckInterval(100)) * time.Millisecond)
|
||||||
|
|
||||||
|
// Add targets
|
||||||
|
for _, target := range cfg.GetTargets(nil) {
|
||||||
|
if err := mon.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
||||||
|
streamCancel()
|
||||||
|
return fmt.Errorf("failed to add target %s: %w", target.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start monitor
|
||||||
|
if err := mon.Start(streamCtx); err != nil {
|
||||||
|
streamCancel()
|
||||||
|
return fmt.Errorf("failed to start monitor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create log stream
|
||||||
|
ls := &LogStream{
|
||||||
|
Name: cfg.Name,
|
||||||
|
Config: cfg,
|
||||||
|
Monitor: mon,
|
||||||
|
Stats: &StreamStats{
|
||||||
|
StartTime: time.Now(),
|
||||||
|
},
|
||||||
|
ctx: streamCtx,
|
||||||
|
cancel: streamCancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start TCP server if configured
|
||||||
|
if cfg.TCPServer != nil && cfg.TCPServer.Enabled {
|
||||||
|
tcpChan := mon.Subscribe()
|
||||||
|
ls.TCPServer = stream.NewTCPStreamer(tcpChan, *cfg.TCPServer)
|
||||||
|
|
||||||
|
if err := s.startTCPServer(ls); err != nil {
|
||||||
|
ls.Shutdown()
|
||||||
|
return fmt.Errorf("TCP server failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP server if configured
|
||||||
|
if cfg.HTTPServer != nil && cfg.HTTPServer.Enabled {
|
||||||
|
httpChan := mon.Subscribe()
|
||||||
|
ls.HTTPServer = stream.NewHTTPStreamer(httpChan, *cfg.HTTPServer)
|
||||||
|
|
||||||
|
if err := s.startHTTPServer(ls); err != nil {
|
||||||
|
ls.Shutdown()
|
||||||
|
return fmt.Errorf("HTTP server failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ls.startStatsUpdater(streamCtx)
|
||||||
|
|
||||||
|
s.streams[cfg.Name] = ls
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetStream(name string) (*LogStream, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
stream, exists := s.streams[name]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("stream '%s' not found", name)
|
||||||
|
}
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListStreams() []string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
names := make([]string, 0, len(s.streams))
|
||||||
|
for name := range s.streams {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RemoveStream(name string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
stream, exists := s.streams[name]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("stream '%s' not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.Shutdown()
|
||||||
|
delete(s.streams, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
s.mu.Lock()
|
||||||
|
streams := make([]*LogStream, 0, len(s.streams))
|
||||||
|
for _, stream := range s.streams {
|
||||||
|
streams = append(streams, stream)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop all streams concurrently
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, stream := range streams {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(ls *LogStream) {
|
||||||
|
defer wg.Done()
|
||||||
|
ls.Shutdown()
|
||||||
|
}(stream)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
s.cancel()
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetGlobalStats() map[string]interface{} {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"streams": make(map[string]interface{}),
|
||||||
|
"total_streams": len(s.streams),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, stream := range s.streams {
|
||||||
|
stats["streams"].(map[string]interface{})[name] = stream.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startTCPServer(ls *LogStream) error {
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
s.wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
if err := ls.TCPServer.Start(); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check startup
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
return err
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startHTTPServer(ls *LogStream) error {
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
s.wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
if err := ls.HTTPServer.Start(); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check startup
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
return err
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// FILE: src/internal/monitor/file_watcher.go
|
||||||
package monitor
|
package monitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -11,6 +12,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -25,18 +27,23 @@ type fileWatcher struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopped bool
|
stopped bool
|
||||||
rotationSeq int
|
rotationSeq int
|
||||||
|
entriesRead atomic.Uint64
|
||||||
|
lastReadTime atomic.Value // time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFileWatcher(path string, callback func(LogEntry)) *fileWatcher {
|
func newFileWatcher(path string, callback func(LogEntry)) *fileWatcher {
|
||||||
return &fileWatcher{
|
w := &fileWatcher{
|
||||||
path: path,
|
path: path,
|
||||||
callback: callback,
|
callback: callback,
|
||||||
|
position: -1,
|
||||||
}
|
}
|
||||||
|
w.lastReadTime.Store(time.Time{})
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *fileWatcher) watch(ctx context.Context) {
|
func (w *fileWatcher) watch(ctx context.Context) error {
|
||||||
if err := w.seekToEnd(); err != nil {
|
if err := w.seekToEnd(); err != nil {
|
||||||
return
|
return fmt.Errorf("seekToEnd failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(100 * time.Millisecond)
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
@ -45,12 +52,15 @@ func (w *fileWatcher) watch(ctx context.Context) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return ctx.Err()
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if w.isStopped() {
|
if w.isStopped() {
|
||||||
return
|
return fmt.Errorf("watcher stopped")
|
||||||
|
}
|
||||||
|
if err := w.checkFile(); err != nil {
|
||||||
|
// Log error but continue watching
|
||||||
|
fmt.Printf("[WARN] checkFile error for %s: %v\n", w.path, err)
|
||||||
}
|
}
|
||||||
w.checkFile()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -58,6 +68,17 @@ func (w *fileWatcher) watch(ctx context.Context) {
|
|||||||
func (w *fileWatcher) seekToEnd() error {
|
func (w *fileWatcher) seekToEnd() error {
|
||||||
file, err := os.Open(w.path)
|
file, err := os.Open(w.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// For non-existent files, initialize position to 0
|
||||||
|
// This allows watching files that don't exist yet
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.position = 0
|
||||||
|
w.size = 0
|
||||||
|
w.modTime = time.Now()
|
||||||
|
w.inode = 0
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@ -67,16 +88,21 @@ func (w *fileWatcher) seekToEnd() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
// Only seek to end if position was never set (-1)
|
||||||
|
// This preserves position = 0 for new files while allowing
|
||||||
|
// directory-discovered files to start reading from current position
|
||||||
|
if w.position == -1 {
|
||||||
pos, err := file.Seek(0, io.SeekEnd)
|
pos, err := file.Seek(0, io.SeekEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
w.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.mu.Lock()
|
|
||||||
w.position = pos
|
w.position = pos
|
||||||
|
}
|
||||||
|
|
||||||
w.size = info.Size()
|
w.size = info.Size()
|
||||||
w.modTime = info.ModTime()
|
w.modTime = info.ModTime()
|
||||||
|
|
||||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
w.inode = stat.Ino
|
w.inode = stat.Ino
|
||||||
}
|
}
|
||||||
@ -88,6 +114,10 @@ func (w *fileWatcher) seekToEnd() error {
|
|||||||
func (w *fileWatcher) checkFile() error {
|
func (w *fileWatcher) checkFile() error {
|
||||||
file, err := os.Open(w.path)
|
file, err := os.Open(w.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// File doesn't exist yet, keep watching
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@ -112,36 +142,49 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
currentInode = stat.Ino
|
currentInode = stat.Ino
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle first time seeing a file that didn't exist before
|
||||||
|
if oldInode == 0 && currentInode != 0 {
|
||||||
|
// File just appeared, don't treat as rotation
|
||||||
|
w.mu.Lock()
|
||||||
|
w.inode = currentInode
|
||||||
|
w.size = currentSize
|
||||||
|
w.modTime = currentModTime
|
||||||
|
// Keep position at 0 to read from beginning if this is a new file
|
||||||
|
// or seek to end if we want to skip existing content
|
||||||
|
if oldSize == 0 && w.position == 0 {
|
||||||
|
// First time seeing this file, seek to end to skip existing content
|
||||||
|
w.position = currentSize
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rotation
|
||||||
rotated := false
|
rotated := false
|
||||||
rotationReason := ""
|
rotationReason := ""
|
||||||
|
|
||||||
if oldInode != 0 && currentInode != 0 && currentInode != oldInode {
|
if oldInode != 0 && currentInode != 0 && currentInode != oldInode {
|
||||||
rotated = true
|
rotated = true
|
||||||
rotationReason = "inode change"
|
rotationReason = "inode change"
|
||||||
}
|
} else if currentSize < oldSize {
|
||||||
|
|
||||||
if !rotated && currentSize < oldSize {
|
|
||||||
rotated = true
|
rotated = true
|
||||||
rotationReason = "size decrease"
|
rotationReason = "size decrease"
|
||||||
}
|
} else if currentModTime.Before(oldModTime) && currentSize <= oldSize {
|
||||||
|
|
||||||
if !rotated && currentModTime.Before(oldModTime) && currentSize <= oldSize {
|
|
||||||
rotated = true
|
rotated = true
|
||||||
rotationReason = "modification time reset"
|
rotationReason = "modification time reset"
|
||||||
}
|
} else if oldPos > currentSize+1024 {
|
||||||
|
|
||||||
if !rotated && oldPos > currentSize+1024 {
|
|
||||||
rotated = true
|
rotated = true
|
||||||
rotationReason = "position beyond file size"
|
rotationReason = "position beyond file size"
|
||||||
}
|
}
|
||||||
|
|
||||||
newPos := oldPos
|
startPos := oldPos
|
||||||
if rotated {
|
if rotated {
|
||||||
newPos = 0
|
startPos = 0
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
w.rotationSeq++
|
w.rotationSeq++
|
||||||
seq := w.rotationSeq
|
seq := w.rotationSeq
|
||||||
w.inode = currentInode
|
w.inode = currentInode
|
||||||
|
w.position = 0 // Reset position on rotation
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
|
|
||||||
w.callback(LogEntry{
|
w.callback(LogEntry{
|
||||||
@ -152,7 +195,9 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := file.Seek(newPos, io.SeekStart); err != nil {
|
// Only read if there's new content
|
||||||
|
if currentSize > startPos {
|
||||||
|
if _, err := file.Seek(startPos, io.SeekStart); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,19 +212,37 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
|
|
||||||
entry := w.parseLine(line)
|
entry := w.parseLine(line)
|
||||||
w.callback(entry)
|
w.callback(entry)
|
||||||
|
w.entriesRead.Add(1)
|
||||||
|
w.lastReadTime.Store(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update position after successful read
|
||||||
if currentPos, err := file.Seek(0, io.SeekCurrent); err == nil {
|
if currentPos, err := file.Seek(0, io.SeekCurrent); err == nil {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
w.position = currentPos
|
w.position = currentPos
|
||||||
w.size = currentSize
|
w.size = currentSize
|
||||||
w.modTime = currentModTime
|
w.modTime = currentModTime
|
||||||
|
if !rotated && currentInode != 0 {
|
||||||
|
w.inode = currentInode
|
||||||
|
}
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanner.Err()
|
return scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update metadata even if no new content
|
||||||
|
w.mu.Lock()
|
||||||
|
w.size = currentSize
|
||||||
|
w.modTime = currentModTime
|
||||||
|
if currentInode != 0 {
|
||||||
|
w.inode = currentInode
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *fileWatcher) parseLine(line string) LogEntry {
|
func (w *fileWatcher) parseLine(line string) LogEntry {
|
||||||
var jsonLog struct {
|
var jsonLog struct {
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
@ -244,6 +307,25 @@ func globToRegex(glob string) string {
|
|||||||
return "^" + regex + "$"
|
return "^" + regex + "$"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *fileWatcher) getInfo() WatcherInfo {
|
||||||
|
w.mu.Lock()
|
||||||
|
info := WatcherInfo{
|
||||||
|
Path: w.path,
|
||||||
|
Size: w.size,
|
||||||
|
Position: w.position,
|
||||||
|
ModTime: w.modTime,
|
||||||
|
EntriesRead: w.entriesRead.Load(),
|
||||||
|
Rotations: w.rotationSeq,
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if lastRead, ok := w.lastReadTime.Load().(time.Time); ok {
|
||||||
|
info.LastReadTime = lastRead
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
func (w *fileWatcher) close() {
|
func (w *fileWatcher) close() {
|
||||||
w.stop()
|
w.stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -20,7 +21,36 @@ type LogEntry struct {
|
|||||||
Fields json.RawMessage `json:"fields,omitempty"`
|
Fields json.RawMessage `json:"fields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Monitor struct {
|
type Monitor interface {
|
||||||
|
Start(ctx context.Context) error
|
||||||
|
Stop()
|
||||||
|
Subscribe() chan LogEntry
|
||||||
|
AddTarget(path, pattern string, isFile bool) error
|
||||||
|
RemoveTarget(path string) error
|
||||||
|
SetCheckInterval(interval time.Duration)
|
||||||
|
GetStats() Stats
|
||||||
|
GetActiveWatchers() []WatcherInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
ActiveWatchers int
|
||||||
|
TotalEntries uint64
|
||||||
|
DroppedEntries uint64
|
||||||
|
StartTime time.Time
|
||||||
|
LastEntryTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatcherInfo struct {
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
Position int64
|
||||||
|
ModTime time.Time
|
||||||
|
EntriesRead uint64
|
||||||
|
LastReadTime time.Time
|
||||||
|
Rotations int
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitor struct {
|
||||||
subscribers []chan LogEntry
|
subscribers []chan LogEntry
|
||||||
targets []target
|
targets []target
|
||||||
watchers map[string]*fileWatcher
|
watchers map[string]*fileWatcher
|
||||||
@ -29,6 +59,10 @@ type Monitor struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
checkInterval time.Duration
|
checkInterval time.Duration
|
||||||
|
totalEntries atomic.Uint64
|
||||||
|
droppedEntries atomic.Uint64
|
||||||
|
startTime time.Time
|
||||||
|
lastEntryTime atomic.Value // time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type target struct {
|
type target struct {
|
||||||
@ -38,14 +72,17 @@ type target struct {
|
|||||||
regex *regexp.Regexp
|
regex *regexp.Regexp
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Monitor {
|
func New() Monitor {
|
||||||
return &Monitor{
|
m := &monitor{
|
||||||
watchers: make(map[string]*fileWatcher),
|
watchers: make(map[string]*fileWatcher),
|
||||||
checkInterval: 100 * time.Millisecond,
|
checkInterval: 100 * time.Millisecond,
|
||||||
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
m.lastEntryTime.Store(time.Time{})
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) Subscribe() chan LogEntry {
|
func (m *monitor) Subscribe() chan LogEntry {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@ -54,26 +91,29 @@ func (m *Monitor) Subscribe() chan LogEntry {
|
|||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) publish(entry LogEntry) {
|
func (m *monitor) publish(entry LogEntry) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
m.totalEntries.Add(1)
|
||||||
|
m.lastEntryTime.Store(entry.Time)
|
||||||
|
|
||||||
for _, ch := range m.subscribers {
|
for _, ch := range m.subscribers {
|
||||||
select {
|
select {
|
||||||
case ch <- entry:
|
case ch <- entry:
|
||||||
default:
|
default:
|
||||||
// Drop message if channel full
|
m.droppedEntries.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) SetCheckInterval(interval time.Duration) {
|
func (m *monitor) SetCheckInterval(interval time.Duration) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.checkInterval = interval
|
m.checkInterval = interval
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) AddTarget(path, pattern string, isFile bool) error {
|
func (m *monitor) AddTarget(path, pattern string, isFile bool) error {
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid path %s: %w", path, err)
|
return fmt.Errorf("invalid path %s: %w", path, err)
|
||||||
@ -100,14 +140,41 @@ func (m *Monitor) AddTarget(path, pattern string, isFile bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) Start(ctx context.Context) error {
|
func (m *monitor) RemoveTarget(path string) error {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove from targets
|
||||||
|
newTargets := make([]target, 0, len(m.targets))
|
||||||
|
for _, t := range m.targets {
|
||||||
|
if t.path != absPath {
|
||||||
|
newTargets = append(newTargets, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.targets = newTargets
|
||||||
|
|
||||||
|
// Stop any watchers for this path
|
||||||
|
if w, exists := m.watchers[absPath]; exists {
|
||||||
|
w.stop()
|
||||||
|
delete(m.watchers, absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *monitor) Start(ctx context.Context) error {
|
||||||
m.ctx, m.cancel = context.WithCancel(ctx)
|
m.ctx, m.cancel = context.WithCancel(ctx)
|
||||||
m.wg.Add(1)
|
m.wg.Add(1)
|
||||||
go m.monitorLoop()
|
go m.monitorLoop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) Stop() {
|
func (m *monitor) Stop() {
|
||||||
if m.cancel != nil {
|
if m.cancel != nil {
|
||||||
m.cancel()
|
m.cancel()
|
||||||
}
|
}
|
||||||
@ -123,7 +190,34 @@ func (m *Monitor) Stop() {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) monitorLoop() {
|
func (m *monitor) GetStats() Stats {
|
||||||
|
lastEntry, _ := m.lastEntryTime.Load().(time.Time)
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
watcherCount := len(m.watchers)
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
return Stats{
|
||||||
|
ActiveWatchers: watcherCount,
|
||||||
|
TotalEntries: m.totalEntries.Load(),
|
||||||
|
DroppedEntries: m.droppedEntries.Load(),
|
||||||
|
StartTime: m.startTime,
|
||||||
|
LastEntryTime: lastEntry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *monitor) GetActiveWatchers() []WatcherInfo {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
info := make([]WatcherInfo, 0, len(m.watchers))
|
||||||
|
for _, w := range m.watchers {
|
||||||
|
info = append(info, w.getInfo())
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *monitor) monitorLoop() {
|
||||||
defer m.wg.Done()
|
defer m.wg.Done()
|
||||||
|
|
||||||
m.checkTargets()
|
m.checkTargets()
|
||||||
@ -155,7 +249,7 @@ func (m *Monitor) monitorLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) checkTargets() {
|
func (m *monitor) checkTargets() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
targets := make([]target, len(m.targets))
|
targets := make([]target, len(m.targets))
|
||||||
copy(targets, m.targets)
|
copy(targets, m.targets)
|
||||||
@ -165,10 +259,13 @@ func (m *Monitor) checkTargets() {
|
|||||||
if t.isFile {
|
if t.isFile {
|
||||||
m.ensureWatcher(t.path)
|
m.ensureWatcher(t.path)
|
||||||
} else {
|
} else {
|
||||||
|
// Directory scanning for pattern matching
|
||||||
files, err := m.scanDirectory(t.path, t.regex)
|
files, err := m.scanDirectory(t.path, t.regex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[DEBUG] Error scanning directory %s: %v\n", t.path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
m.ensureWatcher(file)
|
m.ensureWatcher(file)
|
||||||
}
|
}
|
||||||
@ -178,7 +275,7 @@ func (m *Monitor) checkTargets() {
|
|||||||
m.cleanupWatchers()
|
m.cleanupWatchers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, error) {
|
func (m *monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -199,7 +296,7 @@ func (m *Monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, e
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) ensureWatcher(path string) {
|
func (m *monitor) ensureWatcher(path string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@ -207,17 +304,17 @@ func (m *Monitor) ensureWatcher(path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w := newFileWatcher(path, m.publish)
|
w := newFileWatcher(path, m.publish)
|
||||||
m.watchers[path] = w
|
m.watchers[path] = w
|
||||||
|
|
||||||
|
fmt.Printf("[DEBUG] Created watcher for: %s\n", path)
|
||||||
|
|
||||||
m.wg.Add(1)
|
m.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer m.wg.Done()
|
defer m.wg.Done()
|
||||||
w.watch(m.ctx)
|
if err := w.watch(m.ctx); err != nil {
|
||||||
|
fmt.Printf("[ERROR] Watcher for %s failed: %v\n", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
delete(m.watchers, path)
|
delete(m.watchers, path)
|
||||||
@ -225,7 +322,7 @@ func (m *Monitor) ensureWatcher(path string) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) cleanupWatchers() {
|
func (m *monitor) cleanupWatchers() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// FILE: src/internal/stream/http.go
|
// FILE: src/internal/stream/httpstreamer.go
|
||||||
package stream
|
package stream
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -25,23 +25,53 @@ type HTTPStreamer struct {
|
|||||||
startTime time.Time
|
startTime time.Time
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Path configuration
|
||||||
|
streamPath string
|
||||||
|
statusPath string
|
||||||
|
|
||||||
|
// For router integration
|
||||||
|
standalone bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPStreamer(logChan chan monitor.LogEntry, cfg config.HTTPConfig) *HTTPStreamer {
|
func NewHTTPStreamer(logChan chan monitor.LogEntry, cfg config.HTTPConfig) *HTTPStreamer {
|
||||||
|
// Set default paths if not configured
|
||||||
|
streamPath := cfg.StreamPath
|
||||||
|
if streamPath == "" {
|
||||||
|
streamPath = "/stream"
|
||||||
|
}
|
||||||
|
statusPath := cfg.StatusPath
|
||||||
|
if statusPath == "" {
|
||||||
|
statusPath = "/status"
|
||||||
|
}
|
||||||
|
|
||||||
return &HTTPStreamer{
|
return &HTTPStreamer{
|
||||||
logChan: logChan,
|
logChan: logChan,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
streamPath: streamPath,
|
||||||
|
statusPath: statusPath,
|
||||||
|
standalone: true, // Default to standalone mode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRouterMode configures the streamer for use with a router
|
||||||
|
func (h *HTTPStreamer) SetRouterMode() {
|
||||||
|
h.standalone = false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPStreamer) Start() error {
|
func (h *HTTPStreamer) Start() error {
|
||||||
|
if !h.standalone {
|
||||||
|
// In router mode, don't start our own server
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
h.server = &fasthttp.Server{
|
h.server = &fasthttp.Server{
|
||||||
Handler: h.requestHandler,
|
Handler: h.requestHandler,
|
||||||
DisableKeepalive: false,
|
DisableKeepalive: false,
|
||||||
StreamRequestBody: true,
|
StreamRequestBody: true,
|
||||||
Logger: nil, // Suppress fasthttp logs
|
Logger: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", h.config.Port)
|
addr := fmt.Sprintf(":%d", h.config.Port)
|
||||||
@ -69,13 +99,10 @@ func (h *HTTPStreamer) Stop() {
|
|||||||
// Signal all client handlers to stop
|
// Signal all client handlers to stop
|
||||||
close(h.done)
|
close(h.done)
|
||||||
|
|
||||||
// Shutdown HTTP server
|
// Shutdown HTTP server if in standalone mode
|
||||||
if h.server != nil {
|
if h.standalone && h.server != nil {
|
||||||
// Create context with timeout for server shutdown
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Use ShutdownWithContext for graceful shutdown
|
|
||||||
h.server.ShutdownWithContext(ctx)
|
h.server.ShutdownWithContext(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,16 +110,26 @@ func (h *HTTPStreamer) Stop() {
|
|||||||
h.wg.Wait()
|
h.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HTTPStreamer) RouteRequest(ctx *fasthttp.RequestCtx) {
|
||||||
|
h.requestHandler(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPStreamer) requestHandler(ctx *fasthttp.RequestCtx) {
|
func (h *HTTPStreamer) requestHandler(ctx *fasthttp.RequestCtx) {
|
||||||
path := string(ctx.Path())
|
path := string(ctx.Path())
|
||||||
|
|
||||||
switch path {
|
switch path {
|
||||||
case "/stream":
|
case h.streamPath:
|
||||||
h.handleStream(ctx)
|
h.handleStream(ctx)
|
||||||
case "/status":
|
case h.statusPath:
|
||||||
h.handleStatus(ctx)
|
h.handleStatus(ctx)
|
||||||
default:
|
default:
|
||||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||||
|
ctx.SetContentType("application/json")
|
||||||
|
json.NewEncoder(ctx).Encode(map[string]interface{}{
|
||||||
|
"error": "Not Found",
|
||||||
|
"message": fmt.Sprintf("Available endpoints: %s (SSE stream), %s (status)",
|
||||||
|
h.streamPath, h.statusPath),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,13 +141,6 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
|||||||
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
||||||
ctx.Response.Header.Set("X-Accel-Buffering", "no")
|
ctx.Response.Header.Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
h.activeClients.Add(1)
|
|
||||||
h.wg.Add(1) // Track this client handler
|
|
||||||
defer func() {
|
|
||||||
h.activeClients.Add(-1)
|
|
||||||
h.wg.Done() // Mark handler as done
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create subscription for this client
|
// Create subscription for this client
|
||||||
clientChan := make(chan monitor.LogEntry, h.config.BufferSize)
|
clientChan := make(chan monitor.LogEntry, h.config.BufferSize)
|
||||||
clientDone := make(chan struct{})
|
clientDone := make(chan struct{})
|
||||||
@ -128,14 +158,14 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
|||||||
case clientChan <- entry:
|
case clientChan <- entry:
|
||||||
case <-clientDone:
|
case <-clientDone:
|
||||||
return
|
return
|
||||||
case <-h.done: // Check for server shutdown
|
case <-h.done:
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// Drop if client buffer full
|
// Drop if client buffer full
|
||||||
}
|
}
|
||||||
case <-clientDone:
|
case <-clientDone:
|
||||||
return
|
return
|
||||||
case <-h.done: // Check for server shutdown
|
case <-h.done:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,11 +173,28 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
|||||||
|
|
||||||
// Define the stream writer function
|
// Define the stream writer function
|
||||||
streamFunc := func(w *bufio.Writer) {
|
streamFunc := func(w *bufio.Writer) {
|
||||||
defer close(clientDone)
|
newCount := h.activeClients.Add(1)
|
||||||
|
fmt.Printf("[HTTP DEBUG] Client connected on port %d. Count now: %d\n",
|
||||||
|
h.config.Port, newCount)
|
||||||
|
|
||||||
|
h.wg.Add(1)
|
||||||
|
defer func() {
|
||||||
|
newCount := h.activeClients.Add(-1)
|
||||||
|
fmt.Printf("[HTTP DEBUG] Client disconnected on port %d. Count now: %d\n",
|
||||||
|
h.config.Port, newCount)
|
||||||
|
h.wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
// Send initial connected event
|
// Send initial connected event
|
||||||
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
|
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
fmt.Fprintf(w, "event: connected\ndata: {\"client_id\":\"%s\"}\n\n", clientID)
|
connectionInfo := map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"stream_path": h.streamPath,
|
||||||
|
"status_path": h.statusPath,
|
||||||
|
"buffer_size": h.config.BufferSize,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(connectionInfo)
|
||||||
|
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", data)
|
||||||
w.Flush()
|
w.Flush()
|
||||||
|
|
||||||
var ticker *time.Ticker
|
var ticker *time.Ticker
|
||||||
@ -184,7 +231,7 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-h.done: // ADDED: Check for server shutdown
|
case <-h.done:
|
||||||
// Send final disconnect event
|
// Send final disconnect event
|
||||||
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
||||||
w.Flush()
|
w.Flush()
|
||||||
@ -240,13 +287,48 @@ func (h *HTTPStreamer) handleStatus(ctx *fasthttp.RequestCtx) {
|
|||||||
status := map[string]interface{}{
|
status := map[string]interface{}{
|
||||||
"service": "LogWisp",
|
"service": "LogWisp",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"http_server": map[string]interface{}{
|
"server": map[string]interface{}{
|
||||||
|
"type": "http",
|
||||||
"port": h.config.Port,
|
"port": h.config.Port,
|
||||||
"active_clients": h.activeClients.Load(),
|
"active_clients": h.activeClients.Load(),
|
||||||
"buffer_size": h.config.BufferSize,
|
"buffer_size": h.config.BufferSize,
|
||||||
|
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
||||||
|
"mode": map[string]bool{"standalone": h.standalone, "router": !h.standalone},
|
||||||
|
},
|
||||||
|
"endpoints": map[string]string{
|
||||||
|
"stream": h.streamPath,
|
||||||
|
"status": h.statusPath,
|
||||||
|
},
|
||||||
|
"features": map[string]interface{}{
|
||||||
|
"heartbeat": map[string]interface{}{
|
||||||
|
"enabled": h.config.Heartbeat.Enabled,
|
||||||
|
"interval": h.config.Heartbeat.IntervalSeconds,
|
||||||
|
"format": h.config.Heartbeat.Format,
|
||||||
|
},
|
||||||
|
"ssl": map[string]bool{
|
||||||
|
"enabled": h.config.SSL != nil && h.config.SSL.Enabled,
|
||||||
|
},
|
||||||
|
"rate_limit": map[string]bool{
|
||||||
|
"enabled": h.config.RateLimit != nil && h.config.RateLimit.Enabled,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := json.Marshal(status)
|
data, _ := json.Marshal(status)
|
||||||
ctx.SetBody(data)
|
ctx.SetBody(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActiveConnections returns the current number of active clients
|
||||||
|
func (h *HTTPStreamer) GetActiveConnections() int32 {
|
||||||
|
return h.activeClients.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamPath returns the configured stream endpoint path
|
||||||
|
func (h *HTTPStreamer) GetStreamPath() string {
|
||||||
|
return h.streamPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatusPath returns the configured status endpoint path
|
||||||
|
func (h *HTTPStreamer) GetStatusPath() string {
|
||||||
|
return h.statusPath
|
||||||
|
}
|
||||||
52
src/internal/stream/tcpserver.go
Normal file
52
src/internal/stream/tcpserver.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// FILE: src/internal/monitor/tcpserver.go
|
||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/panjf2000/gnet/v2"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tcpServer struct {
|
||||||
|
gnet.BuiltinEventEngine
|
||||||
|
streamer *TCPStreamer
|
||||||
|
connections sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
|
||||||
|
// Store engine reference for shutdown
|
||||||
|
s.streamer.engine = &eng
|
||||||
|
return gnet.None
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
|
||||||
|
s.connections.Store(c, struct{}{})
|
||||||
|
|
||||||
|
oldCount := s.streamer.activeConns.Load()
|
||||||
|
newCount := s.streamer.activeConns.Add(1)
|
||||||
|
fmt.Printf("[TCP ATOMIC] OnOpen: %d -> %d (expected: %d)\n", oldCount, newCount, oldCount+1)
|
||||||
|
|
||||||
|
fmt.Printf("[TCP DEBUG] Connection opened. Count now: %d\n", newCount)
|
||||||
|
return nil, gnet.None
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) OnClose(c gnet.Conn, err error) gnet.Action {
|
||||||
|
s.connections.Delete(c)
|
||||||
|
|
||||||
|
oldCount := s.streamer.activeConns.Load()
|
||||||
|
newCount := s.streamer.activeConns.Add(-1)
|
||||||
|
fmt.Printf("[TCP ATOMIC] OnClose: %d -> %d (expected: %d)\n", oldCount, newCount, oldCount-1)
|
||||||
|
|
||||||
|
fmt.Printf("[TCP DEBUG] Connection closed. Count now: %d (err: %v)\n", newCount, err)
|
||||||
|
return gnet.None
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
|
||||||
|
// We don't expect input from clients, just discard
|
||||||
|
c.Discard(-1)
|
||||||
|
return gnet.None
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TCPStreamer) GetActiveConnections() int32 {
|
||||||
|
return t.activeConns.Load()
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// FILE: src/internal/stream/tcp.go
|
// FILE: src/internal/stream/tcpstreamer.go
|
||||||
package stream
|
package stream
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -18,25 +18,19 @@ type TCPStreamer struct {
|
|||||||
logChan chan monitor.LogEntry
|
logChan chan monitor.LogEntry
|
||||||
config config.TCPConfig
|
config config.TCPConfig
|
||||||
server *tcpServer
|
server *tcpServer
|
||||||
|
done chan struct{}
|
||||||
activeConns atomic.Int32
|
activeConns atomic.Int32
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
done chan struct{}
|
|
||||||
engine *gnet.Engine
|
engine *gnet.Engine
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
type tcpServer struct {
|
|
||||||
gnet.BuiltinEventEngine
|
|
||||||
streamer *TCPStreamer
|
|
||||||
connections sync.Map
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTCPStreamer(logChan chan monitor.LogEntry, cfg config.TCPConfig) *TCPStreamer {
|
func NewTCPStreamer(logChan chan monitor.LogEntry, cfg config.TCPConfig) *TCPStreamer {
|
||||||
return &TCPStreamer{
|
return &TCPStreamer{
|
||||||
logChan: logChan,
|
logChan: logChan,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
startTime: time.Now(),
|
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,14 +44,14 @@ func (t *TCPStreamer) Start() error {
|
|||||||
t.broadcastLoop()
|
t.broadcastLoop()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Configure gnet with no-op logger
|
// Configure gnet
|
||||||
addr := fmt.Sprintf("tcp://:%d", t.config.Port)
|
addr := fmt.Sprintf("tcp://:%d", t.config.Port)
|
||||||
|
|
||||||
// Run gnet in separate goroutine to avoid blocking
|
// Run gnet in separate goroutine to avoid blocking
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
err := gnet.Run(t.server, addr,
|
err := gnet.Run(t.server, addr,
|
||||||
gnet.WithLogger(noopLogger{}), // No-op logger: discard everything
|
gnet.WithLogger(noopLogger{}),
|
||||||
gnet.WithMulticore(true),
|
gnet.WithMulticore(true),
|
||||||
gnet.WithReusePort(true),
|
gnet.WithReusePort(true),
|
||||||
)
|
)
|
||||||
@ -83,7 +77,6 @@ func (t *TCPStreamer) Stop() {
|
|||||||
|
|
||||||
// Stop gnet engine if running
|
// Stop gnet engine if running
|
||||||
if t.engine != nil {
|
if t.engine != nil {
|
||||||
// Use Stop() method to gracefully shutdown gnet
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
t.engine.Stop(ctx)
|
t.engine.Stop(ctx)
|
||||||
@ -107,7 +100,7 @@ func (t *TCPStreamer) broadcastLoop() {
|
|||||||
select {
|
select {
|
||||||
case entry, ok := <-t.logChan:
|
case entry, ok := <-t.logChan:
|
||||||
if !ok {
|
if !ok {
|
||||||
return // Channel closed
|
return
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(entry)
|
data, err := json.Marshal(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -156,26 +149,3 @@ func (t *TCPStreamer) formatHeartbeat() []byte {
|
|||||||
jsonData, _ := json.Marshal(data)
|
jsonData, _ := json.Marshal(data)
|
||||||
return append(jsonData, '\n')
|
return append(jsonData, '\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
|
|
||||||
s.streamer.engine = &eng
|
|
||||||
return gnet.None
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tcpServer) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
|
|
||||||
s.connections.Store(c, struct{}{})
|
|
||||||
s.streamer.activeConns.Add(1)
|
|
||||||
return nil, gnet.None
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tcpServer) OnClose(c gnet.Conn, err error) gnet.Action {
|
|
||||||
s.connections.Delete(c)
|
|
||||||
s.streamer.activeConns.Add(-1)
|
|
||||||
return gnet.None
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
|
|
||||||
// We don't expect input from clients, just discard
|
|
||||||
c.Discard(-1)
|
|
||||||
return gnet.None
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user