IP Attribution

Where an exit IP really is, decided from local IP databases, the vendor's own claim and an echo endpoint, and what that says about each provider.

Every proxy Octoprox manages has an exit IP: the address a target site sees. Country routing (-cc- username suffix) needs to know which country that IP is in. IP attribution answers that with a small pipeline:

  1. Observe. Every code path that learns an exit IP produces an observation: provider discovery, the exit lookup for manually added proxies, the health checker, the locate button, and the optional preflight check.
  2. Attribute. The IP is looked up in the IP databases loaded on the instance (MaxMind, DB-IP, IPinfo, IP2Location). Together with the vendor’s claim and the echo endpoint’s answer, the owning project’s source policy picks the country and decides whether the vendor’s claim is contradicted.
  3. Act. The resolved country lands on the proxy for routing. A contradicted claim sets a flag that a project’s location policy may turn into ineligibility. Every observation feeds per-connector accuracy totals.

Nothing on the request path waits for a database: attribution is a memory-mapped lookup, and observations are buffered in memory, batched through Redis and written to Postgres by one leader instance.

How the pieces fit together

Four flows, drawn separately because they run at different times. The first is the spine every other flow feeds.

Sightings become attributed proxies

flowchart TD
    syncer["Provider syncer<br/>discovery response or vendor IP list<br/>(vendor-operated URL: the country is the claim)"] -->|exit_ip_observed| attributor
    hc["Health checker<br/>check response that echoes the IP"] -->|exit_ip_observed| attributor
    added["Static proxy added without a country"] -->|proxy_added| echo1["One echo request through the proxy"] --> attributor
    attributor["ProxyAttributor<br/>policy = the project owning the connector"] --> resolve
    resolve["GeoService.apply_observation<br/>databases (memory-mapped) + vendor claim + echo country<br/>→ country, source, location record, conflict flag"]
    resolve --> changed{"anything on the<br/>proxy changed?"}
    changed -->|yes| write["Write the proxy row<br/>publish proxy_changed to peers"]
    changed -->|no| nothing["nothing"]
    resolve --> moved{"different IP than<br/>recorded on the proxy?"}
    moved -->|yes| drop["publish exit_ip_changed<br/>preflight drops its cached verdict"]
    resolve --> obs["IpObservation, always"]
    obs --> buffer["In-memory buffer<br/>pushed every 5 s or when half full"]
    buffer --> redis[("Redis list<br/>geo:observations")]
    redis -->|leader, every 30 s| flusher["ObservationFlusher<br/>a hand-out = the proxy's exit differs from<br/>the last one counted (proxy:status exit_ip)"]
    flusher --> raw[("ip_observations<br/>raw, retention window")]
    flusher --> exits[("connector_exit_ips<br/>distinct exits, latest verdict,<br/>first/last seen, reuse")]

A health check, every minute, per proxy

flowchart TD
    get["GET the check URL through the proxy<br/>connector's own URL, else the echo endpoint"] --> status["publish health_check_completed<br/>(status, latency: unchanged behaviour)"]
    status --> carries{"response carries<br/>the caller's IP?"}
    carries -->|"no: target-site URL, no ip path"| done["done"]
    carries -->|yes| sighting["publish exit_ip_observed<br/>(ip, echo country)"]
    sighting --> same{"same IP as discovered_ip<br/>and already attributed?"}
    same -->|yes| skip["skip: no lookup,<br/>no write, no observation"]
    same -->|"no: new exit, or first time"| apply["apply_observation<br/>country, location, conflict recomputed<br/>row written once, observation recorded once"]

So a proxy whose exit never moves costs one lookup and one write in its lifetime. A residential slot costs one per vendor session rotation.

Preflight, per request, when the project turns it on

