From 9529a1dfcfb9ef38fd7b088c556d1e5d6e3ff02d Mon Sep 17 00:00:00 2001
From: Vladimir Dubrovin <3proxy@3proxy.ru>
Date: Thu, 27 Aug 2026 14:34:27 +0300
Subject: [PATCH] http server implemented, pcre support for hostnames in acls
implemented
---
doc/html/howtoe.html | 182 ++++++
doc/html/howtor.html | 183 ++++++
man/3proxy.cfg.5 | 184 +++++-
src/3proxy.c | 6 +
src/common.c | 142 +++++
src/conf.c | 160 ++++-
src/httpsrv.c | 1010 +++++++++++++++++++++++++++---
src/pcre.c | 65 ++
src/proxy.h | 12 +
src/structures.h | 32 +-
tests/cases/auto.py | 4 +-
tests/cases/httpsrv_auth.py | 4 +-
tests/cases/httpsrv_files.py | 195 ++++++
tests/cases/httpsrv_keepalive.py | 109 ++++
tests/cases/httpsrv_ops.py | 6 +-
tests/cases/httpsrv_parsing.py | 29 +-
tests/cases/httpsrv_rules.py | 49 +-
tests/cases/ipv6.py | 6 +-
tests/cases/parent_ports.py | 2 +-
tests/cases/pcre.py | 29 +-
tests/cases/portmap.py | 4 +-
tests/cases/proxy_http.py | 6 +-
tests/cases/socks.py | 4 +-
tests/cases/ssl.py | 8 +-
tests/cases/tlspr.py | 2 +-
tests/cases/transparent.py | 4 +-
tests/harness.py | 36 ++
27 files changed, 2325 insertions(+), 148 deletions(-)
create mode 100644 tests/cases/httpsrv_files.py
create mode 100644 tests/cases/httpsrv_keepalive.py
diff --git a/doc/html/howtoe.html b/doc/html/howtoe.html
index 1eea04d..0ec3ac7 100644
--- a/doc/html/howtoe.html
+++ b/doc/html/howtoe.html
@@ -34,6 +34,7 @@
How to set up an FTP proxy
How to set up an SNI proxy (tlspr)
How to set up a DNS proxy (dnspr)
+ How to serve pages with the built-in HTTP server (httpsrv)
How to set up TLS/SSL (https proxy, mTLS)
How to create CA and certificates for SSL
How to use PCRE filtering (regular expressions)
@@ -727,6 +728,159 @@ nscache 65536
nscache6 65536
dnspr -p53 -F10.0.0.1
+
+ How to serve pages with the built-in HTTP server (httpsrv)
+
+httpsrv answers requests itself instead of forwarding them. What it does with a
+request is decided by http rules written before the service, the way
+access rules are: the first rule whose host and URL both match handles the
+request. It is useful for a status page, a small static site, a block page for
+requests an ACL rejects, or a health check an upstream balancer can poll.
+
+http OPERATION HOST URL [PARAMETERS]
+
+
+HOST is matched against the Host header, URL against the path with the query
+string removed. A minimal static site:
+
+auth iponly
+allow *
+
+http file * / /usr/local/web/index.html
+http file * /*.html "/usr/local/web/$1.html"
+http file * /css/*.css "/usr/local/web/css/$1.css"
+http cache * /img/** "/usr/local/web/img/$1" * 3600
+httpsrv -p80 -i127.0.0.1
+
+
+Patterns. * stands for any run of characters within one
+element of the path and does not cross a /, so a rule cannot reach
+into a directory it did not name. ** crosses them. Each star, and
+each group of a regular expression, is remembered in order: $1
+upwards stand for them in the path or location the rule builds, and
+$0 for the whole request path. A rewrite_host rule
+uses the stars of its own host pattern instead, since that is what it is
+rewriting. With a PCRE build a pattern may be
+written as a regular expression with a pcre: prefix, for the URL and
+for the host alike:
+
+http file * /d/*.txt "/usr/local/web/$1.txt"
+http cache * "pcre:^/(.*)/pic/(.*)\.(gif|jpeg)$" "/usr/local/web/picts/$1/$2.$3"
+http file status.example.com /** "/usr/local/web/status/$1"
+http file "pcre:^(www|web)\.example\.com$" /** "/usr/local/web/$1"
+
+
+Outside quotes a dollar begins the name of a file to include, so an argument
+holding one - a path built with $1, a regular expression anchored
+with $ - is written in quotes, as above. $$ stands for
+a single dollar and is not read as an include either.
+
+
+Operations.
+
+# file - send the file, using sendfile/TransmitFile where the system can
+http file * /dl/** "/usr/local/web/dl/$1"
+
+# cache - read it into memory on the first request and answer from there
+http cache * /css/*.css "/usr/local/web/css/$1.css"
+
+# redir - answer with a redirect, 302 unless a status is given
+http redir * /old/** 301 "https://example.org/$1"
+
+# rewrite - change the path and hand the request to the rules after this one
+http rewrite * /alias/** "/w/$1"
+
+# rewrite_host - the same for the host, which decides which rules match next
+http rewrite_host *.old.example ** "$1.new.example"
+
+# reply - a status and nothing else
+http reply * /health** 200 "X-Health: ok"
+http reply * /down** 503 "Retry-After: 30"
+
+# echo, data - describe the request, or generate content of a given size
+http echo * /echo**
+http data * /gen** size=1048576
+
+
+What a rule adds to the answer. file and cache
+take, after the path, a content type, a max-age, headers to add and a status to
+answer with. Each may be left out or written as *:
+
+http OPERATION HOST URL PATH [TYPE [MAX-AGE [HEADERS [CODE]]]]
+
+# type worked out from the name, cached by clients for an hour
+http file * /img/*.png "/usr/local/web/img/$1.png" * 3600
+
+# a type of its own, and a header
+http file * /api/*.json "/usr/local/web/api/$1.json" application/json * "X-Api: 1"
+
+# a file serving as the body of an error page
+http file * /err/** /usr/local/web/404.html text/html * * 404
+
+
+HEADERS is one argument holding whole header lines, separated by a backslash and
+an n - the two characters, since a configuration line cannot carry a line
+ending. Quote it, headers contain spaces. A rule's headers and max-age go with
+whatever status that rule asked for, but not with a refusal the server itself
+decided on: a request for a file which is not there is answered 404 by the
+server, not by the rule.
+
+
+Types not known to the server are registered with
+http_content_type, and a type named by a rule is used whatever the
+name of the file says:
+
+http_content_type .webp image/webp
+http_content_type wasm application/wasm
+
+
+Files and dates. Only a full path is taken - a relative one would be read
+against whatever directory the service happens to be in - and a path holding
+. or .. as an element, a line ending or a star is
+refused. On Windows a path must name a drive or a share ("C:\web\$1"
+or "\\host\share\$1"). A request which decodes to a path leaving the
+tree is refused before any of this. Every answer carries Last-Modified, and a
+request carrying If-Modified-Since is answered 304 with no body when the file
+has not changed.
+
+
+Caching. cache reads the file once and answers from memory
+afterwards; a file which has changed on disk is read again, and one larger than
+a megabyte is sent as file would. With a MAX-AGE the file is not
+looked at again for that long - the rule has already told clients the file may
+be treated as unchanged for that time - so a request costs nothing but the copy
+out. Without one every request stats the file and a change is picked up at once.
+
+
+A block page. A service which rejects a request with a redirect can send
+the client to an httpsrv running beside it:
+
+auth iponly
+deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
+allow *
+proxy -p3128 -i192.168.1.1
+
+flush
+auth iponly
+allow *
+http file * /** /usr/local/web/blocked.html text/html * * 403
+httpsrv -p8080 -i127.0.0.1
+
+
+Connections. A client asking in HTTP/1.1 gets a 1.1 answer and the
+connection is kept for the next request, unless it sent
+Connection: close; a 1.0 client has to ask for keep-alive. The
+connection is only kept when the length of the answer is known exactly, which is
+true of every operation except the administration pages, so those are always the
+last thing on a connection. A request body the server cannot read to its end -
+one sent chunked, or one larger than a megabyte - ends the connection too.
+
+
+Administration. The admin service is httpsrv with the pages
+of the administration interface already declared, see
+Administering and information analysis. Rules may be added
+before it in the same way, and are taken first.
+
How to set up TLS/SSL (https proxy, mTLS)
@@ -1223,6 +1377,34 @@ pcre_extend deny * 192.168.0.1/16
Note: Regular expressions don't require authentication and cannot replace
authentication and/or allow/deny ACLs.
+
+
+Regular expressions in host names: a host name in the target list of an
+access rule may be written as a regular expression instead of a wildmask, by
+giving it a pcre: prefix (regex: means the same). This
+needs a build with PCRE support, the same as the pcre commands
+above.
+
+# Wildmask: a name may only be matched at its beginning and its end
+deny * * *ads.example.com
+
+# Regular expression: anything PCRE can express
+deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
+allow * * "pcre:^(www|api)\.example\.com$"
+
+
+The name is lowercased and trailing dots are removed before the pattern is
+matched, so write patterns in lower case. Quote a pattern which ends in
+$, or write it as $$: outside quotes a lone dollar
+begins the name of a file to include. Only the target list takes names - the
+source list is addresses - and the name is only checked when the request
+carries one. A wildmask is cheaper and is enough for most rules; a regular
+expression is matched per request.
+
+
+The same prefix and the same patterns are used by the http command
+of the built-in HTTP server, for the host a rule answers for and for the URL it
+matches.
How to limit service access
diff --git a/doc/html/howtor.html b/doc/html/howtor.html
index b1af3da..21aab93 100644
--- a/doc/html/howtor.html
+++ b/doc/html/howtor.html
@@ -34,6 +34,7 @@
Как настроить FTP прокси?
Как настроить SNI proxy (tlspr)
Как настроить DNS proxy (dnspr)
+ Как отдавать страницы встроенным HTTP-сервером (httpsrv)
Как настроить TLS/SSL (https прокси, mTLS)
Как создать CA и сертификаты для SSL
Как использовать PCRE-фильтрацию (регулярные выражения)
@@ -738,6 +739,160 @@ dnspr -p53 -F10.0.0.1
+ Как отдавать страницы встроенным HTTP-сервером (httpsrv)
+
+httpsrv отвечает на запросы сам, а не пересылает их. Что делать с запросом,
+определяют правила http, записанные перед сервисом, как и правила
+доступа: запрос обрабатывает первое правило, у которого совпали и хост, и URL.
+Это удобно для страницы состояния, небольшого статического сайта, страницы
+блокировки для запросов, отклонённых ACL, или health check, который опрашивает
+вышестоящий балансировщик.
+
+http ОПЕРАЦИЯ ХОСТ URL [ПАРАМЕТРЫ]
+
+
+ХОСТ сопоставляется с заголовком Host, URL - с путём без строки запроса.
+Минимальный статический сайт:
+
+auth iponly
+allow *
+
+http file * / /usr/local/web/index.html
+http file * /*.html "/usr/local/web/$1.html"
+http file * /css/*.css "/usr/local/web/css/$1.css"
+http cache * /img/** "/usr/local/web/img/$1" * 3600
+httpsrv -p80 -i127.0.0.1
+
+
+Шаблоны. * означает любую последовательность символов внутри
+одного элемента пути и не пересекает /, поэтому правило не может
+попасть в каталог, который не назван в нём. ** пересекает.
+Каждая звёздочка и каждая группа регулярного выражения запоминаются по порядку:
+$1 и далее подставляют их в путь или адрес, который строит правило,
+$0 - весь путь запроса. Правило rewrite_host
+использует звёздочки собственного шаблона хоста, поскольку переписывает именно
+его. В сборке с PCRE шаблон можно записать
+регулярным выражением с префиксом pcre: - и для URL, и для хоста:
+
+http file * /d/*.txt "/usr/local/web/$1.txt"
+http cache * "pcre:^/(.*)/pic/(.*)\.(gif|jpeg)$" "/usr/local/web/picts/$1/$2.$3"
+http file status.example.com /** "/usr/local/web/status/$1"
+http file "pcre:^(www|web)\.example\.com$" /** "/usr/local/web/$1"
+
+
+Вне кавычек доллар начинает имя включаемого файла, поэтому аргумент, содержащий
+доллар - путь с $1, регулярное выражение с якорем $ -
+записывается в кавычках, как выше. $$ означает один доллар и тоже
+не читается как включение файла.
+
+
+Операции.
+
+# file - отдать файл, через sendfile/TransmitFile там, где система это умеет
+http file * /dl/** "/usr/local/web/dl/$1"
+
+# cache - прочитать в память при первом запросе и отвечать из неё
+http cache * /css/*.css "/usr/local/web/css/$1.css"
+
+# redir - ответить редиректом, 302, если код не задан
+http redir * /old/** 301 "https://example.org/$1"
+
+# rewrite - изменить путь и передать запрос следующим правилам
+http rewrite * /alias/** "/w/$1"
+
+# rewrite_host - то же для хоста, от которого зависит выбор следующих правил
+http rewrite_host *.old.example ** "$1.new.example"
+
+# reply - только код ответа, без тела
+http reply * /health** 200 "X-Health: ok"
+http reply * /down** 503 "Retry-After: 30"
+
+# echo, data - описание запроса или генерация содержимого заданного размера
+http echo * /echo**
+http data * /gen** size=1048576
+
+
+Что правило добавляет в ответ. file и cache
+принимают после пути тип содержимого, max-age, добавляемые заголовки и код
+ответа. Любой из них можно опустить или записать как *:
+
+http ОПЕРАЦИЯ ХОСТ URL ПУТЬ [ТИП [MAX-AGE [ЗАГОЛОВКИ [КОД]]]]
+
+# тип определяется по имени файла, клиенты кэшируют час
+http file * /img/*.png "/usr/local/web/img/$1.png" * 3600
+
+# собственный тип и заголовок
+http file * /api/*.json "/usr/local/web/api/$1.json" application/json * "X-Api: 1"
+
+# файл как тело страницы ошибки
+http file * /err/** /usr/local/web/404.html text/html * * 404
+
+
+ЗАГОЛОВКИ - один аргумент, содержащий целые строки заголовков, разделённые
+обратной косой чертой и n - двумя символами, поскольку строка конфигурации не
+может содержать конец строки. Аргумент нужно брать в кавычки, в заголовках есть
+пробелы. Заголовки и max-age правила отправляются с тем кодом, который правило
+задало, но не с отказом, который решил вернуть сам сервер: на запрос
+отсутствующего файла 404 отвечает сервер, а не правило.
+
+
+Неизвестные серверу типы регистрируются командой
+http_content_type, а тип, названный в правиле, используется
+независимо от имени файла:
+
+http_content_type .webp image/webp
+http_content_type wasm application/wasm
+
+
+Файлы и даты. Принимается только полный путь - относительный отсчитывался
+бы от того каталога, в котором оказался сервис, - а путь с элементом
+. или .., концом строки или звёздочкой отвергается. В
+Windows путь должен указывать диск или сетевой ресурс ("C:\web\$1"
+или "\\host\share\$1"). Запрос, который декодируется в путь за
+пределами дерева, отвергается раньше всего этого. Каждый ответ содержит
+Last-Modified, а запрос с If-Modified-Since получает 304 без тела, если файл не
+изменился.
+
+
+Кэширование. cache читает файл один раз и дальше отвечает из
+памяти; изменившийся на диске файл читается заново, а файл больше мегабайта
+отдаётся так же, как это сделал бы file. При заданном MAX-AGE файл
+не проверяется в течение этого времени - правило уже сообщило клиентам, что
+столько файл можно считать неизменным, - и запрос стоит только копирования
+наружу. Без MAX-AGE каждый запрос делает stat, и изменение подхватывается сразу.
+
+
+Страница блокировки. Сервис, отклоняющий запрос редиректом, может
+отправить клиента на httpsrv, работающий рядом:
+
+auth iponly
+deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
+allow *
+proxy -p3128 -i192.168.1.1
+
+flush
+auth iponly
+allow *
+http file * /** /usr/local/web/blocked.html text/html * * 403
+httpsrv -p8080 -i127.0.0.1
+
+
+Соединения. Клиент, обратившийся по HTTP/1.1, получает ответ 1.1, и
+соединение сохраняется для следующего запроса, если он не прислал
+Connection: close; клиенту 1.0 нужно запросить keep-alive явно.
+Соединение сохраняется только тогда, когда длина ответа известна точно - это
+верно для всех операций, кроме страниц администрирования, поэтому они всегда
+последнее, что отдаётся в соединении. Тело запроса, которое сервер не может
+дочитать до конца - присланное chunked или размером больше мегабайта, - тоже
+завершает соединение.
+
+
+Администрирование. Сервис admin - это httpsrv с уже
+объявленными страницами интерфейса администрирования, см.
+Администрирование и анализ информации. Правила можно
+добавлять перед ним так же, и они проверяются первыми.
+
+
Как настроить TLS/SSL (https прокси, mTLS)
Начиная с версии 0.9.7 поддержка TLS/SSL встроена в 3proxy при компиляции с OpenSSL
@@ -1221,6 +1376,34 @@ pcre_extend deny * 192.168.0.1/16
Примечание: Регулярные выражения не требуют авторизации и не могут заменить
авторизацию и/или ACL allow/deny.
+
+
+Регулярные выражения в именах хостов: имя хоста в списке назначения
+правила доступа может быть записано регулярным выражением вместо маски, для
+этого используется префикс pcre: (regex: означает то
+же самое). Требуется сборка с поддержкой PCRE, как и для команд
+pcre выше.
+
+# Маска: имя сопоставляется только с начала и с конца
+deny * * *ads.example.com
+
+# Регулярное выражение: всё, что выразимо средствами PCRE
+deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
+allow * * "pcre:^(www|api)\.example\.com$"
+
+
+Перед сопоставлением имя приводится к нижнему регистру, завершающие точки
+удаляются, поэтому шаблоны пишутся в нижнем регистре. Шаблон, оканчивающийся на
+$, нужно взять в кавычки или записать как $$: вне
+кавычек одиночный доллар начинает имя включаемого файла. Имена допустимы только
+в списке назначения (список источника - адреса), и имя проверяется лишь тогда,
+когда оно присутствует в запросе. Маска обходится дешевле и достаточна для
+большинства правил, регулярное выражение сопоставляется на каждый запрос.
+
+
+Тот же префикс и те же шаблоны использует команда http встроенного
+HTTP-сервера - для хоста, на который отвечает правило, и для URL, который оно
+сопоставляет.
Как ограничить доступ к службе
diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5
index 746f8ae..98f3e01 100644
--- a/man/3proxy.cfg.5
+++ b/man/3proxy.cfg.5
@@ -39,7 +39,9 @@ For included file (end of line characters) is treated as space character
(arguments delimiter instead of end of command delimiter).
Thus, include files are only useful to store long single-line commands
(like userlist, network lists, etc).
-To use dollar sign somewhere in argument it must be quoted.
+To use dollar sign somewhere in argument it must be quoted or doubled: inside
+quotes a dollar is ordinary text, and \fB$$\fR stands for a single dollar and is
+not read as an include.
Recursion is not allowed.
.br
@@ -743,6 +745,17 @@ Since 0.6, the targetlist may also contain host names,
instead of addresses. It\'s possible to use a wildmask in
the beginning and at the end of the hostname, e.g. *badsite.com or *badcontent*.
The hostname is only checked if a hostname is present in the request.
+A name written with a \fBpcre:\fR prefix (\fBregex:\fR is the same thing) is a
+regular expression instead of a wildmask, in a build with PCRE support:
+.br
+ deny * * "pcre:^(ads|track)[0-9]*\\.example\\.(com|net)$"
+.br
+ The name is lowercased and any trailing dots are removed before it is matched,
+so patterns are written in lower case. A pattern ending in \fB$\fR has to be
+quoted or written \fB$$\fR, since a lone dollar outside quotes begins the name
+of a file to include. The same patterns, and the same prefix, are used by the
+\fBhttp\fR command, see BUILT IN HTTP SERVER. Regular expressions are matched
+per request and cost more than a wildmask, which is enough for most rules.
Targetportlist may contain ports (X) or port ranges lists (X-Y). For any field *
sign means ANY. If access list is empty it\'s assumed to be
.br
@@ -1511,6 +1524,15 @@ PCRE_NOTEMPTY, PCRE_UTF8, PCRE_NO_AUTO_CAPTURE, PCRE_NO_UTF8_CHECK, PCRE_AUTO_CA
PCRE_PARTIAL, PCRE_DFA_SHORTEST, PCRE_DFA_RESTART, PCRE_FIRSTLINE, PCRE_DUPNAMES,
PCRE_NEWLINE_CR, PCRE_NEWLINE_LF, PCRE_NEWLINE_CRLF, PCRE_NEWLINE_ANY, PCRE_NEWLINE_ANYCRLF,
PCRE_BSR_ANYCRLF, PCRE_BSR_UNICODE.
+.br
+ These options apply to every pattern the configuration compiles, the host
+patterns of access rules and \fBhttp\fR rules included, so set them before the
+rules which are to use them.
+.br
+ Regular expressions are not only for these commands: a host name in the target
+list of an access rule, and the host and URL of an \fBhttp\fR rule, take one
+when it is written with a \fBpcre:\fR prefix. See \fBallow\fR and BUILT IN
+HTTP SERVER.
.SS PCRE Parameters
TYPE - type of filtered data (comma-delimited list):
@@ -1551,6 +1573,166 @@ matched if the ACL matches the connection data.
Warning: Regular expressions don't require authentication and cannot replace
authentication and/or allow/deny ACLs.
+.SH BUILT IN HTTP SERVER
+The \fBhttpsrv\fR service answers requests itself instead of forwarding them.
+What it does with a request is decided by \fBhttp\fR rules, which are taken in
+the order they are written: the first whose host and URL both match handles the
+request. Rules belong to the service that follows them, the way access rules do,
+and \fBadmin\fR is \fBhttpsrv\fR with a set of rules already in place.
+
+.BR http
+\fIOPERATION HOST URL [PARAMETERS]\fR
+.br
+ Handle a request for \fIURL\fR on \fIHOST\fR with \fIOPERATION\fR. HOST is
+matched against the Host header, URL against the path, with the query string
+removed.
+
+.SS Operations
+.br
+ \fBfile\fR \fIPATH [TYPE [MAX-AGE [HEADERS [CODE]]]]\fR - send the file at PATH.
+The file is handed to the socket by the system where it can do that (sendfile,
+TransmitFile) and read here where it cannot, as when the connection carries TLS.
+The arguments after PATH are described below, and each of them may be written as
+\fB*\fR to leave it out.
+.br
+ \fBcache\fR \fIPATH [TYPE [MAX-AGE [HEADERS [CODE]]]]\fR - the same, but the
+file is read into memory on the first request and answered from there afterwards.
+A file that has changed on disk is read again, and one larger than a megabyte is
+sent as \fBfile\fR would. With a MAX-AGE the file is not looked at again for
+that long: the rule has already told clients the file may be treated as
+unchanged for that time, so the server treats its own copy the same way and a
+request costs nothing but the copy out. Without one every request stats the
+file, so a change is picked up at once.
+.br
+ \fBreply\fR \fI[CODE [HEADERS]]\fR - answer with a status and nothing else.
+CODE is the status to send, 200 without one. A status which carries no body of
+its own (1xx, 204, 304) is sent without a length; anything else is sent with a
+length of zero.
+.br
+ \fBredir\fR \fI[CODE] LOCATION\fR - answer with a redirect. CODE is 301 or 302,
+or any status from 300 to 399; without one, 302 is used.
+.br
+ \fBrewrite\fR \fIPATH\fR - change the path of the request and hand it to the
+rules that follow this one.
+.br
+ \fBrewrite_host\fR \fIHOST\fR - the same for the host, which decides which of
+the rules after it match. \fB$1\fR upwards stand for what the stars, or the
+groups, of this rule\'s host pattern matched, the way they stand for those of
+the URL in a \fBrewrite\fR. What is built has to be a host name; the name the
+client sent is what access rules matched and what the log records.
+.br
+ \fBecho\fR - answer with a description of the request: the method, path, query,
+host, and the address and port it came from. For testing.
+.br
+ \fBdata\fR \fI[size=N] [block=N] [status=N] [chunked=1] [delay=N]\fR - answer
+with generated content of the size asked for. For testing.
+.br
+ \fBadmin\fR, \fBadmin_counters\fR, \fBadmin_reload\fR, \fBadmin_services\fR -
+the pages of the administration interface.
+
+.SS What a rule adds to the answer
+\fBTYPE\fR is the content type to answer with. Without it, or with \fB*\fR, the
+type is worked out from the name of the file, see \fBhttp_content_type\fR.
+.br
+ \fBMAX-AGE\fR is a number of seconds, and is sent as Cache-Control: max-age.
+Without it, or with \fB*\fR, nothing is said about caching.
+.br
+ \fBHEADERS\fR is one argument holding whole header lines, separated by a
+backslash and an n \- the two characters, since a configuration line cannot
+carry a line ending. Each becomes a real line ending in the answer. Quote the
+argument if any header holds a space, which they usually do.
+.br
+ \fBCODE\fR is the status to answer with instead of 200, which is how a file
+serves as the body of an error page.
+.br
+ A rule's headers and MAX-AGE go with whatever status that rule asked for. They
+are not sent with a refusal the server itself decided on: a request for a file
+which is not there is answered 404 by the server, not by the rule.
+.br
+ \fBfile\fR and \fBcache\fR send Last-Modified, and answer a request carrying
+If-Modified-Since with 304 and no body when the file has not changed since the
+time it names. All three date formats HTTP allows are read; one which cannot be
+read is treated as no date at all. A rule answering with a CODE of its own is
+answering something other than the file, so it is never turned into a 304.
+.br
+ http file * /err/** "/usr/local/web/404.html" text/html * "X-Served: static" 404
+.br
+ http reply * /health** 200 "X-Health: ok"
+.br
+ http reply * /down** 503 "Retry-After: 30"
+
+.BR http_content_type
+\fIEXTENSION TYPE\fR
+.br
+ Answer for a file with that extension with that content type, in addition to
+the types already known. The extension may be written with or without its dot.
+A type named by a rule is used whatever this says, and a name the server knows
+nothing about is answered as application/octet-stream.
+.br
+ http_content_type .webp image/webp
+
+.SS Patterns
+A URL is matched with stars, or with a regular expression when it carries a
+\fBpcre:\fR prefix (\fBregex:\fR is taken as well). A host is matched the way an
+access list matches one, and takes the same prefix.
+.br
+ \fB*\fR stands for any run of characters within one element of the path: it
+does not cross a \fB/\fR, so a rule cannot reach into a directory it did not
+name.
+.br
+ \fB**\fR crosses them, and is what a rule which should match everything below a
+point, or everything at all, is written with.
+.br
+ Each star, and each group of a regular expression, is remembered in the order
+it appears. \fB$1\fR upwards stand for them in the path or location a rule
+builds, and \fB$0\fR for the whole request path.
+.br
+ Outside quotes a dollar begins the name of a file to include, so an argument
+holding one \- a path or location built with \fB$1\fR, a regular expression
+anchored with \fB$\fR \- is written in quotes. \fB$$\fR stands for a single
+dollar and is not read as an include either, which is how a dollar reaches a
+rule as text.
+
+.SS Connections
+An answer is sent as HTTP/1.1 to a client which asked in HTTP/1.1, and the
+connection is kept for the next request unless the client sent
+\fBConnection: close\fR. A 1.0 client gets a 1.0 answer, and the connection is
+kept only when it asked with \fBConnection: keep-alive\fR.
+.br
+ The connection is kept only when what was sent is framed exactly: every
+operation but the administration pages states a length, or sends a chunked body
+a 1.1 client can read, so the pages of \fBadmin\fR are always the last thing on
+a connection. A request body which cannot be read to its end ends the connection
+as well: one sent with \fBTransfer-Encoding\fR, which this server does not read,
+and one longer than a megabyte, which it will not.
+
+.SS Paths a rule builds
+The path a rule builds is used as it is, so it is refused rather than corrected
+when it is not a plain full path. A relative path is refused: it would be read
+against whatever directory the service happens to be in. So is one holding
+\fB.\fR or \fB..\fR as an element, a carriage return, a newline or a star. On
+Windows a path must name a drive or a share, and is converted to the extended
+\\\\?\\ form and opened through the wide interface, so a long path works.
+.br
+ A request is checked before any of this: a path which decodes to one leaving
+the tree is refused outright.
+
+.SS Examples
+.br
+ http file example.com /my/webpath/*.html "/usr/local/web/$1.html"
+.br
+ http cache example.com "pcre:^/(.*)/pic/(.*)\.(gif|jpeg)$" "/usr/local/web/picts/$1/$2.$3"
+.br
+ http redir * /old/** 301 "https://example.org/$1"
+.br
+ http rewrite * /alias/** "/w/$1"
+.br
+ http rewrite_host *.old.example ** "$1.new.example"
+.br
+ http file * /static/** "/usr/local/web/static/$1"
+.br
+ httpsrv -p8080
+
.SH BUGS
Report all bugs to
.BR 3proxy@3proxy.org
diff --git a/src/3proxy.c b/src/3proxy.c
index 007ed8a..54a982d 100644
--- a/src/3proxy.c
+++ b/src/3proxy.c
@@ -16,6 +16,9 @@ void pcre_install(void);
#ifdef WITH_TRANSPARENT
void transparent_install(void);
#endif
+#ifdef WITH_HTTPSRV
+void httpsrv_init(void);
+#endif
#ifndef _WIN32
#include
#ifndef NOPLUGINS
@@ -535,6 +538,9 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int
#ifdef WITH_TRANSPARENT
transparent_install();
#endif
+#ifdef WITH_HTTPSRV
+ httpsrv_init();
+#endif
freeconf(&conf);
initcommands();
diff --git a/src/common.c b/src/common.c
index 2ad41d2..6e28aef 100644
--- a/src/common.c
+++ b/src/common.c
@@ -817,11 +817,43 @@ int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa,
whatever replaces the star with a regular expression later - behaves the same
way and gains the same syntax at the same time.
*/
+/* A pattern written as a regular expression carries a prefix. Both spellings
+ are taken so a configuration reads the way its author thinks of it. */
+static unsigned char * regexprefix(unsigned char *arg)
+{
+ if(!strncmp((char *)arg, "pcre:", 5)) return arg + 5;
+ if(!strncmp((char *)arg, "regex:", 6)) return arg + 6;
+ return NULL;
+}
+
+/* Shared by every pattern the configuration can carry. Returns 0 on success. */
+static int compileregex(struct hostname *h, unsigned char *pattern)
+{
+#ifdef WITH_PCRE
+ char err[256];
+
+ h->re = pcre_pattern_compile(pattern, err, sizeof(err));
+ if(!h->re){
+ fprintf(stderr, "Bad regular expression '%s': %s\n", pattern, err);
+ return 1;
+ }
+ h->matchtype = MATCHREGEX;
+ h->name = (unsigned char *)strdup((char *)pattern);
+ return h->name? 0 : 1;
+#else
+ fprintf(stderr, "Regular expression '%s' needs a build with PCRE\n", pattern);
+ return 1;
+#endif
+}
+
int parsepattern(struct hostname *h, unsigned char *arg)
{
int arglen;
unsigned char *pattern;
+ h->re = NULL;
+ if((pattern = regexprefix(arg))) return compileregex(h, pattern);
+
arglen = (int)strlen((char *)arg);
h->matchtype = 3;
pattern = arg;
@@ -851,6 +883,14 @@ int patternmatchpos(const struct hostname *h, const unsigned char *str, int *sta
char *found;
if(!h->name || !str) return 0;
+ if(h->matchtype == MATCHREGEX || h->matchtype == MATCHGLOB){
+ struct capture caps[MAXCAPTURES];
+
+ if(!patternmatchcaps(h, str, caps, NULL)) return 0;
+ if(start) *start = caps[1].start;
+ if(len) *len = caps[1].len;
+ return 1;
+ }
lname = (int)strlen((char *)h->name);
lstr = (int)strlen((char *)str);
@@ -903,9 +943,111 @@ int patternmatchpos(const struct hostname *h, const unsigned char *str, int *sta
int patternmatch(const struct hostname *h, const unsigned char *str)
{
+ return patternmatchcaps(h, str, NULL, NULL);
+}
+
+/* Match a glob, recording what each star stood for.
+
+ A single star stands for any run of characters within one element of the
+ path, so it stops at a slash; a double star crosses them. Stars are
+ numbered in the order they appear, which is how a template refers to them.
+ */
+static int globmatch(const unsigned char *pat, const unsigned char *str,
+ const unsigned char *subject, struct capture *caps, int maxcaps, int star)
+{
+ while(*pat){
+ if(*pat == '*'){
+ int crosses = (pat[1] == '*');
+ const unsigned char *rest = pat + (crosses? 2 : 1);
+ int len;
+
+ for(len = 0; ; len++){
+ if(star < maxcaps && caps){
+ caps[star].start = (int)(str - subject);
+ caps[star].len = len;
+ }
+ if(globmatch(rest, str + len, subject, caps, maxcaps, star + 1)) return 1;
+ if(!str[len]) return 0;
+ if(!crosses && str[len] == '/') return 0;
+ }
+ }
+ if(*pat != *str) return 0;
+ pat++;
+ str++;
+ }
+ return *str == 0;
+}
+
+/* Match a pattern of any kind and report what its stars or groups stood for.
+ caps may be NULL when only the yes or no answer is wanted. */
+int patternmatchcaps(const struct hostname *h, const unsigned char *str,
+ struct capture *caps, int *ncaps)
+{
+ int n = 0;
+
+ if(ncaps) *ncaps = 0;
+ if(!h || !str) return 0;
+
+ if(h->matchtype == MATCHREGEX){
+#ifdef WITH_PCRE
+ struct capture local[MAXCAPTURES];
+
+ n = pcre_pattern_match(h->re, str, caps? caps : local, MAXCAPTURES);
+ if(ncaps) *ncaps = n;
+ return n > 0;
+#else
+ return 0;
+#endif
+ }
+
+ if(h->matchtype == MATCHGLOB){
+ struct capture local[MAXCAPTURES];
+ struct capture *use = caps? caps : local;
+ int i;
+
+ for(i = 0; i < MAXCAPTURES; i++){
+ use[i].start = 0;
+ use[i].len = 0;
+ }
+ use[0].start = 0;
+ use[0].len = (int)strlen((char *)str);
+ if(!h->name) return 0;
+ if(!globmatch(h->name, str, str, use, MAXCAPTURES, 1)) return 0;
+ if(ncaps){
+ for(n = MAXCAPTURES - 1; n > 0 && !use[n].len && !use[n].start; n--);
+ *ncaps = n + 1;
+ }
+ return 1;
+ }
+
+ /* the star at one end or both, as an access rule has always written it */
+ if(caps){
+ int start = 0, len = 0;
+
+ if(!patternmatchpos(h, str, &start, &len)) return 0;
+ caps[0].start = 0;
+ caps[0].len = (int)strlen((char *)str);
+ caps[1].start = start;
+ caps[1].len = len;
+ if(ncaps) *ncaps = 2;
+ return 1;
+ }
return patternmatchpos(h, str, NULL, NULL);
}
+/* A URL in an http rule: stars anywhere, or a regular expression. */
+int parsepathpattern(struct hostname *h, unsigned char *arg)
+{
+ unsigned char *pattern;
+
+ h->re = NULL;
+ if((pattern = regexprefix(arg))) return compileregex(h, pattern);
+
+ h->matchtype = MATCHGLOB;
+ h->name = (unsigned char *)strdup((char *)arg);
+ return h->name? 0 : 1;
+}
+
int scanaddr(const unsigned char *s, uint32_t * ip, uint32_t * mask) {
unsigned d1, d2, d3, d4, m;
int res;
diff --git a/src/conf.c b/src/conf.c
index de5295b..1771159 100644
--- a/src/conf.c
+++ b/src/conf.c
@@ -9,7 +9,7 @@
#include "proxy.h"
#ifdef WITH_HTTPSRV
-static int addhttprule(char *host, char *url, char *op, char *params);
+static int addhttprule(char *op, char *host, char *url, char *params);
#endif
#include "mdhash.h"
#ifdef WITH_SSL
@@ -264,10 +264,10 @@ static int h_proxy(int argc, unsigned char ** argv){
else if(!strcmp((char *)argv[0], "admin")) {
/* The same service as httpsrv, with the administration pages
declared for it. */
- if(addhttprule("*", "/C*", "admin_counters", NULL) ||
- addhttprule("*", "/R", "admin_reload", NULL) ||
- addhttprule("*", "/S*", "admin_services", NULL) ||
- addhttprule("*", "*", "admin", NULL)){
+ if(addhttprule("admin_counters", "*", "/C*", NULL) ||
+ addhttprule("admin_reload", "*", "/R", NULL) ||
+ addhttprule("admin_services", "*", "/S*", NULL) ||
+ addhttprule("admin", "*", "**", NULL)){
fprintf(stderr, "Failed to declare the admin pages, line %d\n", linenum);
return 1;
}
@@ -802,8 +802,57 @@ struct redirdesc redirs[] = {
};
#ifdef WITH_HTTPSRV
+/* Headers a rule adds are written as one argument, the lines separated by a
+ backslash and an n, because a configuration line cannot hold a line ending.
+ Those two characters become a real one here. A line ending which reached the
+ argument as itself is dropped: what goes on the wire is decided here and not
+ by whatever produced the string. */
+static unsigned char * parsehdrs(const unsigned char *arg)
+{
+ unsigned char *out, *o;
+ const unsigned char *p;
+ size_t len = strlen((char *)arg);
+
+ out = malloc(len * 2 + 3);
+ if(!out) return NULL;
+ for(p = arg, o = out; *p; p++){
+ if(*p == '\\' && p[1] == 'n'){
+ *o++ = '\r';
+ *o++ = '\n';
+ p++;
+ continue;
+ }
+ if(*p == '\r' || *p == '\n') continue;
+ *o++ = *p;
+ }
+ if(o == out || o[-1] != '\n'){
+ *o++ = '\r';
+ *o++ = '\n';
+ }
+ *o = 0;
+ return out;
+}
+
+/* An optional argument which a star, or nothing at all, leaves at its
+ default. */
+static int optnum(int argc, unsigned char **argv, int at, int def)
+{
+ if(argc <= at || !strcmp((char *)argv[at], "*")) return def;
+ return atoi((char *)argv[at]);
+}
+
+static void freehttprule(struct httprule *rule)
+{
+ if(rule->host.name) free(rule->host.name);
+ if(rule->url.name) free(rule->url.name);
+ if(rule->params) free(rule->params);
+ if(rule->ctype) free(rule->ctype);
+ if(rule->hdrs) free(rule->hdrs);
+ free(rule);
+}
+
/* Installs one rule from code, for the pages a service predefines. */
-static int addhttprule(char *host, char *url, char *op, char *params)
+static int addhttprule(char *op, char *host, char *url, char *params)
{
struct httprule *rule, *tail;
unsigned char hostbuf[64], urlbuf[128];
@@ -811,6 +860,7 @@ static int addhttprule(char *host, char *url, char *op, char *params)
rule = malloc(sizeof(struct httprule));
if(!rule) return 1;
memset(rule, 0, sizeof(struct httprule));
+ rule->maxage = -1;
rule->op = httpopbyname((unsigned char *)op);
if(rule->op < 0){
@@ -820,7 +870,7 @@ static int addhttprule(char *host, char *url, char *op, char *params)
strcpy((char *)hostbuf, host);
strcpy((char *)urlbuf, url);
- if(parsepattern(&rule->host, hostbuf) || parsepattern(&rule->url, urlbuf)){
+ if(parsepattern(&rule->host, hostbuf) || parsepathpattern(&rule->url, urlbuf)){
free(rule->host.name);
free(rule);
return 1;
@@ -973,9 +1023,10 @@ static int h_http(int argc, unsigned char **argv){
struct httprule *rule, *tail;
int op;
- op = httpopbyname(argv[3]);
+ /* http OPERATION HOST URL [PARAMETERS] */
+ op = httpopbyname(argv[1]);
if(op < 0){
- fprintf(stderr, "Unknown http operation: %s line %d\n", argv[3], linenum);
+ fprintf(stderr, "Unknown http operation: %s line %d\n", argv[1], linenum);
return(1);
}
@@ -983,23 +1034,75 @@ static int h_http(int argc, unsigned char **argv){
if(!rule) return(21);
memset(rule, 0, sizeof(struct httprule));
rule->op = op;
+ rule->maxage = -1;
- if(parsepattern(&rule->host, argv[1]) || parsepattern(&rule->url, argv[2])){
+ if(parsepattern(&rule->host, argv[2]) || parsepathpattern(&rule->url, argv[3])){
fprintf(stderr, "No memory for http rule, line %d\n", linenum);
free(rule->host.name);
free(rule);
return(21);
}
- if(argc > 4){
+ if(argc > 4 && (!strcmp((char *)argv[1], "file") || !strcmp((char *)argv[1], "cache"))){
+ /* PATH [TYPE [MAX-AGE [HEADERS [CODE]]]]. A star, or nothing,
+ leaves each of them out: the type is worked out from the name,
+ nothing is said about caching, no headers are added and the
+ answer is the usual 200. */
rule->params = (unsigned char *)strdup((char *)argv[4]);
- if(!rule->params){
- free(rule->host.name);
- free(rule->url.name);
- free(rule);
+ if(argc > 5 && strcmp((char *)argv[5], "*"))
+ rule->ctype = (unsigned char *)strdup((char *)argv[5]);
+ rule->maxage = optnum(argc, argv, 6, -1);
+ if(argc > 7 && strcmp((char *)argv[7], "*"))
+ rule->hdrs = parsehdrs(argv[7]);
+ rule->code = optnum(argc, argv, 8, 0);
+ if(!rule->params
+ || (argc > 5 && strcmp((char *)argv[5], "*") && !rule->ctype)
+ || (argc > 7 && strcmp((char *)argv[7], "*") && !rule->hdrs)){
+ freehttprule(rule);
return(21);
}
}
+ else if(!strcmp((char *)argv[1], "reply")){
+ /* CODE [HEADERS], and no body at all. */
+ rule->code = optnum(argc, argv, 4, 200);
+ if(argc > 5 && strcmp((char *)argv[5], "*")){
+ rule->hdrs = parsehdrs(argv[5]);
+ if(!rule->hdrs){
+ freehttprule(rule);
+ return(21);
+ }
+ }
+ }
+ else if(argc > 4){
+ /* What follows the URL belongs to the operation, and an operation
+ such as redir reads more than one word of it. */
+ int i, len = 0;
+
+ for(i = 4; i < argc; i++) len += (int)strlen((char *)argv[i]) + 1;
+ rule->params = malloc(len);
+ if(rule->params){
+ int at = 0;
+
+ for(i = 4; i < argc; i++)
+ at += sprintf((char *)rule->params + at, "%s%s",
+ i > 4? " " : "", argv[i]);
+ }
+ if(!rule->params){
+ freehttprule(rule);
+ return(21);
+ }
+ }
+
+ if(rule->code && (rule->code < 100 || rule->code > 599)){
+ fprintf(stderr, "Wrong http status: %d line %d\n", rule->code, linenum);
+ freehttprule(rule);
+ return(1);
+ }
+ if(rule->maxage < -1){
+ fprintf(stderr, "Wrong max-age, line %d\n", linenum);
+ freehttprule(rule);
+ return(1);
+ }
if(!conf.httprules) conf.httprules = rule;
else {
@@ -1495,7 +1598,7 @@ static int h_ace(int argc, unsigned char **argv){
tl->ace = acl;
if((acl->action == COUNTIN)||(acl->action == COUNTOUT)||(acl->action == COUNTALL)) {
- unsigned long lim;
+ uint64_t lim = 0;
tl->comment = ( char *)argv[1];
while(isdigit(*tl->comment))tl->comment++;
@@ -1503,9 +1606,9 @@ static int h_ace(int argc, unsigned char **argv){
tl->comment = strdup(tl->comment);
sscanf((char *)argv[1], "%u", &tl->number);
- sscanf((char *)argv[3], "%lu", &lim);
+ if(sscanf((char *)argv[3], "%"SCNu64"", &lim) != 1) lim = 0;
tl->type = getrotate(*argv[2]);
- tl->traflim64 = ((uint64_t)lim)*(1024*1024);
+ tl->traflim64 = lim*(1024*1024);
if(!tl->traflim64) {
free(tl);
freeacl(acl);
@@ -1801,6 +1904,9 @@ int h_server_verify(int argc, unsigned char **argv);
int h_no_server_verify(int argc, unsigned char **argv);
int h_client_mode(int argc, unsigned char **argv);
#endif
+#ifdef WITH_HTTPSRV
+int h_http_content_type(int argc, unsigned char **argv);
+#endif
#ifdef WITH_TRANSPARENT
int h_transparent(int argc, unsigned char **argv);
int h_notransparent(int argc, unsigned char **argv);
@@ -1826,7 +1932,8 @@ struct commands commandhandlers[]={
#endif
#ifdef WITH_HTTPSRV
{NULL, "httpsrv", h_proxy, 1, 0},
- {NULL, "http", h_http, 4, 5},
+ {NULL, "http", h_http, 4, 0},
+ {NULL, "http_content_type", h_http_content_type, 3, 3},
#endif
{NULL, "dnspr", h_proxy, 1, 0},
{NULL, "internal", h_internal, 2, 2},
@@ -1982,6 +2089,21 @@ int parsestr (unsigned char *str, unsigned char **argm, int nitems, unsigned cha
argm[argc] = 0;
return argc;
case '$':
+ /* Two dollars stand for one. That is how a literal dollar is
+ written where a file to include would otherwise be read, and
+ the second one is dropped here as a quote character is. */
+ if(str[1] == '$'){
+ str1 = str;
+ do {
+ *str1 = *(str1 + 1);
+ }while(*(str1++));
+ if(space){
+ argm[argc++] = str;
+ if(argc >= nitems) return argc;
+ space = 0;
+ }
+ break;
+ }
if(comment){
if(space){
argm[argc++] = str;
diff --git a/src/httpsrv.c b/src/httpsrv.c
index 0d3983a..e037d38 100644
--- a/src/httpsrv.c
+++ b/src/httpsrv.c
@@ -18,6 +18,22 @@
#include "proxy.h"
+#include
+#include
+/* Handing a file to the socket without copying it through this process. The
+ call differs on each platform that has one, and Windows has its own. */
+#if defined(__linux__)
+#define HTTPSRV_SENDFILE
+#include
+#elif defined(__APPLE__) || defined(__FreeBSD__)
+#define HTTPSRV_SENDFILE
+#include
+#include
+#elif defined(_WIN32)
+#include
+#include
+#endif
+
#ifdef WITH_HTTPSRV
#include
@@ -27,7 +43,22 @@
#define HTTPSRV_LINE 1024
#define HTTPSRV_BLOCK 8192
#define HTTPSRV_MAXHDR 64
+#define HTTPSRV_MAXCACHED 1048576 /* larger files are streamed instead */
+#ifdef _WIN32
+#define HTTPSRV_O_BINARY O_BINARY
+#else
+#define HTTPSRV_O_BINARY 0
+#endif
+#define HTTPSRV_MAXREWRITE 16
+/* An operation returns this to say the request was changed and the rules
+ after it should be tried again. */
+#define HTTPSRV_REWRITTEN 2
#define HTTPSRV_MAXBODY 1048576
+/* lengths a reply is written with: a count, or one of these */
+#define HTTPSRV_LEN_CHUNKED (-1)
+#define HTTPSRV_LEN_NONE (-2)
+/* one sendfile()/TransmitFile() call takes no more than this */
+#define HTTPSRV_SENDMAX 0x40000000
/* Returns the value of a query parameter, or def when it is missing or not a
@@ -92,20 +123,21 @@ static int copyfield(char *dst, size_t size, const char *src)
return 0;
}
-static long qparam(const char *query, const char *name, long def)
+static int64_t qparam(const char *query, const char *name, int64_t def)
{
const char *p;
size_t len;
- char *end;
- long val;
+ int64_t val;
+ int used;
if(!query || !*query) return def;
len = strlen(name);
for(p = query; *p; ){
if(!strncmp(p, name, len) && p[len] == '='){
- val = strtol(p + len + 1, &end, 10);
- if(end == p + len + 1) return def;
+ used = 0;
+ if(sscanf(p + len + 1, "%"SCNd64"%n", &val, &used) != 1 || !used)
+ return def;
return val;
}
p = strchr(p, '&');
@@ -137,31 +169,164 @@ static int httpsrv_printf(struct httpreq *r, const char *fmt, ...)
return httpsrv_send(r, buf, len);
}
-/* Writes the status line and headers. A negative length asks for chunked
- encoding, which is how a response of unknown or deliberately unstated size is
- produced. */
-static int httpsrv_head(struct httpreq *r, int status, const char *ctype, long len)
+/* Dates on the wire are in GMT and in English whatever the machine is set to,
+ so they are built and read here instead of through strftime and gmtime: one
+ follows the locale, the other answers from a buffer shared by every thread. */
+static const char httpwdays[7][4] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
+static const char httpmonths[12][4] = {
+ "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"
+};
+
+static int64_t civildays(int y, int m, int d)
{
- const char *text;
+ int64_t era;
+ unsigned yoe, doy, doe;
+ y -= m <= 2;
+ era = (y >= 0? y : y - 399) / 400;
+ yoe = (unsigned)(y - era * 400);
+ doy = (unsigned)((153 * (m + (m > 2? -3 : 9)) + 2) / 5 + d - 1);
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ return era * 146097 + (int64_t)doe - 719468;
+}
+
+static void civilfromdays(int64_t z, int *y, int *m, int *d)
+{
+ int64_t era;
+ unsigned doe, yoe, doy, mp;
+
+ z += 719468;
+ era = (z >= 0? z : z - 146096) / 146097;
+ doe = (unsigned)(z - era * 146097);
+ yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ mp = (5 * doy + 2) / 153;
+ *d = (int)(doy - (153 * mp + 2) / 5 + 1);
+ *m = (int)(mp + (mp < 10? 3 : -9));
+ *y = (int)((int64_t)yoe + era * 400) + (*m <= 2);
+}
+
+/* buf takes at least 32 characters */
+static void httpdate(time_t t, char *buf)
+{
+ int64_t days = (int64_t)t / 86400;
+ int secs = (int)((int64_t)t - days * 86400);
+ int y, m, d, wday;
+
+ if(secs < 0){
+ secs += 86400;
+ days--;
+ }
+ civilfromdays(days, &y, &m, &d);
+ wday = (int)(((days % 7) + 11) % 7); /* the epoch was a Thursday */
+ sprintf(buf, "%s, %02d %s %04d %02d:%02d:%02d GMT",
+ httpwdays[wday], d, httpmonths[m - 1], y,
+ secs / 3600, (secs / 60) % 60, secs % 60);
+}
+
+static int monthbyname(const char *name)
+{
+ int i;
+
+ for(i = 0; i < 12; i++) if(!strncasecmp(name, httpmonths[i], 3)) return i + 1;
+ return 0;
+}
+
+/* The three date formats a client is allowed to send. Returns 0 for anything
+ which cannot be read, which a caller takes as no date at all. */
+static time_t parsehttpdate(const char *s)
+{
+ char mon[16];
+ int d, y, hh, mm, ss, m;
+
+ while(*s == ' ') s++;
+ if(sscanf(s, "%*3s, %d %15s %d %d:%d:%d", &d, mon, &y, &hh, &mm, &ss) == 6
+ || sscanf(s, "%*[^,], %d-%15[^-]-%d %d:%d:%d", &d, mon, &y, &hh, &mm, &ss) == 6){
+ if(y < 100) y += (y < 70)? 2000 : 1900;
+ }
+ else if(sscanf(s, "%*3s %15s %d %d:%d:%d %d", mon, &d, &hh, &mm, &ss, &y) == 6){
+ /* asctime, as in "Sun Nov 6 08:49:37 1994" */
+ }
+ else return 0;
+
+ m = monthbyname(mon);
+ if(!m || d < 1 || d > 31 || hh < 0 || hh > 23 || mm < 0 || mm > 59
+ || ss < 0 || ss > 60 || y < 1970) return 0;
+ return (time_t)(civildays(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss);
+}
+
+/* A header such as Connection carries a list, so a name is looked for as one
+ of its items rather than as the whole value. */
+static int headertoken(const char *value, const char *name)
+{
+ size_t len = strlen(name);
+
+ for(; *value; value++){
+ if(strncasecmp(value, name, len)) continue;
+ if(value[len] && value[len] != ',' && value[len] != ' '
+ && value[len] != '\t') continue;
+ return 1;
+ }
+ return 0;
+}
+
+static const char * statustext(int status)
+{
switch(status){
- case 200: text = "OK"; break;
- case 204: text = "No Content"; break;
- case 400: text = "Bad Request"; break;
- case 404: text = "Not Found"; break;
- case 500: text = "Internal Server Error"; break;
- case 503: text = "Service Unavailable"; break;
- default: text = "Unknown"; break;
+ case 200: return "OK";
+ case 201: return "Created";
+ case 202: return "Accepted";
+ case 204: return "No Content";
+ case 301: return "Moved Permanently";
+ case 302: return "Found";
+ case 303: return "See Other";
+ case 304: return "Not Modified";
+ case 307: return "Temporary Redirect";
+ case 308: return "Permanent Redirect";
+ case 400: return "Bad Request";
+ case 401: return "Unauthorized";
+ case 403: return "Forbidden";
+ case 404: return "Not Found";
+ case 405: return "Method Not Allowed";
+ case 410: return "Gone";
+ case 500: return "Internal Server Error";
+ case 502: return "Bad Gateway";
+ case 503: return "Service Unavailable";
+ default: return "Unknown";
}
+}
- if(httpsrv_printf(r, "HTTP/1.0 %d %s\r\n", status, text)) return 1;
- if(httpsrv_printf(r, "Content-Type: %s\r\n", ctype)) return 1;
+/* Writes the status line and headers. HTTPSRV_LEN_CHUNKED asks for chunked
+ encoding, which is how a response of unknown or deliberately unstated size
+ is produced, and HTTPSRV_LEN_NONE leaves the length out altogether, for the
+ statuses which carry no body at all. A NULL type is left out the same way.
+ extra, when it is given, is a header line of its own and goes before what
+ the rule adds. What the rule adds goes with whatever status the rule asked
+ for, a refusal included; a refusal the server itself decided on drops them
+ first, since they describe an answer which is not being given. */
+static int httpsrv_head(struct httpreq *r, int status, const char *ctype, int64_t len,
+ const char *extra)
+{
+ /* A client speaking 1.0 has no chunked encoding to read, so an answer of
+ unstated length ends the connection instead. */
+ if(len == HTTPSRV_LEN_CHUNKED && !r->version) r->keepalive = 0;
+
+ if(httpsrv_printf(r, "HTTP/1.%d %d %s\r\n", r->version, status,
+ statustext(status))) return 1;
+ if(ctype && httpsrv_printf(r, "Content-Type: %s\r\n", ctype)) return 1;
if(len >= 0){
- if(httpsrv_printf(r, "Content-Length: %ld\r\n", len)) return 1;
+ if(httpsrv_printf(r, "Content-Length: %"PRId64"\r\n", len)) return 1;
}
- else if(httpsrv_printf(r, "Transfer-Encoding: chunked\r\n")) return 1;
+ else if(len == HTTPSRV_LEN_CHUNKED
+ && httpsrv_printf(r, "Transfer-Encoding: chunked\r\n")) return 1;
- return httpsrv_printf(r, "Connection: close\r\n\r\n");
+ if(extra && httpsrv_send(r, extra, (int)strlen(extra))) return 1;
+ if(r->maxage >= 0
+ && httpsrv_printf(r, "Cache-Control: max-age=%d\r\n", r->maxage)) return 1;
+ if(r->hdrs && httpsrv_send(r, r->hdrs, (int)strlen(r->hdrs))) return 1;
+
+ return httpsrv_printf(r, "Connection: %s\r\n\r\n",
+ r->keepalive? "keep-alive" : "close");
}
/* Wraps one block as a chunk, a zero length writing the terminating chunk.
@@ -190,17 +355,591 @@ int httpchunk(struct clientparam *param, const char *buf, int len)
/* Fills buf with a repeating pattern carrying its own offset, so a truncated or
reordered body is visible in the output rather than looking like a short
read. */
-static void httpsrv_fill(char *buf, int len, unsigned long offset)
+static void httpsrv_fill(char *buf, int len, uint64_t offset)
{
int i;
for(i = 0; i < len; i++){
- unsigned long pos = offset + (unsigned long)i;
+ uint64_t pos = offset + (uint64_t)i;
buf[i] = (pos % 64 == 63)? '\n' : (char)('0' + (int)((pos / 64) % 10));
}
}
+static int op_forbidden(struct httpreq *r);
+static int op_notfound(struct httpreq *r);
+
+/* Build a string from a template, putting in what the stars or groups stood
+ for. $0 is the whole path, $1 upwards the captures, $$ a literal dollar.
+ Returns 1 when the result would not fit. */
+static int expand(char *dst, int dstsize, const unsigned char *tmpl,
+ const char *subject, const struct capture *caps, int ncaps)
+{
+ int out = 0;
+
+ for(; *tmpl; tmpl++){
+ if(*tmpl == '$' && tmpl[1] == '$'){
+ if(out + 1 >= dstsize) return 1;
+ dst[out++] = '$';
+ tmpl++;
+ continue;
+ }
+ if(*tmpl == '$' && isdigit(tmpl[1])){
+ int n = tmpl[1] - '0';
+
+ tmpl++;
+ if(n >= ncaps || n >= MAXCAPTURES) continue;
+ if(out + caps[n].len >= dstsize) return 1;
+ memcpy(dst + out, subject + caps[n].start, caps[n].len);
+ out += caps[n].len;
+ continue;
+ }
+ if(out + 1 >= dstsize) return 1;
+ dst[out++] = *tmpl;
+ }
+ dst[out] = 0;
+ return 0;
+}
+
+/* A path built from a request is refused rather than corrected: what a
+ client sends decides part of it, and a name that walks out of the tree, or
+ carries a line ending or a star, is not something to guess about.
+
+ Only a full path is taken. A relative one would be read against whatever
+ directory the service happens to be in, which is not something a
+ configuration should depend on. */
+static int targetunsafe(const char *path)
+{
+ const char *p;
+
+ if(!*path) return 1;
+#ifdef _WIN32
+ /* a drive, or a share, and nothing else */
+ if(!(isalpha((unsigned char)path[0]) && path[1] == ':'
+ && (path[2] == '\\' || path[2] == '/'))
+ && !(path[0] == '\\' && path[1] == '\\')) return 1;
+#else
+ if(path[0] != '/') return 1;
+#endif
+ for(p = path; *p; p++){
+ if(*p == '\r' || *p == '\n' || *p == '*') return 1;
+#ifdef _WIN32
+ if(*p == '"' || *p == '<' || *p == '>' || *p == '|' || *p == '?') return 1;
+#endif
+ }
+ if(!strncmp(path, "./", 2) || !strncmp(path, "../", 3)) return 1;
+ if(strstr(path, "/./") || strstr(path, "/../")) return 1;
+ if(strstr(path, "\\.\\") || strstr(path, "\\..\\")) return 1;
+ p = path + strlen(path);
+ if(p - path >= 2 && !strcmp(p - 2, "/.")) return 1;
+ if(p - path >= 3 && !strcmp(p - 3, "/..")) return 1;
+ return 0;
+}
+
+/* Work out the file a rule points at: expand the template, then refuse
+ anything that does not look like a plain path below a root. */
+static int targetpath(struct httpreq *r, const unsigned char *params, char *out, int outsize)
+{
+ if(!params || !*params) return 1;
+ if(expand(out, outsize, params, r->path, r->caps, r->ncaps)) return 1;
+ if(targetunsafe(out)) return 1;
+ return 0;
+}
+
+#ifdef _WIN32
+/* Windows reads a long path only in its extended form, and only through the
+ wide interface, so a full path is converted to \\?\ before it is opened:
+ a drive becomes \\?\C:\..., a share \\?\UNC\server\share\...
+ */
+static int widepath(const char *path, wchar_t *out, int outchars)
+{
+ char prefixed[HTTPSRV_LINE + 8];
+ char *p;
+
+ if(!strncmp(path, "\\\\?\\", 4)) snprintf(prefixed, sizeof(prefixed), "%s", path);
+ else if(path[0] == '\\\\' && path[1] == '\\\\')
+ snprintf(prefixed, sizeof(prefixed), "\\\\?\\UNC\\%s", path + 2);
+ else snprintf(prefixed, sizeof(prefixed), "\\\\?\\%s", path);
+
+ /* the extended form takes no forward slashes */
+ for(p = prefixed; *p; p++) if(*p == '/') *p = '\\\\';
+
+ return MultiByteToWideChar(CP_UTF8, 0, prefixed, -1, out, outchars) > 0? 0 : 1;
+}
+#endif
+
+/* Opening and measuring a file, in the terms each platform wants. */
+struct filemeta {
+ uint64_t size;
+ time_t mtime;
+ int isreg;
+};
+
+static int filemeta(const char *path, struct filemeta *m)
+{
+#ifdef _WIN32
+ wchar_t wide[HTTPSRV_LINE];
+ struct _stat64 st;
+
+ if(widepath(path, wide, HTTPSRV_LINE) || _wstat64(wide, &st)) return 1;
+ m->size = (uint64_t)st.st_size;
+ m->mtime = st.st_mtime;
+ m->isreg = (st.st_mode & _S_IFREG) != 0;
+#else
+ struct stat st;
+
+ if(stat(path, &st)) return 1;
+ m->size = (uint64_t)st.st_size;
+ m->mtime = st.st_mtime;
+ m->isreg = S_ISREG(st.st_mode);
+#endif
+ return 0;
+}
+
+static int fileopen(const char *path)
+{
+#ifdef _WIN32
+ wchar_t wide[HTTPSRV_LINE];
+
+ if(widepath(path, wide, HTTPSRV_LINE)) return -1;
+ return _wopen(wide, _O_RDONLY | _O_BINARY);
+#else
+ return open(path, O_RDONLY);
+#endif
+}
+
+/* Types named in the configuration, tried before the built in list so an
+ installation can add what it serves without waiting for a release. */
+struct ctypeentry {
+ struct ctypeentry *next;
+ char *ext;
+ char *type;
+};
+
+static struct ctypeentry *ctypes = NULL;
+
+int h_http_content_type(int argc, unsigned char **argv)
+{
+ struct ctypeentry *e;
+
+ e = malloc(sizeof(struct ctypeentry));
+ if(!e) return 21;
+ e->ext = strdup((char *)argv[1]);
+ e->type = strdup((char *)argv[2]);
+ if(!e->ext || !e->type){
+ free(e->ext);
+ free(e->type);
+ free(e);
+ return 21;
+ }
+ e->next = ctypes;
+ ctypes = e;
+ return 0;
+}
+
+static void freecontenttypes(void)
+{
+ struct ctypeentry *e, *next;
+
+ for(e = ctypes; e; e = next){
+ next = e->next;
+ free(e->ext);
+ free(e->type);
+ free(e);
+ }
+ ctypes = NULL;
+}
+
+static const char * contenttype(const char *path)
+{
+ struct ctypeentry *e;
+ static const struct { const char *ext; const char *type; } types[] = {
+ {".html", "text/html"}, {".htm", "text/html"},
+ {".css", "text/css"}, {".js", "application/javascript"},
+ {".txt", "text/plain"}, {".xml", "text/xml"},
+ {".json", "application/json"},
+ {".gif", "image/gif"}, {".jpeg", "image/jpeg"}, {".jpg", "image/jpeg"},
+ {".png", "image/png"}, {".svg", "image/svg+xml"}, {".ico", "image/x-icon"},
+ {".pdf", "application/pdf"}, {NULL, NULL}
+ };
+ const char *dot = strrchr(path, '.');
+ int i;
+
+ if(!dot) return "application/octet-stream";
+ for(e = ctypes; e; e = e->next)
+ if(!strcasecmp(dot, e->ext) || !strcasecmp(dot + 1, e->ext)) return e->type;
+ for(i = 0; types[i].ext; i++)
+ if(!strcasecmp(dot, types[i].ext)) return types[i].type;
+ return "application/octet-stream";
+}
+
+/* Hand a file to the client without carrying it through this process where
+ the platform can do that, and read it in the usual way where it cannot.
+ TLS is the case where it cannot: the bytes have to be encrypted on the way
+ out, so the kernel cannot be left to copy them. */
+static int sendfilecontent(struct httpreq *r, int fd, uint64_t size)
+{
+ struct clientparam *param = r->param;
+ char buf[HTTPSRV_BLOCK];
+ uint64_t sent = 0;
+
+#ifdef HTTPSRV_SENDFILE
+ /* Only where the bytes leave as they are. TLS has to see them, so the
+ kernel cannot be left to copy the file straight to the socket. */
+ if(param->srv->so._send == so._send){
+ while(sent < size){
+ uint64_t left = size - sent;
+ ssize_t n = -1;
+
+ if(left > HTTPSRV_SENDMAX) left = HTTPSRV_SENDMAX;
+#if defined(__linux__)
+ off_t off = (off_t)sent;
+
+ n = sendfile(param->clisock, fd, &off, (size_t)left);
+#elif defined(__APPLE__)
+ off_t len = (off_t)left;
+
+ if(!sendfile(fd, param->clisock, (off_t)sent, &len, NULL, 0) || errno == EAGAIN)
+ n = (ssize_t)len;
+#elif defined(__FreeBSD__)
+ off_t written = 0;
+
+ if(!sendfile(fd, param->clisock, (off_t)sent, (size_t)left,
+ NULL, &written, 0) || errno == EAGAIN)
+ n = (ssize_t)written;
+#endif
+ if(n <= 0) break; /* whatever the reason, read it instead */
+ sent += (uint64_t)n;
+ }
+ if(sent >= size) return 0;
+ }
+#elif defined(_WIN32)
+ if(param->srv->so._send == so._send && size <= HTTPSRV_SENDMAX){
+ HANDLE h = (HANDLE)_get_osfhandle(fd);
+
+ if(h != INVALID_HANDLE_VALUE
+ && TransmitFile(param->clisock, h, (DWORD)size, 0, NULL, NULL, 0))
+ return 0;
+ }
+#endif
+ if(lseek(fd, (off_t)sent, SEEK_SET) == (off_t)-1) return 1;
+ while(sent < size){
+ uint64_t left = size - sent;
+ int want = left > (uint64_t)sizeof(buf)? (int)sizeof(buf) : (int)left;
+ int got;
+
+ got = (int)read(fd, buf, want);
+ if(got <= 0) return 1;
+ if(httpsrv_send(r, buf, got)) return 1;
+ sent += (uint64_t)got;
+ }
+ return 0;
+}
+
+/* buf takes at least 64 characters */
+static void lastmodhdr(time_t mtime, char *buf)
+{
+ memcpy(buf, "Last-Modified: ", 15);
+ httpdate(mtime, buf + 15);
+ strcat(buf, "\r\n");
+}
+
+/* A client which has the file already sends the time it has, and gets told to
+ keep it. Only an answer which would have been 200 can be turned into one:
+ a rule answering with a status of its own is answering something else. */
+static int notmodified(struct httpreq *r, time_t mtime)
+{
+ return r->ims && mtime <= r->ims && (!r->code || r->code == 200)
+ && strcasecmp(r->method, "POST");
+}
+
+static int op_file(struct httpreq *r, const unsigned char *params)
+{
+ char path[HTTPSRV_LINE];
+ char lastmod[64];
+ struct filemeta meta;
+ int fd;
+
+ if(targetpath(r, params, path, sizeof(path))) return op_forbidden(r);
+
+ if(filemeta(path, &meta) || !meta.isreg) return op_notfound(r);
+ lastmodhdr(meta.mtime, lastmod);
+ if(notmodified(r, meta.mtime))
+ return httpsrv_head(r, 304, NULL, HTTPSRV_LEN_NONE, lastmod);
+
+ fd = fileopen(path);
+ if(fd < 0) return op_notfound(r);
+ if(httpsrv_head(r, r->code? r->code : 200,
+ r->ctype? r->ctype : contenttype(path), (int64_t)meta.size, lastmod)){
+ close(fd);
+ return 1;
+ }
+ if(!strcasecmp(r->method, "HEAD")){
+ close(fd);
+ return 0;
+ }
+ if(sendfilecontent(r, fd, meta.size)){
+ close(fd);
+ return 1;
+ }
+ close(fd);
+ return 0;
+}
+
+/* Files read once and kept. A hit checks that the file has not been replaced
+ since, which costs one stat and keeps a running server from serving what
+ an editor has already changed. A rule which gives a max-age has already told
+ clients how long the file may be treated as unchanged, so within that time
+ the server may equally trust the copy it holds, and the stat is skipped.
+
+ What was read is counted rather than locked: a request takes a reference to
+ the content under the mutex and lets it go when it has been written out, so
+ the file goes to the socket with nothing held, and a copy which has been
+ replaced meanwhile lives until the last request using it is done with it.
+ The size and the time belong to the content and not to the entry, so the
+ length in the header and the bytes after it can never come from different
+ copies of the file. */
+struct cachedata {
+ int refs;
+ uint64_t size;
+ time_t mtime;
+ char data[1];
+};
+
+struct cachedfile {
+ struct cachedfile *next;
+ char *path;
+ struct cachedata *content;
+ time_t checked;
+};
+
+static struct cachedfile *cachedfiles = NULL;
+static _3proxy_mutex_t cache_mutex;
+static int cache_ready = 0;
+
+void httpsrv_init(void)
+{
+ freecontenttypes();
+ if(!cache_ready){
+ cache_ready = 1;
+ _3proxy_mutex_init(&cache_mutex);
+ }
+}
+
+static struct cachedata * cache_read(const char *path, const struct filemeta *meta)
+{
+ struct cachedata *cd;
+ uint64_t got = 0;
+ int fd;
+
+ fd = fileopen(path);
+ if(fd < 0) return NULL;
+ cd = malloc(sizeof(struct cachedata) + (size_t)meta->size);
+ if(!cd){
+ close(fd);
+ return NULL;
+ }
+ while(got < meta->size){
+ int n = (int)read(fd, cd->data + got, (size_t)(meta->size - got));
+
+ if(n <= 0) break;
+ got += (uint64_t)n;
+ }
+ close(fd);
+ if(got != meta->size){
+ free(cd);
+ return NULL;
+ }
+ cd->refs = 1; /* the one the caller is given */
+ cd->size = meta->size;
+ cd->mtime = meta->mtime;
+ return cd;
+}
+
+/* both called with the mutex held */
+static struct cachedata * cache_hold(struct cachedata *cd)
+{
+ cd->refs++;
+ return cd;
+}
+
+static void cache_drop(struct cachedata *cd)
+{
+ if(cd && --cd->refs <= 0) free(cd);
+}
+
+static void cache_release(struct cachedata *cd)
+{
+ _3proxy_mutex_lock(&cache_mutex);
+ cache_drop(cd);
+ _3proxy_mutex_unlock(&cache_mutex);
+}
+
+static int op_cache(struct httpreq *r, const unsigned char *params)
+{
+ char path[HTTPSRV_LINE];
+ char lastmod[64];
+ struct cachedfile *cf;
+ struct cachedata *content = NULL, *fresh;
+ struct filemeta meta;
+ time_t now = time(NULL);
+ int res;
+
+ if(targetpath(r, params, path, sizeof(path))) return op_forbidden(r);
+
+ /* Within the time the rule promised, what is held is answered with as it
+ is: the file system is not asked again. */
+ if(r->maxage > 0){
+ _3proxy_mutex_lock(&cache_mutex);
+ for(cf = cachedfiles; cf; cf = cf->next){
+ if(!strcmp(cf->path, path) && now - cf->checked < (time_t)r->maxage){
+ content = cache_hold(cf->content);
+ break;
+ }
+ }
+ _3proxy_mutex_unlock(&cache_mutex);
+ }
+
+ if(!content){
+ if(filemeta(path, &meta) || !meta.isreg) return op_notfound(r);
+ if(meta.size > HTTPSRV_MAXCACHED) return op_file(r, params);
+
+ _3proxy_mutex_lock(&cache_mutex);
+ for(cf = cachedfiles; cf; cf = cf->next){
+ if(!strcmp(cf->path, path) && cf->content->mtime == meta.mtime
+ && cf->content->size == meta.size){
+ cf->checked = now;
+ content = cache_hold(cf->content);
+ break;
+ }
+ }
+ _3proxy_mutex_unlock(&cache_mutex);
+ }
+
+ if(!content){
+ fresh = cache_read(path, &meta);
+ if(!fresh) return op_file(r, params);
+
+ _3proxy_mutex_lock(&cache_mutex);
+ for(cf = cachedfiles; cf; cf = cf->next) if(!strcmp(cf->path, path)) break;
+ if(cf){
+ cache_drop(cf->content); /* it was replaced on disk */
+ cf->content = cache_hold(fresh);
+ cf->checked = now;
+ }
+ else if((cf = malloc(sizeof(struct cachedfile)))){
+ cf->path = strdup(path);
+ cf->content = cache_hold(fresh);
+ cf->checked = now;
+ cf->next = cachedfiles;
+ if(cf->path) cachedfiles = cf;
+ else {
+ cache_drop(fresh); /* the entry never went in */
+ free(cf);
+ }
+ }
+ _3proxy_mutex_unlock(&cache_mutex);
+ content = fresh; /* read with a reference of its own */
+ }
+
+ lastmodhdr(content->mtime, lastmod);
+ if(notmodified(r, content->mtime)){
+ res = httpsrv_head(r, 304, NULL, HTTPSRV_LEN_NONE, lastmod);
+ cache_release(content);
+ return res;
+ }
+
+ res = httpsrv_head(r, r->code? r->code : 200,
+ r->ctype? r->ctype : contenttype(path), (int64_t)content->size, lastmod);
+ if(!res && strcasecmp(r->method, "HEAD"))
+ res = httpsrv_send(r, content->data, (int)content->size);
+ cache_release(content);
+ return res;
+}
+
+/* Send the client somewhere else. The parameters are a location, optionally
+ preceded by the status to use. */
+static int op_redir(struct httpreq *r, const unsigned char *params)
+{
+ char location[HTTPSRV_LINE];
+ char hdr[HTTPSRV_LINE];
+ const unsigned char *p = params;
+ int code = r->code? r->code : 302;
+
+ if(!p || !*p) return op_forbidden(r);
+ if(isdigit(*p)){
+ code = atoi((char *)p);
+ while(isdigit(*p)) p++;
+ while(*p == ' ' || *p == '\t') p++;
+ if(code < 300 || code > 399) code = 302;
+ }
+ if(expand(location, sizeof(location), p, r->path, r->caps, r->ncaps))
+ return op_forbidden(r);
+ if(strchr(location, '\r') || strchr(location, '\n')) return op_forbidden(r);
+
+ if(strlen(location) + sizeof("Location: \r\n") > sizeof(hdr)) return op_forbidden(r);
+ sprintf(hdr, "Location: %s\r\n", location);
+
+ return httpsrv_head(r, code, NULL, 0, hdr);
+}
+
+/* Answer with a status and nothing else. The status comes from the rule, as
+ do any headers it adds. */
+static int op_reply(struct httpreq *r, const unsigned char *params)
+{
+ int code = r->code? r->code : 200;
+
+ (void)params;
+ if(code / 100 == 1 || code == 204 || code == 304)
+ return httpsrv_head(r, code, NULL, HTTPSRV_LEN_NONE, NULL);
+ return httpsrv_head(r, code, NULL, 0, NULL);
+}
+
+/* A name a rule builds has to be one, since it decides which rules are taken
+ after it and is written into the log. */
+static int hostunsafe(const char *host)
+{
+ const char *p;
+
+ if(!*host || strlen(host) > 255) return 1;
+ for(p = host; *p; p++){
+ if(isalnum((unsigned char)*p)) continue;
+ if(*p == '.' || *p == '-' || *p == '_' || *p == ':'
+ || *p == '[' || *p == ']') continue;
+ return 1;
+ }
+ return 0;
+}
+
+/* Change the host and let the rules after this one decide what to do with the
+ request. What the stars of the host pattern stood for are what $1 upwards
+ mean here, the way they mean the stars of the URL in a rewrite. */
+static int op_rewrite_host(struct httpreq *r, const unsigned char *params)
+{
+ char host[sizeof(r->host)];
+
+ if(!params || !*params) return op_forbidden(r);
+ if(expand(host, sizeof(host), params, r->host, r->hostcaps, r->nhostcaps))
+ return op_forbidden(r);
+ if(hostunsafe(host)) return op_forbidden(r);
+ strcpy(r->host, host);
+ return HTTPSRV_REWRITTEN;
+}
+
+/* Change the path and let the rules after this one decide what to do with
+ the request. */
+static int op_rewrite(struct httpreq *r, const unsigned char *params)
+{
+ char path[sizeof(r->path)];
+
+ if(!params || !*params) return op_forbidden(r);
+ if(expand(path, sizeof(path), params, r->path, r->caps, r->ncaps))
+ return op_forbidden(r);
+ if(targetunsafe(path) || path[0] != '/') return op_forbidden(r);
+ strcpy(r->path, path);
+ return HTTPSRV_REWRITTEN;
+}
+
static int op_echo(struct httpreq *r, const unsigned char *params)
{
struct clientparam *param = r->param;
@@ -224,7 +963,7 @@ static int op_echo(struct httpreq *r, const unsigned char *params)
"path=%s\n"
"query=%s\n"
"host=%s\n"
- "content.length=%lu\n"
+ "content.length=%"PRIu64"\n"
"glob.start=%d\n"
"glob.len=%d\n"
"glob=%.*s\n",
@@ -235,7 +974,7 @@ static int op_echo(struct httpreq *r, const unsigned char *params)
if(len < 0) return 1;
if(len > (int)sizeof(body) - 1) len = (int)sizeof(body) - 1;
- if(httpsrv_head(r, 200, "text/plain", (long)len)) return 1;
+ if(httpsrv_head(r, 200, "text/plain", (int64_t)len, NULL)) return 1;
return httpsrv_send(r, body, len);
}
@@ -244,9 +983,9 @@ static int op_echo(struct httpreq *r, const unsigned char *params)
static int op_data(struct httpreq *r, const unsigned char *params)
{
char buf[HTTPSRV_BLOCK];
- long size, block, delay, status;
+ int64_t size, block, delay, status;
int chunked;
- unsigned long sent = 0;
+ uint64_t sent = 0;
size = qparam((const char *)params, "size", 0);
size = qparam(r->query, "size", size);
@@ -261,12 +1000,12 @@ static int op_data(struct httpreq *r, const unsigned char *params)
delay = qparam(r->query, "delay", qparam((const char *)params, "delay", 0));
if(httpsrv_head(r, (int)status, "application/octet-stream",
- chunked? -1 : size)) return 1;
+ chunked? (int64_t)HTTPSRV_LEN_CHUNKED : (int64_t)size, NULL)) return 1;
- while(sent < (unsigned long)size){
- int len = (int)block;
+ while(sent < (uint64_t)size){
+ uint64_t left = (uint64_t)size - sent;
+ int len = (left < (uint64_t)block)? (int)left : (int)block;
- if((unsigned long)len > (unsigned long)size - sent) len = (int)(size - sent);
httpsrv_fill(buf, len, sent);
if(delay > 0){
@@ -282,7 +1021,7 @@ static int op_data(struct httpreq *r, const unsigned char *params)
}
else if(httpsrv_send(r, buf, len)) return 1;
- sent += (unsigned long)len;
+ sent += (uint64_t)len;
}
if(chunked) return httpchunk(r->param, NULL, 0);
@@ -293,6 +1032,9 @@ static int op_authrequired(struct httpreq *r)
{
static const char body[] = "authentication required\n";
+ r->hdrs = NULL;
+ r->maxage = -1;
+ r->keepalive = 0;
if(httpsrv_printf(r, "HTTP/1.0 401 Authentication Required\r\n"
"WWW-Authenticate: Basic realm=\"3proxy\"\r\n"
"Content-Type: text/plain\r\n"
@@ -305,7 +1047,9 @@ static int op_forbidden(struct httpreq *r)
{
static const char body[] = "forbidden\n";
- if(httpsrv_head(r, 403, "text/plain", (long)sizeof(body) - 1)) return 1;
+ r->hdrs = NULL;
+ r->maxage = -1;
+ if(httpsrv_head(r, 403, "text/plain", (int64_t)sizeof(body) - 1, NULL)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
@@ -313,7 +1057,9 @@ static int op_badrequest(struct httpreq *r)
{
static const char body[] = "bad request\n";
- if(httpsrv_head(r, 400, "text/plain", (long)sizeof(body) - 1)) return 1;
+ r->hdrs = NULL;
+ r->maxage = -1;
+ if(httpsrv_head(r, 400, "text/plain", (int64_t)sizeof(body) - 1, NULL)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
@@ -321,7 +1067,9 @@ static int op_notfound(struct httpreq *r)
{
static const char body[] = "not found\n";
- if(httpsrv_head(r, 404, "text/plain", (long)sizeof(body) - 1)) return 1;
+ r->hdrs = NULL;
+ r->maxage = -1;
+ if(httpsrv_head(r, 404, "text/plain", (int64_t)sizeof(body) - 1, NULL)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
@@ -330,14 +1078,21 @@ static int op_notfound(struct httpreq *r)
static struct httpop {
const char *name;
int (*fn)(struct httpreq *, const unsigned char *params);
+ int framed; /* the answer says how long it is, so the connection may be kept */
} httpops[] = {
- {"echo", op_echo},
- {"data", op_data},
- {"admin", op_admin},
- {"admin_counters", op_admin_counters},
- {"admin_reload", op_admin_reload},
- {"admin_services", op_admin_services},
- {NULL, NULL}
+ {"echo", op_echo, 1},
+ {"data", op_data, 1},
+ {"file", op_file, 1},
+ {"cache", op_cache, 1},
+ {"redir", op_redir, 1},
+ {"reply", op_reply, 1},
+ {"rewrite", op_rewrite, 1},
+ {"rewrite_host", op_rewrite_host, 1},
+ {"admin", op_admin, 0},
+ {"admin_counters", op_admin_counters, 0},
+ {"admin_reload", op_admin_reload, 0},
+ {"admin_services", op_admin_services, 0},
+ {NULL, NULL, 0}
};
void freehttprules(struct httprule *rule)
@@ -349,6 +1104,8 @@ void freehttprules(struct httprule *rule)
if(rule->host.name) free(rule->host.name);
if(rule->url.name) free(rule->url.name);
if(rule->params) free(rule->params);
+ if(rule->ctype) free(rule->ctype);
+ if(rule->hdrs) free(rule->hdrs);
free(rule);
rule = next;
}
@@ -371,68 +1128,78 @@ int httpopbyname(const unsigned char *name)
client the reply it was about to read. Bounded, so a client cannot keep
the server reading.
*/
-static void httpsrv_drain(struct clientparam *param, unsigned long len)
+static void httpsrv_drain(struct clientparam *param, uint64_t len)
{
char buf[HTTPSRV_BLOCK];
if(len > HTTPSRV_MAXBODY) len = HTTPSRV_MAXBODY;
while(len){
- int want = (len > (unsigned long)sizeof(buf))? (int)sizeof(buf) : (int)len;
+ int want = (len > (uint64_t)sizeof(buf))? (int)sizeof(buf) : (int)len;
int got = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, want, EOF,
conf.timeouts[STRING_S]);
if(got <= 0) break;
- len -= (unsigned long)got;
+ len -= (uint64_t)got;
}
}
-void * httpsrvchild(struct clientparam *param)
+/* Reads one request and answers it. Returns 0 when nothing more came on a
+ connection which was being kept open, which is not a request and not an
+ error, so there is nothing to answer and nothing to log. */
+static int httpsrv_request(struct clientparam *param, struct httpreq *r)
{
- struct httpreq r;
char buf[HTTPSRV_LINE];
char *sp, *q;
struct httprule *rule;
int i, hdrs = 0;
- memset(&r, 0, sizeof(r));
- r.param = param;
-
i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, sizeof(buf) - 1, '\n',
conf.timeouts[STRING_S]);
+ if(i <= 0 && !r->first) return 0; /* the client is done with us */
if(i < 5) RETURN(701);
buf[i] = 0;
sp = strchr(buf, ' ');
if(!sp) RETURN(702);
*sp = 0;
- if(copyfield(r.method, sizeof(r.method), buf)) RETURN(703);
+ if(copyfield(r->method, sizeof(r->method), buf)) RETURN(703);
- if(!strcasecmp(r.method, "GET")) param->operation = HTTP_GET;
- else if(!strcasecmp(r.method, "POST")) param->operation = HTTP_POST;
- else if(!strcasecmp(r.method, "PUT")) param->operation = HTTP_PUT;
- else if(!strcasecmp(r.method, "HEAD")) param->operation = HTTP_HEAD;
+ if(!strcasecmp(r->method, "GET")) param->operation = HTTP_GET;
+ else if(!strcasecmp(r->method, "POST")) param->operation = HTTP_POST;
+ else if(!strcasecmp(r->method, "PUT")) param->operation = HTTP_PUT;
+ else if(!strcasecmp(r->method, "HEAD")) param->operation = HTTP_HEAD;
else param->operation = HTTP_OTHER;
while(*++sp == ' ');
q = strchr(sp, ' ');
- if(q) *q = 0;
+ if(q){
+ char *v = q + 1;
+
+ *q = 0;
+ while(*v == ' ') v++;
+ if(!strncasecmp(v, "HTTP/1.1", 8)) r->version = 1;
+ }
q = sp + strcspn(sp, "\r\n");
*q = 0;
+ /* 1.1 keeps the connection unless the client says otherwise, 1.0 only
+ when the client asks for it. */
+ r->keepalive = r->version;
+
q = strchr(sp, '?');
if(q){
*q = 0;
- if(copyfield(r.query, sizeof(r.query), q + 1)) RETURN(704);
+ if(copyfield(r->query, sizeof(r->query), q + 1)) RETURN(704);
}
{
- char decoded[sizeof(r.path)];
+ char decoded[sizeof(r->path)];
/* Keep the raw path first so a refused request still records what
was asked for. */
- if(copyfield(r.path, sizeof(r.path), sp)) RETURN(705);
+ if(copyfield(r->path, sizeof(r->path), sp)) RETURN(705);
if(urldecode(decoded, sizeof(decoded), sp)) RETURN(707);
if(pathunsafe(decoded)) RETURN(708);
- strcpy(r.path, decoded);
+ strcpy(r->path, decoded);
}
while(hdrs++ < HTTPSRV_MAXHDR &&
@@ -443,7 +1210,7 @@ void * httpsrvchild(struct clientparam *param)
sp = buf + 5;
while(isspace((unsigned char)*sp)) sp++;
sp[strcspn(sp, "\r\n")] = 0;
- if(copyfield(r.host, sizeof(r.host), sp)) RETURN(706);
+ if(copyfield(r->host, sizeof(r->host), sp)) RETURN(706);
}
else if(!strncasecmp(buf, "authorization:", 14)){
char creds[256];
@@ -469,20 +1236,40 @@ void * httpsrvchild(struct clientparam *param)
if(param->username) free(param->username);
param->username = (unsigned char *)strdup(creds);
}
+ else if(!strncasecmp(buf, "connection:", 11)){
+ sp = buf + 11;
+ while(isspace((unsigned char)*sp)) sp++;
+ sp[strcspn(sp, "\r\n")] = 0;
+ if(headertoken(sp, "close")) r->keepalive = 0;
+ else if(headertoken(sp, "keep-alive")) r->keepalive = 1;
+ }
+ else if(!strncasecmp(buf, "transfer-encoding:", 18)){
+ /* A body this server does not know how to read leaves the
+ stream at an unknown place, so the connection ends with
+ this request. */
+ r->chunkedreq = 1;
+ }
+ else if(!strncasecmp(buf, "if-modified-since:", 18)){
+ r->ims = parsehttpdate(buf + 18);
+ }
else if(!strncasecmp(buf, "content-length:", 15)){
- sscanf(buf + 15, "%lu", &r.contentlen);
+ sscanf(buf + 15, "%"SCNu64"", &r->contentlen);
}
}
- if(r.contentlen) httpsrv_drain(param, r.contentlen);
+ /* The next request begins where this body ends, so a body which cannot
+ be read to its end - one this server does not frame, or one longer
+ than it is willing to read - closes the connection instead. */
+ if(r->chunkedreq || r->contentlen > HTTPSRV_MAXBODY) r->keepalive = 0;
+ if(r->contentlen) httpsrv_drain(param, r->contentlen);
- if(r.host[0]){
- char host[sizeof(r.host)];
+ if(r->host[0]){
+ char host[sizeof(r->host)];
char *colon;
/* Access rules match a bare name, so drop the port the client sent.
An address in brackets keeps its colons. */
- strcpy(host, r.host);
+ strcpy(host, r->host);
colon = (*host == '[')? strchr(host, ']') : host;
if(colon){
colon = strchr(colon, ':');
@@ -507,34 +1294,85 @@ void * httpsrvchild(struct clientparam *param)
if(i && i != 10){
/* 4 no credentials, 5 unknown user, 6 wrong password: all of them
should let the client offer credentials again. */
- if(i >= 4 && i <= 6) op_authrequired(&r);
- else op_forbidden(&r);
+ if(i >= 4 && i <= 6) op_authrequired(r);
+ else op_forbidden(r);
RETURN(i);
}
- for(rule = param->srv->httprules; rule; rule = rule->next){
- if(patternmatch(&rule->host, (unsigned char *)r.host) &&
- patternmatchpos(&rule->url, (unsigned char *)r.path,
- &r.globstart, &r.globlen)){
- httpops[rule->op].fn(&r, rule->params);
- RETURN(0);
+ /* A rewrite changes the path and hands the request to the rules that
+ follow it, so the walk restarts. The count bounds a set of rules that
+ rewrite in a circle. */
+ rule = param->srv->httprules;
+ for(hdrs = 0; rule && hdrs < HTTPSRV_MAXREWRITE; rule = rule->next){
+ if(!patternmatchcaps(&rule->host, (unsigned char *)r->host,
+ r->hostcaps, &r->nhostcaps)) continue;
+ if(!patternmatchcaps(&rule->url, (unsigned char *)r->path, r->caps, &r->ncaps))
+ continue;
+
+ /* what the first star stood for, which is what admin reads */
+ r->globstart = r->ncaps > 1? r->caps[1].start : 0;
+ r->globlen = r->ncaps > 1? r->caps[1].len : 0;
+
+ /* Only an answer which says how long it is may be followed by
+ another request on the same connection. */
+ if(!httpops[rule->op].framed) r->keepalive = 0;
+
+ r->ctype = (const char *)rule->ctype;
+ r->hdrs = (const char *)rule->hdrs;
+ r->maxage = rule->maxage;
+ r->code = rule->code;
+ if(httpops[rule->op].fn(r, rule->params) == HTTPSRV_REWRITTEN){
+ hdrs++;
+ continue;
}
+ RETURN(0);
}
- op_notfound(&r);
+ if(hdrs >= HTTPSRV_MAXREWRITE){
+ param->srv->logfunc(param, (unsigned char *)"http: too many rewrites");
+ op_badrequest(r);
+ RETURN(709);
+ }
+ op_notfound(r);
RETURN(404);
CLEANRET:
- if(param->res >= 700 && param->res < 800) op_badrequest(&r);
- /* Log the request the way the proxy does: the parameters decide what was
- served, so a bare path is not enough to explain a response. */
- {
- char logbuf[sizeof(r.method) + sizeof(r.host) + sizeof(r.path) +
- sizeof(r.query) + 8];
+ if(param->res >= 700 && param->res < 800){
+ r->keepalive = 0;
+ op_badrequest(r);
+ }
+ return 1;
+}
- sprintf(logbuf, "%s %s %s%s%s", r.method[0]? r.method : "-",
- r.host[0]? r.host : "-", r.path,
- r.query[0]? "?" : "", r.query);
- dolog(param, (unsigned char *)logbuf);
+/* One connection, and as many requests as the client and the answers allow. */
+void * httpsrvchild(struct clientparam *param)
+{
+ struct httpreq r;
+ int first = 1;
+
+ for(;;){
+ memset(&r, 0, sizeof(r));
+ r.maxage = -1; /* until a rule says otherwise */
+ r.param = param;
+ r.first = first;
+ param->res = 0;
+
+ if(!httpsrv_request(param, &r)) break;
+
+ /* Log the request the way the proxy does: the parameters decide
+ what was served, so a bare path is not enough to explain a
+ response. */
+ {
+ char logbuf[sizeof(r.method) + sizeof(r.host) + sizeof(r.path) +
+ sizeof(r.query) + 8];
+
+ sprintf(logbuf, "%s %s %s%s%s", r.method[0]? r.method : "-",
+ r.host[0]? r.host : "-", r.path,
+ r.query[0]? "?" : "", r.query);
+ dolog(param, (unsigned char *)logbuf);
+ }
+
+ if(!r.keepalive) break;
+ first = 0;
}
return NULL;
}
diff --git a/src/pcre.c b/src/pcre.c
index d268a25..b7f84ba 100644
--- a/src/pcre.c
+++ b/src/pcre.c
@@ -629,6 +629,71 @@ static struct symbol regexp_symbols[] = {
};
+/* Compiling and matching for patterns outside the pcre commands: a host name
+ or a URL in an http rule, an access rule naming a host. They go through the
+ same compile, with whatever pcre_options is set to, so one kind of regular
+ expression is understood everywhere.
+ */
+void * pcre_pattern_compile(const unsigned char *pattern, char *errbuf, int errlen)
+{
+ pcre2_code *re;
+ int errcode;
+ PCRE2_SIZE erroffset;
+
+ re = pcre2_compile((PCRE2_SPTR)pattern, PCRE2_ZERO_TERMINATED, pcre_options,
+ &errcode, &erroffset, NULL);
+ if(!re){
+ if(errbuf && errlen > 0){
+ PCRE2_UCHAR message[256];
+
+ pcre2_get_error_message(errcode, message, sizeof(message));
+ snprintf(errbuf, errlen, "%s at offset %d", (char *)message, (int)erroffset);
+ }
+ return NULL;
+ }
+ return re;
+}
+
+void pcre_pattern_free(void *re)
+{
+ if(re) pcre2_code_free((pcre2_code *)re);
+}
+
+/* Returns the number of captures placed, or 0 when the subject does not
+ match. Element 0 is the whole match. The match data is per call: a rule is
+ matched from several threads at once.
+ */
+int pcre_pattern_match(void *re, const unsigned char *subject, struct capture *caps, int maxcaps)
+{
+ pcre2_match_data *match_data;
+ PCRE2_SIZE *ovector;
+ int count, i, placed = 0;
+
+ if(!re || !subject) return 0;
+ match_data = pcre2_match_data_create_from_pattern((pcre2_code *)re, NULL);
+ if(!match_data) return 0;
+
+ count = pcre2_match((pcre2_code *)re, (PCRE2_SPTR)subject, PCRE2_ZERO_TERMINATED,
+ 0, 0, match_data, NULL);
+ if(count > 0){
+ ovector = pcre2_get_ovector_pointer(match_data);
+ if(count > maxcaps) count = maxcaps;
+ for(i = 0; i < count; i++){
+ if(ovector[i*2] == PCRE2_UNSET){
+ caps[i].start = 0;
+ caps[i].len = 0;
+ }
+ else {
+ caps[i].start = (int)ovector[i*2];
+ caps[i].len = (int)(ovector[i*2+1] - ovector[i*2]);
+ }
+ }
+ placed = count;
+ }
+ pcre2_match_data_free(match_data);
+ return placed;
+}
+
void pcre_install(void){
struct filter *flt, *tmpflt;
diff --git a/src/proxy.h b/src/proxy.h
index fe3e969..d4ac342 100644
--- a/src/proxy.h
+++ b/src/proxy.h
@@ -169,6 +169,7 @@ void daemonize(void);
#ifndef _WIN32
size_t threadstacksize(int extra);
+
#endif
#ifdef WITH_ODBC
@@ -386,7 +387,18 @@ int readconfig(FILE * fp);
void initcommands(void);
int connectwithpoll(struct clientparam *param, SOCKET sock, struct sockaddr *sa, SASIZETYPE size, int to);
int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range);
+#ifdef WITH_PCRE
+/* One regular expression implementation for the whole program: the pcre
+ commands and every pattern that carries a pcre: prefix. */
+void * pcre_pattern_compile(const unsigned char *pattern, char *errbuf, int errlen);
+void pcre_pattern_free(void *re);
+int pcre_pattern_match(void *re, const unsigned char *subject, struct capture *caps, int maxcaps);
+#endif
+
int parsepattern(struct hostname *h, unsigned char *arg);
+int parsepathpattern(struct hostname *h, unsigned char *arg);
+int patternmatchcaps(const struct hostname *h, const unsigned char *str,
+ struct capture *caps, int *ncaps);
int patternmatch(const struct hostname *h, const unsigned char *str);
int patternmatchpos(const struct hostname *h, const unsigned char *str, int *start, int *len);
void applyportranges(struct clientparam * param, struct ace * acentry);
diff --git a/src/structures.h b/src/structures.h
index b5ca3d4..a76f7c6 100644
--- a/src/structures.h
+++ b/src/structures.h
@@ -350,21 +350,47 @@ struct period {
#define MATCHBEGIN 1
#define MATCHEND 2
+/* A pattern is either the star form above, matched by matchtype, or a regular
+ expression compiled once when the configuration is read. */
+#define MATCHGLOB 4 /* stars anywhere: * within a path element, ** across */
+#define MATCHREGEX 5
+
+/* What a star or a capturing group stood for. Element 0 is the whole
+ subject, so a template writes it as $0 and the groups as $1 upwards. */
+#define MAXCAPTURES 10
+struct capture {
+ int start;
+ int len;
+};
struct hostname {
struct hostname *next;
unsigned char * name;
int matchtype;
+ void * re; /* compiled regular expression, MATCHREGEX only */
};
/* A request handed to an http operation. */
struct httpreq {
+ struct capture caps[MAXCAPTURES];
+ int ncaps;
+ struct capture hostcaps[MAXCAPTURES];
+ int nhostcaps;
+ const char *ctype;
+ const char *hdrs;
+ int maxage;
+ int code;
+ time_t ims; /* what If-Modified-Since asked about, or 0 */
+ int version; /* 0 for HTTP/1.0, 1 for HTTP/1.1 */
+ int keepalive; /* whether the connection carries another request */
+ int first; /* the first request on this connection */
+ int chunkedreq; /* a body this server does not know how to read */
struct clientparam *param;
char method[16];
char path[256];
char query[512];
char host[256];
- unsigned long contentlen;
+ uint64_t contentlen;
int globstart, globlen;
};
@@ -377,6 +403,10 @@ struct httprule {
struct hostname url;
int op;
unsigned char *params;
+ unsigned char *ctype; /* type named by the rule, or NULL to work it out */
+ unsigned char *hdrs; /* headers the rule adds, already CRLF separated */
+ int maxage; /* seconds to allow caching for, or -1 to say nothing */
+ int code; /* status the rule answers with, or 0 for the usual */
};
struct ace {
diff --git a/tests/cases/auto.py b/tests/cases/auto.py
index 658dd13..eb7c409 100644
--- a/tests/cases/auto.py
+++ b/tests/cases/auto.py
@@ -21,7 +21,7 @@ def run(t):
ssl_serv
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{secure}
ssl_noserv"""
@@ -30,7 +30,7 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{plain}
{tls_origin}
diff --git a/tests/cases/httpsrv_auth.py b/tests/cases/httpsrv_auth.py
index 67bf2d4..a6a5a49 100644
--- a/tests/cases/httpsrv_auth.py
+++ b/tests/cases/httpsrv_auth.py
@@ -6,14 +6,14 @@ def run(t):
openport = t.free_port()
t.start("httpsrv_auth", f"""
log
- http * /echo echo
+ http echo * /echo
auth strong
users alice:CL:secret bob:CL:hunter2
allow alice
httpsrv -p{srv}
flush
- http * /echo echo
+ http echo * /echo
auth iponly
allow *
httpsrv -p{openport}
diff --git a/tests/cases/httpsrv_files.py b/tests/cases/httpsrv_files.py
new file mode 100644
index 0000000..2031fe3
--- /dev/null
+++ b/tests/cases/httpsrv_files.py
@@ -0,0 +1,195 @@
+"""The operations that serve a filesystem: file, cache, redir and rewrite.
+
+A rule maps a request onto a path with a template, where $1 upwards stand for
+what the stars or the groups of a regular expression matched.
+"""
+
+import os
+
+
+def run(t):
+ root = os.path.join(t.tmpdir, "web")
+ os.makedirs(os.path.join(root, "picts", "set"), exist_ok=True)
+ with open(os.path.join(root, "a.html"), "w") as fp:
+ fp.write("hello
")
+ with open(os.path.join(root, "big.bin"), "wb") as fp:
+ fp.write(b"x" * 300000) # past a single send, and past the cache limit
+ with open(os.path.join(root, "picts", "set", "dog.gif"), "wb") as fp:
+ fp.write(b"GIF89a-pretend")
+
+ with open(os.path.join(root, "b.webp"), "wb") as fp:
+ fp.write(b"RIFF-pretend")
+
+ port = t.free_port()
+ t.start("httpsrv_files", f"""
+ log
+ auth iponly
+ allow *
+ http rewrite * /alias/** "/w/$1"
+ http file * /w/*.html "{root}/$1.html"
+ http file * /big {root}/big.bin
+ http cache * /c/*.html "{root}/$1.html"
+ http cache * "pcre:^/(.*)/pic/(.*)\\.(gif|jpeg)$" "{root}/picts/$1/$2.$3"
+ http redir * /old/** 301 "https://example.org/$1"
+ http redir * /moved /w/a.html
+ http file * /rel/*.html "web/$1.html"
+ http echo * /echo
+
+ http_content_type .webp image/webp
+ http_content_type dat application/x-mydata
+ http file * /ct/*.webp "{root}/$1.webp"
+ http file * /named/*.html "{root}/$1.html" text/x-named
+ http file * /star/*.html "{root}/$1.html" *
+ http cache * /ctc/*.webp "{root}/$1.webp"
+
+ http file * /aged/*.html "{root}/$1.html" * 3600
+ http cache * /aged2/*.html "{root}/$1.html" * 60
+ http file * /extra/*.html "{root}/$1.html" * * "X-One: 1\\nX-Two: two words"
+ http file * /err/*.html "{root}/$1.html" * * "X-Served: static" 404
+ http reply * /ok**
+ http reply * /nobody** 204
+ http reply * /down** 503 "Retry-After: 30"
+ http cache * /held/*.html "{root}/$1.html" * 30
+ httpsrv -p{port}
+ """, ports=[port])
+
+ url = f"http://127.0.0.1:{port}"
+
+ # --- file -------------------------------------------------------------
+ r = t.http(url + "/w/a.html")
+ t.eq(200, r.status, "a file is served")
+ t.contains(r, "hello
", "with its content")
+ t.eq("text/html", r.header("Content-Type"), "and a type taken from the name")
+ t.eq(str(len("hello
")), r.header("Content-Length"), "and its length")
+
+ t.eq(404, t.http(url + "/w/nosuch.html").status, "a missing file is not found")
+ big = t.http(url + "/big")
+ t.eq(300000, big.length, "a large file arrives whole")
+ t.eq("300000", big.header("Content-Length"), "and is announced by its length")
+ t.eq(None, big.header("Transfer-Encoding"),
+ "a file is never sent chunked")
+ t.eq("300000", t.http(url + "/big", method="HEAD").header("Content-Length"),
+ "HEAD gives the length without the body")
+ t.eq(200, t.http(url + "/w/a.html", method="HEAD").status, "HEAD is answered")
+ t.eq(0, t.http(url + "/w/a.html", method="HEAD").length, "HEAD carries no body")
+
+ # --- cache ------------------------------------------------------------
+ first = t.http(url + "/c/a.html")
+ second = t.http(url + "/c/a.html")
+ t.eq(200, first.status, "a cached file is served")
+ t.eq(first.text, second.text, "and the same on the next request")
+ t.contains(second, "hello
", "from memory this time")
+
+ # a file changed on disk is noticed rather than served from before
+ with open(os.path.join(root, "a.html"), "w") as fp:
+ fp.write("changed
")
+ t.contains(t.http(url + "/c/a.html"), "changed",
+ "a file replaced on disk is read again")
+
+ # --- what the stars stand for -----------------------------------------
+ r = t.http(url + "/set/pic/dog.gif")
+ t.eq(200, r.status, "a regular expression maps a request onto a path")
+ t.contains(r, "GIF89a", "and the file is served")
+ t.eq("image/gif", r.header("Content-Type"), "with the type of that name")
+
+ # --- redir ------------------------------------------------------------
+ r = t.http(url + "/old/thing")
+ t.eq(301, r.status, "a redirect uses the status it was given")
+ t.eq("https://example.org/thing", r.header("Location"),
+ "and a location built from the request")
+ t.eq(302, t.http(url + "/moved").status, "without a status it is 302")
+
+ # --- rewrite ----------------------------------------------------------
+ r = t.http(url + "/alias/a.html")
+ t.eq(200, r.status, "a rewritten request reaches the rule after it")
+ t.contains(r, "changed", "and is served from the path it was rewritten to")
+
+ # --- the type a reply carries -------------------------------------------
+ # Worked out from the name, using what the configuration has registered
+ # on top of what is built in, unless the rule says otherwise.
+ t.eq("image/webp", t.http(url + "/ct/b.webp").header("Content-Type"),
+ "a registered extension names the type")
+ t.eq("image/webp", t.http(url + "/ctc/b.webp").header("Content-Type"),
+ "and a cached file is answered the same way")
+ t.eq("text/x-named", t.http(url + "/named/a.html").header("Content-Type"),
+ "a rule may name the type itself")
+ t.eq("text/html", t.http(url + "/star/a.html").header("Content-Type"),
+ "and a star there leaves it to the name of the file")
+
+ # --- what a rule adds to the answer ------------------------------------
+ r = t.http(url + "/aged/a.html")
+ t.eq("max-age=3600", r.header("Cache-Control"), "a rule may describe caching")
+ t.eq("max-age=60", t.http(url + "/aged2/a.html").header("Cache-Control"),
+ "a cached file is answered the same way")
+ t.eq(None, t.http(url + "/w/a.html").header("Cache-Control"),
+ "and a rule which says nothing sends nothing")
+
+ r = t.http(url + "/extra/a.html")
+ t.eq("1", r.header("X-One"), "a rule may add headers")
+ t.eq("two words", r.header("X-Two"),
+ "the second of them arrives whole, spaces and all")
+
+ r = t.http(url + "/err/a.html")
+ t.eq(404, r.status, "a rule may answer with the status it names")
+ t.contains(r, "", "and the file is still the body")
+ t.eq("static", r.header("X-Served"),
+ "what the rule adds goes with the status the rule asked for")
+
+ # a refusal the server decided on is its own answer
+ r = t.http(url + "/err/nosuch.html")
+ t.eq(404, r.status, "a missing file is still not found")
+ t.eq(None, r.header("X-Served"), "and carries none of the rule's headers")
+ t.eq(None, t.http(url + "/aged/nosuch.html").header("Cache-Control"),
+ "nor what it said about caching")
+
+ # --- reply --------------------------------------------------------------
+ r = t.http(url + "/ok")
+ t.eq(200, r.status, "reply answers with 200 by default")
+ t.eq("0", r.header("Content-Length"), "with a length of zero")
+ t.eq(0, r.length, "and no body")
+
+ r = t.http(url + "/nobody")
+ t.eq(204, r.status, "reply answers with the status it was given")
+ t.eq(None, r.header("Content-Length"),
+ "and a status carrying no body is sent without a length")
+
+ r = t.http(url + "/down")
+ t.eq(503, r.status, "reply serves a refusal the configuration decided on")
+ t.eq("30", r.header("Retry-After"), "with the headers that go with it")
+
+ # --- a client which has the file already --------------------------------
+ r = t.http(url + "/w/a.html")
+ stamp = r.header("Last-Modified")
+ t.ne(None, stamp, "a file is answered with the time it was last changed")
+
+ r = t.http(url + "/w/a.html", headers={"If-Modified-Since": stamp})
+ t.eq(304, r.status, "and an unchanged file is answered 304")
+ t.eq(0, r.length, "which carries no body")
+ t.eq(None, r.header("Content-Length"), "and no length")
+ t.eq(stamp, r.header("Last-Modified"), "but still says when the file changed")
+
+ t.eq(200, t.http(url + "/w/a.html",
+ headers={"If-Modified-Since": "Sun, 06 Nov 1994 08:49:37 GMT"}).status,
+ "an older date is answered with the file")
+ t.eq(200, t.http(url + "/w/a.html",
+ headers={"If-Modified-Since": "not a date at all"}).status,
+ "and a date which cannot be read is treated as none")
+ t.eq(304, t.http(url + "/c/a.html", headers={"If-Modified-Since": stamp}).status,
+ "a file answered from memory is conditional in the same way")
+ t.eq(404, t.http(url + "/err/a.html", headers={"If-Modified-Since": stamp}).status,
+ "a rule with a status of its own is not turned into a 304")
+
+ # --- a rule which says how long its copy may be held --------------------
+ t.contains(t.http(url + "/held/a.html"), "changed", "a held file is served")
+ with open(os.path.join(root, "a.html"), "w") as fp:
+ fp.write("replaced
")
+ t.not_contains(t.http(url + "/held/a.html"), "replaced",
+ "and within its max-age the disk is not looked at again")
+ t.contains(t.http(url + "/c/a.html"), "replaced",
+ "while a rule without one notices the change at once")
+
+ # --- the paths a rule may not build ------------------------------------
+ t.eq(403, t.http(url + "/rel/a.html").status,
+ "a relative target is refused")
+ t.ne(200, t.http(url + "/w/../etc/passwd").status,
+ "a request climbing out of the tree is refused")
diff --git a/tests/cases/httpsrv_keepalive.py b/tests/cases/httpsrv_keepalive.py
new file mode 100644
index 0000000..7a4862b
--- /dev/null
+++ b/tests/cases/httpsrv_keepalive.py
@@ -0,0 +1,109 @@
+"""Keep-alive: which answers may be followed by another request.
+
+The next request begins where the last answer ended, so a connection is only
+kept when the length of what was sent is known exactly and the body of the
+request was read to its end. Everything else closes, which is the safe way to
+be wrong.
+"""
+
+import os
+
+
+def run(t):
+ root = os.path.join(t.tmpdir, "ka")
+ os.makedirs(root, exist_ok=True)
+ with open(os.path.join(root, "a.html"), "w") as fp:
+ fp.write("hello
")
+
+ port = t.free_port()
+ t.start("httpsrv_keepalive", f"""
+ log
+ auth iponly
+ allow *
+ http file * /w/*.html "{root}/$1.html"
+ http reply * /ok** 200
+ http echo * /echo**
+ http data * /chunked** size=100&chunked=1
+ httpsrv -p{port}
+ """, ports=[port])
+
+ def req(path, version="1.1", extra="", body=""):
+ head = (f"GET {path} HTTP/{version}\r\nHost: t\r\n{extra}\r\n")
+ if body:
+ head = head.replace("GET", "POST", 1)
+ return head + body
+
+ def session(*requests, quiet=0.5):
+ text, closed = t.raw_session(port, "".join(requests), quiet=quiet)
+ return text, closed, text.count("HTTP/1.")
+
+ # --- what keeps the connection ---------------------------------------
+ text, closed, n = session(req("/w/a.html"), req("/ok"),
+ req("/w/a.html", extra="Connection: close\r\n"))
+ t.eq(3, n, "three 1.1 requests are answered on one connection")
+ t.eq(True, closed, "and the one asking to close ends it")
+ t.contains(text, "Connection: keep-alive", "the answers say the connection is kept")
+ t.eq(2, text.count("hello
"), "each file arrives whole")
+
+ text, closed, n = session(req("/w/a.html", version="1.0"), req("/ok", version="1.0"))
+ t.eq(1, n, "a 1.0 request without the header is answered once")
+ t.eq(True, closed, "and the connection ends")
+ t.contains(text, "Connection: close", "which the answer says")
+
+ text, closed, n = session(req("/w/a.html", version="1.0",
+ extra="Connection: keep-alive\r\n"),
+ req("/ok", version="1.0",
+ extra="Connection: close\r\n"))
+ t.eq(2, n, "a 1.0 client asking for keep-alive gets it")
+
+ # a request carrying a body: the next one begins after it
+ text, closed, n = session(req("/echo", extra="Content-Length: 5\r\n", body="hello"),
+ req("/ok", extra="Connection: close\r\n"))
+ t.eq(2, n, "a body which was read to its end leaves the stream in place")
+ t.contains(text, "content.length=5", "and the body was seen")
+
+ # --- what ends it -----------------------------------------------------
+ text, closed, n = session(req("/echo", extra="Transfer-Encoding: chunked\r\n"),
+ req("/ok"))
+ t.eq(1, n, "a request body this server cannot frame ends the connection")
+ t.eq(True, closed, "the connection is closed rather than left mid-body")
+
+ # A body longer than the server is willing to read leaves the rest of it
+ # in the stream, so the connection cannot carry another request. The send
+ # may not even finish - the server answers and closes part way through -
+ # which is the same answer from the other side.
+ big = "x" * 1500000
+ text, closed, n = session(req("/echo", extra="Content-Length: 1500000\r\n", body=big),
+ quiet=2)
+ t.eq(1, n, "a body past what the server will read is answered once")
+ t.contains(text, "Connection: close",
+ "and the answer ends the connection rather than leaving the rest to be read")
+
+ # --- answers of other shapes -----------------------------------------
+ text, closed, n = session(req("/chunked"), req("/ok", extra="Connection: close\r\n"))
+ t.eq(2, n, "a chunked answer may be followed by another request")
+
+ text, closed, n = session(req("/chunked", version="1.0"), req("/ok", version="1.0"))
+ t.eq(1, n, "but not for a client which has no chunked encoding to read")
+
+ stamp = t.http(f"http://127.0.0.1:{port}/w/a.html").header("Last-Modified")
+ text, closed, n = session(req("/w/a.html", extra=f"If-Modified-Since: {stamp}\r\n"),
+ req("/ok", extra="Connection: close\r\n"))
+ t.eq(2, n, "a 304 carries no body and the next request follows it")
+ t.contains(text, "304", "and it is a 304")
+
+ # --- the administration pages always close ---------------------------
+ aport = t.free_port()
+ t.start("httpsrv_keepalive_admin", f"""
+ log
+ auth iponly
+ allow *
+ http file * /w/*.html "{root}/$1.html"
+ admin -p{aport}
+ """, ports=[aport])
+
+ text, closed = t.raw_session(aport,
+ f"GET /w/a.html HTTP/1.1\r\nHost: t\r\n\r\nGET /C HTTP/1.1\r\nHost: t\r\n\r\n"
+ f"GET /w/a.html HTTP/1.1\r\nHost: t\r\n\r\n")
+ t.eq(2, text.count("HTTP/1."), "an administration page is the last thing on a connection")
+ t.eq(True, closed, "which the server closes, since the page states no length")
diff --git a/tests/cases/httpsrv_ops.py b/tests/cases/httpsrv_ops.py
index d257a43..f2fcf5b 100644
--- a/tests/cases/httpsrv_ops.py
+++ b/tests/cases/httpsrv_ops.py
@@ -9,9 +9,9 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
- http * /small data size=64
+ http echo * /echo**
+ http data * /data
+ http data * /small size=64
httpsrv -p{srv}
""", ports=[srv])
diff --git a/tests/cases/httpsrv_parsing.py b/tests/cases/httpsrv_parsing.py
index 72beaf4..ddf6cfe 100644
--- a/tests/cases/httpsrv_parsing.py
+++ b/tests/cases/httpsrv_parsing.py
@@ -11,8 +11,8 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /safe/* echo
+ http echo * /echo**
+ http echo * /safe/**
httpsrv -p{srv}
""", ports=[srv])
@@ -62,3 +62,28 @@ def run(t):
headers={"Content-Type": "application/x-www-form-urlencoded"})
t.contains(r, "method=POST", "POST reaches the handler")
t.contains(r, "content.length=9", "the POST content length is parsed")
+
+ # --- dollars in the configuration ------------------------------------
+ # Outside quotes a dollar begins the name of a file to include, so an
+ # argument holding one is quoted. Two dollars stand for one, which is how
+ # a dollar reaches a rule as text.
+ dsrv = t.free_port()
+ t.start("httpsrv_dollar", f"""
+ log
+ auth iponly
+ allow *
+ http redir * /old** 301 "http://example.org/x$$y/$1"
+ http redir * "pcre:^/re/([a-z]+)$" 302 "http://example.org/re/$1"
+ http echo * /**
+ httpsrv -p{dsrv}
+ """, ports=[dsrv])
+
+ durl = f"http://127.0.0.1:{dsrv}"
+ r = t.http(durl + "/old/a")
+ t.eq(301, r.status, "a rule holding a doubled dollar loads")
+ t.eq("http://example.org/x$y//a", r.header("Location"),
+ "and two dollars reach the location as one")
+ t.eq(302, t.http(durl + "/re/abc").status,
+ "a quoted regular expression keeps its anchor")
+ t.eq(200, t.http(durl + "/re/ab9").status,
+ "and the anchor is real: what it excludes falls through")
diff --git a/tests/cases/httpsrv_rules.py b/tests/cases/httpsrv_rules.py
index 6026453..61cc2e8 100644
--- a/tests/cases/httpsrv_rules.py
+++ b/tests/cases/httpsrv_rules.py
@@ -8,19 +8,25 @@ def run(t):
log
auth iponly
allow *
- http * /exact echo
- http * /pre* echo
- http * *.suffix echo
- http * *mid* echo
- http host.example.com /byhost echo
- http *.wild.example.com /bywild echo
- http * /only-first echo
+ http echo * /exact
+ http echo * /pre*
+ http echo * /deep/**
+ http echo * **.suffix
+ http echo * **mid**
+ http echo host.example.com /byhost
+ http echo *.wild.example.com /bywild
+ http echo * /only-first
+
+ http rewrite_host *.old.example ** "$1.new.example"
+ http rewrite_host "pcre:^legacy-(.*)$" ** "$1.new.example"
+ http rewrite_host * /badhost** "not a host name"
+ http echo one.new.example /**
httpsrv -p{srv}
flush
auth iponly
allow *
- http * /only-second echo
+ http echo * /only-second
httpsrv -p{srv2}
""", ports=[srv, srv2])
@@ -31,8 +37,15 @@ def run(t):
t.eq(404, t.http(url + "/exactly").status,
"an exact URL does not match a longer path")
t.eq(200, t.http(url + "/pre").status, "a prefix matches the bare prefix")
- t.eq(200, t.http(url + "/pretty/deep").status,
- "a prefix matches a longer path")
+ t.eq(200, t.http(url + "/pretty").status,
+ "a prefix matches a longer name in the same path element")
+
+ # a single star stays inside one element of the path, which is what keeps
+ # a rule from reaching into directories it did not name
+ t.eq(404, t.http(url + "/pretty/deep").status,
+ "a prefix does not cross a slash")
+ t.eq(200, t.http(url + "/deep/a/b/c").status,
+ "a double star does cross one")
t.eq(200, t.http(url + "/any.suffix").status, "a suffix matches")
t.eq(404, t.http(url + "/any.suffixx").status,
"a suffix is anchored at the end")
@@ -67,3 +80,19 @@ def run(t):
"the second service has its own rules")
t.eq(404, t.http(f"http://127.0.0.1:{srv2}/only-first").status,
"the second service does not have the earlier rules")
+
+ # --- a rule which changes the host --------------------------------
+ # The stars of the host pattern are what $1 upwards stand for here, the
+ # way the stars of the URL stand for themselves in a rewrite.
+ r = t.http(url + "/anything", headers={"Host": "one.old.example"})
+ t.eq(200, r.status, "a rewritten host reaches the rules after it")
+ t.contains(r, "host=one.new.example", "and the request carries the new name")
+
+ t.eq(200, t.http(url + "/anything", headers={"Host": "legacy-one"}).status,
+ "a regular expression names the part to keep")
+
+ t.eq(404, t.http(url + "/anything", headers={"Host": "other.example"}).status,
+ "a host no rule rewrites is left as it was")
+
+ t.eq(403, t.http(url + "/badhost", headers={"Host": "x"}).status,
+ "a rule may not build something which is not a host name")
diff --git a/tests/cases/ipv6.py b/tests/cases/ipv6.py
index 09d053c..986e1eb 100644
--- a/tests/cases/ipv6.py
+++ b/tests/cases/ipv6.py
@@ -21,8 +21,8 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
+ http echo * /echo**
+ http data * /data
httpsrv -p{origin} -i::1
# reached over IPv6, and allowed to reach IPv6
@@ -102,7 +102,7 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{v4origin}
{"".join(sections)}
""", ports=[v4origin] + list(family_ports.values()))
diff --git a/tests/cases/parent_ports.py b/tests/cases/parent_ports.py
index cf25d4b..d721c26 100644
--- a/tests/cases/parent_ports.py
+++ b/tests/cases/parent_ports.py
@@ -43,7 +43,7 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{srv}
# every outgoing connection binds inside the range
diff --git a/tests/cases/pcre.py b/tests/cases/pcre.py
index 1a80190..1e37134 100644
--- a/tests/cases/pcre.py
+++ b/tests/cases/pcre.py
@@ -29,9 +29,9 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /secret* echo
- http * /data data
+ http echo * /echo**
+ http echo * /secret**
+ http data * /data
httpsrv -p{origin}
""", ports=[origin])
@@ -88,6 +88,27 @@ def run(t):
t.eq(200, t.http(url + "/echo", proxy=p).status,
"an extension that matches nothing changes nothing")
+ # --- a regular expression where a host name is expected -----------------
+ # The same prefix works in an access rule and in an http rule, so one
+ # kind of expression is understood wherever a name can be written.
+ named = t.free_port()
+ t.start("pcre_named", f"""
+ log
+ flush
+ nserver 127.0.0.1
+ nscache 1024
+ nsrecord host1.test 127.0.0.1
+ nsrecord other.test 127.0.0.1
+ auth iponly
+ allow * * "pcre:^host[0-9]+\\.test$"
+ proxy -p{named}
+ """, ports=[named])
+
+ t.eq(200, t.http(f"http://host1.test:{origin}/echo", proxy=f"127.0.0.1:{named}").status,
+ "a destination matching the expression is allowed")
+ t.ne(200, t.http(f"http://other.test:{origin}/echo", proxy=f"127.0.0.1:{named}").status,
+ "one that does not match is refused")
+
# --- rewriting the reply ------------------------------------------------
p = proxy_with("rewrite_srv",
'pcre_rewrite srvheader dunno "text/plain" "text/rewritten"',
@@ -143,7 +164,7 @@ def run(t):
flush
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{elsewhere}
""", ports=[elsewhere])
diff --git a/tests/cases/portmap.py b/tests/cases/portmap.py
index f16413f..72be8f7 100644
--- a/tests/cases/portmap.py
+++ b/tests/cases/portmap.py
@@ -11,8 +11,8 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
+ http echo * /echo**
+ http data * /data
httpsrv -p{origin}
flush
diff --git a/tests/cases/proxy_http.py b/tests/cases/proxy_http.py
index 43b63d8..558497b 100644
--- a/tests/cases/proxy_http.py
+++ b/tests/cases/proxy_http.py
@@ -16,15 +16,15 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
+ http echo * /echo**
+ http data * /data
httpsrv -p{srv}
# a second origin, used as a destination the rules must keep out
flush
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{other}
# an open proxy
diff --git a/tests/cases/socks.py b/tests/cases/socks.py
index 16e6731..39de551 100644
--- a/tests/cases/socks.py
+++ b/tests/cases/socks.py
@@ -10,8 +10,8 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
+ http echo * /echo**
+ http data * /data
httpsrv -p{srv}
flush
diff --git a/tests/cases/ssl.py b/tests/cases/ssl.py
index b4c1d2e..c8b8731 100644
--- a/tests/cases/ssl.py
+++ b/tests/cases/ssl.py
@@ -32,7 +32,7 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{origin}
flush
@@ -76,8 +76,8 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
- http * /data data
+ http echo * /echo**
+ http data * /data
httpsrv -p{origin}
flush
@@ -129,7 +129,7 @@ def run(t):
ssl_serv
auth iponly
allow *
- http * /secret* echo
+ http echo * /secret**
httpsrv -p{origin}
""", ports=[origin])
diff --git a/tests/cases/tlspr.py b/tests/cases/tlspr.py
index d2b3aaa..6cc82eb 100644
--- a/tests/cases/tlspr.py
+++ b/tests/cases/tlspr.py
@@ -17,7 +17,7 @@ def run(t):
ssl_serv
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{origin}
flush
diff --git a/tests/cases/transparent.py b/tests/cases/transparent.py
index 7f7fcf9..19fb303 100644
--- a/tests/cases/transparent.py
+++ b/tests/cases/transparent.py
@@ -97,14 +97,14 @@ def run(t):
log
auth iponly
allow *
- http * /echo* echo
+ http echo * /echo**
httpsrv -p{origin_port} -i{ORIGIN_ADDR}
# a second server, to tell apart where a connection actually went
flush
auth iponly
allow *
- http * * data size=13
+ http data * * size=13
httpsrv -p{decoy_port} -i{DECOY_ADDR}
# a port mapper aimed at the decoy: with the destination taken from
diff --git a/tests/harness.py b/tests/harness.py
index 15107e8..11fce96 100644
--- a/tests/harness.py
+++ b/tests/harness.py
@@ -321,6 +321,42 @@ class Tester:
except OSError as exc:
return f""
+ def raw_session(self, port, request, host="127.0.0.1", quiet=0.5):
+ """Send bytes and read until the server closes or goes quiet.
+
+ Returns (text, closed). closed says the server ended the connection
+ rather than leaving it open for another request, which is the whole
+ question a keep-alive test asks.
+ """
+ if not isinstance(request, bytes):
+ request = request.encode("latin-1")
+ closed = False
+ chunks = []
+ try:
+ with socket.create_connection((host, port), self.timeout) as sock:
+ try:
+ sock.sendall(request)
+ except OSError:
+ # the server answered and closed before taking all of it,
+ # which is an answer in itself
+ closed = True
+ sock.settimeout(quiet)
+ while True:
+ try:
+ piece = sock.recv(65536)
+ except socket.timeout:
+ break # quiet: the connection is still open
+ except OSError:
+ closed = True # reset: it is not
+ break
+ if not piece:
+ closed = True
+ break
+ chunks.append(piece)
+ except OSError as exc:
+ return f"", True
+ return b"".join(chunks).decode("utf-8", "replace"), closed
+
# ---- UDP ---------------------------------------------------------
def udp_echo(self, prefix=b"echo:"):