http server implemented, pcre support for hostnames in acls implemented

This commit is contained in:
Vladimir Dubrovin 2026-08-27 14:34:27 +03:00
parent ea4b2cc3a2
commit 9529a1dfcf
27 changed files with 2325 additions and 148 deletions

View File

@ -34,6 +34,7 @@
<li><a href="#ISFTP">How to set up an FTP proxy</a></li>
<li><a href="#TLSPR">How to set up an SNI proxy (tlspr)</a></li>
<li><a href="#DNSPR">How to set up a DNS proxy (dnspr)</a></li>
<li><a href="#HTTPSRV">How to serve pages with the built-in HTTP server (httpsrv)</a></li>
<li><a href="#SSLPLUGIN">How to set up TLS/SSL (https proxy, mTLS)</a></li>
<li><a href="#CERTIFICATES">How to create CA and certificates for SSL</a></li>
<li><a href="#PCRE">How to use PCRE filtering (regular expressions)</a></li>
@ -727,6 +728,159 @@ nscache 65536
nscache6 65536
dnspr -p53 -F10.0.0.1
</pre>
</p>
<li><a name="HTTPSRV"><i>How to serve pages with the built-in HTTP server (httpsrv)</i></a>
<p>
httpsrv answers requests itself instead of forwarding them. What it does with a
request is decided by <code>http</code> 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.
</p><pre>
http OPERATION HOST URL [PARAMETERS]
</pre>
<p>
HOST is matched against the Host header, URL against the path with the query
string removed. A minimal static site:
</p><pre>
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
</pre>
<p>
<b>Patterns.</b> <code>*</code> stands for any run of characters within one
element of the path and does not cross a <code>/</code>, so a rule cannot reach
into a directory it did not name. <code>**</code> crosses them. Each star, and
each group of a regular expression, is remembered in order: <code>$1</code>
upwards stand for them in the path or location the rule builds, and
<code>$0</code> for the whole request path. A <code>rewrite_host</code> 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 <code>pcre:</code> prefix, for the URL and
for the host alike:
</p><pre>
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"
</pre>
<p>
Outside quotes a dollar begins the name of a file to include, so an argument
holding one - a path built with <code>$1</code>, a regular expression anchored
with <code>$</code> - is written in quotes, as above. <code>$$</code> stands for
a single dollar and is not read as an include either.
</p>
<p>
<b>Operations.</b>
</p><pre>
&#35; file - send the file, using sendfile/TransmitFile where the system can
http file * /dl/** "/usr/local/web/dl/$1"
&#35; cache - read it into memory on the first request and answer from there
http cache * /css/*.css "/usr/local/web/css/$1.css"
&#35; redir - answer with a redirect, 302 unless a status is given
http redir * /old/** 301 "https://example.org/$1"
&#35; rewrite - change the path and hand the request to the rules after this one
http rewrite * /alias/** "/w/$1"
&#35; rewrite_host - the same for the host, which decides which rules match next
http rewrite_host *.old.example ** "$1.new.example"
&#35; reply - a status and nothing else
http reply * /health** 200 "X-Health: ok"
http reply * /down** 503 "Retry-After: 30"
&#35; echo, data - describe the request, or generate content of a given size
http echo * /echo**
http data * /gen** size=1048576
</pre>
<p>
<b>What a rule adds to the answer.</b> <code>file</code> and <code>cache</code>
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 <code>*</code>:
</p><pre>
http OPERATION HOST URL PATH [TYPE [MAX-AGE [HEADERS [CODE]]]]
&#35; type worked out from the name, cached by clients for an hour
http file * /img/*.png "/usr/local/web/img/$1.png" * 3600
&#35; a type of its own, and a header
http file * /api/*.json "/usr/local/web/api/$1.json" application/json * "X-Api: 1"
&#35; a file serving as the body of an error page
http file * /err/** /usr/local/web/404.html text/html * * 404
</pre>
<p>
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.
</p>
<p>
Types not known to the server are registered with
<code>http_content_type</code>, and a type named by a rule is used whatever the
name of the file says:
</p><pre>
http_content_type .webp image/webp
http_content_type wasm application/wasm
</pre>
<p>
<b>Files and dates.</b> 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
<code>.</code> or <code>..</code> as an element, a line ending or a star is
refused. On Windows a path must name a drive or a share (<code>"C:\web\$1"</code>
or <code>"\\host\share\$1"</code>). 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.
</p>
<p>
<b>Caching.</b> <code>cache</code> 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 <code>file</code> 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.
</p>
<p>
<b>A block page.</b> A service which rejects a request with a redirect can send
the client to an httpsrv running beside it:
</p><pre>
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
</pre>
<p>
<b>Connections.</b> A client asking in HTTP/1.1 gets a 1.1 answer and the
connection is kept for the next request, unless it sent
<code>Connection: close</code>; 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.
</p>
<p>
<b>Administration.</b> The <code>admin</code> service is httpsrv with the pages
of the administration interface already declared, see
<a href="#ADMIN">Administering and information analysis</a>. Rules may be added
before it in the same way, and are taken first.
</p>
</p>
<li><a name="SSLPLUGIN"><i>How to set up TLS/SSL (https proxy, mTLS)</i></a>
<p>
@ -1223,6 +1377,34 @@ pcre_extend deny * 192.168.0.1/16
<p>
<b>Note:</b> Regular expressions don't require authentication and cannot replace
authentication and/or allow/deny ACLs.
</p>
<p>
<b>Regular expressions in host names:</b> 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 <code>pcre:</code> prefix (<code>regex:</code> means the same). This
needs a build with PCRE support, the same as the <code>pcre</code> commands
above.
</p><pre>
&#35; Wildmask: a name may only be matched at its beginning and its end
deny * * *ads.example.com
&#35; Regular expression: anything PCRE can express
deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
allow * * "pcre:^(www|api)\.example\.com$"
</pre>
<p>
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
<code>$</code>, or write it as <code>$$</code>: 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.
</p>
<p>
The same prefix and the same patterns are used by the <code>http</code> command
of the built-in HTTP server, for the host a rule answers for and for the URL it
matches.
</p>
<li><A NAME="AUTH">How to limit service access</a>
<p>

View File

@ -34,6 +34,7 @@
<li><a href="#ISFTP">Как настроить FTP прокси?</a></li>
<li><a href="#TLSPR">Как настроить SNI proxy (tlspr)</a></li>
<li><a href="#DNSPR">Как настроить DNS proxy (dnspr)</a></li>
<li><a href="#HTTPSRV">Как отдавать страницы встроенным HTTP-сервером (httpsrv)</a></li>
<li><a href="#SSLPLUGIN">Как настроить TLS/SSL (https прокси, mTLS)</a></li>
<li><a href="#CERTIFICATES">Как создать CA и сертификаты для SSL</a></li>
<li><a href="#PCRE">Как использовать PCRE-фильтрацию (регулярные выражения)</a></li>
@ -738,6 +739,160 @@ dnspr -p53 -F10.0.0.1
</pre>
</p>
<li><a name="HTTPSRV"><i>Как отдавать страницы встроенным HTTP-сервером (httpsrv)</i></a>
<p>
httpsrv отвечает на запросы сам, а не пересылает их. Что делать с запросом,
определяют правила <code>http</code>, записанные перед сервисом, как и правила
доступа: запрос обрабатывает первое правило, у которого совпали и хост, и URL.
Это удобно для страницы состояния, небольшого статического сайта, страницы
блокировки для запросов, отклонённых ACL, или health check, который опрашивает
вышестоящий балансировщик.
</p><pre>
http ОПЕРАЦИЯ ХОСТ URL [ПАРАМЕТРЫ]
</pre>
<p>
ХОСТ сопоставляется с заголовком Host, URL - с путём без строки запроса.
Минимальный статический сайт:
</p><pre>
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
</pre>
<p>
<b>Шаблоны.</b> <code>*</code> означает любую последовательность символов внутри
одного элемента пути и не пересекает <code>/</code>, поэтому правило не может
попасть в каталог, который не назван в нём. <code>**</code> пересекает.
Каждая звёздочка и каждая группа регулярного выражения запоминаются по порядку:
<code>$1</code> и далее подставляют их в путь или адрес, который строит правило,
<code>$0</code> - весь путь запроса. Правило <code>rewrite_host</code>
использует звёздочки собственного шаблона хоста, поскольку переписывает именно
его. В сборке с PCRE шаблон можно записать
регулярным выражением с префиксом <code>pcre:</code> - и для URL, и для хоста:
</p><pre>
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"
</pre>
<p>
Вне кавычек доллар начинает имя включаемого файла, поэтому аргумент, содержащий
доллар - путь с <code>$1</code>, регулярное выражение с якорем <code>$</code> -
записывается в кавычках, как выше. <code>$$</code> означает один доллар и тоже
не читается как включение файла.
</p>
<p>
<b>Операции.</b>
</p><pre>
&#35; file - отдать файл, через sendfile/TransmitFile там, где система это умеет
http file * /dl/** "/usr/local/web/dl/$1"
&#35; cache - прочитать в память при первом запросе и отвечать из неё
http cache * /css/*.css "/usr/local/web/css/$1.css"
&#35; redir - ответить редиректом, 302, если код не задан
http redir * /old/** 301 "https://example.org/$1"
&#35; rewrite - изменить путь и передать запрос следующим правилам
http rewrite * /alias/** "/w/$1"
&#35; rewrite_host - то же для хоста, от которого зависит выбор следующих правил
http rewrite_host *.old.example ** "$1.new.example"
&#35; reply - только код ответа, без тела
http reply * /health** 200 "X-Health: ok"
http reply * /down** 503 "Retry-After: 30"
&#35; echo, data - описание запроса или генерация содержимого заданного размера
http echo * /echo**
http data * /gen** size=1048576
</pre>
<p>
<b>Что правило добавляет в ответ.</b> <code>file</code> и <code>cache</code>
принимают после пути тип содержимого, max-age, добавляемые заголовки и код
ответа. Любой из них можно опустить или записать как <code>*</code>:
</p><pre>
http ОПЕРАЦИЯ ХОСТ URL ПУТЬ [ТИП [MAX-AGE [ЗАГОЛОВКИ [КОД]]]]
&#35; тип определяется по имени файла, клиенты кэшируют час
http file * /img/*.png "/usr/local/web/img/$1.png" * 3600
&#35; собственный тип и заголовок
http file * /api/*.json "/usr/local/web/api/$1.json" application/json * "X-Api: 1"
&#35; файл как тело страницы ошибки
http file * /err/** /usr/local/web/404.html text/html * * 404
</pre>
<p>
ЗАГОЛОВКИ - один аргумент, содержащий целые строки заголовков, разделённые
обратной косой чертой и n - двумя символами, поскольку строка конфигурации не
может содержать конец строки. Аргумент нужно брать в кавычки, в заголовках есть
пробелы. Заголовки и max-age правила отправляются с тем кодом, который правило
задало, но не с отказом, который решил вернуть сам сервер: на запрос
отсутствующего файла 404 отвечает сервер, а не правило.
</p>
<p>
Неизвестные серверу типы регистрируются командой
<code>http_content_type</code>, а тип, названный в правиле, используется
независимо от имени файла:
</p><pre>
http_content_type .webp image/webp
http_content_type wasm application/wasm
</pre>
<p>
<b>Файлы и даты.</b> Принимается только полный путь - относительный отсчитывался
бы от того каталога, в котором оказался сервис, - а путь с элементом
<code>.</code> или <code>..</code>, концом строки или звёздочкой отвергается. В
Windows путь должен указывать диск или сетевой ресурс (<code>"C:\web\$1"</code>
или <code>"\\host\share\$1"</code>). Запрос, который декодируется в путь за
пределами дерева, отвергается раньше всего этого. Каждый ответ содержит
Last-Modified, а запрос с If-Modified-Since получает 304 без тела, если файл не
изменился.
</p>
<p>
<b>Кэширование.</b> <code>cache</code> читает файл один раз и дальше отвечает из
памяти; изменившийся на диске файл читается заново, а файл больше мегабайта
отдаётся так же, как это сделал бы <code>file</code>. При заданном MAX-AGE файл
не проверяется в течение этого времени - правило уже сообщило клиентам, что
столько файл можно считать неизменным, - и запрос стоит только копирования
наружу. Без MAX-AGE каждый запрос делает stat, и изменение подхватывается сразу.
</p>
<p>
<b>Страница блокировки.</b> Сервис, отклоняющий запрос редиректом, может
отправить клиента на httpsrv, работающий рядом:
</p><pre>
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
</pre>
<p>
<b>Соединения.</b> Клиент, обратившийся по HTTP/1.1, получает ответ 1.1, и
соединение сохраняется для следующего запроса, если он не прислал
<code>Connection: close</code>; клиенту 1.0 нужно запросить keep-alive явно.
Соединение сохраняется только тогда, когда длина ответа известна точно - это
верно для всех операций, кроме страниц администрирования, поэтому они всегда
последнее, что отдаётся в соединении. Тело запроса, которое сервер не может
дочитать до конца - присланное chunked или размером больше мегабайта, - тоже
завершает соединение.
</p>
<p>
<b>Администрирование.</b> Сервис <code>admin</code> - это httpsrv с уже
объявленными страницами интерфейса администрирования, см.
<a href="#ADMIN">Администрирование и анализ информации</a>. Правила можно
добавлять перед ним так же, и они проверяются первыми.
</p>
</p>
<li><a name="SSLPLUGIN"><i>Как настроить TLS/SSL (https прокси, mTLS)</i></a>
<p>
Начиная с версии 0.9.7 поддержка TLS/SSL встроена в 3proxy при компиляции с OpenSSL
@ -1221,6 +1376,34 @@ pcre_extend deny * 192.168.0.1/16
<p>
<b>Примечание:</b> Регулярные выражения не требуют авторизации и не могут заменить
авторизацию и/или ACL allow/deny.
</p>
<p>
<b>Регулярные выражения в именах хостов:</b> имя хоста в списке назначения
правила доступа может быть записано регулярным выражением вместо маски, для
этого используется префикс <code>pcre:</code> (<code>regex:</code> означает то
же самое). Требуется сборка с поддержкой PCRE, как и для команд
<code>pcre</code> выше.
</p><pre>
&#35; Маска: имя сопоставляется только с начала и с конца
deny * * *ads.example.com
&#35; Регулярное выражение: всё, что выразимо средствами PCRE
deny * * "pcre:^(ads|track)[0-9]*\.example\.(com|net)$"
allow * * "pcre:^(www|api)\.example\.com$"
</pre>
<p>
Перед сопоставлением имя приводится к нижнему регистру, завершающие точки
удаляются, поэтому шаблоны пишутся в нижнем регистре. Шаблон, оканчивающийся на
<code>$</code>, нужно взять в кавычки или записать как <code>$$</code>: вне
кавычек одиночный доллар начинает имя включаемого файла. Имена допустимы только
в списке назначения (список источника - адреса), и имя проверяется лишь тогда,
когда оно присутствует в запросе. Маска обходится дешевле и достаточна для
большинства правил, регулярное выражение сопоставляется на каждый запрос.
</p>
<p>
Тот же префикс и те же шаблоны использует команда <code>http</code> встроенного
HTTP-сервера - для хоста, на который отвечает правило, и для URL, который оно
сопоставляет.
</p>
<li><a name="AUTH"><i>Как ограничить доступ к службе</i></a>

View File

@ -39,7 +39,9 @@ For included file <CR> (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

View File

@ -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 <sys/resource.h>
#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();

View File

@ -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;

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -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}

View File

@ -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}

View File

@ -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("<h1>hello</h1>")
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, "<h1>hello</h1>", "with its content")
t.eq("text/html", r.header("Content-Type"), "and a type taken from the name")
t.eq(str(len("<h1>hello</h1>")), 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, "<h1>hello</h1>", "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("<h1>changed</h1>")
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, "<h1>", "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("<h1>replaced</h1>")
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")

View File

@ -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("<h1>hello</h1>")
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("<h1>hello</h1>"), "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")

View File

@ -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])

View File

@ -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")

View File

@ -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")

View File

@ -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()))

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -17,7 +17,7 @@ def run(t):
ssl_serv
auth iponly
allow *
http * /echo* echo
http echo * /echo**
httpsrv -p{origin}
flush

View File

@ -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

View File

@ -321,6 +321,42 @@ class Tester:
except OSError as exc:
return f"<no reply: {exc}>"
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"<no reply: {exc}>", True
return b"".join(chunks).decode("utf-8", "replace"), closed
# ---- UDP ---------------------------------------------------------
def udp_echo(self, prefix=b"echo:"):