Table of Contents
In my previous post about migrating my websites over to an OpenBSD VPS I lamented the fact that I had to repeat identical blocks of code quite a few times to achieve blocking of malicious routes.
However, while httpd indeed doesn't come with any built-in macros
(beyond extremely simple key-value substitution which isn't fit for
the purpose I was looking for), OpenBSD does in fact come with another
utility (or, as you'll find two) that can reduce the repetitive parts
considerably.
In this post I'll explain how.
1. An extremely fast primer on Macros
Depending on how old you are (or how old the technology you're interested in is), your understanding of what a 'macro' is will generally fall into two categories:1
They are "functions" built into languages, that are capable of manipulating the Abstract Syntax Tree of code.
The most blatant example of this is, of course, Lisp/Scheme, where "code is data" to such an extent, that macros are little more than functions that operate on lists, whose outputs will then be evaluated as a value.2
;; First we'll try a normal function (define (incr! x) (set! x (+ x 1))) (define num 1) (incr! num) ;; calls incr! with a *copy* of num num ;; => still 1 ;; Then we define a macro (define-syntax incr! (syntax-rules () ((incr! x) (set! x (+ x 1))))) (define num 1) (incr! num) ;; expands to (set! num (+ num 1)) num ;; => is now 2
The nice thing about these systems is that they are able to make sure the output of your macro results in code that can be parsed by restricting how you can manipulate your values.
Though, of course, just because something can be parsed doesn't mean it necessarily makes sense. AST-based macros won't (and can't) make sure your macro outputs actually do what you want, that's still on you.
The other usual variant are "dumb" expression-expander machines. Their purpose is much the same, i.e. abbreviating often-repeated code, while accepting parameters to make these abbreviations a little more reusable.
However, unlike AST-transformers, these macro processors have no notion about the syntax of the language they are modifying, knowing only how to manipulate text and are therefore completely free to modify your program as you see fit… Including ways that cannot be parsed.
The archetypal example is the C(++) preprocessor:3
#DEFINE ABS(x) x < 0 ? -x : x ABS(-5) // becomes -5 < 0 ? -5 : 5
While the example above is simplistic, it still gives an idea what a macro looks like and how one might use them. What might be less obvious at first glance is that it also shows how easy it is to make a mistake with text-transforming macros.
Consider what would happen if we passed in a more complex parameter into
ABS:ABS(5 - 6) // which, when evaluated becomes // 5 - 6 < 0 ? -5 - 6 : 5 - 6 // 5 - false ? -11 : -1 // 5 - (-1) // 6
As you can see, due to all the missing parentheses, our absolute value function completely trashed its calculation. Fixing it in this case is not a huge ordeal:
#DEFINE ABS(x) ((x) < 0 ? -(x) : (x))However, as your macros become more complex, making sure you're not accidentally expanding an input into something completely different to what you meant becomes an ordeal.
Yet, despite all these potential footguns, these macros are still capable of some amazing things, such as allowing "generics" in C without actual support for generics.4
2. Enter m4
m4 is a POSIX utility (man page), that is likely older than many of
us (it is nearly 50 years old at the time of writing and was inspired
by another macro processor, that's a good decade older). It is
generally found on all Linux distros and (more relevantly to this
post) on all the BSDs, including OpenBSD.
It falls into the second category of macro-evaluator, i.e. it's a text preprocessor, not an AST-transformer. What sets it apart from C's and the like is the fact that it's a standalone program that can thus be easily used with any sort of text manipulation.5
While m4 has quite a few built-in functions and capabilities (see
Michael Breen's guide), for our purposes there are only a couple
things to know:
- Anything that's not a macro invocation will be echoed as-is.
divert(-1)will turn off text output anddivert(0)will turn it back in.dnlallows us to suppress everything coming after it until the start of the next line. We may use it to swallow newlines after macro definitions or after re-enabling text output withdivert(0)for instance.define(X, Y)will substitute allX-s toY-s. Becausem4would echo the newline afterdefine, we need to divert it:divert(-1) define(FOOD, cheese) divert(0)dnl I love FOOD => I love cheese
All macros may refer to up to 9 positional arguments using
$n, wherenis 1 to 9:divert(-1) define(DESCRIBE, That's a $2. It's $1.) divert(0)dnl DESCRIBE(delicious, piece of cheese) => That's a piece of cheese. It's delicious.
Note: Despite there being spaces before "That's" and "piece", these won't appear in the final output, as
m4will automatically cut off any leading white-space in arguments.6Macros are evaluated recursively. If one macro references another one, it'll also be evaluated and so on:
divert(-1) define(FOOD, cheese) define(ENTHUSIASTIC_FOOD, FOOD FOOD FOOD!) divert(0)dnl I love ENTHUSIASTIC_FOOD => I love cheese, cheese, cheese!
Note: punctuation can come after macro names, because those aren't part of valid macro names, but if we wrote, say,
FOODs, then it wouldn't turn into "cheeses", it'd stay "FOODs".
So, now that we know the basics, why not use m4 generate a
httpd.conf for us instead of having to manually copy paste things
around every time I want to modify it?
3. Shortening httpd.conf
While locking down my web server against crawler bots in the previous post, I included a big block of URL path matchers, whose only purpose was to match for malicious file requests and silently drop the connection on them:
# Drop malicious requests location "/.aws*" {block drop} location "/.env*" {block drop} location "/*.cgi*" {block drop} location "/cgi-bin/*" {block drop} location "/*.php*" {block drop} location "/*wp-*" {block drop}
It doesn't exactly take much thinking to realize these all follow an extremely similar pattern. Let's turn it into a macro:
define(BLOCK, location "$1" {block drop})
So now all these lines shorten to just:
# Drop malicious requests BLOCK(/.aws*) BLOCK(/.env*) BLOCK(/*.cgi*) BLOCK(/cgi-bin/*) BLOCK(/*.php*) BLOCK(/*wp-*)
I reckon this is already better than what we had (for instance, if we
ever decide to play nice with bots and show a 403 Forbidden HTTP
response instead of silently dropping their connection, we'd just need
to edit the macro definition and then regenerate the config), but
there is still the fact that these BLOCK statements have to be
repeated in every single server definition.
However, as I mentioned above, macros can reference other macros, so we can just create a macro for the entire blocklist:
define(BLOCK, location "$1" {block drop}) define(BLOCKLIST, BLOCK(/.aws*) BLOCK(/.env*) BLOCK(/*.cgi*) BLOCK(/cgi-bin/*) BLOCK(/*.php*) BLOCK(/*wp-*))
Now we only need to use BLOCKLIST. This shortens the config
considerably, but why stop here? There are three other sections that
either all or nearly all server definitions have, that can be easily
turned into macros of their own:
define(ACME, location "/.well-known/acme-challenge/*" { root "/acme" request strip 2 }) define(TLS, tls { certificate "/etc/ssl/$1.crt" key "/etc/ssl/private/$1.key" }) define(HEADERS, header set "Cache-Control" "public, max-age=86400" always header set "X-Content-Type-Options" "nosniff" always header set "Referrer-Policy" "no-referrer" always header set "Permissions-Policy" "interest-cohort=()" always header set "X-Frame-Options" "SAMEORIGIN" always)
With these macros, we may now shorten a server definition to the following:
server "nemin.hu" { listen on * tls port 443 root "/htdocs/nemin.hu" hsts {preload, subdomains} gzip-static log style combined ACME TLS(nemin.hu) BLOCKLIST HEADERS } server "nemin.hu" { listen on * port 80 ACME BLOCKLIST HEADERS block return 301 "https://nemin.hu$REQUEST_URI" } server "www.nemin.hu" { listen on * port 80 listen on * tls port 443 ACME TLS(nemin.hu) BLOCKLIST HEADERS block return 301 "https://nemin.hu$REQUEST_URI" }
Buuuut… since I have two sites, I might as well go all the way:
define(SERVER, server "$1" { listen on * tls port 443 root "/htdocs/$1" hsts {preload, subdomains} gzip-static log style combined ACME TLS($1) BLOCKLIST HEADERS }) define(HTTP_REDIRECT, server "$1" { listen on * port 80 ACME BLOCKLIST HEADERS block return 301 "https://$1$REQUEST_URI" }) define(WWW_REDIRECT, server "www.$1" { listen on * port 80 listen on * tls port 443 ACME TLS(1) BLOCKLIST HEADERS block return 301 "https://$1$REQUEST_URI" }) define(SITE, SERVER($1) HTTP_REDIRECT($1) WWW_REDIRECT($1))
With all this scaffolding done, the entire config becomes just:
types { include "/usr/share/misc/mime.types" }
prefork 10
no banner
#
# Nemin.hu
#
SITE(nemin.hu)
#
# Oddwords.hu
#
SITE(oddwords.hu)
We can easily test our fancy new macro machinery by issuing m4
httpd.m4 > httpd.conf.1 (the .1 is just there to make sure we don't
override our config until we're certain it's good).
At first everything seems fine… until we spot the following two oddities:
hsts {preload header set "Cache-Control" "public
It almost seems like everything after and including the commas in these lines has vanished without trace.
And, indeed, this is what happened. Remember when I mentioned that
m4 recursively evaluates its macros? Well, it turns out that if the
text contains commas, m4 will happily consider them as separators
between arguments. Including cases where it really ought not to touch
things.
Solving this is non-trivial. While m4 does have something akin to
escaping, it doesn't quite work like it does in other languages and
it's brittle enough for me not to even bother with it in this post.7
Instead, we'll reach for another trusty Unix tool, sed. If you've
ever used s/old pattern/new pattern/ in Vim (or other editors and,
for some inexplicable reason, Discord), you already know what sed is
capable of. It takes a command and executes it on the lines of a
file.
We won't use it for anything too complicated. First we swap all commas
in the macros (there should only be two) for some other pattern that
we definitely won't use anywhere else. I picked ~~, but you could go
with anything you'd like.
This will leave us with the following:
hsts {preload~~ subdomains}
header set "Cache-Control" "public~~ max-age=86400"
Then, we simply call sed -i s/~~/,/g httpd.conf.1. The flag -i
ensures the file is overridden in place. Without it, sed would print
to standard output.
To make calling this easier, we can even make a small script out of these two commands:
#!/bin/sh m4 httpd.m4 | sed %s/~~/,/g > httpd.conf.1
This will finally give us commas and we're done!
4. The final config file
divert(-1) define(BLOCK, location "$1" { block drop }) define(ACME, location "/.well-known/acme-challenge/*" { root "/acme" request strip 2 }) define(TLS, tls { certificate "/etc/ssl/$1.crt" key "/etc/ssl/private/$1.key" }) define(HEADERS, header set "Cache-Control" "public~~ max-age=86400" always header set "X-Content-Type-Options" "nosniff" always header set "Referrer-Policy" "no-referrer" always header set "Permissions-Policy" "interest-cohort=()" always header set "X-Frame-Options" "SAMEORIGIN" always) define(BLOCKLIST, BLOCK(/.aws) BLOCK(/.env*) BLOCK(/*.cgi*) BLOCK(/*.php*) BLOCK(/index.php*) # ... and all the other paths you want to block, I have like 20. # See https://caddy.ninja/ as an inspiration. ) define(SERVER, server "$1" { listen on * tls port 443 root "/htdocs/$1" hsts {preload~~ subdomains} gzip-static log style combined TLS($1) ACME BLOCKLIST HEADERS }) define(HTTP_REDIRECT, server "$1" { listen on * port 80 ACME BLOCKLIST HEADERS block return 301 "https://$1$REQUEST_URI" }) define(WWW_REDIRECT, server "www.$1" { listen on * port 80 listen on * tls port 443 TLS($1) ACME BLOCKLIST HEADERS block return 301 "https://$1$REQUEST_URI" }) define(SITE, SERVER($1) HTTP_REDIRECT($1) WWW_REDIRECT($1)) # This is where the actual site config starts. divert(0)dnl types { include "/usr/share/misc/mime.types" } prefork 10 no banner # # Nemin.hu # SITE(nemin.hu) # # Oddwords.hu # SITE(oddwords.hu)
5. Should you do this?
I cannot give an authoritative answer to this. In my case, I want my VPS to be largely "self-sufficient". That is to say, I'd like to follow the OpenBSD philosophy and rely on tools already present on the machine rather than install extra stuff. This is both a fun challenge and also makes the system very "fire and forget."
In your case, you may have different priorities and instead either install a less wonky macro processor (see a random example I found on the net) or just use software that has built-in ways of making your configuration terse.
Still, if nothing else, trying m4, sed, and the other
pre-installed programs is a fun exercise in seeing how to create
"pipelines" for yourself by relying only on simple, single-purpose
tools. (Also known as following the Unix philosophy.)
Thanks for reading!
Footnotes:
Or three if you like automating games or had the misfortune of needing to work much with MS Office, but those kinds of macros are beyond the scope of this article.
Before you grab your pitchforks and torches, yes, I know macro hygiene and scoping and all those fancy tools these languages use to make sure the user doesn't shoot themselves in the foot are present and very important, but we could fill entire chapters discussing them and people have already done a much better job at doing that than I could.
Funnily enough C's preprocessor is also called "CPP", which isn't at all confusing. But I guess it does predate C++ by around ten years, so it can be excused.
Yes, yes, C now has _Generic, but it's a slightly different mechanism and making type-generic lists is a very common example of using macros in C.
Yes, nothing actually stops you from using the C preprocessor
to handle other kind of files, but it'd feel weird to do that, while
m4 was made for this purpose.
This may be avoided using quoting, but quotes are a beast of their own. The guide I linked earlier talks about them in detail.
You can quote things and every macro invocation strips one layer of quotation, so you must know in advance how deep your call stack is.
