Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func Routes() []*router.Route {
router.NewRoute("POST", "/auth/upgrade-guest-existing", handlers.UpgradeGuestExisting),
router.NewRoute("POST", "/network/auth-client", handlers.AuthNetworkClient),
router.NewRoute("POST", "/network/remove-client", handlers.RemoveNetworkClient),
router.NewRoute("POST", "/network/provider-egress-location", handlers.ProviderEgressLocationSubmit),
router.NewRoute("GET", "/network/clients", handlers.NetworkClients),
router.NewRoute("GET", "/network/peers", handlers.NetworkPeers),
router.NewRoute("GET", "/network/provider-locations", handlers.NetworkGetProviderLocations),
Expand Down
93 changes: 93 additions & 0 deletions api/handlers/provider_egress_location_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package handlers

import (
"crypto/hmac"
"encoding/json"
"io"
"net/http"
"sync"

"github.com/urnetwork/glog"

"github.com/urnetwork/server"
"github.com/urnetwork/server/controller"
)

// operatorSecretHeader carries the operator ingest secret. This endpoint is
// operator-to-server, not a client route: it is authenticated by a shared
// secret from the vault, not by a network jwt.
const operatorSecretHeader = "X-UR-Operator-Secret"

// maxProviderEgressLocationBody bounds the request body.
const maxProviderEgressLocationBody = 16 * 1024

// operatorIngestSecret memoizes readOperatorIngestSecret for the life of the
// process. It is a package-level var (not a plain sync.OnceValue call site)
// so tests can swap it for a stub and restore it with defer; production code
// never reassigns it.
var operatorIngestSecret func() string = sync.OnceValue(readOperatorIngestSecret)

// readOperatorIngestSecret reads the operator ingest secret from the vault
// resource "provider_egress.yml", key "ingest_secret". It returns ""
// when the vault resource is absent, the key is absent, or the key is empty,
// which makes the endpoint fail closed (every request is rejected) rather
// than open. `SimpleResource`/`String` are the non-panicking lookups (unlike
// `RequireSimpleResource`/`RequireString`), so a missing vault resource
// disables the endpoint instead of panicking the api process at startup or
// per-request.
func readOperatorIngestSecret() string {
res, err := server.Vault.SimpleResource("provider_egress.yml")
if err != nil {
glog.Infof("[pegl]no provider_egress.yml in the vault; ingest endpoint disabled\n")
return ""
}
values := res.String("ingest_secret")
if len(values) != 1 || values[0] == "" {
glog.Infof("[pegl]no ingest_secret in provider_egress.yml; ingest endpoint disabled\n")
return ""
}
return values[0]
}

// ProviderEgressLocationSubmit ingests a probed provider egress location from
// the operator's prober. The prober routes geolocation lookups through a
// provider's own egress -- rather than relying on a lookup against the
// provider's control-connection ip -- and submits the result here so the
// server can prefer it over the built-in mmdb lookup. The route is
// operator-to-server, authenticated by the shared secret above rather than a
// network jwt.
func ProviderEgressLocationSubmit(w http.ResponseWriter, r *http.Request) {
secret := operatorIngestSecret()
provided := r.Header.Get(operatorSecretHeader)
if secret == "" || provided == "" || !hmac.Equal([]byte(secret), []byte(provided)) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

body, err := io.ReadAll(io.LimitReader(r.Body, maxProviderEgressLocationBody+1))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if len(body) > maxProviderEgressLocationBody {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}

var args controller.SubmitProviderEgressLocationArgs
if err := json.Unmarshal(body, &args); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}

result, err := controller.SubmitProviderEgressLocation(r.Context(), &args)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
glog.Infof("[pegl]could not write response. err = %s\n", err)
}
}
150 changes: 150 additions & 0 deletions api/handlers/provider_egress_location_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package handlers

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/urnetwork/server"
"github.com/urnetwork/server/controller"
)

func TestProviderEgressLocationSubmitRejectsMissingSecret(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"client_id": "019f8835-158d-6fd8-e9dd-fd0e4c6d6792",
})
req := httptest.NewRequest(http.MethodPost, "/network/provider-egress-location", bytes.NewReader(body))
w := httptest.NewRecorder()

