Protecting HAProxy-ingress with iocaine
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-socketThis 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.yamlunder thecontroller.configkey.
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:42069Then 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_passedYou 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-requestNow 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-spoatesting 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:8080If 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<Vec<u8>>, countries: impl IntoIterator<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:c53f5a90114f59481d31a284409046ed677f0b645fd1e15ec57429dbd764d4f7Then 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!