flowchart TD
    select["Select the upstream<br/>strategy, -cc-/-st-/-city- filter, strict filter"] --> bound{"exit bound to a<br/>vendor session?"}
    bound -->|"no: fixed exit"| fwd0["forward: discovery placed it,<br/>health checks re-read it"]
    bound -->|yes| expected["Expected location<br/>the -cc-, -st-, -city- values, else the vendor's promise<br/>(geo slot, rendered request), else a manual pin"]
    expected -->|none| fwd1["forward: nothing asked or promised"]
    expected -->|some| cached{"verdict cached for<br/>(project, proxy)?"}
    cached -->|yes| reuse["reuse it<br/>preflight_session_ttl_seconds,<br/>dropped early if the exit moves"]
    cached -->|no| echo["Echo request through the proxy"]
    echo -->|fails| fwd2["forward: an echo outage<br/>never blocks traffic<br/>(failure cached 30 s)"]
    echo -->|ip| resolve["Resolve: databases + echo country<br/>(the expectation is left out)<br/>record observation; cache the verdict"]
    resolve -->|"match at every level, or unknown"| fwd3["forward"]
    resolve -->|"mismatch at any level"| mode{"location_preflight"}
    mode -->|report| fwd4["forward anyway;<br/>the proxy is left alone"]
    mode -->|"retry, reject"| mismatch["publish exit_location_mismatch"]
    mismatch --> attributor["ProxyAttributor"]
    attributor -->|"pooled vendor-session slot"| rotate["remove it; the syncer<br/>provisions a fresh session"]
    attributor -->|"dynamic gateway"| leave["leave the row; the session<br/>was this request's alone"]
    mismatch -->|reject| r502["502 Exit country/state/city mismatch"]
    mismatch -->|retry| allowed{"strategy allows moving<br/>this request?"}
    allowed -->|"no: sticky with a session"| r502b["502"]
    allowed -->|yes| reselect["select again, excluding failed proxies<br/>502 after preflight_max_attempts"]
    reselect --> echo

Databases, and what happens when they change

flowchart TD
    upload["Upload in the admin panel"] --> row
    download["Scheduled vendor download"] --> row
    path["Path in the config file<br/>(no row, read in place)"] --> reopen
    row[("geo_databases row + bytes<br/>in Postgres")] -->|geo_database_changed| reopen
    reopen["Every instance: fetch the bytes once per checksum<br/>into geo.cache_dir, memory-map, reopen"] --> reattr["The instance that made the change re-attributes<br/>every proxy with a known IP, offline;<br/>rows written only where the answer moved"]

The periodic full reload re-reads the settings row and the database list as a safety net for dropped events; it hashes nothing unless a checksum or a file’s size or mtime moved. If the listing itself fails, the databases already open stay open until the next reload rather than being closed on a Postgres blip.

How exit IPs are learned, per connector type

Attribution never makes requests of its own to find an IP. It listens for sightings from the code paths that already learn one, and only then looks the IP up. What produces sightings depends on the connector type:

