Dominik Süß

fighting computers since 1999

in 

Protecting HAProxy-ingress with iocaine

Throw out crawlers, regain peace

Whatever your views on LLMs/"AI" are - if you're running a service that's exposed to the public internet, you're getting bombarded with badly behaved scrapers trying to slurp up everything they can.

In ye olden days most scrapers at least somewhat respected a well written robots.txt but now, they just go wild on whatever link they can find. While you can use something like anubis to ward of many bots, this comes at the cost of making visitors waste energy by performing Proof-of-Work calculations which disadvantage low-end devices and are not a real deterrent for big scraping efforts as long as money is still getting thrown at anything that has a .ai domain.

Alternatives like go-away exist but I've opted to use iocaine instead. Because why block them if you can serve them garbage instead?

This post documents how I set up iocaine to integrate with my kubernetes cluster and the challenges encountered along the way. I'll try my best to be as generic as possible but ingresses are highly specific to your infrastructure so you'll have to use your brain and stray from this path where it makes sense for you. If that didn't scare you away, grab a beverage of choice and venture ahead to clear these lands of unholy apparitions!

setting up the HAProxy ingress

Before you can add iocaine, you'll need a working ingress setup with HAProxy. The easiest way to set this up is via their helm chart as documented in the getting started guide.

At the time of writing this, the helm chart defaults to HAProxy 2.8.22 which is too old for iocaine to support. Version 3.3 is the sweet spot in which both iocaine and the HAProxy ingress controller work so update your values.yaml to use this version. You also need to set enabled to true to get a dedicated HAProxy container instead of relying on an image combining both the controller and HAProxy.

controller:
  haproxy:
    enabled: true
    image:
      tag: '3.3-alpine'

Depending on your actual infrastructure, you'll want to customize the way the ingress gets exposed (Loadbalancer Service, Host Port, NodePort service etc) but I can't help you with that. It shouldn't matter for iocaine so go ahead and set things up correctly next. I'll wait.

running iocaine

iocaine talks to HAProxy through the Stream Processing Offload Protocol (SPOP). It is used by HAProxy to hand off the request to a separate process and get some data back. In case of iocaine, this will be the classification of the request.

I have multiple nodes serving as ingresses and while you could run a single iocaine deployment and have each instance connect to it, I prefer to run an iocaine instance next to every ingress. This allows you to use unix sockets to communicate and keeps things simple and contained.

The upstream image does not have a way to inject a sidecar so at this point, you'll either want to eject from the helm setup or use something like tanka or kustomize to apply patches on top of it. For this guide, I'll mention the places that need to be edited but how you persist them is up to you.

First, let's just get iocaine up and running. For this, add a new container to the ingress

spec.template.spec:
  containers:
  - name: iocaine
    image: git.madhouse-project.org/iocaine/iocaine:3.5.1
    args:
    - --config-path
    - /etc/iocaine/config.kdl
    resources:
      limits:
        memory: 256Mi
      requests:
        cpu: 10m
        memory: 128Mi
    securityContext:
      runAsGroup: 99
      runAsUser: 99
    volumeMounts:
    - mountPath: /etc/iocaine/
      name: iocaine-config
    - mountPath: /var/run/iocaine/
      name: iocaine-socket
  volumes:
  - configMap:
      name: iocaine-config
    name: iocaine-config
  - emptyDir: {}
    name: iocaine-socket

This also requires a config map for iocaine. You can use this example from the upstream HAProxy guide as a starting point:

apiVersion: v1
kind: ConfigMap
metadata:
  name: iocaine-config
data:
  config.kdl: |
    state-directory "/var/run/iocaine"
    http-server default {
       bind "127.0.0.1:42069"
       use handler-from=default metrics=main
    }
    prometheus-server "main" {
       bind "0.0.0.0:42042"
    }
    haproxy-spoa-server spoa {
       bind "/var/run/iocaine/iocaine.sock" unix-socket-access="group"
       use handler-from=default metrics=main
    }
    declare-handler default {
       trusted-decision-header "iocaine-decision"
    }

