Skip to content

feat(beta,docs): confine the egress prober in both deployment styles#8

Open
Ryanmello07 wants to merge 9 commits into
feat/probe-due-endpointfrom
feat/probe-deployment-confinement
Open

feat(beta,docs): confine the egress prober in both deployment styles#8
Ryanmello07 wants to merge 9 commits into
feat/probe-due-endpointfrom
feat/probe-deployment-confinement

Conversation

@Ryanmello07

Copy link
Copy Markdown
Owner

Stacked on #7 (feat/probe-due-endpoint), which is stacked on #6, which is stacked on #5. Review only the last commit; retarget to beta/self-contained-env once the parents merge.

P1 Task 4 of the automated confined probing plan.

Why

The prober measures a provider's real egress location by tunnelling through that provider and asking public geolocation APIs what address they see. It must never reach those APIs any other way — a direct lookup records the operator's location for the provider and hands the operator's address to third-party APIs.

The prober now verifies that at startup and refuses to run without evidence. But a self-check only proves a confinement that something else supplies. This PR is that something else, in both deployment styles: beta runs Docker Compose, the mainstream deployment uses no Docker at all, so the confinement cannot be a Docker construct in general.

Beta: docker-compose.beta.yml

  • New egress-prober service on a new prober network with internal: true — no gateway, no NAT, so it reaches api and connect and nothing else. The attachment is the confinement. api and connect join that network too; the prober joins nothing else, publishes no ports, drops all capabilities.
  • Behind the prober compose profile, so beta-setup.sh neither builds nor starts it (it needs a client jwt only an operator can mint).
  • Secrets via a gitignored beta-vault/prober.env (.example template tracked), read as environment so they stay out of docker inspect args and ps.
  • Built from the prober's own repo with named build contexts for the ../connect and ../glog replaces; overridable with PROBER_CONTEXT / CONNECT_CONTEXT / GLOG_CONTEXT.

Mainstream: docs/operator/prober-systemd.md

A complete, copy-pasteable unit: IPAddressDeny=any plus IPAddressAllow= for loopback and the platform only, kernel-enforced through systemd's cgroup BPF filter — no container, no namespace, no capability grant. DynamicUser=yes, NoNewPrivileges=yes, empty CapabilityBoundingSet.

It states that IPAddressAllow takes addresses, not hostnames, so the platform's addresses must be listed literally and updated when they change; that a stale address fails in the safe direction (no probes, no leak); and that widening the rule to cope — or the filter not being enforced at all — is caught by the prober's own self-check, which refuses to start.

The DNS question, answered differently per environment

The self-check fails closed when it cannot resolve the geolocation hosts (ErrNoEvidence: inability to verify is not evidence of confinement). A naive config produces a prober that will not start, so each environment resolves this explicitly:

how why
beta --confinement-address <ip:port> per endpoint an internal: true docker network cannot resolve external names at all — docker's embedded resolver answers SERVFAIL, having no route to forward the query (verified)
systemd allow loopback, so the local stub resolver still answers systemd-resolved is a separate unrestricted service; the check resolves the hosts and then finds it cannot connect to them — the healthy case, and no hand-maintained address list

Neither config sets --skip-confinement-check, and both say why not: a check disabled in a config file is not a check.

Verified live

Against the running beta stack, without stopping, restarting or recreating any existing service (up -d --no-deps egress-prober; docker compose ps showed api/connect/postgres/redis/caddy/taskworker all still at their original uptimes).

Confined — starts and stays up:

egress-prober: confinement self-check: dialing the 4 address(es) given with -confinement-address, skipping resolution: 134.119.216.174:443 104.21.94.136:443 172.67.168.79:443 34.117.59.81:443 (geolocation hosts: ip.pn free.freeipapi.com ipinfo.io -- keep these addresses current with that list)
egress-prober: confinement self-check passed: 4 address(es) tested, none directly reachable
Status=running ExitCode=0 Restarts=0 Nets=server_prober

Not confined — same service, default added to it via a throwaway compose override:

egress-prober: confinement self-check failed: confinement: a direct connection to a geolocation address succeeded; this process is not confined: 134.119.216.174:443
egress-prober: this process must not be able to reach a geolocation api except through a provider tunnel. Confine it ... and start it again.
Status=restarting ExitCode=1 Restarts=7 Nets=server_default server_prober

