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

Rafael Sadowski <[email protected]>
Newsgroups gmane.os.openbsd.tech
Message-ID <[email protected]>
On Wed Aug 19, 2026 at 10:02:57AM +0200, Rafael Sadowski wrote:
> 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
> 

After feedback, here is a diff which copies patterns.{h,c} in relayd:

cd /usr/src/usr.sbin/relayd && patch -p3 -E < relayd-patterns.diff

commit fa1649e31ee7b4b65dbf501c3c7b0aa27ead085f
Author: Rafael Sadowski <[email protected]>
Date:   Wed Aug 12 20:53:09 2026 +0200

    relayd: 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..b6cca7f 100644
--- a/Makefile
+++ b/Makefile
@@ -2,11 +2,35 @@
 
 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
+
+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
+
+# Keep in-sync with httpd
+SRCS+=		patterns.c
+
 MAN=		relayd.8 relayd.conf.5
 
 LDADD=		-lagentx -levent -ltls -lssl -lcrypto -lutil
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/patterns.c b/patterns.c
new file mode 100644
index 0000000..c5f745d
--- /dev/null
+++ b/patterns.c
@@ -0,0 +1,711 @@
+/*	$OpenBSD: patterns.c,v 1.6 2026/07/26 14:46:32 rsadowski Exp $	*/
+
+/*
+ * Copyright (c) 2015 Reyk Floeter <[email protected]>
+ * Copyright (C) 1994-2015 Lua.org, PUC-Rio.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+/*
+ * Derived from Lua 5.3.1:
+ * $Id: patterns.c,v 1.6 2026/07/26 14:46:32 rsadowski Exp $
+ * Standard library for string operations and pattern-matching
+ */
+
+#include <sys/types.h>
+#include <ctype.h>
+#include <errno.h>
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "patterns.h"
+
+#define uchar(c)	((unsigned char)(c)) /* macro to 'unsign' a char */
+#define CAP_UNFINISHED	(-1)
+#define CAP_POSITION	(-2)
+#define L_ESC		'%'
+#define SPECIALS	"^$*+?.([%-"
+
+struct match_state {
+	int matchdepth;		/* control for recursive depth (to avoid C
+				 * stack overflow) */
+	int repetitioncounter;	/* control the repetition items */
+	int maxcaptures;	/* configured capture limit */
+	const char *src_init;	/* init of source string */
+	const char *src_end;	/* end ('\0') of source string */
+	const char *p_end;	/* end ('\0') of pattern */
+	const char *error;	/* should be NULL */
+	int level;		/* total number of captures (finished or
+				 * unfinished) */
+	struct {
+		const char *init;
+		ptrdiff_t len;
+	} capture[MAXCAPTURES];
+};
+
+/* recursive function */
+static const char *match(struct match_state *, const char *, const char *);
+
+static int
+match_error(struct match_state *ms, const char *error)
+{
+	ms->error = ms->error == NULL ? error : ms->error;
+	return (-1);
+}
+
+static int
+check_capture(struct match_state *ms, int l)
+{
+	l -= '1';
+	if (l < 0 || l >= ms->level || ms->capture[l].len == CAP_UNFINISHED)
+		return match_error(ms, "invalid capture index");
+	return (l);
+}
+
+static int
+capture_to_close(struct match_state *ms)
+{
+	int level = ms->level;
+	for (level--; level >= 0; level--)
+		if (ms->capture[level].len == CAP_UNFINISHED)
+			return (level);
+	return match_error(ms, "invalid pattern capture");
+}
+
+static const char *
+classend(struct match_state *ms, const char *p)
+{
+	switch (*p++) {
+	case L_ESC:
+		if (p == ms->p_end)
+			match_error(ms,
+			    "malformed pattern (ends with '%')");
+		return p + 1;
+	case '[':
+		if (*p == '^')
+			p++;
+		do {
+			/* look for a ']' */
+			if (p == ms->p_end) {
+				match_error(ms,
+				    "malformed pattern (missing ']')");
+				break;
+			}
+			if (*(p++) == L_ESC && p < ms->p_end) {
+				/* skip escapes (e.g. '%]') */
+				p++;
+			}
+		} while (*p != ']');
+		return p + 1;
+	default:
+		return p;
+	}
+}
+
+static int
+match_class(int c, int cl)
+{
+	int res;
+	switch (tolower(cl)) {
+	case 'a':
+		res = isalpha(c);
+		break;
+	case 'c':
+		res = iscntrl(c);
+		break;
+	case 'd':
+		res = isdigit(c);
+		break;
+	case 'g':
+		res = isgraph(c);
+		break;
+	case 'l':
+		res = islower(c);
+		break;
+	case 'p':
+		res = ispunct(c);
+		break;
+	case 's':
+		res = isspace(c);
+		break;
+	case 'u':
+		res = isupper(c);
+		break;
+	case 'w':
+		res = isalnum(c);
+		break;
+	case 'x':
+		res = isxdigit(c);
+		break;
+	default:
+		return (cl == c);
+	}
+	return (islower(cl) ? res : !res);
+}
+
+static int
+matchbracketclass(int c, const char *p, const char *ec)
+{
+	int sig = 1;
+	if (*(p + 1) == '^') {
+		sig = 0;
+		/* skip the '^' */
+		p++;
+	}
+	while (++p < ec) {
+		if (*p == L_ESC) {
+			p++;
+			if (match_class(c, uchar(*p)))
+				return sig;
+		} else if ((*(p + 1) == '-') && (p + 2 < ec)) {
+			p += 2;
+			if (uchar(*(p - 2)) <= c && c <= uchar(*p))
+				return sig;
+		} else if (uchar(*p) == c)
+			return sig;
+	}
+	return !sig;
+}
+
+static int
+singlematch(struct match_state *ms, const char *s, const char *p,
+    const char *ep)
+{
+	if (s >= ms->src_end)
+		return 0;
+	else {
+		int c = uchar(*s);
+		switch (*p) {
+		case '.':
+			/* matches any char */
+			return (1);
+		case L_ESC:
+			return match_class(c, uchar(*(p + 1)));
+		case '[':
+			return matchbracketclass(c, p, ep - 1);
+		default:
+			return (uchar(*p) == c);
+		}
+	}
+}
+
+static const char *
+matchbalance(struct match_state *ms, const char *s, const char *p)
+{
+	if (p >= ms->p_end - 1) {
+		match_error(ms,
+		    "malformed pattern (missing arguments to '%b')");
+		return (NULL);
+	}
+	if (*s != *p)
+		return (NULL);
+	else {
+		int b = *p;
+		int e = *(p + 1);
+		int cont = 1;
+		while (++s < ms->src_end) {
+			if (*s == e) {
+				if (--cont == 0)
+					return s + 1;
+			} else if (*s == b)
+				cont++;
+		}
+	}
+
+	/* string ends out of balance */
+	return (NULL);
+}
+
+static const char *
+max_expand(struct match_state *ms, const char *s, const char *p, const char *ep)
+{
+	ptrdiff_t i = 0;
+	/* counts maximum expand for item */
+	while (singlematch(ms, s + i, p, ep))
+		i++;
+	/* keeps trying to match with the maximum repetitions */
+	while (i >= 0) {
+		const char *res = match(ms, (s + i), ep + 1);
+		if (res)
+			return res;
+		/* else didn't match; reduce 1 repetition to try again */
+		i--;
+	}
+	return NULL;
+}
+
+static const char *
+min_expand(struct match_state *ms, const char *s, const char *p, const char *ep)
+{
+	for (;;) {
+		const char *res = match(ms, s, ep + 1);
+		if (res != NULL)
+			return res;
+		else if (singlematch(ms, s, p, ep))
+			s++;	/* try with one more repetition */
+		else
+			return NULL;
+	}
+}
+
+static const char *
+start_capture(struct match_state *ms, const char *s, const char *p, int what)
+{
+	const char *res;
+
+	int level = ms->level;
+	if (level >= ms->maxcaptures) {
+		match_error(ms, "too many captures");
+		return (NULL);
+	}
+	ms->capture[level].init = s;
+	ms->capture[level].len = what;
+	ms->level = level + 1;
+	/* undo capture if match failed */
+	if ((res = match(ms, s, p)) == NULL)
+		ms->level--;
+	return res;
+}
+
+static const char *
+end_capture(struct match_state *ms, const char *s, const char *p)
+{
+	int l = capture_to_close(ms);
+	const char *res;
+	if (l == -1)
+		return NULL;
+	/* close capture */
+	ms->capture[l].len = s - ms->capture[l].init;
+	/* undo capture if match failed */
+	if ((res = match(ms, s, p)) == NULL)
+		ms->capture[l].len = CAP_UNFINISHED;
+	return res;
+}
+
+static const char *
+match_capture(struct match_state *ms, const char *s, int l)
+{
+	size_t len;
+	l = check_capture(ms, l);
+	if (l == -1)
+		return NULL;
+	len = ms->capture[l].len;
+	if ((size_t)(ms->src_end - s) >= len &&
+	    memcmp(ms->capture[l].init, s, len) == 0)
+		return s + len;
+	else
+		return NULL;
+}
+
+static const char *
+match(struct match_state *ms, const char *s, const char *p)
+{
+	const char *ep, *res;
+	char previous;
+
+	if (ms->matchdepth-- == 0) {
+		match_error(ms, "pattern too complex");
+		return (NULL);
+	}
+
+	/* using goto's to optimize tail recursion */
+ init:
+	/* end of pattern? */
+	if (p != ms->p_end) {
+		switch (*p) {
+		case '(':
+			/* start capture */
+			if (*(p + 1) == ')')
+				/* position capture? */
+				s = start_capture(ms, s, p + 2, CAP_POSITION);
+			else
+				s = start_capture(ms, s, p + 1, CAP_UNFINISHED);
+			break;
+		case ')':
+			/* end capture */
+			s = end_capture(ms, s, p + 1);
+			break;
+		case '$':
+			/* is the '$' the last char in pattern? */
+			if ((p + 1) != ms->p_end) {
+				/* no; go to default */
+				goto dflt;
+			}
+			 /* check end of string */
+			s = (s == ms->src_end) ? s : NULL;
+			break;
+		case L_ESC:
+			/* escaped sequences not in the format class[*+?-]? */
+			switch (*(p + 1)) {
+			case 'b':
+				/* balanced string? */
+				s = matchbalance(ms, s, p + 2);
+				if (s != NULL) {
+					p += 4;
+					/* return match(ms, s, p + 4); */
+					goto init;
+				} /* else fail (s == NULL) */
+				break;
+			case 'f':
+				/* frontier? */
+				p += 2;
+				if (*p != '[') {
+					match_error(ms, "missing '['"
+					    " after '%f' in pattern");
+					break;
+				}
+				/* points to what is next */
+				ep = classend(ms, p);
+				if (ms->error != NULL)
+					break;
+				previous =
+				    (s == ms->src_init) ? '\0' : *(s - 1);
+				if (!matchbracketclass(uchar(previous),
+				    p, ep - 1) &&
+				    matchbracketclass(uchar(*s),
+				    p, ep - 1)) {
+					p = ep;
+					/* return match(ms, s, ep); */
+					goto init;
+				}
+				/* match failed */
+				s = NULL;
+				break;
+			case '0':
+			case '1':
+			case '2':
+			case '3':
+			case '4':
+			case '5':
+			case '6':
+			case '7':
+			case '8':
+			case '9':
+				/* capture results (%0-%9)? */
+				s = match_capture(ms, s, uchar(*(p + 1)));
+				if (s != NULL) {
+					p += 2;
+					/* return match(ms, s, p + 2) */
+					goto init;
+				}
+				break;
+			default:
+				goto dflt;
+			}
+			break;
+		default:
+			/* pattern class plus optional suffix */
+ dflt:
+			/* points to optional suffix */
+			ep = classend(ms, p);
+			if (ms->error != NULL)
+				break;
+
+			/* does not match at least once? */
+			if (!singlematch(ms, s, p, ep)) {
+				if (ms->repetitioncounter-- == 0) {
+					match_error(ms, "max repetition items");
+					s = NULL; /* fail */
+				/* accept empty? */
+				} else if (*ep == '*' || *ep == '?' ||
+				    *ep == '-') {
+					p = ep + 1;
+					/* return match(ms, s, ep + 1); */
+					goto init;
+				} else {
+					/* '+' or no suffix */
+					s = NULL; /* fail */
+				}
+			} else {
+				/* matched once */
+				/* handle optional suffix */
+				switch (*ep) {
+				case '?':
+					/* optional */
+					if ((res =
+					    match(ms, s + 1, ep + 1)) != NULL)
+						s = res;
+					else {
+						/*
+						 * else return
+						 *     match(ms, s, ep + 1);
+						 */
+						p = ep + 1;
+						goto init;
+					}
+					break;
+				case '+':
+					/* 1 or more repetitions */
+					s++; /* 1 match already done */
+					/* FALLTHROUGH */
+				case '*':
+					/* 0 or more repetitions */
+					s = max_expand(ms, s, p, ep);
+					break;
+				case '-':
+					/* 0 or more repetitions (minimum) */
+					s = min_expand(ms, s, p, ep);
+					break;
+				default:
+					/* no suffix */
+					s++;
+					p = ep;
+					/* return match(ms, s + 1, ep); */
+					goto init;
+				}
+			}
+			break;
+		}
+	}
+	ms->matchdepth++;
+	return s;
+}
+
+static const char *
+lmemfind(const char *s1, size_t l1,
+    const char *s2, size_t l2)
+{
+	const char *init;
+
+	if (l2 == 0) {
+		/* empty strings are everywhere */
+		return (s1);
+	} else if (l2 > l1) {
+		/* avoids a negative 'l1' */
+		return (NULL);
+	} else {
+		/*
+		 * to search for a '*s2' inside 's1'
+		 * - 1st char will be checked by 'memchr'
+		 * - 's2' cannot be found after that
+		 */
+		l2--;
+		l1 = l1 - l2;
+		while (l1 > 0 &&
+		    (init = (const char *)memchr(s1, *s2, l1)) != NULL) {
+			/* 1st char is already checked */
+			init++;
+			if (memcmp(init, s2 + 1, l2) == 0)
+				return init - 1;
+			else {
+				/* correct 'l1' and 's1' to try again */
+				l1 -= init - s1;
+				s1 = init;
+			}
+		}
+		/* not found */
+		return (NULL);
+	}
+}
+
+static int
+push_onecapture(struct match_state *ms, int i, const char *s,
+    const char *e, struct str_find *sm)
+{
+	if (i >= ms->level) {
+		if (i == 0 || ms->level == 0) {
+			/* add whole match */
+			sm->sm_so = (off_t)(s - ms->src_init);
+			sm->sm_eo = (off_t)(e - s) + sm->sm_so;
+		} else
+			return match_error(ms, "invalid capture index");
+	} else {
+		ptrdiff_t l = ms->capture[i].len;
+		if (l == CAP_UNFINISHED)
+			return match_error(ms, "unfinished capture");
+		sm->sm_so = ms->capture[i].init - ms->src_init;
+		sm->sm_eo = sm->sm_so + l;
+	}
+	sm->sm_eo = sm->sm_eo < sm->sm_so ? sm->sm_so : sm->sm_eo;
+	return (0);
+}
+
+static int
+push_captures(struct match_state *ms, const char *s, const char *e,
+    struct str_find *sm, size_t nsm)
+{
+	unsigned int i;
+	unsigned int nlevels = (ms->level <= 0 && s) ? 1 : ms->level;
+
+	if (nlevels > nsm)
+		nlevels = nsm;
+	for (i = 0; i < nlevels; i++)
+		if (push_onecapture(ms, i, s, e, sm + i) == -1)
+			break;
+
+	/* number of strings pushed */
+	return (nlevels);
+}
+
+/* check whether pattern has no special characters */
+static int
+nospecials(const char *p, size_t l)
+{
+	size_t upto = 0;
+
+	do {
+		if (strpbrk(p + upto, SPECIALS)) {
+			/* pattern has a special character */
+			return 0;
+		}
+		/* may have more after \0 */
+		upto += strlen(p + upto) + 1;
+	} while (upto <= l);
+
+	/* no special chars found */
+	return (1);
+}
+
+static int
+str_find_aux(struct match_state *ms, const char *pattern, const char *string,
+    struct str_find *sm, size_t nsm, off_t init)
+{
+	size_t		 ls = strlen(string);
+	size_t		 lp = strlen(pattern);
+	const char	*s = string;
+	const char	*p = pattern;
+	const char	*s1, *s2;
+	int		 anchor, i;
+
+	if (init < 0)
+		init = 0;
+	else if (init > (off_t)ls)
+		return match_error(ms, "starting after string's end");
+	s1 = s + init;
+
+	if (nospecials(p, lp)) {
+		/* do a plain search */
+		s2 = lmemfind(s1, ls - (size_t)init, p, lp);
+		if (s2 != NULL) {
+			i = 0;
+			sm[i].sm_so = 0;
+			sm[i].sm_eo = ls;
+			if (nsm > 1) {
+				i++;
+				sm[i].sm_so = s2 - s;
+				sm[i].sm_eo = (s2 - s) + lp;
+			}
+			return (i + 1);
+		}
+		return (0);
+	}
+
+	anchor = (*p == '^');
+	if (anchor) {
+		p++;
+		lp--;	/* skip anchor character */
+	}
+	ms->maxcaptures = (nsm > MAXCAPTURES ? MAXCAPTURES : nsm) - 1;
+	ms->matchdepth = MAXCCALLS;
+	ms->repetitioncounter = MAXREPETITION;
+	ms->src_init = s;
+	ms->src_end = s + ls;
+	ms->p_end = p + lp;
+	do {
+		const char *res;
+		ms->level = 0;
+		if ((res = match(ms, s1, p)) != NULL) {
+			sm->sm_so = 0;
+			sm->sm_eo = ls;
+			return push_captures(ms, s1, res, sm + 1, nsm - 1) + 1;
+		} else if (ms->error != NULL) {
+			return 0;
+		}
+	} while (s1++ < ms->src_end && !anchor);
+
+	return 0;
+}
+
+int
+str_find(const char *string, const char *pattern, struct str_find *sm,
+    size_t nsm, const char **errstr)
+{
+	struct match_state	ms;
+	int			ret;
+
+	memset(&ms, 0, sizeof(ms));
+	memset(sm, 0, nsm * sizeof(*sm));
+
+	ret = str_find_aux(&ms, pattern, string, sm, nsm, 0);
+	if (ms.error != NULL) {
+		/* Return 0 on error and store the error string */
+		*errstr = ms.error;
+		ret = 0;
+	} else
+		*errstr = NULL;
+
+	return (ret);
+}
+
+int
+str_match(const char *string, const char *pattern, struct str_match *m,
+    const char **errstr)
+{
+	struct str_find		 sm[MAXCAPTURES];
+	struct match_state	 ms;
+	int			 ret, i;
+	size_t			 len, nsm;
+
+	nsm = MAXCAPTURES;
+	memset(&ms, 0, sizeof(ms));
+	memset(sm, 0, sizeof(sm));
+	memset(m, 0, sizeof(*m));
+
+	ret = str_find_aux(&ms, pattern, string, sm, nsm, 0);
+	if (ret <= 0 || ms.error != NULL) {
+		/* Return -1 on error and store the error string */
+		*errstr = ms.error;
+		return (-1);
+	}
+
+	if ((m->sm_match = calloc(ret, sizeof(char *))) == NULL) {
+		*errstr = strerror(errno);
+		return (-1);
+	}
+	m->sm_nmatch = ret;
+
+	for (i = 0; i < ret; i++) {
+		if (sm[i].sm_so > sm[i].sm_eo)
+			continue;
+		len = sm[i].sm_eo - sm[i].sm_so;
+		if ((m->sm_match[i] = strndup(string +
+		    sm[i].sm_so, len)) == NULL) {
+			*errstr = strerror(errno);
+			str_match_free(m);
+			return (-1);
+		}
+	}
+
+	*errstr = NULL;
+	return (0);
+}
+
+void
+str_match_free(struct str_match *m)
+{
+	unsigned int	 i = 0;
+	for (i = 0; i < m->sm_nmatch; i++)
+		free(m->sm_match[i]);
+	free(m->sm_match);
+	m->sm_match = NULL;
+	m->sm_nmatch = 0;
+}
diff --git a/patterns.h b/patterns.h
new file mode 100644
index 0000000..7521e56
--- /dev/null
+++ b/patterns.h
@@ -0,0 +1,46 @@
+/*	$OpenBSD: patterns.h,v 1.4 2026/07/26 14:46:32 rsadowski Exp $	*/
+
+/*
+ * Copyright (c) 2015 Reyk Floeter <[email protected]>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef PATTERNS_H
+#define PATTERNS_H
+
+#include <sys/types.h>
+
+#define MAXCAPTURES	32	/* Max no. of allowed captures in pattern */
+#define MAXCCALLS	200	/* Max recusion depth in pattern matching */
+#define MAXREPETITION	0xfffff	/* Max for repetition items */
+
+struct str_find {
+	off_t		 sm_so;	/* start offset of match */
+	off_t		 sm_eo;	/* end offset of match */
+};
+
+struct str_match {
+	char		**sm_match; /* allocated array of matched strings */
+	unsigned int	  sm_nmatch; /* number of elements in array */
+};
+
+__BEGIN_DECLS
+int	 str_find(const char *, const char *, struct str_find *, size_t,
+    const char **);
+int	 str_match(const char *, const char *, struct str_match *,
+    const char **);
+void	 str_match_free(struct str_match *);
+__END_DECLS
+
+#endif /* PATTERNS_H */
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 *);
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.