v0.1.5 multi-target support, package refactoring

This commit is contained in:
2025-07-07 13:08:22 -04:00
parent f80601a429
commit 069818bf3d
19 changed files with 2058 additions and 827 deletions

View 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"`
}

View File

@ -1,245 +1,14 @@
// FILE: src/internal/config/config.go
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
lconfig "github.com/lixenwraith/config"
)
type Config struct {
Monitor MonitorConfig `toml:"monitor"`
TCPServer TCPConfig `toml:"tcpserver"`
HTTPServer HTTPConfig `toml:"httpserver"`
// Global monitor settings
Monitor MonitorConfig `toml:"monitor"`
// Stream configurations
Streams []StreamConfig `toml:"streams"`
}
type MonitorConfig 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"`
}
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
CheckIntervalMs int `toml:"check_interval_ms"`
}

View 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"
}

View 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"
}

View 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"`
}

View 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
}

View 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
}

View 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()
}

View 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)
}
}
}
}
}()
}

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

View 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
}
}

View File

@ -1,3 +1,4 @@
// FILE: src/internal/monitor/file_watcher.go
package monitor
import (
@ -11,32 +12,38 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
type fileWatcher struct {
path string
callback func(LogEntry)
position int64
size int64
inode uint64
modTime time.Time
mu sync.Mutex
stopped bool
rotationSeq int
path string
callback func(LogEntry)
position int64
size int64
inode uint64
modTime time.Time
mu sync.Mutex
stopped bool
rotationSeq int
entriesRead atomic.Uint64
lastReadTime atomic.Value // time.Time
}
func newFileWatcher(path string, callback func(LogEntry)) *fileWatcher {
return &fileWatcher{
w := &fileWatcher{
path: path,
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 {
return
return fmt.Errorf("seekToEnd failed: %w", err)
}
ticker := time.NewTicker(100 * time.Millisecond)
@ -45,12 +52,15 @@ func (w *fileWatcher) watch(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
return ctx.Err()
case <-ticker.C:
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 {
file, err := os.Open(w.path)
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
}
defer file.Close()
@ -67,16 +88,21 @@ func (w *fileWatcher) seekToEnd() error {
return err
}
pos, err := file.Seek(0, io.SeekEnd)
if err != nil {
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)
if err != nil {
w.mu.Unlock()
return err
}
w.position = pos
}
w.mu.Lock()
w.position = pos
w.size = info.Size()
w.modTime = info.ModTime()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
w.inode = stat.Ino
}
@ -88,6 +114,10 @@ func (w *fileWatcher) seekToEnd() error {
func (w *fileWatcher) checkFile() error {
file, err := os.Open(w.path)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist yet, keep watching
return nil
}
return err
}
defer file.Close()
@ -112,36 +142,49 @@ func (w *fileWatcher) checkFile() error {
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
rotationReason := ""
if oldInode != 0 && currentInode != 0 && currentInode != oldInode {
rotated = true
rotationReason = "inode change"
}
if !rotated && currentSize < oldSize {
} else if currentSize < oldSize {
rotated = true
rotationReason = "size decrease"
}
if !rotated && currentModTime.Before(oldModTime) && currentSize <= oldSize {
} else if currentModTime.Before(oldModTime) && currentSize <= oldSize {
rotated = true
rotationReason = "modification time reset"
}
if !rotated && oldPos > currentSize+1024 {
} else if oldPos > currentSize+1024 {
rotated = true
rotationReason = "position beyond file size"
}
newPos := oldPos
startPos := oldPos
if rotated {
newPos = 0
startPos = 0
w.mu.Lock()
w.rotationSeq++
seq := w.rotationSeq
w.inode = currentInode
w.position = 0 // Reset position on rotation
w.mu.Unlock()
w.callback(LogEntry{
@ -152,32 +195,52 @@ func (w *fileWatcher) checkFile() error {
})
}
if _, err := file.Seek(newPos, io.SeekStart); err != nil {
return err
}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
// Only read if there's new content
if currentSize > startPos {
if _, err := file.Seek(startPos, io.SeekStart); err != nil {
return err
}
entry := w.parseLine(line)
w.callback(entry)
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
entry := w.parseLine(line)
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 {
w.mu.Lock()
w.position = currentPos
w.size = currentSize
w.modTime = currentModTime
if !rotated && currentInode != 0 {
w.inode = currentInode
}
w.mu.Unlock()
}
return scanner.Err()
}
if currentPos, err := file.Seek(0, io.SeekCurrent); err == nil {
w.mu.Lock()
w.position = currentPos
w.size = currentSize
w.modTime = currentModTime
w.mu.Unlock()
// 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 scanner.Err()
return nil
}
func (w *fileWatcher) parseLine(line string) LogEntry {
@ -244,6 +307,25 @@ func globToRegex(glob string) string {
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() {
w.stop()
}

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"sync"
"sync/atomic"
"time"
)
@ -20,15 +21,48 @@ type LogEntry struct {
Fields json.RawMessage `json:"fields,omitempty"`
}
type Monitor struct {
subscribers []chan LogEntry
targets []target
watchers map[string]*fileWatcher
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
checkInterval time.Duration
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
targets []target
watchers map[string]*fileWatcher
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
checkInterval time.Duration
totalEntries atomic.Uint64
droppedEntries atomic.Uint64
startTime time.Time
lastEntryTime atomic.Value // time.Time
}
type target struct {
@ -38,14 +72,17 @@ type target struct {
regex *regexp.Regexp
}
func New() *Monitor {
return &Monitor{
func New() Monitor {
m := &monitor{
watchers: make(map[string]*fileWatcher),
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()
defer m.mu.Unlock()
@ -54,26 +91,29 @@ func (m *Monitor) Subscribe() chan LogEntry {
return ch
}
func (m *Monitor) publish(entry LogEntry) {
func (m *monitor) publish(entry LogEntry) {
m.mu.RLock()
defer m.mu.RUnlock()
m.totalEntries.Add(1)
m.lastEntryTime.Store(entry.Time)
for _, ch := range m.subscribers {
select {
case ch <- entry:
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.checkInterval = interval
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)
if err != nil {
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
}
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.wg.Add(1)
go m.monitorLoop()
return nil
}
func (m *Monitor) Stop() {
func (m *monitor) Stop() {
if m.cancel != nil {
m.cancel()
}
@ -123,7 +190,34 @@ func (m *Monitor) Stop() {
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()
m.checkTargets()
@ -155,7 +249,7 @@ func (m *Monitor) monitorLoop() {
}
}
func (m *Monitor) checkTargets() {
func (m *monitor) checkTargets() {
m.mu.RLock()
targets := make([]target, len(m.targets))
copy(targets, m.targets)
@ -165,10 +259,13 @@ func (m *Monitor) checkTargets() {
if t.isFile {
m.ensureWatcher(t.path)
} else {
// Directory scanning for pattern matching
files, err := m.scanDirectory(t.path, t.regex)
if err != nil {
fmt.Printf("[DEBUG] Error scanning directory %s: %v\n", t.path, err)
continue
}
for _, file := range files {
m.ensureWatcher(file)
}
@ -178,7 +275,7 @@ func (m *Monitor) checkTargets() {
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)
if err != nil {
return nil, err
@ -199,7 +296,7 @@ func (m *Monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, e
return files, nil
}
func (m *Monitor) ensureWatcher(path string) {
func (m *monitor) ensureWatcher(path string) {
m.mu.Lock()
defer m.mu.Unlock()
@ -207,17 +304,17 @@ func (m *Monitor) ensureWatcher(path string) {
return
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return
}
w := newFileWatcher(path, m.publish)
m.watchers[path] = w
fmt.Printf("[DEBUG] Created watcher for: %s\n", path)
m.wg.Add(1)
go func() {
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()
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()
defer m.mu.Unlock()

View File

@ -1,4 +1,4 @@
// FILE: src/internal/stream/http.go
// FILE: src/internal/stream/httpstreamer.go
package stream
import (
@ -25,23 +25,53 @@ type HTTPStreamer struct {
startTime time.Time
done chan struct{}
wg sync.WaitGroup
// Path configuration
streamPath string
statusPath string
// For router integration
standalone bool
}
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{
logChan: logChan,
config: cfg,
startTime: time.Now(),
done: make(chan struct{}),
logChan: logChan,
config: cfg,
startTime: time.Now(),
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 {
if !h.standalone {
// In router mode, don't start our own server
return nil
}
h.server = &fasthttp.Server{
Handler: h.requestHandler,
DisableKeepalive: false,
StreamRequestBody: true,
Logger: nil, // Suppress fasthttp logs
Logger: nil,
}
addr := fmt.Sprintf(":%d", h.config.Port)
@ -69,13 +99,10 @@ func (h *HTTPStreamer) Stop() {
// Signal all client handlers to stop
close(h.done)
// Shutdown HTTP server
if h.server != nil {
// Create context with timeout for server shutdown
// Shutdown HTTP server if in standalone mode
if h.standalone && h.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Use ShutdownWithContext for graceful shutdown
h.server.ShutdownWithContext(ctx)
}
@ -83,16 +110,26 @@ func (h *HTTPStreamer) Stop() {
h.wg.Wait()
}
func (h *HTTPStreamer) RouteRequest(ctx *fasthttp.RequestCtx) {
h.requestHandler(ctx)
}
func (h *HTTPStreamer) requestHandler(ctx *fasthttp.RequestCtx) {
path := string(ctx.Path())
switch path {
case "/stream":
case h.streamPath:
h.handleStream(ctx)
case "/status":
case h.statusPath:
h.handleStatus(ctx)
default:
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("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
clientChan := make(chan monitor.LogEntry, h.config.BufferSize)
clientDone := make(chan struct{})
@ -128,14 +158,14 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
case clientChan <- entry:
case <-clientDone:
return
case <-h.done: // Check for server shutdown
case <-h.done:
return
default:
// Drop if client buffer full
}
case <-clientDone:
return
case <-h.done: // Check for server shutdown
case <-h.done:
return
}
}
@ -143,11 +173,28 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
// Define the stream writer function
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
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()
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
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
w.Flush()
@ -240,13 +287,48 @@ func (h *HTTPStreamer) handleStatus(ctx *fasthttp.RequestCtx) {
status := map[string]interface{}{
"service": "LogWisp",
"version": "3.0.0",
"http_server": map[string]interface{}{
"server": map[string]interface{}{
"type": "http",
"port": h.config.Port,
"active_clients": h.activeClients.Load(),
"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)
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
}

View 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()
}

View File

@ -1,4 +1,4 @@
// FILE: src/internal/stream/tcp.go
// FILE: src/internal/stream/tcpstreamer.go
package stream
import (
@ -18,25 +18,19 @@ type TCPStreamer struct {
logChan chan monitor.LogEntry
config config.TCPConfig
server *tcpServer
done chan struct{}
activeConns atomic.Int32
startTime time.Time
done chan struct{}
engine *gnet.Engine
wg sync.WaitGroup
}
type tcpServer struct {
gnet.BuiltinEventEngine
streamer *TCPStreamer
connections sync.Map
}
func NewTCPStreamer(logChan chan monitor.LogEntry, cfg config.TCPConfig) *TCPStreamer {
return &TCPStreamer{
logChan: logChan,
config: cfg,
startTime: time.Now(),
done: make(chan struct{}),
startTime: time.Now(),
}
}
@ -50,14 +44,14 @@ func (t *TCPStreamer) Start() error {
t.broadcastLoop()
}()
// Configure gnet with no-op logger
// Configure gnet
addr := fmt.Sprintf("tcp://:%d", t.config.Port)
// Run gnet in separate goroutine to avoid blocking
errChan := make(chan error, 1)
go func() {
err := gnet.Run(t.server, addr,
gnet.WithLogger(noopLogger{}), // No-op logger: discard everything
gnet.WithLogger(noopLogger{}),
gnet.WithMulticore(true),
gnet.WithReusePort(true),
)
@ -83,7 +77,6 @@ func (t *TCPStreamer) Stop() {
// Stop gnet engine if running
if t.engine != nil {
// Use Stop() method to gracefully shutdown gnet
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
t.engine.Stop(ctx)
@ -107,7 +100,7 @@ func (t *TCPStreamer) broadcastLoop() {
select {
case entry, ok := <-t.logChan:
if !ok {
return // Channel closed
return
}
data, err := json.Marshal(entry)
if err != nil {
@ -155,27 +148,4 @@ func (t *TCPStreamer) formatHeartbeat() []byte {
jsonData, _ := json.Marshal(data)
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
}