# Clocker (Cloaker) + Safe Page — Implementation Guide

This document describes a **Clocker** (a.k.a. cloaker): a Laravel-based traffic
filter that inspects every visitor, decides whether they are a "real" visitor or
an unwanted one (bot, VPN, proxy, scanner, wrong country, etc.), and either
serves the real site or a neutral **safe page**. It is controlled from an admin
panel built on the **Paces** admin template.

It is written so a developer (or an AI) can rebuild the exact same system on
another server from scratch. Every moving part, table, and file is described
with working code.

Reference implementation in this repo: `/var/www/html/atgal.com` (a Laravel 11
app). Admin UI template: **Paces** (`public/paces/...`, `data-skin="material"`).

---

## 1. What it does (plain English)

Every HTTP request to the protected site passes through one middleware
(`ClockerMiddleware`). For each visitor it:

1. Resolves the real client IP (respecting Cloudflare).
2. Skips all checks if the IP is **whitelisted** (locally or via a remote sync
   from a central admin).
3. On the **first** visit from an IP, enriches it with:
   - Geo/ISP/ASN from a free geo API (`ip-api.com`).
   - Proxy / VPN / bot flags from a paid fraud API (IPQS-compatible endpoint).
   Enrichment results are cached in a `user_logs` row so later visits are fast.
4. Compares the visitor against a single **rule row** (the `clocker` table)
   containing block lists for country, OS, device, browser, IP, user-agent,
   referrer, ISP, ASN, plus boolean **filters** (Proxy / VPN / Bot).
5. If any rule matches → returns the **safe page** (HTTP 200, looks like a normal
   "coming soon"/brochure site). Otherwise → the request continues to the real
   site.
6. Logs every visitor (IP, geo, UA, referrer, visit count, block reason) so the
   admin can review traffic.

The admin can toggle the whole thing on/off, edit rules, and manage the
whitelist from a Paces-styled page with three tabs: **Conditions**, **Filters**,
**Whitelist IPs**.

Design goal: security scanners, ad-network reviewers, sandboxes, and researchers
see an innocuous page; targeted real users see the real site. The same mechanism
doubles as a generic VPN/proxy/bot blocker.

> ⚠️ **Legal / policy note.** Cloaking is against the terms of Google Ads,
> Facebook Ads, and most ad networks, and is frequently used for phishing. Only
> deploy this where you have the legal right to do so. This document is technical
> reference only.

---

## 2. High-level architecture

```
                    ┌─────────────────────────────────────────────┐
   Visitor  ─────►  │  Web server (nginx/apache) → PHP-FPM         │
                    │  MUST fall through to Laravel index.php      │
                    └───────────────┬─────────────────────────────┘
                                    │  every route in the clocker group
                                    ▼
                    ┌─────────────────────────────────────────────┐
                    │  ClockerMiddleware::handle()                 │
                    │  1. real IP (CF-Connecting-IP | ip())        │
                    │  2. whitelist?  ──yes──►  next() (real site) │
                    │  3. first visit? → ip-api + IPQS enrichment  │
                    │  4. match against `clocker` rule row         │
                    │  5. log visitor into `user_logs`             │
                    └───────┬──────────────────────────┬──────────┘
                            │ blocked                   │ allowed
                            ▼                           ▼
                 ┌────────────────────┐     ┌───────────────────────┐
                 │  view('safepage')  │     │  Real app / legacy site│
                 │  HTTP 200          │     │  (LegacySiteController) │
                 └────────────────────┘     └───────────────────────┘

   Admin (Paces UI):  /admin/clocker   → edit rules, filters, whitelist, on/off
                      /admin/visitors  → traffic log
```

**Two-tier deployment (optional).** In the reference setup there is a **main**
Laravel app (the CRM) that holds the authoritative IP whitelist, and one or more
**satellite** sites (like `atgal.com`) that run the Clocker. Satellites fetch the
whitelist from the main app over HTTP with a shared token, so you whitelist your
own office/testing IPs in one place. This is optional — a single site can just
use its local `whitelist_ips` table.

---

## 3. Technology / prerequisites

- PHP 8.2+ and Laravel 11 (works on 10 with trivial changes).
- MySQL/MariaDB (schema below uses MySQL DDL; adjust for Postgres).
- Composer package **`jenssegers/agent`** for UA parsing:
  ```bash
  composer require jenssegers/agent
  ```
- Outbound HTTP allowed from the server (to `ip-api.com` and your IPQS endpoint).
- A fraud-scoring API for proxy/VPN/bot detection. The reference uses an
  IPQS-compatible endpoint (`https://ipqs.publicleads.net/api/v1/check/{ip}`
  returning `proxy`, `vpn`, `bot_status`, `ISP`, `ASN`). You can swap in
  IPQualityScore.com directly, or ip-api.com "pro", or ipinfo, etc.
- **Paces** admin template assets under `public/paces/` (for the admin UI). Any
  Bootstrap admin theme works; Paces is just what the reference uses.

---

## 4. Data model (database)

Four tables. `clocker` holds a **single rule row**. `user_logs` is the visitor
log. `whitelist_ips` is the local bypass list. `countries` powers the
country dropdown. (`site_settings` is incidental to the demo site and optional.)

