Compare commits
1
Commits
next
..
d79b4169b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79b4169b6 |
@@ -9,38 +9,19 @@ table in real time. It works like this:
|
||||
|
||||
## Rationale
|
||||
|
||||
RouteWatch exists to serve our other services as an internal API for information
|
||||
about IP addresses.
|
||||
RIPE RIS Live streams the BGP updates that RIPE's route collectors receive, as
|
||||
they happen, from the moment a client connects. It reports changes, not the
|
||||
routing table they add up to, so it cannot say which AS an IP address is routed
|
||||
to now or which routes a prefix has. RouteWatch applies the feed's updates to a
|
||||
routing table that it keeps in memory, with a copy in its own SQLite database.
|
||||
It answers IP-to-AS lookups and prefix pages from that table.
|
||||
|
||||
RouteWatch follows BGP routing table updates from RIPE RIS Live. RIS Live
|
||||
streams the updates that RIPE's route collectors receive, as they happen, from
|
||||
the moment a client connects. It reports changes, not the routing table they add
|
||||
up to. RouteWatch applies those changes to a routing table that it keeps in
|
||||
memory, with a copy in its own SQLite database. From that table, RouteWatch sees
|
||||
directly:
|
||||
|
||||
- which AS each IP address is routed to
|
||||
- which announced prefix holds each IP address, and how big that prefix is
|
||||
|
||||
RouteWatch adds details of each AS from WHOIS, the public directory of the
|
||||
internet registries. The details include the organisation that holds the AS.
|
||||
RouteWatch keeps them in its database and looks them up again once they are 30
|
||||
days old. An IP lookup answers from those stored details and never waits for
|
||||
WHOIS.
|
||||
|
||||
So our own services can ask RouteWatch about an IP address, instead of each
|
||||
working it out for itself.
|
||||
|
||||
RIS Live sends no routing table when a client connects. So the routing table
|
||||
holds only the routes announced since RouteWatch first started. An IP lookup
|
||||
finds no route for an address until a prefix that holds it is announced. The
|
||||
routing table also misses these updates:
|
||||
|
||||
- those sent while RouteWatch is stopped
|
||||
- those that RouteWatch drops when it falls behind
|
||||
|
||||
A lookup or a prefix page can therefore show a route that has since been
|
||||
withdrawn.
|
||||
The feed sends no table when a client connects. So the routing table holds only
|
||||
the routes announced since RouteWatch first started. An IP-to-AS lookup finds
|
||||
nothing for an address whose routes have not been announced since then. The
|
||||
routing table also misses updates RouteWatch drops when it falls behind, and
|
||||
updates sent while it is stopped. So a lookup or a prefix page can show a route
|
||||
the feed has withdrawn.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -252,7 +233,7 @@ originates no prefix and has no record in the database or in the AS list built
|
||||
into RouteWatch. AS 0 always answers 404, because it means no known origin. The
|
||||
AS name search never lists it. A prefix with no live route answers 404 too. The
|
||||
HTML pages give these errors as plain text. Under `/api/v1` and `/ip/`, a path
|
||||
that matches no route answers 404 with the message `Not found`. A request with
|
||||
no route matches answers 404 with the message `Not found`. A request with
|
||||
`POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405
|
||||
with `Method not allowed` on these routes:
|
||||
|
||||
@@ -260,9 +241,8 @@ with `Method not allowed` on these routes:
|
||||
- `/status.json`
|
||||
- `/.well-known/healthcheck.json`
|
||||
|
||||
A 500 answer says only `Internal server error`. The error for a host name that
|
||||
does not resolve says only `could not resolve host name`. The detail goes to the
|
||||
log.
|
||||
A 500 answer says only `Internal server error`, and a host name that does not
|
||||
resolve only `could not resolve host name`; the detail goes to the log.
|
||||
|
||||
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
||||
|
||||
@@ -35,20 +35,13 @@ tested.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-27: the server works out the home page's cards every minute until it
|
||||
stops. It used to work them out only once, at startup: the end of the context
|
||||
that `Start` is given stopped the refreshes (closes #211)
|
||||
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
|
||||
services' questions about IP addresses, from its own routing table and the
|
||||
WHOIS details it keeps. It still says what RIS Live does not give (closes
|
||||
#204)
|
||||
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
|
||||
500 with the JSON error body. `/status.json` and the health check answer 405
|
||||
with the JSON error body to any method they do not take. The 500 and the 405
|
||||
used to have an empty body. The home page's IP lookup shows the whole JSON
|
||||
answer, `@meta` included. It used to show only the answer's `data`, or only
|
||||
the message of an error answer. A test checks `@meta` on every JSON route, in
|
||||
its successes and its errors (closes #201)
|
||||
500 with the JSON error body. A method that `/status.json` or the health check
|
||||
does not take answers 405 with it, as under `/api/v1`. Both used to answer
|
||||
with an empty body. The home page's IP lookup shows the whole JSON answer,
|
||||
`@meta` included. It used to show only the answer's `data`, or only the
|
||||
message of an error answer. A test checks `@meta` on every JSON route, in its
|
||||
successes and its errors (closes #201)
|
||||
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
|
||||
unit at the end of its field name. `@meta.execution_time` is now
|
||||
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
@@ -342,66 +341,6 @@ func TestStartedServerWorksOutTheCards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartedServerWorksOutTheCardsUntilStop starts a server with a context
|
||||
// that ends as soon as Start returns. It runs in a synctest bubble, whose clock
|
||||
// is fake: time.Sleep there moves the clock on and returns at once. The server
|
||||
// must work the cards out again a minute later, and no more after Stop. Each
|
||||
// time it works them out, it keeps a new copy. Before the fix the end of
|
||||
// Start's context stopped the goroutine that works them out.
|
||||
func TestStartedServerWorksOutTheCardsUntilStop(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The server is made outside the bubble. The streamer's metrics start a
|
||||
// goroutine that never returns, and a bubble waits for each of its
|
||||
// goroutines to return.
|
||||
s := newServerWithTable(t, newCardsTable())
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
err := s.Start(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// The bubble's clock moves only while each of its goroutines waits
|
||||
// on the bubble, and the goroutine that serves HTTP waits on the
|
||||
// network. The test sends no request, so it closes the HTTP server.
|
||||
_ = s.srv.Close()
|
||||
|
||||
synctest.Wait()
|
||||
|
||||
started := s.cards.Load()
|
||||
if started == nil {
|
||||
t.Fatal("the server did not work out the cards when it started")
|
||||
}
|
||||
|
||||
time.Sleep(homeCardsRefreshInterval)
|
||||
synctest.Wait()
|
||||
|
||||
if s.cards.Load() == started {
|
||||
t.Error("the server did not work out the cards again a minute " +
|
||||
"after its start context ended")
|
||||
}
|
||||
|
||||
err = s.Stop(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
stopped := s.cards.Load()
|
||||
|
||||
time.Sleep(homeCardsRefreshInterval)
|
||||
synctest.Wait()
|
||||
|
||||
if s.cards.Load() != stopped {
|
||||
t.Error("the server worked out the cards again after Stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitUntil fails the test unless done reports true within maxWait.
|
||||
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
|
||||
t.Helper()
|
||||
|
||||
@@ -82,12 +82,6 @@ type Server struct {
|
||||
// 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
|
||||
@@ -117,8 +111,7 @@ func New(
|
||||
// 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.
|
||||
// the server works out the home page's cards every minute until ctx ends.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
const (
|
||||
network = "tcp"
|
||||
@@ -155,22 +148,16 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
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.
|
||||
// Stop 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
|
||||
@@ -178,16 +165,6 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user