feat: Implement initial Go backend and React frontend project structure for the gotail application.
This commit is contained in:
Vendored
+12
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<html><body>Placeholder</body></html>
|
||||
+9
-2
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user