check / check (push) Waiting to run
Server.Start ran the loop that works out the home page's cards on the context it was given. fx ends that context once start-up is done, so the cards were worked out once and never again. The loop now runs on a context that only Stop ends, as the other background parts do. Stop waits for the loop to return, until its own context ends, before it stops the HTTP server. A new test starts a server in a synctest bubble and ends its context at once. On the bubble's fake clock, the cards must be worked out again a minute later, and not after Stop. Model: opus-5-5
210 lines
5.8 KiB
Go
210 lines
5.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 and the times a prefix and an AS were first seen 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/blocklist"
|
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"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
|
|
}
|
|
|
|
// Blocklists tells which public blocklists list an address, from copies it
|
|
// keeps in memory, as *blocklist.Blocklists does.
|
|
type Blocklists interface {
|
|
Lookup(addr netip.Addr) []blocklist.Listing
|
|
ShortestInterval() time.Duration
|
|
}
|
|
|
|
// dnsResolver looks up the addresses of host names and the names of addresses
|
|
// in DNS, as *net.Resolver does.
|
|
type dnsResolver interface {
|
|
LookupHost(ctx context.Context, host string) ([]string, error)
|
|
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
|
}
|
|
|
|
// 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
|
|
blocklists Blocklists
|
|
|
|
// trustedProxies are the reverse proxies whose X-Forwarded-For header
|
|
// RealIPMiddleware believes
|
|
trustedProxies []netip.Prefix
|
|
|
|
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
|
|
// fake in tests
|
|
resolver dnsResolver
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetBlocklists sets the blocklists the IP lookups report. Call it before
|
|
// Start: the handlers read the blocklists without a lock.
|
|
func (s *Server) SetBlocklists(blocklists Blocklists) {
|
|
s.blocklists = blocklists
|
|
}
|