check / check (push) Successful in 1m50s
Each result gains `geo`. One request to geojs carries every address, 1 second in all. On a timeout or error the lookup reads DB-IP City Lite, downloaded monthly into the state directory. geojs answers stay in memory (250,000, least recently used dropped) and in a new `cache.db` table for 7 days. Judgement call: a reserved address goes to neither source, and its `geo` is null. Judgement call: an answer from geojs with every part unknown is still an answer. Judgement call: `updated` for DB-IP is when the copy was downloaded. Deviation: the cache is SQLite, by sneak's words. Model: opus-5-5
240 lines
6.8 KiB
Go
240 lines
6.8 KiB
Go
// Package server serves RouteWatch's web pages and JSON API:
|
|
// - the home page, the status figures and the health check
|
|
// - the pages that list the bogon announcements and the prefixes announced
|
|
// by more than one origin AS
|
|
// - the AS, prefix and prefix length pages, and the JSON answers behind them
|
|
// - the IP lookups
|
|
// - the AS name search
|
|
//
|
|
// Every route it shows comes from the routing table in memory. AS names,
|
|
// WHOIS details, the times a prefix and an AS were first seen, and a prefix's
|
|
// origin changes come from the database.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/geo"
|
|
"git.eeqj.de/sneak/routewatch/internal/iplist"
|
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ASNFetcherStats contains WHOIS fetcher statistics.
|
|
type ASNFetcherStats struct {
|
|
SuccessesLastHour int
|
|
ErrorsLastHour int
|
|
CurrentInterval time.Duration
|
|
ConsecutiveFails int
|
|
}
|
|
|
|
// ASNFetcher is an interface for queuing ASN WHOIS lookups.
|
|
type ASNFetcher interface {
|
|
QueueImmediate(asn int)
|
|
GetStats() ASNFetcherStats
|
|
}
|
|
|
|
// IPLists tells which public IP lists contain an address, from copies it keeps
|
|
// in memory, as *iplist.IPLists does.
|
|
type IPLists interface {
|
|
Lookup(addr netip.Addr) []iplist.Listing
|
|
ShortestInterval() time.Duration
|
|
}
|
|
|
|
// dnsResolver looks up the addresses of host names in DNS, as *net.Resolver
|
|
// does.
|
|
type dnsResolver interface {
|
|
LookupHost(ctx context.Context, host string) ([]string, error)
|
|
}
|
|
|
|
// PTRResolver looks up the PTR names of addresses, as *ptr.Cache does.
|
|
type PTRResolver interface {
|
|
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
|
}
|
|
|
|
// GeoLocator finds where addresses are, as *geo.Locator does. It returns one
|
|
// entry for each address, nil when no source answers.
|
|
type GeoLocator interface {
|
|
Lookup(ctx context.Context, addrs []netip.Addr) []*geo.Location
|
|
}
|
|
|
|
// Server provides HTTP endpoints for status monitoring
|
|
type Server struct {
|
|
router *chi.Mux
|
|
db database.Store
|
|
table *routetable.Table
|
|
streamer *streamer.Streamer
|
|
logger *logger.Logger
|
|
port int
|
|
srv *http.Server
|
|
asnFetcher ASNFetcher
|
|
ipLists IPLists
|
|
|
|
// trustedProxies are the reverse proxies whose X-Forwarded-For header
|
|
// RealIPMiddleware believes
|
|
trustedProxies []netip.Prefix
|
|
|
|
// resolver looks up the IP lookup's host names: net.DefaultResolver, or a
|
|
// fake in tests
|
|
resolver dnsResolver
|
|
|
|
// ptrResolver looks up the IP lookup's PTR names. SetPTRResolver sets it.
|
|
ptrResolver PTRResolver
|
|
|
|
// geoLocator finds where the IP lookup's addresses are. SetGeoLocator
|
|
// sets it.
|
|
geoLocator GeoLocator
|
|
|
|
// cards is the copy of the home page's cards that the pages serve, nil
|
|
// until the server first works them out
|
|
cards atomic.Pointer[homeCards]
|
|
|
|
// stopCards ends the goroutine that works out the cards, and
|
|
// cardsStopped is closed once it has returned. Both are nil until Start
|
|
// starts the goroutine.
|
|
stopCards context.CancelFunc
|
|
cardsStopped chan struct{}
|
|
}
|
|
|
|
// New creates a new HTTP server that reads routes from table, the routing
|
|
// table that the prefix handler keeps current.
|
|
func New(
|
|
db database.Store,
|
|
table *routetable.Table,
|
|
streamer *streamer.Streamer,
|
|
logger *logger.Logger,
|
|
cfg *config.Config,
|
|
) *Server {
|
|
s := &Server{
|
|
db: db,
|
|
table: table,
|
|
streamer: streamer,
|
|
logger: logger,
|
|
port: cfg.Port,
|
|
trustedProxies: cfg.TrustedProxies,
|
|
resolver: net.DefaultResolver,
|
|
}
|
|
|
|
s.setupRoutes()
|
|
|
|
return s
|
|
}
|
|
|
|
// Start listens on the configured port and serves HTTP in the background.
|
|
// It returns an error if it cannot listen, for example because the port is
|
|
// already in use. ctx bounds opening the port, not serving. Once listening,
|
|
// the server works out the home page's cards every minute until Stop: the end
|
|
// of ctx does not stop it.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
const (
|
|
network = "tcp"
|
|
readHeaderTimeout = 40 * time.Second
|
|
readTimeout = 60 * time.Second
|
|
writeTimeout = 60 * time.Second
|
|
idleTimeout = 120 * time.Second
|
|
)
|
|
|
|
s.srv = &http.Server{
|
|
Addr: ":" + strconv.Itoa(s.port),
|
|
Handler: s.router,
|
|
ReadHeaderTimeout: readHeaderTimeout,
|
|
ReadTimeout: readTimeout,
|
|
WriteTimeout: writeTimeout,
|
|
IdleTimeout: idleTimeout,
|
|
}
|
|
|
|
s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr)
|
|
|
|
var listenConfig net.ListenConfig
|
|
|
|
listener, err := listenConfig.Listen(ctx, network, s.srv.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("starting HTTP server: %w", err)
|
|
}
|
|
|
|
s.logger.Info("HTTP server listening", "addr", listener.Addr().String())
|
|
|
|
go func() {
|
|
err := s.srv.Serve(listener)
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
s.logger.Error("HTTP server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
ctx, s.stopCards = context.WithCancel(context.WithoutCancel(ctx))
|
|
s.cardsStopped = make(chan struct{})
|
|
|
|
go func() {
|
|
defer close(s.cardsStopped)
|
|
|
|
s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the goroutine that works out the home page's cards, and waits
|
|
// until it has returned or ctx ends. It then stops the HTTP server. It waits,
|
|
// until ctx ends, for the requests in progress to finish, and then closes the
|
|
// connections of those still running and returns ctx's error.
|
|
func (s *Server) Stop(ctx context.Context) error {
|
|
if s.srv == nil {
|
|
return nil
|
|
}
|
|
|
|
s.logger.Info("Stopping HTTP server")
|
|
|
|
// Start returns before it starts the goroutine when it cannot listen.
|
|
if s.stopCards != nil {
|
|
s.stopCards()
|
|
|
|
select {
|
|
case <-s.cardsStopped:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
err := s.srv.Shutdown(ctx)
|
|
if err != nil {
|
|
return errors.Join(err, s.srv.Close())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetPTRResolver sets what looks up the IP lookup's PTR names. Call it before
|
|
// Start: the handlers read it without a lock.
|
|
func (s *Server) SetPTRResolver(resolver PTRResolver) {
|
|
s.ptrResolver = resolver
|
|
}
|
|
|
|
// SetGeoLocator sets what finds where the IP lookup's addresses are. Call it
|
|
// before Start: the handlers read it without a lock.
|
|
func (s *Server) SetGeoLocator(locator GeoLocator) {
|
|
s.geoLocator = locator
|
|
}
|
|
|
|
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
|
|
// before Start: the handlers read the fetcher without a lock.
|
|
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
|
|
s.asnFetcher = fetcher
|
|
}
|
|
|
|
// SetIPLists sets the IP lists the IP lookups report. Call it before Start:
|
|
// the handlers read the IP lists without a lock.
|
|
func (s *Server) SetIPLists(ipLists IPLists) {
|
|
s.ipLists = ipLists
|
|
}
|