Connector / proxy type How the exit IP is learned Requests through the proxy Vendor claim to verify
Residential and mobile sessions (Oxylabs, Bright Data, Decodo, IPRoyal, NetNut) Never by provisioning: the vendor rotates the exit behind a session and the syncer does not probe it. The health check reports it every minute when the check URL echoes the caller (the attribution echo endpoint by default), and preflight reports it when a project turns preflight on. Health check: one per proxy per health_check_interval, which happens anyway. Preflight: one per (project, proxy) per preflight_session_ttl_seconds, or sooner when a health check sees the exit move. The country the slot was provisioned for, when the connector or a -cc- request geo-targeted it. Untargeted slots make no claim.
Residential and mobile with dynamic sessions (one gateway row, a vendor session per request) Only through preflight. The gateway row is not health-checked, since no single exit speaks for a rotating pool. Every request renders its own vendor session, so an echo through that session, before the request, is the only way to see where the request went: with preflight on, a request carrying -sessid- is echoed once per derived session and cached; a request without one is echoed for the connector’s exit_sample_percent of requests (default 5), uncached. No health checks. Preflight: one per client session per preflight_session_ttl_seconds, plus the sampled share of session-less requests. The -cc- country or, with a country allow-list on the connector, the country rendered for the request. Requests with no country make no claim and are only recorded.
Port-based with IP discovery (Oxylabs ISP and datacenter, Decodo ISP and datacenter) The syncer requests the vendor’s own discovery endpoint (named in the descriptor, e.g. ip.oxylabs.io, ip.decodo.com) through each port when the slot is created, and again on every IP refresh (ip_refresh_interval, hourly by default). The response IP and the vendor’s country arrive as one sighting. One discovery request per slot at creation, one per slot per refresh, plus the health check. The discovery endpoint’s country, since the descriptor marks the URL as vendor-operated (discovery.vendor_operated, the default). A descriptor pointing discovery at a third-party echo unsets it and the country becomes an endpoint candidate instead. With countries listed on the connector, the country the port was kept for.
Port-based with a known-IP API (Bright Data ISP and datacenter) The vendor’s IP list gives each slot its IP and country without any request through the proxy; the syncer announces both. Refresh re-reads the list. Health check only. The country from the vendor’s list.
Proxy lists (Webshare) The list gives host, port and country. The host is normally the exit, but nothing is announced until the first health check reports the caller’s IP. Health check only. The country from the list.
Static connectors (manually added proxies) One request through the proxy to the echo endpoint when the proxy is added without a country (proxy.geo_lookup.enabled), and on the Detect button. Then health checks. One at creation, one per Detect, plus the health check. A country set by hand is treated as the claim and kept for routing.
Cloud instances (AWS, GCP, Azure) Health checks only; the instance’s public address is the exit. Health check only. None. The connector’s declared countries list is not a per-proxy claim.

Two endpoints are therefore in play. Provider descriptors keep the vendor’s discovery endpoint for port-based IP discovery, at slot creation and on each refresh, because it also returns the vendor’s own view of the location. Everything else goes to the attribution echo endpoint (echo_url in the settings): the health check of every connector that names no check URL of its own, the static-proxy lookup, the Detect button and preflight. A port-based proxy is thus probed by the vendor endpoint hourly and by the echo endpoint every minute, which is the same number of requests as before attribution existed; only the destination of the health check changed from httpbin to the echo. Preflight is a project setting, not a per-connector one: with it on, a request that asks for a country, or that lands on a proxy with a promised or pinned country, is verified against the echo once per project and proxy per cache window, whatever the connector type.

Two consequences worth knowing. A health check sighting is applied only when the IP moved or the proxy was never attributed, so a stable proxy costs one database lookup and one write in its lifetime, not one per minute. And a proxy of a connector with declared countries (static, cloud) that attribution places elsewhere gets that country on its row, which country routing then matches on: the declared list only covers proxies whose own location is unknown.

The Detect button on a proxy and Re-attribute proxies on the settings page are the manual paths. Re-attribution is offline: it re-runs the databases over every proxy with a known IP and makes no request.

What each step costs

Step When Cost
Database lookup Every sighting that passes the checks above, every preflight check, every re-attribution Memory-mapped read, microseconds, no I/O
Proxy write to Postgres Only when the resolved country, source, location record, conflict flag or discovered IP changed One row, then one proxy_changed event to peers
Observation Every applied sighting and every preflight check; deduplicated health checks record nothing Appended to an in-memory buffer; pushed to Redis every publish_interval_seconds or as soon as the buffer is half full; written by the leader every flush_interval_seconds, which also maintains the accuracy and exit IP aggregates
Echo request Static proxy creation, Detect, preflight (per cache window), and health checks whose URL is the echo endpoint One HTTP request through the proxy

Databases

