initial commit
This commit is contained in:
commit
885e7d6e02
22 changed files with 3001 additions and 0 deletions
291
cmd/root.go
Normal file
291
cmd/root.go
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mr0xb/nxstats/internal/geo"
|
||||
"github.com/mr0xb/nxstats/internal/parser"
|
||||
"github.com/mr0xb/nxstats/internal/report"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
flagDir string
|
||||
flagOutput string
|
||||
flagAccessLog string
|
||||
flagErrorLog string
|
||||
flagNoGzip bool
|
||||
flagGeoIP string
|
||||
flagSplitByDay bool
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "nxstats",
|
||||
Short: "Nginx log parser and HTML report generator",
|
||||
Long: `nxstats parses nginx access and error logs (including gzip-compressed
|
||||
rotated logs) and generates a rich cyberpunk-themed HTML report with
|
||||
charts, searchable tables, and optional GeoIP2 hit maps.`,
|
||||
RunE: runE,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().StringVarP(&flagDir, "dir", "d", "/var/log/nginx", "Directory to scan for nginx logs")
|
||||
rootCmd.Flags().StringVarP(&flagOutput, "output", "o", "report.html", "Output HTML file path")
|
||||
rootCmd.Flags().StringVarP(&flagAccessLog, "access-log", "a", "", "Specific access log file (skips dir scan for access logs)")
|
||||
rootCmd.Flags().StringVarP(&flagErrorLog, "error-log", "e", "", "Specific error log file (skips dir scan for error logs)")
|
||||
rootCmd.Flags().BoolVar(&flagNoGzip, "no-gzip", false, "Skip .gz compressed rotated logs")
|
||||
rootCmd.Flags().StringVar(&flagGeoIP, "geoip", "", "Path to MaxMind GeoLite2-City.mmdb (enables map)")
|
||||
rootCmd.Flags().BoolVar(&flagSplitByDay, "split-by-day", false, "Write one HTML report per calendar day plus an index page")
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runE(cmd *cobra.Command, args []string) error {
|
||||
accessFiles, errorFiles, err := resolveLogFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "nxstats: reading %d access file(s), %d error file(s)\n",
|
||||
len(accessFiles), len(errorFiles))
|
||||
|
||||
accessEntries, err := parser.ReadAccessEntries(accessFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read access logs: %w", err)
|
||||
}
|
||||
errorEntries, err := parser.ReadErrorEntries(errorFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read error logs: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "nxstats: %d access entries, %d error entries\n",
|
||||
len(accessEntries), len(errorEntries))
|
||||
|
||||
gl, err := geo.NewGeoLookup(flagGeoIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("geoip: %w", err)
|
||||
}
|
||||
defer gl.Close()
|
||||
|
||||
// Build global IP→GeoPoint map (one lookup per unique IP, reused per day).
|
||||
geoPoints := buildGeoPoints(gl, accessEntries)
|
||||
|
||||
if flagSplitByDay {
|
||||
return runSplitByDay(accessEntries, errorEntries, geoPoints)
|
||||
}
|
||||
|
||||
// ── Single-report path ─────────────────────────────────────────────────
|
||||
data := report.ReportData{
|
||||
GeneratedAt: time.Now(),
|
||||
AccessEntries: accessEntries,
|
||||
ErrorEntries: errorEntries,
|
||||
}
|
||||
report.ComputeStats(&data)
|
||||
|
||||
if gl.IsEnabled() {
|
||||
pts := geoPointsSlice(geoPoints)
|
||||
data.GeoLocations = pts
|
||||
fmt.Fprintf(os.Stderr, "nxstats: resolved %d geo locations\n", len(pts))
|
||||
}
|
||||
|
||||
html, err := report.GenerateHTML(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate html: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(flagOutput, []byte(html), 0644); err != nil {
|
||||
return fmt.Errorf("write output %q: %w", flagOutput, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "nxstats: report written to %s\n", flagOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveLogFiles returns the access and error file lists, honouring --access-log,
|
||||
// --error-log, and --dir flags.
|
||||
func resolveLogFiles() (accessFiles, errorFiles []string, err error) {
|
||||
includeGzip := !flagNoGzip
|
||||
|
||||
if flagAccessLog != "" {
|
||||
accessFiles = []string{flagAccessLog}
|
||||
} else {
|
||||
accessFiles, _, err = parser.ScanDirectory(flagDir, includeGzip)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("scan dir %q: %w", flagDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
if flagErrorLog != "" {
|
||||
errorFiles = []string{flagErrorLog}
|
||||
} else {
|
||||
_, errorFiles, err = parser.ScanDirectory(flagDir, includeGzip)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("scan dir %q: %w", flagDir, err)
|
||||
}
|
||||
}
|
||||
return accessFiles, errorFiles, nil
|
||||
}
|
||||
|
||||
// buildGeoPoints runs GeoIP lookups for all unique IPs in accessEntries and
|
||||
// returns a map of ip string → report.GeoPoint. Returns an empty map when
|
||||
// GeoIP is disabled.
|
||||
func buildGeoPoints(gl *geo.GeoLookup, accessEntries []parser.AccessEntry) map[string]report.GeoPoint {
|
||||
result := make(map[string]report.GeoPoint)
|
||||
if !gl.IsEnabled() {
|
||||
return result
|
||||
}
|
||||
for _, e := range accessEntries {
|
||||
if _, seen := result[e.RemoteAddr]; seen {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(e.RemoteAddr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
pt := gl.Lookup(ip)
|
||||
if pt == nil {
|
||||
continue
|
||||
}
|
||||
result[e.RemoteAddr] = report.GeoPoint{
|
||||
IP: pt.IP,
|
||||
Lat: pt.Lat,
|
||||
Lon: pt.Lon,
|
||||
Country: pt.Country,
|
||||
CountryCode: pt.CountryCode,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// geoPointsSlice converts the geo map to a slice for a single-report run,
|
||||
// tallying the request count for each IP from a pre-built count map.
|
||||
func geoPointsSlice(geoPoints map[string]report.GeoPoint) []report.GeoPoint {
|
||||
pts := make([]report.GeoPoint, 0, len(geoPoints))
|
||||
for _, p := range geoPoints {
|
||||
pts = append(pts, p)
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// runSplitByDay groups entries by calendar day, writes one HTML file per day,
|
||||
// then writes the index page to flagOutput.
|
||||
func runSplitByDay(
|
||||
accessEntries []parser.AccessEntry,
|
||||
errorEntries []parser.ErrorEntry,
|
||||
geoPoints map[string]report.GeoPoint,
|
||||
) error {
|
||||
indexBase := filepath.Base(flagOutput)
|
||||
|
||||
days := report.GroupByDay(flagOutput, accessEntries, errorEntries, geoPoints)
|
||||
if len(days) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "nxstats: no entries found, writing empty index")
|
||||
}
|
||||
|
||||
// Write per-day reports (oldest → newest)
|
||||
for i := range days {
|
||||
days[i].Data.IndexFile = indexBase
|
||||
days[i].Data.DayTitle = days[i].Date.Format("2006-01-02")
|
||||
|
||||
// Per-day geo: set Count from this day's access entries
|
||||
if len(geoPoints) > 0 {
|
||||
countByIP := make(map[string]int)
|
||||
for _, e := range days[i].Data.AccessEntries {
|
||||
countByIP[e.RemoteAddr]++
|
||||
}
|
||||
updated := make([]report.GeoPoint, len(days[i].Data.GeoLocations))
|
||||
for j, g := range days[i].Data.GeoLocations {
|
||||
g.Count = countByIP[g.IP]
|
||||
updated[j] = g
|
||||
}
|
||||
days[i].Data.GeoLocations = updated
|
||||
}
|
||||
|
||||
html, err := report.GenerateHTML(days[i].Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate html for %s: %w", days[i].Filename, err)
|
||||
}
|
||||
dailyPath := filepath.Join(filepath.Dir(flagOutput), days[i].Filename)
|
||||
if err := os.WriteFile(dailyPath, []byte(html), 0644); err != nil {
|
||||
return fmt.Errorf("write %q: %w", dailyPath, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "nxstats: wrote %s\n", dailyPath)
|
||||
}
|
||||
|
||||
// Build IndexData (days newest first in the table)
|
||||
idx := buildIndexData(accessEntries, days)
|
||||
|
||||
indexHTML, err := report.GenerateIndexHTML(idx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate index html: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(flagOutput, []byte(indexHTML), 0644); err != nil {
|
||||
return fmt.Errorf("write index %q: %w", flagOutput, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "nxstats: index written to %s (%d daily report(s))\n",
|
||||
flagOutput, len(days))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildIndexData assembles the IndexData from all daily reports. Days are
|
||||
// returned newest-first in the table. Unique IPs are counted across all
|
||||
// access entries (not summed per day) to avoid double-counting.
|
||||
func buildIndexData(allAccess []parser.AccessEntry, days []report.DayReport) report.IndexData {
|
||||
// Reverse a copy for newest-first display
|
||||
summaries := make([]report.DaySummary, len(days))
|
||||
for i, d := range days {
|
||||
summaries[len(days)-1-i] = report.DaySummary{
|
||||
Date: d.Date,
|
||||
Filename: d.Filename,
|
||||
Requests: d.Data.TotalRequests,
|
||||
UniqueIPs: d.Data.UniqueIPs,
|
||||
TotalBytes: d.Data.TotalBytes,
|
||||
ErrorRate: d.Data.ErrorRate,
|
||||
}
|
||||
}
|
||||
|
||||
// Sort summaries newest first (they were built from oldest-first days)
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
return summaries[i].Date.After(summaries[j].Date)
|
||||
})
|
||||
|
||||
var totalReqs int
|
||||
var totalBytes int64
|
||||
var totalErrors int
|
||||
for _, d := range days {
|
||||
totalReqs += d.Data.TotalRequests
|
||||
totalBytes += d.Data.TotalBytes
|
||||
for status, count := range d.Data.StatusCounts {
|
||||
if status >= 500 {
|
||||
totalErrors += count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unique IPs across all days (union, not sum)
|
||||
ipSet := make(map[string]struct{}, len(allAccess))
|
||||
for _, e := range allAccess {
|
||||
ipSet[e.RemoteAddr] = struct{}{}
|
||||
}
|
||||
|
||||
var errorRate float64
|
||||
if totalReqs > 0 {
|
||||
errorRate = float64(totalErrors) / float64(totalReqs) * 100.0
|
||||
}
|
||||
|
||||
return report.IndexData{
|
||||
GeneratedAt: time.Now(),
|
||||
Days: summaries,
|
||||
TotalRequests: totalReqs,
|
||||
TotalUniqueIPs: len(ipSet),
|
||||
TotalBytes: totalBytes,
|
||||
OverallErrorRate: errorRate,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue