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