Index | Thread | Search

From:
Rafael Sadowski <rafael@sizeofvoid.org>
Subject:
relay: add patters(7) support and improve glob(7) documentation
To:
tech@openbsd.org
Cc:
Mischa <mischa@openbsd.amsterdam>
Date:
Wed, 19 Aug 2026 10:02:57 +0200

Download raw body.

Thread
  • Rafael Sadowski:

    relay: add patters(7) support and improve glob(7) documentation

During g2k26, Mischa asked me if we could also include support for patterns(7)
in relayd. It turned out that this required relatively little effort/code
changes.

During testing, I noticed that key/value matching isn't really documented. When
we use glob(7) and when not to, when it's case-sensitive and when it isn't. I
tried to figure this out and documented it. I also changed the misleading word
"pattern" to "response" in the manpage.

I wrote a few gestures for this and played around with the use cases in
the manpage.

With "log level verbose" you should see what is matched in the logs.

I've tried to understand the functions and start writing some documentation.

Feedback and, in particular tests, are very welcome.

Rafael

commit de96da32d619f805ada1c95a7a8a26e0af350284
Author: Rafael Sadowski <rafael@sizeofvoid.org>
Date:   Wed Aug 12 20:53:09 2026 +0200

    relay: add patters(7) support and improve glob(7) documentation
    
    Filter rules on cookie, header, path, query, and url now accept an optional
    "pattern" keyword before the key or value string. With "pattern", the string is
    interpreted as a patterns(7) expression instead of the default glob(7) rules.
    
    glob(7) support was already there before, but it wasn't really documented. The
    documentation now describes all the possibilities and limitations.

diff --git a/Makefile b/Makefile
index d0b678c..7a14428 100644
--- a/Makefile
+++ b/Makefile
@@ -2,17 +2,45 @@
 
 PROG=		relayd
 SRCS=		parse.y
-SRCS+=		agentx_control.c ca.c carp.c check_icmp.c check_script.c \
-		check_tcp.c check_tls.c config.c control.c hce.c log.c \
-		name2id.c pfe.c pfe_filter.c pfe_route.c proc.c \
-		proxy_protocol.c relay.c relay_http.c relay_udp.c relayd.c \
-		shuffle.c ssl.c util.c
+
+HTTPD_DIR =		${.CURDIR}/../httpd
+
+.PATH: ${HTTPD_DIR} ${.CURDIR}
+
+SRCS+=	agentx_control.c \
+		ca.c \
+		carp.c \
+		check_icmp.c \
+		check_script.c \
+		check_tcp.c \
+		check_tls.c \
+		config.c \
+		control.c \
+		hce.c \
+		log.c \
+		name2id.c \
+		pfe.c \
+		pfe_filter.c \
+		pfe_route.c \
+		proc.c \
+		proxy_protocol.c \
+		relay.c \
+		relay_http.c \
+		relay_udp.c \
+		relayd.c \
+		shuffle.c \
+		ssl.c \
+		util.c
+
+# httpd
+SRCS+=		patterns.c
+
 MAN=		relayd.8 relayd.conf.5
 
 LDADD=		-lagentx -levent -ltls -lssl -lcrypto -lutil
 DPADD=		${LIBAGENTX} ${LIBEVENT} ${LIBSSL} ${LIBCRYPTO} ${LIBUTIL}
 #DEBUG=		-g -DDEBUG=3 -O0
-CFLAGS+=	-Wall -I${.CURDIR}
+CFLAGS+=	-Wall -I${.CURDIR} -I${HTTPD_DIR}
 CFLAGS+=	-Wstrict-prototypes -Wmissing-prototypes
 CFLAGS+=	-Wmissing-declarations
 CFLAGS+=	-Wshadow -Wpointer-arith
diff --git a/parse.y b/parse.y
index 929b031..1eb6c6c 100644
--- a/parse.y
+++ b/parse.y
@@ -125,6 +125,7 @@ static enum key_type	 keytype = KEY_TYPE_NONE;
 static enum direction	 dir = RELAY_DIR_ANY;
 static char		*rulefile = NULL;
 static union hashkey	*hashkey = NULL;
