check / check (push) Successful in 4m46s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
244 lines
7.1 KiB
Go
244 lines
7.1 KiB
Go
// 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_WAF_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
|
|
}
|