Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 5 5 .DS_Store 6 6 plc_cache\.tmp/* 7 7 plc_bundles* 8 - config.yaml 8 + config.yaml 9 + /atscand
+39 -5
Makefile
··· 1 - all: run 1 + .PHONY: all build install test clean fmt lint help 2 + 3 + # Binary name 4 + BINARY_NAME=atscand 5 + INSTALL_PATH=$(GOPATH)/bin 6 + 7 + # Go commands 8 + GOCMD=go 9 + GOBUILD=$(GOCMD) build 10 + GOINSTALL=$(GOCMD) install 11 + GOCLEAN=$(GOCMD) clean 12 + GOTEST=$(GOCMD) test 13 + GOGET=$(GOCMD) get 14 + GOFMT=$(GOCMD) fmt 15 + GOMOD=$(GOCMD) mod 16 + GORUN=$(GOCMD) run 17 + 18 + # Default target 19 + all: build 20 + 21 + # Build the CLI tool 22 + build: 23 + @echo "Building $(BINARY_NAME)..." 24 + $(GOBUILD) -o $(BINARY_NAME) ./cmd/atscand 25 + 26 + # Install the CLI tool globally 27 + install: 28 + @echo "Installing $(BINARY_NAME)..." 29 + $(GOINSTALL) ./cmd/atscand 2 30 3 31 run: 4 - go run cmd/atscanner.go -verbose 32 + $(GORUN) cmd/atscand/main.go -verbose 5 33 6 - clean-db: 7 - dropdb -U atscanner atscanner 8 - createdb atscanner -O atscanner 34 + update-plcbundle: 35 + GOPROXY=direct go get -u tangled.org/atscan.net/plcbundle@latest 36 + 37 + # Show help 38 + help: 39 + @echo "Available targets:" 40 + @echo " make build - Build the binary" 41 + @echo " make install - Install binary globally" 42 + @echo " make run - Run app"
+159
cmd/atscand/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "fmt" 7 + "os" 8 + "os/signal" 9 + "syscall" 10 + "time" 11 + 12 + "github.com/atscan/atscand/internal/api" 13 + "github.com/atscan/atscand/internal/config" 14 + "github.com/atscan/atscand/internal/log" 15 + "github.com/atscan/atscand/internal/pds" 16 + "github.com/atscan/atscand/internal/plc" 17 + "github.com/atscan/atscand/internal/storage" 18 + "github.com/atscan/atscand/internal/worker" 19 + ) 20 + 21 + const VERSION = "1.0.0" 22 + 23 + func main() { 24 + configPath := flag.String("config", "config.yaml", "path to config file") 25 + verbose := flag.Bool("verbose", false, "enable verbose logging") 26 + flag.Parse() 27 + 28 + // Load configuration 29 + cfg, err := config.Load(*configPath) 30 + if err != nil { 31 + fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) 32 + os.Exit(1) 33 + } 34 + 35 + // Override verbose setting if flag is provided 36 + if *verbose { 37 + cfg.API.Verbose = true 38 + } 39 + 40 + // Initialize logger 41 + log.Init(cfg.API.Verbose) 42 + 43 + // Print banner 44 + log.Banner(VERSION) 45 + 46 + // Print configuration summary 47 + log.PrintConfig(map[string]string{ 48 + "Database Type": cfg.Database.Type, 49 + "Database Path": cfg.Database.Path, // Will be auto-redacted 50 + "PLC Directory": cfg.PLC.DirectoryURL, 51 + "PLC Scan Interval": cfg.PLC.ScanInterval.String(), 52 + "PLC Bundle Dir": cfg.PLC.BundleDir, 53 + "PLC Cache": fmt.Sprintf("%v", cfg.PLC.UseCache), 54 + "PLC Index DIDs": fmt.Sprintf("%v", cfg.PLC.IndexDIDs), 55 + "PDS Scan Interval": cfg.PDS.ScanInterval.String(), 56 + "PDS Workers": fmt.Sprintf("%d", cfg.PDS.Workers), 57 + "PDS Timeout": cfg.PDS.Timeout.String(), 58 + "API Host": cfg.API.Host, 59 + "API Port": fmt.Sprintf("%d", cfg.API.Port), 60 + "Verbose Logging": fmt.Sprintf("%v", cfg.API.Verbose), 61 + }) 62 + 63 + // Initialize database using factory pattern 64 + db, err := storage.NewDatabase(cfg.Database.Type, cfg.Database.Path) 65 + if err != nil { 66 + log.Fatal("Failed to initialize database: %v", err) 67 + } 68 + defer func() { 69 + log.Info("Closing database connection...") 70 + db.Close() 71 + }() 72 + 73 + // Set scan retention from config 74 + if cfg.PDS.ScanRetention > 0 { 75 + db.SetScanRetention(cfg.PDS.ScanRetention) 76 + log.Verbose("Scan retention set to %d scans per endpoint", cfg.PDS.ScanRetention) 77 + } 78 + 79 + // Run migrations 80 + if err := db.Migrate(); err != nil { 81 + log.Fatal("Failed to run migrations: %v", err) 82 + } 83 + 84 + ctx, cancel := context.WithCancel(context.Background()) 85 + defer cancel() 86 + 87 + // Initialize workers 88 + log.Info("Initializing scanners...") 89 + 90 + bundleManager, err := plc.NewBundleManager(cfg.PLC.BundleDir, cfg.PLC.DirectoryURL, db, cfg.PLC.IndexDIDs) 91 + if err != nil { 92 + log.Fatal("Failed to create bundle manager: %v", err) 93 + } 94 + defer bundleManager.Close() 95 + log.Verbose("✓ Bundle manager initialized (shared)") 96 + 97 + plcScanner := plc.NewScanner(db, cfg.PLC, bundleManager) 98 + defer plcScanner.Close() 99 + log.Verbose("✓ PLC scanner initialized") 100 + 101 + pdsScanner := pds.NewScanner(db, cfg.PDS) 102 + log.Verbose("✓ PDS scanner initialized") 103 + 104 + scheduler := worker.NewScheduler() 105 + 106 + // Schedule PLC directory scan 107 + scheduler.AddJob("plc_scan", cfg.PLC.ScanInterval, func() { 108 + if err := plcScanner.Scan(ctx); err != nil { 109 + log.Error("PLC scan error: %v", err) 110 + } 111 + }) 112 + log.Verbose("✓ PLC scan job scheduled (interval: %s)", cfg.PLC.ScanInterval) 113 + 114 + // Schedule PDS availability checks 115 + scheduler.AddJob("pds_scan", cfg.PDS.ScanInterval, func() { 116 + if err := pdsScanner.ScanAll(ctx); err != nil { 117 + log.Error("PDS scan error: %v", err) 118 + } 119 + }) 120 + log.Verbose("✓ PDS scan job scheduled (interval: %s)", cfg.PDS.ScanInterval) 121 + 122 + // Start API server 123 + log.Info("Starting API server on %s:%d...", cfg.API.Host, cfg.API.Port) 124 + apiServer := api.NewServer(db, cfg.API, cfg.PLC, bundleManager) 125 + go func() { 126 + if err := apiServer.Start(); err != nil { 127 + log.Fatal("API server error: %v", err) 128 + } 129 + }() 130 + 131 + // Give the API server a moment to start 132 + time.Sleep(100 * time.Millisecond) 133 + log.Info("✓ API server started successfully") 134 + log.Info("") 135 + log.Info("🚀 ATScanner is running!") 136 + log.Info(" API available at: http://%s:%d", cfg.API.Host, cfg.API.Port) 137 + log.Info(" Press Ctrl+C to stop") 138 + log.Info("") 139 + 140 + // Start scheduler 141 + scheduler.Start(ctx) 142 + 143 + // Wait for interrupt 144 + sigChan := make(chan os.Signal, 1) 145 + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 146 + <-sigChan 147 + 148 + log.Info("") 149 + log.Info("Shutting down gracefully...") 150 + cancel() 151 + 152 + log.Info("Stopping API server...") 153 + apiServer.Shutdown(context.Background()) 154 + 155 + log.Info("Waiting for active tasks to complete...") 156 + time.Sleep(2 * time.Second) 157 + 158 + log.Info("✓ Shutdown complete. Goodbye!") 159 + }
-152
cmd/atscanner.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "flag" 6 - "fmt" 7 - "os" 8 - "os/signal" 9 - "syscall" 10 - "time" 11 - 12 - "github.com/atscan/atscanner/internal/api" 13 - "github.com/atscan/atscanner/internal/config" 14 - "github.com/atscan/atscanner/internal/log" 15 - "github.com/atscan/atscanner/internal/pds" 16 - "github.com/atscan/atscanner/internal/plc" 17 - "github.com/atscan/atscanner/internal/storage" 18 - "github.com/atscan/atscanner/internal/worker" 19 - ) 20 - 21 - const VERSION = "1.0.0" 22 - 23 - func main() { 24 - configPath := flag.String("config", "config.yaml", "path to config file") 25 - verbose := flag.Bool("verbose", false, "enable verbose logging") 26 - flag.Parse() 27 - 28 - // Load configuration 29 - cfg, err := config.Load(*configPath) 30 - if err != nil { 31 - fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) 32 - os.Exit(1) 33 - } 34 - 35 - // Override verbose setting if flag is provided 36 - if *verbose { 37 - cfg.API.Verbose = true 38 - } 39 - 40 - // Initialize logger 41 - log.Init(cfg.API.Verbose) 42 - 43 - // Print banner 44 - log.Banner(VERSION) 45 - 46 - // Print configuration summary 47 - log.PrintConfig(map[string]string{ 48 - "Database Type": cfg.Database.Type, 49 - "Database Path": cfg.Database.Path, // Will be auto-redacted 50 - "PLC Directory": cfg.PLC.DirectoryURL, 51 - "PLC Scan Interval": cfg.PLC.ScanInterval.String(), 52 - "PLC Bundle Dir": cfg.PLC.BundleDir, 53 - "PLC Cache": fmt.Sprintf("%v", cfg.PLC.UseCache), 54 - "PLC Index DIDs": fmt.Sprintf("%v", cfg.PLC.IndexDIDs), 55 - "PDS Scan Interval": cfg.PDS.ScanInterval.String(), 56 - "PDS Workers": fmt.Sprintf("%d", cfg.PDS.Workers), 57 - "PDS Timeout": cfg.PDS.Timeout.String(), 58 - "API Host": cfg.API.Host, 59 - "API Port": fmt.Sprintf("%d", cfg.API.Port), 60 - "Verbose Logging": fmt.Sprintf("%v", cfg.API.Verbose), 61 - }) 62 - 63 - // Initialize database using factory pattern 64 - db, err := storage.NewDatabase(cfg.Database.Type, cfg.Database.Path) 65 - if err != nil { 66 - log.Fatal("Failed to initialize database: %v", err) 67 - } 68 - defer func() { 69 - log.Info("Closing database connection...") 70 - db.Close() 71 - }() 72 - 73 - // Set scan retention from config 74 - if cfg.PDS.ScanRetention > 0 { 75 - db.SetScanRetention(cfg.PDS.ScanRetention) 76 - log.Verbose("Scan retention set to %d scans per endpoint", cfg.PDS.ScanRetention) 77 - } 78 - 79 - // Run migrations 80 - if err := db.Migrate(); err != nil { 81 - log.Fatal("Failed to run migrations: %v", err) 82 - } 83 - 84 - ctx, cancel := context.WithCancel(context.Background()) 85 - defer cancel() 86 - 87 - // Initialize workers 88 - log.Info("Initializing scanners...") 89 - 90 - plcScanner := plc.NewScanner(db, cfg.PLC) 91 - defer plcScanner.Close() 92 - log.Verbose("✓ PLC scanner initialized") 93 - 94 - pdsScanner := pds.NewScanner(db, cfg.PDS) 95 - log.Verbose("✓ PDS scanner initialized") 96 - 97 - scheduler := worker.NewScheduler() 98 - 99 - // Schedule PLC directory scan 100 - scheduler.AddJob("plc_scan", cfg.PLC.ScanInterval, func() { 101 - if err := plcScanner.Scan(ctx); err != nil { 102 - log.Error("PLC scan error: %v", err) 103 - } 104 - }) 105 - log.Verbose("✓ PLC scan job scheduled (interval: %s)", cfg.PLC.ScanInterval) 106 - 107 - // Schedule PDS availability checks 108 - scheduler.AddJob("pds_scan", cfg.PDS.ScanInterval, func() { 109 - if err := pdsScanner.ScanAll(ctx); err != nil { 110 - log.Error("PDS scan error: %v", err) 111 - } 112 - }) 113 - log.Verbose("✓ PDS scan job scheduled (interval: %s)", cfg.PDS.ScanInterval) 114 - 115 - // Start API server 116 - log.Info("Starting API server on %s:%d...", cfg.API.Host, cfg.API.Port) 117 - apiServer := api.NewServer(db, cfg.API, cfg.PLC) 118 - go func() { 119 - if err := apiServer.Start(); err != nil { 120 - log.Fatal("API server error: %v", err) 121 - } 122 - }() 123 - 124 - // Give the API server a moment to start 125 - time.Sleep(100 * time.Millisecond) 126 - log.Info("✓ API server started successfully") 127 - log.Info("") 128 - log.Info("🚀 ATScanner is running!") 129 - log.Info(" API available at: http://%s:%d", cfg.API.Host, cfg.API.Port) 130 - log.Info(" Press Ctrl+C to stop") 131 - log.Info("") 132 - 133 - // Start scheduler 134 - scheduler.Start(ctx) 135 - 136 - // Wait for interrupt 137 - sigChan := make(chan os.Signal, 1) 138 - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 139 - <-sigChan 140 - 141 - log.Info("") 142 - log.Info("Shutting down gracefully...") 143 - cancel() 144 - 145 - log.Info("Stopping API server...") 146 - apiServer.Shutdown(context.Background()) 147 - 148 - log.Info("Waiting for active tasks to complete...") 149 - time.Sleep(2 * time.Second) 150 - 151 - log.Info("✓ Shutdown complete. Goodbye!") 152 - }
+168
cmd/import-labels/main.go
··· 1 + package main 2 + 3 + import ( 4 + "bufio" 5 + "flag" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + "time" 11 + 12 + "github.com/klauspost/compress/zstd" 13 + "gopkg.in/yaml.v3" 14 + ) 15 + 16 + type Config struct { 17 + PLC struct { 18 + BundleDir string `yaml:"bundle_dir"` 19 + } `yaml:"plc"` 20 + } 21 + 22 + var CONFIG_FILE = "config.yaml" 23 + 24 + // --------------------- 25 + 26 + func main() { 27 + // Define a new flag for changing the directory 28 + workDir := flag.String("C", ".", "Change to this directory before running (for finding config.yaml)") 29 + flag.Usage = func() { // Custom usage message 30 + fmt.Fprintf(os.Stderr, "Usage: ... | %s [-C /path/to/dir]\n", os.Args[0]) 31 + fmt.Fprintln(os.Stderr, "Reads sorted CSV from stdin and writes compressed bundle files.") 32 + flag.PrintDefaults() 33 + } 34 + flag.Parse() // Parse all defined flags 35 + 36 + // Change directory if the flag was used 37 + if *workDir != "." { 38 + fmt.Printf("Changing working directory to %s...\n", *workDir) 39 + if err := os.Chdir(*workDir); err != nil { 40 + fmt.Fprintf(os.Stderr, "Error changing directory to %s: %v\n", *workDir, err) 41 + os.Exit(1) 42 + } 43 + } 44 + 45 + // --- REMOVED UNUSED CODE --- 46 + // The csvFilePath variable and NArg check were removed 47 + // as the script now reads from stdin. 48 + // --------------------------- 49 + 50 + fmt.Println("========================================") 51 + fmt.Println("PLC Operation Labels Import (Go STDIN)") 52 + fmt.Println("========================================") 53 + 54 + // 1. Read config (will now read from the new CWD) 55 + fmt.Printf("Loading config from %s...\n", CONFIG_FILE) 56 + configData, err := os.ReadFile(CONFIG_FILE) 57 + if err != nil { 58 + fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err) 59 + os.Exit(1) 60 + } 61 + 62 + var config Config 63 + if err := yaml.Unmarshal(configData, &config); err != nil { 64 + fmt.Fprintf(os.Stderr, "Error parsing config.yaml: %v\n", err) 65 + os.Exit(1) 66 + } 67 + 68 + if config.PLC.BundleDir == "" { 69 + fmt.Fprintln(os.Stderr, "Error: Could not parse plc.bundle_dir from config.yaml") 70 + os.Exit(1) 71 + } 72 + 73 + finalLabelsDir := filepath.Join(config.PLC.BundleDir, "labels") 74 + if err := os.MkdirAll(finalLabelsDir, 0755); err != nil { 75 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 76 + os.Exit(1) 77 + } 78 + 79 + fmt.Printf("Output Dir: %s\n", finalLabelsDir) 80 + fmt.Println("Waiting for sorted data from stdin...") 81 + 82 + // 2. Process sorted data from stdin 83 + // This script *requires* the input to be sorted by bundle number. 84 + 85 + var currentWriter *zstd.Encoder 86 + var currentFile *os.File 87 + var lastBundleKey string = "" 88 + 89 + lineCount := 0 90 + startTime := time.Now() 91 + 92 + scanner := bufio.NewScanner(os.Stdin) 93 + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) 94 + 95 + for scanner.Scan() { 96 + line := scanner.Text() 97 + lineCount++ 98 + 99 + parts := strings.SplitN(line, ",", 2) 100 + if len(parts) < 1 { 101 + continue // Skip empty/bad lines 102 + } 103 + 104 + bundleNumStr := parts[0] 105 + bundleKey := fmt.Sprintf("%06s", bundleNumStr) // Pad with zeros 106 + 107 + // If the bundle key is new, close the old writer and open a new one. 108 + if bundleKey != lastBundleKey { 109 + // Close the previous writer/file 110 + if currentWriter != nil { 111 + if err := currentWriter.Close(); err != nil { 112 + fmt.Fprintf(os.Stderr, "Error closing writer for %s: %v\n", lastBundleKey, err) 113 + } 114 + currentFile.Close() 115 + } 116 + 117 + // Start the new one 118 + fmt.Printf(" -> Writing bundle %s\n", bundleKey) 119 + outPath := filepath.Join(finalLabelsDir, fmt.Sprintf("%s.csv.zst", bundleKey)) 120 + 121 + file, err := os.Create(outPath) 122 + if err != nil { 123 + fmt.Fprintf(os.Stderr, "Error creating file %s: %v\n", outPath, err) 124 + os.Exit(1) 125 + } 126 + currentFile = file 127 + 128 + writer, err := zstd.NewWriter(file) 129 + if err != nil { 130 + fmt.Fprintf(os.Stderr, "Error creating zstd writer: %v\n", err) 131 + os.Exit(1) 132 + } 133 + currentWriter = writer 134 + lastBundleKey = bundleKey 135 + } 136 + 137 + // Write the line to the currently active writer 138 + if _, err := currentWriter.Write([]byte(line + "\n")); err != nil { 139 + fmt.Fprintf(os.Stderr, "Error writing line: %v\n", err) 140 + } 141 + 142 + // Progress update 143 + if lineCount%100000 == 0 { 144 + elapsed := time.Since(startTime).Seconds() 145 + rate := float64(lineCount) / elapsed 146 + fmt.Printf(" ... processed %d lines (%.0f lines/sec)\n", lineCount, rate) 147 + } 148 + } 149 + 150 + // 3. Close the very last writer 151 + if currentWriter != nil { 152 + if err := currentWriter.Close(); err != nil { 153 + fmt.Fprintf(os.Stderr, "Error closing final writer: %v\n", err) 154 + } 155 + currentFile.Close() 156 + } 157 + 158 + if err := scanner.Err(); err != nil { 159 + fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err) 160 + } 161 + 162 + totalTime := time.Since(startTime) 163 + fmt.Println("\n========================================") 164 + fmt.Println("Import Summary") 165 + fmt.Println("========================================") 166 + fmt.Printf("✓ Import completed in %v\n", totalTime) 167 + fmt.Printf("Total lines processed: %d\n", lineCount) 168 + }
+1 -1
config.sample.yaml
··· 1 1 database: 2 2 type: "postgres" # or "sqlite" 3 - path: "postgres://atscanner:YOUR_PASSWORD@localhost:5432/atscanner?sslmode=disable" 3 + path: "postgres://atscand:YOUR_PASSWORD@localhost:5432/atscand?sslmode=disable" 4 4 # For SQLite: path: "atscan.db" 5 5 6 6 plc:
+6 -5
go.mod
··· 1 - module github.com/atscan/atscanner 1 + module github.com/atscan/atscand 2 2 3 3 go 1.23.0 4 4 5 5 require ( 6 6 github.com/gorilla/mux v1.8.1 7 7 github.com/lib/pq v1.10.9 8 - github.com/mattn/go-sqlite3 v1.14.18 9 8 gopkg.in/yaml.v3 v3.0.1 10 9 ) 11 10 12 - require github.com/klauspost/compress v1.18.0 11 + require github.com/klauspost/compress v1.18.1 13 12 14 13 require ( 15 - github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d 16 14 github.com/gorilla/handlers v1.5.2 15 + github.com/jackc/pgx/v5 v5.7.6 16 + tangled.org/atscan.net/plcbundle v0.3.6 17 17 ) 18 18 19 19 require ( 20 20 github.com/felixge/httpsnoop v1.0.3 // indirect 21 21 github.com/jackc/pgpassfile v1.0.0 // indirect 22 22 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 23 - github.com/jackc/pgx/v5 v5.7.6 // indirect 24 23 github.com/jackc/puddle/v2 v2.2.2 // indirect 24 + github.com/kr/text v0.2.0 // indirect 25 + github.com/rogpeppe/go-internal v1.14.1 // indirect 25 26 golang.org/x/crypto v0.37.0 // indirect 26 27 golang.org/x/sync v0.13.0 // indirect 27 28 golang.org/x/text v0.24.0 // indirect
+17 -7
go.sum
··· 1 - github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 2 - github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 1 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 3 2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 5 github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= 5 6 github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 6 7 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= ··· 15 16 github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 16 17 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 17 18 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 18 - github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 19 - github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 19 + github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= 20 + github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 21 + github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 22 + github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 23 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 20 25 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 21 26 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 22 - github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= 23 - github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 27 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 28 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 30 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 25 31 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 32 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 27 33 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 34 + github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 35 + github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 28 36 golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 29 37 golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 30 38 golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 31 39 golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 32 40 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 33 41 golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 34 - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 35 42 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 43 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 44 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 37 45 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 46 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 47 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 48 + tangled.org/atscan.net/plcbundle v0.3.6 h1:8eSOxEwHRRT7cLhOTUxut80fYLAi+jodR9UTshofIvY= 49 + tangled.org/atscan.net/plcbundle v0.3.6/go.mod h1:XUzSi6wmqAECCLThmuBTzYV5mEd0q/J1wE45cagoBqs=
+340 -519
internal/api/handlers.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/sha256" 6 5 "database/sql" 7 - "encoding/hex" 8 6 "encoding/json" 9 7 "fmt" 8 + "io" 10 9 "net/http" 11 - "os" 12 - "path/filepath" 13 10 "strconv" 14 11 "strings" 15 12 "time" 16 13 17 - "github.com/atscan/atscanner/internal/log" 18 - "github.com/atscan/atscanner/internal/monitor" 19 - "github.com/atscan/atscanner/internal/plc" 20 - "github.com/atscan/atscanner/internal/storage" 14 + "github.com/atscan/atscand/internal/log" 15 + "github.com/atscan/atscand/internal/monitor" 16 + "github.com/atscan/atscand/internal/plc" 17 + "github.com/atscan/atscand/internal/storage" 21 18 "github.com/gorilla/mux" 19 + "tangled.org/atscan.net/plcbundle" 22 20 ) 23 21 24 22 // ===== RESPONSE HELPERS ===== ··· 40 38 http.Error(r.w, msg, code) 41 39 } 42 40 43 - func (r *response) bundleHeaders(bundle *storage.PLCBundle) { 41 + func (r *response) bundleHeaders(bundle *plcbundle.BundleMetadata) { 44 42 r.w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundle.BundleNumber)) 45 43 r.w.Header().Set("X-Bundle-Hash", bundle.Hash) 46 44 r.w.Header().Set("X-Bundle-Compressed-Hash", bundle.CompressedHash) 47 45 r.w.Header().Set("X-Bundle-Start-Time", bundle.StartTime.Format(time.RFC3339Nano)) 48 46 r.w.Header().Set("X-Bundle-End-Time", bundle.EndTime.Format(time.RFC3339Nano)) 49 47 r.w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE)) 50 - r.w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(bundle.DIDs))) 48 + r.w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", bundle.DIDCount)) 51 49 } 52 50 53 51 // ===== REQUEST HELPERS ===== ··· 77 75 78 76 // ===== FORMATTING HELPERS ===== 79 77 80 - func formatBundleResponse(bundle *storage.PLCBundle) map[string]interface{} { 81 - return map[string]interface{}{ 82 - "plc_bundle_number": bundle.BundleNumber, 83 - "start_time": bundle.StartTime, 84 - "end_time": bundle.EndTime, 85 - "operation_count": plc.BUNDLE_SIZE, 86 - "did_count": len(bundle.DIDs), 87 - "hash": bundle.Hash, 88 - "compressed_hash": bundle.CompressedHash, 89 - "compressed_size": bundle.CompressedSize, 90 - "uncompressed_size": bundle.UncompressedSize, 91 - "compression_ratio": float64(bundle.UncompressedSize) / float64(bundle.CompressedSize), 92 - "cursor": bundle.Cursor, 93 - "prev_bundle_hash": bundle.PrevBundleHash, 94 - "created_at": bundle.CreatedAt, 95 - } 96 - } 97 - 98 78 func formatEndpointResponse(ep *storage.Endpoint) map[string]interface{} { 99 79 response := map[string]interface{}{ 100 80 "id": ep.ID, ··· 103 83 "discovered_at": ep.DiscoveredAt, 104 84 "last_checked": ep.LastChecked, 105 85 "status": statusToString(ep.Status), 106 - // REMOVED: "user_count": ep.UserCount, // No longer exists 107 86 } 108 87 109 - // Add IP if available 88 + // Add IPs if available 110 89 if ep.IP != "" { 111 90 response["ip"] = ep.IP 112 91 } 113 - 114 - // REMOVED: IP info extraction - no longer in Endpoint struct 115 - // IPInfo is now in separate table, joined only in PDS handlers 92 + if ep.IPv6 != "" { 93 + response["ipv6"] = ep.IPv6 94 + } 116 95 117 96 return response 118 97 } ··· 165 144 resp.json(stats) 166 145 } 167 146 147 + // handleGetRandomEndpoint returns a random endpoint of specified type 148 + func (s *Server) handleGetRandomEndpoint(w http.ResponseWriter, r *http.Request) { 149 + resp := newResponse(w) 150 + 151 + // Get required type parameter 152 + endpointType := r.URL.Query().Get("type") 153 + if endpointType == "" { 154 + resp.error("type parameter is required", http.StatusBadRequest) 155 + return 156 + } 157 + 158 + // Get optional status parameter 159 + status := r.URL.Query().Get("status") 160 + 161 + filter := &storage.EndpointFilter{ 162 + Type: endpointType, 163 + Status: status, 164 + Random: true, 165 + Limit: 1, 166 + Offset: 0, 167 + } 168 + 169 + endpoints, err := s.db.GetEndpoints(r.Context(), filter) 170 + if err != nil { 171 + resp.error(err.Error(), http.StatusInternalServerError) 172 + return 173 + } 174 + 175 + if len(endpoints) == 0 { 176 + resp.error("no endpoints found matching criteria", http.StatusNotFound) 177 + return 178 + } 179 + 180 + resp.json(formatEndpointResponse(endpoints[0])) 181 + } 182 + 168 183 // ===== PDS HANDLERS ===== 169 184 170 185 func (s *Server) handleGetPDSList(w http.ResponseWriter, r *http.Request) { ··· 233 248 "endpoint": pds.Endpoint, 234 249 "discovered_at": pds.DiscoveredAt, 235 250 "status": statusToString(pds.Status), 251 + "valid": pds.Valid, // NEW 236 252 } 237 253 238 254 // Add server_did if available ··· 257 273 } 258 274 } 259 275 260 - // Add IP if available 276 + // Add IPs if available 261 277 if pds.IP != "" { 262 278 response["ip"] = pds.IP 279 + } 280 + if pds.IPv6 != "" { 281 + response["ipv6"] = pds.IPv6 263 282 } 264 283 265 284 // Add IP info (from ip_infos table via JOIN) ··· 276 295 if pds.IPInfo.ASN > 0 { 277 296 response["asn"] = pds.IPInfo.ASN 278 297 } 279 - if pds.IPInfo.IsDatacenter { 280 - response["is_datacenter"] = pds.IPInfo.IsDatacenter 281 - } 298 + 299 + // Add all network type flags 300 + response["is_datacenter"] = pds.IPInfo.IsDatacenter 301 + response["is_vpn"] = pds.IPInfo.IsVPN 302 + response["is_crawler"] = pds.IPInfo.IsCrawler 303 + response["is_tor"] = pds.IPInfo.IsTor 304 + response["is_proxy"] = pds.IPInfo.IsProxy 305 + 306 + // Add computed is_home field 307 + response["is_home"] = pds.IPInfo.IsHome() 282 308 } 283 309 284 310 return response ··· 316 342 } 317 343 } 318 344 319 - // Add full IP info 345 + // Add full IP info with computed is_home field 320 346 if pds.IPInfo != nil { 321 - response["ip_info"] = pds.IPInfo 347 + // Convert IPInfo to map 348 + ipInfoMap := make(map[string]interface{}) 349 + ipInfoJSON, _ := json.Marshal(pds.IPInfo) 350 + json.Unmarshal(ipInfoJSON, &ipInfoMap) 351 + 352 + // Add computed is_home field 353 + ipInfoMap["is_home"] = pds.IPInfo.IsHome() 354 + 355 + response["ip_info"] = ipInfoMap 322 356 } 323 357 324 358 return response ··· 343 377 scanMap["response_time"] = scan.ResponseTime 344 378 } 345 379 346 - // NEW: Add version if available 347 380 if scan.Version != "" { 348 381 scanMap["version"] = scan.Version 382 + } 383 + 384 + if scan.UsedIP != "" { 385 + scanMap["used_ip"] = scan.UsedIP 349 386 } 350 387 351 388 // Use the top-level UserCount field first ··· 458 495 459 496 // ===== GLOBAL DID HANDLER ===== 460 497 461 - // extractHandle safely extracts the handle from a PLC operation 462 - func extractHandle(op *plc.PLCOperation) string { 463 - if op == nil || op.Operation == nil { 464 - return "" 465 - } 466 - 467 - // Get "alsoKnownAs" 468 - aka, ok := op.Operation["alsoKnownAs"].([]interface{}) 469 - if !ok { 470 - return "" 471 - } 472 - 473 - // Find the handle (e.g., "at://handle.bsky.social") 474 - for _, item := range aka { 475 - if handle, ok := item.(string); ok { 476 - if strings.HasPrefix(handle, "at://") { 477 - return strings.TrimPrefix(handle, "at://") 478 - } 479 - } 480 - } 481 - return "" 482 - } 483 - 484 - // extractPDS safely extracts the PDS endpoint from a PLC operation 485 - func extractPDS(op *plc.PLCOperation) string { 486 - if op == nil || op.Operation == nil { 487 - return "" 488 - } 489 - 490 - // Get "services" 491 - services, ok := op.Operation["services"].(map[string]interface{}) 492 - if !ok { 493 - return "" 494 - } 495 - 496 - // Get "atproto_pds" 497 - pdsService, ok := services["atproto_pds"].(map[string]interface{}) 498 - if !ok { 499 - return "" 500 - } 501 - 502 - // Get "endpoint" 503 - if endpoint, ok := pdsService["endpoint"].(string); ok { 504 - return endpoint 505 - } 506 - 507 - return "" 508 - } 509 - 510 498 // handleGetGlobalDID provides a consolidated view of a DID 511 499 func (s *Server) handleGetGlobalDID(w http.ResponseWriter, r *http.Request) { 512 500 resp := newResponse(w) ··· 514 502 did := vars["did"] 515 503 ctx := r.Context() 516 504 517 - // --- 1. Get Combined DID Info (from dids and pds_repos) --- 505 + // Get DID info (now includes handle and pds from database) 518 506 didInfo, err := s.db.GetGlobalDIDInfo(ctx, did) 519 507 if err != nil { 520 508 if err == sql.ErrNoRows { 521 - // Check if DID indexing is disabled (from config) 522 509 if !s.plcIndexDIDs { 523 510 resp.error("DID not found. Note: DID indexing is disabled in configuration.", http.StatusNotFound) 524 511 } else { ··· 530 517 return 531 518 } 532 519 533 - // --- 2. Get Latest PLC Operation (from plc_bundles) --- 520 + // Optionally include latest operation details if requested 534 521 var latestOperation *plc.PLCOperation 535 - if len(didInfo.BundleNumbers) > 0 { 522 + if r.URL.Query().Get("include_operation") == "true" && len(didInfo.BundleNumbers) > 0 { 536 523 lastBundleNum := didInfo.BundleNumbers[len(didInfo.BundleNumbers)-1] 537 524 ops, err := s.bundleManager.LoadBundleOperations(ctx, lastBundleNum) 538 525 if err != nil { ··· 548 535 } 549 536 } 550 537 551 - // --- 3. Extract Handle and PDS from latest operation --- 552 - currentHandle := extractHandle(latestOperation) 553 - currentPDS := extractPDS(latestOperation) 554 - 555 - // --- 4. Combine and Respond --- 556 - resp.json(map[string]interface{}{ 538 + result := map[string]interface{}{ 557 539 "did": didInfo.DID, 558 - "handle": currentHandle, // NEW 559 - "current_pds": currentPDS, // NEW 540 + "handle": didInfo.Handle, // From database! 541 + "current_pds": didInfo.CurrentPDS, // From database! 560 542 "plc_index_created_at": didInfo.CreatedAt, 561 543 "plc_bundle_history": didInfo.BundleNumbers, 562 - "pds_hosting_on": didInfo.HostingOn, // This is the historical list from pds_repos 563 - "latest_plc_operation": latestOperation, 544 + "pds_hosting_on": didInfo.HostingOn, 545 + } 546 + 547 + // Only include operation if requested 548 + if latestOperation != nil { 549 + result["latest_plc_operation"] = latestOperation 550 + } 551 + 552 + resp.json(result) 553 + } 554 + 555 + // handleGetDIDByHandle resolves a handle to a DID 556 + func (s *Server) handleGetDIDByHandle(w http.ResponseWriter, r *http.Request) { 557 + resp := newResponse(w) 558 + vars := mux.Vars(r) 559 + handle := vars["handle"] 560 + 561 + // Normalize handle (remove @ prefix if present) 562 + handle = strings.TrimPrefix(handle, "@") 563 + 564 + // Look up DID by handle 565 + didRecord, err := s.db.GetDIDByHandle(r.Context(), handle) 566 + if err != nil { 567 + if err == sql.ErrNoRows { 568 + if !s.plcIndexDIDs { 569 + resp.error("Handle not found. Note: DID indexing is disabled in configuration.", http.StatusNotFound) 570 + } else { 571 + resp.error("Handle not found.", http.StatusNotFound) 572 + } 573 + } else { 574 + resp.error(err.Error(), http.StatusInternalServerError) 575 + } 576 + return 577 + } 578 + 579 + // Return just the handle and DID 580 + resp.json(map[string]string{ 581 + "handle": handle, 582 + "did": didRecord.DID, 564 583 }) 565 584 } 566 585 ··· 665 684 return 666 685 } 667 686 668 - lastBundle, err := s.db.GetLastBundleNumber(ctx) 669 - if err != nil { 670 - resp.error(err.Error(), http.StatusInternalServerError) 671 - return 672 - } 673 - 687 + lastBundle := s.bundleManager.GetLastBundleNumber() 674 688 resp.json(map[string]interface{}{ 675 689 "total_unique_dids": totalDIDs, 676 690 "last_bundle": lastBundle, ··· 681 695 682 696 func (s *Server) handleGetPLCBundle(w http.ResponseWriter, r *http.Request) { 683 697 resp := newResponse(w) 684 - 685 698 bundleNum, err := getBundleNumber(r) 686 699 if err != nil { 687 700 resp.error("invalid bundle number", http.StatusBadRequest) 688 701 return 689 702 } 690 703 691 - // Try to get existing bundle 692 - bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum) 693 - if err == nil { 694 - // Bundle exists, return it normally 695 - resp.json(formatBundleResponse(bundle)) 696 - return 697 - } 698 - 699 - // Bundle not found - check if it's the next upcoming bundle 700 - lastBundle, err := s.db.GetLastBundleNumber(r.Context()) 704 + // Get from library's index 705 + index := s.bundleManager.GetIndex() 706 + bundleMeta, err := index.GetBundle(bundleNum) 701 707 if err != nil { 702 - resp.error("bundle not found", http.StatusNotFound) 703 - return 704 - } 705 - 706 - if bundleNum == lastBundle+1 { 707 - // This is the upcoming bundle - return preview based on mempool 708 - upcomingBundle, err := s.createUpcomingBundlePreview(r.Context(), r, bundleNum) 709 - if err != nil { 710 - resp.error(fmt.Sprintf("failed to create upcoming bundle preview: %v", err), http.StatusInternalServerError) 708 + // Check if it's upcoming bundle 709 + lastBundle := index.GetLastBundle() 710 + if lastBundle != nil && bundleNum == lastBundle.BundleNumber+1 { 711 + upcomingBundle, err := s.createUpcomingBundlePreview(bundleNum) 712 + if err != nil { 713 + resp.error(err.Error(), http.StatusInternalServerError) 714 + return 715 + } 716 + resp.json(upcomingBundle) 711 717 return 712 718 } 713 - resp.json(upcomingBundle) 719 + resp.error("bundle not found", http.StatusNotFound) 714 720 return 715 721 } 716 722 717 - // Not an upcoming bundle, just not found 718 - resp.error("bundle not found", http.StatusNotFound) 723 + resp.json(formatBundleMetadata(bundleMeta)) 719 724 } 720 725 721 - func (s *Server) createUpcomingBundlePreview(ctx context.Context, r *http.Request, bundleNum int) (map[string]interface{}, error) { 722 - // Get mempool stats 723 - mempoolCount, err := s.db.GetMempoolCount(ctx) 724 - if err != nil { 725 - return nil, err 726 + // Helper to format library's BundleMetadata 727 + func formatBundleMetadata(meta *plcbundle.BundleMetadata) map[string]interface{} { 728 + return map[string]interface{}{ 729 + "plc_bundle_number": meta.BundleNumber, 730 + "start_time": meta.StartTime, 731 + "end_time": meta.EndTime, 732 + "operation_count": meta.OperationCount, 733 + "did_count": meta.DIDCount, 734 + "hash": meta.Hash, // Chain hash (primary) 735 + "content_hash": meta.ContentHash, // Content hash 736 + "parent": meta.Parent, // Parent chain hash 737 + "compressed_hash": meta.CompressedHash, 738 + "compressed_size": meta.CompressedSize, 739 + "uncompressed_size": meta.UncompressedSize, 740 + "compression_ratio": float64(meta.UncompressedSize) / float64(meta.CompressedSize), 741 + "cursor": meta.Cursor, 742 + "created_at": meta.CreatedAt, 726 743 } 744 + } 727 745 728 - if mempoolCount == 0 { 746 + func (s *Server) createUpcomingBundlePreview(bundleNum int) (map[string]interface{}, error) { 747 + // Get mempool stats from library via wrapper 748 + stats := s.bundleManager.GetMempoolStats() 749 + 750 + count, ok := stats["count"].(int) 751 + if !ok || count == 0 { 729 752 return map[string]interface{}{ 730 753 "plc_bundle_number": bundleNum, 731 754 "is_upcoming": true, ··· 735 758 }, nil 736 759 } 737 760 738 - // Get first and last operations for time range 739 - firstOp, err := s.db.GetFirstMempoolOperation(ctx) 740 - if err != nil { 741 - return nil, err 761 + // Build response 762 + result := map[string]interface{}{ 763 + "plc_bundle_number": bundleNum, 764 + "is_upcoming": true, 765 + "status": "filling", 766 + "operation_count": count, 767 + "did_count": stats["did_count"], 768 + "target_operation_count": 10000, 769 + "progress_percent": float64(count) / 100.0, 770 + "operations_needed": 10000 - count, 742 771 } 743 772 744 - lastOp, err := s.db.GetLastMempoolOperation(ctx) 745 - if err != nil { 746 - return nil, err 773 + if count >= 10000 { 774 + result["status"] = "ready" 747 775 } 748 776 749 - // Get unique DID count 750 - uniqueDIDCount, err := s.db.GetMempoolUniqueDIDCount(ctx) 751 - if err != nil { 752 - return nil, err 777 + // Add time range if available 778 + if firstTime, ok := stats["first_time"]; ok { 779 + result["start_time"] = firstTime 753 780 } 754 - 755 - // Get uncompressed size estimate 756 - uncompressedSize, err := s.db.GetMempoolUncompressedSize(ctx) 757 - if err != nil { 758 - return nil, err 781 + if lastTime, ok := stats["last_time"]; ok { 782 + result["current_end_time"] = lastTime 759 783 } 760 784 761 - // Estimate compressed size (typical ratio is ~0.1-0.15 for PLC data) 762 - estimatedCompressedSize := int64(float64(uncompressedSize) * 0.12) 763 - 764 - // Calculate completion estimate 765 - var estimatedCompletionTime *time.Time 766 - var operationsNeeded int 767 - var currentRate float64 768 - 769 - operationsNeeded = plc.BUNDLE_SIZE - mempoolCount 770 - 771 - if mempoolCount < plc.BUNDLE_SIZE && mempoolCount > 0 { 772 - timeSpan := lastOp.CreatedAt.Sub(firstOp.CreatedAt).Seconds() 773 - if timeSpan > 0 { 774 - currentRate = float64(mempoolCount) / timeSpan 775 - if currentRate > 0 { 776 - secondsNeeded := float64(operationsNeeded) / currentRate 777 - completionTime := time.Now().Add(time.Duration(secondsNeeded) * time.Second) 778 - estimatedCompletionTime = &completionTime 779 - } 780 - } 785 + // Add size info if available 786 + if sizeBytes, ok := stats["size_bytes"]; ok { 787 + result["uncompressed_size"] = sizeBytes 788 + result["estimated_compressed_size"] = int64(float64(sizeBytes.(int)) * 0.12) 781 789 } 782 790 783 - // Get previous bundle for cursor context 784 - var prevBundleHash string 785 - var cursor string 791 + // Get previous bundle info 786 792 if bundleNum > 1 { 787 - prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNum-1) 788 - if err == nil { 789 - prevBundleHash = prevBundle.Hash 790 - cursor = prevBundle.EndTime.Format(time.RFC3339Nano) 791 - } 792 - } 793 - 794 - // Determine bundle status 795 - status := "filling" 796 - if mempoolCount >= plc.BUNDLE_SIZE { 797 - status = "ready" 798 - } 799 - 800 - // Build upcoming bundle response 801 - result := map[string]interface{}{ 802 - "plc_bundle_number": bundleNum, 803 - "is_upcoming": true, 804 - "status": status, 805 - "operation_count": mempoolCount, 806 - "target_operation_count": plc.BUNDLE_SIZE, 807 - "progress_percent": float64(mempoolCount) / float64(plc.BUNDLE_SIZE) * 100, 808 - "operations_needed": operationsNeeded, 809 - "did_count": uniqueDIDCount, 810 - "start_time": firstOp.CreatedAt, // This is FIXED once first op exists 811 - "current_end_time": lastOp.CreatedAt, // This will change as more ops arrive 812 - "uncompressed_size": uncompressedSize, 813 - "estimated_compressed_size": estimatedCompressedSize, 814 - "compression_ratio": float64(uncompressedSize) / float64(estimatedCompressedSize), 815 - "prev_bundle_hash": prevBundleHash, 816 - "cursor": cursor, 817 - } 818 - 819 - if estimatedCompletionTime != nil { 820 - result["estimated_completion_time"] = *estimatedCompletionTime 821 - result["current_rate_per_second"] = currentRate 822 - } 823 - 824 - // Get actual mempool operations if requested 825 - if r.URL.Query().Get("include_dids") == "true" { 826 - ops, err := s.db.GetMempoolOperations(ctx, plc.BUNDLE_SIZE) 827 - if err == nil { 828 - // Extract unique DIDs 829 - didSet := make(map[string]bool) 830 - for _, op := range ops { 831 - didSet[op.DID] = true 832 - } 833 - dids := make([]string, 0, len(didSet)) 834 - for did := range didSet { 835 - dids = append(dids, did) 836 - } 837 - result["dids"] = dids 793 + if prevBundle, err := s.bundleManager.GetBundleMetadata(bundleNum - 1); err == nil { 794 + result["parent"] = prevBundle.Hash // Parent chain hash 795 + result["cursor"] = prevBundle.EndTime.Format(time.RFC3339Nano) 838 796 } 839 797 } 840 798 ··· 850 808 return 851 809 } 852 810 853 - bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum) 811 + // Get from library 812 + dids, didCount, err := s.bundleManager.GetDIDsForBundle(r.Context(), bundleNum) 854 813 if err != nil { 855 814 resp.error("bundle not found", http.StatusNotFound) 856 815 return 857 816 } 858 817 859 818 resp.json(map[string]interface{}{ 860 - "plc_bundle_number": bundle.BundleNumber, 861 - "did_count": len(bundle.DIDs), 862 - "dids": bundle.DIDs, 819 + "plc_bundle_number": bundleNum, 820 + "did_count": didCount, 821 + "dids": dids, 863 822 }) 864 823 } 865 824 ··· 874 833 875 834 compressed := r.URL.Query().Get("compressed") != "false" 876 835 877 - bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNum) 836 + bundle, err := s.bundleManager.GetBundleMetadata(bundleNum) 878 837 if err == nil { 879 838 // Bundle exists, serve it normally 880 839 resp.bundleHeaders(bundle) ··· 888 847 } 889 848 890 849 // Bundle not found - check if it's the upcoming bundle 891 - lastBundle, err := s.db.GetLastBundleNumber(r.Context()) 892 - if err != nil { 893 - resp.error("bundle not found", http.StatusNotFound) 894 - return 895 - } 896 - 850 + lastBundle := s.bundleManager.GetLastBundleNumber() 897 851 if bundleNum == lastBundle+1 { 898 852 // This is the upcoming bundle - serve from mempool 899 - s.serveUpcomingBundle(w, r, bundleNum) 853 + s.serveUpcomingBundle(w, bundleNum) 900 854 return 901 855 } 902 856 ··· 904 858 resp.error("bundle not found", http.StatusNotFound) 905 859 } 906 860 907 - func (s *Server) serveUpcomingBundle(w http.ResponseWriter, r *http.Request, bundleNum int) { 908 - ctx := r.Context() 861 + func (s *Server) serveUpcomingBundle(w http.ResponseWriter, bundleNum int) { 862 + // Get mempool stats 863 + stats := s.bundleManager.GetMempoolStats() 864 + count, ok := stats["count"].(int) 909 865 910 - // Get mempool count 911 - mempoolCount, err := s.db.GetMempoolCount(ctx) 912 - if err != nil { 913 - http.Error(w, fmt.Sprintf("failed to get mempool count: %v", err), http.StatusInternalServerError) 914 - return 915 - } 916 - 917 - if mempoolCount == 0 { 866 + if !ok || count == 0 { 918 867 http.Error(w, "upcoming bundle is empty (no operations in mempool)", http.StatusNotFound) 919 868 return 920 869 } 921 870 922 - // Get mempool operations (up to BUNDLE_SIZE) 923 - mempoolOps, err := s.db.GetMempoolOperations(ctx, plc.BUNDLE_SIZE) 871 + // Get operations from mempool 872 + ops, err := s.bundleManager.GetMempoolOperations() 924 873 if err != nil { 925 874 http.Error(w, fmt.Sprintf("failed to get mempool operations: %v", err), http.StatusInternalServerError) 926 875 return 927 876 } 928 877 929 - if len(mempoolOps) == 0 { 930 - http.Error(w, "upcoming bundle is empty", http.StatusNotFound) 878 + if len(ops) == 0 { 879 + http.Error(w, "no operations in mempool", http.StatusNotFound) 931 880 return 932 881 } 933 882 934 - // Get time range 935 - firstOp := mempoolOps[0] 936 - lastOp := mempoolOps[len(mempoolOps)-1] 883 + // Calculate times 884 + firstOp := ops[0] 885 + lastOp := ops[len(ops)-1] 937 886 938 887 // Extract unique DIDs 939 888 didSet := make(map[string]bool) 940 - for _, op := range mempoolOps { 889 + for _, op := range ops { 941 890 didSet[op.DID] = true 891 + } 892 + 893 + // Calculate uncompressed size 894 + uncompressedSize := int64(0) 895 + for _, op := range ops { 896 + uncompressedSize += int64(len(op.RawJSON)) + 1 // +1 for newline 942 897 } 943 898 944 899 // Get previous bundle hash 945 900 prevBundleHash := "" 946 901 if bundleNum > 1 { 947 - if prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNum-1); err == nil { 902 + if prevBundle, err := s.bundleManager.GetBundleMetadata(bundleNum - 1); err == nil { 948 903 prevBundleHash = prevBundle.Hash 949 904 } 950 905 } 951 906 952 - // Serialize operations to JSONL 953 - var buf []byte 954 - for _, mop := range mempoolOps { 955 - buf = append(buf, []byte(mop.Operation)...) 956 - buf = append(buf, '\n') 957 - } 958 - 959 - // Calculate size 960 - uncompressedSize := int64(len(buf)) 961 - 962 907 // Set headers 963 908 w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundleNum)) 964 909 w.Header().Set("X-Bundle-Is-Upcoming", "true") 965 910 w.Header().Set("X-Bundle-Status", "preview") 966 911 w.Header().Set("X-Bundle-Start-Time", firstOp.CreatedAt.Format(time.RFC3339Nano)) 967 912 w.Header().Set("X-Bundle-Current-End-Time", lastOp.CreatedAt.Format(time.RFC3339Nano)) 968 - w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", len(mempoolOps))) 969 - w.Header().Set("X-Bundle-Target-Count", fmt.Sprintf("%d", plc.BUNDLE_SIZE)) 970 - w.Header().Set("X-Bundle-Progress-Percent", fmt.Sprintf("%.2f", float64(len(mempoolOps))/float64(plc.BUNDLE_SIZE)*100)) 913 + w.Header().Set("X-Bundle-Operation-Count", fmt.Sprintf("%d", len(ops))) 914 + w.Header().Set("X-Bundle-Target-Count", "10000") 915 + w.Header().Set("X-Bundle-Progress-Percent", fmt.Sprintf("%.2f", float64(len(ops))/100.0)) 971 916 w.Header().Set("X-Bundle-DID-Count", fmt.Sprintf("%d", len(didSet))) 972 917 w.Header().Set("X-Bundle-Prev-Hash", prevBundleHash) 918 + w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", uncompressedSize)) 973 919 974 920 w.Header().Set("Content-Type", "application/jsonl") 975 921 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d-upcoming.jsonl", bundleNum)) 976 - w.Header().Set("Content-Length", fmt.Sprintf("%d", uncompressedSize)) 977 - w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", uncompressedSize)) 978 922 923 + // Stream operations as JSONL 979 924 w.WriteHeader(http.StatusOK) 980 - w.Write(buf) 925 + 926 + for _, op := range ops { 927 + // Use RawJSON if available (preserves exact format) 928 + if len(op.RawJSON) > 0 { 929 + w.Write(op.RawJSON) 930 + } else { 931 + // Fallback to marshaling 932 + data, _ := json.Marshal(op) 933 + w.Write(data) 934 + } 935 + w.Write([]byte("\n")) 936 + } 981 937 } 982 938 983 - func (s *Server) serveCompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) { 939 + func (s *Server) serveCompressedBundle(w http.ResponseWriter, r *http.Request, bundle *plcbundle.BundleMetadata) { 984 940 resp := newResponse(w) 985 - path := bundle.GetFilePath(s.plcBundleDir) 986 941 987 - file, err := os.Open(path) 942 + // Use the new streaming API for compressed data 943 + reader, err := s.bundleManager.StreamRaw(r.Context(), bundle.BundleNumber) 988 944 if err != nil { 989 - resp.error("bundle file not found on disk", http.StatusNotFound) 945 + resp.error(fmt.Sprintf("error streaming compressed bundle: %v", err), http.StatusInternalServerError) 990 946 return 991 947 } 992 - defer file.Close() 993 - 994 - fileInfo, _ := file.Stat() 948 + defer reader.Close() 995 949 996 950 w.Header().Set("Content-Type", "application/zstd") 997 951 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundle.BundleNumber)) 998 - w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size())) 999 - w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", fileInfo.Size())) 952 + w.Header().Set("Content-Length", fmt.Sprintf("%d", bundle.CompressedSize)) 953 + w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", bundle.CompressedSize)) 1000 954 1001 - http.ServeContent(w, r, filepath.Base(path), bundle.CreatedAt, file) 955 + // Stream the data directly to the response 956 + w.WriteHeader(http.StatusOK) 957 + io.Copy(w, reader) 1002 958 } 1003 959 1004 - func (s *Server) serveUncompressedBundle(w http.ResponseWriter, r *http.Request, bundle *storage.PLCBundle) { 960 + func (s *Server) serveUncompressedBundle(w http.ResponseWriter, r *http.Request, bundle *plcbundle.BundleMetadata) { 1005 961 resp := newResponse(w) 1006 962 1007 - ops, err := s.bundleManager.LoadBundleOperations(r.Context(), bundle.BundleNumber) 963 + // Use the new streaming API for decompressed data 964 + reader, err := s.bundleManager.StreamDecompressed(r.Context(), bundle.BundleNumber) 1008 965 if err != nil { 1009 - resp.error(fmt.Sprintf("error loading bundle: %v", err), http.StatusInternalServerError) 966 + resp.error(fmt.Sprintf("error streaming decompressed bundle: %v", err), http.StatusInternalServerError) 1010 967 return 1011 968 } 1012 - 1013 - // Serialize to JSONL 1014 - var buf []byte 1015 - for _, op := range ops { 1016 - buf = append(buf, op.RawJSON...) 1017 - buf = append(buf, '\n') 1018 - } 1019 - 1020 - fileInfo, _ := os.Stat(bundle.GetFilePath(s.plcBundleDir)) 1021 - compressedSize := int64(0) 1022 - if fileInfo != nil { 1023 - compressedSize = fileInfo.Size() 1024 - } 969 + defer reader.Close() 1025 970 1026 971 w.Header().Set("Content-Type", "application/jsonl") 1027 972 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundle.BundleNumber)) 1028 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(buf))) 1029 - w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", compressedSize)) 1030 - w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", len(buf))) 1031 - if compressedSize > 0 { 1032 - w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(len(buf))/float64(compressedSize))) 973 + w.Header().Set("Content-Length", fmt.Sprintf("%d", bundle.UncompressedSize)) 974 + w.Header().Set("X-Compressed-Size", fmt.Sprintf("%d", bundle.CompressedSize)) 975 + w.Header().Set("X-Uncompressed-Size", fmt.Sprintf("%d", bundle.UncompressedSize)) 976 + if bundle.CompressedSize > 0 { 977 + w.Header().Set("X-Compression-Ratio", fmt.Sprintf("%.2f", float64(bundle.UncompressedSize)/float64(bundle.CompressedSize))) 1033 978 } 1034 979 980 + // Stream the data directly to the response 1035 981 w.WriteHeader(http.StatusOK) 1036 - w.Write(buf) 982 + io.Copy(w, reader) 1037 983 } 1038 984 1039 985 func (s *Server) handleGetPLCBundles(w http.ResponseWriter, r *http.Request) { 1040 986 resp := newResponse(w) 1041 987 limit := getQueryInt(r, "limit", 50) 1042 988 1043 - bundles, err := s.db.GetBundles(r.Context(), limit) 1044 - if err != nil { 1045 - resp.error(err.Error(), http.StatusInternalServerError) 1046 - return 1047 - } 989 + bundles := s.bundleManager.GetBundles(limit) 1048 990 1049 991 response := make([]map[string]interface{}, len(bundles)) 1050 992 for i, bundle := range bundles { 1051 - response[i] = formatBundleResponse(bundle) 993 + response[i] = formatBundleMetadata(bundle) 1052 994 } 1053 995 1054 996 resp.json(response) ··· 1057 999 func (s *Server) handleGetPLCBundleStats(w http.ResponseWriter, r *http.Request) { 1058 1000 resp := newResponse(w) 1059 1001 1060 - count, compressedSize, uncompressedSize, lastBundle, err := s.db.GetBundleStats(r.Context()) 1061 - if err != nil { 1062 - resp.error(err.Error(), http.StatusInternalServerError) 1063 - return 1064 - } 1002 + stats := s.bundleManager.GetBundleStats() 1003 + 1004 + bundleCount := stats["bundle_count"].(int64) 1005 + totalSize := stats["total_size"].(int64) 1006 + totalUncompressedSize := stats["total_uncompressed_size"].(int64) 1007 + lastBundle := stats["last_bundle"].(int64) 1065 1008 1066 1009 resp.json(map[string]interface{}{ 1067 - "plc_bundle_count": count, 1068 - "last_bundle_number": lastBundle, 1069 - "total_compressed_size": compressedSize, 1070 - "total_compressed_size_mb": float64(compressedSize) / 1024 / 1024, 1071 - "total_compressed_size_gb": float64(compressedSize) / 1024 / 1024 / 1024, 1072 - "total_uncompressed_size": uncompressedSize, 1073 - "total_uncompressed_size_mb": float64(uncompressedSize) / 1024 / 1024, 1074 - "total_uncompressed_size_gb": float64(uncompressedSize) / 1024 / 1024 / 1024, 1075 - "compression_ratio": float64(uncompressedSize) / float64(compressedSize), 1010 + "plc_bundle_count": bundleCount, 1011 + "last_bundle_number": lastBundle, 1012 + "total_compressed_size": totalSize, 1013 + "total_uncompressed_size": totalUncompressedSize, 1014 + "overall_compression_ratio": float64(totalUncompressedSize) / float64(totalSize), 1076 1015 }) 1077 1016 } 1078 1017 ··· 1080 1019 1081 1020 func (s *Server) handleGetMempoolStats(w http.ResponseWriter, r *http.Request) { 1082 1021 resp := newResponse(w) 1083 - ctx := r.Context() 1084 1022 1085 - count, err := s.db.GetMempoolCount(ctx) 1086 - if err != nil { 1087 - resp.error(err.Error(), http.StatusInternalServerError) 1088 - return 1089 - } 1023 + // Get stats from library's mempool via wrapper method 1024 + stats := s.bundleManager.GetMempoolStats() 1090 1025 1091 - uniqueDIDCount, err := s.db.GetMempoolUniqueDIDCount(ctx) 1092 - if err != nil { 1093 - resp.error(err.Error(), http.StatusInternalServerError) 1094 - return 1026 + // Convert to API response format 1027 + result := map[string]interface{}{ 1028 + "operation_count": stats["count"], 1029 + "can_create_bundle": stats["can_create_bundle"], 1095 1030 } 1096 1031 1097 - uncompressedSize, err := s.db.GetMempoolUncompressedSize(ctx) 1098 - if err != nil { 1099 - resp.error(err.Error(), http.StatusInternalServerError) 1100 - return 1032 + // Add size information 1033 + if sizeBytes, ok := stats["size_bytes"]; ok { 1034 + result["uncompressed_size"] = sizeBytes 1035 + result["uncompressed_size_mb"] = float64(sizeBytes.(int)) / 1024 / 1024 1101 1036 } 1102 1037 1103 - result := map[string]interface{}{ 1104 - "operation_count": count, 1105 - "unique_did_count": uniqueDIDCount, 1106 - "uncompressed_size": uncompressedSize, 1107 - "uncompressed_size_mb": float64(uncompressedSize) / 1024 / 1024, 1108 - "can_create_bundle": count >= plc.BUNDLE_SIZE, 1109 - } 1038 + // Add time range and calculate estimated completion 1039 + if count, ok := stats["count"].(int); ok && count > 0 { 1040 + if firstTime, ok := stats["first_time"].(time.Time); ok { 1041 + result["mempool_start_time"] = firstTime 1110 1042 1111 - if count > 0 { 1112 - if firstOp, err := s.db.GetFirstMempoolOperation(ctx); err == nil && firstOp != nil { 1113 - result["mempool_start_time"] = firstOp.CreatedAt 1043 + if lastTime, ok := stats["last_time"].(time.Time); ok { 1044 + result["mempool_end_time"] = lastTime 1114 1045 1115 - if count < plc.BUNDLE_SIZE { 1116 - if lastOp, err := s.db.GetLastMempoolOperation(ctx); err == nil && lastOp != nil { 1117 - timeSpan := lastOp.CreatedAt.Sub(firstOp.CreatedAt).Seconds() 1046 + // Calculate estimated next bundle time if not complete 1047 + if count < 10000 { 1048 + timeSpan := lastTime.Sub(firstTime).Seconds() 1118 1049 if timeSpan > 0 { 1119 1050 opsPerSecond := float64(count) / timeSpan 1120 1051 if opsPerSecond > 0 { 1121 - remainingOps := plc.BUNDLE_SIZE - count 1052 + remainingOps := 10000 - count 1122 1053 secondsNeeded := float64(remainingOps) / opsPerSecond 1123 - result["estimated_next_bundle_time"] = time.Now().Add(time.Duration(secondsNeeded) * time.Second) 1054 + estimatedTime := time.Now().Add(time.Duration(secondsNeeded) * time.Second) 1055 + 1056 + result["estimated_next_bundle_time"] = estimatedTime 1057 + result["current_rate_per_second"] = opsPerSecond 1124 1058 result["operations_needed"] = remainingOps 1125 - result["current_rate_per_second"] = opsPerSecond 1126 1059 } 1127 1060 } 1061 + result["progress_percent"] = float64(count) / 100.0 1062 + } else { 1063 + // Ready to create bundle 1064 + result["estimated_next_bundle_time"] = time.Now() 1065 + result["operations_needed"] = 0 1128 1066 } 1129 - } else { 1130 - result["estimated_next_bundle_time"] = time.Now() 1131 - result["operations_needed"] = 0 1132 1067 } 1133 1068 } 1134 1069 } else { 1070 + // Empty mempool 1135 1071 result["mempool_start_time"] = nil 1136 1072 result["estimated_next_bundle_time"] = nil 1137 1073 } ··· 1156 1092 1157 1093 // ===== VERIFICATION HANDLERS ===== 1158 1094 1159 - func (s *Server) handleVerifyPLCBundle(w http.ResponseWriter, r *http.Request) { 1160 - resp := newResponse(w) 1161 - vars := mux.Vars(r) 1162 - 1163 - bundleNumber, err := strconv.Atoi(vars["bundleNumber"]) 1164 - if err != nil { 1165 - resp.error("Invalid bundle number", http.StatusBadRequest) 1166 - return 1167 - } 1168 - 1169 - bundle, err := s.db.GetBundleByNumber(r.Context(), bundleNumber) 1170 - if err != nil { 1171 - resp.error("Bundle not found", http.StatusNotFound) 1172 - return 1173 - } 1174 - 1175 - // Fetch from PLC and verify 1176 - remoteOps, prevCIDs, err := s.fetchRemoteBundleOps(r.Context(), bundleNumber) 1177 - if err != nil { 1178 - resp.error(fmt.Sprintf("Failed to fetch from PLC directory: %v", err), http.StatusInternalServerError) 1179 - return 1180 - } 1181 - 1182 - remoteHash := computeOperationsHash(remoteOps) 1183 - verified := bundle.Hash == remoteHash 1184 - 1185 - resp.json(map[string]interface{}{ 1186 - "bundle_number": bundleNumber, 1187 - "verified": verified, 1188 - "local_hash": bundle.Hash, 1189 - "remote_hash": remoteHash, 1190 - "local_op_count": plc.BUNDLE_SIZE, 1191 - "remote_op_count": len(remoteOps), 1192 - "boundary_cids_used": len(prevCIDs), 1193 - }) 1194 - } 1195 - 1196 - func (s *Server) fetchRemoteBundleOps(ctx context.Context, bundleNum int) ([]plc.PLCOperation, map[string]bool, error) { 1197 - var after string 1198 - var prevBoundaryCIDs map[string]bool 1199 - 1200 - if bundleNum > 1 { 1201 - prevBundle, err := s.db.GetBundleByNumber(ctx, bundleNum-1) 1202 - if err != nil { 1203 - return nil, nil, fmt.Errorf("failed to get previous bundle: %w", err) 1204 - } 1205 - 1206 - after = prevBundle.EndTime.Format("2006-01-02T15:04:05.000Z") 1207 - 1208 - if len(prevBundle.BoundaryCIDs) > 0 { 1209 - prevBoundaryCIDs = make(map[string]bool) 1210 - for _, cid := range prevBundle.BoundaryCIDs { 1211 - prevBoundaryCIDs[cid] = true 1212 - } 1213 - } 1214 - } 1215 - 1216 - var allRemoteOps []plc.PLCOperation 1217 - seenCIDs := make(map[string]bool) 1218 - 1219 - for cid := range prevBoundaryCIDs { 1220 - seenCIDs[cid] = true 1221 - } 1222 - 1223 - currentAfter := after 1224 - maxFetches := 20 1225 - 1226 - for fetchNum := 0; fetchNum < maxFetches && len(allRemoteOps) < plc.BUNDLE_SIZE; fetchNum++ { 1227 - batch, err := s.plcClient.Export(ctx, plc.ExportOptions{ 1228 - Count: 1000, 1229 - After: currentAfter, 1230 - }) 1231 - if err != nil || len(batch) == 0 { 1232 - break 1233 - } 1234 - 1235 - for _, op := range batch { 1236 - if !seenCIDs[op.CID] { 1237 - seenCIDs[op.CID] = true 1238 - allRemoteOps = append(allRemoteOps, op) 1239 - if len(allRemoteOps) >= plc.BUNDLE_SIZE { 1240 - break 1241 - } 1242 - } 1243 - } 1244 - 1245 - if len(batch) > 0 { 1246 - lastOp := batch[len(batch)-1] 1247 - currentAfter = lastOp.CreatedAt.Format("2006-01-02T15:04:05.000Z") 1248 - } 1249 - 1250 - if len(batch) < 1000 { 1251 - break 1252 - } 1253 - } 1254 - 1255 - if len(allRemoteOps) > plc.BUNDLE_SIZE { 1256 - allRemoteOps = allRemoteOps[:plc.BUNDLE_SIZE] 1257 - } 1258 - 1259 - return allRemoteOps, prevBoundaryCIDs, nil 1260 - } 1261 - 1262 1095 func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) { 1263 1096 resp := newResponse(w) 1264 - ctx := r.Context() 1265 1097 1266 - lastBundle, err := s.db.GetLastBundleNumber(ctx) 1267 - if err != nil { 1268 - resp.error(err.Error(), http.StatusInternalServerError) 1269 - return 1270 - } 1271 - 1098 + lastBundle := s.bundleManager.GetLastBundleNumber() 1272 1099 if lastBundle == 0 { 1273 1100 resp.json(map[string]interface{}{ 1274 1101 "status": "empty", ··· 1282 1109 var errorMsg string 1283 1110 1284 1111 for i := 1; i <= lastBundle; i++ { 1285 - bundle, err := s.db.GetBundleByNumber(ctx, i) 1112 + bundle, err := s.bundleManager.GetBundleMetadata(i) 1286 1113 if err != nil { 1287 1114 valid = false 1288 1115 brokenAt = i ··· 1291 1118 } 1292 1119 1293 1120 if i > 1 { 1294 - prevBundle, err := s.db.GetBundleByNumber(ctx, i-1) 1121 + prevBundle, err := s.bundleManager.GetBundleMetadata(i - 1) 1295 1122 if err != nil { 1296 1123 valid = false 1297 1124 brokenAt = i ··· 1299 1126 break 1300 1127 } 1301 1128 1302 - if bundle.PrevBundleHash != prevBundle.Hash { 1129 + if bundle.Parent != prevBundle.Hash { 1303 1130 valid = false 1304 1131 brokenAt = i 1305 - errorMsg = fmt.Sprintf("Chain broken: bundle %06d prev_hash doesn't match bundle %06d hash", i, i-1) 1132 + errorMsg = fmt.Sprintf("Chain broken: bundle %06d parent doesn't match bundle %06d hash", i, i-1) 1306 1133 break 1307 1134 } 1308 1135 } ··· 1323 1150 1324 1151 func (s *Server) handleGetChainInfo(w http.ResponseWriter, r *http.Request) { 1325 1152 resp := newResponse(w) 1326 - ctx := r.Context() 1327 1153 1328 - lastBundle, err := s.db.GetLastBundleNumber(ctx) 1329 - if err != nil { 1330 - resp.error(err.Error(), http.StatusInternalServerError) 1331 - return 1332 - } 1333 - 1154 + lastBundle := s.bundleManager.GetLastBundleNumber() 1334 1155 if lastBundle == 0 { 1335 1156 resp.json(map[string]interface{}{ 1336 1157 "chain_length": 0, ··· 1339 1160 return 1340 1161 } 1341 1162 1342 - firstBundle, _ := s.db.GetBundleByNumber(ctx, 1) 1343 - lastBundleData, _ := s.db.GetBundleByNumber(ctx, lastBundle) 1344 - 1345 - // Updated to receive 5 values instead of 3 1346 - count, compressedSize, uncompressedSize, _, err := s.db.GetBundleStats(ctx) 1347 - if err != nil { 1348 - resp.error(err.Error(), http.StatusInternalServerError) 1349 - return 1350 - } 1163 + firstBundle, _ := s.bundleManager.GetBundleMetadata(1) 1164 + lastBundleData, _ := s.bundleManager.GetBundleMetadata(lastBundle) 1165 + stats := s.bundleManager.GetBundleStats() 1351 1166 1352 1167 resp.json(map[string]interface{}{ 1353 - "chain_length": lastBundle, 1354 - "total_bundles": count, 1355 - "total_compressed_size": compressedSize, 1356 - "total_compressed_size_mb": float64(compressedSize) / 1024 / 1024, 1357 - "total_uncompressed_size": uncompressedSize, 1358 - "total_uncompressed_size_mb": float64(uncompressedSize) / 1024 / 1024, 1359 - "compression_ratio": float64(uncompressedSize) / float64(compressedSize), 1360 - "chain_start_time": firstBundle.StartTime, 1361 - "chain_end_time": lastBundleData.EndTime, 1362 - "chain_head_hash": lastBundleData.Hash, 1363 - "first_prev_hash": firstBundle.PrevBundleHash, 1364 - "last_prev_hash": lastBundleData.PrevBundleHash, 1168 + "chain_length": lastBundle, 1169 + "total_bundles": stats["bundle_count"], 1170 + "total_compressed_size": stats["total_size"], 1171 + "total_compressed_size_mb": float64(stats["total_size"].(int64)) / 1024 / 1024, 1172 + "chain_start_time": firstBundle.StartTime, 1173 + "chain_end_time": lastBundleData.EndTime, 1174 + "chain_head_hash": lastBundleData.Hash, 1175 + "first_parent": firstBundle.Parent, 1176 + "last_parent": lastBundleData.Parent, 1365 1177 }) 1366 1178 } 1367 1179 ··· 1382 1194 return 1383 1195 } 1384 1196 1385 - startBundle := s.findStartBundle(ctx, afterTime) 1197 + startBundle := s.findStartBundle(afterTime) 1386 1198 ops := s.collectOperations(ctx, startBundle, afterTime, count) 1387 1199 1388 1200 w.Header().Set("Content-Type", "application/jsonl") ··· 1422 1234 return time.Time{}, fmt.Errorf("invalid timestamp format") 1423 1235 } 1424 1236 1425 - func (s *Server) findStartBundle(ctx context.Context, afterTime time.Time) int { 1237 + func (s *Server) findStartBundle(afterTime time.Time) int { 1426 1238 if afterTime.IsZero() { 1427 1239 return 1 1428 1240 } 1429 1241 1430 - foundBundle, err := s.db.GetBundleForTimestamp(ctx, afterTime) 1431 - if err != nil { 1432 - return 1 1433 - } 1434 - 1242 + foundBundle := s.bundleManager.FindBundleForTimestamp(afterTime) 1435 1243 if foundBundle > 1 { 1436 1244 return foundBundle - 1 1437 1245 } ··· 1442 1250 var allOps []plc.PLCOperation 1443 1251 seenCIDs := make(map[string]bool) 1444 1252 1445 - lastBundle, _ := s.db.GetLastBundleNumber(ctx) 1253 + lastBundle := s.bundleManager.GetLastBundleNumber() 1446 1254 1447 1255 for bundleNum := startBundle; bundleNum <= lastBundle && len(allOps) < count; bundleNum++ { 1448 1256 ops, err := s.bundleManager.LoadBundleOperations(ctx, bundleNum) ··· 1602 1410 limit := getQueryInt(r, "limit", 0) 1603 1411 fromBundle := getQueryInt(r, "from", 1) 1604 1412 1605 - history, err := s.db.GetPLCHistory(r.Context(), limit, fromBundle) 1413 + // Use BundleManager instead of database 1414 + history, err := s.bundleManager.GetPLCHistory(r.Context(), limit, fromBundle) 1606 1415 if err != nil { 1607 1416 resp.error(err.Error(), http.StatusInternalServerError) 1608 1417 return ··· 1674 1483 }) 1675 1484 } 1676 1485 1677 - // ===== UTILITY FUNCTIONS ===== 1486 + func (s *Server) handleGetBundleLabels(w http.ResponseWriter, r *http.Request) { 1487 + resp := newResponse(w) 1678 1488 1679 - func computeOperationsHash(ops []plc.PLCOperation) string { 1680 - var jsonlData []byte 1681 - for _, op := range ops { 1682 - jsonlData = append(jsonlData, op.RawJSON...) 1683 - jsonlData = append(jsonlData, '\n') 1489 + bundleNum, err := getBundleNumber(r) 1490 + if err != nil { 1491 + resp.error("invalid bundle number", http.StatusBadRequest) 1492 + return 1684 1493 } 1685 - hash := sha256.Sum256(jsonlData) 1686 - return hex.EncodeToString(hash[:]) 1494 + 1495 + labels, err := s.bundleManager.GetBundleLabels(r.Context(), bundleNum) 1496 + if err != nil { 1497 + resp.error(err.Error(), http.StatusInternalServerError) 1498 + return 1499 + } 1500 + 1501 + resp.json(map[string]interface{}{ 1502 + "bundle": bundleNum, 1503 + "count": len(labels), 1504 + "labels": labels, 1505 + }) 1687 1506 } 1507 + 1508 + // ===== UTILITY FUNCTIONS ===== 1688 1509 1689 1510 func normalizeEndpoint(endpoint string) string { 1690 1511 endpoint = strings.TrimPrefix(endpoint, "https://")
+11 -13
internal/api/server.go
··· 6 6 "net/http" 7 7 "time" 8 8 9 - "github.com/atscan/atscanner/internal/config" 10 - "github.com/atscan/atscanner/internal/log" 11 - "github.com/atscan/atscanner/internal/plc" 12 - "github.com/atscan/atscanner/internal/storage" 9 + "github.com/atscan/atscand/internal/config" 10 + "github.com/atscan/atscand/internal/log" 11 + "github.com/atscan/atscand/internal/plc" 12 + "github.com/atscan/atscand/internal/storage" 13 13 "github.com/gorilla/handlers" 14 14 "github.com/gorilla/mux" 15 15 ) ··· 18 18 router *mux.Router 19 19 server *http.Server 20 20 db storage.Database 21 - plcClient *plc.Client 22 21 plcBundleDir string 23 22 bundleManager *plc.BundleManager 24 23 plcIndexDIDs bool 25 24 } 26 25 27 - func NewServer(db storage.Database, apiCfg config.APIConfig, plcCfg config.PLCConfig) *Server { 28 - bundleManager, _ := plc.NewBundleManager(plcCfg.BundleDir, plcCfg.UseCache, db, plcCfg.IndexDIDs) 29 - 26 + func NewServer(db storage.Database, apiCfg config.APIConfig, plcCfg config.PLCConfig, bundleManager *plc.BundleManager) *Server { 30 27 s := &Server{ 31 28 router: mux.NewRouter(), 32 29 db: db, 33 - plcClient: plc.NewClient(plcCfg.DirectoryURL), 34 30 plcBundleDir: plcCfg.BundleDir, 35 - bundleManager: bundleManager, 31 + bundleManager: bundleManager, // Use provided shared instance 36 32 plcIndexDIDs: plcCfg.IndexDIDs, 37 33 } 38 34 ··· 61 57 // Generic endpoints (keep as-is) 62 58 api.HandleFunc("/endpoints", s.handleGetEndpoints).Methods("GET") 63 59 api.HandleFunc("/endpoints/stats", s.handleGetEndpointStats).Methods("GET") 60 + api.HandleFunc("/endpoints/random", s.handleGetRandomEndpoint).Methods("GET") 64 61 65 62 //PDS-specific endpoints (virtual, created via JOINs) 66 63 api.HandleFunc("/pds", s.handleGetPDSList).Methods("GET") ··· 75 72 api.HandleFunc("/pds/{endpoint}/repos/stats", s.handleGetPDSRepoStats).Methods("GET") 76 73 api.HandleFunc("/pds/repos/{did}", s.handleGetDIDRepos).Methods("GET") 77 74 78 - // Global DID route 75 + // Global DID routes 79 76 api.HandleFunc("/did/{did}", s.handleGetGlobalDID).Methods("GET") 77 + api.HandleFunc("/handle/{handle}", s.handleGetDIDByHandle).Methods("GET") // NEW 80 78 81 79 // PLC Bundle routes 82 80 api.HandleFunc("/plc/bundles", s.handleGetPLCBundles).Methods("GET") ··· 86 84 api.HandleFunc("/plc/bundles/{number}", s.handleGetPLCBundle).Methods("GET") 87 85 api.HandleFunc("/plc/bundles/{number}/dids", s.handleGetPLCBundleDIDs).Methods("GET") 88 86 api.HandleFunc("/plc/bundles/{number}/download", s.handleDownloadPLCBundle).Methods("GET") 89 - api.HandleFunc("/plc/bundles/{bundleNumber}/verify", s.handleVerifyPLCBundle).Methods("POST") 87 + api.HandleFunc("/plc/bundles/{number}/labels", s.handleGetBundleLabels).Methods("GET") 90 88 91 89 // PLC history/metrics 92 90 api.HandleFunc("/plc/history", s.handleGetPLCHistory).Methods("GET") ··· 97 95 // DID routes 98 96 api.HandleFunc("/plc/did/{did}", s.handleGetDID).Methods("GET") 99 97 api.HandleFunc("/plc/did/{did}/history", s.handleGetDIDHistory).Methods("GET") 100 - api.HandleFunc("/plc/dids/stats", s.handleGetDIDStats).Methods("GET") // NEW 98 + api.HandleFunc("/plc/dids/stats", s.handleGetDIDStats).Methods("GET") 101 99 102 100 // Mempool routes 103 101 api.HandleFunc("/mempool/stats", s.handleGetMempoolStats).Methods("GET")
+36 -13
internal/ipinfo/client.go
··· 99 99 return ipInfo, nil 100 100 } 101 101 102 - // ExtractIPFromEndpoint extracts IP from endpoint URL 103 - func ExtractIPFromEndpoint(endpoint string) (string, error) { 102 + // IPAddresses holds both IPv4 and IPv6 addresses 103 + type IPAddresses struct { 104 + IPv4 string 105 + IPv6 string 106 + } 107 + 108 + // ExtractIPsFromEndpoint extracts both IPv4 and IPv6 from endpoint URL 109 + func ExtractIPsFromEndpoint(endpoint string) (*IPAddresses, error) { 104 110 // Parse URL 105 111 parsedURL, err := url.Parse(endpoint) 106 112 if err != nil { 107 - return "", fmt.Errorf("failed to parse endpoint URL: %w", err) 113 + return nil, fmt.Errorf("failed to parse endpoint URL: %w", err) 108 114 } 109 115 110 116 host := parsedURL.Hostname() 111 117 if host == "" { 112 - return "", fmt.Errorf("no hostname in endpoint") 118 + return nil, fmt.Errorf("no hostname in endpoint") 113 119 } 120 + 121 + result := &IPAddresses{} 114 122 115 123 // Check if host is already an IP 116 - if net.ParseIP(host) != nil { 117 - return host, nil 124 + if ip := net.ParseIP(host); ip != nil { 125 + if ip.To4() != nil { 126 + result.IPv4 = host 127 + } else { 128 + result.IPv6 = host 129 + } 130 + return result, nil 118 131 } 119 132 120 - // Resolve hostname to IP 133 + // Resolve hostname to IPs 121 134 ips, err := net.LookupIP(host) 122 135 if err != nil { 123 - return "", fmt.Errorf("failed to resolve hostname: %w", err) 136 + return nil, fmt.Errorf("failed to resolve hostname: %w", err) 124 137 } 125 138 126 139 if len(ips) == 0 { 127 - return "", fmt.Errorf("no IPs found for hostname") 140 + return nil, fmt.Errorf("no IPs found for hostname") 128 141 } 129 142 130 - // Return first IPv4 address 143 + // Extract both IPv4 and IPv6 131 144 for _, ip := range ips { 132 145 if ipv4 := ip.To4(); ipv4 != nil { 133 - return ipv4.String(), nil 146 + if result.IPv4 == "" { 147 + result.IPv4 = ipv4.String() 148 + } 149 + } else { 150 + if result.IPv6 == "" { 151 + result.IPv6 = ip.String() 152 + } 134 153 } 135 154 } 136 155 137 - // Fallback to first IP (might be IPv6) 138 - return ips[0].String(), nil 156 + // Must have at least one IP 157 + if result.IPv4 == "" && result.IPv6 == "" { 158 + return nil, fmt.Errorf("no valid IPs found") 159 + } 160 + 161 + return result, nil 139 162 }
+2 -2
internal/log/log.go
··· 28 28 errorLog = log.New(os.Stderr, "", 0) 29 29 } 30 30 31 - // timestamp returns current time in ISO 8601 format 31 + // timestamp returns current time with milliseconds (local time, no timezone) 32 32 func timestamp() string { 33 - return time.Now().Format(time.RFC3339) 33 + return time.Now().Format("2006-01-02T15:04:05.000") 34 34 } 35 35 36 36 func Verbose(format string, v ...interface{}) {
+37 -8
internal/pds/client.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "net" 7 8 "net/http" 8 9 "time" 9 10 ) ··· 83 84 } 84 85 85 86 // DescribeServer fetches com.atproto.server.describeServer 86 - func (c *Client) DescribeServer(ctx context.Context, endpoint string) (*ServerDescription, error) { 87 + // Returns: description, responseTime, usedIP, error 88 + func (c *Client) DescribeServer(ctx context.Context, endpoint string) (*ServerDescription, time.Duration, string, error) { 89 + startTime := time.Now() 87 90 url := fmt.Sprintf("%s/xrpc/com.atproto.server.describeServer", endpoint) 88 91 89 - //fmt.Println(url) 92 + // Track which IP was used 93 + var usedIP string 94 + transport := &http.Transport{ 95 + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 96 + conn, err := (&net.Dialer{ 97 + Timeout: 30 * time.Second, 98 + KeepAlive: 30 * time.Second, 99 + }).DialContext(ctx, network, addr) 100 + 101 + if err == nil && conn != nil { 102 + if remoteAddr := conn.RemoteAddr(); remoteAddr != nil { 103 + if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok { 104 + usedIP = tcpAddr.IP.String() 105 + } 106 + } 107 + } 108 + return conn, err 109 + }, 110 + } 111 + 112 + client := &http.Client{ 113 + Timeout: c.httpClient.Timeout, 114 + Transport: transport, 115 + } 90 116 91 117 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 92 118 if err != nil { 93 - return nil, err 119 + return nil, 0, "", err 94 120 } 95 121 96 - resp, err := c.httpClient.Do(req) 122 + resp, err := client.Do(req) 123 + responseTime := time.Since(startTime) 124 + 97 125 if err != nil { 98 - return nil, err 126 + return nil, responseTime, usedIP, err 99 127 } 100 128 defer resp.Body.Close() 101 129 102 130 if resp.StatusCode != http.StatusOK { 103 - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 131 + return nil, responseTime, usedIP, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 104 132 } 105 133 106 134 var desc ServerDescription 107 135 if err := json.NewDecoder(resp.Body).Decode(&desc); err != nil { 108 - return nil, err 136 + return nil, responseTime, usedIP, err 109 137 } 110 138 111 - return &desc, nil 139 + return &desc, responseTime, usedIP, nil 112 140 } 113 141 114 142 // CheckHealth performs a basic health check, ensuring the endpoint returns JSON with a "version" 143 + // Returns: available, responseTime, version, error 115 144 func (c *Client) CheckHealth(ctx context.Context, endpoint string) (bool, time.Duration, string, error) { 116 145 startTime := time.Now() 117 146
+53 -38
internal/pds/scanner.go
··· 8 8 "sync/atomic" 9 9 "time" 10 10 11 - "github.com/acarl005/stripansi" 12 - "github.com/atscan/atscanner/internal/config" 13 - "github.com/atscan/atscanner/internal/ipinfo" 14 - "github.com/atscan/atscanner/internal/log" 15 - "github.com/atscan/atscanner/internal/monitor" 16 - "github.com/atscan/atscanner/internal/storage" 11 + "github.com/atscan/atscand/internal/config" 12 + "github.com/atscan/atscand/internal/ipinfo" 13 + "github.com/atscan/atscand/internal/log" 14 + "github.com/atscan/atscand/internal/monitor" 15 + "github.com/atscan/atscand/internal/storage" 17 16 ) 18 17 19 18 type Scanner struct { ··· 40 39 servers, err := s.db.GetEndpoints(ctx, &storage.EndpointFilter{ 41 40 Type: "pds", 42 41 OnlyStale: true, 42 + OnlyValid: true, 43 43 RecheckInterval: s.config.RecheckInterval, 44 44 }) 45 45 if err != nil { ··· 124 124 } 125 125 126 126 func (s *Scanner) scanAndSaveEndpoint(ctx context.Context, ep *storage.Endpoint) { 127 - // STEP 1: Resolve IP (before any network call) 128 - ip, err := ipinfo.ExtractIPFromEndpoint(ep.Endpoint) 127 + // STEP 1: Resolve IPs (both IPv4 and IPv6) 128 + ips, err := ipinfo.ExtractIPsFromEndpoint(ep.Endpoint) 129 129 if err != nil { 130 - // Mark as offline due to DNS failure 131 130 s.saveScanResult(ctx, ep.ID, &ScanResult{ 132 131 Status: storage.EndpointStatusOffline, 133 132 ErrorMessage: fmt.Sprintf("DNS resolution failed: %v", err), ··· 135 134 return 136 135 } 137 136 138 - // Update IP immediately 139 - s.db.UpdateEndpointIP(ctx, ep.ID, ip, time.Now().UTC()) 137 + // Update IPs immediately 138 + s.db.UpdateEndpointIPs(ctx, ep.ID, ips.IPv4, ips.IPv6, time.Now().UTC()) 140 139 141 - // STEP 2: Health check 142 - available, responseTime, version, err := s.client.CheckHealth(ctx, ep.Endpoint) 143 - if err != nil || !available { 144 - errMsg := "health check failed" 145 - if err != nil { 146 - errMsg = err.Error() 147 - } 140 + // STEP 1.5: Fetch IP info asynchronously for both IPs 141 + if ips.IPv4 != "" { 142 + go s.updateIPInfoIfNeeded(ctx, ips.IPv4) 143 + } 144 + if ips.IPv6 != "" { 145 + go s.updateIPInfoIfNeeded(ctx, ips.IPv6) 146 + } 147 + 148 + // STEP 2: Call describeServer (primary health check + metadata) 149 + desc, descResponseTime, usedIP, err := s.client.DescribeServer(ctx, ep.Endpoint) 150 + if err != nil { 148 151 s.saveScanResult(ctx, ep.ID, &ScanResult{ 149 152 Status: storage.EndpointStatusOffline, 150 - ResponseTime: responseTime, 151 - ErrorMessage: errMsg, 153 + ResponseTime: descResponseTime, 154 + ErrorMessage: fmt.Sprintf("describeServer failed: %v", err), 155 + UsedIP: usedIP, 152 156 }) 153 157 return 154 158 } 155 159 156 - // STEP 3: Fetch PDS-specific data 157 - desc, err := s.client.DescribeServer(ctx, ep.Endpoint) 158 - if err != nil { 159 - log.Verbose("Warning: failed to describe server %s: %v", stripansi.Strip(ep.Endpoint), err) 160 - } else if desc != nil && desc.DID != "" { 160 + // Update server DID immediately 161 + if desc.DID != "" { 161 162 s.db.UpdateEndpointServerDID(ctx, ep.ID, desc.DID) 162 163 } 163 164 164 - // Fetch repos with full info 165 + // STEP 3: Call _health to get version 166 + available, healthResponseTime, version, err := s.client.CheckHealth(ctx, ep.Endpoint) 167 + if err != nil || !available { 168 + log.Verbose("Warning: _health check failed for %s: %v", ep.Endpoint, err) 169 + // Server is online (describeServer worked) but _health failed 170 + // Continue with empty version 171 + version = "" 172 + } 173 + 174 + // Calculate average response time from both calls 175 + avgResponseTime := descResponseTime 176 + if available { 177 + avgResponseTime = (descResponseTime + healthResponseTime) / 2 178 + } 179 + 180 + // STEP 4: Fetch repos 165 181 repoList, err := s.client.ListRepos(ctx, ep.Endpoint) 166 182 if err != nil { 167 183 log.Verbose("Warning: failed to list repos for %s: %v", ep.Endpoint, err) 168 184 repoList = []Repo{} 169 185 } 170 186 171 - // Convert to DIDs for backward compatibility 187 + // Convert to DIDs 172 188 dids := make([]string, len(repoList)) 173 189 for i, repo := range repoList { 174 190 dids[i] = repo.DID 175 191 } 176 192 177 - // STEP 4: SAVE scan result 193 + // STEP 5: SAVE scan result 178 194 s.saveScanResult(ctx, ep.ID, &ScanResult{ 179 195 Status: storage.EndpointStatusOnline, 180 - ResponseTime: responseTime, 196 + ResponseTime: avgResponseTime, 181 197 Description: desc, 182 198 DIDs: dids, 183 199 Version: version, 200 + UsedIP: usedIP, // Only from describeServer 184 201 }) 185 202 186 - // Save repos in batches (only tracks changes) 203 + // STEP 6: Save repos in batches (only tracks changes) 187 204 if len(repoList) > 0 { 188 - batchSize := 10000 205 + batchSize := 100_000 189 206 190 207 log.Verbose("Processing %d repos for %s (tracking changes only)", len(repoList), ep.Endpoint) 191 208 ··· 225 242 226 243 log.Verbose("✓ Processed %d repos for %s", len(repoList), ep.Endpoint) 227 244 } 228 - 229 - // STEP 5: Fetch IP info if needed (async, with backoff) 230 - go s.updateIPInfoIfNeeded(ctx, ip) 231 245 } 232 246 233 247 func (s *Scanner) saveScanResult(ctx context.Context, endpointID int64, result *ScanResult) { ··· 237 251 Metadata: make(map[string]interface{}), 238 252 } 239 253 240 - var userCount int64 // NEW: Declare user count 254 + var userCount int64 241 255 242 256 // Add PDS-specific metadata 243 257 if result.Status == storage.EndpointStatusOnline { 244 - userCount = int64(len(result.DIDs)) // NEW: Get user count 245 - scanData.Metadata["user_count"] = userCount // Keep in JSON for completeness 258 + userCount = int64(len(result.DIDs)) 259 + scanData.Metadata["user_count"] = userCount 246 260 if result.Description != nil { 247 261 scanData.Metadata["server_info"] = result.Description 248 262 } ··· 259 273 Status: result.Status, 260 274 ResponseTime: result.ResponseTime.Seconds() * 1000, // Convert to ms 261 275 UserCount: userCount, 262 - Version: result.Version, // NEW: Set the version field 276 + Version: result.Version, 277 + UsedIP: result.UsedIP, // NEW 263 278 ScanData: scanData, 264 279 ScannedAt: time.Now().UTC(), 265 280 }
+2 -1
internal/pds/types.go
··· 37 37 ErrorMessage string 38 38 Description *ServerDescription 39 39 DIDs []string 40 - Version string // NEW: Add this field to pass the version 40 + Version string 41 + UsedIP string // NEW 41 42 }
-662
internal/plc/bundle.go
··· 1 - package plc 2 - 3 - import ( 4 - "bufio" 5 - "bytes" 6 - "context" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "fmt" 11 - "os" 12 - "path/filepath" 13 - "time" 14 - 15 - "github.com/atscan/atscanner/internal/log" 16 - "github.com/atscan/atscanner/internal/storage" 17 - "github.com/klauspost/compress/zstd" 18 - ) 19 - 20 - const BUNDLE_SIZE = 10000 21 - 22 - type BundleManager struct { 23 - dir string 24 - enabled bool 25 - encoder *zstd.Encoder 26 - decoder *zstd.Decoder 27 - db storage.Database 28 - indexDIDs bool 29 - } 30 - 31 - // ===== INITIALIZATION ===== 32 - 33 - func NewBundleManager(dir string, enabled bool, db storage.Database, indexDIDs bool) (*BundleManager, error) { 34 - if !enabled { 35 - return &BundleManager{enabled: false}, nil 36 - } 37 - 38 - if err := os.MkdirAll(dir, 0755); err != nil { 39 - return nil, fmt.Errorf("failed to create bundle dir: %w", err) 40 - } 41 - 42 - encoder, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBetterCompression)) 43 - if err != nil { 44 - return nil, err 45 - } 46 - 47 - decoder, err := zstd.NewReader(nil) 48 - if err != nil { 49 - return nil, err 50 - } 51 - 52 - return &BundleManager{ 53 - dir: dir, 54 - enabled: enabled, 55 - encoder: encoder, 56 - decoder: decoder, 57 - db: db, 58 - indexDIDs: indexDIDs, // NEW 59 - }, nil 60 - } 61 - 62 - func (bm *BundleManager) Close() { 63 - if bm.encoder != nil { 64 - bm.encoder.Close() 65 - } 66 - if bm.decoder != nil { 67 - bm.decoder.Close() 68 - } 69 - } 70 - 71 - // ===== BUNDLE FILE ABSTRACTION ===== 72 - 73 - type bundleFile struct { 74 - path string 75 - operations []PLCOperation 76 - uncompressedHash string 77 - compressedHash string 78 - } 79 - 80 - func (bm *BundleManager) newBundleFile(bundleNum int) *bundleFile { 81 - return &bundleFile{ 82 - path: filepath.Join(bm.dir, fmt.Sprintf("%06d.jsonl.zst", bundleNum)), 83 - } 84 - } 85 - 86 - func (bf *bundleFile) exists() bool { 87 - _, err := os.Stat(bf.path) 88 - return err == nil 89 - } 90 - 91 - func (bm *BundleManager) load(bf *bundleFile) error { 92 - compressed, err := os.ReadFile(bf.path) 93 - if err != nil { 94 - return fmt.Errorf("read failed: %w", err) 95 - } 96 - 97 - decompressed, err := bm.decoder.DecodeAll(compressed, nil) 98 - if err != nil { 99 - return fmt.Errorf("decompress failed: %w", err) 100 - } 101 - 102 - bf.operations = bm.parseJSONL(decompressed) 103 - return nil 104 - } 105 - 106 - func (bm *BundleManager) save(bf *bundleFile) error { 107 - jsonlData := bm.serializeJSONL(bf.operations) 108 - bf.uncompressedHash = bm.hash(jsonlData) 109 - 110 - compressed := bm.encoder.EncodeAll(jsonlData, nil) 111 - bf.compressedHash = bm.hash(compressed) 112 - 113 - return os.WriteFile(bf.path, compressed, 0644) 114 - } 115 - 116 - func (bm *BundleManager) parseJSONL(data []byte) []PLCOperation { 117 - var ops []PLCOperation 118 - scanner := bufio.NewScanner(bytes.NewReader(data)) 119 - 120 - for scanner.Scan() { 121 - line := scanner.Bytes() 122 - if len(line) == 0 { 123 - continue 124 - } 125 - 126 - var op PLCOperation 127 - if err := json.Unmarshal(line, &op); err == nil { 128 - op.RawJSON = append([]byte(nil), line...) 129 - ops = append(ops, op) 130 - } 131 - } 132 - 133 - return ops 134 - } 135 - 136 - func (bm *BundleManager) serializeJSONL(ops []PLCOperation) []byte { 137 - var buf []byte 138 - for _, op := range ops { 139 - buf = append(buf, op.RawJSON...) 140 - buf = append(buf, '\n') 141 - } 142 - return buf 143 - } 144 - 145 - // ===== BUNDLE FETCHING ===== 146 - 147 - type bundleFetcher struct { 148 - client *Client 149 - seenCIDs map[string]bool 150 - currentAfter string 151 - fetchCount int 152 - } 153 - 154 - func newBundleFetcher(client *Client, afterTime string, prevBoundaryCIDs map[string]bool) *bundleFetcher { 155 - seen := make(map[string]bool) 156 - for cid := range prevBoundaryCIDs { 157 - seen[cid] = true 158 - } 159 - 160 - return &bundleFetcher{ 161 - client: client, 162 - seenCIDs: seen, 163 - currentAfter: afterTime, 164 - } 165 - } 166 - 167 - func (bf *bundleFetcher) fetchUntilComplete(ctx context.Context, target int) ([]PLCOperation, bool) { 168 - var ops []PLCOperation 169 - maxFetches := (target / 900) + 5 170 - 171 - for len(ops) < target && bf.fetchCount < maxFetches { 172 - bf.fetchCount++ 173 - batchSize := bf.calculateBatchSize(target - len(ops)) 174 - 175 - log.Verbose(" Fetch #%d: need %d more, requesting %d", bf.fetchCount, target-len(ops), batchSize) 176 - 177 - batch, shouldContinue := bf.fetchBatch(ctx, batchSize) 178 - 179 - for _, op := range batch { 180 - if !bf.seenCIDs[op.CID] { 181 - bf.seenCIDs[op.CID] = true 182 - ops = append(ops, op) 183 - 184 - if len(ops) >= target { 185 - return ops[:target], true 186 - } 187 - } 188 - } 189 - 190 - if !shouldContinue { 191 - break 192 - } 193 - } 194 - 195 - return ops, len(ops) >= target 196 - } 197 - 198 - func (bf *bundleFetcher) calculateBatchSize(remaining int) int { 199 - if bf.fetchCount == 0 { 200 - return 1000 201 - } 202 - if remaining < 100 { 203 - return 50 204 - } 205 - if remaining < 500 { 206 - return 200 207 - } 208 - return 1000 209 - } 210 - 211 - func (bf *bundleFetcher) fetchBatch(ctx context.Context, size int) ([]PLCOperation, bool) { 212 - ops, err := bf.client.Export(ctx, ExportOptions{ 213 - Count: size, 214 - After: bf.currentAfter, 215 - }) 216 - 217 - if err != nil || len(ops) == 0 { 218 - return nil, false 219 - } 220 - 221 - if len(ops) > 0 { 222 - bf.currentAfter = ops[len(ops)-1].CreatedAt.Format(time.RFC3339Nano) 223 - } 224 - 225 - return ops, len(ops) >= size 226 - } 227 - 228 - // ===== MAIN BUNDLE LOADING ===== 229 - 230 - func (bm *BundleManager) LoadBundle(ctx context.Context, bundleNum int, plcClient *Client) ([]PLCOperation, bool, error) { 231 - if !bm.enabled { 232 - return nil, false, fmt.Errorf("bundle manager disabled") 233 - } 234 - 235 - bf := bm.newBundleFile(bundleNum) 236 - 237 - // Try local file first 238 - if bf.exists() { 239 - return bm.loadFromFile(ctx, bundleNum, bf) 240 - } 241 - 242 - // Fetch from PLC 243 - return bm.fetchFromPLC(ctx, bundleNum, bf, plcClient) 244 - } 245 - 246 - func (bm *BundleManager) loadFromFile(ctx context.Context, bundleNum int, bf *bundleFile) ([]PLCOperation, bool, error) { 247 - log.Verbose("→ Loading bundle %06d from local file", bundleNum) 248 - 249 - // Verify hash if bundle is in DB 250 - if dbBundle, err := bm.db.GetBundleByNumber(ctx, bundleNum); err == nil && dbBundle != nil { 251 - if err := bm.verifyHash(bf.path, dbBundle.CompressedHash); err != nil { 252 - log.Error("⚠ Hash mismatch for bundle %06d! Re-fetching...", bundleNum) 253 - os.Remove(bf.path) 254 - return nil, false, fmt.Errorf("hash mismatch") 255 - } 256 - log.Verbose("✓ Hash verified for bundle %06d", bundleNum) 257 - } 258 - 259 - if err := bm.load(bf); err != nil { 260 - return nil, false, err 261 - } 262 - 263 - // Index if not in DB 264 - if _, err := bm.db.GetBundleByNumber(ctx, bundleNum); err != nil { 265 - bf.compressedHash = bm.hashFile(bf.path) 266 - bf.uncompressedHash = bm.hash(bm.serializeJSONL(bf.operations)) 267 - 268 - // Calculate cursor from previous bundle 269 - cursor := bm.calculateCursor(ctx, bundleNum) 270 - 271 - bm.indexBundle(ctx, bundleNum, bf, cursor) 272 - } 273 - 274 - return bf.operations, true, nil 275 - } 276 - 277 - func (bm *BundleManager) fetchFromPLC(ctx context.Context, bundleNum int, bf *bundleFile, client *Client) ([]PLCOperation, bool, error) { 278 - log.Info("→ Bundle %06d not found locally, fetching from PLC directory...", bundleNum) 279 - 280 - afterTime, prevCIDs := bm.getBoundaryInfo(ctx, bundleNum) 281 - fetcher := newBundleFetcher(client, afterTime, prevCIDs) 282 - 283 - ops, isComplete := fetcher.fetchUntilComplete(ctx, BUNDLE_SIZE) 284 - 285 - log.Info(" Collected %d unique operations after %d fetches (complete=%v)", 286 - len(ops), fetcher.fetchCount, isComplete) 287 - 288 - if isComplete { 289 - bf.operations = ops 290 - if err := bm.save(bf); err != nil { 291 - log.Error("Warning: failed to save bundle: %v", err) 292 - } else { 293 - // The cursor is the afterTime that was used to fetch this bundle 294 - cursor := afterTime 295 - bm.indexBundle(ctx, bundleNum, bf, cursor) 296 - log.Info("✓ Bundle %06d saved [%d ops, hash: %s..., cursor: %s]", 297 - bundleNum, len(ops), bf.uncompressedHash[:16], cursor) 298 - } 299 - } 300 - 301 - return ops, isComplete, nil 302 - } 303 - 304 - func (bm *BundleManager) getBoundaryInfo(ctx context.Context, bundleNum int) (string, map[string]bool) { 305 - if bundleNum == 1 { 306 - return "", nil 307 - } 308 - 309 - prevBundle, err := bm.db.GetBundleByNumber(ctx, bundleNum-1) 310 - if err != nil { 311 - return "", nil 312 - } 313 - 314 - afterTime := prevBundle.EndTime.Format(time.RFC3339Nano) 315 - 316 - // Return stored boundary CIDs if available 317 - if len(prevBundle.BoundaryCIDs) > 0 { 318 - cids := make(map[string]bool) 319 - for _, cid := range prevBundle.BoundaryCIDs { 320 - cids[cid] = true 321 - } 322 - return afterTime, cids 323 - } 324 - 325 - // Fallback: compute from file 326 - bf := bm.newBundleFile(bundleNum - 1) 327 - if bf.exists() { 328 - if err := bm.load(bf); err == nil { 329 - _, cids := GetBoundaryCIDs(bf.operations) 330 - return afterTime, cids 331 - } 332 - } 333 - 334 - return afterTime, nil 335 - } 336 - 337 - // ===== BUNDLE INDEXING ===== 338 - 339 - func (bm *BundleManager) indexBundle(ctx context.Context, bundleNum int, bf *bundleFile, cursor string) error { 340 - prevHash := "" 341 - if bundleNum > 1 { 342 - if prev, err := bm.db.GetBundleByNumber(ctx, bundleNum-1); err == nil { 343 - prevHash = prev.Hash 344 - } 345 - } 346 - 347 - dids := bm.extractUniqueDIDs(bf.operations) 348 - compressedFileSize := bm.getFileSize(bf.path) 349 - 350 - // Calculate uncompressed size 351 - uncompressedSize := int64(0) 352 - for _, op := range bf.operations { 353 - uncompressedSize += int64(len(op.RawJSON)) + 1 // +1 for newline 354 - } 355 - 356 - // Get time range from operations 357 - firstSeenAt := bf.operations[0].CreatedAt 358 - lastSeenAt := bf.operations[len(bf.operations)-1].CreatedAt 359 - 360 - bundle := &storage.PLCBundle{ 361 - BundleNumber: bundleNum, 362 - StartTime: firstSeenAt, 363 - EndTime: lastSeenAt, 364 - DIDs: dids, 365 - Hash: bf.uncompressedHash, 366 - CompressedHash: bf.compressedHash, 367 - CompressedSize: compressedFileSize, 368 - UncompressedSize: uncompressedSize, 369 - Cursor: cursor, 370 - PrevBundleHash: prevHash, 371 - Compressed: true, 372 - CreatedAt: time.Now().UTC(), 373 - } 374 - 375 - // Create bundle first 376 - if err := bm.db.CreateBundle(ctx, bundle); err != nil { 377 - return err 378 - } 379 - 380 - // NEW: Only index DIDs if enabled 381 - if bm.indexDIDs { 382 - start := time.Now() 383 - if err := bm.db.AddBundleDIDs(ctx, bundleNum, dids); err != nil { 384 - log.Error("Failed to index DIDs for bundle %06d: %v", bundleNum, err) 385 - // Don't return error - bundle is already created 386 - } else { 387 - elapsed := time.Since(start) 388 - log.Verbose("✓ Indexed %d unique DIDs for bundle %06d in %v", len(dids), bundleNum, elapsed) 389 - } 390 - } else { 391 - log.Verbose("⊘ Skipped DID indexing for bundle %06d (disabled in config)", bundleNum) 392 - } 393 - 394 - return nil 395 - } 396 - 397 - func (bm *BundleManager) extractUniqueDIDs(ops []PLCOperation) []string { 398 - didSet := make(map[string]bool) 399 - for _, op := range ops { 400 - didSet[op.DID] = true 401 - } 402 - 403 - dids := make([]string, 0, len(didSet)) 404 - for did := range didSet { 405 - dids = append(dids, did) 406 - } 407 - return dids 408 - } 409 - 410 - // ===== MEMPOOL BUNDLE CREATION ===== 411 - 412 - func (bm *BundleManager) CreateBundleFromMempool(ctx context.Context, operations []PLCOperation, cursor string) (int, error) { 413 - if !bm.enabled { 414 - return 0, fmt.Errorf("bundle manager disabled") 415 - } 416 - 417 - if len(operations) != BUNDLE_SIZE { 418 - return 0, fmt.Errorf("bundle must have exactly %d operations, got %d", BUNDLE_SIZE, len(operations)) 419 - } 420 - 421 - lastBundle, err := bm.db.GetLastBundleNumber(ctx) 422 - if err != nil { 423 - return 0, err 424 - } 425 - bundleNum := lastBundle + 1 426 - 427 - bf := bm.newBundleFile(bundleNum) 428 - bf.operations = operations 429 - 430 - if err := bm.save(bf); err != nil { 431 - return 0, err 432 - } 433 - 434 - if err := bm.indexBundle(ctx, bundleNum, bf, cursor); err != nil { 435 - return 0, err 436 - } 437 - 438 - log.Info("✓ Created bundle %06d from mempool (hash: %s...)", 439 - bundleNum, bf.uncompressedHash[:16]) 440 - 441 - return bundleNum, nil 442 - } 443 - 444 - // ===== VERIFICATION ===== 445 - 446 - func (bm *BundleManager) VerifyChain(ctx context.Context, endBundle int) error { 447 - if !bm.enabled { 448 - return fmt.Errorf("bundle manager disabled") 449 - } 450 - 451 - log.Info("Verifying bundle chain from 1 to %06d...", endBundle) 452 - 453 - for i := 1; i <= endBundle; i++ { 454 - bundle, err := bm.db.GetBundleByNumber(ctx, i) 455 - if err != nil { 456 - return fmt.Errorf("bundle %06d not found: %w", i, err) 457 - } 458 - 459 - // Verify file hash 460 - path := bm.newBundleFile(i).path 461 - if err := bm.verifyHash(path, bundle.CompressedHash); err != nil { 462 - return fmt.Errorf("bundle %06d hash verification failed: %w", i, err) 463 - } 464 - 465 - // Verify chain link 466 - if i > 1 { 467 - prevBundle, err := bm.db.GetBundleByNumber(ctx, i-1) 468 - if err != nil { 469 - return fmt.Errorf("bundle %06d missing (required by %06d)", i-1, i) 470 - } 471 - 472 - if bundle.PrevBundleHash != prevBundle.Hash { 473 - return fmt.Errorf("bundle %06d chain broken! Expected prev_hash=%s, got=%s", 474 - i, prevBundle.Hash[:16], bundle.PrevBundleHash[:16]) 475 - } 476 - } 477 - 478 - if i%100 == 0 { 479 - log.Verbose(" ✓ Verified bundles 1-%06d", i) 480 - } 481 - } 482 - 483 - log.Info("✓ Chain verification complete: bundles 1-%06d are valid and continuous", endBundle) 484 - return nil 485 - } 486 - 487 - func (bm *BundleManager) EnsureBundleContinuity(ctx context.Context, targetBundle int) error { 488 - if !bm.enabled { 489 - return nil 490 - } 491 - 492 - for i := 1; i < targetBundle; i++ { 493 - if !bm.newBundleFile(i).exists() { 494 - if _, err := bm.db.GetBundleByNumber(ctx, i); err != nil { 495 - return fmt.Errorf("bundle %06d is missing (required for continuity)", i) 496 - } 497 - } 498 - } 499 - 500 - return nil 501 - } 502 - 503 - // ===== UTILITY METHODS ===== 504 - 505 - func (bm *BundleManager) hash(data []byte) string { 506 - h := sha256.Sum256(data) 507 - return hex.EncodeToString(h[:]) 508 - } 509 - 510 - func (bm *BundleManager) hashFile(path string) string { 511 - data, _ := os.ReadFile(path) 512 - return bm.hash(data) 513 - } 514 - 515 - func (bm *BundleManager) verifyHash(path, expectedHash string) error { 516 - if expectedHash == "" { 517 - return nil 518 - } 519 - 520 - actualHash := bm.hashFile(path) 521 - if actualHash != expectedHash { 522 - return fmt.Errorf("hash mismatch") 523 - } 524 - return nil 525 - } 526 - 527 - func (bm *BundleManager) getFileSize(path string) int64 { 528 - if info, err := os.Stat(path); err == nil { 529 - return info.Size() 530 - } 531 - return 0 532 - } 533 - 534 - func (bm *BundleManager) GetStats(ctx context.Context) (int64, int64, int64, int64, error) { 535 - if !bm.enabled { 536 - return 0, 0, 0, 0, nil 537 - } 538 - return bm.db.GetBundleStats(ctx) 539 - } 540 - 541 - func (bm *BundleManager) GetChainInfo(ctx context.Context) (map[string]interface{}, error) { 542 - lastBundle, err := bm.db.GetLastBundleNumber(ctx) 543 - if err != nil { 544 - return nil, err 545 - } 546 - 547 - if lastBundle == 0 { 548 - return map[string]interface{}{ 549 - "chain_length": 0, 550 - "status": "empty", 551 - }, nil 552 - } 553 - 554 - firstBundle, _ := bm.db.GetBundleByNumber(ctx, 1) 555 - lastBundleData, _ := bm.db.GetBundleByNumber(ctx, lastBundle) 556 - 557 - return map[string]interface{}{ 558 - "chain_length": lastBundle, 559 - "first_bundle": 1, 560 - "last_bundle": lastBundle, 561 - "chain_start_time": firstBundle.StartTime, 562 - "chain_end_time": lastBundleData.EndTime, 563 - "chain_head_hash": lastBundleData.Hash, 564 - }, nil 565 - } 566 - 567 - // ===== EXPORTED HELPERS ===== 568 - 569 - func GetBoundaryCIDs(operations []PLCOperation) (time.Time, map[string]bool) { 570 - if len(operations) == 0 { 571 - return time.Time{}, nil 572 - } 573 - 574 - lastOp := operations[len(operations)-1] 575 - boundaryTime := lastOp.CreatedAt 576 - cidSet := make(map[string]bool) 577 - 578 - for i := len(operations) - 1; i >= 0; i-- { 579 - op := operations[i] 580 - if op.CreatedAt.Equal(boundaryTime) { 581 - cidSet[op.CID] = true 582 - } else { 583 - break 584 - } 585 - } 586 - 587 - return boundaryTime, cidSet 588 - } 589 - 590 - func StripBoundaryDuplicates(operations []PLCOperation, boundaryTimestamp string, prevBoundaryCIDs map[string]bool) []PLCOperation { 591 - if len(operations) == 0 { 592 - return operations 593 - } 594 - 595 - boundaryTime, err := time.Parse(time.RFC3339Nano, boundaryTimestamp) 596 - if err != nil { 597 - return operations 598 - } 599 - 600 - startIdx := 0 601 - for startIdx < len(operations) { 602 - op := operations[startIdx] 603 - 604 - if op.CreatedAt.After(boundaryTime) { 605 - break 606 - } 607 - 608 - if op.CreatedAt.Equal(boundaryTime) && prevBoundaryCIDs[op.CID] { 609 - startIdx++ 610 - continue 611 - } 612 - 613 - break 614 - } 615 - 616 - return operations[startIdx:] 617 - } 618 - 619 - // LoadBundleOperations is a public method for external access (e.g., API handlers) 620 - func (bm *BundleManager) LoadBundleOperations(ctx context.Context, bundleNum int) ([]PLCOperation, error) { 621 - if !bm.enabled { 622 - return nil, fmt.Errorf("bundle manager disabled") 623 - } 624 - 625 - bf := bm.newBundleFile(bundleNum) 626 - 627 - if !bf.exists() { 628 - return nil, fmt.Errorf("bundle %06d not found", bundleNum) 629 - } 630 - 631 - if err := bm.load(bf); err != nil { 632 - return nil, err 633 - } 634 - 635 - return bf.operations, nil 636 - } 637 - 638 - // calculateCursor determines the cursor value for a given bundle 639 - // For bundle 1: returns empty string 640 - // For bundle N: returns the end_time of bundle N-1 in RFC3339Nano format 641 - func (bm *BundleManager) calculateCursor(ctx context.Context, bundleNum int) string { 642 - if bundleNum == 1 { 643 - return "" 644 - } 645 - 646 - // Try to get cursor from previous bundle in DB 647 - if prevBundle, err := bm.db.GetBundleByNumber(ctx, bundleNum-1); err == nil { 648 - return prevBundle.EndTime.Format(time.RFC3339Nano) 649 - } 650 - 651 - // If previous bundle not in DB, try to load it from file 652 - prevBf := bm.newBundleFile(bundleNum - 1) 653 - if prevBf.exists() { 654 - if err := bm.load(prevBf); err == nil && len(prevBf.operations) > 0 { 655 - // Return the createdAt of the last operation in previous bundle 656 - lastOp := prevBf.operations[len(prevBf.operations)-1] 657 - return lastOp.CreatedAt.Format(time.RFC3339Nano) 658 - } 659 - } 660 - 661 - return "" 662 - }
-237
internal/plc/client.go
··· 1 - package plc 2 - 3 - import ( 4 - "bufio" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "io" 9 - "net/http" 10 - "strconv" 11 - "time" 12 - 13 - "github.com/atscan/atscanner/internal/log" 14 - ) 15 - 16 - type Client struct { 17 - baseURL string 18 - httpClient *http.Client 19 - rateLimiter *RateLimiter 20 - } 21 - 22 - func NewClient(baseURL string) *Client { 23 - // Rate limit: 90 requests per minute (leaving buffer below 100/min limit) 24 - rateLimiter := NewRateLimiter(90, time.Minute) 25 - 26 - return &Client{ 27 - baseURL: baseURL, 28 - httpClient: &http.Client{ 29 - Timeout: 60 * time.Second, 30 - }, 31 - rateLimiter: rateLimiter, 32 - } 33 - } 34 - 35 - func (c *Client) Close() { 36 - if c.rateLimiter != nil { 37 - c.rateLimiter.Stop() 38 - } 39 - } 40 - 41 - type ExportOptions struct { 42 - Count int 43 - After string // ISO 8601 datetime string 44 - } 45 - 46 - // Export fetches export data from PLC directory with rate limiting and retry 47 - func (c *Client) Export(ctx context.Context, opts ExportOptions) ([]PLCOperation, error) { 48 - return c.exportWithRetry(ctx, opts, 5) 49 - } 50 - 51 - // exportWithRetry implements retry logic with exponential backoff for rate limits 52 - func (c *Client) exportWithRetry(ctx context.Context, opts ExportOptions, maxRetries int) ([]PLCOperation, error) { 53 - var lastErr error 54 - backoff := 1 * time.Second 55 - 56 - for attempt := 1; attempt <= maxRetries; attempt++ { 57 - // Wait for rate limiter token 58 - if err := c.rateLimiter.Wait(ctx); err != nil { 59 - return nil, err 60 - } 61 - 62 - operations, retryAfter, err := c.doExport(ctx, opts) 63 - 64 - if err == nil { 65 - return operations, nil 66 - } 67 - 68 - lastErr = err 69 - 70 - // Check if it's a rate limit error (429) 71 - if retryAfter > 0 { 72 - log.Info("⚠ Rate limited by PLC directory, waiting %v before retry %d/%d", 73 - retryAfter, attempt, maxRetries) 74 - 75 - select { 76 - case <-time.After(retryAfter): 77 - continue 78 - case <-ctx.Done(): 79 - return nil, ctx.Err() 80 - } 81 - } 82 - 83 - // Other errors - exponential backoff 84 - if attempt < maxRetries { 85 - log.Verbose("Request failed (attempt %d/%d): %v, retrying in %v", 86 - attempt, maxRetries, err, backoff) 87 - 88 - select { 89 - case <-time.After(backoff): 90 - backoff *= 2 // Exponential backoff 91 - case <-ctx.Done(): 92 - return nil, ctx.Err() 93 - } 94 - } 95 - } 96 - 97 - return nil, fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr) 98 - } 99 - 100 - // doExport performs the actual HTTP request 101 - func (c *Client) doExport(ctx context.Context, opts ExportOptions) ([]PLCOperation, time.Duration, error) { 102 - url := fmt.Sprintf("%s/export", c.baseURL) 103 - 104 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 105 - if err != nil { 106 - return nil, 0, err 107 - } 108 - 109 - // Add query parameters 110 - q := req.URL.Query() 111 - if opts.Count > 0 { 112 - q.Add("count", fmt.Sprintf("%d", opts.Count)) 113 - } 114 - if opts.After != "" { 115 - q.Add("after", opts.After) 116 - } 117 - req.URL.RawQuery = q.Encode() 118 - 119 - resp, err := c.httpClient.Do(req) 120 - if err != nil { 121 - return nil, 0, fmt.Errorf("request failed: %w", err) 122 - } 123 - defer resp.Body.Close() 124 - 125 - // Handle rate limiting (429) 126 - if resp.StatusCode == http.StatusTooManyRequests { 127 - retryAfter := parseRetryAfter(resp) 128 - 129 - // Also check x-ratelimit headers for info 130 - if limit := resp.Header.Get("x-ratelimit-limit"); limit != "" { 131 - log.Verbose("Rate limit: %s", limit) 132 - } 133 - 134 - return nil, retryAfter, fmt.Errorf("rate limited (429)") 135 - } 136 - 137 - if resp.StatusCode != http.StatusOK { 138 - body, _ := io.ReadAll(resp.Body) 139 - return nil, 0, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) 140 - } 141 - 142 - var operations []PLCOperation 143 - 144 - // PLC export returns newline-delimited JSON 145 - scanner := bufio.NewScanner(resp.Body) 146 - buf := make([]byte, 0, 64*1024) 147 - scanner.Buffer(buf, 1024*1024) 148 - 149 - lineCount := 0 150 - for scanner.Scan() { 151 - lineCount++ 152 - line := scanner.Bytes() 153 - 154 - if len(line) == 0 { 155 - continue 156 - } 157 - 158 - var op PLCOperation 159 - if err := json.Unmarshal(line, &op); err != nil { 160 - log.Error("Warning: failed to parse operation on line %d: %v", lineCount, err) 161 - continue 162 - } 163 - 164 - // CRITICAL: Store the original raw JSON bytes 165 - op.RawJSON = make([]byte, len(line)) 166 - copy(op.RawJSON, line) 167 - 168 - operations = append(operations, op) 169 - } 170 - 171 - if err := scanner.Err(); err != nil { 172 - return nil, 0, fmt.Errorf("error reading response: %w", err) 173 - } 174 - 175 - return operations, 0, nil 176 - 177 - } 178 - 179 - // parseRetryAfter parses the Retry-After header 180 - func parseRetryAfter(resp *http.Response) time.Duration { 181 - retryAfter := resp.Header.Get("Retry-After") 182 - if retryAfter == "" { 183 - // Default to 5 minutes if no header 184 - return 5 * time.Minute 185 - } 186 - 187 - // Try parsing as seconds 188 - if seconds, err := strconv.Atoi(retryAfter); err == nil { 189 - return time.Duration(seconds) * time.Second 190 - } 191 - 192 - // Try parsing as HTTP date 193 - if t, err := http.ParseTime(retryAfter); err == nil { 194 - return time.Until(t) 195 - } 196 - 197 - // Default 198 - return 5 * time.Minute 199 - } 200 - 201 - // GetDID fetches a specific DID document from PLC 202 - func (c *Client) GetDID(ctx context.Context, did string) (*DIDDocument, error) { 203 - // Wait for rate limiter 204 - if err := c.rateLimiter.Wait(ctx); err != nil { 205 - return nil, err 206 - } 207 - 208 - url := fmt.Sprintf("%s/%s", c.baseURL, did) 209 - 210 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 211 - if err != nil { 212 - return nil, err 213 - } 214 - 215 - resp, err := c.httpClient.Do(req) 216 - if err != nil { 217 - return nil, err 218 - } 219 - defer resp.Body.Close() 220 - 221 - if resp.StatusCode == http.StatusTooManyRequests { 222 - retryAfter := parseRetryAfter(resp) 223 - return nil, fmt.Errorf("rate limited, retry after %v", retryAfter) 224 - } 225 - 226 - if resp.StatusCode != http.StatusOK { 227 - body, _ := io.ReadAll(resp.Body) 228 - return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) 229 - } 230 - 231 - var doc DIDDocument 232 - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 233 - return nil, err 234 - } 235 - 236 - return &doc, nil 237 - }
+112
internal/plc/helpers.go
··· 1 + package plc 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + ) 7 + 8 + // MaxHandleLength is the maximum allowed handle length for database storage 9 + const MaxHandleLength = 500 10 + 11 + // Handle validation regex per AT Protocol spec 12 + // Ensures proper domain format: alphanumeric labels separated by dots, TLD starts with letter 13 + var handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 14 + 15 + // ExtractHandle safely extracts the handle from a PLC operation 16 + func ExtractHandle(op *PLCOperation) string { 17 + if op == nil || op.Operation == nil { 18 + return "" 19 + } 20 + 21 + // Get "alsoKnownAs" 22 + aka, ok := op.Operation["alsoKnownAs"].([]interface{}) 23 + if !ok { 24 + return "" 25 + } 26 + 27 + // Find the handle (e.g., "at://handle.bsky.social") 28 + for _, item := range aka { 29 + if handle, ok := item.(string); ok { 30 + if strings.HasPrefix(handle, "at://") { 31 + return strings.TrimPrefix(handle, "at://") 32 + } 33 + } 34 + } 35 + return "" 36 + } 37 + 38 + // ValidateHandle checks if a handle is valid for database storage 39 + // Returns empty string if handle is invalid (too long or wrong format) 40 + func ValidateHandle(handle string) string { 41 + if handle == "" { 42 + return "" 43 + } 44 + 45 + // Check length first (faster) 46 + if len(handle) > MaxHandleLength { 47 + return "" 48 + } 49 + 50 + // Validate format using regex 51 + if !handleRegex.MatchString(handle) { 52 + return "" 53 + } 54 + 55 + return handle 56 + } 57 + 58 + // ExtractPDS safely extracts the PDS endpoint from a PLC operation 59 + func ExtractPDS(op *PLCOperation) string { 60 + if op == nil || op.Operation == nil { 61 + return "" 62 + } 63 + 64 + // Get "services" 65 + services, ok := op.Operation["services"].(map[string]interface{}) 66 + if !ok { 67 + return "" 68 + } 69 + 70 + // Get "atproto_pds" 71 + pdsService, ok := services["atproto_pds"].(map[string]interface{}) 72 + if !ok { 73 + return "" 74 + } 75 + 76 + // Get "endpoint" 77 + if endpoint, ok := pdsService["endpoint"].(string); ok { 78 + return endpoint 79 + } 80 + 81 + return "" 82 + } 83 + 84 + // DIDInfo contains extracted metadata from a PLC operation 85 + type DIDInfo struct { 86 + Handle string 87 + PDS string 88 + } 89 + 90 + // ExtractDIDInfo extracts both handle and PDS from an operation 91 + func ExtractDIDInfo(op *PLCOperation) DIDInfo { 92 + return DIDInfo{ 93 + Handle: ExtractHandle(op), 94 + PDS: ExtractPDS(op), 95 + } 96 + } 97 + 98 + // ExtractDIDInfoMap creates a map of DID -> info from operations 99 + // Processes in reverse order to get the latest state for each DID 100 + func ExtractDIDInfoMap(ops []PLCOperation) map[string]DIDInfo { 101 + infoMap := make(map[string]DIDInfo) 102 + 103 + // Process in reverse to get latest state 104 + for i := len(ops) - 1; i >= 0; i-- { 105 + op := ops[i] 106 + if _, exists := infoMap[op.DID]; !exists { 107 + infoMap[op.DID] = ExtractDIDInfo(&op) 108 + } 109 + } 110 + 111 + return infoMap 112 + }
+522
internal/plc/manager.go
··· 1 + package plc 2 + 3 + import ( 4 + "context" 5 + "encoding/csv" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path/filepath" 10 + "sort" 11 + "strconv" 12 + "strings" 13 + "time" 14 + 15 + "github.com/atscan/atscand/internal/log" 16 + "github.com/atscan/atscand/internal/storage" 17 + "github.com/klauspost/compress/zstd" 18 + plcbundle "tangled.org/atscan.net/plcbundle" 19 + ) 20 + 21 + // BundleManager wraps the library's manager with database integration 22 + type BundleManager struct { 23 + libManager *plcbundle.Manager 24 + db storage.Database 25 + bundleDir string 26 + indexDIDs bool 27 + } 28 + 29 + func NewBundleManager(bundleDir string, plcURL string, db storage.Database, indexDIDs bool) (*BundleManager, error) { 30 + // Create library config 31 + config := plcbundle.DefaultConfig(bundleDir) 32 + 33 + // Create PLC client 34 + var client *plcbundle.PLCClient 35 + if plcURL != "" { 36 + client = plcbundle.NewPLCClient(plcURL) 37 + } 38 + 39 + // Create library manager 40 + libMgr, err := plcbundle.NewManager(config, client) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create library manager: %w", err) 43 + } 44 + 45 + return &BundleManager{ 46 + libManager: libMgr, 47 + db: db, 48 + bundleDir: bundleDir, 49 + indexDIDs: indexDIDs, 50 + }, nil 51 + } 52 + 53 + func (bm *BundleManager) Close() { 54 + if bm.libManager != nil { 55 + bm.libManager.Close() 56 + } 57 + } 58 + 59 + // LoadBundle loads a bundle (from library) and returns operations 60 + func (bm *BundleManager) LoadBundleOperations(ctx context.Context, bundleNum int) ([]PLCOperation, error) { 61 + bundle, err := bm.libManager.LoadBundle(ctx, bundleNum) 62 + if err != nil { 63 + return nil, err 64 + } 65 + return bundle.Operations, nil 66 + } 67 + 68 + // LoadBundle loads a full bundle with metadata 69 + func (bm *BundleManager) LoadBundle(ctx context.Context, bundleNum int) (*plcbundle.Bundle, error) { 70 + return bm.libManager.LoadBundle(ctx, bundleNum) 71 + } 72 + 73 + // FetchAndSaveBundle fetches next bundle from PLC and saves 74 + func (bm *BundleManager) FetchAndSaveBundle(ctx context.Context) (*plcbundle.Bundle, error) { 75 + // Fetch from PLC using library 76 + bundle, err := bm.libManager.FetchNextBundle(ctx) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + // Save to disk (library handles this) 82 + if err := bm.libManager.SaveBundle(ctx, bundle); err != nil { 83 + return nil, fmt.Errorf("failed to save bundle to disk: %w", err) 84 + } 85 + 86 + // Index DIDs if enabled (still use database for this) 87 + if bm.indexDIDs && len(bundle.Operations) > 0 { 88 + if err := bm.indexBundleDIDs(ctx, bundle); err != nil { 89 + log.Error("Failed to index DIDs for bundle %d: %v", bundle.BundleNumber, err) 90 + } 91 + } 92 + 93 + log.Info("✓ Saved bundle %06d", bundle.BundleNumber) 94 + 95 + return bundle, nil 96 + } 97 + 98 + // indexBundleDIDs indexes DIDs from a bundle into the database 99 + func (bm *BundleManager) indexBundleDIDs(ctx context.Context, bundle *plcbundle.Bundle) error { 100 + start := time.Now() 101 + log.Verbose("Indexing DIDs for bundle %06d...", bundle.BundleNumber) 102 + 103 + // Extract DID info from operations 104 + didInfoMap := ExtractDIDInfoMap(bundle.Operations) 105 + 106 + successCount := 0 107 + errorCount := 0 108 + invalidHandleCount := 0 109 + 110 + // Upsert each DID 111 + for did, info := range didInfoMap { 112 + validHandle := ValidateHandle(info.Handle) 113 + if info.Handle != "" && validHandle == "" { 114 + invalidHandleCount++ 115 + } 116 + 117 + if err := bm.db.UpsertDID(ctx, did, bundle.BundleNumber, validHandle, info.PDS); err != nil { 118 + log.Error("Failed to index DID %s: %v", did, err) 119 + errorCount++ 120 + } else { 121 + successCount++ 122 + } 123 + } 124 + 125 + elapsed := time.Since(start) 126 + log.Info("✓ Indexed %d DIDs for bundle %06d (%d errors, %d invalid handles) in %v", 127 + successCount, bundle.BundleNumber, errorCount, invalidHandleCount, elapsed) 128 + 129 + return nil 130 + } 131 + 132 + // VerifyChain verifies bundle chain integrity 133 + func (bm *BundleManager) VerifyChain(ctx context.Context, endBundle int) error { 134 + result, err := bm.libManager.VerifyChain(ctx) 135 + if err != nil { 136 + return err 137 + } 138 + 139 + if !result.Valid { 140 + return fmt.Errorf("chain verification failed at bundle %d: %s", result.BrokenAt, result.Error) 141 + } 142 + 143 + return nil 144 + } 145 + 146 + // GetChainInfo returns chain information 147 + func (bm *BundleManager) GetChainInfo(ctx context.Context) (map[string]interface{}, error) { 148 + return bm.libManager.GetInfo(), nil 149 + } 150 + 151 + // GetMempoolStats returns mempool statistics from the library 152 + func (bm *BundleManager) GetMempoolStats() map[string]interface{} { 153 + return bm.libManager.GetMempoolStats() 154 + } 155 + 156 + // GetMempoolOperations returns all operations currently in mempool 157 + func (bm *BundleManager) GetMempoolOperations() ([]PLCOperation, error) { 158 + return bm.libManager.GetMempoolOperations() 159 + } 160 + 161 + // GetIndex returns the library's bundle index 162 + func (bm *BundleManager) GetIndex() *plcbundle.Index { 163 + return bm.libManager.GetIndex() 164 + } 165 + 166 + // GetLastBundleNumber returns the last bundle number 167 + func (bm *BundleManager) GetLastBundleNumber() int { 168 + index := bm.libManager.GetIndex() 169 + lastBundle := index.GetLastBundle() 170 + if lastBundle == nil { 171 + return 0 172 + } 173 + return lastBundle.BundleNumber 174 + } 175 + 176 + // GetBundleMetadata gets bundle metadata by number 177 + func (bm *BundleManager) GetBundleMetadata(bundleNum int) (*plcbundle.BundleMetadata, error) { 178 + index := bm.libManager.GetIndex() 179 + return index.GetBundle(bundleNum) 180 + } 181 + 182 + // GetBundles returns the most recent bundles (newest first) 183 + func (bm *BundleManager) GetBundles(limit int) []*plcbundle.BundleMetadata { 184 + index := bm.libManager.GetIndex() 185 + allBundles := index.GetBundles() 186 + 187 + // Determine how many bundles to return 188 + count := limit 189 + if count <= 0 || count > len(allBundles) { 190 + count = len(allBundles) 191 + } 192 + 193 + // Build result in reverse order (newest first) 194 + result := make([]*plcbundle.BundleMetadata, count) 195 + for i := 0; i < count; i++ { 196 + result[i] = allBundles[len(allBundles)-1-i] 197 + } 198 + 199 + return result 200 + } 201 + 202 + // GetBundleStats returns bundle statistics 203 + func (bm *BundleManager) GetBundleStats() map[string]interface{} { 204 + index := bm.libManager.GetIndex() 205 + stats := index.GetStats() 206 + 207 + // Convert to expected format 208 + lastBundle := stats["last_bundle"] 209 + if lastBundle == nil { 210 + lastBundle = int64(0) 211 + } 212 + 213 + // Calculate total uncompressed size by iterating through all bundles 214 + totalUncompressedSize := int64(0) 215 + allBundles := index.GetBundles() 216 + for _, bundle := range allBundles { 217 + totalUncompressedSize += bundle.UncompressedSize 218 + } 219 + 220 + return map[string]interface{}{ 221 + "bundle_count": int64(stats["bundle_count"].(int)), 222 + "total_size": stats["total_size"].(int64), 223 + "total_uncompressed_size": totalUncompressedSize, 224 + "last_bundle": int64(lastBundle.(int)), 225 + } 226 + } 227 + 228 + // GetDIDsForBundle gets DIDs from a bundle (loads and extracts) 229 + func (bm *BundleManager) GetDIDsForBundle(ctx context.Context, bundleNum int) ([]string, int, error) { 230 + bundle, err := bm.libManager.LoadBundle(ctx, bundleNum) 231 + if err != nil { 232 + return nil, 0, err 233 + } 234 + 235 + // Extract unique DIDs 236 + didSet := make(map[string]bool) 237 + for _, op := range bundle.Operations { 238 + didSet[op.DID] = true 239 + } 240 + 241 + dids := make([]string, 0, len(didSet)) 242 + for did := range didSet { 243 + dids = append(dids, did) 244 + } 245 + 246 + return dids, bundle.DIDCount, nil 247 + } 248 + 249 + // FindBundleForTimestamp finds bundle containing a timestamp 250 + func (bm *BundleManager) FindBundleForTimestamp(afterTime time.Time) int { 251 + index := bm.libManager.GetIndex() 252 + bundles := index.GetBundles() 253 + 254 + // Find bundle containing this time 255 + for _, bundle := range bundles { 256 + if (bundle.StartTime.Before(afterTime) || bundle.StartTime.Equal(afterTime)) && 257 + (bundle.EndTime.After(afterTime) || bundle.EndTime.Equal(afterTime)) { 258 + return bundle.BundleNumber 259 + } 260 + } 261 + 262 + // Return closest bundle before this time 263 + for i := len(bundles) - 1; i >= 0; i-- { 264 + if bundles[i].EndTime.Before(afterTime) { 265 + return bundles[i].BundleNumber 266 + } 267 + } 268 + 269 + return 1 // Default to first bundle 270 + } 271 + 272 + // StreamRaw streams raw compressed bundle data 273 + func (bm *BundleManager) StreamRaw(ctx context.Context, bundleNumber int) (io.ReadCloser, error) { 274 + return bm.libManager.StreamBundleRaw(ctx, bundleNumber) 275 + } 276 + 277 + // StreamDecompressed streams decompressed bundle data 278 + func (bm *BundleManager) StreamDecompressed(ctx context.Context, bundleNumber int) (io.ReadCloser, error) { 279 + return bm.libManager.StreamBundleDecompressed(ctx, bundleNumber) 280 + } 281 + 282 + // GetPLCHistory calculates historical statistics from the bundle index 283 + func (bm *BundleManager) GetPLCHistory(ctx context.Context, limit int, fromBundle int) ([]*storage.PLCHistoryPoint, error) { 284 + index := bm.libManager.GetIndex() 285 + allBundles := index.GetBundles() 286 + 287 + // Filter bundles >= fromBundle 288 + var filtered []*plcbundle.BundleMetadata 289 + for _, b := range allBundles { 290 + if b.BundleNumber >= fromBundle { 291 + filtered = append(filtered, b) 292 + } 293 + } 294 + 295 + if len(filtered) == 0 { 296 + return []*storage.PLCHistoryPoint{}, nil 297 + } 298 + 299 + // Sort bundles by bundle number to ensure proper cumulative calculation 300 + sort.Slice(filtered, func(i, j int) bool { 301 + return filtered[i].BundleNumber < filtered[j].BundleNumber 302 + }) 303 + 304 + // Group by date 305 + type dailyStat struct { 306 + lastBundle int 307 + bundleCount int 308 + totalUncompressed int64 309 + totalCompressed int64 310 + } 311 + 312 + dailyStats := make(map[string]*dailyStat) 313 + 314 + // Map to store the cumulative values at the end of each date 315 + dateCumulatives := make(map[string]struct { 316 + uncompressed int64 317 + compressed int64 318 + }) 319 + 320 + // Calculate cumulative totals as we iterate through sorted bundles 321 + cumulativeUncompressed := int64(0) 322 + cumulativeCompressed := int64(0) 323 + 324 + for _, bundle := range filtered { 325 + dateStr := bundle.StartTime.Format("2006-01-02") 326 + 327 + // Update cumulative totals 328 + cumulativeUncompressed += bundle.UncompressedSize 329 + cumulativeCompressed += bundle.CompressedSize 330 + 331 + if stat, exists := dailyStats[dateStr]; exists { 332 + // Update existing day 333 + if bundle.BundleNumber > stat.lastBundle { 334 + stat.lastBundle = bundle.BundleNumber 335 + } 336 + stat.bundleCount++ 337 + stat.totalUncompressed += bundle.UncompressedSize 338 + stat.totalCompressed += bundle.CompressedSize 339 + } else { 340 + // Create new day entry 341 + dailyStats[dateStr] = &dailyStat{ 342 + lastBundle: bundle.BundleNumber, 343 + bundleCount: 1, 344 + totalUncompressed: bundle.UncompressedSize, 345 + totalCompressed: bundle.CompressedSize, 346 + } 347 + } 348 + 349 + // Store the cumulative values at the end of this date 350 + // (will be overwritten if there are multiple bundles on the same day) 351 + dateCumulatives[dateStr] = struct { 352 + uncompressed int64 353 + compressed int64 354 + }{ 355 + uncompressed: cumulativeUncompressed, 356 + compressed: cumulativeCompressed, 357 + } 358 + } 359 + 360 + // Convert map to sorted slice by date 361 + var dates []string 362 + for date := range dailyStats { 363 + dates = append(dates, date) 364 + } 365 + sort.Strings(dates) 366 + 367 + // Build history points with cumulative operations 368 + var history []*storage.PLCHistoryPoint 369 + cumulativeOps := 0 370 + 371 + for _, date := range dates { 372 + stat := dailyStats[date] 373 + cumulativeOps += stat.bundleCount * 10000 374 + cumulative := dateCumulatives[date] 375 + 376 + history = append(history, &storage.PLCHistoryPoint{ 377 + Date: date, 378 + BundleNumber: stat.lastBundle, 379 + OperationCount: cumulativeOps, 380 + UncompressedSize: stat.totalUncompressed, 381 + CompressedSize: stat.totalCompressed, 382 + CumulativeUncompressed: cumulative.uncompressed, 383 + CumulativeCompressed: cumulative.compressed, 384 + }) 385 + } 386 + 387 + // Apply limit if specified 388 + if limit > 0 && len(history) > limit { 389 + history = history[:limit] 390 + } 391 + 392 + return history, nil 393 + } 394 + 395 + // GetBundleLabels reads labels from a compressed CSV file for a specific bundle 396 + func (bm *BundleManager) GetBundleLabels(ctx context.Context, bundleNum int) ([]*PLCOpLabel, error) { 397 + // Define the path to the labels file 398 + labelsDir := filepath.Join(bm.bundleDir, "labels") 399 + labelsFile := filepath.Join(labelsDir, fmt.Sprintf("%06d.csv.zst", bundleNum)) 400 + 401 + // Check if file exists 402 + if _, err := os.Stat(labelsFile); os.IsNotExist(err) { 403 + log.Verbose("No labels file found for bundle %d at %s", bundleNum, labelsFile) 404 + // Return empty, not an error 405 + return []*PLCOpLabel{}, nil 406 + } 407 + 408 + // Open the Zstd-compressed file 409 + file, err := os.Open(labelsFile) 410 + if err != nil { 411 + return nil, fmt.Errorf("failed to open labels file: %w", err) 412 + } 413 + defer file.Close() 414 + 415 + // Create a Zstd reader 416 + zstdReader, err := zstd.NewReader(file) 417 + if err != nil { 418 + return nil, fmt.Errorf("failed to create zstd reader: %w", err) 419 + } 420 + defer zstdReader.Close() 421 + 422 + // Create a CSV reader 423 + csvReader := csv.NewReader(zstdReader) 424 + // We skipped the header, so no header read needed 425 + // Set FieldsPerRecord to 7 for validation 426 + //csvReader.FieldsPerRecord = 7 427 + 428 + var labels []*PLCOpLabel 429 + 430 + // Read all records 431 + for { 432 + // Check for context cancellation 433 + if err := ctx.Err(); err != nil { 434 + return nil, err 435 + } 436 + 437 + record, err := csvReader.Read() 438 + if err == io.EOF { 439 + break // End of file 440 + } 441 + if err != nil { 442 + log.Error("Error reading CSV record in %s: %v", labelsFile, err) 443 + continue // Skip bad line 444 + } 445 + 446 + // Parse the CSV record (which is []string) 447 + label, err := parseLabelRecord(record) 448 + if err != nil { 449 + log.Error("Error parsing CSV data for bundle %d: %v", bundleNum, err) 450 + continue // Skip bad data 451 + } 452 + 453 + labels = append(labels, label) 454 + } 455 + 456 + return labels, nil 457 + } 458 + 459 + // parseLabelRecord converts a new format CSV record into a PLCOpLabel struct 460 + func parseLabelRecord(record []string) (*PLCOpLabel, error) { 461 + // New format: 0:bundle, 1:position, 2:cid(short), 3:size, 4:confidence, 5:labels 462 + if len(record) != 6 { 463 + err := fmt.Errorf("invalid record length: expected 6, got %d", len(record)) 464 + // --- ADDED LOG --- 465 + log.Warn("Skipping malformed CSV line: %v (data: %s)", err, strings.Join(record, ",")) 466 + // --- 467 + return nil, err 468 + } 469 + 470 + // 0:bundle 471 + bundle, err := strconv.Atoi(record[0]) 472 + if err != nil { 473 + // --- ADDED LOG --- 474 + log.Warn("Skipping malformed CSV line: 'bundle' column: %v (data: %s)", err, strings.Join(record, ",")) 475 + // --- 476 + return nil, fmt.Errorf("parsing 'bundle': %w", err) 477 + } 478 + 479 + // 1:position 480 + position, err := strconv.Atoi(record[1]) 481 + if err != nil { 482 + // --- ADDED LOG --- 483 + log.Warn("Skipping malformed CSV line: 'position' column: %v (data: %s)", err, strings.Join(record, ",")) 484 + // --- 485 + return nil, fmt.Errorf("parsing 'position': %w", err) 486 + } 487 + 488 + // 2:cid(short) 489 + shortCID := record[2] 490 + 491 + // 3:size 492 + size, err := strconv.Atoi(record[3]) 493 + if err != nil { 494 + // --- ADDED LOG --- 495 + log.Warn("Skipping malformed CSV line: 'size' column: %v (data: %s)", err, strings.Join(record, ",")) 496 + // --- 497 + return nil, fmt.Errorf("parsing 'size': %w", err) 498 + } 499 + 500 + // 4:confidence 501 + confidence, err := strconv.ParseFloat(record[4], 64) 502 + if err != nil { 503 + // --- ADDED LOG --- 504 + log.Warn("Skipping malformed CSV line: 'confidence' column: %v (data: %s)", err, strings.Join(record, ",")) 505 + // --- 506 + return nil, fmt.Errorf("parsing 'confidence': %w", err) 507 + } 508 + 509 + // 5:labels 510 + detectors := strings.Split(record[5], ";") 511 + 512 + label := &PLCOpLabel{ 513 + Bundle: bundle, 514 + Position: position, 515 + CID: shortCID, 516 + Size: size, 517 + Confidence: confidence, 518 + Detectors: detectors, 519 + } 520 + 521 + return label, nil 522 + }
-70
internal/plc/ratelimiter.go
··· 1 - package plc 2 - 3 - import ( 4 - "context" 5 - "time" 6 - ) 7 - 8 - // RateLimiter implements a token bucket rate limiter 9 - type RateLimiter struct { 10 - tokens chan struct{} 11 - refillRate time.Duration 12 - maxTokens int 13 - stopRefill chan struct{} 14 - } 15 - 16 - // NewRateLimiter creates a new rate limiter 17 - // Example: NewRateLimiter(90, time.Minute) = 90 requests per minute 18 - func NewRateLimiter(requestsPerPeriod int, period time.Duration) *RateLimiter { 19 - rl := &RateLimiter{ 20 - tokens: make(chan struct{}, requestsPerPeriod), 21 - refillRate: period / time.Duration(requestsPerPeriod), 22 - maxTokens: requestsPerPeriod, 23 - stopRefill: make(chan struct{}), 24 - } 25 - 26 - // Fill initially 27 - for i := 0; i < requestsPerPeriod; i++ { 28 - rl.tokens <- struct{}{} 29 - } 30 - 31 - // Start refill goroutine 32 - go rl.refill() 33 - 34 - return rl 35 - } 36 - 37 - // refill adds tokens at the specified rate 38 - func (rl *RateLimiter) refill() { 39 - ticker := time.NewTicker(rl.refillRate) 40 - defer ticker.Stop() 41 - 42 - for { 43 - select { 44 - case <-ticker.C: 45 - select { 46 - case rl.tokens <- struct{}{}: 47 - // Token added 48 - default: 49 - // Buffer full, skip 50 - } 51 - case <-rl.stopRefill: 52 - return 53 - } 54 - } 55 - } 56 - 57 - // Wait blocks until a token is available 58 - func (rl *RateLimiter) Wait(ctx context.Context) error { 59 - select { 60 - case <-rl.tokens: 61 - return nil 62 - case <-ctx.Done(): 63 - return ctx.Err() 64 - } 65 - } 66 - 67 - // Stop stops the rate limiter 68 - func (rl *RateLimiter) Stop() { 69 - close(rl.stopRefill) 70 - }
+92 -415
internal/plc/scanner.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "fmt" 7 6 "strings" 8 7 "time" 9 8 10 - "github.com/acarl005/stripansi" 11 - "github.com/atscan/atscanner/internal/config" 12 - "github.com/atscan/atscanner/internal/log" 13 - "github.com/atscan/atscanner/internal/storage" 9 + "github.com/atscan/atscand/internal/config" 10 + "github.com/atscan/atscand/internal/log" 11 + "github.com/atscan/atscand/internal/storage" 14 12 ) 15 13 16 14 type Scanner struct { 17 - client *Client 15 + bundleManager *BundleManager 18 16 db storage.Database 19 17 config config.PLCConfig 20 - bundleManager *BundleManager 21 18 } 22 19 23 - func NewScanner(db storage.Database, cfg config.PLCConfig) *Scanner { 24 - bundleManager, err := NewBundleManager(cfg.BundleDir, cfg.UseCache, db, cfg.IndexDIDs) // NEW: pass IndexDIDs 25 - if err != nil { 26 - log.Error("Warning: failed to initialize bundle manager: %v", err) 27 - bundleManager = &BundleManager{enabled: false} 28 - } 20 + func NewScanner(db storage.Database, cfg config.PLCConfig, bundleManager *BundleManager) *Scanner { 21 + log.Verbose("NewScanner: IndexDIDs config = %v", cfg.IndexDIDs) 29 22 30 23 return &Scanner{ 31 - client: NewClient(cfg.DirectoryURL), 24 + bundleManager: bundleManager, // Use provided instance 32 25 db: db, 33 26 config: cfg, 34 - bundleManager: bundleManager, 35 27 } 36 28 } 37 29 38 30 func (s *Scanner) Close() { 39 - if s.bundleManager != nil { 40 - s.bundleManager.Close() 41 - } 42 - } 43 - 44 - // ScanMetrics tracks scan progress 45 - type ScanMetrics struct { 46 - totalFetched int64 // Total ops fetched from PLC/bundles 47 - totalProcessed int64 // Unique ops processed (after dedup) 48 - newEndpoints int64 // New endpoints discovered 49 - endpointCounts map[string]int64 50 - currentBundle int 51 - startTime time.Time 52 - } 53 - 54 - func newMetrics(startBundle int) *ScanMetrics { 55 - return &ScanMetrics{ 56 - endpointCounts: make(map[string]int64), 57 - currentBundle: startBundle, 58 - startTime: time.Now(), 59 - } 60 - } 61 - 62 - func (m *ScanMetrics) logSummary() { 63 - summary := formatEndpointCounts(m.endpointCounts) 64 - if m.newEndpoints > 0 { 65 - log.Info("PLC scan completed: %d operations processed (%d fetched), %s in %v", 66 - m.totalProcessed, m.totalFetched, summary, time.Since(m.startTime)) 67 - } else { 68 - log.Info("PLC scan completed: %d operations processed (%d fetched), 0 new endpoints in %v", 69 - m.totalProcessed, m.totalFetched, time.Since(m.startTime)) 70 - } 31 + // Don't close bundleManager here - it's shared 71 32 } 72 33 73 34 func (s *Scanner) Scan(ctx context.Context) error { 74 35 log.Info("Starting PLC directory scan...") 75 - log.Info("⚠ Note: PLC directory has rate limit of 500 requests per 5 minutes") 76 36 77 37 cursor, err := s.db.GetScanCursor(ctx, "plc_directory") 78 38 if err != nil { 79 39 return fmt.Errorf("failed to get scan cursor: %w", err) 80 40 } 81 41 82 - startBundle := s.calculateStartBundle(cursor.LastBundleNumber) 83 - metrics := newMetrics(startBundle) 42 + metrics := newMetrics(cursor.LastBundleNumber + 1) 84 43 85 - if startBundle > 1 { 86 - if err := s.ensureContinuity(ctx, startBundle); err != nil { 87 - return err 88 - } 89 - } 90 - 91 - // Handle existing mempool first 92 - if hasMempool, _ := s.hasSufficientMempool(ctx); hasMempool { 93 - return s.handleMempoolOnly(ctx, metrics) 94 - } 95 - 96 - // Process bundles until incomplete or error 44 + // Main processing loop 97 45 for { 98 46 if err := ctx.Err(); err != nil { 99 47 return err 100 48 } 101 49 102 - if err := s.processSingleBundle(ctx, metrics); err != nil { 103 - if s.shouldRetry(err) { 104 - continue 105 - } 106 - break 107 - } 108 - 109 - if err := s.updateCursor(ctx, cursor, metrics); err != nil { 110 - log.Error("Warning: failed to update cursor: %v", err) 111 - } 112 - } 113 - 114 - // Try to finalize mempool 115 - s.finalizeMempool(ctx, metrics) 116 - 117 - metrics.logSummary() 118 - return nil 119 - } 120 - 121 - func (s *Scanner) calculateStartBundle(lastBundle int) int { 122 - if lastBundle == 0 { 123 - return 1 124 - } 125 - return lastBundle + 1 126 - } 127 - 128 - func (s *Scanner) ensureContinuity(ctx context.Context, bundle int) error { 129 - log.Info("Checking bundle continuity...") 130 - if err := s.bundleManager.EnsureBundleContinuity(ctx, bundle); err != nil { 131 - return fmt.Errorf("bundle continuity check failed: %w", err) 132 - } 133 - return nil 134 - } 135 - 136 - func (s *Scanner) hasSufficientMempool(ctx context.Context) (bool, error) { 137 - count, err := s.db.GetMempoolCount(ctx) 138 - if err != nil { 139 - return false, err 140 - } 141 - return count > 0, nil 142 - } 143 - 144 - func (s *Scanner) handleMempoolOnly(ctx context.Context, m *ScanMetrics) error { 145 - count, _ := s.db.GetMempoolCount(ctx) 146 - log.Info("→ Mempool has %d operations, continuing to fill it before fetching new bundles", count) 147 - 148 - if err := s.fillMempool(ctx, m); err != nil { 149 - return err 150 - } 151 - 152 - if err := s.processMempool(ctx, m); err != nil { 153 - log.Error("Error processing mempool: %v", err) 154 - } 155 - 156 - m.logSummary() 157 - return nil 158 - } 159 - 160 - func (s *Scanner) processSingleBundle(ctx context.Context, m *ScanMetrics) error { 161 - log.Verbose("→ Processing bundle %06d...", m.currentBundle) 162 - 163 - ops, isComplete, err := s.bundleManager.LoadBundle(ctx, m.currentBundle, s.client) 164 - if err != nil { 165 - return s.handleBundleError(err, m) 166 - } 167 - 168 - if isComplete { 169 - return s.handleCompleteBundle(ctx, ops, m) 170 - } 171 - return s.handleIncompleteBundle(ctx, ops, m) 172 - } 173 - 174 - func (s *Scanner) handleBundleError(err error, m *ScanMetrics) error { 175 - log.Error("Failed to load bundle %06d: %v", m.currentBundle, err) 176 - 177 - if strings.Contains(err.Error(), "rate limited") { 178 - log.Info("⚠ Rate limit hit, pausing for 5 minutes...") 179 - time.Sleep(5 * time.Minute) 180 - return fmt.Errorf("retry") 181 - } 182 - 183 - if m.currentBundle > 1 { 184 - log.Info("→ Reached end of available data") 185 - } 186 - return err 187 - } 188 - 189 - func (s *Scanner) shouldRetry(err error) bool { 190 - return err != nil && err.Error() == "retry" 191 - } 192 - 193 - func (s *Scanner) handleCompleteBundle(ctx context.Context, ops []PLCOperation, m *ScanMetrics) error { 194 - counts, err := s.processBatch(ctx, ops) 195 - if err != nil { 196 - return err 197 - } 198 - 199 - s.mergeCounts(m.endpointCounts, counts) 200 - m.totalProcessed += int64(len(ops)) // Unique ops after dedup 201 - m.newEndpoints += sumCounts(counts) // NEW: Track new endpoints 202 - 203 - batchTotal := sumCounts(counts) 204 - log.Verbose("✓ Processed bundle %06d: %d operations (after dedup), %d new endpoints", 205 - m.currentBundle, len(ops), batchTotal) 206 - 207 - m.currentBundle++ 208 - return nil 209 - } 210 - 211 - func (s *Scanner) handleIncompleteBundle(ctx context.Context, ops []PLCOperation, m *ScanMetrics) error { 212 - log.Info("→ Bundle %06d incomplete (%d ops), adding to mempool", m.currentBundle, len(ops)) 213 - 214 - if err := s.addToMempool(ctx, ops, m.endpointCounts); err != nil { 215 - return err 216 - } 217 - 218 - s.finalizeMempool(ctx, m) 219 - return fmt.Errorf("incomplete") // Signal end of processing 220 - } 221 - 222 - func (s *Scanner) finalizeMempool(ctx context.Context, m *ScanMetrics) { 223 - if err := s.fillMempool(ctx, m); err != nil { 224 - log.Error("Error filling mempool: %v", err) 225 - } 226 - if err := s.processMempool(ctx, m); err != nil { 227 - log.Error("Error processing mempool: %v", err) 228 - } 229 - } 230 - 231 - func (s *Scanner) fillMempool(ctx context.Context, m *ScanMetrics) error { 232 - const fetchLimit = 1000 233 - 234 - for { 235 - count, err := s.db.GetMempoolCount(ctx) 50 + // Fetch and save bundle (library handles mempool internally) 51 + bundle, err := s.bundleManager.FetchAndSaveBundle(ctx) 236 52 if err != nil { 237 - return err 238 - } 53 + if isInsufficientOpsError(err) { 54 + // Show mempool status 55 + stats := s.bundleManager.libManager.GetMempoolStats() 56 + mempoolCount := stats["count"].(int) 239 57 240 - if count >= BUNDLE_SIZE { 241 - log.Info("✓ Mempool filled to %d operations (target: %d)", count, BUNDLE_SIZE) 242 - return nil 243 - } 58 + if mempoolCount > 0 { 59 + log.Info("→ Waiting for more operations (mempool has %d/%d ops)", 60 + mempoolCount, BUNDLE_SIZE) 61 + } else { 62 + log.Info("→ Caught up! No operations available") 63 + } 64 + break 65 + } 244 66 245 - log.Info("→ Mempool has %d/%d operations, fetching more from PLC directory...", count, BUNDLE_SIZE) 67 + if strings.Contains(err.Error(), "rate limited") { 68 + log.Info("⚠ Rate limited, pausing for 5 minutes...") 69 + time.Sleep(5 * time.Minute) 70 + continue 71 + } 246 72 247 - // ✅ Fix: Don't capture unused 'ops' variable 248 - shouldContinue, err := s.fetchNextBatch(ctx, fetchLimit, m) 249 - if err != nil { 250 - return err 73 + return fmt.Errorf("failed to fetch bundle: %w", err) 251 74 } 252 75 253 - if !shouldContinue { 254 - finalCount, _ := s.db.GetMempoolCount(ctx) 255 - log.Info("→ Stopping fill, mempool has %d/%d operations", finalCount, BUNDLE_SIZE) 256 - return nil 257 - } 258 - } 259 - } 260 - 261 - func (s *Scanner) fetchNextBatch(ctx context.Context, limit int, m *ScanMetrics) (bool, error) { 262 - lastOp, err := s.db.GetLastMempoolOperation(ctx) 263 - if err != nil { 264 - return false, err 265 - } 266 - 267 - var after string 268 - if lastOp != nil { 269 - after = lastOp.CreatedAt.Format(time.RFC3339Nano) 270 - log.Verbose(" Using cursor: %s", after) 271 - } 272 - 273 - ops, err := s.client.Export(ctx, ExportOptions{Count: limit, After: after}) 274 - if err != nil { 275 - return false, fmt.Errorf("failed to fetch from PLC: %w", err) 276 - } 277 - 278 - fetchedCount := len(ops) 279 - m.totalFetched += int64(fetchedCount) // Track all fetched 280 - log.Verbose(" Fetched %d operations from PLC", fetchedCount) 281 - 282 - if fetchedCount == 0 { 283 - count, _ := s.db.GetMempoolCount(ctx) 284 - log.Info("→ No more data available from PLC directory (mempool has %d/%d)", count, BUNDLE_SIZE) 285 - return false, nil 286 - } 287 - 288 - beforeCount, err := s.db.GetMempoolCount(ctx) 289 - if err != nil { 290 - return false, err 291 - } 292 - 293 - endpointsBefore := sumCounts(m.endpointCounts) 294 - if err := s.addToMempool(ctx, ops, m.endpointCounts); err != nil { 295 - return false, err 296 - } 297 - endpointsAfter := sumCounts(m.endpointCounts) 298 - m.newEndpoints += (endpointsAfter - endpointsBefore) // Add new endpoints found 299 - 300 - afterCount, err := s.db.GetMempoolCount(ctx) 301 - if err != nil { 302 - return false, err 303 - } 304 - 305 - uniqueAdded := int64(afterCount - beforeCount) // Cast to int64 306 - m.totalProcessed += uniqueAdded // Track unique ops processed 307 - 308 - log.Verbose(" Added %d new unique operations to mempool (%d were duplicates)", 309 - uniqueAdded, int64(fetchedCount)-uniqueAdded) 310 - 311 - // Continue only if got full batch 312 - shouldContinue := fetchedCount >= limit 313 - if !shouldContinue { 314 - log.Info("→ Received incomplete batch (%d/%d), caught up to latest data", fetchedCount, limit) 315 - } 316 - 317 - return shouldContinue, nil 318 - } 319 - 320 - func (s *Scanner) addToMempool(ctx context.Context, ops []PLCOperation, counts map[string]int64) error { 321 - mempoolOps := make([]storage.MempoolOperation, len(ops)) 322 - for i, op := range ops { 323 - mempoolOps[i] = storage.MempoolOperation{ 324 - DID: op.DID, 325 - Operation: string(op.RawJSON), 326 - CID: op.CID, 327 - CreatedAt: op.CreatedAt, 328 - } 329 - } 330 - 331 - if err := s.db.AddToMempool(ctx, mempoolOps); err != nil { 332 - return err 333 - } 334 - 335 - // Process for endpoint discovery 336 - batchCounts, err := s.processBatch(ctx, ops) 337 - s.mergeCounts(counts, batchCounts) 338 - return err 339 - } 340 - 341 - func (s *Scanner) processMempool(ctx context.Context, m *ScanMetrics) error { 342 - for { 343 - count, err := s.db.GetMempoolCount(ctx) 76 + // Process operations for endpoint discovery 77 + counts, err := s.processBatch(ctx, bundle.Operations) 344 78 if err != nil { 345 - return err 79 + log.Error("Failed to process batch: %v", err) 80 + // Continue anyway 346 81 } 347 82 348 - log.Verbose("Mempool contains %d operations", count) 349 - 350 - if count < BUNDLE_SIZE { 351 - log.Info("Mempool has %d/%d operations, cannot create bundle yet", count, BUNDLE_SIZE) 352 - return nil 353 - } 83 + // Update metrics 84 + s.mergeCounts(metrics.endpointCounts, counts) 85 + metrics.totalProcessed += int64(len(bundle.Operations)) 86 + metrics.newEndpoints += sumCounts(counts) 87 + metrics.currentBundle = bundle.BundleNumber 354 88 355 - log.Info("→ Creating bundle from mempool (%d operations available)...", count) 89 + log.Info("✓ Processed bundle %06d: %d operations, %d new endpoints", 90 + bundle.BundleNumber, len(bundle.Operations), sumCounts(counts)) 356 91 357 - // Updated to receive 4 values instead of 3 358 - bundleNum, ops, cursor, err := s.createBundleFromMempool(ctx) 359 - if err != nil { 360 - return err 361 - } 362 - 363 - // Process and update metrics 364 - countsBefore := sumCounts(m.endpointCounts) 365 - counts, _ := s.processBatch(ctx, ops) 366 - s.mergeCounts(m.endpointCounts, counts) 367 - newEndpointsFound := sumCounts(m.endpointCounts) - countsBefore 368 - 369 - m.totalProcessed += int64(len(ops)) 370 - m.newEndpoints += newEndpointsFound 371 - m.currentBundle = bundleNum 372 - 373 - if err := s.updateCursorForBundle(ctx, bundleNum, m.totalProcessed); err != nil { 92 + // Update cursor 93 + if err := s.updateCursorForBundle(ctx, bundle.BundleNumber, metrics.totalProcessed); err != nil { 374 94 log.Error("Warning: failed to update cursor: %v", err) 375 95 } 376 - 377 - log.Info("✓ Created bundle %06d from mempool (cursor: %s)", bundleNum, cursor) 378 96 } 379 - } 380 97 381 - func (s *Scanner) createBundleFromMempool(ctx context.Context) (int, []PLCOperation, string, error) { 382 - mempoolOps, err := s.db.GetMempoolOperations(ctx, BUNDLE_SIZE) 383 - if err != nil { 384 - return 0, nil, "", err 98 + // Show final mempool status 99 + stats := s.bundleManager.libManager.GetMempoolStats() 100 + if count, ok := stats["count"].(int); ok && count > 0 { 101 + log.Info("Mempool contains %d operations (%.1f%% of next bundle)", 102 + count, float64(count)/float64(BUNDLE_SIZE)*100) 385 103 } 386 104 387 - ops, ids := s.deduplicateMempool(mempoolOps) 388 - if len(ops) < BUNDLE_SIZE { 389 - return 0, nil, "", fmt.Errorf("only got %d unique operations from mempool, need %d", len(ops), BUNDLE_SIZE) 390 - } 391 - 392 - // Determine cursor from last bundle 393 - cursor := "" 394 - lastBundle, err := s.db.GetLastBundleNumber(ctx) 395 - if err == nil && lastBundle > 0 { 396 - if bundle, err := s.db.GetBundleByNumber(ctx, lastBundle); err == nil { 397 - cursor = bundle.EndTime.Format(time.RFC3339Nano) 398 - } 399 - } 400 - 401 - bundleNum, err := s.bundleManager.CreateBundleFromMempool(ctx, ops, cursor) 402 - if err != nil { 403 - return 0, nil, "", err 404 - } 405 - 406 - if err := s.db.DeleteFromMempool(ctx, ids[:len(ops)]); err != nil { 407 - return 0, nil, "", err 408 - } 409 - 410 - return bundleNum, ops, cursor, nil 105 + metrics.logSummary() 106 + return nil 411 107 } 412 108 413 - func (s *Scanner) deduplicateMempool(mempoolOps []storage.MempoolOperation) ([]PLCOperation, []int64) { 414 - ops := make([]PLCOperation, 0, BUNDLE_SIZE) 415 - ids := make([]int64, 0, BUNDLE_SIZE) 416 - seenCIDs := make(map[string]bool) 417 - 418 - for _, mop := range mempoolOps { 419 - if seenCIDs[mop.CID] { 420 - ids = append(ids, mop.ID) 421 - continue 422 - } 423 - seenCIDs[mop.CID] = true 424 - 425 - var op PLCOperation 426 - json.Unmarshal([]byte(mop.Operation), &op) 427 - op.RawJSON = []byte(mop.Operation) 428 - 429 - ops = append(ops, op) 430 - ids = append(ids, mop.ID) 431 - 432 - if len(ops) >= BUNDLE_SIZE { 433 - break 434 - } 435 - } 436 - 437 - return ops, ids 438 - } 439 - 109 + // processBatch extracts endpoints from operations 440 110 func (s *Scanner) processBatch(ctx context.Context, ops []PLCOperation) (map[string]int64, error) { 441 111 counts := make(map[string]int64) 442 112 seen := make(map[string]*PLCOperation) 443 113 444 114 // Collect unique endpoints 445 - for _, op := range ops { 115 + for i := range ops { 116 + op := &ops[i] 117 + 446 118 if op.IsNullified() { 447 119 continue 448 120 } 449 - for _, ep := range s.extractEndpointsFromOperation(op) { 121 + 122 + for _, ep := range s.extractEndpointsFromOperation(*op) { 450 123 key := fmt.Sprintf("%s:%s", ep.Type, ep.Endpoint) 451 124 if _, exists := seen[key]; !exists { 452 - seen[key] = &op 125 + seen[key] = op 453 126 } 454 127 } 455 128 } ··· 465 138 } 466 139 467 140 if err := s.storeEndpoint(ctx, epType, endpoint, firstOp.CreatedAt); err != nil { 468 - log.Error("Error storing %s endpoint %s: %v", epType, stripansi.Strip(endpoint), err) 141 + log.Error("Error storing %s endpoint %s: %v", epType, endpoint, err) 469 142 continue 470 143 } 471 144 472 - log.Info("✓ Discovered new %s endpoint: %s", epType, stripansi.Strip(endpoint)) 145 + log.Info("✓ Discovered new %s endpoint: %s", epType, endpoint) 473 146 counts[epType]++ 474 147 } 475 148 476 149 return counts, nil 477 - } 478 - 479 - func (s *Scanner) storeEndpoint(ctx context.Context, epType, endpoint string, discoveredAt time.Time) error { 480 - return s.db.UpsertEndpoint(ctx, &storage.Endpoint{ 481 - EndpointType: epType, 482 - Endpoint: endpoint, 483 - DiscoveredAt: discoveredAt, 484 - LastChecked: time.Time{}, 485 - Status: storage.EndpointStatusUnknown, 486 - }) 487 150 } 488 151 489 152 func (s *Scanner) extractEndpointsFromOperation(op PLCOperation) []EndpointInfo { ··· 526 189 return nil 527 190 } 528 191 529 - func (s *Scanner) updateCursor(ctx context.Context, cursor *storage.ScanCursor, m *ScanMetrics) error { 530 - return s.db.UpdateScanCursor(ctx, &storage.ScanCursor{ 531 - Source: "plc_directory", 532 - LastBundleNumber: m.currentBundle - 1, 533 - LastScanTime: time.Now().UTC(), 534 - RecordsProcessed: cursor.RecordsProcessed + m.totalProcessed, 192 + func (s *Scanner) storeEndpoint(ctx context.Context, epType, endpoint string, discoveredAt time.Time) error { 193 + valid := validateEndpoint(endpoint) 194 + return s.db.UpsertEndpoint(ctx, &storage.Endpoint{ 195 + EndpointType: epType, 196 + Endpoint: endpoint, 197 + DiscoveredAt: discoveredAt, 198 + LastChecked: time.Time{}, 199 + Status: storage.EndpointStatusUnknown, 200 + Valid: valid, 535 201 }) 536 202 } 537 203 ··· 559 225 return total 560 226 } 561 227 562 - func formatEndpointCounts(counts map[string]int64) string { 563 - if len(counts) == 0 { 564 - return "0 new endpoints" 565 - } 228 + func isInsufficientOpsError(err error) bool { 229 + return err != nil && strings.Contains(err.Error(), "insufficient operations") 230 + } 566 231 567 - total := sumCounts(counts) 232 + // ScanMetrics tracks scan progress 233 + type ScanMetrics struct { 234 + totalProcessed int64 235 + newEndpoints int64 236 + endpointCounts map[string]int64 237 + currentBundle int 238 + startTime time.Time 239 + } 568 240 569 - if len(counts) == 1 { 570 - for typ, count := range counts { 571 - return fmt.Sprintf("%d new %s endpoint(s)", count, typ) 572 - } 241 + func newMetrics(startBundle int) *ScanMetrics { 242 + return &ScanMetrics{ 243 + endpointCounts: make(map[string]int64), 244 + currentBundle: startBundle, 245 + startTime: time.Now(), 573 246 } 247 + } 574 248 575 - parts := make([]string, 0, len(counts)) 576 - for typ, count := range counts { 577 - parts = append(parts, fmt.Sprintf("%d %s", count, typ)) 249 + func (m *ScanMetrics) logSummary() { 250 + if m.newEndpoints > 0 { 251 + log.Info("PLC scan completed: %d operations processed, %d new endpoints in %v", 252 + m.totalProcessed, m.newEndpoints, time.Since(m.startTime)) 253 + } else { 254 + log.Info("PLC scan completed: %d operations processed, 0 new endpoints in %v", 255 + m.totalProcessed, time.Since(m.startTime)) 578 256 } 579 - return fmt.Sprintf("%d new endpoints (%s)", total, strings.Join(parts, ", ")) 580 257 }
+68 -55
internal/plc/types.go
··· 1 1 package plc 2 2 3 - import "time" 4 - 5 - type PLCOperation struct { 6 - DID string `json:"did"` 7 - Operation map[string]interface{} `json:"operation"` 8 - CID string `json:"cid"` 9 - Nullified interface{} `json:"nullified,omitempty"` 10 - CreatedAt time.Time `json:"createdAt"` 11 - 12 - RawJSON []byte `json:"-"` // ✅ Exported (capital R) 13 - } 3 + import ( 4 + "net/url" 5 + "strings" 14 6 15 - // Helper method to check if nullified 16 - func (op *PLCOperation) IsNullified() bool { 17 - if op.Nullified == nil { 18 - return false 19 - } 20 - 21 - switch v := op.Nullified.(type) { 22 - case bool: 23 - return v 24 - case string: 25 - return v != "" 26 - default: 27 - return false 28 - } 29 - } 30 - 31 - // Get nullifying CID if available 32 - func (op *PLCOperation) GetNullifyingCID() string { 33 - if s, ok := op.Nullified.(string); ok { 34 - return s 35 - } 36 - return "" 37 - } 7 + plclib "tangled.org/atscan.net/plcbundle/plc" 8 + ) 38 9 39 - type DIDDocument struct { 40 - Context []string `json:"@context"` 41 - ID string `json:"id"` 42 - AlsoKnownAs []string `json:"alsoKnownAs"` 43 - VerificationMethod []VerificationMethod `json:"verificationMethod"` 44 - Service []Service `json:"service"` 45 - } 10 + // Re-export library types 11 + type PLCOperation = plclib.PLCOperation 12 + type DIDDocument = plclib.DIDDocument 13 + type Client = plclib.Client 14 + type ExportOptions = plclib.ExportOptions 46 15 47 - type VerificationMethod struct { 48 - ID string `json:"id"` 49 - Type string `json:"type"` 50 - Controller string `json:"controller"` 51 - PublicKeyMultibase string `json:"publicKeyMultibase"` 52 - } 16 + // Keep your custom types 17 + const BUNDLE_SIZE = 10000 53 18 54 - type Service struct { 55 - ID string `json:"id"` 56 - Type string `json:"type"` 57 - ServiceEndpoint string `json:"serviceEndpoint"` 58 - } 59 - 60 - // DIDHistoryEntry represents a single operation in DID history 61 19 type DIDHistoryEntry struct { 62 20 Operation PLCOperation `json:"operation"` 63 21 PLCBundle string `json:"plc_bundle,omitempty"` 64 22 } 65 23 66 - // DIDHistory represents the full history of a DID 67 24 type DIDHistory struct { 68 25 DID string `json:"did"` 69 26 Current *PLCOperation `json:"current"` ··· 74 31 Type string 75 32 Endpoint string 76 33 } 34 + 35 + // PLCOpLabel holds metadata from the label CSV file 36 + type PLCOpLabel struct { 37 + Bundle int `json:"bundle"` 38 + Position int `json:"position"` 39 + CID string `json:"cid"` 40 + Size int `json:"size"` 41 + Confidence float64 `json:"confidence"` 42 + Detectors []string `json:"detectors"` 43 + } 44 + 45 + // validateEndpoint checks if endpoint is in correct format: https://<domain> 46 + func validateEndpoint(endpoint string) bool { 47 + // Must not be empty 48 + if endpoint == "" { 49 + return false 50 + } 51 + 52 + // Must not have trailing slash 53 + if strings.HasSuffix(endpoint, "/") { 54 + return false 55 + } 56 + 57 + // Parse URL 58 + u, err := url.Parse(endpoint) 59 + if err != nil { 60 + return false 61 + } 62 + 63 + // Must use https scheme 64 + if u.Scheme != "https" { 65 + return false 66 + } 67 + 68 + // Must have a host 69 + if u.Host == "" { 70 + return false 71 + } 72 + 73 + // Must not have path (except empty) 74 + if u.Path != "" && u.Path != "/" { 75 + return false 76 + } 77 + 78 + // Must not have query parameters 79 + if u.RawQuery != "" { 80 + return false 81 + } 82 + 83 + // Must not have fragment 84 + if u.Fragment != "" { 85 + return false 86 + } 87 + 88 + return true 89 + }
+5 -23
internal/storage/db.go
··· 27 27 EndpointExists(ctx context.Context, endpoint string, endpointType string) (bool, error) 28 28 GetEndpointIDByEndpoint(ctx context.Context, endpoint string, endpointType string) (int64, error) 29 29 GetEndpointScans(ctx context.Context, endpointID int64, limit int) ([]*EndpointScan, error) 30 - UpdateEndpointIP(ctx context.Context, endpointID int64, ip string, resolvedAt time.Time) error 30 + UpdateEndpointIPs(ctx context.Context, endpointID int64, ipv4, ipv6 string, resolvedAt time.Time) error 31 31 SaveEndpointScan(ctx context.Context, scan *EndpointScan) error 32 32 SetScanRetention(retention int) 33 33 UpdateEndpointStatus(ctx context.Context, endpointID int64, update *EndpointUpdate) error ··· 50 50 GetScanCursor(ctx context.Context, source string) (*ScanCursor, error) 51 51 UpdateScanCursor(ctx context.Context, cursor *ScanCursor) error 52 52 53 - // Bundle operations 54 - CreateBundle(ctx context.Context, bundle *PLCBundle) error 55 - GetBundleByNumber(ctx context.Context, bundleNumber int) (*PLCBundle, error) 56 - GetBundles(ctx context.Context, limit int) ([]*PLCBundle, error) 57 - GetBundlesForDID(ctx context.Context, did string) ([]*PLCBundle, error) 58 - GetBundleStats(ctx context.Context) (count, compressedSize, uncompressedSize, lastBundle int64, err error) 59 - GetLastBundleNumber(ctx context.Context) (int, error) 60 - GetBundleForTimestamp(ctx context.Context, afterTime time.Time) (int, error) 61 - GetPLCHistory(ctx context.Context, limit int, fromBundle int) ([]*PLCHistoryPoint, error) 62 - 63 - // Mempool operations 64 - AddToMempool(ctx context.Context, ops []MempoolOperation) error 65 - GetMempoolCount(ctx context.Context) (int, error) 66 - GetMempoolOperations(ctx context.Context, limit int) ([]MempoolOperation, error) 67 - DeleteFromMempool(ctx context.Context, ids []int64) error 68 - GetFirstMempoolOperation(ctx context.Context) (*MempoolOperation, error) 69 - GetLastMempoolOperation(ctx context.Context) (*MempoolOperation, error) 70 - GetMempoolUniqueDIDCount(ctx context.Context) (int, error) 71 - GetMempoolUncompressedSize(ctx context.Context) (int64, error) 72 - 73 53 // Metrics 74 54 StorePLCMetrics(ctx context.Context, metrics *PLCMetrics) error 75 55 GetPLCMetrics(ctx context.Context, limit int) ([]*PLCMetrics, error) 76 56 GetEndpointStats(ctx context.Context) (*EndpointStats, error) 77 57 78 58 // DID operations 79 - UpsertDID(ctx context.Context, did string, bundleNum int) error 59 + UpsertDID(ctx context.Context, did string, bundleNum int, handle, pds string) error 60 + UpsertDIDFromMempool(ctx context.Context, did string, handle, pds string) error 80 61 GetDIDRecord(ctx context.Context, did string) (*DIDRecord, error) 62 + GetDIDByHandle(ctx context.Context, handle string) (*DIDRecord, error) // NEW 81 63 GetGlobalDIDInfo(ctx context.Context, did string) (*GlobalDIDInfo, error) 82 64 AddBundleDIDs(ctx context.Context, bundleNum int, dids []string) error 83 65 GetTotalDIDCount(ctx context.Context) (int64, error) 84 66 85 67 // PDS Repo operations 86 68 UpsertPDSRepos(ctx context.Context, endpointID int64, repos []PDSRepoData) error 87 - GetPDSRepos(ctx context.Context, endpointID int64, activeOnly bool, limit int, offset int) ([]*PDSRepo, error) // Updated 69 + GetPDSRepos(ctx context.Context, endpointID int64, activeOnly bool, limit int, offset int) ([]*PDSRepo, error) 88 70 GetReposByDID(ctx context.Context, did string) ([]*PDSRepo, error) 89 71 GetPDSRepoStats(ctx context.Context, endpointID int64) (map[string]interface{}, error) 90 72
+296 -629
internal/storage/postgres.go
··· 5 5 "database/sql" 6 6 "encoding/json" 7 7 "fmt" 8 - "strings" 9 8 "time" 10 9 11 - "github.com/atscan/atscanner/internal/log" 10 + "github.com/atscan/atscand/internal/log" 12 11 "github.com/jackc/pgx/v5" 13 12 "github.com/jackc/pgx/v5/pgxpool" 14 13 _ "github.com/jackc/pgx/v5/stdlib" ··· 73 72 log.Info("Running database migrations...") 74 73 75 74 schema := ` 76 - -- Endpoints table (NO user_count, NO ip_info) 75 + -- Endpoints table (with IPv6 support) 77 76 CREATE TABLE IF NOT EXISTS endpoints ( 78 77 id BIGSERIAL PRIMARY KEY, 79 78 endpoint_type TEXT NOT NULL DEFAULT 'pds', ··· 83 82 last_checked TIMESTAMP, 84 83 status INTEGER DEFAULT 0, 85 84 ip TEXT, 85 + ipv6 TEXT, 86 86 ip_resolved_at TIMESTAMP, 87 + valid BOOLEAN DEFAULT true, 87 88 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 88 89 UNIQUE(endpoint_type, endpoint) 89 90 ); ··· 92 93 CREATE INDEX IF NOT EXISTS idx_endpoints_status ON endpoints(status); 93 94 CREATE INDEX IF NOT EXISTS idx_endpoints_type ON endpoints(endpoint_type); 94 95 CREATE INDEX IF NOT EXISTS idx_endpoints_ip ON endpoints(ip); 96 + CREATE INDEX IF NOT EXISTS idx_endpoints_ipv6 ON endpoints(ipv6); 95 97 CREATE INDEX IF NOT EXISTS idx_endpoints_server_did ON endpoints(server_did); 96 - CREATE INDEX IF NOT EXISTS idx_endpoints_server_did_type_discovered ON endpoints(server_did, endpoint_type, discovered_at); 98 + CREATE INDEX IF NOT EXISTS idx_endpoints_server_did_type_discovered ON endpoints(server_did, endpoint_type, discovered_at); 99 + CREATE INDEX IF NOT EXISTS idx_endpoints_valid ON endpoints(valid); 97 100 98 - -- IP infos table (IP as PRIMARY KEY) 99 - CREATE TABLE IF NOT EXISTS ip_infos ( 100 - ip TEXT PRIMARY KEY, 101 - city TEXT, 102 - country TEXT, 103 - country_code TEXT, 104 - asn INTEGER, 105 - asn_org TEXT, 106 - is_datacenter BOOLEAN, 107 - is_vpn BOOLEAN, 108 - latitude REAL, 109 - longitude REAL, 110 - raw_data JSONB, 111 - fetched_at TIMESTAMP NOT NULL, 112 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 113 - ); 101 + -- IP infos table (IP as PRIMARY KEY) 102 + CREATE TABLE IF NOT EXISTS ip_infos ( 103 + ip TEXT PRIMARY KEY, 104 + city TEXT, 105 + country TEXT, 106 + country_code TEXT, 107 + asn INTEGER, 108 + asn_org TEXT, 109 + is_datacenter BOOLEAN, 110 + is_vpn BOOLEAN, 111 + is_crawler BOOLEAN, 112 + is_tor BOOLEAN, 113 + is_proxy BOOLEAN, 114 + latitude REAL, 115 + longitude REAL, 116 + raw_data JSONB, 117 + fetched_at TIMESTAMP NOT NULL, 118 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 119 + ); 114 120 115 - CREATE INDEX IF NOT EXISTS idx_ip_infos_country_code ON ip_infos(country_code); 116 - CREATE INDEX IF NOT EXISTS idx_ip_infos_asn ON ip_infos(asn); 121 + CREATE INDEX IF NOT EXISTS idx_ip_infos_country_code ON ip_infos(country_code); 122 + CREATE INDEX IF NOT EXISTS idx_ip_infos_asn ON ip_infos(asn); 117 123 118 - -- Endpoint scans (renamed from pds_scans) 124 + -- Endpoint scans 119 125 CREATE TABLE IF NOT EXISTS endpoint_scans ( 120 126 id BIGSERIAL PRIMARY KEY, 121 127 endpoint_id BIGINT NOT NULL, ··· 123 129 response_time DOUBLE PRECISION, 124 130 user_count BIGINT, 125 131 version TEXT, 132 + used_ip TEXT, 126 133 scan_data JSONB, 127 134 scanned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 128 135 FOREIGN KEY (endpoint_id) REFERENCES endpoints(id) ON DELETE CASCADE ··· 131 138 CREATE INDEX IF NOT EXISTS idx_endpoint_scans_endpoint_status_scanned ON endpoint_scans(endpoint_id, status, scanned_at DESC); 132 139 CREATE INDEX IF NOT EXISTS idx_endpoint_scans_scanned_at ON endpoint_scans(scanned_at); 133 140 CREATE INDEX IF NOT EXISTS idx_endpoint_scans_user_count ON endpoint_scans(user_count DESC NULLS LAST); 141 + CREATE INDEX IF NOT EXISTS idx_endpoint_scans_used_ip ON endpoint_scans(used_ip); 142 + 134 143 135 144 CREATE TABLE IF NOT EXISTS plc_metrics ( 136 145 id BIGSERIAL PRIMARY KEY, ··· 149 158 records_processed BIGINT DEFAULT 0 150 159 ); 151 160 152 - CREATE TABLE IF NOT EXISTS plc_bundles ( 153 - bundle_number INTEGER PRIMARY KEY, 154 - start_time TIMESTAMP NOT NULL, 155 - end_time TIMESTAMP NOT NULL, 156 - dids JSONB NOT NULL, 157 - hash TEXT NOT NULL, 158 - compressed_hash TEXT NOT NULL, 159 - compressed_size BIGINT NOT NULL, 160 - uncompressed_size BIGINT NOT NULL, 161 - cumulative_compressed_size BIGINT NOT NULL, 162 - cumulative_uncompressed_size BIGINT NOT NULL, 163 - cursor TEXT, 164 - prev_bundle_hash TEXT, 165 - compressed BOOLEAN DEFAULT true, 166 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 167 - ); 168 - 169 - CREATE INDEX IF NOT EXISTS idx_plc_bundles_time ON plc_bundles(start_time, end_time); 170 - CREATE INDEX IF NOT EXISTS idx_plc_bundles_hash ON plc_bundles(hash); 171 - CREATE INDEX IF NOT EXISTS idx_plc_bundles_prev ON plc_bundles(prev_bundle_hash); 172 - CREATE INDEX IF NOT EXISTS idx_plc_bundles_number_desc ON plc_bundles(bundle_number DESC); 173 - CREATE INDEX IF NOT EXISTS idx_plc_bundles_dids ON plc_bundles USING gin(dids); 174 - 175 - CREATE TABLE IF NOT EXISTS plc_mempool ( 176 - id BIGSERIAL PRIMARY KEY, 177 - did TEXT NOT NULL, 178 - operation TEXT NOT NULL, 179 - cid TEXT NOT NULL UNIQUE, 180 - created_at TIMESTAMP NOT NULL, 181 - added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 182 - ); 183 - 184 - CREATE INDEX IF NOT EXISTS idx_mempool_created_at ON plc_mempool(created_at); 185 - CREATE INDEX IF NOT EXISTS idx_mempool_did ON plc_mempool(did); 186 - CREATE UNIQUE INDEX IF NOT EXISTS idx_mempool_cid ON plc_mempool(cid); 161 + -- Minimal dids table 162 + CREATE TABLE IF NOT EXISTS dids ( 163 + did TEXT PRIMARY KEY, 164 + handle TEXT, 165 + pds TEXT, 166 + bundle_numbers JSONB NOT NULL DEFAULT '[]'::jsonb, 167 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 168 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 169 + ); 187 170 188 - -- Minimal dids table 189 - CREATE TABLE IF NOT EXISTS dids ( 190 - did TEXT PRIMARY KEY, 191 - bundle_numbers JSONB NOT NULL DEFAULT '[]'::jsonb, 192 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 193 - ); 194 - 195 - CREATE INDEX IF NOT EXISTS idx_dids_bundle_numbers ON dids USING gin(bundle_numbers); 196 - CREATE INDEX IF NOT EXISTS idx_dids_created_at ON dids(created_at); 171 + CREATE INDEX IF NOT EXISTS idx_dids_bundle_numbers ON dids USING gin(bundle_numbers); 172 + CREATE INDEX IF NOT EXISTS idx_dids_created_at ON dids(created_at); 173 + CREATE INDEX IF NOT EXISTS idx_dids_handle ON dids(handle); 174 + CREATE INDEX IF NOT EXISTS idx_dids_pds ON dids(pds); 197 175 198 176 -- PDS Repositories table 199 177 CREATE TABLE IF NOT EXISTS pds_repos ( ··· 232 210 233 211 func (p *PostgresDB) UpsertEndpoint(ctx context.Context, endpoint *Endpoint) error { 234 212 query := ` 235 - INSERT INTO endpoints (endpoint_type, endpoint, discovered_at, last_checked, status, ip, ip_resolved_at) 236 - VALUES ($1, $2, $3, $4, $5, $6, $7) 213 + INSERT INTO endpoints (endpoint_type, endpoint, discovered_at, last_checked, status, ip, ipv6, ip_resolved_at, valid) 214 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 237 215 ON CONFLICT(endpoint_type, endpoint) DO UPDATE SET 238 216 last_checked = EXCLUDED.last_checked, 239 217 status = EXCLUDED.status, ··· 241 219 WHEN EXCLUDED.ip IS NOT NULL AND EXCLUDED.ip != '' THEN EXCLUDED.ip 242 220 ELSE endpoints.ip 243 221 END, 222 + ipv6 = CASE 223 + WHEN EXCLUDED.ipv6 IS NOT NULL AND EXCLUDED.ipv6 != '' THEN EXCLUDED.ipv6 224 + ELSE endpoints.ipv6 225 + END, 244 226 ip_resolved_at = CASE 245 - WHEN EXCLUDED.ip IS NOT NULL AND EXCLUDED.ip != '' THEN EXCLUDED.ip_resolved_at 227 + WHEN (EXCLUDED.ip IS NOT NULL AND EXCLUDED.ip != '') OR (EXCLUDED.ipv6 IS NOT NULL AND EXCLUDED.ipv6 != '') THEN EXCLUDED.ip_resolved_at 246 228 ELSE endpoints.ip_resolved_at 247 229 END, 230 + valid = EXCLUDED.valid, 248 231 updated_at = CURRENT_TIMESTAMP 249 232 RETURNING id 250 233 ` 251 234 err := p.db.QueryRowContext(ctx, query, 252 235 endpoint.EndpointType, endpoint.Endpoint, endpoint.DiscoveredAt, 253 - endpoint.LastChecked, endpoint.Status, endpoint.IP, endpoint.IPResolvedAt).Scan(&endpoint.ID) 236 + endpoint.LastChecked, endpoint.Status, endpoint.IP, endpoint.IPv6, endpoint.IPResolvedAt, endpoint.Valid).Scan(&endpoint.ID) 254 237 return err 255 238 } 256 239 ··· 271 254 func (p *PostgresDB) GetEndpoint(ctx context.Context, endpoint string, endpointType string) (*Endpoint, error) { 272 255 query := ` 273 256 SELECT id, endpoint_type, endpoint, discovered_at, last_checked, status, 274 - ip, ip_resolved_at, updated_at 257 + ip, ipv6, ip_resolved_at, valid, updated_at 275 258 FROM endpoints 276 259 WHERE endpoint = $1 AND endpoint_type = $2 277 260 ` 278 261 279 262 var ep Endpoint 280 263 var lastChecked, ipResolvedAt sql.NullTime 281 - var ip sql.NullString 264 + var ip, ipv6 sql.NullString 282 265 283 266 err := p.db.QueryRowContext(ctx, query, endpoint, endpointType).Scan( 284 267 &ep.ID, &ep.EndpointType, &ep.Endpoint, &ep.DiscoveredAt, &lastChecked, 285 - &ep.Status, &ip, &ipResolvedAt, &ep.UpdatedAt, 268 + &ep.Status, &ip, &ipv6, &ipResolvedAt, &ep.Valid, &ep.UpdatedAt, 286 269 ) 287 270 if err != nil { 288 271 return nil, err ··· 294 277 if ip.Valid { 295 278 ep.IP = ip.String 296 279 } 280 + if ipv6.Valid { 281 + ep.IPv6 = ipv6.String 282 + } 297 283 if ipResolvedAt.Valid { 298 284 ep.IPResolvedAt = ipResolvedAt.Time 299 285 } ··· 303 289 304 290 func (p *PostgresDB) GetEndpoints(ctx context.Context, filter *EndpointFilter) ([]*Endpoint, error) { 305 291 query := ` 306 - SELECT DISTINCT ON (COALESCE(server_did, id::text)) 307 - id, endpoint_type, endpoint, server_did, discovered_at, last_checked, status, 308 - ip, ip_resolved_at, updated_at 309 - FROM endpoints 310 - WHERE 1=1 292 + SELECT DISTINCT ON (COALESCE(server_did, id::text)) 293 + id, endpoint_type, endpoint, server_did, discovered_at, last_checked, status, 294 + ip, ipv6, ip_resolved_at, valid, updated_at 295 + FROM endpoints 296 + WHERE 1=1 311 297 ` 312 298 args := []interface{}{} 313 299 argIdx := 1 ··· 318 304 args = append(args, filter.Type) 319 305 argIdx++ 320 306 } 307 + 308 + // NEW: Filter by valid flag 309 + if filter.OnlyValid { 310 + query += fmt.Sprintf(" AND valid = true", argIdx) 311 + } 321 312 if filter.Status != "" { 322 313 statusInt := EndpointStatusUnknown 323 314 switch filter.Status { ··· 340 331 } 341 332 } 342 333 343 - // NEW: Order by server_did and discovered_at to get primary endpoints 344 - query += " ORDER BY COALESCE(server_did, id::text), discovered_at ASC" 334 + // NEW: Choose ordering strategy 335 + if filter != nil && filter.Random { 336 + // For random selection, we need to wrap in a subquery 337 + query = fmt.Sprintf(` 338 + WITH filtered_endpoints AS ( 339 + %s 340 + ) 341 + SELECT * FROM filtered_endpoints 342 + ORDER BY RANDOM() 343 + `, query) 344 + } else { 345 + // Original ordering for non-random queries 346 + query += " ORDER BY COALESCE(server_did, id::text), discovered_at ASC" 347 + } 345 348 346 349 if filter != nil && filter.Limit > 0 { 347 350 query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1) ··· 358 361 for rows.Next() { 359 362 var ep Endpoint 360 363 var lastChecked, ipResolvedAt sql.NullTime 361 - var ip, serverDID sql.NullString 364 + var ip, ipv6, serverDID sql.NullString 362 365 363 366 err := rows.Scan( 364 367 &ep.ID, &ep.EndpointType, &ep.Endpoint, &serverDID, &ep.DiscoveredAt, &lastChecked, 365 - &ep.Status, &ip, &ipResolvedAt, &ep.UpdatedAt, 368 + &ep.Status, &ip, &ipv6, &ipResolvedAt, &ep.UpdatedAt, 366 369 ) 367 370 if err != nil { 368 371 return nil, err ··· 376 379 } 377 380 if ip.Valid { 378 381 ep.IP = ip.String 382 + } 383 + if ipv6.Valid { 384 + ep.IPv6 = ipv6.String 379 385 } 380 386 if ipResolvedAt.Valid { 381 387 ep.IPResolvedAt = ipResolvedAt.Time ··· 397 403 return err 398 404 } 399 405 400 - func (p *PostgresDB) UpdateEndpointIP(ctx context.Context, endpointID int64, ip string, resolvedAt time.Time) error { 406 + func (p *PostgresDB) UpdateEndpointIPs(ctx context.Context, endpointID int64, ipv4, ipv6 string, resolvedAt time.Time) error { 401 407 query := ` 402 408 UPDATE endpoints 403 - SET ip = $1, ip_resolved_at = $2, updated_at = $3 404 - WHERE id = $4 409 + SET ip = $1, ipv6 = $2, ip_resolved_at = $3, updated_at = $4 410 + WHERE id = $5 405 411 ` 406 - _, err := p.db.ExecContext(ctx, query, ip, resolvedAt, time.Now().UTC(), endpointID) 412 + _, err := p.db.ExecContext(ctx, query, ipv4, ipv6, resolvedAt, time.Now().UTC(), endpointID) 407 413 return err 408 414 } 409 415 ··· 470 476 defer tx.Rollback() 471 477 472 478 query := ` 473 - INSERT INTO endpoint_scans (endpoint_id, status, response_time, user_count, version, scan_data, scanned_at) 474 - VALUES ($1, $2, $3, $4, $5, $6, $7) 479 + INSERT INTO endpoint_scans (endpoint_id, status, response_time, user_count, version, used_ip, scan_data, scanned_at) 480 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 475 481 ` 476 - _, err = tx.ExecContext(ctx, query, scan.EndpointID, scan.Status, scan.ResponseTime, scan.UserCount, scan.Version, scanDataJSON, scan.ScannedAt) 482 + _, err = tx.ExecContext(ctx, query, scan.EndpointID, scan.Status, scan.ResponseTime, scan.UserCount, scan.Version, scan.UsedIP, scanDataJSON, scan.ScannedAt) 477 483 if err != nil { 478 484 return err 479 485 } ··· 500 506 501 507 func (p *PostgresDB) GetEndpointScans(ctx context.Context, endpointID int64, limit int) ([]*EndpointScan, error) { 502 508 query := ` 503 - SELECT id, endpoint_id, status, response_time, user_count, version, scan_data, scanned_at 509 + SELECT id, endpoint_id, status, response_time, user_count, version, used_ip, scan_data, scanned_at 504 510 FROM endpoint_scans 505 511 WHERE endpoint_id = $1 506 512 ORDER BY scanned_at DESC ··· 518 524 var scan EndpointScan 519 525 var responseTime sql.NullFloat64 520 526 var userCount sql.NullInt64 521 - var version sql.NullString // NEW 527 + var version, usedIP sql.NullString 522 528 var scanDataJSON []byte 523 529 524 - err := rows.Scan(&scan.ID, &scan.EndpointID, &scan.Status, &responseTime, &userCount, &version, &scanDataJSON, &scan.ScannedAt) 530 + err := rows.Scan(&scan.ID, &scan.EndpointID, &scan.Status, &responseTime, &userCount, &version, &usedIP, &scanDataJSON, &scan.ScannedAt) 525 531 if err != nil { 526 532 return nil, err 527 533 } ··· 534 540 scan.UserCount = userCount.Int64 535 541 } 536 542 537 - if version.Valid { // NEW 543 + if version.Valid { 538 544 scan.Version = version.String 539 545 } 540 546 547 + if usedIP.Valid { 548 + scan.UsedIP = usedIP.String 549 + } 550 + 541 551 if len(scanDataJSON) > 0 { 542 552 var scanData EndpointScanData 543 553 if err := json.Unmarshal(scanDataJSON, &scanData); err == nil { ··· 563 573 discovered_at, 564 574 last_checked, 565 575 status, 566 - ip 576 + ip, 577 + ipv6, 578 + valid 567 579 FROM endpoints 568 580 WHERE endpoint_type = 'pds' 569 581 ORDER BY COALESCE(server_did, id::text), discovered_at ASC 570 582 ) 571 583 SELECT 572 - e.id, e.endpoint, e.server_did, e.discovered_at, e.last_checked, e.status, e.ip, 584 + e.id, e.endpoint, e.server_did, e.discovered_at, e.last_checked, e.status, e.ip, e.ipv6, e.valid, 573 585 latest.user_count, latest.response_time, latest.version, latest.scanned_at, 574 586 i.city, i.country, i.country_code, i.asn, i.asn_org, 575 - i.is_datacenter, i.is_vpn, i.latitude, i.longitude 587 + i.is_datacenter, i.is_vpn, i.is_crawler, i.is_tor, i.is_proxy, 588 + i.latitude, i.longitude 576 589 FROM unique_servers e 577 590 LEFT JOIN LATERAL ( 578 591 SELECT ··· 629 642 var items []*PDSListItem 630 643 for rows.Next() { 631 644 item := &PDSListItem{} 632 - var ip, serverDID, city, country, countryCode, asnOrg sql.NullString 645 + var ip, ipv6, serverDID, city, country, countryCode, asnOrg sql.NullString 633 646 var asn sql.NullInt32 634 - var isDatacenter, isVPN sql.NullBool 647 + var isDatacenter, isVPN, isCrawler, isTor, isProxy sql.NullBool 635 648 var lat, lon sql.NullFloat64 636 649 var userCount sql.NullInt32 637 650 var responseTime sql.NullFloat64 ··· 639 652 var scannedAt sql.NullTime 640 653 641 654 err := rows.Scan( 642 - &item.ID, &item.Endpoint, &serverDID, &item.DiscoveredAt, &item.LastChecked, &item.Status, &ip, 655 + &item.ID, &item.Endpoint, &serverDID, &item.DiscoveredAt, &item.LastChecked, &item.Status, &ip, &ipv6, &item.Valid, 643 656 &userCount, &responseTime, &version, &scannedAt, 644 657 &city, &country, &countryCode, &asn, &asnOrg, 645 - &isDatacenter, &isVPN, &lat, &lon, 658 + &isDatacenter, &isVPN, &isCrawler, &isTor, &isProxy, 659 + &lat, &lon, 646 660 ) 647 661 if err != nil { 648 662 return nil, err ··· 651 665 if ip.Valid { 652 666 item.IP = ip.String 653 667 } 668 + if ipv6.Valid { 669 + item.IPv6 = ipv6.String 670 + } 654 671 if serverDID.Valid { 655 672 item.ServerDID = serverDID.String 656 673 } ··· 681 698 ASNOrg: asnOrg.String, 682 699 IsDatacenter: isDatacenter.Bool, 683 700 IsVPN: isVPN.Bool, 701 + IsCrawler: isCrawler.Bool, 702 + IsTor: isTor.Bool, 703 + IsProxy: isProxy.Bool, 684 704 Latitude: float32(lat.Float64), 685 705 Longitude: float32(lon.Float64), 686 706 } ··· 694 714 695 715 func (p *PostgresDB) GetPDSDetail(ctx context.Context, endpoint string) (*PDSDetail, error) { 696 716 query := ` 697 - WITH target_endpoint AS ( 717 + WITH target_endpoint AS MATERIALIZED ( 698 718 SELECT 699 719 e.id, 700 720 e.endpoint, ··· 702 722 e.discovered_at, 703 723 e.last_checked, 704 724 e.status, 705 - e.ip 725 + e.ip, 726 + e.ipv6, 727 + e.valid 706 728 FROM endpoints e 707 - WHERE e.endpoint = $1 AND e.endpoint_type = 'pds' 708 - ), 709 - aliases_agg AS ( 710 - SELECT 711 - te.server_did, 712 - array_agg(e.endpoint ORDER BY e.discovered_at) FILTER (WHERE e.endpoint != te.endpoint) as aliases, 713 - MIN(e.discovered_at) as first_discovered_at 714 - FROM target_endpoint te 715 - LEFT JOIN endpoints e ON te.server_did = e.server_did 716 - AND e.endpoint_type = 'pds' 717 - AND te.server_did IS NOT NULL 718 - GROUP BY te.server_did 729 + WHERE e.endpoint = $1 730 + AND e.endpoint_type = 'pds' 731 + LIMIT 1 719 732 ) 720 733 SELECT 721 734 te.id, ··· 725 738 te.last_checked, 726 739 te.status, 727 740 te.ip, 741 + te.ipv6, 742 + te.valid, 728 743 latest.user_count, 729 744 latest.response_time, 730 745 latest.version, 731 746 latest.scan_data->'metadata'->'server_info' as server_info, 732 747 latest.scanned_at, 733 748 i.city, i.country, i.country_code, i.asn, i.asn_org, 734 - i.is_datacenter, i.is_vpn, i.latitude, i.longitude, 749 + i.is_datacenter, i.is_vpn, i.is_crawler, i.is_tor, i.is_proxy, 750 + i.latitude, i.longitude, 735 751 i.raw_data, 736 - COALESCE(aa.aliases, ARRAY[]::text[]) as aliases, 737 - aa.first_discovered_at 752 + COALESCE( 753 + ARRAY( 754 + SELECT e2.endpoint 755 + FROM endpoints e2 756 + WHERE e2.server_did = te.server_did 757 + AND e2.endpoint_type = 'pds' 758 + AND e2.endpoint != te.endpoint 759 + AND te.server_did IS NOT NULL 760 + ORDER BY e2.discovered_at 761 + ), 762 + ARRAY[]::text[] 763 + ) as aliases, 764 + CASE 765 + WHEN te.server_did IS NOT NULL THEN ( 766 + SELECT MIN(e3.discovered_at) 767 + FROM endpoints e3 768 + WHERE e3.server_did = te.server_did 769 + AND e3.endpoint_type = 'pds' 770 + ) 771 + ELSE NULL 772 + END as first_discovered_at 738 773 FROM target_endpoint te 739 - LEFT JOIN aliases_agg aa ON te.server_did = aa.server_did 740 774 LEFT JOIN LATERAL ( 741 - SELECT scan_data, response_time, version, scanned_at, user_count 742 - FROM endpoint_scans 743 - WHERE endpoint_id = te.id 744 - ORDER BY scanned_at DESC 775 + SELECT 776 + es.scan_data, 777 + es.response_time, 778 + es.version, 779 + es.scanned_at, 780 + es.user_count 781 + FROM endpoint_scans es 782 + WHERE es.endpoint_id = te.id 783 + ORDER BY es.scanned_at DESC 745 784 LIMIT 1 746 785 ) latest ON true 747 - LEFT JOIN ip_infos i ON te.ip = i.ip 786 + LEFT JOIN ip_infos i ON te.ip = i.ip; 748 787 ` 749 788 750 789 detail := &PDSDetail{} 751 - var ip, city, country, countryCode, asnOrg, serverDID sql.NullString 790 + var ip, ipv6, city, country, countryCode, asnOrg, serverDID sql.NullString 752 791 var asn sql.NullInt32 753 - var isDatacenter, isVPN sql.NullBool 792 + var isDatacenter, isVPN, isCrawler, isTor, isProxy sql.NullBool 754 793 var lat, lon sql.NullFloat64 755 794 var userCount sql.NullInt32 756 795 var responseTime sql.NullFloat64 ··· 762 801 var firstDiscoveredAt sql.NullTime 763 802 764 803 err := p.db.QueryRowContext(ctx, query, endpoint).Scan( 765 - &detail.ID, &detail.Endpoint, &serverDID, &detail.DiscoveredAt, &detail.LastChecked, &detail.Status, &ip, 804 + &detail.ID, &detail.Endpoint, &serverDID, &detail.DiscoveredAt, &detail.LastChecked, &detail.Status, &ip, &ipv6, &detail.Valid, 766 805 &userCount, &responseTime, &version, &serverInfoJSON, &scannedAt, 767 806 &city, &country, &countryCode, &asn, &asnOrg, 768 - &isDatacenter, &isVPN, &lat, &lon, 807 + &isDatacenter, &isVPN, &isCrawler, &isTor, &isProxy, 808 + &lat, &lon, 769 809 &rawDataJSON, 770 810 pq.Array(&aliases), 771 811 &firstDiscoveredAt, ··· 777 817 if ip.Valid { 778 818 detail.IP = ip.String 779 819 } 820 + if ipv6.Valid { 821 + detail.IPv6 = ipv6.String 822 + } 780 823 781 824 if serverDID.Valid { 782 825 detail.ServerDID = serverDID.String ··· 785 828 // Set aliases and is_primary 786 829 detail.Aliases = aliases 787 830 if serverDID.Valid && serverDID.String != "" && firstDiscoveredAt.Valid { 788 - // Has server_did - check if this is the first discovered 789 831 detail.IsPrimary = detail.DiscoveredAt.Equal(firstDiscoveredAt.Time) || 790 832 detail.DiscoveredAt.Before(firstDiscoveredAt.Time) 791 833 } else { 792 - // No server_did means unique server 793 834 detail.IsPrimary = true 794 835 } 795 836 ··· 815 856 } 816 857 } 817 858 818 - // Parse IP info 859 + // Parse IP info with all fields 819 860 if city.Valid || country.Valid { 820 861 detail.IPInfo = &IPInfo{ 821 862 IP: ip.String, ··· 826 867 ASNOrg: asnOrg.String, 827 868 IsDatacenter: isDatacenter.Bool, 828 869 IsVPN: isVPN.Bool, 870 + IsCrawler: isCrawler.Bool, 871 + IsTor: isTor.Bool, 872 + IsProxy: isProxy.Bool, 829 873 Latitude: float32(lat.Float64), 830 874 Longitude: float32(lon.Float64), 831 875 } ··· 961 1005 countryCode := extractString(ipInfo, "location", "country_code") 962 1006 asn := extractInt(ipInfo, "asn", "asn") 963 1007 asnOrg := extractString(ipInfo, "asn", "org") 964 - isDatacenter := extractBool(ipInfo, "company", "type", "hosting") 965 - isVPN := extractBool(ipInfo, "security", "vpn") 1008 + 1009 + // Extract top-level boolean flags 1010 + isDatacenter := false 1011 + if val, ok := ipInfo["is_datacenter"].(bool); ok { 1012 + isDatacenter = val 1013 + } 1014 + 1015 + isVPN := false 1016 + if val, ok := ipInfo["is_vpn"].(bool); ok { 1017 + isVPN = val 1018 + } 1019 + 1020 + isCrawler := false 1021 + if val, ok := ipInfo["is_crawler"].(bool); ok { 1022 + isCrawler = val 1023 + } 1024 + 1025 + isTor := false 1026 + if val, ok := ipInfo["is_tor"].(bool); ok { 1027 + isTor = val 1028 + } 1029 + 1030 + isProxy := false 1031 + if val, ok := ipInfo["is_proxy"].(bool); ok { 1032 + isProxy = val 1033 + } 1034 + 966 1035 lat := extractFloat(ipInfo, "location", "latitude") 967 1036 lon := extractFloat(ipInfo, "location", "longitude") 968 1037 969 1038 query := ` 970 - INSERT INTO ip_infos (ip, city, country, country_code, asn, asn_org, is_datacenter, is_vpn, latitude, longitude, raw_data, fetched_at) 971 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) 1039 + INSERT INTO ip_infos (ip, city, country, country_code, asn, asn_org, is_datacenter, is_vpn, is_crawler, is_tor, is_proxy, latitude, longitude, raw_data, fetched_at) 1040 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) 972 1041 ON CONFLICT(ip) DO UPDATE SET 973 1042 city = EXCLUDED.city, 974 1043 country = EXCLUDED.country, ··· 977 1046 asn_org = EXCLUDED.asn_org, 978 1047 is_datacenter = EXCLUDED.is_datacenter, 979 1048 is_vpn = EXCLUDED.is_vpn, 1049 + is_crawler = EXCLUDED.is_crawler, 1050 + is_tor = EXCLUDED.is_tor, 1051 + is_proxy = EXCLUDED.is_proxy, 980 1052 latitude = EXCLUDED.latitude, 981 1053 longitude = EXCLUDED.longitude, 982 1054 raw_data = EXCLUDED.raw_data, 983 1055 fetched_at = EXCLUDED.fetched_at, 984 1056 updated_at = CURRENT_TIMESTAMP 985 1057 ` 986 - _, err := p.db.ExecContext(ctx, query, ip, city, country, countryCode, asn, asnOrg, isDatacenter, isVPN, lat, lon, rawDataJSON, time.Now().UTC()) 1058 + _, err := p.db.ExecContext(ctx, query, ip, city, country, countryCode, asn, asnOrg, isDatacenter, isVPN, isCrawler, isTor, isProxy, lat, lon, rawDataJSON, time.Now().UTC()) 987 1059 return err 988 1060 } 989 1061 990 1062 func (p *PostgresDB) GetIPInfo(ctx context.Context, ip string) (*IPInfo, error) { 991 1063 query := ` 992 - SELECT ip, city, country, country_code, asn, asn_org, is_datacenter, is_vpn, 1064 + SELECT ip, city, country, country_code, asn, asn_org, is_datacenter, is_vpn, is_crawler, is_tor, is_proxy, 993 1065 latitude, longitude, raw_data, fetched_at, updated_at 994 1066 FROM ip_infos 995 1067 WHERE ip = $1 ··· 1000 1072 1001 1073 err := p.db.QueryRowContext(ctx, query, ip).Scan( 1002 1074 &info.IP, &info.City, &info.Country, &info.CountryCode, &info.ASN, &info.ASNOrg, 1003 - &info.IsDatacenter, &info.IsVPN, &info.Latitude, &info.Longitude, 1075 + &info.IsDatacenter, &info.IsVPN, &info.IsCrawler, &info.IsTor, &info.IsProxy, 1076 + &info.Latitude, &info.Longitude, 1004 1077 &rawDataJSON, &info.FetchedAt, &info.UpdatedAt, 1005 1078 ) 1006 1079 if err != nil { ··· 1090 1163 return 0 1091 1164 } 1092 1165 1093 - func extractBool(data map[string]interface{}, keys ...string) bool { 1094 - current := data 1095 - for i, key := range keys { 1096 - if i == len(keys)-1 { 1097 - if val, ok := current[key].(bool); ok { 1098 - return val 1099 - } 1100 - // Check if it's a string that matches (for type="hosting") 1101 - if val, ok := current[key].(string); ok { 1102 - // For cases like company.type == "hosting" 1103 - expectedValue := keys[len(keys)-1] 1104 - return val == expectedValue 1105 - } 1106 - return false 1107 - } 1108 - if nested, ok := current[key].(map[string]interface{}); ok { 1109 - current = nested 1110 - } else { 1111 - return false 1112 - } 1113 - } 1114 - return false 1115 - } 1116 - 1117 - // ===== BUNDLE OPERATIONS ===== 1118 - 1119 - func (p *PostgresDB) CreateBundle(ctx context.Context, bundle *PLCBundle) error { 1120 - didsJSON, err := json.Marshal(bundle.DIDs) 1121 - if err != nil { 1122 - return err 1123 - } 1124 - 1125 - // Calculate cumulative sizes from previous bundle 1126 - if bundle.BundleNumber > 1 { 1127 - prevBundle, err := p.GetBundleByNumber(ctx, bundle.BundleNumber-1) 1128 - if err == nil && prevBundle != nil { 1129 - bundle.CumulativeCompressedSize = prevBundle.CumulativeCompressedSize + bundle.CompressedSize 1130 - bundle.CumulativeUncompressedSize = prevBundle.CumulativeUncompressedSize + bundle.UncompressedSize 1131 - } else { 1132 - bundle.CumulativeCompressedSize = bundle.CompressedSize 1133 - bundle.CumulativeUncompressedSize = bundle.UncompressedSize 1134 - } 1135 - } else { 1136 - bundle.CumulativeCompressedSize = bundle.CompressedSize 1137 - bundle.CumulativeUncompressedSize = bundle.UncompressedSize 1138 - } 1139 - 1140 - query := ` 1141 - INSERT INTO plc_bundles ( 1142 - bundle_number, start_time, end_time, dids, 1143 - hash, compressed_hash, compressed_size, uncompressed_size, 1144 - cumulative_compressed_size, cumulative_uncompressed_size, 1145 - cursor, prev_bundle_hash, compressed 1146 - ) 1147 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 1148 - ON CONFLICT(bundle_number) DO UPDATE SET 1149 - start_time = EXCLUDED.start_time, 1150 - end_time = EXCLUDED.end_time, 1151 - dids = EXCLUDED.dids, 1152 - hash = EXCLUDED.hash, 1153 - compressed_hash = EXCLUDED.compressed_hash, 1154 - compressed_size = EXCLUDED.compressed_size, 1155 - uncompressed_size = EXCLUDED.uncompressed_size, 1156 - cumulative_compressed_size = EXCLUDED.cumulative_compressed_size, 1157 - cumulative_uncompressed_size = EXCLUDED.cumulative_uncompressed_size, 1158 - cursor = EXCLUDED.cursor, 1159 - prev_bundle_hash = EXCLUDED.prev_bundle_hash, 1160 - compressed = EXCLUDED.compressed 1161 - ` 1162 - _, err = p.db.ExecContext(ctx, query, 1163 - bundle.BundleNumber, bundle.StartTime, bundle.EndTime, 1164 - didsJSON, bundle.Hash, bundle.CompressedHash, 1165 - bundle.CompressedSize, bundle.UncompressedSize, 1166 - bundle.CumulativeCompressedSize, bundle.CumulativeUncompressedSize, 1167 - bundle.Cursor, bundle.PrevBundleHash, bundle.Compressed, 1168 - ) 1169 - 1170 - return err 1171 - } 1172 - 1173 - func (p *PostgresDB) GetBundleByNumber(ctx context.Context, bundleNumber int) (*PLCBundle, error) { 1174 - query := ` 1175 - SELECT bundle_number, start_time, end_time, dids, hash, compressed_hash, 1176 - compressed_size, uncompressed_size, cumulative_compressed_size, 1177 - cumulative_uncompressed_size, cursor, prev_bundle_hash, compressed, created_at 1178 - FROM plc_bundles 1179 - WHERE bundle_number = $1 1180 - ` 1181 - 1182 - var bundle PLCBundle 1183 - var didsJSON []byte 1184 - var prevHash sql.NullString 1185 - var cursor sql.NullString 1186 - 1187 - err := p.db.QueryRowContext(ctx, query, bundleNumber).Scan( 1188 - &bundle.BundleNumber, &bundle.StartTime, &bundle.EndTime, 1189 - &didsJSON, &bundle.Hash, &bundle.CompressedHash, 1190 - &bundle.CompressedSize, &bundle.UncompressedSize, 1191 - &bundle.CumulativeCompressedSize, &bundle.CumulativeUncompressedSize, 1192 - &cursor, &prevHash, &bundle.Compressed, &bundle.CreatedAt, 1193 - ) 1194 - if err != nil { 1195 - return nil, err 1196 - } 1197 - 1198 - if prevHash.Valid { 1199 - bundle.PrevBundleHash = prevHash.String 1200 - } 1201 - if cursor.Valid { 1202 - bundle.Cursor = cursor.String 1203 - } 1204 - 1205 - json.Unmarshal(didsJSON, &bundle.DIDs) 1206 - return &bundle, nil 1207 - } 1208 - 1209 - func (p *PostgresDB) GetBundles(ctx context.Context, limit int) ([]*PLCBundle, error) { 1210 - query := ` 1211 - SELECT bundle_number, start_time, end_time, dids, hash, compressed_hash, 1212 - compressed_size, uncompressed_size, cumulative_compressed_size, 1213 - cumulative_uncompressed_size, cursor, prev_bundle_hash, compressed, created_at 1214 - FROM plc_bundles 1215 - ORDER BY bundle_number DESC 1216 - LIMIT $1 1217 - ` 1218 - 1219 - rows, err := p.db.QueryContext(ctx, query, limit) 1220 - if err != nil { 1221 - return nil, err 1222 - } 1223 - defer rows.Close() 1224 - 1225 - return p.scanBundles(rows) 1226 - } 1227 - 1228 - func (p *PostgresDB) GetBundlesForDID(ctx context.Context, did string) ([]*PLCBundle, error) { 1229 - query := ` 1230 - SELECT bundle_number, start_time, end_time, dids, hash, compressed_hash, 1231 - compressed_size, uncompressed_size, cumulative_compressed_size, 1232 - cumulative_uncompressed_size, cursor, prev_bundle_hash, compressed, created_at 1233 - FROM plc_bundles 1234 - WHERE dids ? $1 1235 - ORDER BY bundle_number ASC 1236 - ` 1237 - 1238 - rows, err := p.db.QueryContext(ctx, query, did) 1239 - if err != nil { 1240 - return nil, err 1241 - } 1242 - defer rows.Close() 1243 - 1244 - return p.scanBundles(rows) 1245 - } 1246 - 1247 - func (p *PostgresDB) scanBundles(rows *sql.Rows) ([]*PLCBundle, error) { 1248 - var bundles []*PLCBundle 1249 - 1250 - for rows.Next() { 1251 - var bundle PLCBundle 1252 - var didsJSON []byte 1253 - var prevHash sql.NullString 1254 - var cursor sql.NullString 1255 - 1256 - if err := rows.Scan( 1257 - &bundle.BundleNumber, 1258 - &bundle.StartTime, 1259 - &bundle.EndTime, 1260 - &didsJSON, 1261 - &bundle.Hash, 1262 - &bundle.CompressedHash, 1263 - &bundle.CompressedSize, 1264 - &bundle.UncompressedSize, 1265 - &bundle.CumulativeCompressedSize, 1266 - &bundle.CumulativeUncompressedSize, 1267 - &cursor, 1268 - &prevHash, 1269 - &bundle.Compressed, 1270 - &bundle.CreatedAt, 1271 - ); err != nil { 1272 - return nil, err 1273 - } 1274 - 1275 - if prevHash.Valid { 1276 - bundle.PrevBundleHash = prevHash.String 1277 - } 1278 - if cursor.Valid { 1279 - bundle.Cursor = cursor.String 1280 - } 1281 - 1282 - json.Unmarshal(didsJSON, &bundle.DIDs) 1283 - bundles = append(bundles, &bundle) 1284 - } 1285 - 1286 - return bundles, rows.Err() 1287 - } 1288 - 1289 - func (p *PostgresDB) GetBundleStats(ctx context.Context) (int64, int64, int64, int64, error) { 1290 - var count, lastBundleNum int64 1291 - err := p.db.QueryRowContext(ctx, ` 1292 - SELECT COUNT(*), COALESCE(MAX(bundle_number), 0) 1293 - FROM plc_bundles 1294 - `).Scan(&count, &lastBundleNum) 1295 - if err != nil { 1296 - return 0, 0, 0, 0, err 1297 - } 1298 - 1299 - if lastBundleNum == 0 { 1300 - return 0, 0, 0, 0, nil 1301 - } 1302 - 1303 - var compressedSize, uncompressedSize int64 1304 - err = p.db.QueryRowContext(ctx, ` 1305 - SELECT cumulative_compressed_size, cumulative_uncompressed_size 1306 - FROM plc_bundles 1307 - WHERE bundle_number = $1 1308 - `, lastBundleNum).Scan(&compressedSize, &uncompressedSize) 1309 - if err != nil { 1310 - return 0, 0, 0, 0, err 1311 - } 1312 - 1313 - return count, compressedSize, uncompressedSize, lastBundleNum, nil 1314 - } 1315 - 1316 - func (p *PostgresDB) GetLastBundleNumber(ctx context.Context) (int, error) { 1317 - query := "SELECT COALESCE(MAX(bundle_number), 0) FROM plc_bundles" 1318 - var num int 1319 - err := p.db.QueryRowContext(ctx, query).Scan(&num) 1320 - return num, err 1321 - } 1322 - 1323 - func (p *PostgresDB) GetBundleForTimestamp(ctx context.Context, afterTime time.Time) (int, error) { 1324 - query := ` 1325 - SELECT bundle_number 1326 - FROM plc_bundles 1327 - WHERE start_time <= $1 AND end_time >= $1 1328 - ORDER BY bundle_number ASC 1329 - LIMIT 1 1330 - ` 1331 - 1332 - var bundleNum int 1333 - err := p.db.QueryRowContext(ctx, query, afterTime).Scan(&bundleNum) 1334 - if err == sql.ErrNoRows { 1335 - query = ` 1336 - SELECT bundle_number 1337 - FROM plc_bundles 1338 - WHERE end_time < $1 1339 - ORDER BY bundle_number DESC 1340 - LIMIT 1 1341 - ` 1342 - err = p.db.QueryRowContext(ctx, query, afterTime).Scan(&bundleNum) 1343 - if err == sql.ErrNoRows { 1344 - return 1, nil 1345 - } 1346 - if err != nil { 1347 - return 0, err 1348 - } 1349 - return bundleNum, nil 1350 - } 1351 - if err != nil { 1352 - return 0, err 1353 - } 1354 - 1355 - return bundleNum, nil 1356 - } 1357 - 1358 - func (p *PostgresDB) GetPLCHistory(ctx context.Context, limit int, fromBundle int) ([]*PLCHistoryPoint, error) { 1359 - query := ` 1360 - WITH daily_stats AS ( 1361 - SELECT 1362 - DATE(start_time) as date, 1363 - MAX(bundle_number) as last_bundle, 1364 - COUNT(*) as bundle_count, 1365 - SUM(uncompressed_size) as total_uncompressed, 1366 - SUM(compressed_size) as total_compressed, 1367 - MAX(cumulative_uncompressed_size) as cumulative_uncompressed, 1368 - MAX(cumulative_compressed_size) as cumulative_compressed 1369 - FROM plc_bundles 1370 - WHERE bundle_number >= $1 1371 - GROUP BY DATE(start_time) 1372 - ) 1373 - SELECT 1374 - date::text, 1375 - last_bundle, 1376 - SUM(bundle_count * 10000) OVER (ORDER BY date) as cumulative_operations, 1377 - total_uncompressed, 1378 - total_compressed, 1379 - cumulative_uncompressed, 1380 - cumulative_compressed 1381 - FROM daily_stats 1382 - ORDER BY date ASC 1383 - ` 1384 - 1385 - if limit > 0 { 1386 - query += fmt.Sprintf(" LIMIT %d", limit) 1387 - } 1388 - 1389 - rows, err := p.db.QueryContext(ctx, query, fromBundle) 1390 - if err != nil { 1391 - return nil, err 1392 - } 1393 - defer rows.Close() 1394 - 1395 - var history []*PLCHistoryPoint 1396 - for rows.Next() { 1397 - var point PLCHistoryPoint 1398 - var cumulativeOps int64 1399 - 1400 - err := rows.Scan( 1401 - &point.Date, 1402 - &point.BundleNumber, 1403 - &cumulativeOps, 1404 - &point.UncompressedSize, 1405 - &point.CompressedSize, 1406 - &point.CumulativeUncompressed, 1407 - &point.CumulativeCompressed, 1408 - ) 1409 - if err != nil { 1410 - return nil, err 1411 - } 1412 - 1413 - point.OperationCount = int(cumulativeOps) 1414 - 1415 - history = append(history, &point) 1416 - } 1417 - 1418 - return history, rows.Err() 1419 - } 1420 - 1421 - // ===== MEMPOOL OPERATIONS ===== 1422 - 1423 - func (p *PostgresDB) AddToMempool(ctx context.Context, ops []MempoolOperation) error { 1424 - if len(ops) == 0 { 1425 - return nil 1426 - } 1427 - 1428 - tx, err := p.db.BeginTx(ctx, nil) 1429 - if err != nil { 1430 - return err 1431 - } 1432 - defer tx.Rollback() 1433 - 1434 - stmt, err := tx.PrepareContext(ctx, ` 1435 - INSERT INTO plc_mempool (did, operation, cid, created_at) 1436 - VALUES ($1, $2, $3, $4) 1437 - ON CONFLICT(cid) DO NOTHING 1438 - `) 1439 - if err != nil { 1440 - return err 1441 - } 1442 - defer stmt.Close() 1443 - 1444 - for _, op := range ops { 1445 - _, err := stmt.ExecContext(ctx, op.DID, op.Operation, op.CID, op.CreatedAt) 1446 - if err != nil { 1447 - return err 1448 - } 1449 - } 1450 - 1451 - return tx.Commit() 1452 - } 1453 - 1454 - func (p *PostgresDB) GetMempoolCount(ctx context.Context) (int, error) { 1455 - query := "SELECT COUNT(*) FROM plc_mempool" 1456 - var count int 1457 - err := p.db.QueryRowContext(ctx, query).Scan(&count) 1458 - return count, err 1459 - } 1460 - 1461 - func (p *PostgresDB) GetMempoolOperations(ctx context.Context, limit int) ([]MempoolOperation, error) { 1462 - query := ` 1463 - SELECT id, did, operation, cid, created_at, added_at 1464 - FROM plc_mempool 1465 - ORDER BY created_at ASC 1466 - LIMIT $1 1467 - ` 1468 - 1469 - rows, err := p.db.QueryContext(ctx, query, limit) 1470 - if err != nil { 1471 - return nil, err 1472 - } 1473 - defer rows.Close() 1474 - 1475 - var ops []MempoolOperation 1476 - for rows.Next() { 1477 - var op MempoolOperation 1478 - err := rows.Scan(&op.ID, &op.DID, &op.Operation, &op.CID, &op.CreatedAt, &op.AddedAt) 1479 - if err != nil { 1480 - return nil, err 1481 - } 1482 - ops = append(ops, op) 1483 - } 1484 - 1485 - return ops, rows.Err() 1486 - } 1487 - 1488 - func (p *PostgresDB) DeleteFromMempool(ctx context.Context, ids []int64) error { 1489 - if len(ids) == 0 { 1490 - return nil 1491 - } 1492 - 1493 - placeholders := make([]string, len(ids)) 1494 - args := make([]interface{}, len(ids)) 1495 - for i, id := range ids { 1496 - placeholders[i] = fmt.Sprintf("$%d", i+1) 1497 - args[i] = id 1498 - } 1499 - 1500 - query := fmt.Sprintf("DELETE FROM plc_mempool WHERE id IN (%s)", 1501 - strings.Join(placeholders, ",")) 1502 - 1503 - _, err := p.db.ExecContext(ctx, query, args...) 1504 - return err 1505 - } 1506 - 1507 - func (p *PostgresDB) GetFirstMempoolOperation(ctx context.Context) (*MempoolOperation, error) { 1508 - query := ` 1509 - SELECT id, did, operation, cid, created_at, added_at 1510 - FROM plc_mempool 1511 - ORDER BY created_at ASC, id ASC 1512 - LIMIT 1 1513 - ` 1514 - 1515 - var op MempoolOperation 1516 - err := p.db.QueryRowContext(ctx, query).Scan( 1517 - &op.ID, &op.DID, &op.Operation, &op.CID, &op.CreatedAt, &op.AddedAt, 1518 - ) 1519 - if err == sql.ErrNoRows { 1520 - return nil, nil 1521 - } 1522 - if err != nil { 1523 - return nil, err 1524 - } 1525 - 1526 - return &op, nil 1527 - } 1528 - 1529 - func (p *PostgresDB) GetLastMempoolOperation(ctx context.Context) (*MempoolOperation, error) { 1530 - query := ` 1531 - SELECT id, did, operation, cid, created_at, added_at 1532 - FROM plc_mempool 1533 - ORDER BY created_at DESC, id DESC 1534 - LIMIT 1 1535 - ` 1536 - 1537 - var op MempoolOperation 1538 - err := p.db.QueryRowContext(ctx, query).Scan( 1539 - &op.ID, &op.DID, &op.Operation, &op.CID, &op.CreatedAt, &op.AddedAt, 1540 - ) 1541 - if err == sql.ErrNoRows { 1542 - return nil, nil 1543 - } 1544 - if err != nil { 1545 - return nil, err 1546 - } 1547 - 1548 - return &op, nil 1549 - } 1550 - 1551 - func (p *PostgresDB) GetMempoolUniqueDIDCount(ctx context.Context) (int, error) { 1552 - query := "SELECT COUNT(DISTINCT did) FROM plc_mempool" 1553 - var count int 1554 - err := p.db.QueryRowContext(ctx, query).Scan(&count) 1555 - return count, err 1556 - } 1557 - 1558 - func (p *PostgresDB) GetMempoolUncompressedSize(ctx context.Context) (int64, error) { 1559 - query := "SELECT COALESCE(SUM(LENGTH(operation)), 0) FROM plc_mempool" 1560 - var size int64 1561 - err := p.db.QueryRowContext(ctx, query).Scan(&size) 1562 - return size, err 1563 - } 1564 - 1565 1166 // ===== CURSOR OPERATIONS ===== 1566 1167 1567 1168 func (p *PostgresDB) GetScanCursor(ctx context.Context, source string) (*ScanCursor, error) { ··· 1634 1235 1635 1236 // ===== DID OPERATIONS ===== 1636 1237 1637 - func (p *PostgresDB) UpsertDID(ctx context.Context, did string, bundleNum int) error { 1238 + func (p *PostgresDB) UpsertDID(ctx context.Context, did string, bundleNum int, handle, pds string) error { 1638 1239 query := ` 1639 - INSERT INTO dids (did, bundle_numbers, created_at) 1640 - VALUES ($1, jsonb_build_array($2), CURRENT_TIMESTAMP) 1240 + INSERT INTO dids (did, handle, pds, bundle_numbers, created_at) 1241 + VALUES ($1, $2, $3, jsonb_build_array($4::integer), CURRENT_TIMESTAMP) 1641 1242 ON CONFLICT(did) DO UPDATE SET 1243 + handle = EXCLUDED.handle, 1244 + pds = EXCLUDED.pds, 1642 1245 bundle_numbers = CASE 1643 - WHEN dids.bundle_numbers ? $2::text THEN dids.bundle_numbers 1644 - ELSE dids.bundle_numbers || jsonb_build_array($2) 1645 - END 1246 + WHEN dids.bundle_numbers @> jsonb_build_array($4::integer) THEN dids.bundle_numbers 1247 + ELSE dids.bundle_numbers || jsonb_build_array($4::integer) 1248 + END, 1249 + updated_at = CURRENT_TIMESTAMP 1250 + ` 1251 + _, err := p.db.ExecContext(ctx, query, did, handle, pds, bundleNum) 1252 + return err 1253 + } 1254 + 1255 + // UpsertDIDFromMempool creates/updates DID record without adding to bundle_numbers 1256 + func (p *PostgresDB) UpsertDIDFromMempool(ctx context.Context, did string, handle, pds string) error { 1257 + query := ` 1258 + INSERT INTO dids (did, handle, pds, bundle_numbers, created_at) 1259 + VALUES ($1, $2, $3, '[]'::jsonb, CURRENT_TIMESTAMP) 1260 + ON CONFLICT(did) DO UPDATE SET 1261 + handle = EXCLUDED.handle, 1262 + pds = EXCLUDED.pds, 1263 + updated_at = CURRENT_TIMESTAMP 1646 1264 ` 1647 - _, err := p.db.ExecContext(ctx, query, did, bundleNum) 1265 + _, err := p.db.ExecContext(ctx, query, did, handle, pds) 1648 1266 return err 1649 1267 } 1650 1268 1651 1269 func (p *PostgresDB) GetDIDRecord(ctx context.Context, did string) (*DIDRecord, error) { 1652 1270 query := ` 1653 - SELECT did, bundle_numbers, created_at 1271 + SELECT did, handle, pds, bundle_numbers, created_at 1654 1272 FROM dids 1655 1273 WHERE did = $1 1656 1274 ` 1657 1275 1658 1276 var record DIDRecord 1659 1277 var bundleNumbersJSON []byte 1278 + var handle, pds sql.NullString 1660 1279 1661 1280 err := p.db.QueryRowContext(ctx, query, did).Scan( 1662 1281 &record.DID, 1282 + &handle, 1283 + &pds, 1663 1284 &bundleNumbersJSON, 1664 1285 &record.CreatedAt, 1665 1286 ) ··· 1667 1288 return nil, err 1668 1289 } 1669 1290 1291 + if handle.Valid { 1292 + record.Handle = handle.String 1293 + } 1294 + if pds.Valid { 1295 + record.CurrentPDS = pds.String 1296 + } 1297 + 1298 + if err := json.Unmarshal(bundleNumbersJSON, &record.BundleNumbers); err != nil { 1299 + return nil, err 1300 + } 1301 + 1302 + return &record, nil 1303 + } 1304 + 1305 + func (p *PostgresDB) GetDIDByHandle(ctx context.Context, handle string) (*DIDRecord, error) { 1306 + query := ` 1307 + SELECT did, handle, pds, bundle_numbers, created_at 1308 + FROM dids 1309 + WHERE handle = $1 1310 + ` 1311 + 1312 + var record DIDRecord 1313 + var bundleNumbersJSON []byte 1314 + var recordHandle, pds sql.NullString 1315 + 1316 + err := p.db.QueryRowContext(ctx, query, handle).Scan( 1317 + &record.DID, 1318 + &recordHandle, 1319 + &pds, 1320 + &bundleNumbersJSON, 1321 + &record.CreatedAt, 1322 + ) 1323 + if err != nil { 1324 + return nil, err 1325 + } 1326 + 1327 + if recordHandle.Valid { 1328 + record.Handle = recordHandle.String 1329 + } 1330 + if pds.Valid { 1331 + record.CurrentPDS = pds.String 1332 + } 1333 + 1670 1334 if err := json.Unmarshal(bundleNumbersJSON, &record.BundleNumbers); err != nil { 1671 1335 return nil, err 1672 1336 } ··· 1676 1340 1677 1341 // GetGlobalDIDInfo retrieves consolidated DID info from 'dids' and 'pds_repos' 1678 1342 func (p *PostgresDB) GetGlobalDIDInfo(ctx context.Context, did string) (*GlobalDIDInfo, error) { 1679 - // This query now includes a CTE to find primary endpoints and filters 1680 - // the 'hosting_on' aggregation to only include repos from those endpoints. 1681 1343 query := ` 1682 1344 WITH primary_endpoints AS ( 1683 - -- First, get the ID of every "primary" PDS. 1684 - -- A primary PDS is the one with the earliest 'discovered_at' timestamp 1685 - -- for a given 'server_did'. 1686 1345 SELECT DISTINCT ON (COALESCE(server_did, id::text)) 1687 1346 id 1688 1347 FROM endpoints ··· 1691 1350 ) 1692 1351 SELECT 1693 1352 d.did, 1353 + d.handle, 1354 + d.pds, 1694 1355 d.bundle_numbers, 1695 1356 d.created_at, 1696 1357 COALESCE( ··· 1710 1371 ) 1711 1372 ORDER BY pr.last_seen DESC 1712 1373 ) FILTER ( 1713 - -- This filter clause ensures we only aggregate repos 1714 - -- where the endpoint_id is in our list of primary endpoints. 1715 1374 WHERE pr.id IS NOT NULL AND pe.id IS NOT NULL 1716 1375 ), 1717 1376 '[]'::jsonb ··· 1723 1382 LEFT JOIN 1724 1383 endpoints e ON pr.endpoint_id = e.id 1725 1384 LEFT JOIN 1726 - -- We join the primary_endpoints CTE. 'pe.id' will be NON-NULL 1727 - -- only if the repo's endpoint_id (pr.endpoint_id) is a primary one. 1728 1385 primary_endpoints pe ON pr.endpoint_id = pe.id 1729 1386 WHERE 1730 1387 d.did = $1 1731 1388 GROUP BY 1732 - d.did, d.bundle_numbers, d.created_at 1389 + d.did, d.handle, d.pds, d.bundle_numbers, d.created_at 1733 1390 ` 1734 1391 1735 1392 var info GlobalDIDInfo 1736 1393 var bundleNumbersJSON []byte 1737 1394 var hostingOnJSON []byte 1395 + var handle, pds sql.NullString 1738 1396 1739 1397 err := p.db.QueryRowContext(ctx, query, did).Scan( 1740 1398 &info.DID, 1399 + &handle, 1400 + &pds, 1741 1401 &bundleNumbersJSON, 1742 1402 &info.CreatedAt, 1743 1403 &hostingOnJSON, 1744 1404 ) 1745 1405 if err != nil { 1746 - return nil, err // This will correctly be sql.ErrNoRows if not in 'dids' 1406 + return nil, err 1407 + } 1408 + 1409 + if handle.Valid { 1410 + info.Handle = handle.String 1411 + } 1412 + if pds.Valid { 1413 + info.CurrentPDS = pds.String 1747 1414 } 1748 1415 1749 1416 if err := json.Unmarshal(bundleNumbersJSON, &info.BundleNumbers); err != nil {
+22 -16
internal/storage/types.go
··· 26 26 LastChecked time.Time 27 27 Status int 28 28 IP string 29 + IPv6 string 29 30 IPResolvedAt time.Time 31 + Valid bool 30 32 UpdatedAt time.Time 31 33 } 32 34 ··· 53 55 Status int 54 56 ResponseTime float64 55 57 UserCount int64 56 - Version string // NEW: Add this field 58 + Version string 59 + UsedIP string // NEW: Track which IP was actually used 57 60 ScanData *EndpointScanData 58 61 ScannedAt time.Time 59 62 } ··· 74 77 75 78 // EndpointFilter for querying endpoints 76 79 type EndpointFilter struct { 77 - Type string // "pds", "labeler", etc. 80 + Type string 78 81 Status string 79 82 MinUserCount int64 80 - OnlyStale bool // NEW: Only return endpoints that need re-checking 81 - RecheckInterval time.Duration // NEW: How long before an endpoint is considered stale 83 + OnlyStale bool 84 + OnlyValid bool 85 + RecheckInterval time.Duration 86 + Random bool 82 87 Limit int 83 88 Offset int 84 89 } ··· 117 122 StartTime time.Time 118 123 EndTime time.Time 119 124 BoundaryCIDs []string 120 - DIDs []string 125 + DIDCount int // Changed from DIDs []string 121 126 Hash string 122 127 CompressedHash string 123 128 CompressedSize int64 ··· 150 155 CumulativeCompressed int64 `json:"cumulative_compressed"` 151 156 } 152 157 153 - // MempoolOperation represents an operation waiting to be bundled 154 - type MempoolOperation struct { 155 - ID int64 156 - DID string 157 - Operation string 158 - CID string 159 - CreatedAt time.Time 160 - AddedAt time.Time 161 - } 162 - 163 158 // ScanCursor stores scanning progress 164 159 type ScanCursor struct { 165 160 Source string ··· 194 189 ASNOrg string `json:"asn_org,omitempty"` 195 190 IsDatacenter bool `json:"is_datacenter"` 196 191 IsVPN bool `json:"is_vpn"` 192 + IsCrawler bool `json:"is_crawler"` 193 + IsTor bool `json:"is_tor"` 194 + IsProxy bool `json:"is_proxy"` 197 195 Latitude float32 `json:"latitude,omitempty"` 198 196 Longitude float32 `json:"longitude,omitempty"` 199 197 RawData map[string]interface{} `json:"raw_data,omitempty"` ··· 201 199 UpdatedAt time.Time `json:"updated_at"` 202 200 } 203 201 202 + // IsHome returns true if this is a residential/home IP 203 + // (not crawler, datacenter, tor, proxy, or vpn) 204 + func (i *IPInfo) IsHome() bool { 205 + return !i.IsCrawler && !i.IsDatacenter && !i.IsTor && !i.IsProxy && !i.IsVPN 206 + } 207 + 204 208 // PDSListItem is a virtual type created by JOIN for /pds endpoint 205 209 type PDSListItem struct { 206 210 // From endpoints table 207 211 ID int64 208 212 Endpoint string 209 - ServerDID string // NEW: Add this 213 + ServerDID string 210 214 DiscoveredAt time.Time 211 215 LastChecked time.Time 212 216 Status int 213 217 IP string 218 + IPv6 string 219 + Valid bool // NEW 214 220 215 221 // From latest endpoint_scans (via JOIN) 216 222 LatestScan *struct {
+2 -2
internal/worker/scheduler.go
··· 5 5 "sync" 6 6 "time" 7 7 8 - "github.com/atscan/atscanner/internal/log" 9 - "github.com/atscan/atscanner/internal/monitor" 8 + "github.com/atscan/atscand/internal/log" 9 + "github.com/atscan/atscand/internal/monitor" 10 10 ) 11 11 12 12 type Job struct {
+113
utils/import-labels.js
··· 1 + import { file, write } from "bun"; 2 + import { join } from "path"; 3 + import { mkdir } from "fs/promises"; 4 + import { init, compress } from "@bokuweb/zstd-wasm"; 5 + 6 + // --- Configuration --- 7 + const CSV_FILE = process.argv[2]; 8 + const CONFIG_FILE = "config.yaml"; 9 + const COMPRESSION_LEVEL = 5; // zstd level 1-22 (5 is a good balance) 10 + // --------------------- 11 + 12 + if (!CSV_FILE) { 13 + console.error("Usage: bun run utils/import-labels.js <path-to-csv-file>"); 14 + process.exit(1); 15 + } 16 + 17 + console.log("========================================"); 18 + console.log("PLC Operation Labels Import (Bun + WASM)"); 19 + console.log("========================================"); 20 + 21 + // 1. Read and parse config 22 + console.log(`Loading config from ${CONFIG_FILE}...`); 23 + const configFile = await file(CONFIG_FILE).text(); 24 + const config = Bun.YAML.parse(configFile); 25 + const bundleDir = config?.plc?.bundle_dir; 26 + 27 + if (!bundleDir) { 28 + console.error("Error: Could not parse plc.bundle_dir from config.yaml"); 29 + process.exit(1); 30 + } 31 + 32 + const FINAL_LABELS_DIR = join(bundleDir, "labels"); 33 + await mkdir(FINAL_LABELS_DIR, { recursive: true }); 34 + 35 + console.log(`CSV File: ${CSV_FILE}`); 36 + console.log(`Output Dir: ${FINAL_LABELS_DIR}`); 37 + console.log(""); 38 + 39 + // 2. Initialize Zstd WASM module 40 + await init(); 41 + 42 + // --- Pass 1: Read entire file into memory and group by bundle --- 43 + console.log("Pass 1/2: Reading and grouping all lines by bundle..."); 44 + console.warn("This will use a large amount of RAM!"); 45 + 46 + const startTime = Date.now(); 47 + const bundles = new Map(); // Map<string, string[]> 48 + let lineCount = 0; 49 + 50 + const inputFile = file(CSV_FILE); 51 + const fileStream = inputFile.stream(); 52 + const decoder = new TextDecoder(); 53 + let remainder = ""; 54 + 55 + for await (const chunk of fileStream) { 56 + const text = remainder + decoder.decode(chunk); 57 + const lines = text.split("\n"); 58 + remainder = lines.pop() || ""; 59 + 60 + for (const line of lines) { 61 + if (line === "") continue; 62 + lineCount++; 63 + 64 + if (lineCount === 1 && line.startsWith("bundle,")) { 65 + continue; // Skip header 66 + } 67 + 68 + const firstCommaIndex = line.indexOf(","); 69 + if (firstCommaIndex === -1) { 70 + console.warn(`Skipping malformed line: ${line}`); 71 + continue; 72 + } 73 + const bundleNumStr = line.substring(0, firstCommaIndex); 74 + const bundleKey = bundleNumStr.padStart(6, "0"); 75 + 76 + // Add line to the correct bundle's array 77 + if (!bundles.has(bundleKey)) { 78 + bundles.set(bundleKey, []); 79 + } 80 + bundles.get(bundleKey).push(line); 81 + } 82 + } 83 + // Note: We ignore any final `remainder` as it's likely an empty line 84 + 85 + console.log(`Finished reading ${lineCount.toLocaleString()} lines.`); 86 + console.log(`Found ${bundles.size} unique bundles.`); 87 + 88 + // --- Pass 2: Compress and write each bundle --- 89 + console.log("\nPass 2/2: Compressing and writing bundle files..."); 90 + let i = 0; 91 + for (const [bundleKey, lines] of bundles.entries()) { 92 + i++; 93 + console.log(` (${i}/${bundles.size}) Compressing bundle ${bundleKey}...`); 94 + 95 + // Join all lines for this bundle into one big string 96 + const content = lines.join("\n"); 97 + 98 + // Compress the string 99 + const compressedData = compress(Buffer.from(content), COMPRESSION_LEVEL); 100 + 101 + // Write the compressed data to the file 102 + const outPath = join(FINAL_LABELS_DIR, `${bundleKey}.csv.zst`); 103 + await write(outPath, compressedData); 104 + } 105 + 106 + // 3. Clean up 107 + const totalTime = (Date.now() - startTime) / 1000; 108 + console.log("\n========================================"); 109 + console.log("Import Summary"); 110 + console.log("========================================"); 111 + console.log(`✓ Import completed in ${totalTime.toFixed(2)} seconds.`); 112 + console.log(`Total lines processed: ${lineCount.toLocaleString()}`); 113 + console.log(`Label files are stored in: ${FINAL_LABELS_DIR}`);
+91
utils/import-labels.sh
··· 1 + #!/bin/bash 2 + # import-labels-v4-sorted-pipe.sh 3 + 4 + set -e 5 + 6 + if [ $# -lt 1 ]; then 7 + echo "Usage: ./utils/import-labels-v4-sorted-pipe.sh <csv-file>" 8 + exit 1 9 + fi 10 + 11 + CSV_FILE="$1" 12 + CONFIG_FILE="config.yaml" 13 + 14 + [ ! -f "$CSV_FILE" ] && echo "Error: CSV file not found" && exit 1 15 + [ ! -f "$CONFIG_FILE" ] && echo "Error: config.yaml not found" && exit 1 16 + 17 + # Extract bundle directory path 18 + BUNDLE_DIR=$(grep -A 5 "^plc:" "$CONFIG_FILE" | grep "bundle_dir:" | sed 's/.*bundle_dir: *"//' | sed 's/".*//' | head -1) 19 + 20 + [ -z "$BUNDLE_DIR" ] && echo "Error: Could not parse plc.bundle_dir from config.yaml" && exit 1 21 + 22 + FINAL_LABELS_DIR="$BUNDLE_DIR/labels" 23 + 24 + echo "========================================" 25 + echo "PLC Operation Labels Import (Sorted Pipe)" 26 + echo "========================================" 27 + echo "CSV File: $CSV_FILE" 28 + echo "Output Dir: $FINAL_LABELS_DIR" 29 + echo "" 30 + 31 + # Ensure the final directory exists 32 + mkdir -p "$FINAL_LABELS_DIR" 33 + 34 + echo "Streaming, sorting, and compressing on the fly..." 35 + echo "This will take time. `pv` will show progress of the TAIL command." 36 + echo "The `sort` command will run after `pv` is complete." 37 + echo "" 38 + 39 + # This is the single-pass pipeline 40 + tail -n +2 "$CSV_FILE" | \ 41 + pv -l -s $(tail -n +2 "$CSV_FILE" | wc -l) | \ 42 + sort -t, -k1,1n | \ 43 + awk -F',' -v final_dir="$FINAL_LABELS_DIR" ' 44 + # This awk script EXPECTS input sorted by bundle number (col 1) 45 + BEGIN { 46 + # last_bundle_num tracks the bundle we are currently writing 47 + last_bundle_num = -1 48 + # cmd holds the current zstd pipe command 49 + cmd = "" 50 + } 51 + { 52 + current_bundle_num = $1 53 + 54 + # Check if the bundle number has changed 55 + if (current_bundle_num != last_bundle_num) { 56 + 57 + # If it changed, and we have an old pipe open, close it 58 + if (last_bundle_num != -1) { 59 + close(cmd) 60 + } 61 + 62 + # Create the new pipe command, writing to the final .zst file 63 + outfile = sprintf("%s/%06d.csv.zst", final_dir, current_bundle_num) 64 + cmd = "zstd -T0 -o " outfile 65 + 66 + # Update the tracker 67 + last_bundle_num = current_bundle_num 68 + 69 + # Print progress to stderr 70 + printf " -> Writing bundle %06d\n", current_bundle_num > "/dev/stderr" 71 + } 72 + 73 + # Print the current line ($0) to the open pipe 74 + # The first time this runs for a bundle, it opens the pipe 75 + # Subsequent times, it writes to the already-open pipe 76 + print $0 | cmd 77 + } 78 + # END block: close the very last pipe 79 + END { 80 + if (last_bundle_num != -1) { 81 + close(cmd) 82 + } 83 + printf " Finished. Total lines: %d\n", NR > "/dev/stderr" 84 + }' 85 + 86 + echo "" 87 + echo "========================================" 88 + echo "Import Summary" 89 + echo "========================================" 90 + echo "✓ Import completed successfully!" 91 + echo "Label files are stored in: $FINAL_LABELS_DIR"
+2 -2
utils/migrate-ipinfo.sh
··· 4 4 # Configuration (edit these) 5 5 DB_HOST="localhost" 6 6 DB_PORT="5432" 7 - DB_NAME="atscanner" 8 - DB_USER="atscanner" 7 + DB_NAME="atscand" 8 + DB_USER="atscand" 9 9 DB_PASSWORD="Noor1kooz5eeFai9leZagh5ua5eihai4" 10 10 11 11 # Colors for output
+199
utils/vuln-scanner-parallel.sh
··· 1 + #!/bin/bash 2 + 3 + # Configuration 4 + API_HOST="${API_HOST:-http://localhost:8080}" 5 + TIMEOUT=5 6 + PARALLEL_JOBS=20 7 + OUTPUT_DIR="./pds_scan_results" 8 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) 9 + RESULTS_FILE="${OUTPUT_DIR}/scan_${TIMESTAMP}.txt" 10 + FOUND_FILE="${OUTPUT_DIR}/found_${TIMESTAMP}.txt" 11 + 12 + # Paths to check 13 + PATHS=( 14 + "/info.php" 15 + "/phpinfo.php" 16 + "/test.php" 17 + "/admin" 18 + "/admin.php" 19 + "/wp-admin" 20 + "/robots.txt" 21 + "/.env" 22 + "/.git/config" 23 + "/config.php" 24 + "/backup" 25 + "/db.sql" 26 + "/.DS_Store" 27 + "/server-status" 28 + "/.well-known/security.txt" 29 + ) 30 + 31 + # Colors 32 + RED='\033[0;31m' 33 + GREEN='\033[0;32m' 34 + YELLOW='\033[1;33m' 35 + BLUE='\033[0;34m' 36 + NC='\033[0m' 37 + 38 + # Check dependencies 39 + if ! command -v jq &> /dev/null; then 40 + echo -e "${RED}Error: jq is required${NC}" 41 + echo "Install: sudo apt-get install jq" 42 + exit 1 43 + fi 44 + 45 + if ! command -v parallel &> /dev/null; then 46 + echo -e "${RED}Error: GNU parallel is required${NC}" 47 + echo "Install: sudo apt-get install parallel (or brew install parallel)" 48 + exit 1 49 + fi 50 + 51 + mkdir -p "$OUTPUT_DIR" 52 + 53 + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" 54 + echo -e "${BLUE}║ PDS Security Scanner (Parallel) ║${NC}" 55 + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" 56 + echo "" 57 + echo "API Host: $API_HOST" 58 + echo "Timeout: ${TIMEOUT}s per request" 59 + echo "Parallel jobs: ${PARALLEL_JOBS}" 60 + echo "Paths to check: ${#PATHS[@]}" 61 + echo "" 62 + 63 + # Scan function - will be called by GNU parallel 64 + scan_endpoint() { 65 + local endpoint="$1" 66 + local timeout="$2" 67 + shift 2 68 + local paths=("$@") 69 + 70 + for path in "${paths[@]}"; do 71 + url="${endpoint}${path}" 72 + 73 + response=$(curl -s -o /dev/null -w "%{http_code}" \ 74 + --max-time "$timeout" \ 75 + --connect-timeout "$timeout" \ 76 + --retry 0 \ 77 + -A "Mozilla/5.0 (Security Scanner)" \ 78 + "$url" 2>/dev/null) 79 + 80 + if [ -n "$response" ] && [ "$response" != "404" ] && [ "$response" != "000" ]; then 81 + if [ "$response" = "200" ] || [ "$response" = "301" ] || [ "$response" = "302" ]; then 82 + echo "FOUND|$endpoint|$path|$response" 83 + elif [ "$response" != "403" ] && [ "$response" != "401" ]; then 84 + echo "MAYBE|$endpoint|$path|$response" 85 + fi 86 + fi 87 + done 88 + } 89 + 90 + export -f scan_endpoint 91 + 92 + # Fetch active PDS endpoints 93 + echo -e "${YELLOW}Fetching active PDS endpoints...${NC}" 94 + ENDPOINTS=$(curl -s "${API_HOST}/api/v1/pds?status=online&limit=10000" | \ 95 + jq -r '.[].endpoint' 2>/dev/null) 96 + 97 + if [ -z "$ENDPOINTS" ]; then 98 + echo -e "${RED}Error: Could not fetch endpoints from API${NC}" 99 + echo "Check that the API is running at: $API_HOST" 100 + exit 1 101 + fi 102 + 103 + ENDPOINT_COUNT=$(echo "$ENDPOINTS" | wc -l | tr -d ' ') 104 + echo -e "${GREEN}✓ Found ${ENDPOINT_COUNT} active PDS endpoints${NC}" 105 + echo "" 106 + 107 + # Write header to results file 108 + { 109 + echo "PDS Security Scan Results" 110 + echo "=========================" 111 + echo "Scan started: $(date)" 112 + echo "Endpoints scanned: ${ENDPOINT_COUNT}" 113 + echo "Paths checked: ${#PATHS[@]}" 114 + echo "Parallel jobs: ${PARALLEL_JOBS}" 115 + echo "" 116 + echo "Results:" 117 + echo "--------" 118 + } > "$RESULTS_FILE" 119 + 120 + # Run parallel scan 121 + echo -e "${YELLOW}Starting parallel scan...${NC}" 122 + echo -e "${BLUE}(This may take a few minutes depending on endpoint count)${NC}" 123 + echo "" 124 + 125 + echo "$ENDPOINTS" | \ 126 + parallel \ 127 + -j "$PARALLEL_JOBS" \ 128 + --bar \ 129 + --joblog "${OUTPUT_DIR}/joblog_${TIMESTAMP}.txt" \ 130 + scan_endpoint {} "$TIMEOUT" "${PATHS[@]}" \ 131 + >> "$RESULTS_FILE" 132 + 133 + echo "" 134 + echo -e "${YELLOW}Processing results...${NC}" 135 + 136 + # Count results 137 + FOUND_COUNT=$(grep -c "^FOUND|" "$RESULTS_FILE" 2>/dev/null || echo 0) 138 + MAYBE_COUNT=$(grep -c "^MAYBE|" "$RESULTS_FILE" 2>/dev/null || echo 0) 139 + 140 + # Extract found URLs to separate file 141 + { 142 + echo "Found URLs (HTTP 200/301/302)" 143 + echo "==============================" 144 + echo "Scan: $(date)" 145 + echo "" 146 + } > "$FOUND_FILE" 147 + 148 + grep "^FOUND|" "$RESULTS_FILE" 2>/dev/null | while IFS='|' read -r status endpoint path code; do 149 + echo "$endpoint$path [$code]" 150 + done >> "$FOUND_FILE" 151 + 152 + # Create summary at end of results file 153 + { 154 + echo "" 155 + echo "Summary" 156 + echo "=======" 157 + echo "Scan completed: $(date)" 158 + echo "Total endpoints scanned: ${ENDPOINT_COUNT}" 159 + echo "Total paths checked: $((ENDPOINT_COUNT * ${#PATHS[@]}))" 160 + echo "Found (200/301/302): ${FOUND_COUNT}" 161 + echo "Maybe (other codes): ${MAYBE_COUNT}" 162 + } >> "$RESULTS_FILE" 163 + 164 + # Display summary 165 + echo "" 166 + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" 167 + echo -e "${BLUE}║ Scan Complete! ║${NC}" 168 + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" 169 + echo "" 170 + echo -e "Endpoints scanned: ${GREEN}${ENDPOINT_COUNT}${NC}" 171 + echo -e "Paths checked per site: ${BLUE}${#PATHS[@]}${NC}" 172 + echo -e "Total requests made: ${BLUE}$((ENDPOINT_COUNT * ${#PATHS[@]}))${NC}" 173 + echo "" 174 + echo -e "Results:" 175 + echo -e " ${GREEN}✓ Found (200/301/302):${NC} ${FOUND_COUNT}" 176 + echo -e " ${YELLOW}? Maybe (other):${NC} ${MAYBE_COUNT}" 177 + echo "" 178 + echo "Files created:" 179 + echo " Full results: $RESULTS_FILE" 180 + echo " Found URLs: $FOUND_FILE" 181 + echo " Job log: ${OUTPUT_DIR}/joblog_${TIMESTAMP}.txt" 182 + 183 + # Show sample of found URLs if any 184 + if [ "$FOUND_COUNT" -gt 0 ]; then 185 + echo "" 186 + echo -e "${RED}⚠ SECURITY ALERT: Found exposed paths!${NC}" 187 + echo "" 188 + echo "Sample findings (first 10):" 189 + grep "^FOUND|" "$RESULTS_FILE" 2>/dev/null | head -10 | while IFS='|' read -r status endpoint path code; do 190 + echo -e " ${RED}✗${NC} $endpoint${RED}$path${NC} [$code]" 191 + done 192 + 193 + if [ "$FOUND_COUNT" -gt 10 ]; then 194 + echo "" 195 + echo " ... and $((FOUND_COUNT - 10)) more (see $FOUND_FILE)" 196 + fi 197 + fi 198 + 199 + echo ""
+117
utils/vuln-scanner.sh
··· 1 + #!/bin/bash 2 + 3 + # Configuration 4 + API_HOST="${API_HOST:-http://localhost:8080}" 5 + TIMEOUT=5 6 + OUTPUT_DIR="./pds_scan_results" 7 + TIMESTAMP=$(date +%Y%m%d_%H%M%S) 8 + RESULTS_FILE="${OUTPUT_DIR}/scan_${TIMESTAMP}.txt" 9 + FOUND_FILE="${OUTPUT_DIR}/found_${TIMESTAMP}.txt" 10 + 11 + # Paths to check (one per line for easier editing) 12 + PATHS=( 13 + "/info.php" 14 + "/phpinfo.php" 15 + "/test.php" 16 + "/admin" 17 + "/admin.php" 18 + "/wp-admin" 19 + "/robots.txt" 20 + "/.env" 21 + "/.git/config" 22 + "/config.php" 23 + "/backup" 24 + "/db.sql" 25 + "/.DS_Store" 26 + "/server-status" 27 + "/.well-known/security.txt" 28 + ) 29 + 30 + # Colors 31 + RED='\033[0;31m' 32 + GREEN='\033[0;32m' 33 + YELLOW='\033[1;33m' 34 + BLUE='\033[0;34m' 35 + NC='\033[0m' 36 + 37 + mkdir -p "$OUTPUT_DIR" 38 + 39 + echo -e "${BLUE}=== PDS Security Scanner ===${NC}" 40 + echo "API Host: $API_HOST" 41 + echo "Timeout: ${TIMEOUT}s" 42 + echo "Scanning for ${#PATHS[@]} paths" 43 + echo "Results: $RESULTS_FILE" 44 + echo "" 45 + 46 + # Fetch active PDS endpoints 47 + echo -e "${YELLOW}Fetching active PDS endpoints...${NC}" 48 + ENDPOINTS=$(curl -s "${API_HOST}/api/v1/pds?status=online&limit=10000" | \ 49 + jq -r '.[].endpoint' 2>/dev/null) 50 + 51 + if [ -z "$ENDPOINTS" ]; then 52 + echo -e "${RED}Error: Could not fetch endpoints from API${NC}" 53 + exit 1 54 + fi 55 + 56 + ENDPOINT_COUNT=$(echo "$ENDPOINTS" | wc -l) 57 + echo -e "${GREEN}Found ${ENDPOINT_COUNT} active PDS endpoints${NC}" 58 + echo "" 59 + 60 + # Write header 61 + echo "PDS Security Scan - $(date)" > "$RESULTS_FILE" 62 + echo "========================================" >> "$RESULTS_FILE" 63 + echo "" >> "$RESULTS_FILE" 64 + 65 + # Counters 66 + CURRENT=0 67 + TOTAL_FOUND=0 68 + TOTAL_MAYBE=0 69 + 70 + # Scan each endpoint sequentially 71 + while IFS= read -r endpoint; do 72 + CURRENT=$((CURRENT + 1)) 73 + 74 + echo -e "${BLUE}[$CURRENT/$ENDPOINT_COUNT]${NC} Scanning: $endpoint" 75 + 76 + # Scan each path 77 + for path in "${PATHS[@]}"; do 78 + url="${endpoint}${path}" 79 + 80 + # Make request with timeout 81 + response=$(curl -s -o /dev/null -w "%{http_code}" \ 82 + --max-time "$TIMEOUT" \ 83 + --connect-timeout "$TIMEOUT" \ 84 + -L \ 85 + -A "Mozilla/5.0 (Security Scanner)" \ 86 + "$url" 2>/dev/null) 87 + 88 + # Check response 89 + if [ -n "$response" ] && [ "$response" != "404" ] && [ "$response" != "000" ]; then 90 + if [ "$response" = "200" ] || [ "$response" = "301" ] || [ "$response" = "302" ]; then 91 + echo -e " ${GREEN}✓ FOUND${NC} $path ${YELLOW}[$response]${NC}" 92 + echo "FOUND: $endpoint$path [$response]" >> "$RESULTS_FILE" 93 + echo "$endpoint$path" >> "$FOUND_FILE" 94 + TOTAL_FOUND=$((TOTAL_FOUND + 1)) 95 + elif [ "$response" != "403" ]; then 96 + echo -e " ${YELLOW}? MAYBE${NC} $path ${YELLOW}[$response]${NC}" 97 + echo "MAYBE: $endpoint$path [$response]" >> "$RESULTS_FILE" 98 + TOTAL_MAYBE=$((TOTAL_MAYBE + 1)) 99 + fi 100 + fi 101 + done 102 + 103 + echo "" >> "$RESULTS_FILE" 104 + 105 + done <<< "$ENDPOINTS" 106 + 107 + # Summary 108 + echo "" 109 + echo -e "${BLUE}========================================${NC}" 110 + echo -e "${GREEN}Scan Complete!${NC}" 111 + echo "Scanned: ${ENDPOINT_COUNT} endpoints" 112 + echo "Paths checked per endpoint: ${#PATHS[@]}" 113 + echo -e "${GREEN}Found (200/301/302): ${TOTAL_FOUND}${NC}" 114 + echo -e "${YELLOW}Maybe (other codes): ${TOTAL_MAYBE}${NC}" 115 + echo "" 116 + echo "Full results: $RESULTS_FILE" 117 + [ -f "$FOUND_FILE" ] && echo "Found URLs: $FOUND_FILE"