Any file in MaxMind’s mmdb format works, from any vendor. Octoprox reads the file’s own metadata to recognise the vendor and normalises each vendor’s record layout into one shape (country, region, city, coordinates, ASN, organisation, anonymity flags). IP2Location’s BIN format is also supported when the optional IP2Location Python package is installed.

Vendor Files Free edition Notes
MaxMind GeoIP2 / GeoLite2 Country, City, ASN, Anonymous IP (mmdb) GeoLite2 with a free account and license key Attribution text required for GeoLite2; redistribution is not permitted, so every install brings its own file
DB-IP Country, City, ASN (mmdb) Lite editions, CC BY 4.0 Same layout as MaxMind
IPinfo Country + ASN, Location, Privacy (mmdb) Country + ASN with a free token Flat record layout, ASN as AS12345
IP2Location DB1 to DB26 (BIN, and mmdb for DB1/DB9), IP2Proxy (BIN) LITE editions BIN needs pip install IP2Location

Databases reach an instance three ways:

  • Upload in Settings → IP attribution. The file is validated by opening it, stored in Postgres, and every instance copies it to its geo.cache_dir and memory-maps it. Peers learn about it over the cross-instance event feed.
  • Scheduled download from the vendor. Register the vendor’s download URL with your own credentials (MaxMind account ID and license key as basic auth, an IPinfo token in the URL). The leader instance downloads it now and again every update_interval_hours, unpacks .tar.gz and .gz containers, and publishes the new file only when its checksum changed. A URL may carry {YYYY} and {MM}, filled in for the current month on each run, for vendors such as DB-IP that name their files by month and offer no fixed URL; when the current month’s file is not published yet the previous month’s is fetched instead.
  • Config file for installs that already run geoipupdate on a shared volume: list the paths under geo.databases. These are never copied and cannot be removed from the UI.

Databases are consulted in priority order, lowest first. The first database with a country answers; the others fill in fields it lacked (a country database plus an ASN database give one merged record). Uploading, enabling, disabling or removing a database re-attributes every proxy with a known exit IP, offline.

Which files to load. Load one geolocation database per vendor. A city file already contains everything the same vendor’s country file does, so never add both: choose the country file when you only route by country and want the smaller footprint, or the city file when you route or verify below the country. State and city routing matches fixed exits on the state code and city the databases record, and preflight can only judge a state or city claim with a city-level database loaded, so any project that targets -st- or -city- wants the city file. Adding a second vendor’s geolocation file is worthwhile: the resolver treats each database as an independent opinion, and answers the databases disagree on are left open rather than confirmed (see the conflict rule below). ASN and anonymity files sit alongside any of these and only fill in fields the geolocation file lacks.

Free editions carry a license attribution; the settings page shows it while such a database is loaded.

Source policy

Three kinds of evidence exist for an exit IP:

  • database: the loaded IP databases;
  • vendor: what the proxy vendor claimed (a country in its proxy list or known-IP API, or the country a slot was provisioned for);
  • endpoint: what the echo endpoint reported when requested through the proxy, for endpoints that know geography.

A source policy is an ordered list of these kinds plus a conflict rule. The first kind with an answer decides the proxy’s country. “Database only” is [database], “prefer the vendor, fall back to databases” is [vendor, database], and so on. A kind left out never decides, but still counts as evidence about the vendor.

The install has a default policy (Settings → IP attribution → Policy & echo). Each project may override the source order and the conflict rule on its Location tab; a proxy is always resolved under the policy of the project that owns its connector. None of the kinds costs a request of its own: vendor claims arrive with the vendor’s proxy list or slot provisioning, and the echo endpoint is the same request discovery and health checks already make to learn the exit IP.

Conflict rule says when a vendor claim counts as contradicted:

  • consensus (default): every independent source agrees with each other and all of them disagree with the vendor. Databases lag on residential ranges, so a single dissenting database is not evidence against a vendor; independent sources that disagree among themselves leave the claim open instead: no verdict, so the vendor is neither cleared nor blamed. A claim no independent source can judge at all, an IP no loaded database knows, is open for the same reason.
  • first: the top-ranked independent source disagrees with the vendor.

