ip-crawler¶
ip-crawler is a Python service that enriches peer IP addresses discovered by nebula with geolocation and network data from the ipinfo.io API, into crawlers_data.ipinfo. It feeds three public-dashboard HOPR marts.
Purpose¶
While nebula discovers which peers exist on the network and captures their IP addresses, ip-crawler adds geographic and organizational context to each IP. This enables analytics such as:
- Geographic distribution of Gnosis Chain nodes by country and city
- Hosting provider and ASN concentration analysis
- Network diversity metrics
- Identification of centralization risks
How It Works¶
- Find un-enriched IPs — a server-side anti-join of
nebula.visitsagainstcrawlers_data.ipinfo, bounded by a memory fuse. There is no state table and no cursor: the anti-join is the state. - Look up once, ever — an IP is queried once, including failures, which are written as permanent negatives so they are never retried.
- Batch lookup — IPs go to the ipinfo.io API in batches, under two per-run caps.
- Write — enriched rows land in
crawlers_data.ipinfo.
Data Schema¶
The crawlers_data.ipinfo table stores enrichment results:
| Column | Type | Description |
|---|---|---|
ip | String | IP address (primary key component) |
hostname | String | Reverse DNS hostname |
city | String | City name |
region | String | Region/state name |
country | String | ISO country code |
loc | String | Latitude,longitude coordinates |
org | String | Organization name |
postal | String | Postal/ZIP code |
timezone | String | IANA timezone identifier |
asn | String | Autonomous System Number |
company | String | Company name |
carrier | String | Mobile carrier (if applicable) |
is_bogon | Boolean | Whether the IP is a bogon (private/reserved) |
is_mobile | Boolean | Whether the IP is a mobile connection |
abuse_email | String | Abuse contact email |
abuse_phone | String | Abuse contact phone |
error | String | Error message if lookup failed |
attempts | UInt8 | Number of lookup attempts |
success | Boolean | Whether the lookup succeeded |
created_at | DateTime | First lookup timestamp |
updated_at | DateTime | Most recent lookup timestamp |
company, carrier and abuse_* are legitimately empty — the current ipinfo plan returns only the nine core fields. The table is a plain MergeTree and does not dedupe: readers need LIMIT 1 BY ip. There are 71 known legacy duplicate rows.
Configuration¶
Required Settings¶
| Variable | Description |
|---|---|
CLICKHOUSE_HOST | ClickHouse server hostname |
CLICKHOUSE_PASSWORD | ClickHouse authentication password |
IPINFO_API_TOKEN | ipinfo.io API token |
ClickHouse Connection¶
| Variable | Default | Description |
|---|---|---|
CLICKHOUSE_PORT | -- | ClickHouse server port |
CLICKHOUSE_USER | -- | Username |
CLICKHOUSE_DATABASE | crawlers_data | Target database |
CLICKHOUSE_SECURE | -- | Use TLS connection |
Processing Settings¶
| Variable | Default | Description |
|---|---|---|
BATCH_SIZE | 50 | Number of IPs per API batch |
REQUEST_TIMEOUT | 10 | Seconds before an API request times out |
MAX_RETRIES | 3 | Maximum retry attempts for failed API requests |
RETRY_DELAY | 5 | Seconds between retries |
IPINFO_RATE_LIMIT | 1000 | Pacing value. The real ipinfo limit is 50,000 per month, not per day; the two per-run caps are the actual budget control |
Fork Digest Configuration¶
| Variable | Default | Description |
|---|---|---|
FORK_DIGESTS | 0x56fdb5e0,0x824be431,0x21a6f836,0x3ebfd484,0x7d5aab40,0xf9ab5f85 | Comma-separated list of Gnosis Chain fork digests to filter for |
Fork digests are updated when the Gnosis Chain undergoes protocol upgrades.
Operating and recovering¶
How it runs¶
A daily CronJob at 02:00 UTC, concurrencyPolicy: Forbid, with a 36,000 s deadline. Runs are stateless and idempotent: every run re-derives its work list from the anti-join, so re-running is always safe. There is no continuous mode in production.
Health — one log line¶
There are zero alert rules and no cron-miss rule. Health is the single run_summary JSON line each run emits. Alert-worthy: no run_summary for 26 h; exit_code != 0; truncated=true on the recent window; clickhouse_errors > 0. phases.sweep.truncated alone is normal while a backlog drains.
The data check is the same as every other source: max(created_at) on crawlers_data.ipinfo should be within a day.
Backfill¶
Backfill a window explicitly (always single-phase):
Run it as a one-shot job cloned from the CronJob (how). Forbid cannot see a hand-created Job, so space manual runs yourself.
This job once caused a platform-wide Code 241 event
Under an older image it paged nebula.visits with a growing NOT IN list, took ~6 h to find nothing, and with concurrencyPolicy: Allow left three runs alive at once, holding the warehouse over its memory limit and producing 241 errors for unrelated jobs. The fix moved the anti-join server-side with a 1 GiB fuse and set Forbid plus the deadline. If you see long ip-crawler runs, treat it as a warehouse risk, not a local one.
The hopr preset has an ordering bug. It reads dbt.int_hopr_nodes and writes a table dbt reads back, so it should run after the dbt cron. It fires at 02:00; dbt runs at 06:00. Not currently harmful for the default preset, which reads nebula.visits, but do not add dbt-reading presets to this schedule without moving it.
Then dbt¶
The three HOPR marts are rebuilt by the nightly run; nothing to do by hand unless the gap is older than the models' incremental window — see dbt reprocess.
Internal runbook
runbooks/27-nebula-and-ip-crawler.md — private repository; carries the cluster-specific commands for this page.
Redeploying¶
On an image roll nothing; the CronJob template changes and the 02:00 UTC run is the first on the new image. Apply outside 01:55-02:10 UTC and never while a Job is active. The decisive proof is the next run succeeds (morning check). Procedure: Redeploying a service.
ClickHouse Table Schemas¶
Table: crawlers_data.ipinfo
Engine: MergeTree() ORDER BY: (ip, updated_at) INDEX: minmax on ip
| Column | Type | Notes |
|---|---|---|
ip | String | IP address |
hostname | String | Reverse DNS hostname |
city | String | City name |
region | String | Region / state name |
country | String | ISO country code |
loc | String | Latitude,longitude coordinates |
org | String | Organization name |
postal | String | Postal / ZIP code |
timezone | String | IANA timezone identifier |
asn | String | Autonomous System Number |
company | String | Company name |
carrier | String | Mobile carrier (if applicable) |
is_bogon | Boolean | Default false; private/reserved IP |
is_mobile | Boolean | Default false; mobile connection |
abuse_email | String | Abuse contact email |
abuse_phone | String | Abuse contact phone |
error | String | Error message if lookup failed |
attempts | UInt8 | Default 1; number of lookup attempts |
success | Boolean | Default true; whether lookup succeeded |
created_at | DateTime | Default now(); first lookup timestamp |
updated_at | DateTime | Default now(); most recent lookup timestamp |