This configuration starts both an http and an HAProxy SPOA server. They both use the same default handler which uses iocaine-decision as the trusted decision header. You need this header to correctly communicate the output back to iocaine once a decision has been made. More information on this can be found in the upstream HAProxy guide.

configuring HAproxy

Now that iocaine is running, the next step is connecting HAProxy to it. The best way to do this is by adding entries to the haproxy-ingress config map.

If you're somehow still able to update the helm chart without removing the iocaine sidecar, you can also specify these values in your values.yaml under the controller.config key.

First, add the SPOP and http backend to the section config:

config-sections: |
  backend iocaine
    mode spop
    option spop-check
    server iocaine "/var/run/iocaine/iocaine.sock"
  backend iocaine-output
    mode http
    server iocaine-http 127.0.0.1:42069

Then in config-frontend-early, run the request against iocaine and store the result in the iocaine-decision header.

config-frontend-early: |
  acl forbidden_hdr hdr_cnt(iocaine-decision) gt 0
  http-request set-header iocaine-decision "garbage" if forbidden_hdr

  filter spoe engine iocaine config /etc/iocaine/iocaine-spoa.cfg
  # fallback if iocaine is unavailable
  http-request set-var(txn.iocaine.response,ifnotset) str("borked")


  # iocaine decision check
  acl iocaine_passed          var(txn.iocaine.response) -m str eq "default"
  acl iocaine_unavailable     var(txn.iocaine.response) -m str eq "borked"

The first part of this snippet checks if the request already included an untrusted iocaine-decision header in which case we override that with garbage to ensure a scraper can't just work around this by faking a decision upfront.

Now that you have haproxy ACLs telling you if a request is valid, you can send them off to the garbage generation backend in config-frontend-late:

config-frontend-late: |
  use_backend iocaine-output if !iocaine_passed

You might have noticed a reference to /etc/iocaine/iocaine-spoa.cfg in the earlier snippet. This is the last thing you need to provide for this to work. Put it in a separate config map like this:

apiVersion: v1
kind: ConfigMap
metadata:
  name: iocaine-spoa
data:
  iocaine-spoa.cfg: |
    [iocaine]
    spoe-agent iocaine
        log global
        option var-prefix iocaine
        timeout processing 5s
        messages check-request
        use-backend iocaine

    spoe-message check-request
        args req_method=method req_hdrs=req.hdrs req_path=path req_query=query
        event on-frontend-http-request

Now to tie everything together, mount this config map and the existing iocaines-socket volume in the haproxy container:

spec.template.spec:
  containers:
  - name: haproxy
    volumeMounts:
    - mountPath: /etc/iocaine/
      name: iocaine-spoa
    - mountPath: /var/run/iocaine/
      name: iocaine-socket
  volumes:
  - configMap:
      name: public-haproxy-iocaine-spoa
    name: iocaine-spoa

testing this

To test if iocaine is working, send an HTTP request to any of your ingresses with a user agent from ai-robots.txt:

# Port forward if you're just testing out things without a real ingress behind this
# kubectl port-forward svc/haproxy-ingress 8080:80
curl -A Perplexity http://localhost:8080

If everything is set up correctly, you'll be presented with a bunch of garbage like this:

<!doctype html><meta charset=utf-8><meta content="width=device-width,initial-scale=1.0" name=viewport><title>LuaMetricRegistry { fn new( db: maxminddb::Reader<Vec<u8>>, countries: impl IntoIterator<Item = u32>) .</title><body><main><h1>LuaMetricRegistry { fn new( db: maxminddb::Reader&lt;Vec&lt;u8>>, countries: impl IntoIterator&lt;Item = u32>) .</h1><p>You, the template remains the same. With a seed, you can imagine the rest here --> """# } ``` #### Unwanted visitors While gently guiding known and disguising crawlers into the // same Substr. Pub struct.<p>"Devin AI", "respect": "Yes", "function": "Scrapes data.", "frequency": "No information.", "description": "Crawls sites to provide accurate answers with line-by-line source citat\u2026 More info can be found at https://knownagents.com/agents/addsearchbot" }, "AgentTimes": { "operator": "Unclear at this time.", "function": "AI Agents", "frequency": "Unclear at this time.", "description": "Note that excluding FacebookExternalHit will block incorporating OpenGraph.<p>= output(request, decide(request)) return POISON_ID_PATTERNS:matches(utf8_from(response.body)) end local function kv_table_3f(t) if table_3f(t) then local filename = ((m and m.line) or ast_tbl.line or "?") local.<nav><strong>See also:</strong><ul><li><a href=/B1SQd0DSUae0OWkxguVuLQas/>Entries in the scope of.</a><li><a href=/B1SQd0DSUae0OWkxguVuLQend/>LuaMetricRegistry(metrics.registry.clone())) .or_raise.</a><li><a href=/B1SQd0DSUae0OWkxguVuLQtrue/>"enable": true.</a><li><a href=/B1SQd0DSUae0OWkxguVuLQto/>As_base64(&self) -> String.</a><li><a href=/B1SQd0DSUae0OWkxguVuLQreturn/>And _G["sym?"](pattern[2.</a><li><a href=/B1SQd0DSUae0OWkxguVuLQsave_locals_3f-or/>Meta_fields .</a></ul></nav></main><footer><hr><p>Copyright © 1325 Cookie.</footer>

Congrats! Your services are now protected by the deadliest poison known to AI!

bonus round: nam-shub-of-enki

The default classifier QMK (Quickly Mark & Kill) offers a good starting point. I had it running for half a year and it warded off most scrapers. At some point in the last few months though, more bots managed to sneak through and hit expensive endpoints on my git forge. This wasn't ideal so I took the plunge and set up nam-shub-of-enki (henceforth NSE) which is a more opinionated request handler.

Installation should be straight forward but there are some quirks to make it work with haproxy so I'll share what I did here as well.

To get the request handler logic to the iocaine container, I am using image volumes with a custom image that I build myself. This way I can continue to use mostly upstream images and don't have to rebuild the iocaine image when NSE changes. I also apply a small patch on top of the upstream that brings back the trusted-decision-header logic which is absent upstream.

Mounting this requires a somewhat recent kubernetes version. Configuration is quite simple though:

spec.template.spec:
  containers:
  - name: iocaine
    volumeMounts:
    - mountPath: /opt/nam-shub-of-enki/
      name: nam-shub-of-enki
      subPath: nam-shub-of-enki
  volumes:
  - image:
      pullPolicy: IfNotPresent
      reference: codeberg.org/thesuess/nse-oci@sha256:c53f5a90114f59481d31a284409046ed677f0b645fd1e15ec57429dbd764d4f7

Then you can update the iocaine config to use this new handler like this:

http-server default {
    bind "127.0.0.1:42069"
    use handler-from=nam-shub-of-enki metrics=main
}
prometheus-server "main" {
    bind "0.0.0.0:42042"
}
haproxy-spoa-server spoa {
    bind "/var/run/iocaine/iocaine.sock" unix-socket-access="group"
    use handler-from=nam-shub-of-enki metrics=main
}
declare-handler default {
    trusted-decision-header "iocaine-decision"
}
declare-handler nam-shub-of-enki path="/opt/nam-shub-of-enki" {
    inherits "default"
    checks {
        disable cgi-bin-trap

        ai-robots-txt {
            path "/opt/nam-shub-of-enki/robots.json"
        }
        cookie-monster {
            forgejo-hosts "code.kulupu.party"
        }
        generated-urls {
            identifiers "ilo-ike"
        }
    }
}

One thing to note is that NSE uses a different decision terminology from QMK. To prevent all your requests from returning 421 Misdirected Request, update the iocaine_passed ACL to match on not-for-us instead of default.

acl iocaine_passed          var(txn.iocaine.response) -m str eq "not-for-us"

If everything went well, you'll now see even fewer crawlers hitting your real endpoints!