feat: Implement initial Go backend and React frontend project structure for the gotail application.

This commit is contained in:
Luiz Costa
2025-11-19 17:15:29 -03:00
parent dfe9b10bb8
commit ef1784f8d4
189 changed files with 25220 additions and 279 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Tail Pro</title>
</head>
<body>
<h1>Web Tail Pro - Placeholder</h1>
<p>Execute o build completo para gerar a interface React.</p>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/seu-usuario/go-react-web-tail/internal/broker"
"github.com/seu-usuario/go-react-web-tail/internal/config"
"github.com/seu-usuario/go-react-web-tail/internal/handlers"
"github.com/seu-usuario/go-react-web-tail/internal/middleware"
"github.com/seu-usuario/go-react-web-tail/internal/tail"
)
//go:embed dist
var staticFiles embed.FS
var version = "2.0.0"
func main() {
if err := run(); err != nil {
log.Fatal().Err(err).Msg("Application failed")
}
}
func run() error {
// Carregar configuração
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Configurar logging
setupLogging(cfg.Logging)
log.Info().
Str("version", version).
Str("port", cfg.Server.Port).
Bool("auth_enabled", cfg.Auth.Enabled).
Bool("tls_enabled", cfg.Server.TLSEnabled).
Bool("rate_limit_enabled", cfg.Security.RateLimitEnabled).
Msg("Starting Web Tail Pro")
// Obter arquivos de log dos argumentos
logFiles := flag.Args()
if len(logFiles) < 1 {
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
fmt.Println("\nOpções:")
flag.PrintDefaults()
fmt.Println("\nVariáveis de Ambiente:")
fmt.Println(" PORT - Porta do servidor (padrão: :8080)")
fmt.Println(" USERNAME - Nome de usuário (padrão: admin)")
fmt.Println(" PASSWORD - Senha de autenticação")
fmt.Println(" LOG_LEVEL - Nível de log: debug, info, warn, error (padrão: info)")
fmt.Println(" LOG_FORMAT - Formato: json, console (padrão: console)")
fmt.Println(" RATE_LIMIT_ENABLED - Habilitar rate limiting (padrão: true)")
fmt.Println(" RATE_LIMIT_RPS - Requisições por segundo (padrão: 100)")
fmt.Println(" CORS_ORIGINS - Origens CORS permitidas, separadas por vírgula")
fmt.Println(" TLS_ENABLED - Habilitar TLS (padrão: false)")
fmt.Println(" TLS_CERT_FILE - Arquivo de certificado TLS")
fmt.Println(" TLS_KEY_FILE - Arquivo de chave TLS")
os.Exit(1)
}
// Criar context para graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Inicializar broker
brk := broker.New(ctx)
// Inicializar tailer
tailer := tail.New(ctx, logFiles, brk.Broadcast)
if err := tailer.Start(); err != nil {
return fmt.Errorf("failed to start tailer: %w", err)
}
// Configurar servidor HTTP
server, err := setupServer(cfg, brk, logFiles)
if err != nil {
return fmt.Errorf("failed to setup server: %w", err)
}
// Iniciar servidor em goroutine
serverErrors := make(chan error, 1)
go func() {
log.Info().Str("address", cfg.Server.Port).Msg("HTTP server listening")
if cfg.Server.TLSEnabled {
serverErrors <- server.ListenAndServeTLS(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
} else {
serverErrors <- server.ListenAndServe()
}
}()
// Aguardar sinal de shutdown
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serverErrors:
return fmt.Errorf("server error: %w", err)
case sig := <-shutdown:
log.Info().Str("signal", sig.String()).Msg("Shutdown signal received")
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
defer shutdownCancel()
log.Info().Msg("Shutting down HTTP server...")
if err := server.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("HTTP server shutdown error")
}
log.Info().Msg("Stopping tailer...")
if err := tailer.Shutdown(); err != nil {
log.Error().Err(err).Msg("Tailer shutdown error")
}
log.Info().Msg("Stopping broker...")
brk.Shutdown()
log.Info().Msg("Shutdown complete")
}
return nil
}
func setupLogging(cfg config.LoggingConfig) {
// Configurar nível de log
level, err := zerolog.ParseLevel(cfg.Level)
if err != nil {
level = zerolog.InfoLevel
}
zerolog.SetGlobalLevel(level)
// Configurar formato
if cfg.Format == "console" {
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: time.RFC3339,
})
}
}
func setupServer(cfg *config.Config, brk *broker.Broker, logFiles []string) (*http.Server, error) {
// Configurar handlers
h := handlers.New(brk, logFiles)
// Configurar arquivos estáticos
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
return nil, fmt.Errorf("failed to create sub-filesystem: %w", err)
}
// Configurar rotas
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(distFS)))
mux.HandleFunc("/logs", h.HandleSSE)
mux.HandleFunc("/api/files", h.HandleFiles)
mux.HandleFunc("/api/last", h.HandleLastLines)
mux.HandleFunc("/health", h.HandleHealth)
mux.HandleFunc("/metrics", h.HandleMetrics)
// Aplicar cadeia de middleware
var handler http.Handler = mux
// Security headers
handler = middleware.SecurityHeadersMiddleware(handler)
// CORS
corsMiddleware := middleware.NewCORSMiddleware(cfg.Security.CORSOrigins)
handler = corsMiddleware.Handler(handler)
// Rate limiting
if cfg.Security.RateLimitEnabled {
rateLimitMiddleware := middleware.NewRateLimitMiddleware(cfg.Security.RateLimitRPS)
handler = rateLimitMiddleware.Handler(handler)
log.Info().Int("rps", cfg.Security.RateLimitRPS).Msg("Rate limiting enabled")
}
// Authentication
authMiddleware := middleware.NewAuthMiddleware(cfg.Auth.Enabled, cfg.Auth.Username, cfg.Auth.Password)
handler = authMiddleware.Handler(handler)
// Logging
handler = middleware.LoggingMiddleware(handler)
// Criar servidor
server := &http.Server{
Addr: cfg.Server.Port,
Handler: handler,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
return server, nil
}
+1
View File
@@ -0,0 +1 @@
&lt;html&gt;&lt;body&gt;Placeholder&lt;/body&gt;&lt;/html&gt;
+9 -2
View File
@@ -2,11 +2,18 @@ module github.com/seu-usuario/go-react-web-tail
go 1.24.0
require github.com/hpcloud/tail v1.0.0
require (
github.com/hpcloud/tail v1.0.0
github.com/nxadm/tail v1.4.11
github.com/rs/zerolog v1.33.0
golang.org/x/time v0.8.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
)
)
+21
View File
@@ -1,9 +1,30 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+166
View File
@@ -0,0 +1,166 @@
package broker
import (
"context"
"sync"
"time"
"github.com/rs/zerolog/log"
"github.com/seu-usuario/go-react-web-tail/internal/models"
)
// Broker gerencia clientes SSE e broadcasting de logs
type Broker struct {
clients map[chan models.LogEntry]struct{}
mu sync.RWMutex
register chan chan models.LogEntry
unregister chan chan models.LogEntry
broadcast chan models.LogEntry
ctx context.Context
cancel context.CancelFunc
// Métricas
totalBroadcasts uint64
activeClients int
}
// New cria uma nova instância do Broker
func New(ctx context.Context) *Broker {
ctx, cancel := context.WithCancel(ctx)
b := &Broker{
clients: make(map[chan models.LogEntry]struct{}),
register: make(chan chan models.LogEntry),
unregister: make(chan chan models.LogEntry),
broadcast: make(chan models.LogEntry, 1000),
ctx: ctx,
cancel: cancel,
}
go b.run()
return b
}
// run é o loop principal do broker
func (b *Broker) run() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Info().Msg("Broker started")
for {
select {
case <-b.ctx.Done():
log.Info().Msg("Broker shutting down")
b.closeAllClients()
return
case client := <-b.register:
b.addClient(client)
case client := <-b.unregister:
b.removeClient(client)
case entry := <-b.broadcast:
b.broadcastToClients(entry)
case <-ticker.C:
b.logMetrics()
}
}
}
// Subscribe registra um novo cliente para receber logs
func (b *Broker) Subscribe() chan models.LogEntry {
ch := make(chan models.LogEntry, 100)
b.register <- ch
return ch
}
// Unsubscribe remove um cliente
func (b *Broker) Unsubscribe(ch chan models.LogEntry) {
b.unregister <- ch
}
// Broadcast envia uma entrada de log para todos os clientes
func (b *Broker) Broadcast(entry models.LogEntry) {
select {
case b.broadcast <- entry:
case <-b.ctx.Done():
default:
log.Warn().Msg("Broadcast channel full, dropping message")
}
}
// Shutdown encerra o broker graciosamente
func (b *Broker) Shutdown() {
log.Info().Msg("Broker shutdown requested")
b.cancel()
}
// addClient adiciona um novo cliente
func (b *Broker) addClient(client chan models.LogEntry) {
b.mu.Lock()
defer b.mu.Unlock()
b.clients[client] = struct{}{}
b.activeClients = len(b.clients)
log.Info().Int("active_clients", b.activeClients).Msg("Client subscribed")
}
// removeClient remove um cliente
func (b *Broker) removeClient(client chan models.LogEntry) {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.clients[client]; ok {
delete(b.clients, client)
close(client)
b.activeClients = len(b.clients)
log.Info().Int("active_clients", b.activeClients).Msg("Client unsubscribed")
}
}
// broadcastToClients envia uma entrada para todos os clientes conectados
func (b *Broker) broadcastToClients(entry models.LogEntry) {
b.mu.RLock()
defer b.mu.RUnlock()
b.totalBroadcasts++
for clientCh := range b.clients {
select {
case clientCh <- entry:
// Enviado com sucesso
default:
// Cliente está lento, remover
go b.Unsubscribe(clientCh)
log.Warn().Msg("Removed slow client")
}
}
}
// closeAllClients fecha todos os canais de clientes
func (b *Broker) closeAllClients() {
b.mu.Lock()
defer b.mu.Unlock()
for client := range b.clients {
close(client)
}
b.clients = make(map[chan models.LogEntry]struct{})
log.Info().Msg("All clients disconnected")
}
// logMetrics registra métricas do broker
func (b *Broker) logMetrics() {
b.mu.RLock()
defer b.mu.RUnlock()
log.Debug().
Int("active_clients", b.activeClients).
Uint64("total_broadcasts", b.totalBroadcasts).
Int("broadcast_buffer", len(b.broadcast)).
Msg("Broker metrics")
}
// GetMetrics retorna métricas atuais do broker
func (b *Broker) GetMetrics() (activeClients int, totalBroadcasts uint64) {
b.mu.RLock()
defer b.mu.RUnlock()
return b.activeClients, b.totalBroadcasts
}
+202
View File
@@ -0,0 +1,202 @@
package broker
import (
"context"
"testing"
"time"
"github.com/seu-usuario/go-react-web-tail/internal/models"
)
func TestBroker_SubscribeUnsubscribe(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := New(ctx)
defer b.Shutdown()
// Subscribe
ch := b.Subscribe()
if ch == nil {
t.Fatal("Subscribe returned nil channel")
}
// Give broker time to process
time.Sleep(10 * time.Millisecond)
activeClients, _ := b.GetMetrics()
if activeClients != 1 {
t.Errorf("Expected 1 active client, got %d", activeClients)
}
// Unsubscribe
b.Unsubscribe(ch)
time.Sleep(10 * time.Millisecond)
activeClients, _ = b.GetMetrics()
if activeClients != 0 {
t.Errorf("Expected 0 active clients after unsubscribe, got %d", activeClients)
}
}
func TestBroker_Broadcast(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := New(ctx)
defer b.Shutdown()
ch := b.Subscribe()
defer b.Unsubscribe(ch)
entry := models.LogEntry{
Filename: "test.log",
Line: "Test message",
}
// Broadcast
b.Broadcast(entry)
// Receive
select {
case received := <-ch:
if received.Filename != entry.Filename {
t.Errorf("Expected filename %s, got %s", entry.Filename, received.Filename)
}
if received.Line != entry.Line {
t.Errorf("Expected line %s, got %s", entry.Line, received.Line)
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for broadcast message")
}
}
func TestBroker_MultipleClients(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := New(ctx)
defer b.Shutdown()
// Subscribe multiple clients
clients := make([]chan models.LogEntry, 3)
for i := range clients {
clients[i] = b.Subscribe()
}
time.Sleep(10 * time.Millisecond)
activeClients, _ := b.GetMetrics()
if activeClients != 3 {
t.Errorf("Expected 3 active clients, got %d", activeClients)
}
entry := models.LogEntry{
Filename: "test.log",
Line: "Broadcast to all",
}
b.Broadcast(entry)
// All clients should receive
for i, ch := range clients {
select {
case received := <-ch:
if received.Line != entry.Line {
t.Errorf("Client %d: expected line %s, got %s", i, entry.Line, received.Line)
}
case <-time.After(1 * time.Second):
t.Errorf("Client %d: timeout waiting for message", i)
}
}
// Cleanup
for _, ch := range clients {
b.Unsubscribe(ch)
}
}
func TestBroker_Shutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := New(ctx)
ch := b.Subscribe()
time.Sleep(10 * time.Millisecond)
b.Shutdown()
time.Sleep(50 * time.Millisecond)
// Channel should be closed
_, ok := <-ch
if ok {
t.Error("Expected channel to be closed after shutdown")
}
}
func TestBroker_GetMetrics(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b := New(ctx)
defer b.Shutdown()
// Initial metrics
activeClients, totalBroadcasts := b.GetMetrics()
if activeClients != 0 {
t.Errorf("Expected 0 active clients initially, got %d", activeClients)
}
if totalBroadcasts != 0 {
t.Errorf("Expected 0 total broadcasts initially, got %d", totalBroadcasts)
}
// Subscribe and broadcast
ch := b.Subscribe()
defer b.Unsubscribe(ch)
time.Sleep(10 * time.Millisecond)
for i := 0; i < 5; i++ {
b.Broadcast(models.LogEntry{
Filename: "test.log",
Line: "Test",
})
}
time.Sleep(10 * time.Millisecond)
activeClients, totalBroadcasts = b.GetMetrics()
if activeClients != 1 {
t.Errorf("Expected 1 active client, got %d", activeClients)
}
if totalBroadcasts != 5 {
t.Errorf("Expected 5 total broadcasts, got %d", totalBroadcasts)
}
}
func BenchmarkBroker_Broadcast(b *testing.B) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker := New(ctx)
defer broker.Shutdown()
ch := broker.Subscribe()
defer broker.Unsubscribe(ch)
// Drain channel in background
go func() {
for range ch {
}
}()
entry := models.LogEntry{
Filename: "test.log",
Line: "Benchmark message",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
broker.Broadcast(entry)
}
}
+160
View File
@@ -0,0 +1,160 @@
package config
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config contém todas as configurações da aplicação
type Config struct {
Server ServerConfig
Auth AuthConfig
Logging LoggingConfig
Security SecurityConfig
}
// ServerConfig contém configurações do servidor HTTP
type ServerConfig struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
TLSEnabled bool
TLSCertFile string
TLSKeyFile string
}
// AuthConfig contém configurações de autenticação
type AuthConfig struct {
Enabled bool
Username string
Password string
}
// LoggingConfig contém configurações de logging
type LoggingConfig struct {
Level string // debug, info, warn, error
Format string // json ou console
}
// SecurityConfig contém configurações de segurança
type SecurityConfig struct {
RateLimitEnabled bool
RateLimitRPS int
CORSOrigins []string
}
// Load carrega a configuração de múltiplas fontes (flags, env vars)
func Load() (*Config, error) {
cfg := &Config{}
// Definir flags (mantém compatibilidade com versão anterior)
flag.StringVar(&cfg.Server.Port, "port", getEnv("PORT", ":8080"), "Porta para o servidor web (ex: :8080, 9090)")
flag.StringVar(&cfg.Auth.Username, "username", getEnv("USERNAME", "admin"), "Nome de usuário para autenticação")
flag.StringVar(&cfg.Auth.Password, "password", getEnv("PASSWORD", ""), "Senha para autenticação")
flag.Parse()
// Carregar configurações de variáveis de ambiente
cfg.Logging.Level = getEnv("LOG_LEVEL", "info")
cfg.Logging.Format = getEnv("LOG_FORMAT", "console")
cfg.Security.RateLimitEnabled = getEnvBool("RATE_LIMIT_ENABLED", true)
cfg.Security.RateLimitRPS = getEnvInt("RATE_LIMIT_RPS", 100)
corsOrigins := getEnv("CORS_ORIGINS", "")
if corsOrigins != "" {
cfg.Security.CORSOrigins = strings.Split(corsOrigins, ",")
} else {
cfg.Security.CORSOrigins = []string{"*"}
}
cfg.Server.ReadTimeout = getEnvDuration("READ_TIMEOUT", 15*time.Second)
cfg.Server.WriteTimeout = getEnvDuration("WRITE_TIMEOUT", 15*time.Second)
cfg.Server.ShutdownTimeout = getEnvDuration("SHUTDOWN_TIMEOUT", 30*time.Second)
cfg.Server.TLSEnabled = getEnvBool("TLS_ENABLED", false)
cfg.Server.TLSCertFile = getEnv("TLS_CERT_FILE", "")
cfg.Server.TLSKeyFile = getEnv("TLS_KEY_FILE", "")
// Autenticação está habilitada se houver senha
cfg.Auth.Enabled = cfg.Auth.Password != ""
// Validar configuração
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// Validate valida a configuração
func (c *Config) Validate() error {
// Validar porta
if c.Server.Port == "" {
return fmt.Errorf("porta do servidor não pode ser vazia")
}
// Validar TLS
if c.Server.TLSEnabled {
if c.Server.TLSCertFile == "" || c.Server.TLSKeyFile == "" {
return fmt.Errorf("TLS habilitado mas certificado ou chave não especificados")
}
}
// Validar log level
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
if !validLevels[c.Logging.Level] {
return fmt.Errorf("nível de log inválido: %s (use: debug, info, warn, error)", c.Logging.Level)
}
// Validar log format
if c.Logging.Format != "json" && c.Logging.Format != "console" {
return fmt.Errorf("formato de log inválido: %s (use: json, console)", c.Logging.Format)
}
return nil
}
// Funções auxiliares para ler variáveis de ambiente
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
b, err := strconv.ParseBool(value)
if err == nil {
return b
}
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
i, err := strconv.Atoi(value)
if err == nil {
return i
}
}
return defaultValue
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
d, err := time.ParseDuration(value)
if err == nil {
return d
}
}
return defaultValue
}
+300
View File
@@ -0,0 +1,300 @@
package config
import (
"os"
"testing"
"time"
)
func TestConfig_Validate(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "Valid config",
config: &Config{
Server: ServerConfig{
Port: ":8080",
},
Logging: LoggingConfig{
Level: "info",
Format: "console",
},
},
wantErr: false,
},
{
name: "Empty port",
config: &Config{
Server: ServerConfig{
Port: "",
},
Logging: LoggingConfig{
Level: "info",
Format: "console",
},
},
wantErr: true,
},
{
name: "Invalid log level",
config: &Config{
Server: ServerConfig{
Port: ":8080",
},
Logging: LoggingConfig{
Level: "invalid",
Format: "console",
},
},
wantErr: true,
},
{
name: "Invalid log format",
config: &Config{
Server: ServerConfig{
Port: ":8080",
},
Logging: LoggingConfig{
Level: "info",
Format: "invalid",
},
},
wantErr: true,
},
{
name: "TLS enabled without cert",
config: &Config{
Server: ServerConfig{
Port: ":8080",
TLSEnabled: true,
TLSCertFile: "",
TLSKeyFile: "",
},
Logging: LoggingConfig{
Level: "info",
Format: "console",
},
},
wantErr: true,
},
{
name: "TLS enabled with cert and key",
config: &Config{
Server: ServerConfig{
Port: ":8080",
TLSEnabled: true,
TLSCertFile: "/path/to/cert.pem",
TLSKeyFile: "/path/to/key.pem",
},
Logging: LoggingConfig{
Level: "info",
Format: "console",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGetEnv(t *testing.T) {
tests := []struct {
name string
key string
defaultValue string
envValue string
want string
}{
{
name: "Environment variable set",
key: "TEST_VAR",
defaultValue: "default",
envValue: "custom",
want: "custom",
},
{
name: "Environment variable not set",
key: "UNSET_VAR",
defaultValue: "default",
envValue: "",
want: "default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
got := getEnv(tt.key, tt.defaultValue)
if got != tt.want {
t.Errorf("getEnv() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetEnvBool(t *testing.T) {
tests := []struct {
name string
key string
defaultValue bool
envValue string
want bool
}{
{
name: "true value",
key: "TEST_BOOL",
defaultValue: false,
envValue: "true",
want: true,
},
{
name: "false value",
key: "TEST_BOOL",
defaultValue: true,
envValue: "false",
want: false,
},
{
name: "1 value",
key: "TEST_BOOL",
defaultValue: false,
envValue: "1",
want: true,
},
{
name: "invalid value uses default",
key: "TEST_BOOL",
defaultValue: true,
envValue: "invalid",
want: true,
},
{
name: "not set uses default",
key: "UNSET_BOOL",
defaultValue: true,
envValue: "",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
got := getEnvBool(tt.key, tt.defaultValue)
if got != tt.want {
t.Errorf("getEnvBool() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetEnvInt(t *testing.T) {
tests := []struct {
name string
key string
defaultValue int
envValue string
want int
}{
{
name: "valid integer",
key: "TEST_INT",
defaultValue: 10,
envValue: "42",
want: 42,
},
{
name: "invalid integer uses default",
key: "TEST_INT",
defaultValue: 10,
envValue: "invalid",
want: 10,
},
{
name: "not set uses default",
key: "UNSET_INT",
defaultValue: 100,
envValue: "",
want: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
got := getEnvInt(tt.key, tt.defaultValue)
if got != tt.want {
t.Errorf("getEnvInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetEnvDuration(t *testing.T) {
tests := []struct {
name string
key string
defaultValue time.Duration
envValue string
want time.Duration
}{
{
name: "valid duration",
key: "TEST_DURATION",
defaultValue: 10 * time.Second,
envValue: "30s",
want: 30 * time.Second,
},
{
name: "invalid duration uses default",
key: "TEST_DURATION",
defaultValue: 10 * time.Second,
envValue: "invalid",
want: 10 * time.Second,
},
{
name: "not set uses default",
key: "UNSET_DURATION",
defaultValue: 1 * time.Minute,
envValue: "",
want: 1 * time.Minute,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
got := getEnvDuration(tt.key, tt.defaultValue)
if got != tt.want {
t.Errorf("getEnvDuration() = %v, want %v", got, tt.want)
}
})
}
}
+166
View File
@@ -0,0 +1,166 @@
package handlers
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"github.com/rs/zerolog/log"
"github.com/seu-usuario/go-react-web-tail/internal/broker"
"github.com/seu-usuario/go-react-web-tail/internal/models"
)
// Handler contém os handlers HTTP da aplicação
type Handler struct {
broker *broker.Broker
logFiles []string
}
// New cria uma nova instância do Handler
func New(broker *broker.Broker, logFiles []string) *Handler {
return &Handler{
broker: broker,
logFiles: logFiles,
}
}
// HandleSSE gerencia conexões Server-Sent Events para streaming de logs
func (h *Handler) HandleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
clientChan := h.broker.Subscribe()
defer h.broker.Unsubscribe(clientChan)
log.Info().Str("remote_addr", r.RemoteAddr).Msg("SSE client connected")
for {
select {
case entry := <-clientChan:
data, err := json.Marshal(entry)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal log entry")
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
log.Info().Str("remote_addr", r.RemoteAddr).Msg("SSE client disconnected")
return
}
}
}
// HandleFiles retorna a lista de arquivos de log monitorados
func (h *Handler) HandleFiles(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(h.logFiles); err != nil {
log.Error().Err(err).Msg("Failed to encode log files")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// HandleLastLines retorna as últimas N linhas de um arquivo
func (h *Handler) HandleLastLines(w http.ResponseWriter, r *http.Request) {
file := r.URL.Query().Get("file")
nParam := r.URL.Query().Get("n")
n := 200
if nParam != "" {
if parsed, err := strconv.Atoi(nParam); err == nil && parsed > 0 {
n = parsed
}
}
// Validar que o arquivo está na lista permitida
allowed := false
for _, f := range h.logFiles {
if f == file {
allowed = true
break
}
}
if !allowed {
log.Warn().Str("file", file).Str("remote_addr", r.RemoteAddr).Msg("Attempted access to unauthorized file")
http.Error(w, "Invalid file", http.StatusBadRequest)
return
}
entries, err := h.getLastLines(file, n)
if err != nil {
log.Error().Err(err).Str("file", file).Msg("Failed to read last lines")
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(entries); err != nil {
log.Error().Err(err).Msg("Failed to encode entries")
}
}
// getLastLines lê as últimas N linhas de um arquivo
func (h *Handler) getLastLines(filename string, n int) ([]models.LogEntry, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
buf := make([]string, 0, n)
for scanner.Scan() {
line := scanner.Text()
if len(buf) == n {
buf = buf[1:]
}
buf = append(buf, line)
}
if err := scanner.Err(); err != nil {
return nil, err
}
entries := make([]models.LogEntry, 0, len(buf))
for _, line := range buf {
entry := models.NewLogEntry(filename, strings.TrimRight(line, "\r"))
entries = append(entries, entry)
}
return entries, nil
}
// HandleHealth retorna o status de saúde da aplicação
func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
})
}
// HandleMetrics retorna métricas da aplicação
func (h *Handler) HandleMetrics(w http.ResponseWriter, r *http.Request) {
activeClients, totalBroadcasts := h.broker.GetMetrics()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"active_clients": activeClients,
"total_broadcasts": totalBroadcasts,
"monitored_files": len(h.logFiles),
})
}
+209
View File
@@ -0,0 +1,209 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/time/rate"
)
// AuthMiddleware implementa autenticação HTTP Basic
type AuthMiddleware struct {
enabled bool
username string
password string
}
// NewAuthMiddleware cria uma nova instância do middleware de autenticação
func NewAuthMiddleware(enabled bool, username, password string) *AuthMiddleware {
return &AuthMiddleware{
enabled: enabled,
username: username,
password: password,
}
}
// Handler retorna o handler HTTP com autenticação
func (m *AuthMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.enabled {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok {
m.requestAuth(w)
return
}
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
} else {
log.Warn().
Str("username", user).
Str("remote_addr", r.RemoteAddr).
Msg("Authentication failed")
m.requestAuth(w)
}
})
}
func (m *AuthMiddleware) requestAuth(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Web Tail Pro - Restricted Access"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized\n"))
}
// CORSMiddleware implementa CORS
type CORSMiddleware struct {
allowedOrigins []string
}
// NewCORSMiddleware cria uma nova instância do middleware CORS
func NewCORSMiddleware(origins []string) *CORSMiddleware {
return &CORSMiddleware{allowedOrigins: origins}
}
// Handler retorna o handler HTTP com CORS
func (m *CORSMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if m.isAllowedOrigin(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (m *CORSMiddleware) isAllowedOrigin(origin string) bool {
if len(m.allowedOrigins) == 0 {
return true
}
for _, allowed := range m.allowedOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
// RateLimitMiddleware implementa rate limiting por IP
type RateLimitMiddleware struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rps int
}
// NewRateLimitMiddleware cria uma nova instância do middleware de rate limiting
func NewRateLimitMiddleware(rps int) *RateLimitMiddleware {
return &RateLimitMiddleware{
limiters: make(map[string]*rate.Limiter),
rps: rps,
}
}
// Handler retorna o handler HTTP com rate limiting
func (m *RateLimitMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := m.getIP(r)
limiter := m.getLimiter(ip)
if !limiter.Allow() {
log.Warn().Str("ip", ip).Str("path", r.URL.Path).Msg("Rate limit exceeded")
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func (m *RateLimitMiddleware) getLimiter(ip string) *rate.Limiter {
m.mu.RLock()
limiter, exists := m.limiters[ip]
m.mu.RUnlock()
if !exists {
m.mu.Lock()
limiter = rate.NewLimiter(rate.Limit(m.rps), m.rps*2)
m.limiters[ip] = limiter
m.mu.Unlock()
}
return limiter
}
func (m *RateLimitMiddleware) getIP(r *http.Request) string {
// Tentar obter IP real de headers de proxy
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.Header.Get("X-Real-IP")
}
if ip == "" {
ip = strings.Split(r.RemoteAddr, ":")[0]
}
return ip
}
// SecurityHeadersMiddleware adiciona headers de segurança
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
// LoggingMiddleware registra todas as requisições HTTP
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrapper para capturar status code
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Info().
Str("method", r.Method).
Str("path", r.URL.Path).
Str("remote_addr", r.RemoteAddr).
Int("status", wrapped.statusCode).
Dur("duration", time.Since(start)).
Msg("HTTP request")
})
}
// responseWriter é um wrapper para capturar o status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
+64
View File
@@ -0,0 +1,64 @@
package models
import (
"regexp"
"strings"
"time"
)
// LogEntry representa uma linha de log com metadados extraídos
type LogEntry struct {
Filename string `json:"filename"`
Line string `json:"line"`
Timestamp time.Time `json:"timestamp,omitempty"`
Level string `json:"level,omitempty"`
}
var (
// Regex para extrair timestamps em diferentes formatos
timestampRegex1 = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
timestampRegex2 = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
// Regex para extrair nível de log
logLevelRegex = regexp.MustCompile(`(?i)\b(DEBUG|INFO|WARN|WARNING|ERROR|FATAL|TRACE)\b`)
)
// NewLogEntry cria uma nova entrada de log com parsing automático de timestamp e nível
func NewLogEntry(filename, line string) LogEntry {
entry := LogEntry{
Filename: filename,
Line: strings.TrimRight(line, "\r\n"),
}
// Extrair timestamp
if ts := timestampRegex1.FindString(line); ts != "" {
if parsed, err := time.Parse("2006-01-02T15:04:05", ts); err == nil {
entry.Timestamp = parsed
}
} else if ts := timestampRegex2.FindString(line); ts != "" {
if parsed, err := time.Parse("2006-01-02 15:04:05", ts); err == nil {
entry.Timestamp = parsed
}
}
// Extrair nível de log
if level := logLevelRegex.FindString(line); level != "" {
entry.Level = strings.ToUpper(level)
}
return entry
}
// TimestampString retorna o timestamp como string no formato original
// Retorna string vazia se não houver timestamp
func (e LogEntry) TimestampString() string {
if e.Timestamp.IsZero() {
return ""
}
// Tentar detectar o formato original da linha
if strings.Contains(e.Line, "T") {
return e.Timestamp.Format("2006-01-02T15:04:05")
}
return e.Timestamp.Format("2006-01-02 15:04:05")
}
+146
View File
@@ -0,0 +1,146 @@
package models
import (
"testing"
"time"
)
func TestNewLogEntry(t *testing.T) {
tests := []struct {
name string
filename string
line string
wantLevel string
wantTimestamp bool
}{
{
name: "Log with ISO8601 timestamp and INFO level",
filename: "app.log",
line: "2025-11-19T10:00:00 INFO Application started",
wantLevel: "INFO",
wantTimestamp: true,
},
{
name: "Log with space-separated timestamp and ERROR level",
filename: "app.log",
line: "2025-11-19 10:00:00 ERROR Connection failed",
wantLevel: "ERROR",
wantTimestamp: true,
},
{
name: "Log with DEBUG level",
filename: "debug.log",
line: "DEBUG: Entering function processRequest",
wantLevel: "DEBUG",
wantTimestamp: false,
},
{
name: "Log with WARN level",
filename: "app.log",
line: "WARN: Memory usage high",
wantLevel: "WARN",
wantTimestamp: false,
},
{
name: "Log without level or timestamp",
filename: "simple.log",
line: "Simple log message",
wantLevel: "",
wantTimestamp: false,
},
{
name: "Log with FATAL level",
filename: "app.log",
line: "FATAL: System crash",
wantLevel: "FATAL",
wantTimestamp: false,
},
{
name: "Log with carriage return",
filename: "windows.log",
line: "Log line with CR\r\n",
wantLevel: "",
wantTimestamp: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := NewLogEntry(tt.filename, tt.line)
if entry.Filename != tt.filename {
t.Errorf("Filename = %v, want %v", entry.Filename, tt.filename)
}
if entry.Level != tt.wantLevel {
t.Errorf("Level = %v, want %v", entry.Level, tt.wantLevel)
}
if tt.wantTimestamp && entry.Timestamp.IsZero() {
t.Error("Expected timestamp to be parsed, but got zero value")
}
if !tt.wantTimestamp && !entry.Timestamp.IsZero() {
t.Error("Expected no timestamp, but got one")
}
// Line should have CR/LF trimmed
if entry.Line != tt.line && entry.Line != tt.line[:len(tt.line)-2] {
t.Errorf("Line not properly trimmed: %q", entry.Line)
}
})
}
}
func TestLogEntry_TimestampString(t *testing.T) {
tests := []struct {
name string
timestamp time.Time
line string
want string
}{
{
name: "ISO8601 format",
timestamp: time.Date(2025, 11, 19, 10, 0, 0, 0, time.UTC),
line: "2025-11-19T10:00:00 INFO Test",
want: "2025-11-19T10:00:00",
},
{
name: "Space-separated format",
timestamp: time.Date(2025, 11, 19, 10, 0, 0, 0, time.UTC),
line: "2025-11-19 10:00:00 INFO Test",
want: "2025-11-19 10:00:00",
},
{
name: "Zero timestamp",
timestamp: time.Time{},
line: "No timestamp",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := LogEntry{
Filename: "test.log",
Line: tt.line,
Timestamp: tt.timestamp,
}
got := entry.TimestampString()
if got != tt.want {
t.Errorf("TimestampString() = %v, want %v", got, tt.want)
}
})
}
}
func BenchmarkNewLogEntry(b *testing.B) {
line := "2025-11-19T10:00:00 INFO Application started successfully"
filename := "app.log"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = NewLogEntry(filename, line)
}
}
+115
View File
@@ -0,0 +1,115 @@
package tail
import (
"context"
"os"
"sync"
"github.com/nxadm/tail"
"github.com/rs/zerolog/log"
"github.com/seu-usuario/go-react-web-tail/internal/models"
)
// Tailer gerencia o monitoramento de múltiplos arquivos de log
type Tailer struct {
files []string
tails []*tail.Tail
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
onEntry func(models.LogEntry)
}
// New cria uma nova instância do Tailer
func New(ctx context.Context, files []string, onEntry func(models.LogEntry)) *Tailer {
ctx, cancel := context.WithCancel(ctx)
return &Tailer{
files: files,
tails: make([]*tail.Tail, 0, len(files)),
ctx: ctx,
cancel: cancel,
onEntry: onEntry,
}
}
// Start inicia o monitoramento de todos os arquivos
func (t *Tailer) Start() error {
log.Info().Int("file_count", len(t.files)).Msg("Starting tailer")
for _, filename := range t.files {
if err := t.startTailing(filename); err != nil {
log.Error().Err(err).Str("file", filename).Msg("Failed to start tailing file")
continue
}
}
return nil
}
// startTailing inicia o monitoramento de um arquivo específico
func (t *Tailer) startTailing(filename string) error {
config := tail.Config{
Follow: true,
ReOpen: true,
Location: &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END},
Logger: tail.DiscardingLogger,
}
tailFile, err := tail.TailFile(filename, config)
if err != nil {
return err
}
t.mu.Lock()
t.tails = append(t.tails, tailFile)
t.mu.Unlock()
log.Info().Str("file", filename).Msg("Started tailing file")
go t.processTail(tailFile, filename)
return nil
}
// processTail processa as linhas de um arquivo
func (t *Tailer) processTail(tailFile *tail.Tail, filename string) {
for {
select {
case <-t.ctx.Done():
log.Debug().Str("file", filename).Msg("Stopping tail processing")
return
case line, ok := <-tailFile.Lines:
if !ok {
log.Warn().Str("file", filename).Msg("Tail channel closed")
return
}
if line.Err != nil {
log.Error().Err(line.Err).Str("file", filename).Msg("Error reading line")
continue
}
entry := models.NewLogEntry(filename, line.Text)
t.onEntry(entry)
}
}
}
// Shutdown para o monitoramento de todos os arquivos
func (t *Tailer) Shutdown() error {
log.Info().Msg("Shutting down tailer")
t.cancel()
t.mu.Lock()
defer t.mu.Unlock()
for _, tailFile := range t.tails {
if err := tailFile.Stop(); err != nil {
log.Error().Err(err).Msg("Error stopping tail")
}
}
log.Info().Msg("All tail operations stopped")
return nil
}
+200 -200
View File
@@ -1,12 +1,12 @@
package main
import (
"bufio"
"crypto/subtle"
"embed"
"encoding/json"
"flag"
"fmt"
"bufio"
"io/fs"
"log"
"net/http"
@@ -25,9 +25,9 @@ var staticFiles embed.FS
// Variáveis para os argumentos de linha de comando
var (
port string
password string
username string
port string
password string
username string
)
// Lista global de arquivos de log (apenas argumentos após flags)
@@ -35,257 +35,257 @@ var logFiles []string
// init() é executado antes de main(). É o lugar ideal para configurar flags.
func init() {
flag.StringVar(&port, "port", ":8080", "Porta para o servidor web (ex: :8080, 9090)")
flag.StringVar(&password, "password", "", "Senha para autenticar o acesso à interface web")
flag.StringVar(&username, "username", "admin", "Nome de usuário para autenticar o acesso à interface web")
flag.StringVar(&port, "port", ":8080", "Porta para o servidor web (ex: :8080, 9090)")
flag.StringVar(&password, "password", "", "Senha para autenticar o acesso à interface web")
flag.StringVar(&username, "username", "admin", "Nome de usuário para autenticar o acesso à interface web")
}
// LogEntry representa uma linha de log
type LogEntry struct {
Filename string `json:"filename"`
Line string `json:"line"`
Timestamp string `json:"timestamp"`
Filename string `json:"filename"`
Line string `json:"line"`
Timestamp string `json:"timestamp"`
}
// Broker gerencia os clientes SSE e broadcasting
type Broker struct {
clients map[chan LogEntry]bool
mu sync.Mutex
clients map[chan LogEntry]bool
mu sync.Mutex
}
var broker = &Broker{
clients: make(map[chan LogEntry]bool),
clients: make(map[chan LogEntry]bool),
}
func (b *Broker) Subscribe() chan LogEntry {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan LogEntry, 100)
b.clients[ch] = true
return ch
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan LogEntry, 100)
b.clients[ch] = true
return ch
}
func (b *Broker) Unsubscribe(ch chan LogEntry) {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.clients[ch]; ok {
delete(b.clients, ch)
close(ch)
}
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.clients[ch]; ok {
delete(b.clients, ch)
close(ch)
}
}
func (b *Broker) Broadcast(entry LogEntry) {
b.mu.Lock()
defer b.mu.Unlock()
for clientCh := range b.clients {
select {
case clientCh <- entry:
default:
delete(b.clients, clientCh)
close(clientCh)
}
}
b.mu.Lock()
defer b.mu.Unlock()
for clientCh := range b.clients {
select {
case clientCh <- entry:
default:
delete(b.clients, clientCh)
close(clientCh)
}
}
}
// handleSSE envia logs em tempo real via SSE
func handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8080")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8080")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
clientChan := broker.Subscribe()
defer broker.Unsubscribe(clientChan)
clientChan := broker.Subscribe()
defer broker.Unsubscribe(clientChan)
for {
select {
case entry := <-clientChan:
data, err := json.Marshal(entry)
if err != nil {
log.Printf("Erro ao serializar entrada de log: %v", err)
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
return
}
}
for {
select {
case entry := <-clientChan:
data, err := json.Marshal(entry)
if err != nil {
log.Printf("Erro ao serializar entrada de log: %v", err)
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
// handleFiles lista os arquivos de log disponíveis
func handleFiles(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logFiles)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logFiles)
}
func handleLastLines(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
file := r.URL.Query().Get("file")
nParam := r.URL.Query().Get("n")
n := 200
if nParam != "" {
fmt.Sscanf(nParam, "%d", &n)
}
allowed := false
for _, f := range logFiles {
if f == file {
allowed = true
break
}
}
if !allowed {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("arquivo inválido"))
return
}
entries, err := getLastLines(file, n)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("erro ao ler arquivo"))
return
}
json.NewEncoder(w).Encode(entries)
w.Header().Set("Content-Type", "application/json")
file := r.URL.Query().Get("file")
nParam := r.URL.Query().Get("n")
n := 200
if nParam != "" {
fmt.Sscanf(nParam, "%d", &n)
}
allowed := false
for _, f := range logFiles {
if f == file {
allowed = true
break
}
}
if !allowed {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("arquivo inválido"))
return
}
entries, err := getLastLines(file, n)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("erro ao ler arquivo"))
return
}
json.NewEncoder(w).Encode(entries)
}
func getLastLines(filename string, n int) ([]LogEntry, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
buf := make([]string, 0, n)
for scanner.Scan() {
line := scanner.Text()
if len(buf) == n {
buf = buf[1:]
}
buf = append(buf, line)
}
if err := scanner.Err(); err != nil {
return nil, err
}
tsRe1 := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
tsRe2 := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
entries := make([]LogEntry, 0, len(buf))
for _, line := range buf {
t := tsRe1.FindString(line)
if t == "" {
t = tsRe2.FindString(line)
}
entries = append(entries, LogEntry{Filename: filename, Line: strings.TrimRight(line, "\r"), Timestamp: t})
}
return entries, nil
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
buf := make([]string, 0, n)
for scanner.Scan() {
line := scanner.Text()
if len(buf) == n {
buf = buf[1:]
}
buf = append(buf, line)
}
if err := scanner.Err(); err != nil {
return nil, err
}
tsRe1 := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
tsRe2 := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
entries := make([]LogEntry, 0, len(buf))
for _, line := range buf {
t := tsRe1.FindString(line)
if t == "" {
t = tsRe2.FindString(line)
}
entries = append(entries, LogEntry{Filename: filename, Line: strings.TrimRight(line, "\r"), Timestamp: t})
}
return entries, nil
}
// startTailing inicia o monitoramento dos arquivos
func startTailing(filenames []string) {
for _, filename := range filenames {
tailConfig := tail.Config{
Follow: true,
ReOpen: true,
Location: &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END},
}
t, err := tail.TailFile(filename, tailConfig)
if err != nil {
log.Printf("Erro ao fazer tail do arquivo %s: %v", filename, err)
continue
}
log.Printf("Iniciando tail no arquivo: %s", filename)
go func(t *tail.Tail, fn string) {
for line := range t.Lines {
if line.Err != nil {
log.Printf("Erro lendo linha de %s: %v", fn, line.Err)
continue
}
timestamp := ""
if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
timestamp = match
} else if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
timestamp = match
}
broker.Broadcast(LogEntry{Filename: fn, Line: line.Text, Timestamp: timestamp})
}
}(t, filename)
}
for _, filename := range filenames {
tailConfig := tail.Config{
Follow: true,
ReOpen: true,
Location: &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END},
}
t, err := tail.TailFile(filename, tailConfig)
if err != nil {
log.Printf("Erro ao fazer tail do arquivo %s: %v", filename, err)
continue
}
log.Printf("Iniciando tail no arquivo: %s", filename)
go func(t *tail.Tail, fn string) {
for line := range t.Lines {
if line.Err != nil {
log.Printf("Erro lendo linha de %s: %v", fn, line.Err)
continue
}
timestamp := ""
if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
timestamp = match
} else if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
timestamp = match
}
broker.Broadcast(LogEntry{Filename: fn, Line: line.Text, Timestamp: timestamp})
}
}(t, filename)
}
}
// authMiddleware é um middleware que protege as rotas com HTTP Basic Auth
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if password == "" {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok {
requestAuth(w)
return
}
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte(username)) == 1
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(password)) == 1
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
} else {
requestAuth(w)
}
})
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if password == "" {
next.ServeHTTP(w, r)
return
}
user, pass, ok := r.BasicAuth()
if !ok {
requestAuth(w)
return
}
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte(username)) == 1
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(password)) == 1
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
} else {
requestAuth(w)
}
})
}
// requestAuth envia o header 401 Unauthorized
func requestAuth(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Web Tail Pro - Acesso Restrito"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Não autorizado.\n"))
w.Header().Set("WWW-Authenticate", `Basic realm="Web Tail Pro - Acesso Restrito"`)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Não autorizado.\n"))
}
func main() {
flag.Parse()
logFiles = flag.Args()
if len(logFiles) < 1 {
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
fmt.Println("\nOpções:")
flag.PrintDefaults()
os.Exit(1)
}
flag.Parse()
logFiles = flag.Args()
if len(logFiles) < 1 {
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
fmt.Println("\nOpções:")
flag.PrintDefaults()
os.Exit(1)
}
// Configurar tratamento de sinais para desligamento gracioso
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
log.Printf("Recebido sinal %v, encerrando servidor...", sig)
os.Exit(0)
}()
// Configurar tratamento de sinais para desligamento gracioso
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
log.Printf("Recebido sinal %v, encerrando servidor...", sig)
os.Exit(0)
}()
startTailing(logFiles)
startTailing(logFiles)
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
log.Fatal("Erro ao criar sub-filesystem para arquivos estáticos:", err)
}
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
log.Fatal("Erro ao criar sub-filesystem para arquivos estáticos:", err)
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(distFS)))
mux.HandleFunc("/logs", handleSSE)
mux.HandleFunc("/api/files", handleFiles)
mux.HandleFunc("/api/last", handleLastLines)
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.FS(distFS)))
mux.HandleFunc("/logs", handleSSE)
mux.HandleFunc("/api/files", handleFiles)
mux.HandleFunc("/api/last", handleLastLines)
protectedHandler := authMiddleware(mux)
protectedHandler := authMiddleware(mux)
log.Printf("Servidor iniciado em http://localhost%s", port)
if password != "" {
log.Println("🔐 Autenticação por senha está ATIVADA.")
}
if err := http.ListenAndServe(port, protectedHandler); err != nil {
log.Fatal("Erro ao iniciar o servidor:", err)
}
log.Printf("Servidor iniciado em http://localhost%s", port)
if password != "" {
log.Println("🔐 Autenticação por senha está ATIVADA.")
}
if err := http.ListenAndServe(port, protectedHandler); err != nil {
log.Fatal("Erro ao iniciar o servidor:", err)
}
}