Migration — `database/migrations/2024_01_01_000001_create_clocker_tables.php`:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Single-row rule set. Every text column is a comma-separated block list.
        DB::statement("
            CREATE TABLE IF NOT EXISTS `clocker` (
              `id` int NOT NULL AUTO_INCREMENT,
              `country`  text        DEFAULT NULL,  -- blocked country NAMES, CSV
              `os`       varchar(255) DEFAULT NULL, -- blocked OS, CSV
              `device`   varchar(255) DEFAULT NULL, -- Desktop/Mobile/Tablet, CSV
              `browser`  varchar(255) DEFAULT NULL, -- Chrome/Firefox/..., CSV
              `ips`      text        DEFAULT NULL,  -- blocked IPs, CSV
              `ua`       text        DEFAULT NULL,  -- blocked user-agent substrings, CSV
              `refferer` text        DEFAULT NULL,  -- blocked referrer substrings, CSV (sic: spelling)
              `isp`      text        DEFAULT NULL,  -- blocked ISP substrings, CSV
              `asn`      text        DEFAULT NULL,  -- blocked ASN substrings, CSV
              `filters`  varchar(255) DEFAULT NULL, -- CSV of: Bot, Proxy, VPN, Black List IPs
              `status`   tinyint     DEFAULT 1,     -- 1 = clocker ON, 0 = OFF (allow all)
              PRIMARY KEY (`id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ");

        // Visitor log + per-IP enrichment cache.
        DB::statement("
            CREATE TABLE IF NOT EXISTS `user_logs` (
              `id` int NOT NULL AUTO_INCREMENT,
              `ip_address`  varchar(255) DEFAULT NULL,
              `country`     varchar(150) DEFAULT NULL,
              `logs`        text        DEFAULT NULL,  -- raw geo JSON
              `ua`          text        DEFAULT NULL,
              `refer_by`    varchar(255) DEFAULT NULL,
              `visite_count` int NOT NULL DEFAULT 0,
              `isp`         varchar(100) DEFAULT NULL,
              `asn`         varchar(100) DEFAULT NULL,
              `isProxy`     varchar(10) NOT NULL DEFAULT 'false',
              `isVPN`       varchar(10) NOT NULL DEFAULT 'false',
              `isBot`       varchar(10) NOT NULL DEFAULT 'false',
              `is_blocked`  tinyint     DEFAULT 0,     -- add if not present
              `block_reason` varchar(255) DEFAULT NULL,-- add if not present
              `create_date` timestamp DEFAULT CURRENT_TIMESTAMP,
              PRIMARY KEY (`id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ");

        // Country reference for the admin dropdown (name + ISO + dial prefix).
        DB::statement("
            CREATE TABLE IF NOT EXISTS `countries` (
              `id` int NOT NULL AUTO_INCREMENT,
              `prefix` varchar(255) DEFAULT NULL,
              `code`   varchar(255) DEFAULT NULL,
              `name`   varchar(255) DEFAULT NULL,
              PRIMARY KEY (`id`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ");
    }

    public function down(): void
    {
        Schema::dropIfExists('clocker');
        Schema::dropIfExists('user_logs');
        Schema::dropIfExists('countries');
    }
};
```

Local whitelist — `database/migrations/xxxx_create_whitelist_ips_table.php`:

```php
Schema::create('whitelist_ips', function (Blueprint $table) {
    $table->id();
    $table->string('ip_address');
    $table->string('label')->nullable();
    // Optional: $table->timestamps();  // the admin UI shows created_at if present
});
```

Seed one empty rule row so `Clocker::first()` returns a record:

```sql
INSERT INTO clocker (status) VALUES (1);
```

Populate `countries` with a standard country list (name column is what the
matcher compares against — it must match the geo API's country names, e.g.
"United States", "Germany"). Any country CSV seeder works.

### Eloquent models

`app/Models/Clocker.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Clocker extends Model
{
    protected $table = 'clocker';
    protected $guarded = [];
    public $timestamps = false;
}
```

`app/Models/Visitors.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Visitors extends Model
{
    protected $table = 'user_logs';
    protected $guarded = [];
    public $timestamps = false;
}
```

`app/Models/WhitelistIp.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class WhitelistIp extends Model
{
    protected $table = 'whitelist_ips';
    protected $guarded = [];
    public $timestamps = false; // set true if you added timestamps() above
}
```

`app/Models/Countries.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Countries extends Model
{
    protected $table = 'countries';
    protected $guarded = [];
    public $timestamps = false;
}
```

---

## 5. The core: `ClockerMiddleware`

This is the heart of the system. `app/Http/Middleware/ClockerMiddleware.php`:

```php
<?php

namespace App\Http\Middleware;

use App\Models\Clocker;
use App\Models\Visitors;
use App\Models\WhitelistIp;
use App\Services\RemoteWhitelistIps;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Jenssegers\Agent\Agent;
use Symfony\Component\HttpFoundation\Response;

class ClockerMiddleware
{
    public function handle($request, Closure $next): Response
    {
        DB::beginTransaction();

        // 1. Real client IP. Behind Cloudflare, ->ip() is the CF edge, so prefer
        //    the CF-Connecting-IP header. (Also configure trustProxies, see §8.)
        $clientIp = $request->header('CF-Connecting-IP') ?? $request->ip();

        // 2a. Whitelist synced from the central admin (optional two-tier setup).
        if (RemoteWhitelistIps::contains($clientIp)) {
            return $next($request);
        }
        // 2b. Local whitelist rows bypass every check.
        if (WhitelistIp::where('ip_address', $clientIp)->exists()) {
            return $next($request);
        }

        $userAgent = $request->userAgent();
        $referer   = $request->headers->get('referer', '');

        // 3. Enrichment cache: only hit external APIs the FIRST time we see an IP.
        $existingVisitor = Visitors::where('ip_address', $clientIp)->first();

        $geoData = null;
        $isp = $asn = '';
        $isProxy = $isVPN = $isBot = false;

        if (!$existingVisitor) {
            // 3a. Free geo/ISP/ASN lookup (no key). Returns country, city, isp, as.
            $ipApiData = @json_decode(file_get_contents(
                "http://ip-api.com/json/{$clientIp}?fields=status,country,countryCode,regionName,city,isp,as,query"
            ));

            $geoData = new \stdClass();
            if ($ipApiData && ($ipApiData->status ?? '') === 'success') {
                $geoData->geoplugin_countryName = $ipApiData->country ?? 'N/A';
                $geoData->geoplugin_countryCode = $ipApiData->countryCode ?? '';
                $geoData->geoplugin_city        = $ipApiData->city ?? '';
                $geoData->geoplugin_regionName  = $ipApiData->regionName ?? '';
                $isp = $ipApiData->isp ?? '';
                $asn = $ipApiData->as ?? '';
            } else {
                $geoData->geoplugin_countryName = 'N/A';
                $geoData->geoplugin_countryCode = '';
                $geoData->geoplugin_city = '';
                $geoData->geoplugin_regionName = '';
            }

            // 3b. Fraud API for proxy / vpn / bot. IPQS-compatible response.
            $ipqsData = null;
            $ipqsKey = env('IPQS_API_KEY');
            if ($ipqsKey && $ipqsKey !== 'your_api_key_here') {
                try {
                    $resp = Http::timeout(5)
                        ->withHeaders(['X-API-Key' => $ipqsKey])
                        ->get("https://ipqs.publicleads.net/api/v1/check/{$clientIp}");
                    $ipqsData = $resp->json();
                    if (!$ipqsData || isset($ipqsData['detail'])) {
                        $ipqsData = null;
                    }
                } catch (\Throwable $e) {
                    $ipqsData = null;
                }
            }

            if ($ipqsData) {
                $isp     = $ipqsData['ISP'] ?? $isp;
                $asn     = $ipqsData['ASN'] ?? $asn;
                $isProxy = $ipqsData['proxy'] ?? false;
                $isVPN   = $ipqsData['vpn'] ?? false;
                $isBot   = $ipqsData['bot_status'] ?? false;
            }
        } else {
            // Reuse cached enrichment from the visitor's first hit.
            $isp     = $existingVisitor->isp;
            $asn     = $existingVisitor->asn;
            $isProxy = $existingVisitor->isProxy;
            $isVPN   = $existingVisitor->isVPN;
            $isBot   = $existingVisitor->isBot;
        }

        // 4. Evaluate rules against the single clocker row (if enabled).
        $isBlocked = false;
        $blockReason = null;

        $clocker = Clocker::first();
        if ($clocker && $clocker->status == 1) {
            $agent = new Agent();
            $agent->setUserAgent($userAgent);
            $browserName = $agent->browser();
            $osName      = $agent->platform();
            $deviceName  = $this->detectDeviceType($userAgent);

            $blockedCountries = $this->parseCSV($clocker->country);
            $blockedOS        = $this->parseCSV($clocker->os);
            $blockedDevices   = $this->parseCSV($clocker->device);
            $blockedBrowsers  = $this->parseCSV($clocker->browser);
            $blockedIPs       = $this->parseCSV($clocker->ips);
            $blockedUAs       = $this->parseCSV($clocker->ua);
            $blockedReferers  = $this->parseCSV($clocker->refferer);
            $blockedISPs      = $this->parseCSV($clocker->isp);
            $blockedASNs      = $this->parseCSV($clocker->asn);
            $filters          = $this->parseCSV($clocker->filters);

            $countryName = $geoData->geoplugin_countryName
                ?? $existingVisitor->country ?? 'N/A';

            if (in_array($clientIp, $blockedIPs)) {
                $isBlocked = true; $blockReason = 'Blocked IP';
            } elseif ($this->matchesInStringArray($userAgent, $blockedUAs)) {
                $isBlocked = true; $blockReason = 'Blocked User Agent';
            } elseif ($this->matchesInStringArray($referer, $blockedReferers)) {
                $isBlocked = true; $blockReason = 'Blocked Referrer';
            } elseif (in_array($countryName, $blockedCountries)) {
                $isBlocked = true; $blockReason = 'Blocked Country';
            } elseif ($this->matchesInStringArray($osName, $blockedOS)) {
                $isBlocked = true; $blockReason = 'Blocked OS';
            } elseif ($this->matchesInStringArray($deviceName, $blockedDevices)) {
                $isBlocked = true; $blockReason = 'Blocked Device';
            } elseif ($this->matchesInStringArray($browserName, $blockedBrowsers)) {
                $isBlocked = true; $blockReason = 'Blocked Browser';
            } elseif ($this->matchesInStringArray($isp, $blockedISPs)) {
                $isBlocked = true; $blockReason = 'Blocked ISP';
            } elseif ($this->matchesInStringArray($asn, $blockedASNs)) {
                $isBlocked = true; $blockReason = 'Blocked ASN';
            } elseif (in_array('Proxy', $filters) && $isProxy) {
                $isBlocked = true; $blockReason = 'Proxy Detected';
            } elseif (in_array('VPN', $filters) && $isVPN) {
                $isBlocked = true; $blockReason = 'VPN Detected';
            } elseif (in_array('Bot', $filters) && $isBot) {
                $isBlocked = true; $blockReason = 'Bot Detected';
            }
        }

        // 5. Log / update the visitor row.
        if ($existingVisitor) {
            $existingVisitor->visite_count += 1;
            $existingVisitor->ua = $userAgent;
            $existingVisitor->refer_by = $referer;
            $existingVisitor->is_blocked = $isBlocked ? 1 : 0;
            $existingVisitor->block_reason = $blockReason;
            $existingVisitor->save();
        } else {
            Visitors::create([
                'ip_address'   => $clientIp,
                'country'      => $geoData->geoplugin_countryName ?? 'N/A',
                'visite_count' => 1,
                'logs'         => json_encode($geoData),
                'ua'           => $userAgent,
                'refer_by'     => $referer,
                'isp'          => $isp,
                'asn'          => $asn,
                'isProxy'      => $isProxy,
                'isVPN'        => $isVPN,
                'isBot'        => $isBot,
                'is_blocked'   => $isBlocked ? 1 : 0,
                'block_reason' => $blockReason,
            ]);
        }

        DB::commit();

        // 6. Serve the safe page (200 OK) or continue to the real site.
        if ($isBlocked) {
            return response(view('safepage'), 200);
        }

        return $next($request);
    }

    private function parseCSV($input): array
    {
        return array_filter(array_map('trim', explode(',', $input ?? '')));
    }

    // Case-insensitive "haystack contains any needle" (substring match).
    private function matchesInStringArray($value, array $haystack): bool
    {
        $value = strtolower((string) $value);
        foreach ($haystack as $item) {
            if ($item !== '' && stripos($value, strtolower($item)) !== false) {
                return true;
            }
        }
        return false;
    }

    private function detectDeviceType($userAgent): string
    {
        $userAgent = strtolower($userAgent);
        if (preg_match('/mobile|iphone|ipod|android.*mobile|blackberry|webos/', $userAgent)) {
            return 'Mobile';
        }
        if (preg_match('/ipad|tablet|android(?!.*mobile)/', $userAgent)) {
            return 'Tablet';
        }
        return 'Desktop';
    }
}
```

### Key behaviours to preserve when porting

- **First-visit enrichment only.** External APIs are called once per IP; the
  result is frozen in `user_logs`. This keeps latency low and API cost down but
  means an IP's proxy/VPN verdict is *sticky*. If you want re-checks, add a TTL:
  re-enrich when `create_date` is older than N hours.
- **Rule precedence** is top-to-bottom `elseif`; the first match wins and sets
  `block_reason`. IP block is checked first, filters last.
- **Substring matching** for UA/referrer/ISP/ASN/OS/device/browser. Country and
  IP use exact `in_array`. This means `ua` = `bot,curl,python` blocks any UA
  containing those words.
- **`status = 0` disables everything** — visitors are still logged, but never
  blocked. Use this as the master kill-switch.
- The safe page is returned with **HTTP 200**, not 403 — a redirect or error
  code is itself a cloaking signal. It must look like a real page.

---

## 6. The whitelist (bypass)

Two layers, both checked before anything else:

### 6a. Local whitelist
Rows in `whitelist_ips`. Managed from the admin Whitelist tab. Simple exact-IP
match. Always add your own office/dev/testing IPs here first.

### 6b. Remote whitelist sync (optional, two-tier)
Satellites pull a central whitelist from the main app so you maintain it once.

Service — `app/Services/RemoteWhitelistIps.php`:

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class RemoteWhitelistIps
{
    public static function contains(string $ip): bool
    {
        $url   = config('services.main_server.whitelist_url');
        $token = config('services.main_server.whitelist_sync_token');
        if (empty($url) || empty($token)) {
            return false; // not a satellite → skip
        }

        $ips = Cache::remember(
            'main_server_whitelist_ips',
            (int) config('services.main_server.whitelist_cache_ttl', 60),
            function () use ($url, $token) {
                try {
                    $res = Http::timeout((int) config('services.main_server.whitelist_timeout', 5))
                        ->withHeaders(['X-Whitelist-Sync-Token' => $token])
                        ->get($url);
                    if (! $res->successful()) return [];
                    $ips = $res->json('ips');
                    return is_array($ips)
                        ? array_values(array_unique(array_filter(array_map('trim', $ips))))
                        : [];
                } catch (\Throwable $e) {
                    Log::warning('main_server_whitelist_fetch_failed', ['message' => $e->getMessage()]);
                    return [];
                }
            }
        );

        return in_array($ip, $ips, true);
    }
}
```

Config — add to `config/services.php`:

```php
'main_server' => [
    'whitelist_url'        => env('MAIN_SERVER_WHITELIST_URL'),
    'whitelist_sync_token' => env('MAIN_SERVER_WHITELIST_SYNC_TOKEN'),
    'whitelist_cache_ttl'  => env('MAIN_SERVER_WHITELIST_CACHE_TTL', 60),
    'whitelist_timeout'    => env('MAIN_SERVER_WHITELIST_TIMEOUT', 5),
],
```

The **main app** exposes a tiny JSON endpoint that returns the IPs, guarded by
the shared token. Minimal example for the central server:

```php
// routes/api.php (or web.php) on the MAIN app
Route::get('/api/internal/whitelist-ips', function (\Illuminate\Http\Request $r) {
    abort_unless(
        hash_equals(
            (string) config('services.main_server.whitelist_sync_token'),
            (string) $r->header('X-Whitelist-Sync-Token')
        ),
        403
    );
    // Return the active IPs from wherever the main app stores them.
    $ips = \App\Models\AllowedIp::where('status', 1)->pluck('ip')->all();
    return response()->json(['ips' => array_values($ips)]);
});
```

If you only run a single site, skip this entirely — leave the two
`MAIN_SERVER_*` env vars empty and `RemoteWhitelistIps::contains()` returns
`false` immediately.

---

## 7. The safe page

A self-contained Blade view at `resources/views/safepage.blade.php`. It must:

- Be a **complete, believable page** (a plain brochure / "coming soon" site).
  Inline all CSS so there are no extra asset requests that could 404.
- Include `<meta name="robots" content="noindex,nofollow">`.
- Return HTTP 200 (handled by the middleware).
- **Not** reference the real product, login, or dashboard.

Skeleton:

```blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ACME Capital · investment · advisory</title>
    <meta name="description" content="ACME — professional advisory services.">
    <meta name="robots" content="noindex,nofollow">
    <style>
        /* inline a small, clean brochure layout: header, hero, services, contact, footer */
        body{font-family:system-ui,Arial,sans-serif;margin:0;color:#333}
        .hero{background:#0f2044;color:#fff;text-align:center;padding:96px 24px}
        /* ...etc... */
    </style>
</head>
<body>
    <header>…logo + nav…</header>
    <section class="hero"><h1>ACME Capital</h1><p>Advisory · Portfolio · Planning</p></section>
    <section class="services">…generic service cards…</section>
    <section class="contact">…phone / email…</section>
    <footer>© ACME</footer>
</body>
</html>
```

The reference `safepage.blade.php` is a full navy/gold brochure for "ATGAL" with
hero, 9 service cards, and contact — copy its structure and swap the branding.

---

## 8. Wiring it up (routing + middleware registration)

### Register the middleware alias
Laravel 11 — `bootstrap/app.php`:

```php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'clocker' => \App\Http\Middleware\ClockerMiddleware::class,
        ]);

        // Behind Cloudflare / a load balancer, trust proxies so ->ip() and
        // the CF header are read correctly.
        $middleware->trustProxies(at: '*');

        // Optional hardening headers on every response.
        $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
    })
    ->withExceptions(function (Exceptions $exceptions) {})
    ->create();
```

Laravel 10 equivalent — add to `$routeMiddleware` in `app/Http/Kernel.php`:
```php
'clocker' => \App\Http\Middleware\ClockerMiddleware::class,
```

### Routes — `routes/web.php`

```php
use App\Http\Controllers\ClockerController;
use App\Http\Controllers\LegacySiteController;
use App\Http\Controllers\VisitorController;

// Safe page MUST be reachable WITHOUT the clocker middleware (so the middleware
// can render it, and so it never recurses).
Route::view('safepage', 'safepage')->name('safepage');

// Admin (protected by auth) — NOT behind the clocker.
Route::middleware(['auth'])->prefix('admin')->group(function () {
    Route::get('clocker', [ClockerController::class, 'index'])->name('clocker');
    Route::post('save_clocker', [ClockerController::class, 'save'])->name('save_clocker');
    Route::post('update_clocker_status', [ClockerController::class, 'update_clocker_status'])
        ->name('update_clocker_status');
    Route::get('visitors', [VisitorController::class, 'index'])->name('visitors');

    // Whitelist add/remove (used by the Whitelist tab).
    Route::post('settings/whitelist-ip', [SiteSettingsController::class, 'addWhitelistIp'])
        ->name('settings.whitelist-ip');
    Route::delete('settings/whitelist-ip/{id}', [SiteSettingsController::class, 'deleteWhitelistIp'])
        ->name('settings.whitelist-ip.delete');
});

// EVERYTHING that should be cloaked goes inside this group.
Route::middleware('clocker')->group(function () {
    Route::get('/', [LegacySiteController::class, 'show'])->defaults('path', '')->name('home');

    // Catch-all for the public site, EXCLUDING admin/login/api/safepage/etc.
    Route::get('{path}', [LegacySiteController::class, 'show'])
        ->where('path', '(?!admin(?:/|$)|login|logout|api|safepage|up|vendor(?:/|$)).+');
});
```

**Critical points:**
- The `safepage` route and all `admin`/`login` routes are **outside** the
  clocker group. If the safe page were behind the clocker, blocked visitors
  would loop.
- The public catch-all uses a negative-lookahead `where()` so it doesn't
  swallow admin/auth/api URLs.
- If you need a specific file to always be readable by an external verifier
  (e.g. an ad-network or antivirus site-verification `.txt`), add an explicit
  route for that literal path *outside* the clocker group.

### Serving a mirrored static site through Laravel (optional)
In the reference, the public site is a static HTML mirror served **through**
Laravel (via `LegacySiteController`) specifically so the clocker middleware runs
on every page. If your real site is a normal Laravel app, you don't need this —
just put your real routes/controllers inside the `clocker` group. The important
architectural rule is: **the web server must route unknown paths to
`index.php`** (nginx `try_files ... /index.php?$query_string;`) so PHP — and
therefore the middleware — sees every request. If nginx/apache serves static
files directly, the clocker never runs for them.

---

## 9. Admin panel (Paces template)

The admin UI is built on the **Paces** admin template (Bootstrap 5,
`data-skin="material"`, assets under `public/paces/`). Any Bootstrap admin theme
works; what matters is the controller + Blade behaviour below.

### Controller — `app/Http/Controllers/ClockerController.php`

```php
<?php

namespace App\Http\Controllers;

use App\Models\Clocker;
use App\Models\Countries;
use App\Models\WhitelistIp;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class ClockerController extends Controller
{
    public function index(Request $request)
    {
        $clocker = Clocker::first();
        $clockerExists = (bool) $clocker;
        if (!$clocker) $clocker = new Clocker();

        $countries = Countries::orderBy('name', 'ASC')->get();

        $selectedCountries = array_values(array_filter(explode(',', $clocker->country ?? '')));
        $selectedDevices   = array_values(array_filter(explode(',', $clocker->device ?? '')));
        $selectedOS        = array_values(array_filter(explode(',', $clocker->os ?? '')));
        $selectedBrowsers  = array_values(array_filter(explode(',', $clocker->browser ?? '')));
        $selectedFilters   = array_values(array_filter(explode(',', $clocker->filters ?? '')));

        $whitelistedIps = WhitelistIp::orderByDesc('id')->get();

        return view('admin.pages.clocker', compact(
            'countries', 'clocker', 'clockerExists',
            'selectedCountries', 'selectedDevices', 'selectedOS',
            'selectedBrowsers', 'selectedFilters', 'whitelistedIps'
        ));
    }

    public function save(Request $request)
    {
        try {
            DB::beginTransaction();
            $clocker = Clocker::first();

            $data = [
                'country'  => $request->input('country'),   // CSV strings from the UI
                'os'       => $request->input('os'),
                'device'   => $request->input('device'),
                'browser'  => $request->input('browser'),
                'ips'      => $request->input('ips'),
                'ua'       => $request->input('ua'),
                'refferer' => $request->input('refferer'),
                'isp'      => $request->input('isp'),
                'asn'      => $request->input('asn'),
                'filters'  => $request->input('filters'),
            ];

            $clocker ? $clocker->update($data) : Clocker::create($data);

            DB::commit();
            return response()->json(['status' => 'true', 'icon' => 'success', 'message' => 'Clocker saved successfully']);
        } catch (\Throwable $th) {
            DB::rollBack();
            return response()->json(['status' => 'false', 'icon' => 'error', 'message' => 'Error saving', 'error' => $th->getMessage()]);
        }
    }

    public function update_clocker_status(Request $request)
    {
        try {
            DB::beginTransaction();
            $status = $request->input('status');
            $clocker = Clocker::first();
            $clocker->status = $status;
            $clocker->update();
            DB::commit();
            $str = $status == 1 ? 'Enabled' : 'Disabled';
            return response()->json(['status' => 'true', 'icon' => 'success', 'message' => "Clocker {$str} successfully"]);
        } catch (\Throwable $th) {
            DB::rollBack();
            return response()->json(['status' => 'false', 'icon' => 'error', 'message' => 'Exception updating status']);
        }
    }
}
```

Whitelist add/remove live in a small `SiteSettingsController` (or fold into
`ClockerController`):

```php
public function addWhitelistIp(Request $request)
{
    $request->validate(['ip_address' => 'required|string']);
    WhitelistIp::create([
        'ip_address' => trim($request->ip_address),
        'label'      => $request->label,
    ]);
    return response()->json(['status' => 'true', 'message' => 'IP whitelisted']);
}

public function deleteWhitelistIp($id)
{
    // The UI may send id="new" for a just-added row; guard for that.
    if (is_numeric($id)) WhitelistIp::where('id', $id)->delete();
    return response()->json(['status' => 'true', 'message' => 'Removed']);
}
```

### The admin Blade page — `resources/views/admin/pages/clocker.blade.php`

Structure (full styling is in the reference file; behaviour summarized here):

- **Header:** page title + a toggle switch bound to `update_clocker_status`
  (the master on/off).
- **Three tabs** (`.ck-tab` buttons switch `.ck-pane` panels):
  1. **Conditions** — a `<form id="save_clocker">` with:
     - Country multiselect (Select2), options from `$countries`.
     - OS / Device / Browser multiselects (fixed option lists).
     - Textareas for IPs, User Agents, Referrers, ISPs, ASNs (comma-separated).
     - "Save Clocker" button → AJAX POST to `save_clocker`.
  2. **Filters** — a `<form id="save_clocker_filters">` with a multiselect of
     `Bot, Black List IPs, Proxy, VPN`. Posts to the **same** `save_clocker`
     endpoint (it re-sends the other fields so they aren't wiped).
  3. **Whitelist IPs** — add form (IP + optional label) → `settings.whitelist-ip`,
     and a table with per-row delete → `settings.whitelist-ip.delete`. A badge
     shows the count.
- Uses **Select2** for the multiselects and **SweetAlert2** for feedback.

The multiselects submit **comma-joined** values so they land in the CSV columns:

```js
fd.append('country', ($('#country').val() || []).join(','));
fd.append('os',      ($('#os').val()      || []).join(','));
// ...same for device, browser, filters...
fd.append('ips',      $('#ips').val().trim());   // textareas already CSV
fd.append('ua',       $('#ua').val().trim());
```

Minimal tab + save wiring (jQuery):

```js
// tab switch
$(document).on('click', '.ck-tab', function () {
    var t = $(this).data('ck-target');
    $('.ck-tab').removeClass('active'); $('.ck-pane').removeClass('active');
    $(this).addClass('active'); $('#' + t).addClass('active');
});

// save conditions
$('#save_clocker').on('submit', function (e) {
    e.preventDefault();
    var fd = new FormData();
    fd.append('country', ($('#country').val()||[]).join(','));
    fd.append('os',      ($('#os').val()||[]).join(','));
    fd.append('device',  ($('#device').val()||[]).join(','));
    fd.append('browser', ($('#browser').val()||[]).join(','));
    fd.append('ips',     $('#ips').val().trim());
    fd.append('ua',      $('#ua').val().trim());
    fd.append('refferer',$('#refferer').val().trim());
    fd.append('isp',     $('#isp').val().trim());
    fd.append('asn',     $('#asn').val().trim());
    fd.append('filters', ($('#filters').val()||[]).join(','));
    $.ajax({ url: "{{ route('save_clocker') }}", type:'POST',
        processData:false, contentType:false, data:fd,
        headers:{ 'X-CSRF-TOKEN': $('meta[name=csrf-token]').attr('content') },
        success:r=>Swal.fire({icon:r.icon,title:r.status=='true'?'Saved':'Failed',text:r.message})
            .then(()=>{ if(r.status=='true') location.reload(); })
    });
});

// master toggle
$('#clocker_status').on('change', function () {
    var on = $(this).is(':checked'); var fd = new FormData(); fd.append('status', on?1:0);
    $.ajax({ url:"{{ route('update_clocker_status') }}", type:'POST',
        processData:false, contentType:false, data:fd,
        headers:{ 'X-CSRF-TOKEN': $('meta[name=csrf-token]').attr('content') } });
});
```

### Visitors screen (traffic log)
`VisitorController@index` simply paginates `user_logs` newest-first and renders a
table: IP, country, ISP, ASN, proxy/vpn/bot flags, visit count, blocked?,
block reason, UA, last seen. This is your audit/troubleshooting view.

```php
public function index()
{
    $visitors = \App\Models\Visitors::orderByDesc('id')->paginate(50);
    return view('admin.pages.visitors', compact('visitors'));
}
```

---

## 10. Configuration / environment

Add to `.env`:

```dotenv
# Fraud scoring API (proxy / vpn / bot). IPQS-compatible.
IPQS_API_KEY=your_real_key

# Satellite-only: pull whitelist from the central admin. Leave blank for single-site.
MAIN_SERVER_WHITELIST_URL="https://main-app.example.com/api/internal/whitelist-ips"
MAIN_SERVER_WHITELIST_SYNC_TOKEN=long_random_shared_secret
MAIN_SERVER_WHITELIST_CACHE_TTL=60
MAIN_SERVER_WHITELIST_TIMEOUT=5

# On the MAIN app (the one exposing the endpoint), set only:
# MAIN_SERVER_WHITELIST_SYNC_TOKEN=long_random_shared_secret
```

`config/legacy_site.php` (only if you serve a static mirror):
```php
return ['root' => env('LEGACY_SITE_ROOT', base_path('legacy_site'))];
```

---

## 11. External services

| Purpose | Reference service | Response fields used | Notes |
|---|---|---|---|
| Geo / ISP / ASN | `ip-api.com` (free) | `country`, `countryCode`, `city`, `regionName`, `isp`, `as` | 45 req/min free; HTTP not HTTPS on free tier |
| Proxy / VPN / Bot | IPQS-compatible endpoint | `proxy`, `vpn`, `bot_status`, `ISP`, `ASN` | Swap for IPQualityScore.com, ipinfo, ipdata, etc. |

To use **IPQualityScore.com** directly, change the enrichment call to their
endpoint (`https://ipqualityscore.com/api/json/ip/{KEY}/{ip}`) and map their
fields (`proxy`, `vpn`, `bot_status`, `ISP`, `ASN`) — the names already line up.

Only the **first** request per IP consumes these APIs (results cached in
`user_logs`).

---

## 12. Optional hardening layers (ship with the reference)

### `SecurityHeaders` middleware (appended globally)
```php
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
```

### `public/js/security.js` (injected into public pages)
Client-side deterrents: disables right-click, text selection, drag, and common
DevTools/view-source/save shortcuts. Cosmetic only — not real security, but it
raises the effort bar for casual scraping. The reference injects it before
`</body>` on served HTML pages.

> These are *defense in depth* extras. The clocker works without them.

---

## 13. Build checklist for a new server

1. **New Laravel app** (or reuse an existing one). `composer require jenssegers/agent`.
2. **Migrations**: create `clocker`, `user_logs`, `whitelist_ips`, `countries`.
   `php artisan migrate`.
3. **Seed**: insert one `clocker` row (`status=1`); seed `countries`; add your
   own IP to `whitelist_ips`.
4. **Models**: `Clocker`, `Visitors`, `WhitelistIp`, `Countries`.
5. **Service**: `RemoteWhitelistIps` (+ `config/services.php` `main_server` block).
   Skip the remote part for single-site.
6. **Middleware**: `ClockerMiddleware` (+ `SecurityHeaders` if wanted).
7. **Register** the `clocker` alias and `trustProxies` in `bootstrap/app.php`.
8. **Safe page**: `resources/views/safepage.blade.php` (believable brochure).
9. **Controllers**: `ClockerController`, `VisitorController`, whitelist actions.
10. **Admin Blade**: `admin/pages/clocker.blade.php` (Paces or any Bootstrap
    theme) + `visitors.blade.php`.
11. **Routes**: safe page + admin **outside** the clocker group; public site
    **inside** it with the negative-lookahead catch-all.
12. **Web server**: ensure unknown paths fall through to `index.php`
    (`try_files $uri $uri/ /index.php?$query_string;`).
13. **`.env`**: `IPQS_API_KEY`, and `MAIN_SERVER_*` if using sync.
14. **Cloudflare** (recommended): proxy the domain so `CF-Connecting-IP` is
    present and the origin IP is hidden.
15. **Test** (see below), then flip the master toggle **on**.

---

## 14. Testing & verification

Because the safe page returns 200, test by inspecting **which body** you get.

```bash
# Whitelisted / normal desktop visitor → should get the REAL site
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120" https://your-site/ | head

# Simulate a blocked User-Agent (after adding e.g. "curl" or "python" to UA rules)
curl -s -A "python-requests/2.31" https://your-site/ | grep -i "safe\|coming\|brochure-marker"

# Simulate a blocked country / ISP: add a test IP to clocker.ips, then request
# from that IP (or temporarily add your own IP to clocker.ips to confirm blocking),
# and confirm you get the safe page. Remove it afterwards.
```

Checklist:
- Add your IP to `whitelist_ips` → you always see the real site regardless of
  rules. (Test this FIRST so you don't lock yourself out.)
- Add your IP to `clocker.ips` (and remove from whitelist) → you see the safe
  page. Confirm `user_logs.block_reason = 'Blocked IP'`.
- Toggle `status = 0` → everybody sees the real site; rows still logged.
- Confirm `/admin/*`, `/login`, `/safepage` are reachable while blocked.
- Watch `user_logs` fill in with geo/isp/asn/proxy/vpn/bot on first hits.

---

## 15. Operational notes, gotchas, caveats

- **Whitelist yourself before enabling.** The master toggle plus a self-IP
  whitelist row is your safety net.
- **Sticky enrichment.** Proxy/VPN/bot verdicts are cached per IP forever unless
  you add a TTL/refresh. Residential IPs recycle — consider periodic cleanup of
  old `user_logs` rows, or re-enrich after N hours.
- **`ip-api.com` free tier is HTTP + rate-limited** (≈45/min). Under heavy first-
  time traffic you'll hit limits; upgrade to a paid/HTTPS tier or a local
  MaxMind DB for volume.
- **Cloudflare / proxies:** without `trustProxies` and `CF-Connecting-IP`,
  every visitor looks like the CF edge IP and geo/rules break. This is the #1
  porting mistake.
- **Country names must match the geo source.** The block list stores country
  *names* ("United States"), compared to `ip-api`'s `country`. Keep the
  `countries` seed names consistent with the geo API.
- **Static files bypass PHP.** If nginx/apache serves `.html`/assets directly,
  the middleware never runs for them. Route the pages you want cloaked through
  PHP (or serve them via a controller like `LegacySiteController`).
- **The safe page must be self-sufficient** — inline CSS, no links to the real
  app, `noindex`. A safe page that 404s its own assets is a giveaway.
- **Never return 403/redirects for blocks** — always 200 with the safe page.
- **DB writes on every request.** `user_logs` grows fast and each hit does a
  read+write inside a transaction. For high traffic, add an index on
  `ip_address`, batch/queue logging, or prune regularly.
- **Ad networks:** if you cloak to pass ad review, expect the account to be
  banned when detected. Reviewers increasingly use residential IPs and real
  browsers that this static rule set will not catch. (Again: policy/legal risk
  is on the operator.)

---

## 16. File map (reference implementation)

| Concern | File |
|---|---|
| Core filter | `app/Http/Middleware/ClockerMiddleware.php` |
| Rule model | `app/Models/Clocker.php` |
| Visitor log model | `app/Models/Visitors.php` (`user_logs` table) |
| Local whitelist model | `app/Models/WhitelistIp.php` |
| Country list model | `app/Models/Countries.php` |
| Remote whitelist sync | `app/Services/RemoteWhitelistIps.php` |
| Sync config | `config/services.php` → `main_server` |
| Admin controller | `app/Http/Controllers/ClockerController.php` |
| Visitor screen | `app/Http/Controllers/VisitorController.php` |
| Admin UI (Paces) | `resources/views/admin/pages/clocker.blade.php` |
| Safe page | `resources/views/safepage.blade.php` |
| DB schema | `database/migrations/2024_01_01_000001_create_clocker_tables.php` + whitelist migration |
| Middleware/route wiring | `bootstrap/app.php`, `routes/web.php` |
| Hardening extras | `app/Http/Middleware/SecurityHeaders.php`, `public/js/security.js` |

---

*End of guide.*