A missing vendor claim never conflicts. A country set by hand on a proxy stays the routing country and is treated as the claim to verify.

Per-project policy

Each project chooses how strict to be (project settings → Location):

Setting Values Effect
location_policy off, warn, strict strict makes proxies with a contradicted vendor location ineligible for the project’s requests. warn only flags them.
location_preflight off, report, retry, reject Verify a session’s exit before its first request is forwarded (below).
location_sources ordered list of database, vendor, endpoint, or null Which source decides this project’s proxies’ country; null inherits the install default.
location_conflict_rule consensus, first, or null When a vendor claim counts as contradicted; null inherits the install default.

Preflight

A residential or mobile exit belongs to the session, not to the proxy row, so verifying it means checking the proxy the request was actually routed to. With preflight on, Octoprox makes one echo request through the selected upstream, attributes the IP, and compares it with the location the request requires: the -cc-, -st- and -city- values, else the country the vendor promised for the proxy (a geo-targeted slot, or what a dynamic request rendered) or one an operator pinned by hand. A location that was merely observed is not a requirement, so an untargeted residential slot, which may move countries freely, is not preflighted unless the request names a place.

Only rows whose exit the vendor can move are checked: dynamic-sessions gateways and pooled session slots. A fixed exit (static, ISP, datacenter, list-mode) was placed by discovery and is re-read by every health check that reports its IP, so an echo per request would only repeat what attribution already knows; those rows are never preflighted, whatever was asked or promised.

Every level is judged the same way, under the project’s source policy and conflict rule: the expected place is the claim, the databases and the echo endpoint are the evidence, and the level is contradicted, confirmed, or left open when nothing independent answered there or the independent sources disagreed under consensus. The echo endpoint contributes below the country only when echo_state_path and echo_city_path are set (Octoprox’s own /echo answers state_code and city), and a country-only database never answers there. A confirmed mismatch at any level fails the check; an open level never does.

The verdict is cached in Redis per project and proxy for preflight_session_ttl_seconds (default 600), so a session pays one extra round trip and the rest of its requests pay nothing. That rests on the assumption that a vendor session keeps its exit for about that long. Vendors do not always oblige: a sticky window may be shorter, or the exit may be replaced after an upstream failure. So the cache is also dropped the moment a health check or an IP refresh sees the proxy exiting from a different address (the exit_ip_changed signal), and the next request through that proxy is verified again. The window during which a rotated exit is trusted unverified is therefore at most one health_check_interval, not the whole TTL. Lower the TTL for a tighter bound at the cost of one echo request per proxy per TTL.

  • report records the observation and forwards the request regardless.
  • retry re-selects among the project’s remaining eligible proxies on a mismatch, up to preflight_max_attempts proxies, then answers 502. Whether a request may move is the routing strategy’s call: under sticky a request with a session was promised one exit, so a mismatch fails it instead of moving it; the other strategies allow the move.
  • reject answers 502 Bad Gateway naming the failed level, Exit city mismatch: requested GB, city manchester, observed GB, city london (…), on the first mismatch.

Under retry and reject a mismatch also acts on the proxy. A pooled vendor-session slot (residential, mobile) is rotated: removed, so the provider sync provisions a fresh session in its place. A dynamic gateway row is left alone: the misplaced session was that request’s, and the next request mints another.

An unreachable echo endpoint or an IP no source knows never blocks traffic: preflight only rejects a confirmed mismatch. Rotating pools without a session id cannot be gated this way; their observations still feed provider accuracy.

