Standard library · Network

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
On this page
  1. Functions

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:

SettingMeaning
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 = 301Redirects. 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 = 30dSends 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 = 30sHow 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 root of 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 .env or .git, is answered with 404. .well-known is allowed.
  • Every file goes out with an ETag and a Last-Modified header. A request with a matching If-None-Match or a recent enough If-Modified-Since gets 304 and no body, and a single Range: bytes=a-b (also a- and -n) gets 206 with the part; a range outside the file gets 416. If-Range is 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 Allow header. A FastCGI location answers every method.
  • fastcgi_pass reads a TCP address only. unix:/run/php.sock is 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 uses PATH_INFO.
  • REMOTE_ADDR is 127.0.0.1 unless a fastcgi_param sets 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 tls is 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 Check or LoadText, 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): int
The index of the site that answers a host name on a port, or -1 when none does. The name is lower-cased and a trailing port or dot is ignored. An exact name wins over a wildcard, and a name nobody listed falls back to the default site of that port.
net-httpd-Match.salam
import 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")
end
Outputtrue 0 2 example.test *.example.test other.test 0 0 1 1 -1 /health /assets/ /static/ \.php$ -1 -1

LocationFor

func LocationFor(site: int, path: str): int
The index of the location that handles a path in a site, or -1 when only the site settings apply. An exact location wins, then a ^~ 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(): int
The number of sites in the loaded configuration.

The example for this item is shown with the ones above: net-httpd-Match.salam

SiteNames

func SiteNames(site: int): str
The host names of one site, separated by spaces.

The example for this item is shown with the ones above: net-httpd-Match.salam

LocationPattern

func LocationPattern(loc: int): str
The pattern of a location without its =, ^~ or ~ marker.

The example for this item is shown with the ones above: net-httpd-Match.salam

ProblemCount

func ProblemCount(): int
The number of problems found while loading the last configuration; zero means it is valid.
net-httpd-Problems.salam
import 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")
end
Outputfalse 1 bad.salam:2:5: unknown block 'sit' (did you mean 'site'?) false 1 bad.salam:3:18: port must be a number from 1 to 65535 false 1 bad.salam:2:5: root '/no/such/dir' is not a directory false 1 bad.salam:5:5: name 'a.test' is used by two sites on port 80 false 1 bad.salam:3:22: tls is not available yet false 1 bad.salam:4:24: unknown setting 'hedaer' in a location (did you mean 'header'?) false 1 bad.salam:1:1: the config has no sites false 1 bad.salam:4:9: cache must be a duration such as 30s, 10m, 12h, 30d or 1y false 1 bad.salam:5:9: alias works only with a prefix location, use root for the others

ProblemAt

func ProblemAt(i: int): str
One problem as file: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): bool
Reads a file and loads it like LoadText. A file that cannot be read is a problem too.
net-httpd-Run.salam
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
end

Check

func Check(path: str): int
Loads a file the way salam 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(): int
Starts one listener for each distinct address in the loaded configuration and then waits until Stop 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.
net-httpd-Serve.salam
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)
end
Outputtrue 200 the shop shop 200 the blog ok 302 http://localhost:19434/new/old/page 404 no such post 404

Stop

func Stop()
Makes 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): i64
Reads a length of time such as 45, 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.
net-httpd-ParseDuration.salam
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")
end
Output45 90 600 43200 2592000 1209600 31536000 -1 -1 -1

LoadText

func LoadText(text: str, file: str): bool
Loads a configuration from text. The second argument names the file in the problem messages. It returns true when there are no problems; otherwise the problems are kept for ProblemAt and nothing else changes.

The example for this item is shown with the ones above: net-httpd-Serve.salam