Writing an IP Module

4 views Markdown

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

file layout
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
UserManager::ip_info() Geolocation entry point. Calls info() and caches the array.
UserManager::is_proxy() Risk entry point. Calls proxy() when declared, applies the whitelist.
Modules::getInstance() Builds your object; new is never used.
Modules::getPage() Puts pages/settings.php into the Settings screen.
coremio/modules/IP Your folder goes here.

Walkthrough

1. Scaffold the Module

  1. Create coremio/modules/IP/AcmeGeo/ with AcmeGeo.php. The class name must match the folder.
  2. Write config.php. The dropdown uses the website key.
  3. Add your settings fields as further keys, empty.

2. Implement info()

  1. Call your provider with the address you were handed; core already resolved it.
  2. Normalise the answer. Two rules are mandatory: countryCode lower case, and city filled even when the provider gives only a region.
  3. On failure, set $this->error and return false.

3. Implement proxy(), or Do Not

  1. The method is optional. A module that only geolocates works without it; proxy blocking then gives no verdict.
  2. Two keys look alike: proxy means "looks like a proxy", result means "block this". Only result gates anything.
  3. Return the autonomous system as as, shaped AS15169 Example Org; the whitelist matches the first token.

4. Add the Settings Page

  1. Create pages/settings.php, a plain fragment injected into the Settings form.
  2. Name every input ip_api_config[yourkey]; the name is the config key.
  3. Read current values from $module->config.
  4. 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.

your class
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 = '');
core side
// 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.

KeyShapeWho reads it
countryCodelower case ISO code, e.g. nlCurrency and login location check. Missing means failure.
citycity, or region when there is no cityLogin location check, city precision.
regionNameregion or state nameDisplay; usually copied into city.
countrycountry name in EnglishDisplay.
asAS15169 Example OrgProxy whitelist, first token.
querythe looked-up addressEcho of the input.
zip, lat, lon, timezone, ispstrings or numbersOptional; stored, not required.
No country code means return false

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

result Boolean. The blocking verdict; the only key that stops anything.
proxy Boolean. "Looks like a proxy or VPN." Informational.
hosting Boolean. Datacentre range. Also informational.
as The autonomous system. Clears a risky verdict when whitelisted.
score, verdict Free-form extras. Not read by core, but written into the cache.

The detailed form returns only the three booleans:

calling side
// 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.

temp/ip-log-{ip}.json A successful info() result, per address, no expiry.
temp/{ip}-proxy.json The same for proxy(). Delete both when retesting an address.
coremio/storage/ip-overload-limit.php Date plus counter. Past the cap, ip_info() returns an error array and never calls you. options/ip-overload-limit, default 100.
coremio/storage/proxy-overload-limit.php Same for risk lookups: options/proxy-overload-limit, default 100.
in-request memo A static per-address map; repeat lookups in one request are free.

Settings Page Contract

config.php: website The dropdown label. A missing key yields a blank option.
input name: ip_api_config[key] The name in brackets is the config key.
$module The only variable the fragment receives: your live instance.
get_ip_api_configs() Fetches your fragment (operations/AdminGeneralSettings.php).
recursive merge The posted array is merged over the stored config, so an omitted key keeps its value.

Example

A complete module, its settings fragment and the consumer side.

AcmeGeo.php
<?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"] ?? '')) : '',
        ];
    }
}
config.php
<?php
return [
    'website' => "AcmeGeo",   // the label the Settings dropdown shows
    'key'     => '',          // written back by the save path, never by hand
];
pages/settings.php
<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>
core side, do not copy
// 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

Report failure with the error property, not by throwing

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.

Keep the timeout short: you run while a page loads

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.

A cached lookup hides your change

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.

Only one module is active

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.