Dynamic sessions. A connector running a residential or mobile product with dynamic sessions has one gateway row and no slot per exit, so the verdict cache is keyed per project, gateway row, vendor session and expected country: a client that keeps its -sessid- but changes -cc- is verified again. A request with -sessid- derives its vendor session deterministically and is verified once per session per TTL, like a slot. A request without one gets a fresh vendor session that nothing else will ever use, so it is echoed only when sampled (the connector’s exit_sample_percent, 0 never, 100 every one), the verdict is not cached, and when the request named no country there is nothing to verify: the sighting is recorded for accuracy and unique exits and the request forwarded. Sampling thins observation only. Under reject and retry a session-less request that asks for a country, or lands on a connector that promises one through its allow-list, is verified every time regardless of the percentage, at one echo per such request; a sampled guarantee would be none. Under report the percentage applies to every session-less request. Preflight is therefore the only source of exit sightings for dynamic traffic; with preflight off, the connector has no accuracy or unique-exit data at all (the gateway is not health-checked). Sampling below 100 undercounts, and the shortfall cannot be corrected afterwards: an exit handed out once has only the sampled chance of being seen at all, and a repeat is detected only when both hand-outs were sampled, so distinct exits and the reuse share for session-less traffic are both low. The accuracy rate is unaffected, being a proportion of a random sample. GET /geo/exits and GET /geo/accuracy therefore return a coverage object for dynamic connectors (preflight_on, sampled_percent), and the Exit locations views and the connector inspector mark their unique-exit figures “sampled N%” or “preflight off”. The percentage is a connector setting (Exit sampling on a dynamic connector), since the cost lands on that connector’s vendor bill and one pool may carry most of the session-less traffic; set it to 100 for a complete count at the price of one echo per session-less request. A mismatch on a dynamic request is judged like any other for that request (report, retry, reject) but does nothing to the gateway row; under retry a session-less request is retried on the same gateway, since selecting it again mints a fresh vendor session, while a request with a client session moves to another connector or fails as usual. The row itself is left alone: the misplaced session was that request’s alone, and the next request mints another. Nothing is ever written on the gateway row by attribution, so a strict location policy cannot exclude a dynamic connector.

Provider accuracy

Every observation records what the vendor claimed and what attribution resolved, and the exit IP table keeps the latest of those per distinct exit. Provider accuracy is computed from that table: of a connector’s distinct exits last seen in the window, how many carried a vendor claim, and how many of those the latest verdict confirms, contradicts or leaves open (no independent answer, or independent sources that disagree). The same is kept one level down. A claim below the country is a city or state the vendor’s list or discovery endpoint named for an exit, the place a request asked for with -st- or -city-, or one an operator pinned on a static proxy; it is judged by the same policy and conflict rule as the country, against the databases and the echo endpoint, and the observation records the claim, what was resolved at that level, and a verdict with the polarity of the country’s (state_conflict, city_conflict: true contradicted, false confirmed, null when nothing was claimed, nothing independent answered, or the independent sources disagreed under consensus). Routing on a fixed exit reads the resolved state and city, so a policy that ranks the vendor lets its city stand where no database knows one, exactly as for the country. Cities are compared as slugs: the databases’ district entries (Sofia (g.k. Banishora), Sofia (Old City Center), London (Soho): DB-IP has tens per city) lose the parenthesised part and count as their city, and a few spellings of one place are folded together (City of London is london). After such a rule changes, Re-attribute on the IP attribution settings page re-judges every exit under it; the observation log keeps what was judged at the time. The accuracy table shows a country rate, a state rate and a city rate; the two lower rates are confirmed over the exits with a verdict, so a missing city-level database shows as open claims rather than as a bad vendor. Opening a connector’s row shows the judgement per level and every contradicted pair, filterable by level and by place. The observation log and the exit IP table show the country verdict as the row’s badge, since an exit right at the country is usable at the country whatever its city turned out to be, and the state and city verdicts as their own lines under it (“city contradicted”); the verdict filter is the country verdict. Each IP counts once however often it was seen, so a restart, an hourly refresh or a re-attribution does not inflate the numbers, and a re-attribution after a database change rewrites the verdicts rather than adding to a frozen history. For a residential pool that hands out a fresh IP per session this is one judgement per exit; for pools that reuse exits a wrong IP counts once, not once per hand-out. Every user sees it for the current project under Exit locations in the project menu; admins see all projects, with a project filter, under Settings → IP attribution. The connector inspector shows the connector’s own numbers. For a dynamic-sessions connector the unique-exit figures carry a “sampled N%” or “preflight off” mark, since only preflight sees that traffic (see Preflight).

