Skip to content

Commit 4d28fab

Browse files
authored
Merge pull request #429 from Ryanmello07/checkpoint/provider-probing-health
Provider probing checkpoint: egress geolocation, verdicts, bandwidth, health-gated offers and counts, blank-name repair
2 parents 5090308 + 76a4a13 commit 4d28fab

51 files changed

Lines changed: 15352 additions & 100 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/api.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ func Routes() []*router.Route {
6060
router.NewRoute("POST", "/network/auth-client", handlers.AuthNetworkClient),
6161
router.NewRoute("POST", "/network/remove-client", handlers.RemoveNetworkClient),
6262
router.NewRoute("POST", "/network/remove-clients", handlers.RemoveNetworkClients),
63+
router.NewRoute("POST", "/network/provider-egress-location", handlers.ProviderEgressLocationSubmit),
64+
router.NewRoute("GET", "/network/provider-egress-due", handlers.ProviderEgressLocationDue),
65+
router.NewRoute("POST", "/network/provider-egress-attempt", handlers.ProviderEgressLocationAttempt),
66+
// operator-to-server, same operator secret: the certificate pins this
67+
// server observed DIRECTLY for the geolocation source hosts. The
68+
// prober fetches them here instead of carrying a compile-time
69+
// constant, and refuses to probe at all if it cannot get a complete
70+
// set -- probing unpinned would let the provider under test forge its
71+
// own location, which is the thing the probe exists to catch.
72+
router.NewRoute("GET", "/network/geolocation-source-pins", handlers.GeolocationSourcePins),
73+
// operator-to-server, gated by the same operator secret as the egress
74+
// location ingest above: the active bandwidth probe's download target,
75+
// its result submission, and the byte-budget reservation the prober
76+
// takes before spending any probe bytes
77+
router.NewRoute("GET", "/network/provider-bandwidth-test", handlers.ProviderBandwidthTest),
78+
router.NewRoute("POST", "/network/provider-bandwidth-result", handlers.ProviderBandwidthResult),
79+
router.NewRoute("POST", "/network/provider-bandwidth-reserve", handlers.ProviderBandwidthReserve),
80+
// operator-to-server, same operator secret again: the egress-health
81+
// run the prober takes over the tunnel the geolocation probe already
82+
// opened. Until this existed the result was a log line and nothing
83+
// else.
84+
router.NewRoute("POST", "/network/provider-egress-health", handlers.ProviderEgressHealthResult),
85+
// client-to-server, and the only route in this group that is NOT
86+
// operator-secret authed: a real client network reporting that a
87+
// provider carried nothing. The reporting network is taken from the
88+
// session jwt, never from the body, because the quorum counts distinct
89+
// networks. A met quorum only brings the provider's next probe
90+
// forward -- see model.ProviderClientVerdictQuorumMet.
91+
router.NewRoute("POST", "/network/provider-verdict", handlers.ProviderClientVerdictSubmit),
6392
router.NewRoute("GET", "/network/clients", handlers.NetworkClients),
6493
router.NewRoute("GET", "/network/peers", handlers.NetworkPeers),
6594
router.NewRoute("GET", "/network/provider-locations", handlers.NetworkGetProviderLocations),
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package handlers
2+
3+
import (
4+
"crypto/hmac"
5+
"encoding/json"
6+
"net/http"
7+
8+
"github.com/urnetwork/glog"
9+
10+
"github.com/urnetwork/server/model"
11+
)
12+
13+
// GeolocationSourcePin is one host's observed certificate pin as served to the
14+
// prober: the SPKI hash of the leaf certificate and of its issuing
15+
// intermediate, both base64 sha-256, exactly as the observation job recorded
16+
// them from a DIRECT, WebPKI-validated connection on this server's own network.
17+
//
18+
// Both are served, not just the leaf, because the prober's check
19+
// (providertunnel.checkPin) accepts a match anywhere in the verified chain: the
20+
// intermediate is what absorbs routine leaf renewal between two observations,
21+
// and the leaf is the tighter of the two while it lasts.
22+
type GeolocationSourcePin struct {
23+
Leaf string `json:"leaf"`
24+
Intermediate string `json:"intermediate"`
25+
}
26+
27+
// GeolocationSourcePinsResult is the response body: a BARE map from host to its
28+
// pin, `{"ipinfo.io": {"leaf": "...", "intermediate": "..."}}`.
29+
//
30+
// The other operator endpoints wrap their payload in a named field
31+
// (`{"client_ids": [...]}`); this one deliberately does not, because the plan
32+
// specifies this shape and because the map IS the whole answer -- there is no
33+
// second field this response could ever grow that would not be better as its
34+
// own endpoint. A host absent from the map has never been successfully
35+
// observed, and the prober's correct response to that is to refuse to probe,
36+
// so the absence has to survive the wire rather than being padded out to a
37+
// placeholder entry here.
38+
type GeolocationSourcePinsResult map[string]GeolocationSourcePin
39+
40+
// GeolocationSourcePins serves the certificate pins this server has observed
41+
// for the geolocation source hosts, so the prober does not have to carry them
42+
// as a compile-time constant.
43+
//
44+
// # Why serving pins is safe, and where the line is
45+
//
46+
// The geolocation lookup the prober makes is issued THROUGH the provider under
47+
// test. The pin is what stops that provider substituting a certificate and
48+
// forging its own apparent location, which is the entire point of the probe.
49+
// Handing the prober a pin the SERVER chose is therefore only sound because the
50+
// server observed it directly, on its own network, with no provider anywhere in
51+
// the path and full chain validation (see work.RefreshGeolocationSourcePins).
52+
// A provider cannot influence what this server saw, so it cannot influence what
53+
// this endpoint says. Nothing in this file may ever accept a pin from a
54+
// request: this endpoint is read-only, and the table it reads has exactly one
55+
// writer, the observation job.
56+
//
57+
// # It serves what was observed, and nothing else
58+
//
59+
// It does not synthesize a row for a source host that has not been observed,
60+
// and it does not fall back to any built-in default. An empty or partial answer
61+
// is a truthful one, and the prober treats it as a hard stop rather than
62+
// probing unpinned -- which is the whole reason the shortfall must be visible
63+
// rather than papered over. That is also why an empty table returns `{}` with
64+
// 200 rather than 404: 404 would be indistinguishable from "this server does
65+
// not implement the endpoint", and the prober does distinguish those in its
66+
// message even though both are fatal.
67+
//
68+
// Same auth as the operator endpoints beside it: the shared secret header
69+
// rather than a network jwt, fail-closed when the vault resource is missing.
70+
// The pins are not secret -- anyone can open a TLS connection to ipinfo.io and
71+
// compute them -- but the endpoint is operator-to-server like the rest of the
72+
// probe control plane, and there is no reason to give it a wider door than the
73+
// due list it is fetched alongside.
74+
func GeolocationSourcePins(w http.ResponseWriter, r *http.Request) {
75+
secret := operatorIngestSecret()
76+
provided := r.Header.Get(operatorSecretHeader)
77+
if secret == "" || provided == "" || !hmac.Equal([]byte(secret), []byte(provided)) {
78+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
79+
return
80+
}
81+
82+
pins := model.GetGeolocationSourcePins(r.Context())
83+
result := GeolocationSourcePinsResult{}
84+
for host, pin := range pins {
85+
result[host] = GeolocationSourcePin{
86+
Leaf: pin.LeafSpki,
87+
Intermediate: pin.IntermediateSpki,
88+
}
89+
}
90+
91+
w.Header().Set("Content-Type", "application/json")
92+
if err := json.NewEncoder(w).Encode(result); err != nil {
93+
glog.Infof("[gsp]could not write response. err = %s\n", err)
94+
}
95+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/urnetwork/server"
10+
"github.com/urnetwork/server/model"
11+
)
12+
13+
// The pin endpoint is what stands between the prober and probing unpinned, so
14+
// its auth has to fail closed in exactly the two ways an operator gets wrong:
15+
// no header at all (a deployment that never configured the secret) and a wrong
16+
// one (a rotated secret on one side only). Both are 401, and neither reaches
17+
// the database.
18+
func TestGeolocationSourcePinsRejectsMissingSecret(t *testing.T) {
19+
req := httptest.NewRequest(http.MethodGet, "/network/geolocation-source-pins", nil)
20+
w := httptest.NewRecorder()
21+
22+
GeolocationSourcePins(w, req)
23+
24+
if w.Code != http.StatusUnauthorized {
25+
t.Fatalf("status = %d, want 401 when the operator secret header is absent", w.Code)
26+
}
27+
}
28+
29+
func TestGeolocationSourcePinsRejectsWrongSecret(t *testing.T) {
30+
req := httptest.NewRequest(http.MethodGet, "/network/geolocation-source-pins", nil)
31+
req.Header.Set(operatorSecretHeader, "definitely-not-the-secret")
32+
w := httptest.NewRecorder()
33+
34+
GeolocationSourcePins(w, req)
35+
36+
if w.Code != http.StatusUnauthorized {
37+
t.Fatalf("status = %d, want 401 on a wrong operator secret", w.Code)
38+
}
39+
}
40+
41+
// TestGeolocationSourcePinsRejectsAlteredSecret pins the comparison itself.
42+
// The two tests above both run with the vault unconfigured and take the
43+
// secret=="" short-circuit, so they would still pass if the comparison were
44+
// `strings.HasPrefix` or dropped entirely. This one configures a real secret
45+
// and offers a near miss.
46+
func TestGeolocationSourcePinsRejectsAlteredSecret(t *testing.T) {
47+
const secret = "correct-operator-secret-0123456789"
48+
defer withStubOperatorIngestSecret(secret)()
49+
50+
for _, wrong := range []string{
51+
secret + "x",
52+
secret[:len(secret)-1],
53+
"",
54+
} {
55+
req := httptest.NewRequest(http.MethodGet, "/network/geolocation-source-pins", nil)
56+
if wrong != "" {
57+
req.Header.Set(operatorSecretHeader, wrong)
58+
}
59+
w := httptest.NewRecorder()
60+
61+
GeolocationSourcePins(w, req)
62+
63+
if w.Code != http.StatusUnauthorized {
64+
t.Errorf("status = %d for secret %q, want 401", w.Code, wrong)
65+
}
66+
}
67+
}
68+
69+
// TestGeolocationSourcePinsServesTheObservedSet is the other half: the auth
70+
// gate must be able to ACCEPT, and what comes back must be what the
71+
// observation job stored, keyed by host, on the exact wire shape the prober
72+
// decodes (`{host: {leaf, intermediate}}`).
73+
//
74+
// It asserts on the decoded JSON rather than on the handler's Go types,
75+
// because the prober is a separate repository that only ever sees the bytes:
76+
// a renamed json tag would be invisible to a Go-level assertion and would take
77+
// the fleet's probing offline.
78+
func TestGeolocationSourcePinsServesTheObservedSet(t *testing.T) {
79+
t.Setenv("WARP_ENV", "local")
80+
server.DefaultTestEnv().Run(t, func(t testing.TB) {
81+
const secret = "correct-operator-secret-0123456789"
82+
defer withStubOperatorIngestSecret(secret)()
83+
84+
observedAt := server.NowUtc()
85+
model.SetGeolocationSourcePin(t.Context(), &model.GeolocationSourcePin{
86+
Host: "ipinfo.io",
87+
LeafSpki: "leaf-ipinfo",
88+
IntermediateSpki: "int-ipinfo",
89+
ObservedAt: observedAt,
90+
})
91+
model.SetGeolocationSourcePin(t.Context(), &model.GeolocationSourcePin{
92+
Host: "api.i.pn",
93+
LeafSpki: "leaf-ipn",
94+
IntermediateSpki: "int-ipn",
95+
ObservedAt: observedAt,
96+
})
97+
98+
req := httptest.NewRequest(http.MethodGet, "/network/geolocation-source-pins", nil)
99+
req.Header.Set(operatorSecretHeader, secret)
100+
w := httptest.NewRecorder()
101+
102+
GeolocationSourcePins(w, req)
103+
104+
if w.Code != http.StatusOK {
105+
t.Fatalf("status = %d, want 200 with the correct operator secret. body = %s", w.Code, w.Body.String())
106+
}
107+
108+
var got map[string]map[string]string
109+
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
110+
t.Fatalf("decode response %q: %s", w.Body.String(), err)
111+
}
112+
for host, want := range map[string][2]string{
113+
"ipinfo.io": {"leaf-ipinfo", "int-ipinfo"},
114+
"api.i.pn": {"leaf-ipn", "int-ipn"},
115+
} {
116+
pin, ok := got[host]
117+
if !ok {
118+
t.Fatalf("host %q missing from the served set %v; the prober treats a missing source host as a hard stop", host, got)
119+
}
120+
if pin["leaf"] != want[0] {
121+
t.Errorf("%s leaf = %q, want %q", host, pin["leaf"], want[0])
122+
}
123+
if pin["intermediate"] != want[1] {
124+
t.Errorf("%s intermediate = %q, want %q", host, pin["intermediate"], want[1])
125+
}
126+
}
127+
})
128+
}
129+
130+
// A host that has never been observed must stay ABSENT from the answer. The
131+
// endpoint must not invent a placeholder row to make the map look complete:
132+
// the prober decides what to do about a missing host (refuse to probe), and it
133+
// can only decide that if the absence reaches it.
134+
func TestGeolocationSourcePinsOmitsUnobservedHosts(t *testing.T) {
135+
t.Setenv("WARP_ENV", "local")
136+
server.DefaultTestEnv().Run(t, func(t testing.TB) {
137+
const secret = "correct-operator-secret-0123456789"
138+
defer withStubOperatorIngestSecret(secret)()
139+
140+
// exactly one of the source hosts observed
141+
model.SetGeolocationSourcePin(t.Context(), &model.GeolocationSourcePin{
142+
Host: model.GeolocationSourceHosts[0],
143+
LeafSpki: "leaf-only",
144+
IntermediateSpki: "int-only",
145+
ObservedAt: server.NowUtc(),
146+
})
147+
148+
req := httptest.NewRequest(http.MethodGet, "/network/geolocation-source-pins", nil)
149+
req.Header.Set(operatorSecretHeader, secret)
150+
w := httptest.NewRecorder()
151+
152+
GeolocationSourcePins(w, req)
153+
154+
if w.Code != http.StatusOK {
155+
t.Fatalf("status = %d, want 200. body = %s", w.Code, w.Body.String())
156+
}
157+
var got map[string]map[string]string
158+
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
159+
t.Fatalf("decode response %q: %s", w.Body.String(), err)
160+
}
161+
if len(got) != 1 {
162+
t.Fatalf("served %d host(s) %v, want only the one that was observed", len(got), got)
163+
}
164+
for _, host := range model.GeolocationSourceHosts[1:] {
165+
if _, ok := got[host]; ok {
166+
t.Errorf("unobserved host %q appears in the served set; a placeholder pin here would take the fail-closed decision away from the prober", host)
167+
}
168+
}
169+
})
170+
}

0 commit comments

Comments
 (0)