Writing an IP Module
Plug a geolocation and proxy-detection provider into the one place the product asks where a visitor is, and whether the address is risky.
Overview
An IP module answers two questions about an address. Where is it, and does it look like a proxy, a VPN or a datacentre? Exactly one module is active, stored as modules/ip. Everything else reads it through UserManager::ip_info() and UserManager::is_proxy().
Those answers reach far. The visitor's currency comes from the country code, the login flow challenges a session whose country or city changed, and forms can refuse a proxy.
Three modules ship: WAtlas, WiseIP and ip_api. There is no base class; the contract is the call site.
Prerequisites
- A provider that resolves IPv4 or IPv6 to at least an ISO country code. That field has no fallback.
- Outbound HTTP to that provider, plus a plan for its rate limit.
- Module Anatomy and Module Configuration.
Structure
coremio/modules/IP/AcmeGeo/
├── AcmeGeo.php class AcmeGeo (no base class, no namespace required)
├── config.php ['website' => 'AcmeGeo', 'key' => '']
└── pages/
└── settings.php the credential fields, rendered inside Settings
info() and caches the array.
proxy() when declared, applies the whitelist.
new is never used.
pages/settings.php into the Settings screen.
Walkthrough
1. Scaffold the Module
- Create
coremio/modules/IP/AcmeGeo/withAcmeGeo.php. The class name must match the folder. - Write
config.php. The dropdown uses thewebsitekey. - Add your settings fields as further keys, empty.
2. Implement info()
- Call your provider with the address you were handed; core already resolved it.
- Normalise the answer. Two rules are mandatory:
countryCodelower case, andcityfilled even when the provider gives only a region. - On failure, set
$this->errorand return false.
3. Implement proxy(), or Do Not
- The method is optional. A module that only geolocates works without it; proxy blocking then gives no verdict.
- Two keys look alike:
proxymeans "looks like a proxy",resultmeans "block this". Onlyresultgates anything. - Return the autonomous system as
as, shapedAS15169 Example Org; the whitelist matches the first token.
4. Add the Settings Page
- Create
pages/settings.php, a plain fragment injected into the Settings form. - Name every input
ip_api_config[yourkey]; the name is the config key. - Read current values from
$module->config. - Choose your module in Settings and save. The selection lands in
modules/ip.
Reference
The Two Methods
There is no interface to implement. These are the exact call sites.
public $error; // read by core after a false return
public $config = []; // filled in the constructor from the module's config.php
public function __construct();
// Geolocation. REQUIRED. Return the array below, or false with $this->error set.
// Called from UserManager::ip_info() in classes/UserManager.php.
public function info($ip = '');
// Risk scoring. OPTIONAL - core probes with method_exists() before calling.
// Called from UserManager::is_proxy() in classes/UserManager.php.
public function proxy($ip = '');
// classes/UserManager.php, ip_info()
$ip_module = Config::get("modules/ip");
$obj = Modules::getInstance("IP", $ip_module);
if (!$obj) return ['status' => "error", 'message' => "IP module '{$ip_module}' could not be loaded."];
$result = $obj->info($ip);
if (!$result) {
// A timeout is swallowed as a plain false; anything else is logged and surfaced.
if (stristr($obj->error, 'timed out')) return false;
Modules::save_log("IP", $ip_module, "check", $ip, $obj->error);
return ['status' => "error", 'message' => $obj->error];
}
// classes/UserManager.php, is_proxy()
$proxy_obj = Modules::getInstance("IP", $ip_module);
if ($proxy_obj && method_exists($proxy_obj, 'proxy')) {
$pdata = $proxy_obj->proxy($ip);
if ($pdata === false) $error = $proxy_obj->error;
}
What info() Returns
The keys consumers read, in the form they expect.
| Key | Shape | Who reads it |
|---|---|---|
countryCode | lower case ISO code, e.g. nl | Currency and login location check. Missing means failure. |
city | city, or region when there is no city | Login location check, city precision. |
regionName | region or state name | Display; usually copied into city. |
country | country name in English | Display. |
as | AS15169 Example Org | Proxy whitelist, first token. |
query | the looked-up address | Echo of the input. |
zip, lat, lon, timezone, isp | strings or numbers | Optional; stored, not required. |
Callers treat an empty countryCode as "unknown", but a truthy array still counts as a successful lookup and gets cached. The failure then sticks to that address until the cache file is removed.
What proxy() Returns
The detailed form returns only the three booleans:
// Third argument true asks for the breakdown instead of the bare verdict.
$verdict = UserManager::is_proxy($ip, false, true);
// ['proxy' => bool, 'hosting' => bool, 'risky' => bool]
// 'risky' is your 'result', after the operator's whitelist has been applied.
// The common form, used by the login and registration gates:
if (Config::get('options/proxy-block') && UserManager::is_proxy() === true)
throw new Exception(Language::g('errors/error9'));
Caching and Quota
Core protects your provider before your code runs. These files are also why a fix can look dead.
info() result, per address, no expiry.
proxy(). Delete both when retesting an address.
ip_info() returns an error array and never calls you. options/ip-overload-limit, default 100.
options/proxy-overload-limit, default 100.
Settings Page Contract
operations/AdminGeneralSettings.php).
Example
A complete module, its settings fragment and the consumer side.
<?php
class AcmeGeo
{
public $error;
public $config = [];
public function __construct()
{
$this->config = Modules::Config("IP", __CLASS__);
}
public function info($ip = '')
{
$this->error = null;
$key = (string) ($this->config["key"] ?? '');
$url = "https://api.acmegeo.example/v1/lookup/" . rawurlencode($ip);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: " . $key]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
$this->error = curl_error($ch);
$response = false;
}
curl_close($ch);
if ($response === false) return false;
$data = Utility::jdecode((string) $response, true);
if (!is_array($data)) {
$this->error = "Invalid response from AcmeGeo.";
return false;
}
// No country means no usable answer. Returning a truthy array here would be
// cached as a success and the address would stay broken until the file is removed.
$iso = (string) ($data["country_code"] ?? '');
if (!$iso) {
$this->error = "Country code not found : " . $ip;
return false;
}
$region = (string) ($data["region"] ?? '');
$city = (string) ($data["city"] ?? '');
return [
'status' => "success",
'query' => (string) ($data["ip"] ?? $ip),
'countryCode' => strtolower($iso), // lower case is required
'country' => (string) ($data["country_name"] ?? ''),
'regionName' => $region ?: $city,
'city' => $city ?: $region, // never leave city empty
'zip' => (string) ($data["postal"] ?? ''),
'lat' => $data["latitude"] ?? '',
'lon' => $data["longitude"] ?? '',
'timezone' => (string) ($data["time_zone"] ?? ''),
'as' => isset($data["asn"]) ? trim("AS" . $data["asn"] . " " . ($data["asn_org"] ?? '')) : '',
];
}
public function proxy($ip = '')
{
$this->error = null;
$ch = curl_init("https://api.acmegeo.example/v1/risk/" . rawurlencode($ip));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: " . (string) ($this->config["key"] ?? '')]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
$this->error = curl_error($ch);
$response = false;
}
curl_close($ch);
if ($response === false) return false;
$data = Utility::jdecode((string) $response, true);
if (!is_array($data)) {
$this->error = "AcmeGeo risk lookup failed.";
return false;
}
$score = (int) ($data["score"] ?? 0);
return [
// 'result' blocks, 'proxy' only describes. Do not collapse the two.
'result' => $score >= 60,
'proxy' => $score >= 30,
'hosting' => (bool) ($data["datacenter"] ?? false),
'score' => $score,
'as' => isset($data["asn"]) ? trim("AS" . $data["asn"] . " " . ($data["asn_org"] ?? '')) : '',
];
}
}
<?php
return [
'website' => "AcmeGeo", // the label the Settings dropdown shows
'key' => '', // written back by the save path, never by hand
];
<div class="row mb-3 pb-3 pt-3 border-bottom">
<label for="acmegeo_key" class="col-sm-1 col-form-label text-sm-end">
<span class="d-block fw-semibold">Api Key</span>
</label>
<div class="col-sm-11">
<!-- The name is the config key: ip_api_config[key] lands on config.php as 'key'. -->
<input type="text" class="form-control" id="acmegeo_key"
name="ip_api_config[key]"
value="<?php echo $module->config["key"] ?? ''; ?>">
</div>
</div>
// helpers/Money.php - the visitor's currency comes from the country code.
$info = UserManager::ip_info();
// ip_info() also answers ['status' => 'error', 'message' => ...] when the daily quota is
// spent or the module cannot load: truthy, but with no countryCode. Read it null-safely.
$needle = strtoupper($info["countryCode"] ?? '');
// classes/Auth.php - the login location check, at country or city precision.
$country = is_array($info) ? (string) ($info['countryCode'] ?? '') : '';
$city = is_array($info) ? (string) ($info['city'] ?? '') : '';
if ($country === '') return false; // an unavailable lookup is never treated as a change
Pitfalls
Provisioning, payment and registrar modules throw and their callers catch. The two entry points here do not: they check for a falsy return, then read $this->error. An exception inside info() escapes into the global handler. Set the property, return false.
These lookups run inside ordinary requests. The shipped modules use two to ten seconds. A message containing "timed out" or "timeout" is deliberately swallowed as a plain false, so a slow provider degrades quietly.
The per-address cache files have no expiry, so after the first call your new code is never reached for that address. Delete both files before you measure.
Unlike payment methods, this is a single choice stored in modules/ip. Installing your module switches nothing on: an operator has to pick it first.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.