Rates below 100% are normal for residential pools whose ranges databases have not caught up with. A rate that keeps falling, or one vendor far below the others, is a vendor problem.

Unique exits per connector

Every observation also feeds connector_exit_ips: one row per connector and distinct exit IP, with when it was first and last seen, how many times it was newly handed out, and the state of its latest observation: the proxy that held it, how it was seen, what the vendor claimed, what attribution resolved and the verdict. That state is stored on the row so the view is one indexed table rather than a join into the raw history, and it is refreshed by every observation, including re-attribution and preflight, while only hand-outs move the count. The flusher upserts it in the same batch as the accuracy aggregates, and it is kept until exit_ip_retention_days after the IP was last seen (forever by default), so “how many distinct exits has this pool ever given us” survives the raw observation window. Both aggregates are deleted with their connector; the raw observations are not, so what a removed connector did stays visible until retention.

Exit locations has three tabs built on this. Provider accuracy shows, per connector, the distinct exits first seen in the window and ever, and the share of IPs handed out more than once. Exit IPs lists the distinct exits themselves with their latest state, one row per connector and IP, filtered and paged on the server. Observation log is the raw history, one row per sighting, which grows with every restart and IP change and follows the observation retention. Admins see the same three under Settings → IP attribution. GET /geo/exits returns the summary and GET /geo/exits/ips the paged exit list.

sightings counts hand-outs, and the flusher alone decides what is one. It keeps the last exit it counted for each proxy in the proxy’s status hash in Redis (proxy:status:<id>, field exit_ip, the same hash that holds the health fields and goes when the proxy goes) and walks each batch in time order: a sighting is a hand-out when the proxy’s exit differs from that recorded one, or none is recorded yet. Since every instance’s sightings pass through the one leader in order, two instances reporting the same exit for a proxy, seconds or minutes apart, count once and both raw rows are kept. Detect and preflight count only when they find a different exit. Unchanged health checks are skipped before they become observations. Re-attribution is not a sighting at all: nothing looked at the exit, the IP already on the proxy was resolved again. It travels the same list as a judgement that the flusher applies to the exit row’s claim, resolution and verdict only, so it writes no log row, counts nothing and leaves first and last seen where they were. The Exit IPs view shows such a verdict as “via reattribute”. When Redis holds no exit for a proxy yet, after an upgrade or a Redis loss, the flusher asks the exit table instead: an exit already on record with that proxy as its latest holder was counted before and is not counted again. That lookup happens inside the flusher’s own transaction, so nothing runs ahead of it. So the number is how many times the connector gave that IP to a proxy, not how many minutes it was in use. A residential pool that keeps returning the same few thousand exits shows a high reuse share; one drawing on millions shows a low one.

Echo endpoint

Every Octoprox instance serves GET /echo, unauthenticated, returning the caller’s IP as JSON plus the attribution from the loaded databases:

{"ip": "203.0.113.7", "country": "GB", "city": "London", "asn": 12345, "organization": "Example Ltd", "databases": ["…"], "timestamp": "…"}

Requested through a proxy it returns the proxy’s exit. The settings’ echo_url is what the static-proxy lookup, the Detect button, preflight and, when a connector names no check URL of its own, the health checker request. The fresh-install default is https://httpbin.org/ip, which returns the IP and nothing else to every caller; set echo_url to a publicly reachable Octoprox instance and those requests stop depending on a third party. Provider descriptors keep their vendor-specific discovery endpoints for port-based IP discovery, since those also return the vendor’s own view of the location.

