In my last blog post What’s next in pattern patching syntax world I gave a small preview of what I am working on and what keeps me busy. In this blog post I want to take you on a small journey that explains the whole thing.
I would say the whole matching syntax is easy to read, but its behavior
is not always what you expect. While implementing patterns(7), I
quickly noticed in the config parser that all of this is even more
complicated in the code than in the config syntax itself. That led me
to the first question:
“Is a filter rule in relayd.conf case-sensitive?”
The honest answer was: It depends.
The question nobody(tm) could answer#
During the w2k26 hackathon I started writing regress tests for the filter rules. Mainly to capture the current behavior before touching anything. In the end I had a list that nobody wants to learn by heart:
- Cookie names and values: case doesn’t matter.
- Header names: doesn’t matter.
- Header values: matters.
- Path: matters.
- Query names and values: matters.
- URL lookup: doesn’t matter.
pattern: always matters.
During development I summed this up in a commit message: Whether the rule is case-sensitive depends on the case. That is strange. By the way, I committed that sentence with the typo “chnage”. So spelling was apparently a matter of luck there too. Anyone who reads my commits/texts knows this already ;)
There is real logic behind the list above. According to RFC 9110, header
names are case-insensitive, paths are not. The defaults reflect how the
client puts things on the wire. That makes sense. It’s just not written
anywhere in the rule itself. If you read block path "/admin/*", you
don’t see that /Admin/ slips through, right?
So it all comes down to the fine print, but nobody really wants that, right? I want to read a config and understand what happens without reading the details in the man page. I don’t know if that is possible with a config monster like relayd, but I think there is a solution.
The solution: Write down what you mean#
Until now there was exactly one keyword to control matching:
pattern (and only since OpenBSD 8.0), which means Lua-like patterns(7)
instead of glob(7) (not always either ;). Now there are two more:
| Keyword | Meaning |
|---|---|
pattern | patterns(7), always case-sensitive |
glob | glob(7), case-sensitive |
glob ignorecase | glob(7), case doesn’t matter |
The keywords go before the key, before the value, or before both:
# Block the admin area, no matter how you write /admin
block request quick path glob ignorecase "/admin/*"
# Only JSON on the API
pass request header "Content-Type" \
value glob ignorecase "application/json*"
# Remove all debug headers
pass request header remove glob ignorecase "X-Debug-*"This is longer than before. But now the rule says what it does. You no longer need to look at the man page to understand a line.
If you don’t use a keyword, you get the old defaults. The goal of the implementation was that existing configurations keep doing exactly what they did before. That was the most important requirement for me, and it cost me more work than the actual feature. More on that later.
There is one exception: glob on a header name is treated as
glob ignorecase. Header names are simply case-insensitive, and a rule
that doesn’t find user-agent because the client sent User-Agent
helps nobody. pattern stays case-sensitive there. If you write
patterns(7), you want it exactly like that, or you shouldn’t be
configuring relayd.
Files too#
Something that was completely new to me: I had never used it, and you
don’t see it in the code at first glance either. It’s the feature that
lets you load keys from a file with file. The keyword then applies to
every entry:
block request header glob ignorecase file "/etc/relayd.badheaders"Without a keyword, header keys from a file are still compared literally.
Stricter than before#
I know, my goal was to stay backward compatible, but during the implementation I found some red lines that we have to fix. relayd used to silently accept these cases. Now they are errors:
Duplicate lookup types. A rule with two header clauses used to
simply replace the first one with the second one. Without a warning. If
you thought you were checking two headers, you were checking one. Now
the parser rejects this. For combined conditions there are tag and
tagged:
match request header glob ignorecase "X-*" tag "XHDR"
block request quick tagged "XHDR" header "Host"To be honest, I think this is the worst design decision. It makes everything so complicated. This could surely be done better.
Matching keywords on set and append. These options write a
header, they don’t match one. A glob in front of them never had any
effect. This is now an error. The only exception is a path key.
Rules that can never match. log without a key matches *. That
is a glob wildcard, but not a patterns(7) expression. So pattern there
never found anything. And URL lookups don’t look at the value at all.
That’s why glob and glob ignorecase are rejected there.
This breaks configurations. But only those that didn’t do what they said before. I think that’s a fair trade.
When you think you’re done#

As I wrote above, I wrote tests. At some point I got tired of it, so I described my use case to an LLM and it put the test together. A really useful helper, considering how many variations all these syntax options create.
So I was very confident that Kirill wouldn’t really find anything in his review.
Unfortunately, my first draft didn’t properly keep the old defaults for
keys that are only set after parsing. path log without a key, for
example, or keys from a file. kirill@’s eagle eyes found this before it
could hurt anyone. Thanks!
Then there was another finding that is older than my change/fix. The
lookups for Host, Cookie and Set-Cookie created a struct kv on
the stack and only set kv_key. But kv_find() looks at kv_flags.
With some bad luck, garbage on the stack turned a simple comparison into
a glob or pattern match. With the new flags, this could even have
resulted in a case-sensitive comparison that misses a lowercase
host:. So whether a rule matched could depend on what was on the
stack before.
Tests, tests, tests#
The real value of all this is probably in the regress tests. Every default now has a test, and so does every new keyword.
I also have a diff lying around somewhere that scans a directory for relayd configs, tests each config and then starts it for a few seconds. This should help find config parser bugs in complex configs.

My hope is: If you’ve read this far, you’re interested in relayd or you have a complex config. I’m looking for complex configs to add to the regress test suite. I’d be happy if you sent me your relayd config. You should know my email.
Conclusion#
The old defaults weren’t wrong. They were just invisible. With glob,
glob ignorecase and pattern you can now write down exactly what you
expect, and if you don’t write anything, you get the old behavior, and
the man page describes exactly what is used.
Is my rule case-sensitive? It says so in the rule now! So it no longer depends. Now it depends on you.
devel#
Development happens primarily on the Gothub instance. The other locations are kept in sync:
Primary: https://rsadowski.gothub.org/
Mirror: https://codeberg.org/rsadowski/relayd
Mirror: https://github.com/sizeofvoid/relayd
As always, the source of truth is OpenBSD -current.A special thanks to all who support my work.
BTW: This photo was taken during a long run along the Beltline in Atlanta. If you’re in Atlanta, this is the place you’ll want to go for a run.

