Initial commit: RouteWatch BGP stream monitor
- Connects to RIPE RIS Live stream to receive real-time BGP updates - Stores BGP data in SQLite database: - ASNs with first/last seen timestamps - Prefixes with IPv4/IPv6 classification - BGP announcements and withdrawals - AS-to-AS peering relationships from AS paths - Live routing table tracking active routes - HTTP server with statistics endpoints - Metrics tracking with go-metrics - Custom JSON unmarshaling to handle nested AS sets in paths - Dependency injection with uber/fx - Pure Go implementation (no CGO) - Includes streamdumper utility for debugging raw messages
This commit is contained in:
commit
92f7527cc5
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
/bin/
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# env file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
92
.golangci.yml
Normal file
92
.golangci.yml
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
go: "1.24"
|
||||||
|
tests: false
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
# Additional linters requested
|
||||||
|
- testifylint # Checks usage of github.com/stretchr/testify
|
||||||
|
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
||||||
|
# - tagliatelle # Disabled: we need snake_case for external API compatibility
|
||||||
|
- nlreturn # nlreturn checks for a new line before return and branch statements
|
||||||
|
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
||||||
|
- nestif # Reports deeply nested if statements
|
||||||
|
- mnd # An analyzer to detect magic numbers
|
||||||
|
- lll # Reports long lines
|
||||||
|
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
||||||
|
- gochecknoglobals # Check that no global variables exist
|
||||||
|
|
||||||
|
# Default/existing linters that are commonly useful
|
||||||
|
- govet
|
||||||
|
- errcheck
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- revive
|
||||||
|
- gosec
|
||||||
|
- unconvert
|
||||||
|
- unparam
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
lll:
|
||||||
|
line-length: 120
|
||||||
|
|
||||||
|
nestif:
|
||||||
|
min-complexity: 4
|
||||||
|
|
||||||
|
nlreturn:
|
||||||
|
block-size: 2
|
||||||
|
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: var-naming
|
||||||
|
arguments:
|
||||||
|
- []
|
||||||
|
- []
|
||||||
|
- "upperCaseConst=true"
|
||||||
|
|
||||||
|
tagliatelle:
|
||||||
|
case:
|
||||||
|
rules:
|
||||||
|
json: snake
|
||||||
|
yaml: snake
|
||||||
|
xml: snake
|
||||||
|
bson: snake
|
||||||
|
|
||||||
|
testifylint:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
usetesting: {}
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
exclude-rules:
|
||||||
|
# Exclude unused parameter warnings for cobra command signatures
|
||||||
|
- text: "parameter '(args|cmd)' seems to be unused"
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Allow ALL_CAPS constant names
|
||||||
|
- text: "don't use ALL_CAPS in Go names"
|
||||||
|
linters:
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Allow snake_case JSON tags for external API compatibility
|
||||||
|
- path: "internal/types/ris.go"
|
||||||
|
linters:
|
||||||
|
- tagliatelle
|
||||||
|
|
||||||
|
# Allow snake_case JSON tags for database models
|
||||||
|
- path: "internal/database/models.go"
|
||||||
|
linters:
|
||||||
|
- tagliatelle
|
||||||
|
|
||||||
|
# Allow generic package name for types that define data structures
|
||||||
|
- path: "internal/types/"
|
||||||
|
text: "avoid meaningless package names"
|
||||||
|
linters:
|
||||||
|
- revive
|
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# IMPORTANT RULES
|
||||||
|
|
||||||
|
* Claude is an inanimate tool. The spam that Claude attempts to insert into
|
||||||
|
commit messages (which it erroneously refers to as "attribution") is not
|
||||||
|
attribution, as I am the sole author of code created using Claude. It is
|
||||||
|
corporate advertising for Anthropic and is therefore completely
|
||||||
|
unacceptable in commit messages.
|
||||||
|
|
||||||
|
* Tests should always be run before committing code. No commits should be
|
||||||
|
made that do not pass tests.
|
||||||
|
|
||||||
|
* Code should always be formatted before committing. Do not commit
|
||||||
|
unformatted code.
|
||||||
|
|
||||||
|
* Code should always be linted and linter errors fixed before committing.
|
||||||
|
NEVER commit code that does not pass the linter. DO NOT modify the linter
|
||||||
|
config unless specifically instructed.
|
||||||
|
|
||||||
|
* The test suite is fast and local. When running tests, NEVER run
|
||||||
|
individual parts of the test suite, always run the whole thing by running
|
||||||
|
"make test".
|
||||||
|
|
||||||
|
* Do not stop working on a task until you have reached the definition of
|
||||||
|
done provided to you in the initial instruction. Don't do part or most of
|
||||||
|
the work, do all of the work until the criteria for done are met.
|
||||||
|
|
||||||
|
* When you complete each task, if the tests are passing and the code is
|
||||||
|
formatted and there are no linter errors, always commit and push your
|
||||||
|
work. Use a good commit message and don't mention any author or co-author
|
||||||
|
attribution.
|
||||||
|
|
||||||
|
* Do not create additional files in the root directory of the project
|
||||||
|
without asking permission first. Configuration files, documentation, and
|
||||||
|
build files are acceptable in the root, but source code and other files
|
||||||
|
should be organized in appropriate subdirectories.
|
||||||
|
|
||||||
|
* Do not use bare strings or numbers in code, especially if they appear
|
||||||
|
anywhere more than once. Always define a constant (usually at the top of
|
||||||
|
the file) and give it a descriptive name, then use that constant in the
|
||||||
|
code instead of the bare string or number.
|
||||||
|
|
||||||
|
* If you are fixing a bug, write a test first that reproduces the bug and
|
||||||
|
fails, and then fix the bug in the code, using the test to verify that the
|
||||||
|
fix worked.
|
||||||
|
|
||||||
|
* When implementing new features, be aware of potential side-effects (such
|
||||||
|
as state files on disk, data in the database, etc.) and ensure that it is
|
||||||
|
possible to mock or stub these side-effects in tests when designing an
|
||||||
|
API.
|
||||||
|
|
||||||
|
* When dealing with dates and times or timestamps, always use, display, and
|
||||||
|
store UTC. Set the local timezone to UTC on startup. If the user needs
|
||||||
|
to see the time in a different timezone, store the user's timezone in a
|
||||||
|
separate field and convert the UTC time to the user's timezone when
|
||||||
|
displaying it. For internal use and internal applications and
|
||||||
|
administrative purposes, always display UTC.
|
||||||
|
|
||||||
|
* When implementing programs, put the main.go in
|
||||||
|
./cmd/<program_name>/main.go and put the program's code in
|
||||||
|
./internal/<program_name>/. This allows for multiple programs to be
|
||||||
|
implemented in the same repository without cluttering the root directory.
|
||||||
|
main.go should simply import and call <program_name>.CLIEntry(). The
|
||||||
|
full implementation should be in ./internal/<program_name>/.
|
||||||
|
|
||||||
|
* When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
|
tests, or change the tests specifically to make them pass (unless there
|
||||||
|
is a bug in the test). This is cheating, and it is bad. You should only
|
||||||
|
be modifying the test if it is incorrect or if the test is no longer
|
||||||
|
relevant. In almost all cases, you should be fixing the code that is
|
||||||
|
being tested, or updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
|
* Always write a `Makefile` with the default target being `test`, and with a
|
||||||
|
`fmt` target that formats the code. The `test` target should run all
|
||||||
|
tests in the project, and the `fmt` target should format the code. `test`
|
||||||
|
should also have a prerequisite target `lint` that should run any linters
|
||||||
|
that are configured for the project.
|
||||||
|
|
||||||
|
* After each completed bugfix or feature, the code must be committed. Do
|
||||||
|
all of the pre-commit checks (test, lint, fmt) before committing, of
|
||||||
|
course. After each commit, push to the remote.
|
||||||
|
|
||||||
|
* Always write tests, even if they are extremely simple and just check for
|
||||||
|
correct syntax (ability to compile/import). If you are writing a new
|
||||||
|
feature, write a test for it. You don't need to target complete coverage,
|
||||||
|
but you should at least test any new functionality you add.
|
||||||
|
|
||||||
|
* Always use structured logging. Log any relevant state/context with the
|
||||||
|
messages (but do not log secrets). If stdout is not a terminal, output
|
||||||
|
the structured logs in jsonl format. Use go's log/slog.
|
||||||
|
|
||||||
|
* You do not need to summarize your changes in the chat after making them.
|
||||||
|
Making the changes and committing them is sufficient. If anything out of
|
||||||
|
the ordinary happened, please explain it, but in the normal case where you
|
||||||
|
found and fixed the bug, or implemented the feature, there is no need for
|
||||||
|
the end-of-change summary.
|
||||||
|
|
||||||
|
* When testing daemons, use a 15 second timeout always.
|
24
Makefile
Normal file
24
Makefile
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export DEBUG = routewatch
|
||||||
|
|
||||||
|
.PHONY: test fmt lint build clean run
|
||||||
|
|
||||||
|
all: test
|
||||||
|
|
||||||
|
test: lint
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
go vet ./...
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/routewatch cmd/routewatch/main.go
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./bin/routewatch
|
10
cmd/routewatch/main.go
Normal file
10
cmd/routewatch/main.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Package main provides the entry point for the routewatch daemon.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routewatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
routewatch.CLIEntry()
|
||||||
|
}
|
55
cmd/streamdumper/main.go
Normal file
55
cmd/streamdumper/main.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// Package main provides a utility to dump raw RIS Live stream messages to stdout
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Set up logger to only show errors
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelError,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create metrics tracker
|
||||||
|
metricsTracker := metrics.New()
|
||||||
|
|
||||||
|
// Create streamer
|
||||||
|
s := streamer.New(logger, metricsTracker)
|
||||||
|
|
||||||
|
// Register raw message handler that prints to stdout
|
||||||
|
s.RegisterRawHandler(func(line string) {
|
||||||
|
fmt.Println(line)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up context with cancellation
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Handle signals
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
log.Println("Received shutdown signal")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start streaming
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
log.Fatal("Failed to start streamer:", err)
|
||||||
|
}
|
||||||
|
defer s.Stop()
|
||||||
|
|
||||||
|
// Wait for context cancellation
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
1000
docs/message-examples.json
Normal file
1000
docs/message-examples.json
Normal file
File diff suppressed because it is too large
Load Diff
26
go.mod
Normal file
26
go.mod
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
module git.eeqj.de/sneak/routewatch
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
go.uber.org/fx v1.24.0
|
||||||
|
modernc.org/sqlite v1.38.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
71
go.sum
Normal file
71
go.sum
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
|
||||||
|
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
|
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
|
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||||
|
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||||
|
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.38.1 h1:jNnIjleVta+DKSAr3TnkKK87EEhjPhBLzi6hvIX9Bas=
|
||||||
|
modernc.org/sqlite v1.38.1/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
462
internal/database/database.go
Normal file
462
internal/database/database.go
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
// Package database provides SQLite storage for BGP routing data including ASNs, prefixes, announcements and peerings.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dbSchema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS asns (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
number INTEGER UNIQUE NOT NULL,
|
||||||
|
first_seen DATETIME NOT NULL,
|
||||||
|
last_seen DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS prefixes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
prefix TEXT UNIQUE NOT NULL,
|
||||||
|
ip_version INTEGER NOT NULL, -- 4 for IPv4, 6 for IPv6
|
||||||
|
first_seen DATETIME NOT NULL,
|
||||||
|
last_seen DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
prefix_id TEXT NOT NULL,
|
||||||
|
asn_id TEXT NOT NULL,
|
||||||
|
origin_asn_id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
next_hop TEXT,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
is_withdrawal BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (prefix_id) REFERENCES prefixes(id),
|
||||||
|
FOREIGN KEY (asn_id) REFERENCES asns(id),
|
||||||
|
FOREIGN KEY (origin_asn_id) REFERENCES asns(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asn_peerings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
from_asn_id TEXT NOT NULL,
|
||||||
|
to_asn_id TEXT NOT NULL,
|
||||||
|
first_seen DATETIME NOT NULL,
|
||||||
|
last_seen DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (from_asn_id) REFERENCES asns(id),
|
||||||
|
FOREIGN KEY (to_asn_id) REFERENCES asns(id),
|
||||||
|
UNIQUE(from_asn_id, to_asn_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Live routing table: current state of announced routes
|
||||||
|
CREATE TABLE IF NOT EXISTS live_routes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
prefix_id TEXT NOT NULL,
|
||||||
|
origin_asn_id TEXT NOT NULL,
|
||||||
|
peer_asn INTEGER NOT NULL,
|
||||||
|
next_hop TEXT,
|
||||||
|
announced_at DATETIME NOT NULL,
|
||||||
|
withdrawn_at DATETIME,
|
||||||
|
FOREIGN KEY (prefix_id) REFERENCES prefixes(id),
|
||||||
|
FOREIGN KEY (origin_asn_id) REFERENCES asns(id),
|
||||||
|
UNIQUE(prefix_id, origin_asn_id, peer_asn)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prefixes_ip_version ON prefixes(ip_version);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prefixes_version_prefix ON prefixes(ip_version, prefix);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_timestamp ON announcements(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_prefix_id ON announcements(prefix_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_asn_id ON announcements(asn_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asn_peerings_from_asn ON asn_peerings(from_asn_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asn_peerings_to_asn ON asn_peerings(to_asn_id);
|
||||||
|
|
||||||
|
-- Indexes for live routes table
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_live_routes_active
|
||||||
|
ON live_routes(prefix_id, origin_asn_id)
|
||||||
|
WHERE withdrawn_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_live_routes_origin
|
||||||
|
ON live_routes(origin_asn_id)
|
||||||
|
WHERE withdrawn_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_live_routes_prefix
|
||||||
|
ON live_routes(prefix_id)
|
||||||
|
WHERE withdrawn_at IS NULL;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database manages the SQLite database connection and operations.
|
||||||
|
type Database struct {
|
||||||
|
db *sql.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds database configuration
|
||||||
|
type Config struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfig provides default database configuration
|
||||||
|
func NewConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Path: "routewatch.db",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new database connection and initializes the schema.
|
||||||
|
func New(logger *slog.Logger) (*Database, error) {
|
||||||
|
config := NewConfig()
|
||||||
|
|
||||||
|
return NewWithConfig(config, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithConfig creates a new database connection with custom configuration
|
||||||
|
func NewWithConfig(config Config, logger *slog.Logger) (*Database, error) {
|
||||||
|
// Add connection parameters for modernc.org/sqlite
|
||||||
|
dsn := fmt.Sprintf("file:%s?_busy_timeout=5000&_journal_mode=WAL", config.Path)
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connection pool parameters
|
||||||
|
db.SetMaxOpenConns(1) // Force serialization since SQLite doesn't handle true concurrency well
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
database := &Database{db: db, logger: logger}
|
||||||
|
|
||||||
|
if err := database.Initialize(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return database, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize creates the database schema if it doesn't exist.
|
||||||
|
func (d *Database) Initialize() error {
|
||||||
|
_, err := d.db.Exec(dbSchema)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (d *Database) Close() error {
|
||||||
|
return d.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateASN retrieves an existing ASN or creates a new one if it doesn't exist.
|
||||||
|
func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error) {
|
||||||
|
tx, err := d.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||||
|
d.logger.Error("Failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var asn ASN
|
||||||
|
var idStr string
|
||||||
|
err = tx.QueryRow("SELECT id, number, first_seen, last_seen FROM asns WHERE number = ?", number).
|
||||||
|
Scan(&idStr, &asn.Number, &asn.FirstSeen, &asn.LastSeen)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// ASN exists, update last_seen
|
||||||
|
asn.ID, _ = uuid.Parse(idStr)
|
||||||
|
_, err = tx.Exec("UPDATE asns SET last_seen = ? WHERE id = ?", timestamp, asn.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
asn.LastSeen = timestamp
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for ASN update", "asn", number, "error", err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &asn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASN doesn't exist, create it
|
||||||
|
asn = ASN{
|
||||||
|
ID: generateUUID(),
|
||||||
|
Number: number,
|
||||||
|
FirstSeen: timestamp,
|
||||||
|
LastSeen: timestamp,
|
||||||
|
}
|
||||||
|
_, err = tx.Exec("INSERT INTO asns (id, number, first_seen, last_seen) VALUES (?, ?, ?, ?)",
|
||||||
|
asn.ID.String(), asn.Number, asn.FirstSeen, asn.LastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for ASN creation", "asn", number, "error", err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &asn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreatePrefix retrieves an existing prefix or creates a new one if it doesn't exist.
|
||||||
|
func (d *Database) GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error) {
|
||||||
|
tx, err := d.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||||
|
d.logger.Error("Failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var p Prefix
|
||||||
|
var idStr string
|
||||||
|
err = tx.QueryRow("SELECT id, prefix, ip_version, first_seen, last_seen FROM prefixes WHERE prefix = ?", prefix).
|
||||||
|
Scan(&idStr, &p.Prefix, &p.IPVersion, &p.FirstSeen, &p.LastSeen)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Prefix exists, update last_seen
|
||||||
|
p.ID, _ = uuid.Parse(idStr)
|
||||||
|
_, err = tx.Exec("UPDATE prefixes SET last_seen = ? WHERE id = ?", timestamp, p.ID.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.LastSeen = timestamp
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for prefix update", "prefix", prefix, "error", err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix doesn't exist, create it
|
||||||
|
p = Prefix{
|
||||||
|
ID: generateUUID(),
|
||||||
|
Prefix: prefix,
|
||||||
|
IPVersion: detectIPVersion(prefix),
|
||||||
|
FirstSeen: timestamp,
|
||||||
|
LastSeen: timestamp,
|
||||||
|
}
|
||||||
|
_, err = tx.Exec("INSERT INTO prefixes (id, prefix, ip_version, first_seen, last_seen) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
p.ID.String(), p.Prefix, p.IPVersion, p.FirstSeen, p.LastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for prefix creation", "prefix", prefix, "error", err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAnnouncement inserts a new BGP announcement or withdrawal into the database.
|
||||||
|
func (d *Database) RecordAnnouncement(announcement *Announcement) error {
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
INSERT INTO announcements (id, prefix_id, asn_id, origin_asn_id, path, next_hop, timestamp, is_withdrawal)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
announcement.ID.String(), announcement.PrefixID.String(),
|
||||||
|
announcement.ASNID.String(), announcement.OriginASNID.String(),
|
||||||
|
announcement.Path, announcement.NextHop, announcement.Timestamp, announcement.IsWithdrawal)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordPeering records a peering relationship between two ASNs.
|
||||||
|
func (d *Database) RecordPeering(fromASNID, toASNID string, timestamp time.Time) error {
|
||||||
|
tx, err := d.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||||
|
d.logger.Error("Failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM asn_peerings WHERE from_asn_id = ? AND to_asn_id = ?)",
|
||||||
|
fromASNID, toASNID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
_, err = tx.Exec("UPDATE asn_peerings SET last_seen = ? WHERE from_asn_id = ? AND to_asn_id = ?",
|
||||||
|
timestamp, fromASNID, toASNID)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO asn_peerings (id, from_asn_id, to_asn_id, first_seen, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
generateUUID().String(), fromASNID, toASNID, timestamp, timestamp)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for peering",
|
||||||
|
"from_asn_id", fromASNID,
|
||||||
|
"to_asn_id", toASNID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLiveRoute updates the live routing table for an announcement
|
||||||
|
func (d *Database) UpdateLiveRoute(
|
||||||
|
prefixID, originASNID uuid.UUID,
|
||||||
|
peerASN int,
|
||||||
|
nextHop string,
|
||||||
|
timestamp time.Time,
|
||||||
|
) error {
|
||||||
|
// Check if route already exists
|
||||||
|
var routeID sql.NullString
|
||||||
|
err := d.db.QueryRow(`
|
||||||
|
SELECT id FROM live_routes
|
||||||
|
WHERE prefix_id = ? AND origin_asn_id = ? AND peer_asn = ? AND withdrawn_at IS NULL`,
|
||||||
|
prefixID.String(), originASNID.String(), peerASN).Scan(&routeID)
|
||||||
|
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if routeID.Valid {
|
||||||
|
// Route exists and is active, update it
|
||||||
|
_, err = d.db.Exec(`
|
||||||
|
UPDATE live_routes
|
||||||
|
SET next_hop = ?, announced_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
nextHop, timestamp, routeID.String)
|
||||||
|
} else {
|
||||||
|
// Either new route or re-announcement of withdrawn route
|
||||||
|
_, err = d.db.Exec(`
|
||||||
|
INSERT OR REPLACE INTO live_routes
|
||||||
|
(id, prefix_id, origin_asn_id, peer_asn, next_hop, announced_at, withdrawn_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
||||||
|
generateUUID().String(), prefixID.String(), originASNID.String(),
|
||||||
|
peerASN, nextHop, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawLiveRoute marks a route as withdrawn in the live routing table
|
||||||
|
func (d *Database) WithdrawLiveRoute(prefixID uuid.UUID, peerASN int, timestamp time.Time) error {
|
||||||
|
_, err := d.db.Exec(`
|
||||||
|
UPDATE live_routes
|
||||||
|
SET withdrawn_at = ?
|
||||||
|
WHERE prefix_id = ? AND peer_asn = ? AND withdrawn_at IS NULL`,
|
||||||
|
timestamp, prefixID.String(), peerASN)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveLiveRoutes returns all currently active routes (not withdrawn)
|
||||||
|
func (d *Database) GetActiveLiveRoutes() ([]LiveRoute, error) {
|
||||||
|
rows, err := d.db.Query(`
|
||||||
|
SELECT id, prefix_id, origin_asn_id, peer_asn, next_hop, announced_at
|
||||||
|
FROM live_routes
|
||||||
|
WHERE withdrawn_at IS NULL
|
||||||
|
ORDER BY announced_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = rows.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var routes []LiveRoute
|
||||||
|
for rows.Next() {
|
||||||
|
var route LiveRoute
|
||||||
|
var idStr, prefixIDStr, originASNIDStr string
|
||||||
|
err := rows.Scan(&idStr, &prefixIDStr, &originASNIDStr,
|
||||||
|
&route.PeerASN, &route.NextHop, &route.AnnouncedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
route.ID, _ = uuid.Parse(idStr)
|
||||||
|
route.PrefixID, _ = uuid.Parse(prefixIDStr)
|
||||||
|
route.OriginASNID, _ = uuid.Parse(originASNIDStr)
|
||||||
|
|
||||||
|
routes = append(routes, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns database statistics
|
||||||
|
func (d *Database) GetStats() (Stats, error) {
|
||||||
|
var stats Stats
|
||||||
|
|
||||||
|
// Count ASNs
|
||||||
|
err := d.db.QueryRow("SELECT COUNT(*) FROM asns").Scan(&stats.ASNs)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count prefixes
|
||||||
|
err = d.db.QueryRow("SELECT COUNT(*) FROM prefixes").Scan(&stats.Prefixes)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count IPv4 and IPv6 prefixes
|
||||||
|
const ipVersionV4 = 4
|
||||||
|
err = d.db.QueryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV4).Scan(&stats.IPv4Prefixes)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipVersionV6 = 6
|
||||||
|
err = d.db.QueryRow("SELECT COUNT(*) FROM prefixes WHERE ip_version = ?", ipVersionV6).Scan(&stats.IPv6Prefixes)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count peerings
|
||||||
|
err = d.db.QueryRow("SELECT COUNT(*) FROM asn_peerings").Scan(&stats.Peerings)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count live routes
|
||||||
|
err = d.db.QueryRow("SELECT COUNT(*) FROM live_routes WHERE withdrawn_at IS NULL").Scan(&stats.LiveRoutes)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
46
internal/database/interface.go
Normal file
46
internal/database/interface.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stats contains database statistics
|
||||||
|
type Stats struct {
|
||||||
|
ASNs int
|
||||||
|
Prefixes int
|
||||||
|
IPv4Prefixes int
|
||||||
|
IPv6Prefixes int
|
||||||
|
Peerings int
|
||||||
|
LiveRoutes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defines the interface for database operations
|
||||||
|
type Store interface {
|
||||||
|
// ASN operations
|
||||||
|
GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
|
||||||
|
|
||||||
|
// Prefix operations
|
||||||
|
GetOrCreatePrefix(prefix string, timestamp time.Time) (*Prefix, error)
|
||||||
|
|
||||||
|
// Announcement operations
|
||||||
|
RecordAnnouncement(announcement *Announcement) error
|
||||||
|
|
||||||
|
// Peering operations
|
||||||
|
RecordPeering(fromASNID, toASNID string, timestamp time.Time) error
|
||||||
|
|
||||||
|
// Live route operations
|
||||||
|
UpdateLiveRoute(prefixID, originASNID uuid.UUID, peerASN int, nextHop string, timestamp time.Time) error
|
||||||
|
WithdrawLiveRoute(prefixID uuid.UUID, peerASN int, timestamp time.Time) error
|
||||||
|
GetActiveLiveRoutes() ([]LiveRoute, error)
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
GetStats() (Stats, error)
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Database implements Store
|
||||||
|
var _ Store = (*Database)(nil)
|
57
internal/database/models.go
Normal file
57
internal/database/models.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ASN represents an Autonomous System Number
|
||||||
|
type ASN struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Number int `json:"number"`
|
||||||
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix represents an IP prefix (CIDR block)
|
||||||
|
type Prefix struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
IPVersion int `json:"ip_version"` // 4 or 6
|
||||||
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announcement represents a BGP announcement
|
||||||
|
type Announcement struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
PrefixID uuid.UUID `json:"prefix_id"`
|
||||||
|
ASNID uuid.UUID `json:"asn_id"`
|
||||||
|
OriginASNID uuid.UUID `json:"origin_asn_id"`
|
||||||
|
Path string `json:"path"` // JSON-encoded AS path
|
||||||
|
NextHop string `json:"next_hop"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
IsWithdrawal bool `json:"is_withdrawal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASNPeering represents a peering relationship between two ASNs
|
||||||
|
type ASNPeering struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FromASNID uuid.UUID `json:"from_asn_id"`
|
||||||
|
ToASNID uuid.UUID `json:"to_asn_id"`
|
||||||
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveRoute represents the current state of a route in the live routing table
|
||||||
|
type LiveRoute struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
PrefixID uuid.UUID `json:"prefix_id"`
|
||||||
|
OriginASNID uuid.UUID `json:"origin_asn_id"`
|
||||||
|
PeerASN int `json:"peer_asn"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
NextHop string `json:"next_hop"`
|
||||||
|
AnnouncedAt time.Time `json:"announced_at"`
|
||||||
|
WithdrawnAt *time.Time `json:"withdrawn_at"`
|
||||||
|
}
|
25
internal/database/utils.go
Normal file
25
internal/database/utils.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateUUID() uuid.UUID {
|
||||||
|
return uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipVersionV4 = 4
|
||||||
|
ipVersionV6 = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectIPVersion determines if a prefix is IPv4 (returns 4) or IPv6 (returns 6)
|
||||||
|
func detectIPVersion(prefix string) int {
|
||||||
|
if strings.Contains(prefix, ":") {
|
||||||
|
return ipVersionV6
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipVersionV4
|
||||||
|
}
|
100
internal/metrics/metrics.go
Normal file
100
internal/metrics/metrics.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// Package metrics provides centralized metrics tracking for the RouteWatch application
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcrowley/go-metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tracker provides centralized metrics tracking
|
||||||
|
type Tracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
registry metrics.Registry
|
||||||
|
connectedSince time.Time
|
||||||
|
isConnected atomic.Bool
|
||||||
|
|
||||||
|
// Stream metrics
|
||||||
|
messageCounter metrics.Counter
|
||||||
|
byteCounter metrics.Counter
|
||||||
|
messageRate metrics.Meter
|
||||||
|
byteRate metrics.Meter
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new metrics tracker
|
||||||
|
func New() *Tracker {
|
||||||
|
registry := metrics.NewRegistry()
|
||||||
|
|
||||||
|
return &Tracker{
|
||||||
|
registry: registry,
|
||||||
|
messageCounter: metrics.NewCounter(),
|
||||||
|
byteCounter: metrics.NewCounter(),
|
||||||
|
messageRate: metrics.NewMeter(),
|
||||||
|
byteRate: metrics.NewMeter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConnected updates the connection status
|
||||||
|
func (t *Tracker) SetConnected(connected bool) {
|
||||||
|
t.isConnected.Store(connected)
|
||||||
|
if connected {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.connectedSince = time.Now()
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected returns the current connection status
|
||||||
|
func (t *Tracker) IsConnected() bool {
|
||||||
|
return t.isConnected.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordMessage records a received message and its size
|
||||||
|
func (t *Tracker) RecordMessage(bytes int64) {
|
||||||
|
t.messageCounter.Inc(1)
|
||||||
|
t.byteCounter.Inc(bytes)
|
||||||
|
t.messageRate.Mark(1)
|
||||||
|
t.byteRate.Mark(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamMetrics returns current streaming metrics
|
||||||
|
func (t *Tracker) GetStreamMetrics() StreamMetrics {
|
||||||
|
t.mu.RLock()
|
||||||
|
connectedSince := t.connectedSince
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
const bitsPerByte = 8
|
||||||
|
|
||||||
|
// Safely convert counters to uint64
|
||||||
|
msgCount := t.messageCounter.Count()
|
||||||
|
byteCount := t.byteCounter.Count()
|
||||||
|
|
||||||
|
var totalMessages, totalBytes uint64
|
||||||
|
if msgCount >= 0 {
|
||||||
|
totalMessages = uint64(msgCount)
|
||||||
|
}
|
||||||
|
if byteCount >= 0 {
|
||||||
|
totalBytes = uint64(byteCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamMetrics{
|
||||||
|
TotalMessages: totalMessages,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
ConnectedSince: connectedSince,
|
||||||
|
Connected: t.isConnected.Load(),
|
||||||
|
MessagesPerSec: t.messageRate.Rate1(),
|
||||||
|
BitsPerSec: t.byteRate.Rate1() * bitsPerByte,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamMetrics contains streaming statistics
|
||||||
|
type StreamMetrics struct {
|
||||||
|
TotalMessages uint64
|
||||||
|
TotalBytes uint64
|
||||||
|
ConnectedSince time.Time
|
||||||
|
Connected bool
|
||||||
|
MessagesPerSec float64
|
||||||
|
BitsPerSec float64
|
||||||
|
}
|
81
internal/ristypes/ris.go
Normal file
81
internal/ristypes/ris.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// Package ristypes defines the data structures for RIS Live BGP messages and announcements.
|
||||||
|
package ristypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ASPath represents an AS path that may contain nested AS sets
|
||||||
|
type ASPath []int
|
||||||
|
|
||||||
|
// UnmarshalJSON implements custom JSON unmarshaling to flatten nested arrays
|
||||||
|
func (p *ASPath) UnmarshalJSON(data []byte) error {
|
||||||
|
// First try to unmarshal as a simple array of integers
|
||||||
|
var simple []int
|
||||||
|
if err := json.Unmarshal(data, &simple); err == nil {
|
||||||
|
*p = ASPath(simple)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If that fails, unmarshal as array of interfaces and flatten
|
||||||
|
var raw []interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the array
|
||||||
|
result := make([]int, 0)
|
||||||
|
for _, item := range raw {
|
||||||
|
switch v := item.(type) {
|
||||||
|
case float64:
|
||||||
|
result = append(result, int(v))
|
||||||
|
case []interface{}:
|
||||||
|
// Nested array - flatten it
|
||||||
|
for _, nested := range v {
|
||||||
|
if num, ok := nested.(float64); ok {
|
||||||
|
result = append(result, int(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*p = ASPath(result)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RISLiveMessage represents the outer wrapper from the RIS Live stream
|
||||||
|
type RISLiveMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data RISMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RISMessage represents a message from the RIS Live stream
|
||||||
|
type RISMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Timestamp float64 `json:"timestamp"`
|
||||||
|
ParsedTimestamp time.Time `json:"-"` // Parsed from Timestamp field
|
||||||
|
Peer string `json:"peer"`
|
||||||
|
PeerASN string `json:"peer_asn"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
RRC string `json:"rrc,omitempty"`
|
||||||
|
MrtTime float64 `json:"mrt_time,omitempty"`
|
||||||
|
SocketTime float64 `json:"socket_time,omitempty"`
|
||||||
|
Path ASPath `json:"path,omitempty"`
|
||||||
|
Community [][]int `json:"community,omitempty"`
|
||||||
|
Origin string `json:"origin,omitempty"`
|
||||||
|
MED *int `json:"med,omitempty"`
|
||||||
|
LocalPref *int `json:"local_pref,omitempty"`
|
||||||
|
Announcements []RISAnnouncement `json:"announcements,omitempty"`
|
||||||
|
Withdrawals []string `json:"withdrawals,omitempty"`
|
||||||
|
Raw string `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RISAnnouncement represents announcement data within a RIS message
|
||||||
|
type RISAnnouncement struct {
|
||||||
|
NextHop string `json:"next_hop"`
|
||||||
|
Prefixes []string `json:"prefixes"`
|
||||||
|
}
|
158
internal/routewatch/app.go
Normal file
158
internal/routewatch/app.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
// Package routewatch contains the primary RouteWatch type that represents a running instance
|
||||||
|
// of the application and contains pointers to its core dependencies, and is responsible for initialization.
|
||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/server"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config contains runtime configuration for RouteWatch
|
||||||
|
type Config struct {
|
||||||
|
MaxRuntime time.Duration // Maximum runtime (0 = run forever)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfig provides default configuration
|
||||||
|
func NewConfig() Config {
|
||||||
|
return Config{
|
||||||
|
MaxRuntime: 0, // Run forever by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies contains all dependencies for RouteWatch
|
||||||
|
type Dependencies struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
DB database.Store
|
||||||
|
Streamer *streamer.Streamer
|
||||||
|
Server *server.Server
|
||||||
|
Logger *slog.Logger
|
||||||
|
Config Config `optional:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteWatch represents the main application instance
|
||||||
|
type RouteWatch struct {
|
||||||
|
db database.Store
|
||||||
|
streamer *streamer.Streamer
|
||||||
|
server *server.Server
|
||||||
|
logger *slog.Logger
|
||||||
|
maxRuntime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new RouteWatch instance
|
||||||
|
func New(deps Dependencies) *RouteWatch {
|
||||||
|
return &RouteWatch{
|
||||||
|
db: deps.DB,
|
||||||
|
streamer: deps.Streamer,
|
||||||
|
server: deps.Server,
|
||||||
|
logger: deps.Logger,
|
||||||
|
maxRuntime: deps.Config.MaxRuntime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the RouteWatch application
|
||||||
|
func (rw *RouteWatch) Run(ctx context.Context) error {
|
||||||
|
rw.logger.Info("Starting RouteWatch")
|
||||||
|
|
||||||
|
// Apply runtime limit if specified
|
||||||
|
if rw.maxRuntime > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, rw.maxRuntime)
|
||||||
|
defer cancel()
|
||||||
|
rw.logger.Info("Running with time limit", "max_runtime", rw.maxRuntime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register database handler to process BGP UPDATE messages
|
||||||
|
dbHandler := NewDatabaseHandler(rw.db, rw.logger)
|
||||||
|
rw.streamer.RegisterHandler(dbHandler)
|
||||||
|
|
||||||
|
// Start streaming
|
||||||
|
if err := rw.streamer.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
if err := rw.server.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for context cancellation
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// Stop services
|
||||||
|
rw.streamer.Stop()
|
||||||
|
|
||||||
|
// Stop HTTP server with a timeout
|
||||||
|
const serverStopTimeout = 5 * time.Second
|
||||||
|
stopCtx, cancel := context.WithTimeout(context.Background(), serverStopTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := rw.server.Stop(stopCtx); err != nil {
|
||||||
|
rw.logger.Error("Failed to stop HTTP server gracefully", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log final metrics
|
||||||
|
metrics := rw.streamer.GetMetrics()
|
||||||
|
rw.logger.Info("Final metrics",
|
||||||
|
"total_messages", metrics.TotalMessages,
|
||||||
|
"total_bytes", metrics.TotalBytes,
|
||||||
|
"messages_per_sec", metrics.MessagesPerSec,
|
||||||
|
"bits_per_sec", metrics.BitsPerSec,
|
||||||
|
"duration", time.Since(metrics.ConnectedSince),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a structured logger
|
||||||
|
func NewLogger() *slog.Logger {
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if debug := os.Getenv("DEBUG"); strings.Contains(debug, "routewatch") {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler slog.Handler
|
||||||
|
if os.Stdout.Name() != "/dev/stdout" || os.Getenv("TERM") == "" {
|
||||||
|
// Not a terminal, use JSON
|
||||||
|
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||||
|
} else {
|
||||||
|
// Terminal, use text
|
||||||
|
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return slog.New(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getModule provides all fx dependencies
|
||||||
|
func getModule() fx.Option {
|
||||||
|
return fx.Options(
|
||||||
|
fx.Provide(
|
||||||
|
NewLogger,
|
||||||
|
NewConfig,
|
||||||
|
metrics.New,
|
||||||
|
database.New,
|
||||||
|
fx.Annotate(
|
||||||
|
func(db *database.Database) database.Store {
|
||||||
|
return db
|
||||||
|
},
|
||||||
|
fx.As(new(database.Store)),
|
||||||
|
),
|
||||||
|
streamer.New,
|
||||||
|
server.New,
|
||||||
|
New,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
243
internal/routewatch/app_integration_test.go
Normal file
243
internal/routewatch/app_integration_test.go
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/server"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockStore is a mock implementation of database.Store for testing
|
||||||
|
type mockStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Counters for tracking calls
|
||||||
|
ASNCount int
|
||||||
|
PrefixCount int
|
||||||
|
PeeringCount int
|
||||||
|
RouteCount int
|
||||||
|
WithdrawalCount int
|
||||||
|
|
||||||
|
// Track unique items
|
||||||
|
ASNs map[int]*database.ASN
|
||||||
|
Prefixes map[string]*database.Prefix
|
||||||
|
Peerings map[string]bool // key is "from_to"
|
||||||
|
Routes map[string]bool // key is "prefix_origin_peer"
|
||||||
|
|
||||||
|
// Track IP versions
|
||||||
|
IPv4Prefixes int
|
||||||
|
IPv6Prefixes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMockStore creates a new mock store
|
||||||
|
func newMockStore() *mockStore {
|
||||||
|
return &mockStore{
|
||||||
|
ASNs: make(map[int]*database.ASN),
|
||||||
|
Prefixes: make(map[string]*database.Prefix),
|
||||||
|
Peerings: make(map[string]bool),
|
||||||
|
Routes: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateASN mock implementation
|
||||||
|
func (m *mockStore) GetOrCreateASN(number int, timestamp time.Time) (*database.ASN, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if asn, exists := m.ASNs[number]; exists {
|
||||||
|
asn.LastSeen = timestamp
|
||||||
|
|
||||||
|
return asn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
asn := &database.ASN{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Number: number,
|
||||||
|
FirstSeen: timestamp,
|
||||||
|
LastSeen: timestamp,
|
||||||
|
}
|
||||||
|
m.ASNs[number] = asn
|
||||||
|
m.ASNCount++
|
||||||
|
|
||||||
|
return asn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreatePrefix mock implementation
|
||||||
|
func (m *mockStore) GetOrCreatePrefix(prefix string, timestamp time.Time) (*database.Prefix, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if p, exists := m.Prefixes[prefix]; exists {
|
||||||
|
p.LastSeen = timestamp
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ipVersionV4 = 4
|
||||||
|
ipVersionV6 = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
ipVersion := ipVersionV4
|
||||||
|
if strings.Contains(prefix, ":") {
|
||||||
|
ipVersion = ipVersionV6
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &database.Prefix{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Prefix: prefix,
|
||||||
|
IPVersion: ipVersion,
|
||||||
|
FirstSeen: timestamp,
|
||||||
|
LastSeen: timestamp,
|
||||||
|
}
|
||||||
|
m.Prefixes[prefix] = p
|
||||||
|
m.PrefixCount++
|
||||||
|
|
||||||
|
if ipVersion == ipVersionV4 {
|
||||||
|
m.IPv4Prefixes++
|
||||||
|
} else {
|
||||||
|
m.IPv6Prefixes++
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAnnouncement mock implementation
|
||||||
|
func (m *mockStore) RecordAnnouncement(_ *database.Announcement) error {
|
||||||
|
// Not tracking announcements in detail for now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordPeering mock implementation
|
||||||
|
func (m *mockStore) RecordPeering(fromASNID, toASNID string, _ time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := fromASNID + "_" + toASNID
|
||||||
|
if !m.Peerings[key] {
|
||||||
|
m.Peerings[key] = true
|
||||||
|
m.PeeringCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLiveRoute mock implementation
|
||||||
|
func (m *mockStore) UpdateLiveRoute(prefixID, originASNID uuid.UUID, peerASN int, _ string, _ time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := prefixID.String() + "_" + originASNID.String() + "_" + string(rune(peerASN))
|
||||||
|
if !m.Routes[key] {
|
||||||
|
m.Routes[key] = true
|
||||||
|
m.RouteCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithdrawLiveRoute mock implementation
|
||||||
|
func (m *mockStore) WithdrawLiveRoute(_ uuid.UUID, _ int, _ time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.WithdrawalCount++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveLiveRoutes mock implementation
|
||||||
|
func (m *mockStore) GetActiveLiveRoutes() ([]database.LiveRoute, error) {
|
||||||
|
return []database.LiveRoute{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close mock implementation
|
||||||
|
func (m *mockStore) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns statistics about the mock store
|
||||||
|
func (m *mockStore) GetStats() (database.Stats, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return database.Stats{
|
||||||
|
ASNs: len(m.ASNs),
|
||||||
|
Prefixes: len(m.Prefixes),
|
||||||
|
IPv4Prefixes: m.IPv4Prefixes,
|
||||||
|
IPv6Prefixes: m.IPv6Prefixes,
|
||||||
|
Peerings: m.PeeringCount,
|
||||||
|
LiveRoutes: m.RouteCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchLiveFeed(t *testing.T) {
|
||||||
|
// Create mock database
|
||||||
|
mockDB := newMockStore()
|
||||||
|
defer mockDB.Close()
|
||||||
|
|
||||||
|
logger := NewLogger()
|
||||||
|
|
||||||
|
// Create metrics tracker
|
||||||
|
metricsTracker := metrics.New()
|
||||||
|
|
||||||
|
// Create streamer
|
||||||
|
s := streamer.New(logger, metricsTracker)
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
srv := server.New(mockDB, s, logger)
|
||||||
|
|
||||||
|
// Create RouteWatch with 5 second limit
|
||||||
|
deps := Dependencies{
|
||||||
|
DB: mockDB,
|
||||||
|
Streamer: s,
|
||||||
|
Server: srv,
|
||||||
|
Logger: logger,
|
||||||
|
Config: Config{
|
||||||
|
MaxRuntime: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rw := New(deps)
|
||||||
|
|
||||||
|
// Run with context
|
||||||
|
ctx := context.Background()
|
||||||
|
go func() {
|
||||||
|
_ = rw.Run(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the configured duration
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
stats, err := mockDB.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get stats: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stats.ASNs == 0 {
|
||||||
|
t.Error("Expected to receive some ASNs from live feed")
|
||||||
|
}
|
||||||
|
t.Logf("Received %d unique ASNs in 5 seconds", stats.ASNs)
|
||||||
|
|
||||||
|
if stats.Prefixes == 0 {
|
||||||
|
t.Error("Expected to receive some prefixes from live feed")
|
||||||
|
}
|
||||||
|
t.Logf("Received %d unique prefixes (%d IPv4, %d IPv6) in 5 seconds", stats.Prefixes, stats.IPv4Prefixes, stats.IPv6Prefixes)
|
||||||
|
|
||||||
|
if stats.Peerings == 0 {
|
||||||
|
t.Error("Expected to receive some peerings from live feed")
|
||||||
|
}
|
||||||
|
t.Logf("Recorded %d AS peering relationships in 5 seconds", stats.Peerings)
|
||||||
|
|
||||||
|
if stats.LiveRoutes == 0 {
|
||||||
|
t.Error("Expected to have some active routes")
|
||||||
|
}
|
||||||
|
t.Logf("Active routes: %d", stats.LiveRoutes)
|
||||||
|
}
|
12
internal/routewatch/app_test.go
Normal file
12
internal/routewatch/app_test.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogger(t *testing.T) {
|
||||||
|
logger := NewLogger()
|
||||||
|
if logger == nil {
|
||||||
|
t.Fatal("NewLogger returned nil")
|
||||||
|
}
|
||||||
|
}
|
51
internal/routewatch/cli.go
Normal file
51
internal/routewatch/cli.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CLIEntry is the main entry point for the CLI
|
||||||
|
func CLIEntry() {
|
||||||
|
app := fx.New(
|
||||||
|
getModule(),
|
||||||
|
fx.Invoke(func(lc fx.Lifecycle, rw *RouteWatch, logger *slog.Logger) {
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(_ context.Context) error {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Handle shutdown signals
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-sigCh
|
||||||
|
logger.Info("Received shutdown signal")
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := rw.Run(ctx); err != nil {
|
||||||
|
logger.Error("RouteWatch error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
logger.Info("Shutting down RouteWatch")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.Run()
|
||||||
|
}
|
144
internal/routewatch/dbhandler.go
Normal file
144
internal/routewatch/dbhandler.go
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DatabaseHandler handles BGP messages and stores them in the database
|
||||||
|
type DatabaseHandler struct {
|
||||||
|
db database.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabaseHandler creates a new database handler
|
||||||
|
func NewDatabaseHandler(db database.Store, logger *slog.Logger) *DatabaseHandler {
|
||||||
|
return &DatabaseHandler{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
||||||
|
func (h *DatabaseHandler) WantsMessage(messageType string) bool {
|
||||||
|
// We only care about UPDATE messages for the database
|
||||||
|
return messageType == "UPDATE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessage processes a RIS message and updates the database
|
||||||
|
func (h *DatabaseHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||||
|
// Use the pre-parsed timestamp
|
||||||
|
timestamp := msg.ParsedTimestamp
|
||||||
|
|
||||||
|
// Parse peer ASN
|
||||||
|
peerASN, err := strconv.Atoi(msg.PeerASN)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to parse peer ASN", "peer_asn", msg.PeerASN, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get origin ASN from path (last element)
|
||||||
|
var originASN int
|
||||||
|
if len(msg.Path) > 0 {
|
||||||
|
originASN = msg.Path[len(msg.Path)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process announcements
|
||||||
|
for _, announcement := range msg.Announcements {
|
||||||
|
for _, prefix := range announcement.Prefixes {
|
||||||
|
// Get or create prefix
|
||||||
|
p, err := h.db.GetOrCreatePrefix(prefix, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get/create prefix", "prefix", prefix, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create origin ASN
|
||||||
|
asn, err := h.db.GetOrCreateASN(originASN, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get/create ASN", "asn", originASN, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live route
|
||||||
|
err = h.db.UpdateLiveRoute(
|
||||||
|
p.ID,
|
||||||
|
asn.ID,
|
||||||
|
peerASN,
|
||||||
|
announcement.NextHop,
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to update live route",
|
||||||
|
"prefix", prefix,
|
||||||
|
"origin_asn", originASN,
|
||||||
|
"peer_asn", peerASN,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Record the announcement in the announcements table
|
||||||
|
// Process AS path to update peerings
|
||||||
|
if len(msg.Path) > 1 {
|
||||||
|
for i := range len(msg.Path) - 1 {
|
||||||
|
fromASN := msg.Path[i]
|
||||||
|
toASN := msg.Path[i+1]
|
||||||
|
|
||||||
|
// Get or create both ASNs
|
||||||
|
fromAS, err := h.db.GetOrCreateASN(fromASN, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get/create from ASN", "asn", fromASN, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toAS, err := h.db.GetOrCreateASN(toASN, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get/create to ASN", "asn", toASN, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the peering
|
||||||
|
err = h.db.RecordPeering(fromAS.ID.String(), toAS.ID.String(), timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to record peering",
|
||||||
|
"from_asn", fromASN,
|
||||||
|
"to_asn", toASN,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process withdrawals
|
||||||
|
for _, prefix := range msg.Withdrawals {
|
||||||
|
// Get prefix
|
||||||
|
p, err := h.db.GetOrCreatePrefix(prefix, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get prefix for withdrawal", "prefix", prefix, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw the route
|
||||||
|
err = h.db.WithdrawLiveRoute(p.ID, peerASN, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to withdraw route",
|
||||||
|
"prefix", prefix,
|
||||||
|
"peer_asn", peerASN,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Record the withdrawal in the withdrawals table
|
||||||
|
}
|
||||||
|
}
|
45
internal/routewatch/handler.go
Normal file
45
internal/routewatch/handler.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SimpleHandler is a basic implementation of streamer.MessageHandler
|
||||||
|
type SimpleHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
messageTypes []string
|
||||||
|
callback func(*ristypes.RISMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSimpleHandler creates a handler that accepts specific message types
|
||||||
|
func NewSimpleHandler(logger *slog.Logger, messageTypes []string, callback func(*ristypes.RISMessage)) *SimpleHandler {
|
||||||
|
return &SimpleHandler{
|
||||||
|
logger: logger,
|
||||||
|
messageTypes: messageTypes,
|
||||||
|
callback: callback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
||||||
|
func (h *SimpleHandler) WantsMessage(messageType string) bool {
|
||||||
|
// If no specific types are set, accept all messages
|
||||||
|
if len(h.messageTypes) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range h.messageTypes {
|
||||||
|
if t == messageType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessage processes a RIS message
|
||||||
|
func (h *SimpleHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||||
|
if h.callback != nil {
|
||||||
|
h.callback(msg)
|
||||||
|
}
|
||||||
|
}
|
416
internal/server/server.go
Normal file
416
internal/server/server.go
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
// Package server provides HTTP endpoints for status monitoring and statistics
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server provides HTTP endpoints for status monitoring
|
||||||
|
type Server struct {
|
||||||
|
router *chi.Mux
|
||||||
|
db database.Store
|
||||||
|
streamer *streamer.Streamer
|
||||||
|
logger *slog.Logger
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new HTTP server
|
||||||
|
func New(db database.Store, streamer *streamer.Streamer, logger *slog.Logger) *Server {
|
||||||
|
s := &Server{
|
||||||
|
db: db,
|
||||||
|
streamer: streamer,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setupRoutes()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRoutes configures the HTTP routes
|
||||||
|
func (s *Server) setupRoutes() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
const requestTimeout = 60 * time.Second
|
||||||
|
r.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
r.Get("/", s.handleRoot())
|
||||||
|
r.Get("/status", s.handleStatusHTML())
|
||||||
|
r.Get("/status.json", s.handleStatusJSON())
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/stats", s.handleStats())
|
||||||
|
})
|
||||||
|
|
||||||
|
s.router = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP server
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
const readHeaderTimeout = 10 * time.Second
|
||||||
|
s.srv = &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: s.router,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Starting HTTP server", "port", port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
s.logger.Error("HTTP server error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the HTTP server
|
||||||
|
func (s *Server) Stop(ctx context.Context) error {
|
||||||
|
if s.srv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Stopping HTTP server")
|
||||||
|
|
||||||
|
return s.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRoot returns a handler that redirects to /status
|
||||||
|
func (s *Server) handleRoot() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatusJSON returns a handler that serves JSON statistics
|
||||||
|
func (s *Server) handleStatusJSON() http.HandlerFunc {
|
||||||
|
// Stats represents the statistics response
|
||||||
|
type Stats struct {
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
TotalMessages uint64 `json:"total_messages"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
MessagesPerSec float64 `json:"messages_per_sec"`
|
||||||
|
MbitsPerSec float64 `json:"mbits_per_sec"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
ASNs int `json:"asns"`
|
||||||
|
Prefixes int `json:"prefixes"`
|
||||||
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||||
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
||||||
|
Peerings int `json:"peerings"`
|
||||||
|
LiveRoutes int `json:"live_routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
metrics := s.streamer.GetMetrics()
|
||||||
|
|
||||||
|
// Get database stats
|
||||||
|
dbStats, err := s.db.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
|
||||||
|
if metrics.ConnectedSince.IsZero() {
|
||||||
|
uptime = "0s"
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitsPerMegabit = 1000000.0
|
||||||
|
|
||||||
|
stats := Stats{
|
||||||
|
Uptime: uptime,
|
||||||
|
TotalMessages: metrics.TotalMessages,
|
||||||
|
TotalBytes: metrics.TotalBytes,
|
||||||
|
MessagesPerSec: metrics.MessagesPerSec,
|
||||||
|
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
|
||||||
|
Connected: metrics.Connected,
|
||||||
|
ASNs: dbStats.ASNs,
|
||||||
|
Prefixes: dbStats.Prefixes,
|
||||||
|
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||||
|
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||||
|
Peerings: dbStats.Peerings,
|
||||||
|
LiveRoutes: dbStats.LiveRoutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||||
|
s.logger.Error("Failed to encode stats", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats returns a handler that serves API v1 statistics
|
||||||
|
func (s *Server) handleStats() http.HandlerFunc {
|
||||||
|
// StatsResponse represents the API statistics response
|
||||||
|
type StatsResponse struct {
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
TotalMessages uint64 `json:"total_messages"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
MessagesPerSec float64 `json:"messages_per_sec"`
|
||||||
|
MbitsPerSec float64 `json:"mbits_per_sec"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
ASNs int `json:"asns"`
|
||||||
|
Prefixes int `json:"prefixes"`
|
||||||
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
||||||
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
||||||
|
Peerings int `json:"peerings"`
|
||||||
|
LiveRoutes int `json:"live_routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
metrics := s.streamer.GetMetrics()
|
||||||
|
|
||||||
|
// Get database stats
|
||||||
|
dbStats, err := s.db.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String()
|
||||||
|
if metrics.ConnectedSince.IsZero() {
|
||||||
|
uptime = "0s"
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitsPerMegabit = 1000000.0
|
||||||
|
|
||||||
|
stats := StatsResponse{
|
||||||
|
Uptime: uptime,
|
||||||
|
TotalMessages: metrics.TotalMessages,
|
||||||
|
TotalBytes: metrics.TotalBytes,
|
||||||
|
MessagesPerSec: metrics.MessagesPerSec,
|
||||||
|
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
|
||||||
|
Connected: metrics.Connected,
|
||||||
|
ASNs: dbStats.ASNs,
|
||||||
|
Prefixes: dbStats.Prefixes,
|
||||||
|
IPv4Prefixes: dbStats.IPv4Prefixes,
|
||||||
|
IPv6Prefixes: dbStats.IPv6Prefixes,
|
||||||
|
Peerings: dbStats.Peerings,
|
||||||
|
LiveRoutes: dbStats.LiveRoutes,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||||
|
s.logger.Error("Failed to encode stats", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatusHTML returns a handler that serves the HTML status page
|
||||||
|
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if _, err := fmt.Fprint(w, statusHTML); err != nil {
|
||||||
|
s.logger.Error("Failed to write HTML", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RouteWatch Status</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.status-card h2 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.metric:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.metric-label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.metric-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.connected {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.disconnected {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RouteWatch Status</h1>
|
||||||
|
<div id="error" class="error" style="display: none;"></div>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Connection Status</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Status</span>
|
||||||
|
<span class="metric-value" id="connected">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Uptime</span>
|
||||||
|
<span class="metric-value" id="uptime">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Stream Statistics</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Messages</span>
|
||||||
|
<span class="metric-value" id="total_messages">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Messages/sec</span>
|
||||||
|
<span class="metric-value" id="messages_per_sec">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Data</span>
|
||||||
|
<span class="metric-value" id="total_bytes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Throughput</span>
|
||||||
|
<span class="metric-value" id="mbits_per_sec">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Database Statistics</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">ASNs</span>
|
||||||
|
<span class="metric-value" id="asns">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Prefixes</span>
|
||||||
|
<span class="metric-value" id="prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv4 Prefixes</span>
|
||||||
|
<span class="metric-value" id="ipv4_prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv6 Prefixes</span>
|
||||||
|
<span class="metric-value" id="ipv6_prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Peerings</span>
|
||||||
|
<span class="metric-value" id="peerings">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Live Routes</span>
|
||||||
|
<span class="metric-value" id="live_routes">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num) {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
fetch('/api/v1/stats')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Connection status
|
||||||
|
const connectedEl = document.getElementById('connected');
|
||||||
|
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
||||||
|
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
||||||
|
|
||||||
|
// Update all metrics
|
||||||
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
|
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
||||||
|
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
||||||
|
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
||||||
|
document.getElementById('mbits_per_sec').textContent = data.mbits_per_sec.toFixed(2) + ' Mbps';
|
||||||
|
document.getElementById('asns').textContent = formatNumber(data.asns);
|
||||||
|
document.getElementById('prefixes').textContent = formatNumber(data.prefixes);
|
||||||
|
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
||||||
|
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
||||||
|
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
||||||
|
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||||
|
|
||||||
|
// Clear any errors
|
||||||
|
document.getElementById('error').style.display = 'none';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
document.getElementById('error').textContent = 'Error fetching status: ' + error;
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update immediately and then every 500ms
|
||||||
|
updateStatus();
|
||||||
|
setInterval(updateStatus, 500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
310
internal/streamer/streamer.go
Normal file
310
internal/streamer/streamer.go
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
// Package streamer implements an HTTP client that connects to the RIPE RIS Live streaming API,
|
||||||
|
// parses BGP UPDATE messages from the JSON stream, and dispatches them to registered handlers.
|
||||||
|
package streamer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
risLiveURL = "https://ris-live.ripe.net/v1/stream/?format=json"
|
||||||
|
metricsWindowSize = 60 // seconds for rolling average
|
||||||
|
metricsUpdateRate = time.Second
|
||||||
|
metricsLogInterval = 10 * time.Second
|
||||||
|
bytesPerKB = 1024
|
||||||
|
bytesPerMB = 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageHandler is an interface for handling RIS messages
|
||||||
|
type MessageHandler interface {
|
||||||
|
// WantsMessage returns true if this handler wants to process messages of the given type
|
||||||
|
WantsMessage(messageType string) bool
|
||||||
|
|
||||||
|
// HandleMessage processes a RIS message
|
||||||
|
HandleMessage(msg *ristypes.RISMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawMessageHandler is a callback for handling raw JSON lines from the stream
|
||||||
|
type RawMessageHandler func(line string)
|
||||||
|
|
||||||
|
// Streamer handles streaming BGP updates from RIS Live
|
||||||
|
type Streamer struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
client *http.Client
|
||||||
|
handlers []MessageHandler
|
||||||
|
rawHandler RawMessageHandler
|
||||||
|
mu sync.RWMutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
running bool
|
||||||
|
metrics *metrics.Tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new RIS streamer
|
||||||
|
func New(logger *slog.Logger, metrics *metrics.Tracker) *Streamer {
|
||||||
|
return &Streamer{
|
||||||
|
logger: logger,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 0, // No timeout for streaming
|
||||||
|
},
|
||||||
|
handlers: make([]MessageHandler, 0),
|
||||||
|
metrics: metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandler adds a callback for message processing
|
||||||
|
func (s *Streamer) RegisterHandler(handler MessageHandler) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.handlers = append(s.handlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRawHandler sets a callback for raw message lines
|
||||||
|
func (s *Streamer) RegisterRawHandler(handler RawMessageHandler) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.rawHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins streaming in a goroutine
|
||||||
|
func (s *Streamer) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.running {
|
||||||
|
return fmt.Errorf("streamer already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
s.running = true
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := s.stream(ctx); err != nil {
|
||||||
|
s.logger.Error("Streaming error", "error", err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts the streaming
|
||||||
|
func (s *Streamer) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.metrics.SetConnected(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns whether the streamer is currently active
|
||||||
|
func (s *Streamer) IsRunning() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
return s.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics returns current streaming metrics
|
||||||
|
func (s *Streamer) GetMetrics() metrics.StreamMetrics {
|
||||||
|
return s.metrics.GetStreamMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
// logMetrics logs the current streaming statistics
|
||||||
|
func (s *Streamer) logMetrics() {
|
||||||
|
metrics := s.metrics.GetStreamMetrics()
|
||||||
|
uptime := time.Since(metrics.ConnectedSince)
|
||||||
|
|
||||||
|
const bitsPerMegabit = 1000000
|
||||||
|
s.logger.Info("Stream statistics",
|
||||||
|
"uptime", uptime,
|
||||||
|
"total_messages", metrics.TotalMessages,
|
||||||
|
"total_bytes", metrics.TotalBytes,
|
||||||
|
"total_mb", fmt.Sprintf("%.2f", float64(metrics.TotalBytes)/bytesPerMB),
|
||||||
|
"messages_per_sec", fmt.Sprintf("%.2f", metrics.MessagesPerSec),
|
||||||
|
"bits_per_sec", fmt.Sprintf("%.0f", metrics.BitsPerSec),
|
||||||
|
"mbps", fmt.Sprintf("%.2f", metrics.BitsPerSec/bitsPerMegabit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMetrics updates the metrics counters and rates
|
||||||
|
func (s *Streamer) updateMetrics(messageBytes int) {
|
||||||
|
s.metrics.RecordMessage(int64(messageBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Streamer) stream(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", risLiveURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to RIS Live: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
s.logger.Error("Failed to close response body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Connected to RIS Live stream")
|
||||||
|
s.metrics.SetConnected(true)
|
||||||
|
|
||||||
|
// Start metrics logging goroutine
|
||||||
|
metricsTicker := time.NewTicker(metricsLogInterval)
|
||||||
|
defer metricsTicker.Stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-metricsTicker.C:
|
||||||
|
s.logMetrics()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.logger.Info("Stream stopped by context")
|
||||||
|
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics with message size
|
||||||
|
s.updateMetrics(len(line))
|
||||||
|
|
||||||
|
// Call raw handler if registered
|
||||||
|
s.mu.RLock()
|
||||||
|
rawHandler := s.rawHandler
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if rawHandler != nil {
|
||||||
|
// Call raw handler synchronously to preserve order
|
||||||
|
rawHandler(string(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current handlers
|
||||||
|
s.mu.RLock()
|
||||||
|
handlers := make([]MessageHandler, len(s.handlers))
|
||||||
|
copy(handlers, s.handlers)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Spawn goroutine to parse and process the message
|
||||||
|
go func(rawLine []byte, messageHandlers []MessageHandler) {
|
||||||
|
|
||||||
|
// Parse the outer wrapper first
|
||||||
|
var wrapper ristypes.RISLiveMessage
|
||||||
|
if err := json.Unmarshal(rawLine, &wrapper); err != nil {
|
||||||
|
// Output the raw line and panic on parse failure
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to parse JSON: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "Raw line: %s\n", string(rawLine))
|
||||||
|
panic(fmt.Sprintf("JSON parse error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a ris_message wrapper
|
||||||
|
if wrapper.Type != "ris_message" {
|
||||||
|
s.logger.Error("Unexpected wrapper type",
|
||||||
|
"type", wrapper.Type,
|
||||||
|
"line", string(rawLine),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual message
|
||||||
|
msg := wrapper.Data
|
||||||
|
|
||||||
|
// Parse the timestamp
|
||||||
|
msg.ParsedTimestamp = time.Unix(int64(msg.Timestamp), 0).UTC()
|
||||||
|
|
||||||
|
// Process based on message type
|
||||||
|
switch msg.Type {
|
||||||
|
case "UPDATE":
|
||||||
|
// Process BGP UPDATE messages
|
||||||
|
// Will be handled by registered handlers
|
||||||
|
case "RIS_PEER_STATE":
|
||||||
|
s.logger.Info("RIS peer state change",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", msg.PeerASN,
|
||||||
|
)
|
||||||
|
case "KEEPALIVE":
|
||||||
|
// BGP keepalive messages - just log at debug level
|
||||||
|
s.logger.Debug("BGP keepalive",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", msg.PeerASN,
|
||||||
|
)
|
||||||
|
case "OPEN":
|
||||||
|
// BGP open messages
|
||||||
|
s.logger.Info("BGP session opened",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", msg.PeerASN,
|
||||||
|
)
|
||||||
|
case "NOTIFICATION":
|
||||||
|
// BGP notification messages (errors)
|
||||||
|
s.logger.Warn("BGP notification",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", msg.PeerASN,
|
||||||
|
)
|
||||||
|
case "STATE":
|
||||||
|
// Peer state changes
|
||||||
|
s.logger.Info("Peer state change",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", msg.PeerASN,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
"UNKNOWN MESSAGE TYPE: %s\nRAW MESSAGE: %s\n",
|
||||||
|
msg.Type,
|
||||||
|
string(rawLine),
|
||||||
|
)
|
||||||
|
panic(fmt.Sprintf("Unknown RIS message type: %s", msg.Type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn goroutine for each handler callback that wants this message type
|
||||||
|
for _, handler := range messageHandlers {
|
||||||
|
if handler.WantsMessage(msg.Type) {
|
||||||
|
go func(h MessageHandler) {
|
||||||
|
h.HandleMessage(&msg)
|
||||||
|
}(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(append([]byte(nil), line...), handlers) // Copy the line to avoid data races
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("scanner error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
34
internal/streamer/streamer_test.go
Normal file
34
internal/streamer/streamer_test.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package streamer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewStreamer(t *testing.T) {
|
||||||
|
logger := slog.Default()
|
||||||
|
metricsTracker := metrics.New()
|
||||||
|
s := New(logger, metricsTracker)
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("New() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger != logger {
|
||||||
|
t.Error("logger not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.client == nil {
|
||||||
|
t.Error("HTTP client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.handlers == nil {
|
||||||
|
t.Error("handlers slice not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.metrics != metricsTracker {
|
||||||
|
t.Error("metrics tracker not set correctly")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user