// Package routewatch asks a routewatch instance about client addresses, in the // background, and keeps its answers. A routewatch instance knows, for an // address, the netblock it is announced in and the AS that originates it. The // answers are kept in memory, for at most 100,000 clients and for an hour each, // and are written to routewatch.json and read from it by the state package. package routewatch import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/netip" "net/url" "slices" "sync" "time" "github.com/hashicorp/golang-lru/v2/simplelru" ) const ( // answerKept is how long an answer is used after it came. answerKept = time.Hour // failureKept is how long routewatch is not asked again after a query // fails, so that a dead routewatch is not asked on every request. failureKept = time.Minute // queryTimeout is how long one query may take. routewatch answers only // after a reverse DNS lookup it allows 2 seconds, so this is longer. queryTimeout = 5 * time.Second // maxAnswers is how many answers are kept. Past it, the one that came // longest ago is dropped. maxAnswers = 100000 // maxQueries is how many queries may be under way at once. Past it, a // client is not asked about until its next request, so that a swarm of // new addresses cannot fill the memory. maxQueries = 100 // maxAnswerBytes is the most of an answer that is read. maxAnswerBytes = 64 << 10 ) // Missing is why Route gives no route. The request log's field // routewatch_tags_missing gives these values. type Missing string const ( // NoAnswerYet is a client routewatch has not answered about yet. NoAnswerYet Missing = "no_answer_yet" // RequestFailed is no answer because a recent query failed. RequestFailed Missing = "request_failed" // NoRoute is routewatch knowing no live route for the address. NoRoute Missing = "no_route" ) var ( errStatus = errors.New("routewatch answered") errNoResult = errors.New("the answer holds no result") errNotNetblock = errors.New("the answer's netblock is not a netblock") ) // Route is what routewatch knows of an address: the netblock it is announced // in, and the AS that originates it. type Route struct { Netblock netip.Prefix ASN uint32 } // Answer is what routewatch answered about a client, as routewatch.json holds // it: the netblock the client's address is announced in, none for an address // without a live route, the AS that originates it, and when routewatch // answered. type Answer struct { Client netip.Prefix `json:"client"` Netblock netip.Prefix `json:"netblock"` ASN uint32 `json:"asn"` Answered time.Time `json:"answered"` } // Params are what New needs. type Params struct { // URL is where routewatch is (SWW_ROUTEWATCH_URL). URL string // Now tells the time, normally time.Now in UTC. Now func() time.Time // ProcessLog receives the query that fails, and why. ProcessLog *slog.Logger } // Client asks a routewatch instance about clients, in the background, and keeps // its answers. It is safe for concurrent use. type Client struct { params Params httpClient *http.Client mu sync.Mutex // answers are by client. Each is added as it comes and never moved up, // so that the one that came longest ago is the first dropped. answers *simplelru.LRU[netip.Prefix, Answer] // asking are the clients whose query is under way. asking map[netip.Prefix]bool // retryAt is when routewatch may be asked again after a query failed. retryAt time.Time } // New returns a Client with no answer yet. func New(params Params) *Client { answers, err := simplelru.NewLRU[netip.Prefix, Answer](maxAnswers, nil) if err != nil { panic(err) // NewLRU fails only for a size below one } return &Client{ params: params, httpClient: &http.Client{}, answers: answers, asking: map[netip.Prefix]bool{}, } } // Route returns what routewatch answered about client, an IPv4 address or an // IPv6 group, or why it has no route. It never waits for a query. A client // without an answer is asked about in the background, by addr, the address its // request came from, unless its query is under way, a query failed less than // failureKept ago, or maxQueries are under way. ctx is the context of the // client's request, and a query goes on after the request ends. func (c *Client) Route( ctx context.Context, client netip.Prefix, addr netip.Addr, ) (Route, Missing) { c.mu.Lock() defer c.mu.Unlock() now := c.params.Now() kept, found := c.answers.Peek(client) if found && now.Sub(kept.Answered) < answerKept { if !kept.Netblock.IsValid() { return Route{}, NoRoute } return Route{Netblock: kept.Netblock, ASN: kept.ASN}, "" } if now.Before(c.retryAt) { return Route{}, RequestFailed } if !c.asking[client] && len(c.asking) < maxQueries { c.asking[client] = true go c.ask(context.WithoutCancel(ctx), client, addr) } return Route{}, NoAnswerYet } // Snapshot returns every answer still in use, sorted by client, as // routewatch.json lists them. func (c *Client) Snapshot() []Answer { c.mu.Lock() now := c.params.Now() answers := make([]Answer, 0, c.answers.Len()) for _, kept := range c.answers.Values() { if now.Sub(kept.Answered) < answerKept { answers = append(answers, kept) } } c.mu.Unlock() slices.SortFunc(answers, func(a, b Answer) int { return a.Client.Compare(b.Client) }) return answers } // Load keeps answers, read from routewatch.json, in place of those it keeps, // but for those that came answerKept ago or more and, past maxAnswers, those // that came longest ago. func (c *Client) Load(answers []Answer) { answers = slices.Clone(answers) slices.SortStableFunc(answers, func(a, b Answer) int { return a.Answered.Compare(b.Answered) }) c.mu.Lock() defer c.mu.Unlock() c.answers.Purge() now := c.params.Now() for _, kept := range answers { if now.Sub(kept.Answered) < answerKept { c.answers.Add(kept.Client, kept) } } } // ask asks routewatch about addr, keeps the answer as client's, and notes the // query as no longer under way. A query that fails gives no answer, and // routewatch is not asked again for failureKept. Only the first failure of // those under way is logged. func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) { route, err := c.query(ctx, addr) now := c.params.Now() c.mu.Lock() delete(c.asking, client) firstFailure := err != nil && !now.Before(c.retryAt) if err == nil { c.answers.Add(client, Answer{ Client: client, Netblock: route.Netblock, ASN: route.ASN, Answered: now, }) } else { c.retryAt = now.Add(failureKept) } c.mu.Unlock() if firstFailure { c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error()) } } // query asks routewatch's IP info API about addr. An answer other than 200, one // that cannot be read, and none within queryTimeout, fail. A result with no // netblock is an address without a live route, and gives a Route without one. func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) { ctx, cancel := context.WithTimeout(ctx, queryTimeout) defer cancel() endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String()) if err != nil { return Route{}, fmt.Errorf("make the URL: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { return Route{}, fmt.Errorf("make the request: %w", err) } req.Header.Set("Accept", "application/json") res, err := c.httpClient.Do(req) if err != nil { // Do's error names the URL, which holds the client's address, which // is not to be logged: only what went wrong is kept. return Route{}, fmt.Errorf("ask routewatch: %w", errors.Unwrap(err)) } defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { return Route{}, fmt.Errorf("%w %s", errStatus, res.Status) } return readAnswer(io.LimitReader(res.Body, maxAnswerBytes)) } // readAnswer reads routewatch's answer about one address. func readAnswer(body io.Reader) (Route, error) { var decoded struct { Data struct { Results []struct { Netblock string `json:"netblock"` ASN uint32 `json:"asn"` } `json:"results"` } `json:"data"` } err := json.NewDecoder(body).Decode(&decoded) if err != nil { return Route{}, fmt.Errorf("read the answer: %w", err) } if len(decoded.Data.Results) == 0 { return Route{}, errNoResult } result := decoded.Data.Results[0] if result.Netblock == "" { return Route{}, nil } netblock, err := netip.ParsePrefix(result.Netblock) if err != nil { return Route{}, fmt.Errorf("%w: %q", errNotNetblock, result.Netblock) } return Route{Netblock: netblock.Masked(), ASN: result.ASN}, nil }