Also verified: an internal: true network gives SERVFAIL for external names but still resolves and reaches other containers on it (two throwaway containers, HTTP/1.1 200 OK), which is what makes api and connect reachable from the confined prober.

Not verified: the prober actually talking to this deployment's api, because that needs api and connect recreated to join the prober network and the live stack was left untouched. BETA.md documents that one-time step.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg

Ryanmello07 and others added 9 commits July 26, 2026 11:34
The prober measures a provider's real egress location by tunnelling through
that provider and asking public geolocation apis what address they see. It
must never reach those apis any other way: a direct lookup records the
OPERATOR's location for the provider and hands the operator's address to
third-party apis. The prober now verifies this itself at startup and refuses
to run without evidence, but a self-check only proves a confinement that
something else supplies. This is that something else, for both deployment
styles.

Beta (docker compose): a new `egress-prober` service on a new `prober`
network with `internal: true` -- no gateway, no NAT, so the container can
reach `api` and `connect` and nothing else. The attachment IS the
confinement, so `api` and `connect` join that network too and the prober
joins nothing else. Behind the `prober` profile so beta-setup.sh neither
builds nor starts it, since it needs a client jwt only an operator can mint.
Secrets come from a gitignored beta-vault/prober.env, read as environment so
they stay out of `docker inspect` and `ps`.

Mainstream (no docker at all): docs/operator/prober-systemd.md, a complete
unit using IPAddressDeny=any with IPAddressAllow for the platform and
loopback only. Kernel-enforced through systemd's cgroup BPF filter, so it
needs no container, no namespace and no capability grant --
DynamicUser=yes, NoNewPrivileges=yes, empty CapabilityBoundingSet. The doc
states that IPAddressAllow takes addresses and not hostnames, so the
platform's addresses have to be listed literally and updated when they
change, and that widening the rule to cope is caught by the prober's own
self-check.

The two environments answer the DNS question differently, because the
self-check fails closed when it cannot resolve the geolocation hosts
(ErrNoEvidence -- inability to verify is not evidence of confinement):

  - an internal docker network cannot resolve external names at all
    (SERVFAIL, the embedded resolver has no route to forward the query), so
    the beta service passes -confinement-address for each endpoint and the
    check dials known addresses without dns;
  - the systemd unit allows loopback instead, so the local stub resolver
    still answers, the check resolves the hosts and then discovers it cannot
    connect to them -- the healthy case, with no hand-maintained address
    list.

Neither config sets -skip-confinement-check, and both say why not.

Verified against the live beta stack without recreating any existing
service: on the prober network the container starts, logs "confinement
self-check passed: 4 address(es) tested, none directly reachable" and stays
up; with `default` added to it, it logs "a direct connection to a
geolocation address succeeded; this process is not confined" and exits 1 on
every restart.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
Written as "fails with EPERM at connect() time". Running the prober under the
documented unit on a real host shows otherwise: systemd's cgroup BPF filter
DROPS the packet, so every dial runs to its own deadline instead of failing
fast. 6 addresses x --confinement-timeout 3s = ~18s of startup before

  egress-prober: confinement self-check passed: 6 address(es) tested, none
  directly reachable

which matters, because an operator reading "fails at connect() time" would
read an 18s pause as a hang and reach for a shorter --confinement-timeout --
the one change that makes the check report a pass having tested nothing (the
binary refuses anything below 500ms for exactly that reason).

Also records the smoke test's real output (curl: (28) Connection timed out)
rather than "rc != 0", and the healthy log lines.

Same host, same session, the widening mistake the doc warns about was
exercised too: IPAddressAllow=any in place of the platform's addresses ->
"a direct connection to a geolocation address succeeded; this process is not
confined: 134.119.216.174:443", exit 1. The self-check does catch it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
A full active census at 50MB per provider is ~5TB per round at 100k
providers, and because probes are real paid contracts that is money
leaving the operator and arriving at providers. Not worth it for a
ranking input.

transfer_escrow.payout_byte_count already records bytes actually
delivered per contract, so throughput can be derived from real user
traffic at zero additional cost -- and it cannot be inflated without
genuinely serving real users fast. Aggregated per provider only; this is
a new use of billing data, not new collection.