The request travels out through the vendor’s proxy and back in from the public internet, so the endpoint must be reachable from there. Installs on a private network run the standalone echo service on a public host instead:

OCTOPROX_ECHO_DATABASES=/data/GeoLite2-City.mmdb octoprox-echo   # same image, listens on :8090

Behind a load balancer the peer address is the balancer’s, so list its addresses in geo.echo.trusted_proxies (or OCTOPROX_ECHO_TRUSTED_PROXIES for the standalone service) and the client IP is read from X-Forwarded-For. The bundled nginx config sets that header on the API listener.

Health checks

Connectors that name no check URL are checked against the echo endpoint, so every check already sees the exit IP; Octoprox reads it and re-attributes the proxy when the IP changed, with no extra request. A dynamic-sessions gateway row is the exception: it is not health-checked at all, and any sighting of it (Detect, preflight) is recorded but never written on the row, whose location would otherwise mean nothing. Connectors whose check URL is a target site rather than an echo endpoint report no IP. For a custom echo-style URL set healthcheck_ip_path (and optionally healthcheck_country_path) in the connector config to the JMESPath of the address in the response, or @text for a plain-text body; the httpbin origin layout is recognised without configuration.

What is stored on a proxy

Metadata key Meaning
country The resolved country routing reads
country_source database, vendor, endpoint or manual
vendor_country, vendor_state, vendor_city What the vendor claimed at each level: its proxy list, known-IP API or own discovery endpoint
endpoint_country, endpoint_state, endpoint_city What a third-party discovery endpoint or the echo reported; a vendor-operated discovery URL writes the vendor_* keys instead
state_code, city The resolved state and city routing reads for -st- and -city-, with state_source and city_source
location Full record from the databases: the first-level subdivision as region (its name) and state_code (its ISO 3166-2 part), city, coordinates, ASN, anonymity flags
manual_location A state code and city an operator pinned by hand on a static proxy; routing matches on them ahead of location
location_conflict The vendor’s claim is contradicted under the conflict rule
location_candidates Each source’s answer, for the inspector

country, location.state_code and location.city (or the manual pin) take part in routing: -cc- matches the country, -st- the state code and -city- the city as a slug. The rest is shown in the inspector.

Backup and restore

The admin backup (Settings > Backup) always carries the attribution settings row and the database records. The two optional boxes decide the rest:

Backup option What it adds
Include history Raw exit IP observations and the per-connector exit IPs with their latest verdicts, alongside proxy and project metrics.
Include IP database files The bytes of every uploaded or downloaded database. Off by default because a city database is tens of megabytes.

A restore without the files keeps the records, so the databases list shows each one without a file and with a load error until it is re-uploaded, or refreshed when it has an update URL. Operator-managed files from the config are not part of the backup at all; they live on disk next to the instance.

Configuration

geo:
  cache_dir: data/geo                 # where this instance caches stored database files
  databases:                          # operator-managed files (optional)
    - path: /etc/octoprox/GeoLite2-City.mmdb
      priority: 10
  defaults:                           # settings of a fresh install; the admin panel edits the live row
    default_sources: [database, vendor, endpoint]
    default_conflict_rule: consensus
    echo_url: https://proxy.example.com/echo
    preflight_max_attempts: 3
  echo:
    enabled: true
    trusted_proxies: [10.0.0.0/8]
  observations:
    publish_interval_seconds: 5
    flush_interval_seconds: 30
    max_buffer: 5000
  updater:
    check_interval_seconds: 3600

The older proxy.geo_lookup settings still seed the echo endpoint of a fresh install, so existing installs keep their endpoint until an admin saves the settings.

API

See the API reference for the /api/v1/geo endpoints: settings, databases, lookup, re-attribution, observations, accuracy and status.