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