// Package routewatch asks a routewatch instance about client addresses, in the // background, and keeps its answers in memory. A routewatch instance knows, for // an address, the netblock it is announced in and the AS that originates it. package routewatch import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/netip" "net/url" "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 } // 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 } // answer is one kept answer: the route, or why there is none, and when it came. type answer struct { route Route missing Missing came 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.came) < answerKept { return kept.route, kept.missing } 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 } // 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, missing, 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{route: route, missing: missing, came: 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. func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, 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, Missing, 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{}, NoRoute, 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 }