// Package client talks to the ingestion API (GET /config, POST /batch, GET /agent/latest) with signed requests // and keeps the clock-offset estimate derived from server_time in every response. package client import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "internetpressure.io/probe-agent/internal/protocol" "internetpressure.io/probe-agent/internal/signer" ) // Timeouts. const ( ConfigTimeout = 15 * time.Second BatchTimeout = 10 * time.Second LatestTimeout = 15 * time.Second maxRespBody = 4 << 20 ) // HTTPError is returned for non-2xx responses. type HTTPError struct { Status int Body string } func (e *HTTPError) Error() string { b := e.Body if len(b) > 200 { b = b[:200] + "…" } return fmt.Sprintf("http %d: %s", e.Status, strings.TrimSpace(b)) } // IsSkew reports whether a 401 body mentions a clock skew (server hint → resync and retry once). func (e *HTTPError) IsSkew() bool { return e.Status == http.StatusUnauthorized && strings.Contains(strings.ToLower(e.Body), "skew") } // Retryable reports whether the failure warrants spooling (network error or 5xx). func Retryable(err error) bool { var he *HTTPError if errors.As(err, &he) { return he.Status >= 500 || he.Status == http.StatusTooManyRequests } return err != nil } // Client is a signed HTTP client for the ingestion API. type Client struct { base *url.URL signer *signer.Signer ua string version string http *http.Client Clock *Clock } // New builds a client for baseURL (e.g. https://www.internetpressure.io/ingest/v1). func New(baseURL string, s *signer.Signer, version string) (*Client, error) { u, err := url.Parse(strings.TrimRight(baseURL, "/")) if err != nil { return nil, err } return &Client{ base: u, signer: s, version: version, ua: UserAgent(version), http: &http.Client{Transport: &http.Transport{ Proxy: nil, MaxIdleConns: 2, IdleConnTimeout: 90 * time.Second, ForceAttemptHTTP2: true, TLSHandshakeTimeout: 10 * time.Second, }}, Clock: NewClock(), }, nil } // UserAgent returns the protocol User-Agent string. func UserAgent(version string) string { return "InternetPressureProbe/" + version + " (+https://www.internetpressure.io/probes)" } // endpoint joins base path + suffix. func (c *Client) endpoint(suffix string) *url.URL { u := *c.base u.Path = strings.TrimRight(u.Path, "/") + suffix return &u } // do sends a signed request. body is the exact bytes to send (nil for GET). The timestamp header is the local // clock corrected by the estimated offset so a drifting probe still passes the ±300 s window. func (c *Client) do(ctx context.Context, method string, u *url.URL, body []byte, gzip bool, timeout time.Duration) ([]byte, time.Duration, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var rd io.Reader if body != nil { rd = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, u.String(), rd) if err != nil { return nil, 0, err } req.Header.Set("User-Agent", c.ua) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") if gzip { req.Header.Set("Content-Encoding", "gzip") } } c.signer.Apply(req, c.Clock.ServerNow().Unix(), body) t0 := time.Now() resp, err := c.http.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, maxRespBody)) rtt := time.Since(t0) if err != nil { return nil, rtt, err } if resp.StatusCode/100 != 2 { he := &HTTPError{Status: resp.StatusCode, Body: string(data)} // On an auth skew the server's Date header is the best resync source we have. if he.IsSkew() { if d, perr := http.ParseTime(resp.Header.Get("Date")); perr == nil { c.Clock.Set(t0.Add(rtt/2), d) } } return data, rtt, he } // Feed the clock from server_time when present (all JSON responses carry it), else from Date. var st struct { ServerTime string `json:"server_time"` } if json.Unmarshal(data, &st) == nil && st.ServerTime != "" { if t, perr := protocol.ParseTime(st.ServerTime); perr == nil { c.Clock.Observe(t0, rtt, t) } } return data, rtt, nil } // GetConfig fetches the probe assignment. func (c *Client) GetConfig(ctx context.Context) (*protocol.RemoteConfig, error) { data, _, err := c.do(ctx, http.MethodGet, c.endpoint("/config"), nil, false, ConfigTimeout) if err != nil { return nil, err } var cfg protocol.RemoteConfig if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("config: bad json: %w", err) } return &cfg, nil } // PostBatch sends an already gzip-compressed batch body. func (c *Client) PostBatch(ctx context.Context, gz []byte, timeout time.Duration) (*protocol.BatchResponse, error) { if timeout <= 0 { timeout = BatchTimeout } data, _, err := c.do(ctx, http.MethodPost, c.endpoint("/batch"), gz, true, timeout) if err != nil { return nil, err } var resp protocol.BatchResponse if len(data) > 0 { if err := json.Unmarshal(data, &resp); err != nil { return nil, fmt.Errorf("batch: bad json: %w", err) } } return &resp, nil } // AgentLatest fetches the newest release descriptor. func (c *Client) AgentLatest(ctx context.Context) (*protocol.AgentLatest, error) { data, _, err := c.do(ctx, http.MethodGet, c.endpoint("/agent/latest"), nil, false, LatestTimeout) if err != nil { return nil, err } var al protocol.AgentLatest if err := json.Unmarshal(data, &al); err != nil { return nil, fmt.Errorf("agent/latest: bad json: %w", err) } return &al, nil } // Download fetches an arbitrary URL (release asset) into w with the probe User-Agent. Not signed. func (c *Client) Download(ctx context.Context, rawURL string, w io.Writer, limit int64) (int64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return 0, err } req.Header.Set("User-Agent", c.ua) resp, err := c.http.Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return 0, &HTTPError{Status: resp.StatusCode} } return io.Copy(w, io.LimitReader(resp.Body, limit)) }