# Writing an IP Module

https://dev.wisecp.com/es/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](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration).

## Structure

```bash
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.

```php
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 = '');
```

```php
// 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. |

> **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:

```php
// 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.

```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"] ?? '')) : '',
        ];
    }
}
```

```php
<?php
return [
    'website' => "AcmeGeo",   // the label the Settings dropdown shows
    'key'     => '',          // written back by the save path, never by hand
];
```

```html
<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>
```

```php
// 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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module)
- [Writing a Currency Module](https://dev.wisecp.com/en/writing-a-currency-module)
- [Error Handling](https://dev.wisecp.com/en/error-handling)
- [Security Practices](https://dev.wisecp.com/en/security-practices)
