Logo for my website.

Nemin's Blog

Repeating Ourselves Less with M4

A short guide to how I made my httpd.conf file shorter using macros.

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 and divert(0) will turn it back in.
  • dnl allows 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 with divert(0) for instance.
  • define(X, Y) will substitute all X-s to Y-s. Because m4 would echo the newline after define, 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, where n is 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 m4 will automatically cut off any leading white-space in arguments.6

  • Macros 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:

1

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.

2

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.

3

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.

4

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.

5

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.

6

This may be avoided using quoting, but quotes are a beast of their own. The guide I linked earlier talks about them in detail.

7

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.