net/httpd
A web server driven by a config file: several sites on one port, locations, redirects, caching, static files and FastCGI backends such as PHP.
import net.httpd
This package runs several websites from one process and one set of ports, the job people give to nginx in front of their programs. You describe the sites in a config file, load it, and call Run. The command salam serve --config=web.salam does exactly that, and --test checks the file without starting anything.
The config is written in the same block style as the rest of Salam: a setting is name = value, a block opens with name: and closes with end, comments start with //, and a setting and its value stay on one line. Strings are in double quotes, or in backticks when they hold backslashes, like a regular expression.
server:
log: access = "/var/log/salam/access.log" end
site:
listen = 80
names = "example.com www.example.com"
root = "/srv/example/public"
index = "index.html"
error page = 404 "404.html"
location "/assets/": cache = 30d end
location "= /health": return = 200 "ok" end
location "/old/": redirect = "https://example.com$uri" code = 301 end
end
site:
names = "blog.example.com *.blog.example.com"
root = "/srv/blog"
end
end
Two sites on the same port are told apart by the Host header. An exact name wins over a *.example.com wildcard, and a request for a name nobody listed goes to the site marked default = true, or else to the first site on that port. A site that does not say listen listens on port 80; listen = "127.0.0.1:8080" picks the address too.
Inside a site, the request path is matched against its location blocks the way nginx does it:
location "= /health": this exact path, and nothing else is tried.location "/assets/": any path starting with it; the longest prefix is remembered.location "^~ /static/": a prefix that also stops the regular expressions from being tried.location "~ \.php$"and"~* \.png$": a regular expression (the second ignores case), tried in file order after the prefixes; the first hit wins, and otherwise the longest prefix is used.
The settings of a site, and of a location unless noted:
| Setting | Meaning |
|---|---|
listen = 80 (site) | The port, optionally with an address: listen = "0.0.0.0:8080". It can be repeated. |
names = "a.com b.com" (site) | The host names of the site; *.a.com matches any subdomain. |
default = true (site) | This site answers names no site claims. |
root = "/srv/site" | The folder the request path is appended to. A location with its own root replaces the site's. |
alias = "/srv/files" (prefix location) | The folder that replaces the matched prefix: with location "/dl/": alias = "/srv/files" end, /dl/a.zip is /srv/files/a.zip. |
index = "index.html index.htm" | The files tried for a folder, in order. The default is index.html. |
return = 200 "ok" | Answers at once with a status and an optional text. |
redirect = "https://$host$request_uri", code = 301 | Redirects. The code defaults to 301. The variables are $host, $uri, $args, $request_uri and $scheme. At the site level it applies to every request, before any location. |
cache = 30d | Sends Cache-Control: public, max-age=... with the files. Units: s, m, h, d, w, y. |
header = "Name" "value" | Adds a response header. Site headers go on every answer, location headers only on that location's. |
error page = 404 "404.html" | The file, relative to the root, sent with that error status. |
fastcgi_pass = "127.0.0.1:9000" | Hands the matched requests to a FastCGI backend, such as php-fpm. |
fastcgi_index = "index.php" | The script used when the path ends in /. The default is index.php. |
fastcgi_timeout = 30s | How long one backend request may take. The default is 30 seconds. |
fastcgi_param = "NAME" "value" | One more CGI variable, with $uri style variables. It can be repeated. |
A location can hand the request to a FastCGI backend instead of reading a file, which is how PHP runs behind Salam:
server:
site:
listen = 8080
names = "localhost"
root = "/srv/app/public"
location "~ \.php$":
fastcgi_pass = "127.0.0.1:9000"
fastcgi_index = "index.php"
fastcgi_param = "APP_ENV" "production"
end
location "/":
fastcgi_pass = "127.0.0.1:9000"
end
end
end
fastcgi_pass is the TCP address of php-fpm, or of php-cgi -b 127.0.0.1:9000. The script is root plus the request path; a path ending in / gets fastcgi_index, and a path with segments after an existing script is split into SCRIPT_NAME and PATH_INFO, so /index.php/a/b runs index.php with PATH_INFO=/a/b. A path that names no file answers 404.
Salam fills the usual CGI variables: GATEWAY_INTERFACE, SERVER_SOFTWARE, SERVER_PROTOCOL, REQUEST_METHOD, REQUEST_URI, SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_ROOT, DOCUMENT_URI, QUERY_STRING, CONTENT_TYPE, CONTENT_LENGTH, SERVER_NAME, SERVER_PORT, REMOTE_ADDR, REDIRECT_STATUS, and HTTP_* for Host, User-Agent, Accept, Accept-Language, Accept-Encoding, Cookie, Referer, Authorization, Origin, Cache-Control, If-None-Match, If-Modified-Since, Range, X-Requested-With, X-Forwarded-For, X-Forwarded-Proto and X-Real-IP. fastcgi_param adds or overrides one more and understands $host, $uri, $document_uri, $request_uri, $args, $query_string, $document_root, $root, $script_name, $server_port, $remote_addr and $scheme. A value of $header:Name copies that request header into the variable, which is how a header Salam does not forward by default reaches PHP.
The answer is whatever the backend sends: the Status: line sets the status, every other header goes out as it is (several Set-Cookie headers included), and the body is passed through byte for byte. A backend that cannot be reached, or that dies halfway, answers 502.
The log: block takes access = "path", where the path can also be "stdout" or "stderr". Each line has the time, the host, the method, the path and the status.
The things people get wrong:
- Files are only read from the
rootof the matched site. The path is cleaned first, so..cannot climb out of it, and any file or folder whose name starts with a dot, such as.envor.git, is answered with 404..well-knownis allowed. - Every file goes out with an
ETagand aLast-Modifiedheader. A request with a matchingIf-None-Matchor a recent enoughIf-Modified-Sincegets 304 and no body, and a singleRange: bytes=a-b(alsoa-and-n) gets 206 with the part; a range outside the file gets 416.If-Rangeis honoured with the ETag. Several ranges in one request are answered with the whole file. - Only GET and HEAD are answered for files; anything else gets 405 with an
Allowheader. A FastCGI location answers every method. fastcgi_passreads a TCP address only.unix:/run/php.sockis refused with a message, so point php-fpm at a port (listen = 127.0.0.1:9000).- The location must match the script path.
location "~ \.php$"does not match/index.php/a/b, so a prefix location is needed when the application usesPATH_INFO. REMOTE_ADDRis 127.0.0.1 unless afastcgi_paramsets it, because the HTTP layer does not expose the peer address. Each request opens its own connection to the backend.- Names and values are checked when the file is loaded: a misspelled setting, a port out of range, a missing root folder, a bad regular expression or two sites with the same name on one port is reported with its line and column, and a close name is suggested. Nothing is started from a file with problems.
listen = 443 tlsis read but refused for now; HTTPS arrives in a later release, so put a TLS terminator in front until then.- The package keeps one loaded configuration for the process. Loading again replaces it, but a running server keeps the sites it started with.
- The settings, the sites and the locations are plain data, so a config that loads is a config that runs; everything that can be wrong about the file is found by
CheckorLoadText, not on the first request.
Related packages: net/http is the server underneath, net/router holds the routes, net/httpd/conf is the parser that reads the file, and net/fastcgi speaks the FastCGI protocol.
Functions
SiteFor
func SiteFor(host: str, port: int): intimport net.httpd
func main:
text := `
server:
site:
listen = 8080
names = "example.test *.example.test"
location "/assets/": cache = 30d end
location "= /health": return = 200 "ok" end
location "^~ /static/": cache = 1h end
location "~ \.php$": return = 403 "no" end
end
site:
listen = 8080
names = "other.test"
default = true
end
end
`
println httpd.LoadText(text, "match.salam"), httpd.ProblemCount()
println httpd.SiteCount(), httpd.SiteNames(0), httpd.SiteNames(1)
println httpd.SiteFor("example.test", 8080), httpd.SiteFor("API.Example.Test:8080", 8080), httpd.SiteFor("other.test.", 8080)
println httpd.SiteFor("unknown.test", 8080), httpd.SiteFor("example.test", 9090)
println httpd.LocationPattern(httpd.LocationFor(0, "/health"))
println httpd.LocationPattern(httpd.LocationFor(0, "/assets/logo.png"))
println httpd.LocationPattern(httpd.LocationFor(0, "/static/a.php"))
println httpd.LocationPattern(httpd.LocationFor(0, "/x/index.php"))
println httpd.LocationFor(0, "/plain"), httpd.LocationFor(1, "/anything")
endLocationFor
func LocationFor(site: int, path: str): int^~ prefix, then the first matching regular expression, then the longest prefix.The example for this item is shown with the ones above: net-httpd-Match.salam
SiteCount
func SiteCount(): intThe example for this item is shown with the ones above: net-httpd-Match.salam
SiteNames
func SiteNames(site: int): strThe example for this item is shown with the ones above: net-httpd-Match.salam
LocationPattern
func LocationPattern(loc: int): str=, ^~ or ~ marker.The example for this item is shown with the ones above: net-httpd-Match.salam
ProblemCount
func ProblemCount(): intimport net.httpd
func attempt(text: str):
ok := httpd.LoadText(text, "bad.salam")
println ok, httpd.ProblemCount()
repeat httpd.ProblemCount() in i:
println httpd.ProblemAt(i)
end
end
func main:
attempt("server:\n sit:\n end\nend\n")
attempt("server:\n site:\n listen = 70000\n end\nend\n")
attempt("server:\n site:\n names = \"a.test\"\n root = \"/no/such/dir\"\n end\nend\n")
attempt("server:\n site:\n names = \"a.test\"\n end\n site:\n names = \"a.test\"\n end\nend\n")
attempt("server:\n site:\n listen = 443 tls\n end\nend\n")
attempt("server:\n site:\n names = \"a.test\"\n location \"/x\": hedaer = \"A\" \"b\" end\n end\nend\n")
attempt("server:\nend\n")
attempt("server:\n site:\n names = \"a.test\"\n cache = soon\n end\nend\n")
attempt("server:\n site:\n names = \"a.test\"\n location \"/x\": alias = \"/tmp\" end\n location \"~ x\": alias = \"/tmp\" end\n end\nend\n")
endProblemAt
func ProblemAt(i: int): strfile:line:column: message, counting from 0.The example for this item is shown with the ones above: net-httpd-Problems.salam
LoadFile
func LoadFile(path: str): boolLoadText. A file that cannot be read is a problem too.import os
import net.httpd
func main:
mut path := "web.salam"
args := os.Args()
if args.len() > 1:
path = args[1]
end
if httpd.Check(path) != 0:
ret 1
end
if not httpd.LoadFile(path):
ret 2
end
code := httpd.Run()
httpd.Stop()
ret code
endCheck
func Check(path: str): intsalam serve --config=FILE --test does: it prints the problems to the error output and returns 1, or prints a one line summary and returns 0.The example for this item is shown with the ones above: net-httpd-Run.salam
Run
func Run(): intStop is called. It returns 2 at once when no valid configuration is loaded, when the log cannot be opened or when an address cannot be bound.import io
import os
import time
import str
import net.http
import net.httpd
func serve():
httpd.Run()
end
func get(host: str, path: str): http.Response:
r := http.Do("GET", "http://" + host + ":19434" + path, http.NewHeaders(), "", http.RequestOptions {})
ret r
end
func main:
root := os.TempDir() + "/salam_httpd_serve"
os.RemoveAll(root)
os.MkdirAll(root + "/shop")
os.MkdirAll(root + "/blog")
io.WriteFile(root + "/shop/index.html", "the shop")
io.WriteFile(root + "/blog/index.html", "the blog")
io.WriteFile(root + "/blog/404.html", "no such post")
text := `
server:
site:
listen = 19434
names = "localhost"
root = "@ROOT@/shop"
header = "X-Site" "shop"
location "= /health": return = 200 "ok" end
location "/old/": redirect = "http://$host/new$uri" code = 302 end
end
site:
listen = 19434
names = "127.0.0.1"
root = "@ROOT@/blog"
error page = 404 "404.html"
end
end
`
println httpd.LoadText(str.Replace(text, "@ROOT@", root), "serve.salam")
t := spawn(serve)
time.Sleep(300)
a := get("localhost", "/")
println a.status, a.body, http.GetHeader(a, "x-site")
b := get("127.0.0.1", "/")
println b.status, b.body
println get("localhost", "/health").body
c := get("localhost", "/old/page")
println c.status, http.GetHeader(c, "location")
d := get("127.0.0.1", "/missing")
println d.status, d.body
println get("localhost", "/.git/config").status
httpd.Stop()
join(t)
os.RemoveAll(root)
endStop
func Stop()Run return. Connections already accepted are not drained.The example for this item is shown with the ones above: net-httpd-Serve.salam
ParseDuration
func ParseDuration(text: str): i6445, 10m or 30d and returns it in seconds, or -1 when it is not valid. The units are s, m, h, d, w and y (365 days); no unit means seconds.import net.httpd
func main:
println httpd.ParseDuration("45")
println httpd.ParseDuration("90s")
println httpd.ParseDuration("10m")
println httpd.ParseDuration("12h")
println httpd.ParseDuration("30d")
println httpd.ParseDuration("2w")
println httpd.ParseDuration("1y")
println httpd.ParseDuration("")
println httpd.ParseDuration("abc")
println httpd.ParseDuration("5x")
endLoadText
func LoadText(text: str, file: str): boolProblemAt and nothing else changes.The example for this item is shown with the ones above: net-httpd-Serve.salam