ProviderEgressLocationSubmit(w, req)

if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 when the operator secret header is absent", w.Code)
}
}

func TestProviderEgressLocationSubmitRejectsWrongSecret(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"client_id": "019f8835-158d-6fd8-e9dd-fd0e4c6d6792",
})
req := httptest.NewRequest(http.MethodPost, "/network/provider-egress-location", bytes.NewReader(body))
req.Header.Set(operatorSecretHeader, "definitely-not-the-secret")
w := httptest.NewRecorder()

ProviderEgressLocationSubmit(w, req)

if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 on a wrong operator secret", w.Code)
}
}

// withStubOperatorIngestSecret swaps the package-level operatorIngestSecret
// memo for a stub that always returns secret, and returns a func to restore
// the original (real, still-memoized) reader. This lets a test exercise the
// "configured vault" path without the sync.OnceValue in the real reader ever
// touching the vault, and without the reject tests above (which rely on an
// unconfigured vault) observing any change.
func withStubOperatorIngestSecret(secret string) (restore func()) {
prev := operatorIngestSecret
operatorIngestSecret = func() string { return secret }
return func() { operatorIngestSecret = prev }
}

// TestProviderEgressLocationSubmitAcceptsCorrectSecret proves the auth gate
// can ACCEPT a correct secret and hand off to the controller. Without this
// test, the two reject tests above (which both run with the vault
// unconfigured and take the secret=="" short-circuit) would pass unchanged
// even if the handler's entire body were replaced with an unconditional 401 -
// hmac.Equal would never be proven to run on a real match.
//
// Clearing auth hands the request to controller.SubmitProviderEgressLocation,
// which looks up the client in the database before it can return "Unknown
// client.", so this test needs a real (throwaway) test database - see
// server.DefaultTestEnv, the same harness
// controller/provider_egress_location_controller_test.go uses. t.Setenv makes
// it self-sufficient under a plain `go test`, matching the pattern in
// router/warp_handlers_status_test.go.
func TestProviderEgressLocationSubmitAcceptsCorrectSecret(t *testing.T) {
t.Setenv("WARP_ENV", "local")
server.DefaultTestEnv().Run(t, func(t testing.TB) {
const secret = "correct-operator-secret-0123456789"
defer withStubOperatorIngestSecret(secret)()

// A syntactically valid, semantically unregistered submission: once
// auth clears, the controller looks up the client and (since it does
// not exist in the fresh test database) returns "Unknown client.",
// surfaced by the handler as 400. That 400 is proof the request
// reached the controller, i.e. proof auth passed.
args := controller.SubmitProviderEgressLocationArgs{
ClientId: server.NewId(),
CountryCode: "US",
Country: "United States",
CountryConfident: true,
ObservedAt: server.NowUtc(),
}
body, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %s", err)
}

req := httptest.NewRequest(http.MethodPost, "/network/provider-egress-location", bytes.NewReader(body))
req.Header.Set(operatorSecretHeader, secret)
w := httptest.NewRecorder()

ProviderEgressLocationSubmit(w, req)

if w.Code == http.StatusUnauthorized {
t.Fatalf("status = %d, want the correct secret to clear auth (not 401)", w.Code)
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (Unknown client.) once auth clears for an unregistered client id; body = %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Unknown client.") {
t.Fatalf("body = %q, want it to report the unknown client", w.Body.String())
}
})
}

// TestProviderEgressLocationSubmitRejectsAlteredSecret proves that once the
// vault is configured, hmac.Equal is actually consulted rather than the
// endpoint accepting any request once secret != "". Same configured secret as
// the accept test above, but the request carries a one-character-altered
// value.
func TestProviderEgressLocationSubmitRejectsAlteredSecret(t *testing.T) {
const secret = "correct-operator-secret-0123456789"
const wrongSecret = "correct-operator-secret-0123456780" // last char changed
defer withStubOperatorIngestSecret(secret)()

body, _ := json.Marshal(map[string]any{
"client_id": "019f8835-158d-6fd8-e9dd-fd0e4c6d6792",
})
req := httptest.NewRequest(http.MethodPost, "/network/provider-egress-location", bytes.NewReader(body))
req.Header.Set(operatorSecretHeader, wrongSecret)
w := httptest.NewRecorder()

ProviderEgressLocationSubmit(w, req)

if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 when the operator secret is configured but the request's secret is wrong", w.Code)
}
}