+static int		 value_pattern = 0;
 
 struct address	*host_ip(const char *);
 int		 host_dns(const char *, struct addresslist *,
@@ -185,8 +186,8 @@ typedef struct {
 %token	LABEL LEASTSTATES LISTEN LOADBALANCE LOG LOOKUP
 %token	MATCH METHOD MODE NAT NO NODELAY NOTHING
 %token	OCSP ON
-%token	PARAMS PARENT PASS PASSWORD PATH PFLOG PFTAG PORT PREFORK PRIORITY
-%token	PROTO PROXYPROTO
+%token	PARAMS PARENT PASS PASSWORD PATH PATTERN PFLOG PFTAG PORT PREFORK
+%token	PRIORITY PROTO PROXYPROTO
 %token	QUERYSTR QUICK
 %token	RANDOM REAL REDIRECT RELAY REMOVE REQUEST RESPONSE RETRY RETURN
 %token	ROUNDROBIN ROUTE ROUTER RTABLE RTLABEL
@@ -203,7 +204,7 @@ typedef struct {
 %type	<v.number>	dstmode flag forwardmode retry
 %type	<v.number>	opttls opttlsclient optproxyproto
 %type	<v.number>	redirect_proto relay_proto match pflog
-%type	<v.number>	action ruleaf key_option
+%type	<v.number>	action ruleaf key_option optpattern
 %type	<v.port>	port
 %type	<v.host>	host
 %type	<v.addr>	address rulesrc ruledst addrprefix
@@ -1598,18 +1599,24 @@ ruleopts	: METHOD STRING					{
 			rule->rule_method = id;
 			free($2);
 		}
-		| COOKIE key_option STRING value		{
+		| COOKIE key_option optpattern STRING value	{
 			keytype = KEY_TYPE_COOKIE;
-			rule->rule_kv[keytype].kv_key = strdup($3);
+			rule->rule_kv[keytype].kv_key = strdup($4);
 			rule->rule_kv[keytype].kv_option = $2;
-			rule->rule_kv[keytype].kv_value = (($4 != NULL) ?
-			    strdup($4) : strdup("*"));
+			if ($3)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_KEY_PATTERN;
+			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
+			    strdup($5) : strdup("*"));
+			if (value_pattern)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_VAL_PATTERN;
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			free($3);
-			if ($4)
-				free($4);
+			free($4);
+			if ($5)
+				free($5);
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
 		| COOKIE key_option				{
@@ -1617,20 +1624,26 @@ ruleopts	: METHOD STRING					{
 			rule->rule_kv[keytype].kv_option = $2;
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
-		| HEADER key_option STRING value		{
+		| HEADER key_option optpattern STRING value		{
 			keytype = KEY_TYPE_HEADER;
 			memset(&rule->rule_kv[keytype], 0,
 			    sizeof(rule->rule_kv[keytype]));
 			rule->rule_kv[keytype].kv_option = $2;
-			rule->rule_kv[keytype].kv_key = strdup($3);
-			rule->rule_kv[keytype].kv_value = (($4 != NULL) ?
-			    strdup($4) : strdup("*"));
+			if ($3)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_KEY_PATTERN;
+			rule->rule_kv[keytype].kv_key = strdup($4);
+			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
+			    strdup($5) : strdup("*"));
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			free($3);
-			if ($4)
-				free($4);
+			if (value_pattern)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_VAL_PATTERN;
+			free($4);
+			if ($5)
+				free($5);
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
 		| HEADER key_option				{
@@ -1638,18 +1651,24 @@ ruleopts	: METHOD STRING					{
 			rule->rule_kv[keytype].kv_option = $2;
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
-		| PATH key_option STRING value			{
+		| PATH key_option optpattern STRING value	{
 			keytype = KEY_TYPE_PATH;
 			rule->rule_kv[keytype].kv_option = $2;
-			rule->rule_kv[keytype].kv_key = strdup($3);
-			rule->rule_kv[keytype].kv_value = (($4 != NULL) ?
-			    strdup($4) : strdup("*"));
+			if ($3)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_KEY_PATTERN;
+			rule->rule_kv[keytype].kv_key = strdup($4);
+			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
+			    strdup($5) : strdup("*"));
+			if (value_pattern)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_VAL_PATTERN;
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			free($3);
-			if ($4)
-				free($4);
+			free($4);
+			if ($5)
+				free($5);
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
 		| PATH key_option				{
@@ -1671,30 +1690,36 @@ ruleopts	: METHOD STRING					{
 			rule->rule_kv[keytype].kv_value = strip;
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
-		| QUERYSTR key_option STRING value		{
+		| QUERYSTR key_option optpattern STRING value	{
 			switch ($2) {
 			case KEY_OPTION_APPEND:
 			case KEY_OPTION_SET:
 			case KEY_OPTION_REMOVE:
 				yyerror("combining query type and the given "
 				    "option is not supported");
-				free($3);
-				if ($4)
-					free($4);
+				free($4);
+				if ($5)
+					free($5);
 				YYERROR;
 				break;
 			}
 			keytype = KEY_TYPE_QUERY;
 			rule->rule_kv[keytype].kv_option = $2;
-			rule->rule_kv[keytype].kv_key = strdup($3);
-			rule->rule_kv[keytype].kv_value = (($4 != NULL) ?
-			    strdup($4) : strdup("*"));
+			if ($3)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_KEY_PATTERN;
+			rule->rule_kv[keytype].kv_key = strdup($4);
+			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
+			    strdup($5) : strdup("*"));
+			if (value_pattern)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_VAL_PATTERN;
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			free($3);
-			if ($4)
-				free($4);
+			free($4);
+			if ($5)
+				free($5);
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
 		| QUERYSTR key_option				{
@@ -1711,30 +1736,36 @@ ruleopts	: METHOD STRING					{
 			rule->rule_kv[keytype].kv_option = $2;
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
-		| URL key_option optdigest value			{
+		| URL key_option optpattern optdigest value	{
 			switch ($2) {
 			case KEY_OPTION_APPEND:
 			case KEY_OPTION_SET:
 			case KEY_OPTION_REMOVE:
 				yyerror("combining url type and the given "
 				"option is not supported");
-				free($3.digest);
-				free($4);
+				free($4.digest);
+				free($5);
 				YYERROR;
 				break;
 			}
 			keytype = KEY_TYPE_URL;
 			rule->rule_kv[keytype].kv_option = $2;
-			rule->rule_kv[keytype].kv_key = strdup($3.digest);
-			rule->rule_kv[keytype].kv_digest = $3.type;
-			rule->rule_kv[keytype].kv_value = (($4 != NULL) ?
-			    strdup($4) : strdup("*"));
+			if ($3)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_KEY_PATTERN;
+			rule->rule_kv[keytype].kv_key = strdup($4.digest);
+			rule->rule_kv[keytype].kv_digest = $4.type;
+			rule->rule_kv[keytype].kv_value = (($5 != NULL) ?
+			    strdup($5) : strdup("*"));
+			if (value_pattern)
+				rule->rule_kv[keytype].kv_flags |=
+				    KV_FLAG_VAL_PATTERN;
 			if (rule->rule_kv[keytype].kv_key == NULL ||
 			    rule->rule_kv[keytype].kv_value == NULL)
 				fatal("out of memory");
-			free($3.digest);
-			if ($4)
-				free($4);
+			free($4.digest);
+			if ($5)
+				free($5);
 			rule->rule_kv[keytype].kv_type = keytype;
 		}
 		| URL key_option					{
@@ -1888,8 +1919,19 @@ ruleopts	: METHOD STRING					{
 		}
 		;
 
-value		: /* empty */		{ $$ = NULL; }
-		| VALUE STRING		{ $$ = $2; }
+optpattern	: /* empty */		{ $$ = 0; }
+		| PATTERN		{ $$ = 1; }
+		;
+
+value		: /* empty */		{ $$ = NULL;
+					  value_pattern = 0;
+					}
+		| VALUE STRING		{ $$ = $2;
+					  value_pattern = 0;
+					}
+		| VALUE PATTERN STRING	{ $$ = $3;
+					  value_pattern = 1;
+					}
 		;
 
 key_option	: /* empty */		{ $$ = KEY_OPTION_NONE; }
@@ -2600,6 +2642,7 @@ lookup(char *s)
 		{ "pass",		PASS },
 		{ "password",		PASSWORD },
 		{ "path",		PATH },
+		{ "pattern",		PATTERN},
 		{ "pflog",		PFLOG },
 		{ "pftag",		PFTAG },
 		{ "port",		PORT },
diff --git a/relay_http.c b/relay_http.c
index 806752f..a63462f 100644
--- a/relay_http.c
+++ b/relay_http.c
@@ -39,6 +39,7 @@
 
 #include "relayd.h"
 #include "http.h"
+#include "patterns.h"
 #include "log.h"
 
 static int	 _relay_lookup_url(struct ctl_relay_event *, char *, char *,
@@ -836,6 +837,12 @@ relay_reset_http(struct ctl_relay_event *cre)
 	cre->done = 0;
 }
 
+/*
+ * Match a single URL candidate (host+path[?query], or its digest) against
+ * the rule key. Default matches literally, ignoring case. With pattern
+ * the key is a patterns(7) expression.
+ * Return RES_DROP on match, RES_PASS on miss.
+ */
 static int
 _relay_lookup_url(struct ctl_relay_event *cre, char *host, char *path,
     char *query, struct kv *kv)
@@ -860,7 +867,7 @@ _relay_lookup_url(struct ctl_relay_event *cre, char *host, char *path,
 		    val, strlen(val), NULL)) == NULL) {
 			relay_abort_http(con, 500,
 			    "failed to allocate digest", 0);
-			goto fail;
+			goto done;
 		}
 		str = md;
 		break;
@@ -869,21 +876,36 @@ _relay_lookup_url(struct ctl_relay_event *cre, char *host, char *path,
 		break;
 	}
 
-	log_debug("%s: session %d: %s, %s: %d", __func__, con->se_id,
-	    str, kv->kv_key, strcasecmp(kv->kv_key, str));
+	log_debug("%s: session %d: %s, %s", __func__, con->se_id,
+	    str, kv->kv_key);
 
-	if (strcasecmp(kv->kv_key, str) == 0) {
+	if (kv->kv_flags & KV_FLAG_KEY_PATTERN) {
+		if (kv_match_key(kv, str, 0)) {
+			log_debug("%s: session %d: pattern \"%s\" matched "
+			    "url \"%s\"",
+			    __func__, con->se_id, kv->kv_key, str);
+			ret = RES_DROP;
+			goto done;
+		}
+	} else if (strcasecmp(kv->kv_key, str) == 0) {
 		ret = RES_DROP;
-		goto fail;
+		goto done;
 	}
 
 	ret = RES_PASS;
- fail:
+ done:
 	free(md);
 	free(val);
 	return (ret);
 }
 
+/*
+ * URL lookup algorithm inspired by an old version of
+ * https://developers.google.com/safe-browsing/reference/URLs.and.Hashing
+ * Enumerate URL candidates by stripping subdomains and path components
+ * and probe each with _relay_lookup_url.
+ * Return RES_DROP on the first match, RES_PASS if none match.
+ */
 int
 relay_lookup_url(struct ctl_relay_event *cre, const char *host, struct kv *kv)
 {
@@ -896,12 +918,6 @@ relay_lookup_url(struct ctl_relay_event *cre, const char *host, struct kv *kv)
 	if (desc->http_path == NULL)
 		return (RES_PASS);
 
-	/*
-	 * This is an URL lookup algorithm inspired by
-	 * http://code.google.com/apis/safebrowsing/
-	 *     developers_guide.html#PerformingLookups
-	 */
-
 	log_debug("%s: host '%s', path '%s', query '%s'",
 	    __func__, host, desc->http_path,
 	    desc->http_query == NULL ? "" : desc->http_query);
@@ -960,6 +976,11 @@ relay_lookup_url(struct ctl_relay_event *cre, const char *host, struct kv *kv)
 	return (ret);
 }
 
+/*
+ * Devide the cookie "str" into key/value pairs.
+ * If key match search case-senstive and value by pattern matching
+ * we return RES_DROP otherwise RES_PASS
+ */
 int
 relay_lookup_cookie(struct ctl_relay_event *cre, const char *str,
     struct kv *kv)
@@ -971,22 +992,22 @@ relay_lookup_cookie(struct ctl_relay_event *cre, const char *str,
 		return (RES_INTERNAL);
 	}
 
+	/* split the whole cookie string into pairs of key/value */
 	for (ptr = val; ptr != NULL && strlen(ptr);) {
 		if (*ptr == ' ')
 			*ptr++ = '\0';
 		key = ptr;
 		if ((ptr = strchr(ptr, ';')) != NULL)
 			*ptr++ = '\0';
+
 		/*
-		 * XXX We do not handle attributes
-		 * ($Path, $Domain, or $Port)
+		 * Skip RFC 2965 attributes ($Path, $Domain, $Port);
+		 * obsolete per RFC 6265.
 		 */
 		if (*key == '$')
 			continue;
 
-		if ((value =
-		    strchr(key, '=')) == NULL ||
-		    strlen(value) < 1)
+		if ((value = strchr(key, '=')) == NULL || strlen(value) < 1)
 			continue;
 		*value++ = '\0';
 		if (*value == '"')
@@ -994,19 +1015,19 @@ relay_lookup_cookie(struct ctl_relay_event *cre, const char *str,
 		if (value[strlen(value) - 1] == '"')
 			value[strlen(value) - 1] = '\0';
 
-		log_debug("%s: key %s = %s, %s = %s : %d",
-		    __func__, key, value, kv->kv_key, kv->kv_value,
-		    strcasecmp(kv->kv_key, key));
-
-		if (strcasecmp(kv->kv_key, key) == 0 &&
-		    ((kv->kv_value == NULL) ||
-		     (fnmatch(kv->kv_value, value,
-		      FNM_CASEFOLD) != FNM_NOMATCH))) {
+		if (((kv->kv_flags & KV_FLAG_KEY_PATTERN) ?
+		    kv_match_key(kv, key, 0) :
+		    strcasecmp(kv->kv_key, key) == 0) &&
+		    (kv->kv_value == NULL ||
+		     kv_match_val(kv, value, FNM_CASEFOLD))) {
+			log_debug("%s: matched cookie \"%s\" value \"%s\" "
+			    "(rule value \"%s\")",
+			    __func__, key, value,
+			    kv->kv_value ? kv->kv_value : "(any)");
 			ret = RES_DROP;
 			goto done;
 		}
 	}
-
 	ret = RES_PASS;
 
  done:
@@ -1014,16 +1035,21 @@ relay_lookup_cookie(struct ctl_relay_event *cre, const char *str,
 	return (ret);
 }
 
+/*
+ * Devide the http query string by "&" and the key/value pairs by "=".
+ * Search key/value pair pattern matching, of seccessful match return RES_DROP
+ * otherwise RES_FAIL.
+ */
 int
 relay_lookup_query(struct ctl_relay_event *cre, struct kv *kv)
 {
 	struct http_descriptor	*desc = cre->desc;
 	struct kv		*match = &desc->http_matchquery;
 	char			*val, *ptr, *tmpkey = NULL, *tmpval = NULL;
-	int			 ret = -1;
+	int			 ret = RES_FAIL;
 
 	if (desc->http_query == NULL)
-		return (-1);
+		return (ret);
 	if ((val = strdup(desc->http_query)) == NULL) {
 		return (RES_INTERNAL);
 	}
@@ -1038,11 +1064,14 @@ relay_lookup_query(struct ctl_relay_event *cre, struct kv *kv)
 			continue;
 		*tmpval++ = '\0';
 
-		if (fnmatch(kv->kv_key, tmpkey, 0) != FNM_NOMATCH &&
-		    (kv->kv_value == NULL || fnmatch(kv->kv_value, tmpval, 0) !=
-		     FNM_NOMATCH))
+		if (kv_match_key(kv, tmpkey, 0) &&
+		    (kv->kv_value == NULL || kv_match_val(kv, tmpval, 0))) {
+			log_debug("%s: matched query key \"%s\" value \"%s\" "
+			    "(rule key \"%s\" value \"%s\")",
+			    __func__, tmpkey, tmpval, kv->kv_key,
+			    kv->kv_value ? kv->kv_value : "(any)");
 			break;
-		else
+		} else
 			tmpkey = NULL;
 	}
 
@@ -1055,7 +1084,7 @@ relay_lookup_query(struct ctl_relay_event *cre, struct kv *kv)
 	match->kv_value = strdup(tmpval);
 	if (match->kv_value == NULL)
 		goto done;
-	ret = 0;
+	ret = RES_DROP;
 
  done:
 	free(val);
@@ -1208,6 +1237,7 @@ relay_expand_http(struct ctl_relay_event *cre, char *val, char *buf,
 
 	if (strstr(val, "$HOST") != NULL) {
 		key.kv_key = "Host";
+		/* look up the Host header for $HOST expansion */
 		host = kv_find(&desc->http_headers, &key);
 		if (host) {
 			if (host->kv_value == NULL)
@@ -1439,7 +1469,7 @@ relay_httpquery_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		return (0);
 	else if (kv->kv_key == NULL)
 		return (0);
-	else if ((res = relay_lookup_query(cre, kv)) != 0)
+	else if ((res = relay_lookup_query(cre, kv)) != RES_DROP)
 		return (res);
 
 	relay_match(actions, kv, match, NULL);
@@ -1447,6 +1477,13 @@ relay_httpquery_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	return (0);
 }
 
+/*
+ * Match the rule against request or response headers.
+ * When the rule has a value, it is compared against the header value
+ * (default glob(7), or patterns(7) when pattern is set).
+ * Return 0 on match or when the header will be appended or set later,
+ * or a negative value on mismatch or missing header.
+ */
 int
 relay_httpheader_test(struct ctl_relay_event *cre, struct relay_rule *rule,
     struct kvlist *actions)
@@ -1458,6 +1495,7 @@ relay_httpheader_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	if (kv->kv_type != KEY_TYPE_HEADER)
 		return (0);
 
+	/* find a header matching the rule key (pattern/glob aware) */
 	match = kv_find(&desc->http_headers, kv);
 
 	if (kv->kv_option == KEY_OPTION_APPEND ||
@@ -1467,13 +1505,14 @@ relay_httpheader_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		/* Fail if header doesn't exist */
 		return (-1);
 	} else {
-		if (fnmatch(kv->kv_key, match->kv_key,
-		    FNM_CASEFOLD) == FNM_NOMATCH)
-			return (-1);
-		if (kv->kv_value != NULL &&
-		    match->kv_value != NULL &&
-		    fnmatch(kv->kv_value, match->kv_value, 0) == FNM_NOMATCH)
+		if (kv->kv_value != NULL && match->kv_value != NULL &&
+		    !kv_match_val(kv, match->kv_value, 0)) {
+			log_debug("%s: rule %d: header \"%s\" value mismatch: "
+			    "rule \"%s\" vs. actual \"%s\"",
+			    __func__, rule->rule_id, kv->kv_key,
+			    kv->kv_value, match->kv_value);
 			return (-1);
+		}
 	}
 
 	relay_match(actions, kv, match, &desc->http_headers);
@@ -1481,6 +1520,12 @@ relay_httpheader_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	return (0);
 }
 
+/*
+ * Match the rule against the request path, and when a rule value is set
+ * also against the query string.
+ * Return 0 on match or when the path is only being stripped,
+ * or a negative value on mismatch.
+ */
 int
 relay_httppath_test(struct ctl_relay_event *cre, struct relay_rule *rule,
     struct kvlist *actions)
@@ -1495,15 +1540,23 @@ relay_httppath_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	else if (kv->kv_option != KEY_OPTION_STRIP) {
 		if (kv->kv_key == NULL)
 			return (0);
-		else if (fnmatch(kv->kv_key, desc->http_path, 0) == FNM_NOMATCH)
+		else if (!kv_match_key(kv, desc->http_path, 0)) {
+			log_debug("%s: rule %d: path \"%s\" does not match "
+			    "rule key \"%s\"",
+			    __func__, rule->rule_id, desc->http_path,
+			    kv->kv_key);
 			return (-1);
-		else if (kv->kv_value != NULL &&
+		} else if (kv->kv_value != NULL &&
 		    kv->kv_option == KEY_OPTION_NONE) {
 			query = desc->http_query == NULL ? "" :
 			    desc->http_query;
-			if (fnmatch(kv->kv_value, query, FNM_CASEFOLD) ==
-			    FNM_NOMATCH)
+			if (!kv_match_val(kv, query, FNM_CASEFOLD)) {
+				log_debug("%s: rule %d: query \"%s\" does not "
+				    "match rule value \"%s\"",
+				    __func__, rule->rule_id, query,
+				    kv->kv_value);
 				return (-1);
+			}
 		}
 	}
 
@@ -1512,6 +1565,10 @@ relay_httppath_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 	return (0);
 }
 
+/*
+ * Match the rule against the full URL (Host header + path[?query]) via
+ * relay_lookup_url.
+ */
 int
 relay_httpurl_test(struct ctl_relay_event *cre, struct relay_rule *rule,
     struct kvlist *actions)
@@ -1533,8 +1590,11 @@ relay_httpurl_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		return (0);
 	else if (rule->rule_action != RULE_ACTION_BLOCK &&
 	    kv->kv_option == KEY_OPTION_LOG &&
-	    fnmatch(kv->kv_key, match->kv_key, FNM_CASEFOLD) != FNM_NOMATCH) {
-		/* fnmatch url only for logging */
+	    kv_match_key(kv, match->kv_key, FNM_CASEFOLD)) {
+		log_info("%s: rule %d: url \"%s\" matched",
+		    __func__, rule->rule_id,
+		    match->kv_key ? match->kv_key : "");
+		/* match url only for logging */
 	} else if ((res = relay_lookup_url(cre, host->kv_value, kv)) != 0)
 		return (res);
 	relay_match(actions, kv, match, NULL);
@@ -1577,7 +1637,7 @@ relay_httpcookie_test(struct ctl_relay_event *cre, struct relay_rule *rule,
 		if (kv->kv_key == NULL || match->kv_value == NULL)
 			return (0);
 		else if ((res = relay_lookup_cookie(cre, match->kv_value,
-		    kv)) != 0)
+		    kv)) != RES_DROP)
 			return (res);
 	}
 
diff --git a/relayd.c b/relayd.c
index f953bae..120749f 100644
--- a/relayd.c
+++ b/relayd.c
@@ -48,6 +48,7 @@
 #include <tls.h>
 
 #include "relayd.h"
+#include "patterns.h"
 #include "log.h"
 
 #define MAXIMUM(a, b)	(((a) > (b)) ? (a) : (b))
@@ -839,18 +840,65 @@ kv_log(struct rsession *con, struct kv *kv, u_int16_t labelid,
 	return (0);
 }
 
+/*
+ * Match "pattern" against "str".
+ * Returns 1 on match, 0 on no match or error.
+ */
+static int
+_kv_match(const char *pattern, const char *str, int is_pattern, int fnflags)
+{
+	struct str_find	 sm;
+	const char	*errstr = NULL;
+
+	if (pattern == NULL || str == NULL)
+		return (0);
+	if (!is_pattern)
+		return (fnmatch(pattern, str, fnflags) != FNM_NOMATCH);
+
+	if (str_find(str, pattern, &sm, 1, &errstr) > 0 && errstr == NULL)
+		return (1);
+	if (errstr != NULL)
+		log_warnx("%s: pattern \"%s\": %s", __func__, pattern, errstr);
+	return (0);
+}
+
+int
+kv_match_key(const struct kv *kv, const char *str, int fnflags)
+{
+	return (_kv_match(kv->kv_key, str, kv->kv_flags & KV_FLAG_KEY_PATTERN,
+	    fnflags));
+}
+
+int
+kv_match_val(const struct kv *kv, const char *str, int fnflags)
+{
+	return (_kv_match(kv->kv_value, str, kv->kv_flags & KV_FLAG_VAL_PATTERN,
+	    fnflags));
+}
+
+/*
+ * Look up for "kv"
+ * Return the matched kv, or NULL if none.
+ */
 struct kv *
 kv_find(struct kvtree *keys, struct kv *kv)
 {
-	struct kv	*match;
-	const char	*key;
+	struct kv	*match = NULL;
 
-	if (kv->kv_flags & KV_FLAG_GLOBBING) {
-		/* Test header key using shell globbing rules */
-		key = kv->kv_key == NULL ? "" : kv->kv_key;
+	 /*
+	  * If the key uses glob(7) or a patterns(7) expression, fall back
+	  * to a linear scan and match each entry.
+	  */
+	if (kv->kv_flags & (KV_FLAG_GLOBBING | KV_FLAG_KEY_PATTERN)) {
 		RB_FOREACH(match, kvtree, keys) {
-			if (fnmatch(key, match->kv_key, FNM_CASEFOLD) == 0)
+			if (kv_match_key(kv, match->kv_key, FNM_CASEFOLD)) {
+				log_debug("%s: %s \"%s\" matched key \"%s\"",
+				    __func__,
+				    (kv->kv_flags & KV_FLAG_KEY_PATTERN) ?
+				    "pattern" : "glob",
+				    kv->kv_key, match->kv_key);
 				break;
+			}
 		}
 	} else {
 		/* Fast tree-based lookup only works without globbing */
@@ -945,9 +993,14 @@ rule_add(struct protocol *proto, struct relay_rule *rule, const char *rulefile)
 			break;
 		}
 
-		if (kv->kv_value != NULL && strchr(kv->kv_value, '$') != NULL)
+		/* Only auto-detect on the side that is not a pattern. */
+		if (kv->kv_value != NULL &&
+		    !(kv->kv_flags & KV_FLAG_VAL_PATTERN) &&
+		    strchr(kv->kv_value, '$') != NULL)
 			kv->kv_flags |= KV_FLAG_MACRO;
-		if (kv->kv_key != NULL && strpbrk(kv->kv_key, "*?[") != NULL)
+		if (kv->kv_key != NULL &&
+		    !(kv->kv_flags & KV_FLAG_KEY_PATTERN) &&
+		    strpbrk(kv->kv_key, "*?[") != NULL)
 			kv->kv_flags |= KV_FLAG_GLOBBING;
 	}
 
diff --git a/relayd.conf.5 b/relayd.conf.5
index 4d30c29..08f04ea 100644
--- a/relayd.conf.5
+++ b/relayd.conf.5
@@ -366,16 +366,18 @@ milliseconds.
 .Ic check send
 .Ar data
 .Ic expect
-.Ar pattern
+.Ar response
 .Op Ic tls
 .Xc
 For each host in the table, a TCP connection is established on the
 port specified, then
 .Ar data
 is sent.
-Incoming data is then read and is expected to match against
-.Ar pattern
-using shell globbing rules.
+Incoming data is then read and is expected to match the
+.Ar response
+using shell-style
+.Xr glob 7
+pattern matching.
 If
 .Ar data
 is an empty string or
@@ -1320,16 +1322,47 @@ $ echo -n "example.com/path/?args" | sha1
 .Bq Ar type
 may be one of:
 .Bl -tag -width Ds
-.It Ic cookie Ar option Oo Ar key Oo Ic value Ar value Oc Oc
+.It Xo Ic cookie Ar option
+.Oo Oo Ic pattern Oc Ar key
+.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Xc
 Look up the entity as a value in the Cookie header.
 This type is only available with the direction
-.Ic request .
-.It Ic header Ar option Oo Ar key Oo Ic value Ar value Oc Oc
+.Ic request ,
+for example:
+.Bd -literal -offset indent
+# Block requests carrying an admin session cookie
+block request quick cookie "session" value pattern "^admin_"
+
+# Only accept two-letter language cookies
+pass  request cookie "lang" value pattern "^%a%a$"
+.Ed
+.It Xo Ic header Ar option
+.Oo Oo Ic pattern Oc Ar key
+.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Xc
 Look up the entity in the application protocol headers, like HTTP
 headers in
 .Ic http
-mode.
-.It Ic path Ar option Oo Ar key Oo Ic value Ar value Oc Oc
+mode,
+for example:
+.Bd -literal -offset indent
+# Block crawlers whose User-Agent contains a "bot" word
+# ([Bb]ot at the end of a word, using a frontier pattern)
+block request quick header "User-Agent" \e
+    value pattern "[Bb]ot%f[^%a]"
+
+# Require JSON requests on the API
+pass  request header "Content-Type" \e
+    value pattern "^application/json"
+
+# Strip any custom debug header (matches key by pattern)
+pass request header remove pattern "^X%-Debug%-"
+.Ed
+.It Xo Ic path Ar option
+.Oo Oo Ic pattern Oc Ar key
+.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Xc
 Look up the entity as a value in the URL path when using the
 .Ic http
 protocol.
@@ -1342,7 +1375,17 @@ and query and the value will match the complete query,
 for example:
 .Bd -literal -offset indent
 block path "/index.html"
+block path pattern "^/index%.html$"
 block path "/cgi-bin/t.cgi" value "foo=bar*"
+
+# Block direct access to the admin tree
+block request quick path pattern "^/admin/"
+
+# Only versioned API paths (v1, v2, ...)
+pass request path pattern "^/api/v%d+/"
+
+# Match image extensions
+pass request path pattern "%.jpe?g$"
 .Ed
 .It Ic path  strip Ar number
 Strip
@@ -1353,7 +1396,10 @@ when using the
 protocol.
 This type is only available with the direction
 .Ic request .
-.It Ic query Ar option Oo Ar key Oo Ic value Ar value Oc Oc
+.It Xo Ic query Ar option
+.Oo Oo Ic pattern Oc Ar key
+.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Xc
 Look up the entity as a query variable in the URL when using the
 .Ic http
 protocol.
@@ -1363,8 +1409,17 @@ for example:
 .Bd -literal -offset indent
 # Will match /cgi-bin/example.pl?foo=bar&ok=yes
 pass request query "foo" value "bar"
+
+# Only allow numeric IDs
+pass request query "id" value pattern "^%d+$"
+
+# Reject requests that carry a debug flag on any query key
+block request quick query pattern "^debug" value "*"
 .Ed
-.It Ic url Ar option Oo Oo Ic digest Oc Ar key Oo Ic value Ar value Oc Oc
+.It Xo Ic url Ar option
+.Oo Oo Ic pattern Oc Oo Ic digest Oc Ar key
+.Oo Ic value Oo Ic pattern Oc Ar value Oc Oc
+.Xc
 Look up the entity as a URL suffix/prefix expression consisting of a
 canonicalized hostname without port or suffix and a path name or
 prefix when using the
@@ -1375,7 +1430,10 @@ This type is only available with the direction
 for example:
 .Bd -literal -offset indent
 block url "example.com/index.html"
-block url "example.com/test.cgi?val=1"
+block url pattern "^example%.com/index%.html$"
+
+# Any subdomain of example.com, path under /api/
+pass request url pattern "^[%w%-]+%.example%.com/api/"
 .Ed
 .Pp
 .Xr relayd 8
@@ -1401,6 +1459,87 @@ example.com/1/2/3/
 .Ed
 .El
 .Pp
+The optional
+.Ic pattern
+keyword makes the following string a
+.Xr patterns 7
+expression instead of the default
+.Xr glob 7
+pattern.
+It can appear before any matchable string, for example:
+.Bd -literal -offset indent
+pass request path pattern "^/api/v%d+/" \e
+    value pattern "^id=%d+$"
+.Ed
+.Pp
+Note that macro expansion is disabled on a
+.Ar value
+that uses
+.Ic pattern ,
+because
+.Sq $
+in
+.Xr patterns 7
+is the end-of-string anchor, not a macro prefix.
+Use a plain
+.Ar value
+if macro expansion is required.
+.Pp
+Matching with
+.Ic pattern
+is case-sensitive, whereas the default
+.Xr glob 7
+matching for
+.Ic cookie ,
+.Ic header ,
+.Ic path ,
+and
+.Ic query
+is case-insensitive.
+The default matching for
+.Ic url
+is a case-insensitive literal comparison against every candidate form
+listed above.
+When porting existing rules to
+.Ic pattern ,
+add character classes such as
+.Dq [Uu]ser%-[Aa]gent
+to preserve the previous behavior.
+.Pp
+If
+.Ar value
+is omitted, it defaults to
+.Sq *
+and matches any value using
+.Xr glob 7
+rules regardless of whether the key uses
+.Ic pattern .
+.Pp
+For
+.Ic url ,
+.Ic pattern
+on the
+.Ar key
+is tested against every candidate form listed above; anchor patterns
+carefully.
+When combined with
+.Ic digest ,
+the pattern applies to the digest string, not to the URL.
+.Pp
+.Ic pattern
+on the
+.Ar key
+is most useful with the
+.Ic remove
+and
+.Ic log
+options.
+It has no effect for
+.Ic append
+or
+.Ic set ,
+which create a new entity from a literal name.
+.Pp
 .Bq Ar option
 may be one of:
 .Bl -tag -width Ds
@@ -1720,7 +1859,9 @@ router "uplinks" {
 }
 .Ed
 .Sh SEE ALSO
+.Xr glob 7 ,
 .Xr ocspcheck 8 ,
+.Xr patterns 7 ,
 .Xr relayctl 8 ,
 .Xr relayd 8 ,
 .Xr ssl 8
diff --git a/relayd.h b/relayd.h
index 308931e..4cad20a 100644
--- a/relayd.h
+++ b/relayd.h
@@ -323,6 +323,12 @@ enum digest_type {
 TAILQ_HEAD(kvlist, kv);
 RB_HEAD(kvtree, kv);
 
+#define KV_FLAG_MACRO		 0x01
+#define KV_FLAG_INVALID		 0x02
+#define KV_FLAG_GLOBBING	 0x04
+#define KV_FLAG_KEY_PATTERN	 0x08
+#define KV_FLAG_VAL_PATTERN	 0x10
+
 struct kv {
 	char			*kv_key;
 	char			*kv_value;
@@ -331,9 +337,6 @@ struct kv {
 	enum key_option		 kv_option;
 	enum digest_type	 kv_digest;
 
-#define KV_FLAG_MACRO		 0x01
-#define KV_FLAG_INVALID		 0x02
-#define KV_FLAG_GLOBBING	 0x04
 	u_int8_t		 kv_flags;
 
 	struct kvlist		 kv_children;
@@ -1354,6 +1357,8 @@ int			 kv_log(struct rsession *, struct kv *, u_int16_t,
 struct kv		*kv_find(struct kvtree *, struct kv *);
 struct kv		*kv_find_value(struct kvtree *, char *, const char *,
     const char *);
+int			 kv_match_key(const struct kv *, const char *, int);
+int			 kv_match_val(const struct kv *, const char *, int);
 int			 kv_cmp(struct kv *, struct kv *);
 int			 rule_add(struct protocol *, struct relay_rule *,
     const char *);