Category: Uncategorized

  • Enterprise Shield – The Evolution – Part 2: The System

    Now that you have seen the why of Enterprise Shield, this post presents the how. By migrating from a simple set of SHELL scripts and flat files, things get more complex, but also far more manageable and scalable.

    Also, by adding in a reporting capability, it is very easy to track who is trying to get in and report on volume, location, network, and type of attack.


    1. The Firewall Architecture: Chains and Sets

    1.1 How Traffic Flows

    Every inbound packet passes through this evaluation sequence before anything else happens. Enterprise Shield inserts itself at position 1 of the INPUT chain, ahead of UFW’s rules.

    Inbound Packet (any source)
             │
             ▼
    ┌─────────────────────────────┐
    │        INPUT chain          │  ← UFW manages this
    │  [position 1] SHIELD-LOGIC ─┼──────────────────────────────────────────┐
    │  [position 2+] UFW rules    │                                          │
    └─────────────────────────────┘                                          │
                                                                             ▼
                                                  ┌──────────────────────────────────────────┐
                                                  │           SHIELD-LOGIC chain             │
                                                  │                                          │
                                                  │  1. ESTABLISHED/RELATED → ACCEPT         │
                                                  │     (existing connections pass through)  │
                                                  │                                          │
                                                  │  2. loopback (lo) → ACCEPT               │
                                                  │                                          │
                                                  │  3. LAN + own IP → ACCEPT                │
                                                  │     (192.168.x.x, 127.x.x.x, public IP)  │
                                                  │                                          │
                                                  │  4. shield_allow → ACCEPT                │
                                                  │     (manually whitelisted CIDRs/IPs)     │
                                                  │                                          │
                                                  │  5. shield_abuseipdb → DROP + LOG        │
                                                  │     (AbuseIPDB flagged IPs, ≥90 score)   │
                                                  │                                          │
                                                  │  6. shield_block → DROP + LOG            │
                                                  │     (ASN blocks + country blocks)        │
                                                  │                                          │
                                                  │  7. shield_penalty → DROP + LOG          │
                                                  │     (time-limited penalty box)           │
                                                  │                                          │
                                                  │  8. shield_azure → AZURE-RATELIMIT       │
                                                  │     (AS8075 / Microsoft Azure)           │
                                                  │                                          │
                                                  │  9. shield_hyperscaler → CLOUD-RATELIMIT │
                                                  │     (AWS, GCP, Oracle, Cloudflare)       │
                                                  │                                          │
                                                  │ 10. RETURN → UFW handles remaining       │
                                                  └──────────────────────────────────────────┘

    Critical ordering note: shield_abuseipdb fires before the Azure and hyperscaler rate-limit rules (steps 8-9). This means a known-bad Azure IP is dropped outright rather than merely rate-limited. This was an explicit design
    decision made during the final production verification.

    1.2 The Rate-Limit Chains

    Traffic that matches Azure or hyperscaler ipsets is not simply blocked — it would break Bingbot (which runs on AS8075) and legitimate cloud-based monitoring tools. Instead, it flows into dedicated rate-limit chains:

    AZURE-RATELIMIT chain
    ├── hashlimit: 3 connections/minute per source IP, burst 2
    ├── If within limit: ACCEPT
    └── If over limit: DROP + LOG [SHIELD_AZURE_LIMIT]
    
    CLOUD-RATELIMIT chain
    ├── hashlimit: 20 connections/minute per source IP, burst 8
    ├── If within limit: ACCEPT
    └── If over limit: DROP + LOG [SHIELD_CLOUD_LIMIT]

    Azure gets a tighter limit (3/min) because it’s the most frequently abused hyperscaler ASN against this server. AWS/GCP/Oracle/Cloudflare get more headroom (20/min) to accommodate legitimate crawler and monitoring traffic.

    Known limitation: HTTP/1.1 keep-alive connections bypass the hashlimit entirely because ESTABLISHED,RELATED packets are accepted at step 1 before they ever reach the rate-limit chains. This is a fundamental iptables constraint, not a bug in Enterprise Shield.

    1.3 The Seven ipsets

    ┌─────────────────-─┬───────────────┬────────────────────────────────────────┐
    │ ipset name        │ type          │ contents                               │
    ├──────────────────-┼───────────────┼────────────────────────────────────────┤
    │ shield_allow      │ hash:net      │ Manually whitelisted CIDRs/IPs         │
    │ shield_abuseipdb  │ hash:net      │ AbuseIPDB flagged IPs (score ≥ 90)     │
    │ shield_block      │ hash:net      │ ASN blocks + 43-country blocks         │
    │ shield_penalty    │ hash:net      │ Time-limited penalty box               │
    │ shield_azure      │ hash:net      │ AS8075 (Microsoft Azure) CIDRs         │
    │ shield_hyperscaler│  hash:net     │ AWS, GCP, Oracle, Cloudflare CIDRs     │
    │ shield_country    │ hash:net      │ Country blocks (may merge with _block) │
    └──────────────────-┴───────────────┴────────────────────────────────────────┘
    
    All sets: maxelem 1000000, hashsize 131072
    Total entries across all sets: ~500,000 CIDRs

    1.4 The LOG Rules and Tag Format

    Every DROP action in SHIELD-LOGIC includes a LOG rule that fires first. Each log message is tagged so rsyslog can route it:

    TagMeaning
    [SHIELD_BLOCK]Dropped by shield_block (ASN or country)
    [SHIELD_ABUSEIPDB]Dropped by shield_abuseipdb
    [SHIELD_PENALTY]Dropped by shield_penalty (penalty box)
    [SHIELD_AZURE_LIMIT]Rate-limited by AZURE-RATELIMIT chain
    [SHIELD_CLOUD_LIMIT]Rate-limited by CLOUD-RATELIMIT chain

    The log line format from iptables looks like:

    Jun 03 14:22:11 creaky2 kernel: [SHIELD_BLOCK] IN=eth0 OUT= SRC=185.220.101.47 DST=x.x.x.x PROTO=TCP DPT=443 ...

    rsyslog matches on [SHIELD_ prefix and routes to /var/log/enterprise_shield/hits.log, which hits_parser.py then consumes every 5 minutes.


    2. The Database Schema

    The SQLite database is the authoritative state of the entire system. If you have the database, you can reconstruct everything.

    2.1 Schema Overview

    shield.db
    │
    ├── cidr_blocks          ← every CIDR the system manages
    ├── asn_registry         ← all 447+ ASNs with their classification
    ├── country_registry     ← 43 countries with ETag cache
    ├── abuseipdb_entries    ← per-IP AbuseIPDB data, independent lifecycle
    ├── firewall_hits        ← raw parsed hits from rsyslog (rolling window)
    ├── hits_hourly          ← aggregated hourly rollup (long-term storage)
    ├── ip_enrichment        ← per-IP enrichment cache (ip-api.com, Shodan)
    ├── campaigns            ← computed attack campaign groupings (rebuilt each run)
    └── system_state         ← all runtime state: timestamps, flags, hashes

    2.2 cidr_blocks — The Core Table

    CREATE TABLE cidr_blocks (
        id              INTEGER PRIMARY KEY,
        cidr            TEXT NOT NULL,          -- e.g. "185.220.0.0/16"
        source          TEXT NOT NULL,          -- 'asn', 'country', 'manual', 'allow'
        source_id       TEXT,                   -- ASN number or country code
        ipset_target    TEXT NOT NULL,          -- which ipset this CIDR belongs to
        first_seen      INTEGER,                -- epoch timestamp
        last_verified   INTEGER,               -- epoch of last successful WHOIS confirm
        UNIQUE(cidr, ipset_target)
    );

    The ipset_target field is what drives the actual ipset membership. When the rebuild runs, it diffs this table against the live ipset state and applies only the delta.

    2.3 asn_registry — ASN Classification

    CREATE TABLE asn_registry (
        asn             TEXT PRIMARY KEY,      -- 'AS3209', 'AS8075', etc.
        name            TEXT,                  -- human-readable name from WHOIS
        classification  TEXT NOT NULL,         -- 'block', 'azure', 'hyperscaler', 'allow'
        last_whois      INTEGER,               -- epoch of last WHOIS lookup
        fail_count      INTEGER DEFAULT 0,     -- consecutive WHOIS failures
        note            TEXT                   -- operator annotation
    );

    The classification field determines which ipset a CIDR ends up in. azureshield_azure, hyperscalershield_hyperscaler, blockshield_block.

    2.4 abuseipdb_entries — Separate Lifecycle

    CREATE TABLE abuseipdb_entries (
        ip              TEXT PRIMARY KEY,
        abuse_score     INTEGER,              -- 0-100 confidence score
        country_code    TEXT,
        last_seen       TEXT,                 -- from AbuseIPDB's "lastReportedAt"
        refreshed_at    INTEGER,             -- epoch of our last fetch
        in_ipset        INTEGER DEFAULT 0    -- currently loaded into shield_abuseipdb?
    );

    AbuseIPDB entries are refreshed 5× daily (at 00:00, 05:00, 10:00, 15:00, 20:00 UTC) because of the free tier’s rate limit on the blacklist endpoint. The confidence threshold is 90 — only IPs with a score of 90 or above are loaded into the ipset. The table currently holds ~10,000 entries.

    2.5 firewall_hits and hits_hourly — The Hit Pipeline

    CREATE TABLE firewall_hits (
        id          INTEGER PRIMARY KEY,
        hit_time    INTEGER NOT NULL,         -- epoch timestamp
        src_ip      TEXT NOT NULL,
        dst_port    INTEGER,
        protocol    TEXT,
        shield_tag  TEXT,                     -- SHIELD_BLOCK, SHIELD_ABUSEIPDB, etc.
        log_line    TEXT                      -- raw log line for debugging
    );
    
    CREATE TABLE hits_hourly (
        hour_bucket INTEGER NOT NULL,         -- epoch rounded to hour
        src_ip      TEXT NOT NULL,
        shield_tag  TEXT NOT NULL,
        hit_count   INTEGER DEFAULT 0,
        PRIMARY KEY (hour_bucket, src_ip, shield_tag)
    );

    Raw hits accumulate in firewall_hits. The hits_rollup.py job runs at 03:00 daily and aggregates rows older than RAW_RETENTION_DAYS (default: 3 days) into hits_hourly, then deletes the raw rows. hits_hourly retains data for AGGREGATE_RETENTION_DAYS (default: 365 days).

    2.6 system_state — No More Flat Files

    CREATE TABLE system_state (
        key     TEXT PRIMARY KEY,
        value   TEXT
    );
    
    -- Key entries:
    -- 'last_rebuild_time'      : epoch of last successful rebuild
    -- 'last_entry_count'       : CIDR count at last successful rebuild
    -- 'rebuild_in_progress'    : '1' if a rebuild is currently running (crash detection)
    -- 'last_public_ip'         : server's external IP at last rebuild
    -- 'config_hash'            : SHA-256 of /etc/enterprise_shield/config.conf
    -- 'last_abuseipdb_refresh' : epoch of last AbuseIPDB refresh
    -- 'hits_parser_position'   : byte offset in hits.log (resume parsing from here)

    The rebuild_in_progress flag is the crash recovery mechanism. If the system reboots
    mid-rebuild, restore.py detects this flag on boot and triggers a full rebuild before
    loading ipsets.


    3. The Rebuild Flow

    3.1 Nightly Rebuild (shield.py rebuild)

    [cron: 30 2 * * *]
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Acquire PID lock                   │
    │  Set rebuild_in_progress = 1 in DB  │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Check config SHA-256 hash          │
    │  If changed: re-import config file  │
    │  Destroy any orphan staging ipsets  │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  WHOIS refresh (only stale ASNs)    │
    │  Staleness threshold: 14 days       │
    │  Typically 2-5 ASNs per night       │
    │  On failure: keep existing CIDRs,   │
    │  increment fail_count               │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Country block refresh              │
    │  ETag conditional GET to GitHub     │
    │  304 Not Modified: skip download    │
    │  200 OK: parse and update DB        │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Compute delta                      │
    │  DB state vs. live ipset state      │
    │  Entries to ADD: new CIDRs in DB    │
    │  Entries to DELETE: removed CIDRs   │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  DELTA SAFETY CHECK                 │
    │  New count < (last × 0.95)?         │
    │  YES → ABORT, preserve current set  │
    │  NO  → proceed                      │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Apply delta to staging ipsets      │
    │  ipset swap staging → live (atomic) │
    │  ipset destroy staging sets         │
    └─────────────────────────────────────┘
             │
             ▼
    ┌─────────────────────────────────────┐
    │  Update DB:                         │
    │  - last_rebuild_time                │
    │  - last_entry_count                 │
    │  - rebuild_in_progress = 0          │
    │  Release PID lock                   │
    └─────────────────────────────────────┘

    3.2 The Atomic Swap

    The ipset swap is the critical section of the rebuild. The live set is never empty:

    shield_block_staging (new CIDRs)
             │
             │  ipset swap shield_block_staging shield_block
             ▼
    shield_block (now has new CIDRs — atomically)
             │
             │  ipset destroy shield_block_staging
             ▼
    (staging set gone)

    If the swap fails for any reason, the original shield_block is untouched and the staging set is left for cleanup on the next run.


    4. The Boot Persistence Architecture

    This is one of the most critical (and most debugged) parts of the system. Getting the ordering wrong leaves the server unprotected between reboot and first cron run.

    4.1 The Two-Service Boot Sequence

    Boot sequence:
    
    kernel loads
        │
        ▼
    systemd-modules-load.service     ← ensures ip_tables, ip_set kernel modules are loaded
        │
        ▼
    local-fs.target                  ← ensures /var/lib/enterprise_shield/ is mounted
        │
        ▼
    enterprise-shield-ipset-restore.service    ← runs restore.py --ipsets-only
        │  Loads all 7 ipsets from SQLite
        │  Sets ipsets before UFW needs them
        │
        ▼
    ufw.service                      ← UFW loads its rules
        │  The ipsets now exist when UFW's before.rules references them
        │
        ▼
    enterprise-shield-chain-restore.service   ← runs restore.py --chains-only
           Rebuilds SHIELD-LOGIC, AZURE-RATELIMIT, CLOUD-RATELIMIT chains
           Inserts "INPUT -j SHIELD-LOGIC" at position 1
           Uses --noflush so UFW's chains are preserved

    4.2 Why the Ordering Matters

    ServiceMust run BEFOREMust run AFTER
    ipset-restoreufw.servicesystemd-modules-load, local-fs.target
    chain-restore(nothing)ufw.service, ipset-restore

    The DefaultDependencies=no setting is not used (a lesson learned from a failed boot cycle) — it was removed too aggressively early and prevented the kernel module loading dependency from being honoured.

    4.3 What restore.py Does

    # restore.py --ipsets-only
    for each ipset in [shield_allow, shield_abuseipdb, shield_block,
                       shield_penalty, shield_azure, shield_hyperscaler]:
        create ipset if not exists
        bulk-load CIDRs from cidr_blocks WHERE ipset_target = ipset
    
    # restore.py --chains-only
    create SHIELD-LOGIC chain (flush if exists)
    add ESTABLISHED/RELATED ACCEPT rule
    add loopback ACCEPT rule
    add LAN + own IP ACCEPT rule  (reads last_public_ip from system_state)
    add shield_allow ACCEPT rule
    add shield_abuseipdb DROP+LOG rule
    add shield_block DROP+LOG rule
    add shield_penalty DROP+LOG rule
    add shield_azure → AZURE-RATELIMIT rule
    add shield_hyperscaler → CLOUD-RATELIMIT rule
    create AZURE-RATELIMIT chain with hashlimit rules
    create CLOUD-RATELIMIT chain with hashlimit rules
    iptables -I INPUT 1 -j SHIELD-LOGIC

    4.4 Crash Recovery

    If the server loses power mid-rebuild:

    Next boot
        │
        ▼
    enterprise-shield-ipset-restore.service
        │
        ▼
    restore.py checks system_state WHERE key='rebuild_in_progress'
        │
        ├── value = '0': normal restore from DB
        │
        └── value = '1': CRASH DETECTED
                 │
                 ▼
            Log CRITICAL to /var/log/enterprise_shield/restore.log
            Load last known-good CIDRs from DB (last successfully committed state)
            Continue with normal restore
            Set rebuild_in_progress = 0

    The last known-good state is whatever was in the database at the time of the crash. Because the DB commit happens after the ipset swap succeeds, any incomplete rebuild simply means the previous night’s CIDRs are loaded — which is correct behaviour.


    5. The Hit Logging Pipeline

    iptables LOG rule fires
        │
        │  Kernel writes to syslog
        ▼
    /var/log/syslog
        │
        │  rsyslog matches: if $msg contains '[SHIELD_'
        ▼
    /var/log/enterprise_shield/hits.log
        │
        │  hits_parser.py runs every 5 minutes via cron
        │  Resumes from byte offset stored in system_state.hits_parser_position
        ▼
    firewall_hits table (SQLite)
        │
        │  hits_rollup.py runs at 03:00 daily
        │  Aggregates rows older than RAW_RETENTION_DAYS into hits_hourly
        │  Deletes aggregated raw rows
        ▼
    hits_hourly table (SQLite)
        │
        │  deep_shield.py runs every 10 minutes
        │  Queries both tables, enriches IPs, computes campaigns
        ▼
    private threat analysis dashboard
    
        │  public_shield.py runs hourly
        ▼
    https://performancezen.com/shield/public_shield.html
    (public-facing summary — sanitised, no internal data)

    5.1 rsyslog Configuration

    # /etc/rsyslog.d/10-enterprise-shield.conf
    :msg, contains, "[SHIELD_" /var/log/enterprise_shield/hits.log
    & stop

    The & stop prevents these messages from also going into /var/log/syslog, keeping the main syslog uncluttered.

    5.2 hits_parser.py — Resumable Parsing

    The parser reads hits.log from the byte offset stored in system_state.hits_parser_position.
    On each 5-minute run, it:

    1. Opens the file, seeks to the stored position
    2. Reads all new lines since the last run
    3. Parses each [SHIELD_*] log line for SRC=, DPT=, PROTO=
    4. Inserts rows into firewall_hits
    5. Updates hits_parser_position to the new end-of-file offset

    If the log file is rotated (via logrotate), the parser detects the file is smaller than the stored offset and resets to position 0.


    6. The AbuseIPDB Integration

    AbuseIPDB operates on a completely separate lifecycle from the main rebuild:

    [cron: 0 0,5,10,15,20 * * *]  (5× daily)
             │
             ▼
    abuseipdb.py refresh
        │
        ├── Query AbuseIPDB /api/v2/blacklist
        │   Parameters: confidenceMinimum=90, limit=10000
        │
        ├── Parse response → list of {ip, abuseConfidenceScore, countryCode}
        │
        ├── Diff against abuseipdb_entries table:
        │   - New IPs: INSERT into table, add to shield_abuseipdb ipset
        │   - Removed IPs: DELETE from table, remove from shield_abuseipdb ipset
        │   - Unchanged IPs: update refreshed_at timestamp only
        │
        └── Log summary: X added, Y removed, Z unchanged

    The AbuseIPDB ipset (shield_abuseipdb) is kept live-updated between the main nightly rebuilds. A known-bad IP that appears on AbuseIPDB is blocked within 5 hours maximum, without waiting for the 2:30AM rebuild.


    7. The Penalty Box

    The penalty box (shield_penalty ipset) handles time-limited blocks — typically IPs that have triggered specific application-layer rules or been manually added for investigation.

    Adding to penalty box:
        shield.py penalty add <ip> [--hours N]  (default: 24 hours)
            │
            ├── INSERT into penalty_entries table with expires_at timestamp
            └── ipset add shield_penalty <ip>
    
    Expiry check (every 15 minutes via cron):
        penalty.py expire
            │
            ├── SELECT from penalty_entries WHERE expires_at < NOW()
            ├── For each expired entry:
            │   ipset del shield_penalty <ip>
            │   DELETE from penalty_entries
            └── Log: "Expired N penalty entries"

    The penalty box also survives reboots — restore.py loads shield_penalty from the database on boot, but only entries whose expires_at is still in the future. Expired entries are not restored.


    8. Module Structure

    All Python modules live at /usr/local/lib/enterprise_shield/:

    /usr/local/lib/enterprise_shield/
    ├── shield.py          ← Main CLI: rebuild, add, add-asn, check, status
    ├── restore.py         ← Boot restore: --ipsets-only, --chains-only
    ├── abuseipdb.py       ← AbuseIPDB refresh daemon
    ├── hits_parser.py     ← rsyslog hits.log → firewall_hits table
    ├── hits_rollup.py     ← firewall_hits → hits_hourly aggregation
    ├── penalty.py         ← Penalty box expiry
    ├── deep_shield.py     ← Private threat analysis dashboard generator
    ├── public_shield.py   ← Public summary dashboard generator
    ├── db.py              ← Database connection and schema management
    └── config.py          ← Configuration constants and file parsing
  • Enterprise Shield: The flow and general process

    So, for those who are interested here is the current processing flow and update cycles for my Enterprise Shield and Bot Filtering setup.

    Currently this setup comfortably supports blocking 425,000 CIDR Blocks and 10K AbuseIPDB IPs, with additional processing for cloud providers that depends on the rate they send traffic through.

    Attackers, do what you will.

    Enterprise Shield — Request Processing Flow A three-layer flowchart showing how incoming requests are processed through the kernel SHIELD-LOGIC chain, Apache mod_rewrite UA filter, and WordPress/Wordfence application layer. Inbound request Kernel — SHIELD-LOGIC chain Trusted source? Loopback · ESTAB/RELATED · LAN · CDN IPs AbuseIPDB penalty box? SHIELD_PENALTY ipset — updated 5 times daily (AbuseIPDB Free API) Blocked ASN or country? blocked_asns ipset — rebuilt nightly at 02:00 UTC Known AI crawler UA? GPTBot · ClaudeBot · Google-Extended · others Apache — mod_rewrite UA filter UA block rules? Bot strings · outdated browsers · attack patterns Application — WordPress / Wordfence / PHP Legitimate request served Wordfence WAF evaluates · WordPress delivers content Accept Silent drop AbuseIPDB match Silent drop ASN / country match rDNS verify Accept or drop 403 GO AWAY!

    Enterprise Shield — Component Update Cycles

    Scheduled refresh intervals and out-of-band injection methods for each protection layer.

    🕐 All scheduled times are UTC
    Component What it controls Update cycle (UTC) Out-of-band injection
    blocked_asns ipset Kernel IP ranges for all ASNs in blocklist_asns.txt, resolved via RADB WHOIS using 8 parallel threads Nightly — 02:00
    • Penalty box (temporary): sudo block_asn.sh AS9009 Live inject. Cleared at next 02:00 UTC run.
    • Permanent block: sudo block_asn.sh --permanent AS9009 Writes to blocklist and injects live. Persists forever.
    Country IP blocks Kernel IPv4 CIDRs for blocked countries from the ipverse GitHub feed, merged into the same blocked_asns ipset Nightly — 02:00
    • CIDR penalty box: sudo block_asn.sh --cidr 1.2.3.0/24 Live inject only. Cleared at next 02:00 UTC run.
    • Add country permanently: Edit BLOCK_COUNTRIES in enterprise_shield.sh and re-run. Takes effect immediately; persists.
    SHIELD_PENALTY ipset Kernel Top abusive IPs from AbuseIPDB API (≥ 90% confidence). Evaluated before the ASN chain in iptables INPUT 5x Daily — :00
    • No manual add. The set is atomically replaced each run. To block an IP immediately, use block_asn.sh --cidr <IP>/32 against the main ipset instead.
    • Force early refresh: sudo /usr/local/bin/abuseipdb_penaltybox.sh
    AI bot verifier Kernel Python daemon on NFQUEUE 10. Intercepts known AI crawler UAs (GPTBot, ClaudeBot, Google-Extended) and verifies via rDNS before allowing or dropping On service restart
    • Add a new AI bot UA: Edit the NFQUEUE rules in enterprise_shield.sh, then: sudo systemctl restart shield-ai-bot.service
    • Rebuild the full chain: sudo /usr/local/bin/enterprise_shield.sh
    mod_rewrite UA rules Apache Apache-level .htaccess and VirtualHost rewrite rules blocking by UA string, version ranges, empty UAs, and attack patterns. Returns 403 GO AWAY! inline — no PHP, no WordPress bootstrap Manual
    • Add bot string: Append a RewriteCond to .htaccess, then: sudo apachectl graceful Takes effect immediately with no dropped connections.
    • Update browser version range: Edit the version regex, then run apachectl graceful. Must cover the ESR floor and current version ceiling.
    • IP block at Apache layer: Add Require not ip <addr> to the VirtualHost config.
    Wordfence WAF WordPress PHP-layer WAF bootstrapped before WordPress via waf/bootstrap.php. Independently evaluates every request surviving Apache, checks Wordfence’s threat database, and serves its own 403 pages from wp-content/wflogs/ Automatic Free: 30-day rule delay.
    Premium: real-time feed.
    • Block an IP immediately: WP-Admin → Wordfence → Blocking → Create a Block → Block by IP No server restart required.
    • Add a custom firewall rule: WP-Admin → Wordfence → Firewall → Custom Patterns Can match on IP, UA, referrer, URL, or request parameter.
    • Force rule sync: WP-Admin → Wordfence → Firewall → Sync Firewall Rules
    Boot persistence: shield-ipset-restoreufw.serviceshield-iptables-restoreshield-ai-bot.service. All ipsets and chains restored on reboot. All times UTC.
  • Enterprise Shield on Dinosaur Hardware

    There’s a certain kind of satisfaction that comes from taking something old and making it do something remarkable. This is the story of how a 2008 MacBook 13” aluminum — a machine that predates the iPhone App Store — ended up running a multi-threaded, self-healing, boot-persistent IP threat blocking system protecting a production web server on Ubuntu 24.04. It took a full day of iterative development, a fair amount of debugging, and one very honest conversation about an 18-year-old piece of hardware.


    The Starting Point

    The project began with a script called Enterprise Shield v11.4. On paper it did what it promised: it blocked traffic from hostile Autonomous System Numbers (ASNs) and geographic regions by maintaining a massive ipset of known-bad IP ranges, then dropping packets matching that set at the firewall level. In practice, it was held together with duct tape.

    The first code review found problems at every layer. There was a truncated grep statement in the country block loop — a literal syntax error that prevented the script from ever completing. The leading-zero stripping logic for CIDR normalisation ran in the wrong order, cleaning data after the validation regex had already rejected it. The script injected custom iptables rules directly while also running ufw --force reset, meaning UFW silently wiped those rules on every reload. And perhaps most practically damaging: it fetched IP data for every ASN serially, sleeping two seconds between each query, making a large blocklist a multi-hour operation.

    The objective was clear: fix everything, make it fast, make it resilient, and make it understand its own hardware.


    Understanding the Hardware

    Before optimising anything, we needed to understand what we were working with. The machine is a 2008 MacBook with a Core 2 Duo processor — a 64-bit dual-core chip from the era when 4GB of RAM was considered ambitious. This one has been upgraded to 8GB, which turned out to matter significantly for one specific decision later.

    The Core 2 Duo changes the calculus on parallelism. Modern CPUs handle process spawning cheaply. On a processor from 2008, every subprocess fork is measurably expensive, and context switching between background jobs has real overhead. This shaped nearly every optimisation decision that followed: eliminate unnecessary subprocess forks, use bash builtins instead of external binaries wherever possible, and be conservative with thread counts.

    It also runs Ubuntu Server 24.04, which introduced a subtle wrinkle: the system ships with iptables-nft, a compatibility shim that translates iptables commands into nftables rules. Early in the project we suspected this would break the ipset integration — specifically the --match-set rule that does the actual packet dropping. A quick check of the live chain output confirmed it was working:

    93  5448 DROP  ...  match-set blocked_asns src

    Those 93 drops told us the integration was solid. We moved on.


    Phase 1: Making It Correct

    The first rewrite — v11.5 — focused entirely on correctness before touching performance.

    The truncated grep was fixed. The UFW/iptables conflict was documented and mitigated by injecting the ipset DROP rule into /etc/ufw/before.rules, making it survive UFW reloads. The leading-zero stripping was reordered so it ran before validation, not after. The ipset restore file was given a flush directive so stale entries from previous partial runs couldn’t accumulate. The country feed fetches were given --fail flags so 404 error pages didn’t silently pass through as IP data.

    Most importantly: the script was given a proper trap ... EXIT so temp files were always cleaned up, the root check was moved to the absolute first line, and every (( counter++ )) was replaced with counter=$(( counter + 1 )) — because in bash, arithmetic that evaluates to zero returns exit code 1, which set -e interprets as a fatal error.


    Phase 2: Making It Fast

    With a correct foundation, the next challenge was the whois lookup bottleneck. The serial version queried RADB one ASN at a time with a two-second sleep between each. With 152 ASNs in the blocklist, that’s over five minutes of wall clock time before any actual data processing begins.

    The first parallel version — v11.6 — used export -f to pass a bash worker function into xargs -P subshells. It looked right. It wasn’t. On many systems, xargs subshells don’t reliably inherit exported bash functions. Workers spawned successfully, registered their completion files, and wrote nothing. The blocklist came back at roughly one-third of its expected size. The failure was completely silent.

    The fix was architectural. Instead of relying on function inheritance, the worker logic was written to a self-contained bash script at runtime — /tmp/shield_whois_worker.sh — and each background job executed that file directly. No inheritance, no environment dependencies, no silent failures.

    The second parallel problem was subtler: all threads were hitting RADB simultaneously, triggering connection throttling that caused empty responses with no error code. RADB doesn’t say “rate limited.” It just stops returning data. The solution was per-worker random jitter (0–2.5 seconds) combined with inter-batch pausing — every 20 dispatches, all active workers drain and a 3-second pause lets RADB’s connection count settle before the next batch opens.

    The final thread count settled at 4. Eight threads was causing the silent data loss. Four threads with batching gives full coverage with no throttling, and on a Core 2 Duo the overhead of managing 4 concurrent background jobs is well within budget.


    Phase 3: Making It Resilient

    A firewall system that runs once nightly creates a specific failure mode: if something goes wrong with a data source — RADB is slow, a country feed returns an error, the network hiccups — the next scheduled run could silently shrink the blocklist without anyone noticing.

    The delta check was the answer. After every run, the entry count is written to /var/lib/shield/last_entry_count. The following night, before committing the new ruleset, the script compares. If the new count is more than 10% below the previous run, the atomic swap is aborted entirely — the existing live ipset is preserved untouched — and an alert is written to a separate log file.

    “Atomic swap” is the key phrase here. The shield script never modifies the live ipset directly. It builds a complete replacement set in /tmp, populates it, then executes ipset swap blocked_asns-temp blocked_asns — a single kernel operation that is instantaneous and never leaves the firewall in a partially-updated state. The machine is always either running the old ruleset or the new one. There is no window where it’s running neither.


    Phase 4: Surviving Reboots

    This is where the project surfaced its most interesting architectural gap.

    The ipset kernel module stores its data entirely in memory. Every reboot wipes it. The script saves a snapshot to /etc/ipset.conf after each run, but nothing was loading that snapshot back on boot. The result: after every reboot, the machine came up with an empty blocked_asns set. UFW loaded its rules, including the DROP rule that referenced blocked_asns — but the set it referenced didn’t exist. Traffic flowed freely until 2AM when the cron job fired.

    The fix required two systemd services with precise ordering:

    shield-ipset-restore.service   (Before ufw.service)
        └── ufw.service
              └── shield-iptables-restore.service  (After ufw.service)

    The ipset service runs before UFW and loads the saved set. The iptables service runs after UFW and rebuilds the custom SHIELD-LOGIC iptables chain using iptables-restore --noflush, which merges the saved rules into UFW’s ruleset without disturbing UFW’s own chains.

    Both services include first-boot guards: if their respective state files don’t exist yet (fresh install before the first cron run), they exit cleanly rather than failing and potentially delaying UFW startup.

    After the first reboot with both services running, verification was clean:

    Active: active (exited)   ← correct for a oneshot service
    status=0/SUCCESS
    shield-ipset-restore: blocklist restored from /etc/ipset.conf

    Phase 5: The Operational Tooling

    A blocking system is only as useful as its ability to respond to threats that aren’t in the scheduled blocklist. The companion tool — block_asn.sh — evolved through five versions across the session.

    The original script had several problems: it saved to the wrong path (meaning penalty box entries vanished on reboot), it validated IP addresses with a pattern that accepted octets above 255, and it made one kernel call per route which was painfully slow for large ASNs.

    The rewrite introduced two distinct modes:

    Penalty box — adds ASN routes directly to the live ipset. No file writes. Effective immediately. Cleared automatically on the next 2AM cron run when the ipset is rebuilt from scratch.

    Permanent — does everything the penalty box does, plus appends the ASN to /etc/blocklist_asns.txt with a timestamp and an operator-supplied reason note. Persists forever.

    Later, a third mode was added: --cidr accepts a single IP range for penalty box injection. CIDRs are never written to the permanent blocklist by design — they’re too specific and ephemeral for a long-term list.

    The most important optimisation was replacing the per-route injection loop with a single ipset restore call. For a 500-route ASN, the old approach was 500 process forks and 500 kernel netlink calls. The new approach is one of each. The practical difference is roughly 5 seconds versus 50 milliseconds.

    A before/after entry count snapshot provides transparent reporting on every injection — you know exactly how many routes were genuinely new versus already present.


    The Bug That Was Hiding Everywhere

    Late in the project, a test with CIDR 186.179.0.0/18 failed validation with “Invalid CIDR.” Tracing through the normalisation pipeline revealed a bug that had been quietly corrupting data all along.

    The perl zero-stripping substitution s/(^|\.)0+\./$1./g was intended to fix malformed octets like 023.23. from RADB output. Instead, it matched any zero octet followed by a dot — including valid ones. 103.0.0.0/24 became 103..0.0/24. 5.0.0.0/8 became 5..0.0/8. Both silently failed validation and were dropped.

    Every network with a zero in a non-terminal octet position — and there are many — had been invisible to the blocklist since the normalisation code was written.

    The fix changes 0+ to 0+([0-9]), requiring the match to include at least one additional digit after the leading zeros. Lone zeros are left alone. The fix was applied to both enterprise_shield.sh and block_asn.sh.

    # Before (broken)
    perl -pe 's/(^|\.)0+\./$1./g'
    
    # After (correct)
    perl -pe 's/(^|\.)0+([0-9])\./$1$2./g'

    Results

    At the end of the session, the system was running with:

    • 343,966 blocked IP ranges loaded in the live ipset, consuming approximately 9.8MB of kernel memory
    • Boot-persistent protection — full blocklist restored within 3 seconds of kernel start, before UFW processes its first rule
    • Nightly automated updates at 2AM with delta checking, atomic swaps, and structured logging
    • On-demand injection for immediate response via block_asn.sh
    • Full documentation covering installation, operation, monitoring, and uninstall

    The final cron run after all fixes produced:

    [INFO ] --- Run complete: status=SUCCESS entries=343966 elapsed=76s ---

    76 seconds. On an 18-year-old machine. For a complete rebuild of a 344,000-entry firewall blocklist from live external data sources.


    What Made It Work

    Looking back across the session, a few principles drove the outcomes:

    Fix correctness before optimising. The original script had bugs that would have made any performance work meaningless. Getting it right first meant the parallel version had a solid foundation to build on.

    Understand the failure modes of your tools. export -f failing silently. RADB returning empty responses instead of errors when rate-limited. ipset restore erroring on an existing set without -exist. None of these produced clear error messages. Each required understanding what the tool was supposed to do versus what it actually did under pressure.

    Instrument everything. The structured logging, delta checks, and before/after entry counts weren’t cosmetic additions. They were what allowed us to diagnose the shrinking entry count issue (thread pressure), the double-logging issue (cron redirect + direct file append), and the missing public IP (lookup happening during UFW teardown).

    Respect the hardware. Reducing threads from 8 to 4, using bash builtins instead of forking date on every log line, sorting in RAM with a 1GB buffer — these decisions were driven by understanding that a Core 2 Duo is not a cloud VM. It has constraints. Working within them produced a faster, more stable result than ignoring them.


    The Machine

    The 2008 MacBook 13” aluminum is not a recommended platform for production server workloads. It draws more power than a modern ARM server, runs warmer, and has a shorter remaining hardware lifespan than purpose-built server equipment.

    It’s also, as of this writing, blocking nearly 344,000 hostile IP ranges, rebuilding its blocklist every night, surviving reboots gracefully, and responding to threats on demand in under a second.

    Sometimes the best server is the one you already have.

  • The overuse of no-store in Cache-Control Headers

    Many of the sites that I work with have this habit of using a browser Cache-Control header without fully understanding what it means:

    cache-control: max-age=0, no-cache, no-store, private

    Everything in that header is moot once no-store is added, as Cache-Control rules always default to the most restrictive directive in the list. So the effective set of caching rules defined by that group of directives equals

    cache-control: no-store

    Now, the issue comes when the visitor refreshes the page. They do not get the opportunity to REVALIDATE the content, as the browser has been told to completely block the content from being stored anywhere.

    If the goal is to actually force a visitor to REVALIDATE the content on every page view, then use this instead:

    cache-control: max-age=0, no-cache, private

    While this set of directives would seemingly prevent any caching, its actual objective is to force the browser to process the content as if it is stale, and send an if-modified-since (including any relevant ETag information) to the server confirming if the content it has stored in a transitory state is still valid.

    Performance a REVALIDATE rather than a full load reduces the amount of data transferred between client and server and can improve performance and reduce CDN costs, especially at scale.

  • MSIE6 Euthanized. Rejoicing Among Web Developers Begins.

    It’s official. According to the IE Dev Blog at MSDN, MSIE8 will be the direct upgrade path via Windows Update in the third week of April. [here]
    I discussed the slow decrease in MSIE6 browser share earlier today, but it is not occurring fast enough for my liking. It is a browser from what seems like a generation ago.
    To give you some idea of why MSIE6 should gently euthanized, when it was a shiny new browser:

    • Facebook, MySpace, GMail, and YouTube did not exist
    • You could count the number of bloggers (remember them?) on one hand
    • UserLand was the primary blogging tool. Or a text editor.
    • Scoble didn’t work for Microsoft
    • Excite and AltaVista were still viable search engines

    Is it too early to write a redirect rule to direct MSIE6 users to a page telling them to upgrade to view content?
    [Image courtesy of CreativeBits]

  • Browser Wars: StatCounter Data for North America

    Tracking browser penetration and market share has become a new obsession with me. With 2009 shaping up to be the year of the browser container, the choices that people make will affect the development of Web technologies for the next few years.
    So far, the only new player to come out of the gate as a production release is MSIE8. Since March 19, MSIE8 has seen a slow and steady increase in market share, at least in North America. This has occurred, as I have noted previously, without the support of Windows Update, which will drive millions of users to automatically upgrade their systems.

    NA-browser stats-MAR15-APR132009

    What can be seen in this data from StatCounter is that MSIE8 is finally (Thank [insert deity or supernatural object here]!) taking market share away from MSIE6. As the corporate browser of choice (mainly because IT departments don’t have the resources or initiative to sign-off on MSIE7), MSIE6 is the default platform that all developers are forced to lower their standards to.
    One can hope that IT departments will allow their customer base to make the leap directly to MSIE8 from MSIE6. We can all celebrate now, as the StatCounter data now shows that MSIE6 has dropped below 10% market share.

    NA-browser stats-APR1-132009

    What is more interesting is that Firefox 3.0 made a serious encroachment into MSIE7 near the end of March. I am not aware of what may have caused this, and would be interested to hear if the StatCounter team has any insight into why this may have occurred.

  • Scaling Web Analytics – Considerations for Consumers

    A comment on my Hit Tracking with PHP and MySQL post raised some interesting questions about what a consumer of Web analytics data needs to consider when selecting providers for their sites.
    Most folks are familiar with the model of Web analytics vendors: You place their JS tag on your page which makes a call back to their centralized system with all of the data that can be collected about the visitor who has just come to your site.
    There are three items that you, the consumer of this third-party service, need to get straight answers about.

    Performance of the Tag

    Visitors to your site do not know which components you are responsible for and which you have farmed out to vendors and services. In their mind, all content is your content. This means that the performance of the Web analytics tag, which if not placed correctly on the page can affect the rendering of the content, is a critical factor in selecting a vendor.
    To my knowledge, none of the Web analytics vendors used by most people blog and social media firms (StatCounter, GetClicky, Google Analytics, Omniture, etc.) freely share response time and error rate metrics of their tag infrastructure with the world as measured by a third party.
    Is the tag delivered from a central location, or does the provider use a CDN? Is the data delivered asynchronously or synchronously?
    The download performance of the tag is not the only concern. Ask them to provide data on the average processing time that they see when a client parses and processes the code. How does this processing time vary from browser to browser, between OSs? What steps are they taking to ensure that the tag does not affect the perceived performance of your page?
    Ask for this data. Ask that the measurement data be collected by an impartial third party. Demand that this data be freely available to you before you make a decision on purchasing their service.

    Size of the Tag

    All of these services rely on a JS tag to collect and deliver the data to their data warehouse. Ask the provider to tell you how large this tag is, and what steps they have taken to reduce its size so that it has a limited effect on the download of your content.
    Have they minified the tag? Is it delivered using HTTP compression to further reduce the size? Are ways to reduce the size of the tag always under considerations?

    Data Storage

    Analyzing data takes a couple of forms: daily operational views to spot changes or issues; and long-term trending to find larger patterns and major shifts in your visitors. As a result, data needs to be available with a substantial degree of detail while still providing aggregated data that allows larger patterns to be easily discovered.
    How long does your provider store detailed data? What is the data expiry policy? Can you extract the data and import it into your own database?

    Summary

    Web analytics is key to determining what works and doesn’t work on your site. It tells who, where and how people are accessing your content. But without a Web analytics partner/vendor that provides performance and support, you may be left more in the cold than if you were just looking at your raw Web logs.

  • IPV4 and Registrar Data – April 10 2009

    From my IPV4 database, here are the the Registrar and Country statistics as of April 10 2009.

    Since the last update, ARIN has crossed the 1.6B IPV4 address boundary, adding nearly 5M IPV4 Addresses. APNIC matched this growth by adding an additional 5M new addresses of its own.

    Registrar      Number of IPV4 Adresses
    ---------      -----------------------
    arin                    1,600,640,000
    ripencc                   564,850,616
    apnic                     528,362,496
    lacnic                     79,931,136
    afrinic                    21,526,272

    The majority of the growth in APNIC continues to be driven by China, which added an additional 4M IPV4 addresses since March 14.

    Country                                     Number of IPV4 Adresses
    ------------------------------------------  -----------------------
    UNITED STATES                                            1472741888
    CHINA                                                     191119104
    JAPAN                                                     152412672
    EUROPEAN UNION                                            114155680
    GERMANY                                                    85174424
    CANADA                                                     75846144
    KOREA, REPUBLIC OF                                         72142592
    UNITED KINGDOM                                             70675288
    FRANCE                                                     68370880
    AUSTRALIA                                                  36573184
    ITALY                                                      32202432
    BRAZIL                                                     29754880
    TAIWAN, PROVINCE OF CHINA                                  24680704
    RUSSIAN FEDERATION                                         24529224
    SPAIN                                                      21794976
    MEXICO                                                     21504000
    NETHERLANDS                                                21290792
    SWEDEN                                                     18982304
    INDIA                                                      18285568
    SOUTH AFRICA                                               14009344
    POLAND                                                     13869704
    TURKEY                                                     10417600
    DENMARK                                                     9281632
    FINLAND                                                     8932864
    ROMANIA                                                     8640512
    SWITZERLAND                                                 8249320
    HONG KONG                                                   8206848
    NORWAY                                                      7425584
    AUSTRIA                                                     7290592
    ARGENTINA                                                   7239424
    INDONESIA                                                   6997248
    VIETNAM                                                     6707456
    BELGIUM                                                     6412416
    NEW ZEALAND                                                 6115072
    CZECH REPUBLIC                                              6040960
    UKRAINE                                                     5507904
    THAILAND                                                    4743936
    CHILE                                                       4731136
    PORTUGAL                                                    4473952
    SINGAPORE                                                   4410880
    COLOMBIA                                                    4261632
    IRELAND                                                     4203680
    MALAYSIA                                                    4147456
    PHILIPPINES                                                 4070656
    ISRAEL                                                      3949760
    GREECE                                                      3834624
    HUNGARY                                                     3716480
    VENEZUELA                                                   3693056
    BULGARIA                                                    3334912
    EGYPT                                                       2731008
    SAUDI ARABIA                                                2703616
    UNITED ARAB EMIRATES                                        2286848
    LITHUANIA                                                   2009216
    IRAN, ISLAMIC REPUBLIC OF                                   1894400
    PERU                                                        1715968
    PAKISTAN                                                    1637632
    CROATIA                                                     1623136
    SLOVAKIA                                                    1611520
    COSTA RICA                                                  1502208
    LATVIA                                                      1384448
    SLOVENIA                                                    1277952
    PANAMA                                                      1130240
    MONTENEGRO                                                  1083648
    ESTONIA                                                     1021464

    As China’s Internet growth continues, the number of addresses assigned to ISPs in that nation will continue to grow. However, it is unlikely that China will equal or exceed the US mainly due to the movement to mobile devices, which will be managed using private IP space rather than public IPs.

  • Why Chrome 2.0 Dev was Deleted

    I deleted Chrome 2.0 from my system on Friday for one very powerful reason: When it is installed, it makes itself the default browser.
    Its performance gains and light weight were impressive. But its invasion of my system was uncalled for.
    No matter which browser you set to be the default browser, Chrome 2.0 prevents that browser from regaining control. If you click a link in another program, Chrome launches, even if you set MSIE or Firefox or Safari as the default browser.
    I thought that kind of behavior was behind browser manufacturers.

  • The Rise of MSIE 8


    In the 10 days since its public release, MSIE8 has made a run up the charts. Courtesy of the great folks at StatCounter and their public analytics data, this growing browser share for MSIE8 can be easily followed.
    In the US, prior to its release, MSIE8 RC1 was in sixth position behind even the old battleship Firefox 2.0, but ahead of Chrome 1.0.

    msie8-week-mar12-182009

    In the week following its release, MSIE has quickly surpassed Firefox 2.0 browser share in the US. I am not really sure who these Firefox 2.0 users are, but they and the MSIE6 users must be found and encouraged to immediately upgrade.

    msie8-week-mar19-252009

    The values for the first week don’t tell the entire story. As it enters its second week of general availability, MSIE8 continues to increase its share of the browser market, moving into fourth place in StatCounters US stats, overtaking Safari 3.2.

    msie8-week-mar26-282009

    What does t his mean? While it still has a long way to go before it comes close to approaching even the dinosaur, MSIE6, it has to be said that this growth in MSIE8 browser share has occurred without the use of Windows Update. People are making a conscious decision to switch to and use MSIE8.
    Site and application designers will need to take heed – MSIE8 compatibility initiatives will have to be in place yesterday rather than some vague time in the future.