// TestProviderEgressLocationSubmitReadsSecretFromVault proves the vault
// plumbing itself - not the test stub - returns the configured secret:
// readOperatorIngestSecret (the un-memoized reader) reads a
// PushSimpleResource-injected provider_egress.yml.
func TestProviderEgressLocationSubmitReadsSecretFromVault(t *testing.T) {
const secret = "vault-provisioned-secret-abcdef"
pop := server.Vault.PushSimpleResource(
"provider_egress.yml",
[]byte(`ingest_secret: "`+secret+`"`),
)
defer pop()

if got := readOperatorIngestSecret(); got != secret {
t.Fatalf("readOperatorIngestSecret() = %q, want %q", got, secret)
}
}
59 changes: 59 additions & 0 deletions controller/network_client_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package controller

import (
"context"
"net/netip"
// "strings"
"time"

Expand Down Expand Up @@ -56,6 +57,64 @@ func SetConnectionLocation(
connectionId server.Id,
clientIp string,
) error {
// a provider probed through its own egress is located from that probe, not
// from a lookup on its control-connection ip: the egress is where user
// traffic actually exits, and an operator-run prober learns it by routing
// geolocation lookups through the provider itself and cross-checking them
// across several sources, then submits the result here. When a fresh
// probed entry exists we prefer it over the built-in mmdb lookup on the
// control ip. GetFreshProviderEgressLocationForConnection is
// a single query joining network_client_connection to
// provider_egress_location: this runs for every connection (provider or
// not) on the connect-announce path and inside a retry loop, so it must
// not cost the two round trips (client lookup, then egress lookup) the
// naive version would.
if egress := model.GetFreshProviderEgressLocationForConnection(
ctx,
connectionId,
model.ProviderEgressLocationMaxAge,
); egress != nil {
scores := &model.ConnectionLocationScores{}
if egress.Hosting {
scores.NetTypeHosting = 1
}
if egress.Proxy {
scores.NetTypePrivacy = 1
}
// egress.Mobile deliberately does NOT feed NetTypeVirtual: unlike
// Hosting/Proxy, Mobile has no mmdb-path equivalent (IpInfo has no
// Mobile concept; NetTypeVirtual is set from the ipinfo schema's
// is_satellite field only, see GetLocationForIp, and never from
// DB-IP). Deriving NetTypeVirtual from Mobile here would penalize a
// probed mobile provider's ranking with no equivalent penalty for an
// otherwise-identical unprobed one -- the opposite of the parity
// this feature is meant to preserve (see arinForeignScore's doc for
// the same parity reasoning applied to net_type_foreign). Mobile
// stays on the model/wire contract as metadata; it just does not
// feed the ranking score.
// keep the ARIN org-vs-country foreign check on the probed path too,
// so a probed provider is ranked on equal terms with an equivalent
// unprobed one (net_type_foreign feeds the ranking columns). Compute
// it exactly as the mmdb path does in GetLocationForIp: the ARIN org
// country of the control ip against the mmdb country of that SAME
// control ip -- not the probed country, which is a different
// question (whether probing changed the answer) and must not be
// silently folded into this ranking penalty. Any lookup failure
// just leaves NetTypeForeign at 0; it must never fail or panic this
// path.
if addr, err := netip.ParseAddr(clientIp); err == nil {
if ipInfo, err := server.GetIpInfo(addr); err == nil {
scores.NetTypeForeign = arinForeignScore(addr, ipInfo.CountryCode)
}
}
err := model.SetConnectionLocation(ctx, connectionId, egress.LocationId, scores)
if err == nil {
return nil
}
// fall through to the mmdb path on a storage error
glog.Infof("[ncc][%s]could not set probed egress location. err = %s\n", connectionId, err)
}

location, connectionLocationScores, err := GetLocationForIp(ctx, clientIp)
if err != nil {
// server.Logger().Printf("Get ip for location error: %s", err)
Expand Down
Loading