↓ Skip to main content
  1. Posts/

What does relayd actually match? It depends!

·882 words·5 mins· loading · loading ·
Rafael Sadowski
Author
Rafael Sadowski
Shut up and hack

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:

KeywordMeaning
patternpatterns(7), always case-sensitive
globglob(7), case-sensitive
glob ignorecaseglob(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.