Active probing narrows to what passive cannot answer: providers with no
traffic history, and periodic calibration spot-checks. Sampled rather
than census, 5MB rather than 25MB per target, one target routinely and
the second only to corroborate a suspected result. Lands at ~25GB per
round -- cheaper than the existing platform test while measuring the path
users actually get.

Also records why the existing test is unusable: 512KiB completes inside
TCP slow start above ~50Mbit, so it is precise-looking noise on the wrong
path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
connect only trusts a forwarded client address when it receives BOTH
X-Forwarded-For and X-Forwarded-Source-Port (connect/transport.go:220);
that pair is what the warp load balancer sends. Caddy set only the first,
so connect fell back to the raw peer address and recorded Caddy's private
container address for every client. A private address has no mmdb entry,
so GuessLocationType returned "Unknown location type", no
network_client_location row was written, nothing reached
network_client_location_reliability, and /network/provider-locations
returned zero locations.

Caddy now sends the source port, and the raw 8080/5080/15080 publishes are
removed so the tls hostnames are the only ingress -- a client arriving on
a published port presents the docker gateway address and fails the same
way.

Verified: 80 connected / 80 with_location and zero location errors, from
0 / 40 before.

Note for anyone editing the Caddyfile: it is bind-mounted as a single
file, so replacing it on the host leaves the container on the old inode.
`up -d --force-recreate caddy` is required; an edit plus `caddy reload`
silently reloads the old content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…ble header

Two problems the P1 final review found.

The incident-fix commit dropped `- prober` from api and connect in the
same hunk that removed the published ports, so the committed compose
leaves the prober alone on an internal-only network: it starts, passes
its confinement self-check, and can never reach the server. The running
deployment was only correct because of an uncommitted working-tree edit,
and BETA.md's prescribed `up -d api connect` would have detached the live
containers and silently killed it.

connect trusts X-UR-Forwarded-For ahead of every other source of the
client address (connect/transport.go:220), and the api derives the
session address from the same value. A proxy passes client headers
through verbatim, so with Caddy as the sole ingress any client could set
that header and pin itself to an address of its choosing -- forging its
provider location and evading the per-address rate limits. Caddy now
strips it inbound; only the warp load balancer is entitled to set it and
it is not in this path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…rved_at

Backports two fixes that landed upstream on the provider-egress-geolocation
branch but are missing from beta's older copy of the feature.

1. SetConnectionLocation no longer maps a probed egress's Mobile flag onto
   NetTypeVirtual. Unlike Hosting/Proxy, Mobile has no mmdb-path equivalent:
   IpInfo has no Mobile concept, and NetTypeVirtual is only ever set from the
   ipinfo schema's is_satellite field, never from DB-IP. Deriving it from
   Mobile penalized a probed mobile provider's ranking with no equivalent
   penalty for an otherwise-identical unprobed one -- the opposite of the
   parity this feature exists to preserve. This is live on beta today: a
   probed Philippine provider on Globe Telecoms reports mobile=true and is
   being penalized for having been probed.

2. SubmitProviderEgressLocation now rejects an observed_at further in the
   future than MaxProviderEgressLocationSubmissionSkew (5m). Freshness is
   judged against observed_at, so an unbounded future value defeated every
   other safeguard at once: it always won the monotonic upsert, read as fresh
   forever, and outlived the taskworker sweep, pinning a provider's location
   indefinitely with no API-side recovery. The skew constant tolerates
   ordinary prober/server clock drift without opening that door.

The explanatory comments are carried over from upstream deliberately: both
fixes are non-obvious and read as removable without the reasoning attached.

Tests ported from upstream, each verified to fail without its fix:
TestSetConnectionLocationMapsProbedFlagsToScores now submits Mobile:true and
asserts net_type_virtual stays 0 while hosting/privacy still map, so it proves
the distinction rather than a wholesale deletion; plus
TestSubmitProviderEgressLocationRejectsFutureObservedAt and
...AcceptsWithinSkewObservedAt, the latter guarding against an over-strict
bound that would reject legitimate drift.

Beta-only: upstream already carries both fixes at 6b5de2b.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…y list

The raw 8080/5080/15080 publishes are gone; Caddy is the only ingress. The
docs still told operators to open those ports and gave http:// and ws://
endpoints that no longer answer.

Records why the unpublished ports are load-bearing rather than cosmetic:
connect trusts a forwarded client address only when it receives both
X-Forwarded-For and X-Forwarded-Source-Port, so a client arriving on a
published port presents the docker gateway address, gets no mmdb match,
and never receives a location -- which silently empties the provider list.

Adds a troubleshooting entry for exactly that symptom, including the
single-file bind mount trap: editing the Caddyfile on the host replaces
the inode, the container keeps the old one, and `caddy reload` reports
success while reloading stale content.

Also notes --http1.1 for the websocket check, since HTTP/2 rejects the
Upgrade header and makes a healthy service look broken.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…ders discoverable to their own network

Two production risks in the provider counting/scoring change, found by a
backward-compatibility audit of this PR.

1. Country-only clients were counted three times.

A client whose geo lookup resolved neither a city nor a region is stored
with city_location_id = region_location_id = country_location_id --
SetConnectionLocation writes the coarsest available id into the NOT NULL
city/region columns. Both fan-out loops then walked city, region and
country unconditionally, so one such client added 3 to its own country's
provider count and was inserted three times into its country's scoring
map. The inflation is worst exactly where geo resolution is coarsest:
datacenter, mobile and VPN egress.

Both loops now go through the distinct set of location ids, so a client
counts once per location it is actually in. A genuinely city-granular
client still rolls up into its region and its country unchanged -- that
half is asserted explicitly, because a dedupe keyed on the client rather
than on (client, location) would silently stop city clients counting
toward their country, a worse regression than the one being fixed.

(The per-location and per-group scoring maps are keyed by client id, so
they already absorbed the repeat; the count was the live defect. The
loops are made explicit anyway so the two stay in step.)

2. Network-only providers vanished from their own network's discovery.

The Public-only filter is right for the public provider count -- a
stranger genuinely cannot use a ProvideModeNetwork provider -- but wrong
for the candidate pool. Such a provider serves same-network sources today
via the working CreateContractNoEscrow path and is discoverable to them
now; filtering the pool to Public made it undiscoverable to exactly the
users it exists for. That is a live regression for anyone running
providers for their own organisation.

The pool must also not hand a cross-network caller a provider whose
contract will be refused, so this is not a revert:

  - UpdateClientLocations keeps the Public-only filter. That number is
    shown to everyone and should reflect what a stranger can reach.
  - The UpdateClientScores source queries now admit Public or Network --
    exactly the two modes GetProvideRelationship can return, hence
    exactly what CreateContract can accept -- and record NetworkOnly on
    each ClientScore.
  - FindProviders2 filters at request time: a candidate is eligible if it
    is publicly usable, or if its NetworkId equals the requesting
    session's network. This cannot be baked into the cache: the client
    score redis entries are keyed by (forceMinimum, rankMode, locationId,
    callerLocationId) with no network component, so one cached set is
    shared by callers from every network.

NetworkOnly is stored negated deliberately. The score cache is gob
encoded with a 5h ttl, so entries written before the field existed decode
with the zero value; the zero value therefore has to mean "publicly
usable", or every provider would be treated as network-only until the
cache turned over.

The ClientFilter exported alongside the samples still counts only
publicly usable providers. It is read solely by loadLocationStables,
which decides the public Stable flag GetProviderLocations publishes -- a
public surface, so it follows the same rule as the provider count. A
location whose only supply is network-only is not stable and reports no
providers.

No Stream carve-out: resolveNonCompanionProvideMode does return
companion=true for a Stream-only destination, but that dead-ends at
CreateCompanionTransferEscrow, which requires a pre-existing
reverse-direction origin contract, so a Stream-only destination can never
bootstrap a cross-network session. The inline comments that implied
Stream was the only excluded case are corrected.

Tests: a country-only provider counted exactly once and a city provider
still counted at all three granularities, for both UpdateClientLocations
and UpdateClientScores; and all four visibility cases in FindProviders2
(Public and Network-only providers x same-network and other-network
callers). The two existing pool assertions that encoded the Public-only
pool are updated to the new two-tier contract; their location-stability
assertions are unchanged and still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…yment-confinement

# Conflicts:
#	model/network_client_location_model_test.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant