improve webserver, add domain redirects (aliases), add tests and admin page ui to manage the config

- make builtin http handlers serve on specific domains, such as for mta-sts, so
  e.g. /.well-known/mta-sts.txt isn't served on all domains.
- add logging of a few more fields in access logging.
- small tweaks/bug fixes in webserver request handling.
- add config option for redirecting entire domains to another (common enough).
- split httpserver metric into two: one for duration until writing header (i.e.
  performance of server), another for duration until full response is sent to
  client (i.e. performance as perceived by users).
- add admin ui, a new page for managing the configs. after making changes
  and hitting "save", the changes take effect immediately. the page itself
  doesn't look very well-designed (many input fields, makes it look messy). i
  have an idea to improve it (explained in admin.html as todo) by making the
  layout look just like the config file. not urgent though.

i've already changed my websites/webapps over.

the idea of adding a webserver is to take away a (the) reason for folks to want
to complicate their mox setup by running an other webserver on the same machine.
i think the current webserver implementation can already serve most common use
cases. with a few more tweaks (feedback needed!) we should be able to get to 95%
of the use cases. the reverse proxy can take care of the remaining 5%.
nevertheless, a next step is still to change the quickstart to make it easier
for folks to run with an existing webserver, with existing tls certs/keys.
that's how this relates to issue #5.
This commit is contained in:
Mechiel Lukkien
2023-03-02 18:15:54 +01:00
parent 6706c5c84a
commit 6abee87aa3
24 changed files with 1545 additions and 144 deletions

View File

@ -76,7 +76,7 @@ func checkAccountAuth(ctx context.Context, log *mlog.Log, w http.ResponseWriter,
}
if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) {
metrics.AuthenticationRatelimitedInc("httpaccount")
http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests)
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
return ""
}

View File

@ -16,6 +16,7 @@ import (
"net"
"net/http"
"os"
"reflect"
"runtime/debug"
"sort"
"strings"
@ -31,6 +32,7 @@ import (
"github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dmarcdb"
@ -136,7 +138,7 @@ func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWri
}
if addr != nil && !mox.LimiterFailedAuth.Add(addr.IP, start, 1) {
metrics.AuthenticationRatelimitedInc("httpadmin")
http.Error(w, "http 429 - too many auth attempts", http.StatusTooManyRequests)
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
return false
}
@ -1525,3 +1527,73 @@ func (Admin) LogLevelRemove(ctx context.Context, pkg string) {
func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
return mox.Conf.Static.CheckUpdates
}
// WebserverConfig is the combination of WebDomainRedirects and WebHandlers
// from the domains.conf configuration file.
type WebserverConfig struct {
WebDNSDomainRedirects [][2]dns.Domain // From server to frontend.
WebDomainRedirects [][2]string // From frontend to server, it's not convenient to create dns.Domain in the frontend.
WebHandlers []config.WebHandler
}
// WebserverConfig returns the current webserver config
func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
conf = webserverConfig()
conf.WebDomainRedirects = nil
return conf
}
func webserverConfig() WebserverConfig {
r, l := mox.Conf.WebServer()
x := make([][2]dns.Domain, 0, len(r))
xs := make([][2]string, 0, len(r))
for k, v := range r {
x = append(x, [2]dns.Domain{k, v})
xs = append(xs, [2]string{k.Name(), v.Name()})
}
sort.Slice(x, func(i, j int) bool {
return x[i][0].ASCII < x[j][0].ASCII
})
sort.Slice(xs, func(i, j int) bool {
return xs[i][0] < xs[j][0]
})
return WebserverConfig{x, xs, l}
}
// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
// the current config, an error is returned.
func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf WebserverConfig) (savedConf WebserverConfig) {
current := webserverConfig()
webhandlersEqual := func() bool {
if len(current.WebHandlers) != len(oldConf.WebHandlers) {
return false
}
for i, wh := range current.WebHandlers {
if !wh.Equal(oldConf.WebHandlers[i]) {
return false
}
}
return true
}
if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
xcheckf(ctx, errors.New("config has changed"), "comparing old/current config")
}
// Convert to map, check that there are no duplicates here. The canonicalized
// dns.Domain are checked again for uniqueness when parsing the config before
// storing.
domainRedirects := map[string]string{}
for _, x := range newConf.WebDomainRedirects {
if _, ok := domainRedirects[x[0]]; ok {
xcheckf(ctx, errors.New("already present"), "checking redirect %s", x[0])
}
domainRedirects[x[0]] = x[1]
}
err := mox.WebserverConfigSet(ctx, domainRedirects, newConf.WebHandlers)
xcheckf(ctx, err, "saving webserver config")
savedConf = webserverConfig()
savedConf.WebDomainRedirects = nil
return savedConf
}

View File

@ -14,6 +14,9 @@ h3, h4 { font-size: 1rem; }
ul { padding-left: 1rem; }
.literal { background-color: #fdfdfd; padding: .5em 1em; border: 1px solid #eee; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 15px; tab-size: 4; }
table td, table th { padding: .2em .5em; }
table table td, table table th { padding: 0 0.1em; }
table.long >tbody >tr >td { padding: 1em .5em; }
table.long td { vertical-align: top; }
table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; }
.text { max-width: 50em; }
p { margin-bottom: 1em; max-width: 50em; }
@ -267,7 +270,8 @@ const index = async () => {
dom.div(dom.a('DNSBL status', attr({href: '#dnsbl'}))),
dom.br(),
dom.h2('Configuration'),
dom.div(dom.a('See configuration', attr({href: '#config'}))),
dom.div(dom.a('Webserver', attr({href: '#webserver'}))),
dom.div(dom.a('Files', attr({href: '#config'}))),
dom.div(dom.a('Log levels', attr({href: '#loglevels'}))),
footer,
)
@ -1561,6 +1565,541 @@ const queueList = async () => {
)
}
const webserver = async () => {
let conf = await api.WebserverConfig()
// We disable this while saving the form.
let fieldset
// Keep track of redirects. Rows are objects that hold both the DOM and allows
// retrieving the visible (modified) data to construct a config for saving.
let redirectRows = []
let redirectsTbody
let noredirect
// Similar to redirects, but for web handlers.
let handlerRows = []
let handlersTbody
let nohandler
// Make a new redirect rows, adding it to the list. The caller typically uses this
// while building the DOM, the element is added because this object has it as
// "root" field.
const redirectRow = (t) => {
const row = {}
row.root = dom.tr(
dom.td(
row.from=dom.input(attr({required: '', value: domainName(t[0])})),
),
dom.td(
row.to=dom.input(attr({required: '', value: domainName(t[1])})),
),
dom.td(
dom.button('Remove', attr({type: 'button'}), function click(e) {
redirectRows = redirectRows.filter(r => r !== row)
row.root.remove()
noredirect.style.display = redirectRows.length ? 'none' : ''
}),
),
)
// "get" is the common function to retrieve the data from an object with a root field as DOM element.
row.get = () => [row.from.value, row.to.value]
redirectRows.push(row)
return row
}
// Reusable component for managing headers. Just a table with a header key and
// value. We can remove existing rows, and add new rows, and edit existing.
const makeHeaders = (h) => {
const r = {
rows: [],
}
let tbody, norow
const headerRow = (k, v) => {
const row = {}
row.root = dom.tr(
dom.td(
row.key=dom.input(attr({required: '', value: k})),
),
dom.td(
row.value=dom.input(attr({required: '', value: v})),
),
dom.td(
dom.button('Remove', attr({type: 'button'}), function click(e) {
r.rows = r.rows.filter(x => x !== row)
row.root.remove()
norow.style.display = r.rows.length ? 'none' : ''
})
),
)
r.rows.push(row)
row.get = () => [row.key.value, row.value.value]
return row
}
r.add = dom.button('Add', attr({type: 'button'}), function click(e) {
const row = headerRow('', '')
tbody.appendChild(row.root)
norow.style.display = r.rows.length ? 'none' : ''
})
r.root = dom.table(
tbody=dom.tbody(
Object.entries(h).sort().map(t => headerRow(t[0], t[1])),
norow=dom.tr(
style({display: r.rows.length ? 'none' : ''}),
dom.td(attr({colspan: 3}), 'None added.'),
)
),
)
r.get = () => Object.fromEntries(r.rows.map(row => row.get()))
return r
}
// todo: make a mechanism to get the ../config/config.go sconf-doc struct tags
// here. So we can use them for the titles, as documentation. Instead of current
// approach of copy/pasting those texts, inevitably will get out of date.
// todo: perhaps lay these out in the same way as in the config file? will help admins mentally map between the two. will take a bit more vertical screen space, but current approach looks messy/garbled. we could use that mechanism for more parts of the configuration file. we can even show the same sconf-doc struct tags. the html admin page will then just be a glorified guided text editor!
// Make a handler row. This is more complicated, since it can be one of the three
// types (static, redirect, forward), and can change between those types.
const handlerRow = (wh) => {
// We make and remember components for headers, possibly not used.
const row = {
staticHeaders: makeHeaders((wh.WebStatic || {}).ResponseHeaders || {}),
forwardHeaders: makeHeaders((wh.WebForward || {}).ResponseHeaders || {}),
}
const makeWebStatic = () => {
const ws = wh.WebStatic || {}
row.getDetails = () => {
return {
StripPrefix: row.StripPrefix.value,
Root: row.Root.value,
ListFiles: row.ListFiles.checked,
ContinueNotFound: row.ContinueNotFound.checked,
ResponseHeaders: row.staticHeaders.get(),
}
}
return dom.table(
dom.tr(
dom.td('Type'),
dom.td(
'StripPrefix',
attr({title: 'Path to strip from the request URL before evaluating to a local path. If the requested URL path does not start with this prefix and ContinueNotFound it is considered non-matching and next WebHandlers are tried. If ContinueNotFound is not set, a file not found (404) is returned in that case.'}),
),
dom.td(
'Root',
attr({title: 'Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox.'}),
),
dom.td(
'ListFiles',
attr({title: 'If set, and a directory is requested, and no index.html is present that can be served, a file listing is returned. Results in 403 if ListFiles is not set. If a directory is requested and the URL does not end with a slash, the response is a redirect to the path with trailing slash.'}),
),
dom.td(
'ContinueNotFound',
attr({title: "If a requested URL does not exist, don't return a file not found (404) response, but consider this handler non-matching and continue attempts to serve with later WebHandlers, which may be a reverse proxy generating dynamic content, possibly even writing a static file for a next request to serve statically. If ContinueNotFound is set, HTTP requests other than GET and HEAD do not match. This mechanism can be used to implement the equivalent of 'try_files' in other webservers."}),
),
dom.td(
dom.span(
'Response headers',
attr({title: 'Headers to add to the response. Useful for cache-control, content-type, etc. By default, Content-Type headers are automatically added for recognized file types, unless added explicitly through this setting. For directory listings, a content-type header is skipped.'}),
),
' ',
row.staticHeaders.add,
),
),
dom.tr(
dom.td(
row.type=dom.select(
attr({required: ''}),
dom.option('Static', attr({selected: ''})),
dom.option('Redirect'),
dom.option('Forward'),
function change(e) {
makeType(e.target.value)
},
),
),
dom.td(
row.StripPrefix=dom.input(attr({value: ws.StripPrefix || ''})),
),
dom.td(
row.Root=dom.input(attr({required: '', placeholder: 'web/...', value: ws.Root || ''})),
),
dom.td(
row.ListFiles=dom.input(attr({type: 'checkbox'}), ws.ListFiles ? attr({checked: ''}) : []),
),
dom.td(
row.ContinueNotFound=dom.input(attr({type: 'checkbox'}), ws.ContinueNotFound ? attr({checked: ''}) : []),
),
dom.td(
row.staticHeaders,
),
)
)
}
const makeWebRedirect = () => {
const wr = wh.WebRedirect || {}
row.getDetails = () => {
return {
BaseURL: row.BaseURL.value,
OrigPathRegexp: row.OrigPathRegexp.value,
ReplacePath: row.ReplacePath.value,
StatusCode: row.StatusCode.value ? parseInt(row.StatusCode.value) : 0,
}
}
return dom.table(
dom.tr(
dom.td('Type'),
dom.td(
'BaseURL',
attr({title: 'Base URL to redirect to. The path must be empty and will be replaced, either by the request URL path, or by OrigPathRegexp/ReplacePath. Scheme, host, port and fragment stay intact, and query strings are combined. If empty, the response redirects to a different path through OrigPathRegexp and ReplacePath, which must then be set. Use a URL without scheme to redirect without changing the protocol, e.g. //newdomain/.'}),
),
dom.td(
'OrigPathRegexp',
attr({title: 'Regular expression for matching path. If set and path does not match, a 404 is returned. The HTTP path used for matching always starts with a slash.'}),
),
dom.td(
'ReplacePath',
attr({title: "Replacement path for destination URL based on OrigPathRegexp. Implemented with Go's Regexp.ReplaceAllString: $1 is replaced with the text of the first submatch, etc. If both OrigPathRegexp and ReplacePath are empty, BaseURL must be set and all paths are redirected unaltered."}),
),
dom.td(
'StatusCode',
attr({title: 'Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned.'}),
),
),
dom.tr(
dom.td(
row.type=dom.select(
attr({required: ''}),
dom.option('Static'),
dom.option('Redirect', attr({selected: ''})),
dom.option('Forward'),
function change(e) {
makeType(e.target.value)
},
),
),
dom.td(
row.BaseURL=dom.input(attr({placeholder: 'empty or https://target/path?q=1#frag or //target/...', value: wr.BaseURL || ''})),
),
dom.td(
row.OrigPathRegexp=dom.input(attr({placeholder: '^/old/(.*)', value: wr.OrigPathRegexp || ''})),
),
dom.td(
row.ReplacePath=dom.input(attr({placeholder: '/new/$1', value: wr.ReplacePath || ''})),
),
dom.td(
row.StatusCode=dom.input(style({width: '4em'}), attr({type: 'number', value: wr.StatusCode || '', min: 300, max: 399})),
),
),
)
}
const makeWebForward = () => {
const wf = wh.WebForward || {}
row.getDetails = () => {
return {
StripPath: row.StripPath.checked,
URL: row.URL.value,
ResponseHeaders: row.forwardHeaders.get(),
}
}
return dom.table(
dom.tr(
dom.td('Type'),
dom.td(
'StripPath',
attr({title: 'Strip the matching WebHandler path from the WebHandler before forwarding the request.'}),
),
dom.td(
'URL',
attr({title: "URL to forward HTTP requests to, e.g. http://127.0.0.1:8123/base. If StripPath is false the full request path is added to the URL. Host headers are sent unmodified. New X-Forwarded-{For,Host,Proto} headers are set. Any query string in the URL is ignored. Requests are made using Go's net/http.DefaultTransport that takes environment variables HTTP_PROXY and HTTPS_PROXY into account."}),
),
dom.td(
dom.span(
'Response headers',
attr({title: 'Headers to add to the response. Useful for adding security- and cache-related headers.'}),
),
' ',
row.forwardHeaders.add,
),
),
dom.tr(
dom.td(
row.type=dom.select(
attr({required: ''}),
dom.option('Static', ),
dom.option('Redirect'),
dom.option('Forward', attr({selected: ''})),
function change(e) {
makeType(e.target.value)
},
),
),
dom.td(
row.StripPath=dom.input(attr({type: 'checkbox'}), wf.StripPath || wf.StripPath === undefined ? attr({checked: ''}) : []),
),
dom.td(
row.URL=dom.input(attr({required: '', placeholder: 'http://127.0.0.1:8888', value: wf.URL || ''})),
),
dom.td(
row.forwardHeaders,
),
),
)
}
// Transform the input fields to match the type of WebHandler.
const makeType = (s) => {
let details
if (s === 'Static') {
details = makeWebStatic()
} else if (s === 'Redirect') {
details = makeWebRedirect()
} else if (s === 'Forward') {
details = makeWebForward()
} else {
throw new Error('unknown handler type')
}
row.details.replaceWith(details)
row.details = details
}
// Remove row from oindex, insert it in nindex. Both in handlerRows and in the DOM.
const moveHandler = (row, oindex, nindex) => {
row.root.remove()
handlersTbody.insertBefore(row.root, handlersTbody.children[nindex])
handlerRows.splice(oindex, 1)
handlerRows.splice(nindex, 0, row)
}
// Row that starts starts with two tables: one for the fields all WebHandlers have
// (in common). And one for the details, i.e. WebStatic, WebRedirect, WebForward.
row.root = dom.tr(
dom.td(
dom.table(
dom.tr(
dom.td('LogName', attr({title: 'Name used during logging for requests matching this handler. If empty, the index of the handler in the list is used.'})),
dom.td('Domain', attr({title: 'Request must be for this domain to match this handler.'})),
dom.td('Path Regexp', attr({title: 'Request must match this path regular expression to match this handler. Must start with with a ^.'})),
dom.td('To HTTPS', attr({title: 'Redirect plain HTTP (non-TLS) requests to HTTPS'})),
),
dom.tr(
dom.td(
row.LogName=dom.input(attr({value: wh.LogName || ''})),
),
dom.td(
row.Domain=dom.input(attr({required: '', placeholder: 'example.org', value: domainName(wh.DNSDomain)})),
),
dom.td(
row.PathRegexp=dom.input(attr({required: '', placeholder: '^/', value: wh.PathRegexp || ''})),
),
dom.td(
row.ToHTTPS=dom.input(attr({type: 'checkbox', title: 'Redirect plain HTTP (non-TLS) requests to HTTPS'}), !wh.DontRedirectPlainHTTP ? attr({checked: ''}) : []),
),
),
),
// Replaced with a call to makeType, below (and later when switching types).
row.details=dom.table(),
),
dom.td(
dom.td(
dom.button('Remove', attr({type: 'button'}), function click(e) {
handlerRows = handlerRows.filter(r => r !== row)
row.root.remove()
nohandler.style.display = handlerRows.length ? 'none' : ''
}),
' ',
// We show/hide the buttons to move when clicking the Move button.
row.moveButtons=dom.span(
style({display: 'none'}),
dom.button('↑↑', attr({type: 'button', title: 'Move to top.'}), function click(e) {
const index = handlerRows.findIndex(r => r === row)
if (index > 0) {
moveHandler(row, index, 0)
}
}),
' ',
dom.button('↑', attr({type: 'button', title: 'Move one up.'}), function click(e) {
const index = handlerRows.findIndex(r => r === row)
if (index > 0) {
moveHandler(row, index, index-1)
}
}),
' ',
dom.button('↓', attr({type: 'button', title: 'Move one down.'}), function click(e) {
const index = handlerRows.findIndex(r => r === row)
if (index+1 < handlerRows.length) {
moveHandler(row, index, index+1)
}
}),
' ',
dom.button('↓↓', attr({type: 'button', title: 'Move to bottom.'}), function click(e) {
const index = handlerRows.findIndex(r => r === row)
if (index+1 < handlerRows.length) {
moveHandler(row, index, handlerRows.length-1)
}
}),
),
),
),
)
// Final "get" that returns a WebHandler that reflects the UI.
row.get = () => {
const wh = {
LogName: row.LogName.value,
Domain: row.Domain.value,
PathRegexp: row.PathRegexp.value,
DontRedirectPlainHTTP: !row.ToHTTPS.checked,
}
const s = row.type.value
const details = row.getDetails()
if (s === 'Static') {
wh.WebStatic = details
} else if (s === 'Redirect') {
wh.WebRedirect = details
} else if (s === 'Forward') {
wh.WebForward = details
}
return wh
}
// Initialize one of the Web* types.
let s
if (wh.WebStatic) {
s = 'Static'
} else if (wh.WebRedirect) {
s = 'Redirect'
} else if (wh.WebForward) {
s = 'Forward'
}
makeType(s)
handlerRows.push(row)
return row
}
// Return webserver config to store.
const gatherConf = () => {
return {
WebDomainRedirects: redirectRows.map(row => row.get()),
WebHandlers: handlerRows.map(row => row.get()),
}
}
// Add and move buttons, both above and below the table for quick access, hence a function.
const handlerActions = () => {
return [
'Action ',
dom.button('Add', attr({type: 'button'}), function click(e) {
// New WebHandler added as WebForward. Good chance this is what the user wants. And
// it has the least fields. (;
const nwh = {
LogName: '',
DNSDomain: {ASCII: ''},
PathRegexp: '^/',
DontRedirectPlainHTTP: false,
WebForward: {
StripPath: true,
URL: '',
},
}
const row = handlerRow(nwh)
handlersTbody.appendChild(row.root)
nohandler.style.display = handlerRows.length ? 'none' : ''
}),
' ',
dom.button('Move', attr({type: 'button'}), function click(e) {
for(const row of handlerRows) {
row.moveButtons.style.display = row.moveButtons.style.display === 'none' ? '' : 'none'
}
}),
]
}
const page = document.getElementById('page')
dom._kids(page,
crumbs(
crumblink('Mox Admin', '#'),
'Webserver config',
),
dom.form(
fieldset=dom.fieldset(
dom.h2('Domain redirects', attr({title: 'Corresponds with WebDomainRedirects in domains.conf'})),
dom.p('Incoming requests for these domains are redirected to the target domain, with HTTPS.'),
dom.table(
dom.thead(
dom.tr(
dom.th('From'),
dom.th('To'),
dom.th(
'Action ',
dom.button('Add', attr({type: 'button'}), function click(e) {
const row = redirectRow([{ASCII: ''}, {ASCII: ''}])
redirectsTbody.appendChild(row.root)
noredirect.style.display = redirectRows.length ? 'none' : ''
}),
),
),
),
redirectsTbody=dom.tbody(
(conf.WebDNSDomainRedirects || []).sort().map(t => redirectRow(t)),
noredirect=dom.tr(
style({display: redirectRows.length ? 'none' : ''}),
dom.td(attr({colspan: 3}), 'No redirects.'),
),
),
),
dom.br(),
dom.h2('Handlers', attr({title: 'Corresponds with WebHandlers in domains.conf'})),
dom.p('Each incoming request is check against these handlers, in order. The first matching handler serves the request.'),
dom('table.long',
dom.thead(
dom.tr(
dom.th(),
dom.th(handlerActions()),
),
),
handlersTbody=dom.tbody(
(conf.WebHandlers || []).map(wh => handlerRow(wh)),
nohandler=dom.tr(
style({display: handlerRows.length ? 'none' : ''}),
dom.td(attr({colspan: 2}), 'No handlers.'),
),
),
dom.tfoot(
dom.tr(
dom.th(),
dom.th(handlerActions()),
),
),
),
dom.br(),
dom.button('Save', attr({type: 'submit'}), attr({title: 'Save config. If the configuration has changed since this page was loaded, an error will be returned. After saving, the changes take effect immediately.'})),
),
async function submit(e) {
e.preventDefault()
e.stopPropagation()
fieldset.disabled = true
try {
const newConf = gatherConf()
const savedConf = await api.WebserverConfigSave(conf, newConf)
conf = savedConf
} catch (err) {
console.log({err})
window.alert('Error: ' + err.message)
} finally {
fieldset.disabled = false
}
}
),
)
}
const init = async () => {
let curhash
@ -1611,6 +2150,8 @@ const init = async () => {
await mtasts()
} else if (h === 'dnsbl') {
await dnsbl()
} else if (h === 'webserver') {
await webserver()
} else {
dom._kids(page, 'page not found')
}

View File

@ -649,6 +649,45 @@
]
}
]
},
{
"Name": "WebserverConfig",
"Docs": "WebserverConfig returns the current webserver config",
"Params": [],
"Returns": [
{
"Name": "conf",
"Typewords": [
"WebserverConfig"
]
}
]
},
{
"Name": "WebserverConfigSave",
"Docs": "WebserverConfigSave saves a new webserver config. If oldConf is not equal to\nthe current config, an error is returned.",
"Params": [
{
"Name": "oldConf",
"Typewords": [
"WebserverConfig"
]
},
{
"Name": "newConf",
"Typewords": [
"WebserverConfig"
]
}
],
"Returns": [
{
"Name": "savedConf",
"Typewords": [
"WebserverConfig"
]
}
]
}
],
"Sections": [],
@ -2898,6 +2937,214 @@
]
}
]
},
{
"Name": "WebserverConfig",
"Docs": "WebserverConfig is the combination of WebDomainRedirects and WebHandlers\nfrom the domains.conf configuration file.",
"Fields": [
{
"Name": "WebDNSDomainRedirects",
"Docs": "From server to frontend.",
"Typewords": [
"[]",
"[]",
"Domain"
]
},
{
"Name": "WebDomainRedirects",
"Docs": "From frontend to server, it's not convenient to create dns.Domain in the frontend.",
"Typewords": [
"[]",
"[]",
"string"
]
},
{
"Name": "WebHandlers",
"Docs": "",
"Typewords": [
"[]",
"WebHandler"
]
}
]
},
{
"Name": "WebHandler",
"Docs": "",
"Fields": [
{
"Name": "LogName",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Domain",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "PathRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "DontRedirectPlainHTTP",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "WebStatic",
"Docs": "",
"Typewords": [
"nullable",
"WebStatic"
]
},
{
"Name": "WebRedirect",
"Docs": "",
"Typewords": [
"nullable",
"WebRedirect"
]
},
{
"Name": "WebForward",
"Docs": "",
"Typewords": [
"nullable",
"WebForward"
]
},
{
"Name": "Name",
"Docs": "Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics.",
"Typewords": [
"string"
]
},
{
"Name": "DNSDomain",
"Docs": "",
"Typewords": [
"Domain"
]
}
]
},
{
"Name": "WebStatic",
"Docs": "",
"Fields": [
{
"Name": "StripPrefix",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Root",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "ListFiles",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "ContinueNotFound",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "ResponseHeaders",
"Docs": "",
"Typewords": [
"{}",
"string"
]
}
]
},
{
"Name": "WebRedirect",
"Docs": "",
"Fields": [
{
"Name": "BaseURL",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "OrigPathRegexp",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "ReplacePath",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "StatusCode",
"Docs": "",
"Typewords": [
"int32"
]
}
]
},
{
"Name": "WebForward",
"Docs": "",
"Fields": [
{
"Name": "StripPath",
"Docs": "",
"Typewords": [
"bool"
]
},
{
"Name": "URL",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "ResponseHeaders",
"Docs": "",
"Typewords": [
"{}",
"string"
]
}
]
}
],
"Ints": [],

View File

@ -31,20 +31,41 @@ import (
var xlog = mlog.New("http")
var metricHTTPServer = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mox_httpserver_request_duration_seconds",
Help: "HTTP(s) server request with handler name, protocol, method, result codes, and duration in seconds.",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30, 60, 120},
},
[]string{
"handler", // Name from webhandler, can be empty.
"proto", // "http" or "https"
"method", // "(unknown)" and otherwise only common verbs
"code",
},
var (
// metricRequest tracks performance (time to write response header) of server.
metricRequest = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mox_httpserver_request_duration_seconds",
Help: "HTTP(s) server request with handler name, protocol, method, result codes, and duration until response status code is written, in seconds.",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30, 60, 120},
},
[]string{
"handler", // Name from webhandler, can be empty.
"proto", // "http" or "https"
"method", // "(unknown)" and otherwise only common verbs
"code",
},
)
// metricResponse tracks performance of entire request as experienced by users,
// which also depends on their connection speed, so not necessarily something you
// could act on.
metricResponse = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mox_httpserver_response_duration_seconds",
Help: "HTTP(s) server response with handler name, protocol, method, result codes, and duration of entire response, in seconds.",
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30, 60, 120},
},
[]string{
"handler", // Name from webhandler, can be empty.
"proto", // "http" or "https"
"method", // "(unknown)" and otherwise only common verbs
"code",
},
)
)
// todo: automatic gzip on responses, if client supports it, content is not already compressed. in case of static file only if it isn't too large. skip for certain response content-types (image/*, video/*), or file extensions if there is no identifying content-type. if cpu load isn't too high. if first N kb look compressible and come in quickly enough after first byte (e.g. within 100ms). always flush after 100ms to prevent stalled real-time connections.
// http.ResponseWriter that writes access log and tracks metrics at end of response.
type loggingWriter struct {
W http.ResponseWriter // Calls are forwarded.
@ -54,16 +75,34 @@ type loggingWriter struct {
Handler string // Set by router.
// Set by handlers.
Code int
Size int64
WriteErr error
StatusCode int
Size int64
WriteErr error
}
func (w *loggingWriter) Header() http.Header {
return w.W.Header()
}
func (w *loggingWriter) setStatusCode(statusCode int) {
if w.StatusCode != 0 {
return
}
w.StatusCode = statusCode
method := metricHTTPMethod(w.R.Method)
proto := "http"
if w.R.TLS != nil {
proto = "https"
}
metricRequest.WithLabelValues(w.Handler, proto, method, fmt.Sprintf("%d", w.StatusCode)).Observe(float64(time.Since(w.Start)) / float64(time.Second))
}
func (w *loggingWriter) Write(buf []byte) (int, error) {
if w.Size == 0 {
w.setStatusCode(http.StatusOK)
}
n, err := w.W.Write(buf)
if n > 0 {
w.Size += int64(n)
@ -75,9 +114,7 @@ func (w *loggingWriter) Write(buf []byte) (int, error) {
}
func (w *loggingWriter) WriteHeader(statusCode int) {
if w.Code == 0 {
w.Code = statusCode
}
w.setStatusCode(statusCode)
w.W.WriteHeader(statusCode)
}
@ -104,7 +141,7 @@ func (w *loggingWriter) Done() {
if w.R.TLS != nil {
proto = "https"
}
metricHTTPServer.WithLabelValues(w.Handler, proto, method, fmt.Sprintf("%d", w.Code)).Observe(float64(time.Since(w.Start)) / float64(time.Second))
metricResponse.WithLabelValues(w.Handler, proto, method, fmt.Sprintf("%d", w.StatusCode)).Observe(float64(time.Since(w.Start)) / float64(time.Second))
tlsinfo := "plain"
if w.R.TLS != nil {
@ -114,7 +151,21 @@ func (w *loggingWriter) Done() {
tlsinfo = "(other)"
}
}
xlog.WithContext(w.R.Context()).Debugx("http request", w.WriteErr, mlog.Field("httpaccess", ""), mlog.Field("handler", w.Handler), mlog.Field("url", w.R.URL), mlog.Field("host", w.R.Host), mlog.Field("duration", time.Since(w.Start)), mlog.Field("size", w.Size), mlog.Field("statuscode", w.Code), mlog.Field("proto", strings.ToLower(w.R.Proto)), mlog.Field("remoteaddr", w.R.RemoteAddr), mlog.Field("tlsinfo", tlsinfo))
xlog.WithContext(w.R.Context()).Debugx("http request", w.WriteErr,
mlog.Field("httpaccess", ""),
mlog.Field("handler", w.Handler),
mlog.Field("method", method),
mlog.Field("url", w.R.URL),
mlog.Field("host", w.R.Host),
mlog.Field("duration", time.Since(w.Start)),
mlog.Field("size", w.Size),
mlog.Field("statuscode", w.StatusCode),
mlog.Field("proto", strings.ToLower(w.R.Proto)),
mlog.Field("remoteaddr", w.R.RemoteAddr),
mlog.Field("tlsinfo", tlsinfo),
mlog.Field("useragent", w.R.Header.Get("User-Agent")),
mlog.Field("referrr", w.R.Header.Get("Referrer")),
)
}
// Set some http headers that should prevent potential abuse. Better safe than sorry.
@ -131,9 +182,10 @@ func safeHeaders(fn http.HandlerFunc) http.HandlerFunc {
// Built-in handlers, e.g. mta-sts and autoconfig.
type pathHandler struct {
Name string // For logging/metrics.
Path string // Path to register, like on http.ServeMux.
Fn http.HandlerFunc
Name string // For logging/metrics.
HostMatch func(dom dns.Domain) bool // If not nil, called to see if domain of requests matches. Only called if requested host is a valid domain.
Path string // Path to register, like on http.ServeMux.
Handle http.HandlerFunc
}
type serve struct {
Kinds []string // Type of handler and protocol (http/https).
@ -142,10 +194,10 @@ type serve struct {
Webserver bool // Whether serving WebHandler. PathHandlers are always evaluated before WebHandlers.
}
// HandleFunc registers a named handler for a path. If path ends with a slash, it
// is used as prefix match, otherwise a full path match is required.
func (s *serve) HandleFunc(name, path string, fn http.HandlerFunc) {
s.PathHandlers = append(s.PathHandlers, pathHandler{name, path, fn})
// HandleFunc registers a named handler for a path and optional host. If path ends with a slash, it
// is used as prefix match, otherwise a full path match is required. If hostOpt is set, only requests to those host are handled by this handler.
func (s *serve) HandleFunc(name string, hostMatch func(dns.Domain) bool, path string, fn http.HandlerFunc) {
s.PathHandlers = append(s.PathHandlers, pathHandler{name, hostMatch, path, fn})
}
var (
@ -180,10 +232,10 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
proto = "https"
}
metricHTTPServer.WithLabelValues("(ratelimited)", proto, method, "429").Observe(0)
metricRequest.WithLabelValues("(ratelimited)", proto, method, "429").Observe(0)
// No logging, that's just noise.
http.Error(xw, "http 429 - too many auth attempts", http.StatusTooManyRequests)
http.Error(xw, "429 - too many auth attempts", http.StatusTooManyRequests)
return
}
@ -210,15 +262,27 @@ func (s *serve) ServeHTTP(xw http.ResponseWriter, r *http.Request) {
r.URL.Path += "/"
}
var dom dns.Domain
host := r.Host
nhost, _, err := net.SplitHostPort(host)
if err == nil {
host = nhost
}
// host could be an IP, some handles may match, not an error.
dom, domErr := dns.ParseDomain(host)
for _, h := range s.PathHandlers {
if h.HostMatch != nil && (domErr != nil || !h.HostMatch(dom)) {
continue
}
if r.URL.Path == h.Path || strings.HasSuffix(h.Path, "/") && strings.HasPrefix(r.URL.Path, h.Path) {
nw.Handler = h.Name
h.Fn(nw, r)
h.Handle(nw, r)
return
}
}
if s.Webserver {
if WebHandle(nw, r) {
if s.Webserver && domErr == nil {
if WebHandle(nw, r, dom) {
return
}
}
@ -260,35 +324,35 @@ func Listen() {
if l.AccountHTTP.Enabled {
port := config.Port(l.AccountHTTP.Port, 80)
srv := ensureServe(false, port, "account-http")
srv.HandleFunc("account", "/", safeHeaders(accountHandle))
srv.HandleFunc("account", nil, "/", safeHeaders(accountHandle))
}
if l.AccountHTTPS.Enabled {
port := config.Port(l.AccountHTTPS.Port, 443)
srv := ensureServe(true, port, "account-https")
srv.HandleFunc("account", "/", safeHeaders(accountHandle))
srv.HandleFunc("account", nil, "/", safeHeaders(accountHandle))
}
if l.AdminHTTP.Enabled {
port := config.Port(l.AdminHTTP.Port, 80)
srv := ensureServe(false, port, "admin-http")
if !l.AccountHTTP.Enabled {
srv.HandleFunc("admin", "/", safeHeaders(adminIndex))
srv.HandleFunc("admin", nil, "/", safeHeaders(adminIndex))
}
srv.HandleFunc("admin", "/admin/", safeHeaders(adminHandle))
srv.HandleFunc("admin", nil, "/admin/", safeHeaders(adminHandle))
}
if l.AdminHTTPS.Enabled {
port := config.Port(l.AdminHTTPS.Port, 443)
srv := ensureServe(true, port, "admin-https")
if !l.AccountHTTPS.Enabled {
srv.HandleFunc("admin", "/", safeHeaders(adminIndex))
srv.HandleFunc("admin", nil, "/", safeHeaders(adminIndex))
}
srv.HandleFunc("admin", "/admin/", safeHeaders(adminHandle))
srv.HandleFunc("admin", nil, "/admin/", safeHeaders(adminHandle))
}
if l.MetricsHTTP.Enabled {
port := config.Port(l.MetricsHTTP.Port, 8010)
srv := ensureServe(false, port, "metrics-http")
srv.HandleFunc("metrics", "/metrics", safeHeaders(promhttp.Handler().ServeHTTP))
srv.HandleFunc("metrics", "/", safeHeaders(func(w http.ResponseWriter, r *http.Request) {
srv.HandleFunc("metrics", nil, "/metrics", safeHeaders(promhttp.Handler().ServeHTTP))
srv.HandleFunc("metrics", nil, "/", safeHeaders(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
@ -303,13 +367,21 @@ func Listen() {
if l.AutoconfigHTTPS.Enabled {
port := config.Port(l.AutoconfigHTTPS.Port, 443)
srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "autoconfig-https")
srv.HandleFunc("autoconfig", "/mail/config-v1.1.xml", safeHeaders(autoconfHandle(l)))
srv.HandleFunc("autodiscover", "/autodiscover/autodiscover.xml", safeHeaders(autodiscoverHandle(l)))
autoconfigMatch := func(dom dns.Domain) bool {
// todo: may want to check this against the configured domains, could in theory be just a webserver.
return strings.HasPrefix(dom.ASCII, "autoconfig.")
}
srv.HandleFunc("autoconfig", autoconfigMatch, "/mail/config-v1.1.xml", safeHeaders(autoconfHandle(l)))
srv.HandleFunc("autodiscover", autoconfigMatch, "/autodiscover/autodiscover.xml", safeHeaders(autodiscoverHandle(l)))
}
if l.MTASTSHTTPS.Enabled {
port := config.Port(l.MTASTSHTTPS.Port, 443)
srv := ensureServe(!l.AutoconfigHTTPS.NonTLS, port, "mtasts-https")
srv.HandleFunc("mtasts", "/.well-known/mta-sts.txt", safeHeaders(mtastsPolicyHandle))
mtastsMatch := func(dom dns.Domain) bool {
// todo: may want to check this against the configured domains, could in theory be just a webserver.
return strings.HasPrefix(dom.ASCII, "mta-sts.")
}
srv.HandleFunc("mtasts", mtastsMatch, "/.well-known/mta-sts.txt", safeHeaders(mtastsPolicyHandle))
}
if l.PprofHTTP.Enabled {
// Importing net/http/pprof registers handlers on the default serve mux.
@ -319,7 +391,7 @@ func Listen() {
}
srv := &serve{[]string{"pprof-http"}, nil, nil, false}
portServe[port] = srv
srv.HandleFunc("pprof", "/", http.DefaultServeMux.ServeHTTP)
srv.HandleFunc("pprof", nil, "/", http.DefaultServeMux.ServeHTTP)
}
if l.WebserverHTTP.Enabled {
port := config.Port(l.WebserverHTTP.Port, 80)
@ -381,17 +453,17 @@ func Listen() {
}
for port, srv := range portServe {
sort.Slice(srv.PathHandlers, func(i, j int) bool {
a := srv.PathHandlers[i].Path
b := srv.PathHandlers[j].Path
if len(a) == len(b) {
// For consistent order.
return a < b
}
// Longest paths first.
return len(a) > len(b)
})
for _, ip := range l.IPs {
sort.Slice(srv.PathHandlers, func(i, j int) bool {
a := srv.PathHandlers[i].Path
b := srv.PathHandlers[j].Path
if len(a) == len(b) {
// For consistent order.
return a < b
}
// Longest paths first.
return len(a) > len(b)
})
listen1(ip, port, srv.TLSConfig, name, srv.Kinds, srv)
}
}

69
http/web_test.go Normal file
View File

@ -0,0 +1,69 @@
package http
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
func TestServeHTTP(t *testing.T) {
os.RemoveAll("../testdata/web/data")
mox.ConfigStaticPath = "../testdata/web/mox.conf"
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig()
srv := &serve{
PathHandlers: []pathHandler{
{
HostMatch: func(dom dns.Domain) bool {
return strings.HasPrefix(dom.ASCII, "mta-sts.")
},
Path: "/.well-known/mta-sts.txt",
Handle: func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("mta-sts!"))
},
},
},
Webserver: true,
}
test := func(method, target string, expCode int, expContent string, expHeaders map[string]string) {
t.Helper()
req := httptest.NewRequest(method, target, nil)
rw := httptest.NewRecorder()
rw.Body = &bytes.Buffer{}
srv.ServeHTTP(rw, req)
resp := rw.Result()
if resp.StatusCode != expCode {
t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
}
if expContent != "" {
s := rw.Body.String()
if s != expContent {
t.Fatalf("got response data %q, expected %q", s, expContent)
}
}
for k, v := range expHeaders {
if xv := resp.Header.Get(k); xv != v {
t.Fatalf("got %q for header %q, expected %q", xv, k, v)
}
}
}
test("GET", "http://mta-sts.mox.example/.well-known/mta-sts.txt", http.StatusOK, "mta-sts!", nil)
test("GET", "http://mox.example/.well-known/mta-sts.txt", http.StatusNotFound, "", nil) // mta-sts endpoint not in this domain.
test("GET", "http://mta-sts.mox.example/static/", http.StatusNotFound, "", nil) // static not served on this domain.
test("GET", "http://mta-sts.mox.example/other", http.StatusNotFound, "", nil)
test("GET", "http://mox.example/static/", http.StatusOK, "html\n", map[string]string{"X-Test": "mox"}) // index.html is served
test("GET", "http://mox.example/static/index.html", http.StatusOK, "html\n", map[string]string{"X-Test": "mox"})
test("GET", "http://mox.example/static/dir/", http.StatusOK, "", map[string]string{"X-Test": "mox"}) // Dir listing.
test("GET", "http://mox.example/other", http.StatusNotFound, "", nil)
}

View File

@ -5,7 +5,6 @@ import (
htmltemplate "html/template"
"io"
golog "log"
"net"
"net/http"
"net/http/httputil"
"os"
@ -21,32 +20,28 @@ import (
"github.com/mjl-/mox/moxio"
)
// todo: automatic gzip on responses, if client supports it, and if content looks compressible.
// WebHandle serves an HTTP request by going through the list of WebHandlers,
// check if there is a domain+path match, and running the handler if so.
// WebHandle runs after the built-in handlers for mta-sts, autoconfig, etc.
// If no handler matched, false is returned.
// WebHandle sets w.Name to that of the matching handler.
func WebHandle(w *loggingWriter, r *http.Request) (handled bool) {
log := func() *mlog.Log {
return xlog.WithContext(r.Context())
}
func WebHandle(w *loggingWriter, r *http.Request, host dns.Domain) (handled bool) {
redirects, handlers := mox.Conf.WebServer()
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// Common, there often is not port.
host = r.Host
}
dom, err := dns.ParseDomain(host)
if err != nil {
log().Debugx("parsing http request domain", err, mlog.Field("host", host))
http.NotFound(w, r)
for from, to := range redirects {
if host != from {
continue
}
u := r.URL
u.Scheme = "https"
u.Host = to.Name()
w.Handler = "(domainredirect)"
http.Redirect(w, r, u.String(), http.StatusPermanentRedirect)
return true
}
for _, h := range mox.Conf.WebHandlers() {
if h.DNSDomain != dom {
for _, h := range handlers {
if host != h.DNSDomain {
continue
}
loc := h.Path.FindStringIndex(r.URL.Path)
@ -60,7 +55,7 @@ func WebHandle(w *loggingWriter, r *http.Request) (handled bool) {
if r.TLS == nil && !h.DontRedirectPlainHTTP {
u := *r.URL
u.Scheme = "https"
u.Host = host
u.Host = h.DNSDomain.Name()
w.Handler = h.Name
http.Redirect(w, r, u.String(), http.StatusPermanentRedirect)
return true
@ -138,7 +133,7 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
if cid <= 0 {
return ""
}
return " (requestid " + mox.ReceivedID(cid) + ")"
return " (id " + mox.ReceivedID(cid) + ")"
}
if r.Method != "GET" && r.Method != "HEAD" {
@ -166,6 +161,15 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
fspath = filepath.Join(h.Root, r.URL.Path)
}
serveFile := func(name string, mtime time.Time, content *os.File) {
// ServeContent only sets a content-type if not already present in the response headers.
hdr := w.Header()
for k, v := range h.ResponseHeaders {
hdr.Add(k, v)
}
http.ServeContent(w, r, name, mtime, content)
}
f, err := os.Open(fspath)
if err != nil {
if os.IsNotExist(err) {
@ -176,6 +180,22 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
http.NotFound(w, r)
return true
} else if os.IsPermission(err) {
// If we tried opening a directory, we may not have permission to read it, but
// still access files inside it (execute bit), such as index.html. So try to serve it.
index, err := os.Open(filepath.Join(fspath, "index.html"))
if err == nil {
defer index.Close()
var ifi os.FileInfo
ifi, err = index.Stat()
if err != nil {
log().Errorx("stat index.html in directory we cannot list", err, mlog.Field("url", r.URL), mlog.Field("fspath", fspath))
http.Error(w, "500 - internal server error"+recvid(), http.StatusInternalServerError)
return true
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
serveFile("index.html", ifi.ModTime(), index)
return true
}
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
}
@ -191,23 +211,21 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
http.Error(w, "500 - internal server error"+recvid(), http.StatusInternalServerError)
return true
}
// Redirect if the local path is a directory.
if fi.IsDir() && !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
return true
}
serveFile := func(name string, mtime time.Time, content *os.File) {
// ServeContent only sets a content-type if not already present in the response headers.
hdr := w.Header()
for k, v := range h.ResponseHeaders {
hdr.Add(k, v)
}
http.ServeContent(w, r, name, mtime, content)
}
if fi.IsDir() {
index, err := os.Open(filepath.Join(fspath, "index.html"))
if err != nil && os.IsPermission(err) || err != nil && os.IsNotExist(err) && !h.ListFiles {
if err != nil && os.IsPermission(err) {
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
} else if err != nil && os.IsNotExist(err) && !h.ListFiles {
if h.ContinueNotFound {
return false
}
http.Error(w, "403 - permission denied", http.StatusForbidden)
return true
} else if err == nil {
@ -216,7 +234,7 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
ifi, err = index.Stat()
if err == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
serveFile(filepath.Base(fspath), ifi.ModTime(), index)
serveFile("index.html", ifi.ModTime(), index)
return true
}
}
@ -243,11 +261,13 @@ func HandleStatic(h *config.WebStatic, w http.ResponseWriter, r *http.Request) (
mb := float64(e.Size()) / (1024 * 1024)
var size string
var sizepad bool
if mb >= 10 {
size = fmt.Sprintf("%d", int64(mb))
sizepad = true
} else {
size = fmt.Sprintf("%.2f", mb)
if !e.IsDir() {
if mb >= 10 {
size = fmt.Sprintf("%d", int64(mb))
sizepad = true
} else {
size = fmt.Sprintf("%.2f", mb)
}
}
const dateTime = "2006-01-02 15:04:05" // time.DateTime, but only since go1.20.
modified := e.ModTime().UTC().Format(dateTime)
@ -309,12 +329,12 @@ func HandleRedirect(h *config.WebRedirect, w http.ResponseWriter, r *http.Reques
u.ForceQuery = h.URL.ForceQuery
u.RawQuery = h.URL.RawQuery
u.Fragment = h.URL.Fragment
}
if r.URL.RawQuery != "" {
if u.RawQuery != "" {
u.RawQuery += "&"
if r.URL.RawQuery != "" {
if u.RawQuery != "" {
u.RawQuery += "&"
}
u.RawQuery += r.URL.RawQuery
}
u.RawQuery += r.URL.RawQuery
}
u.Path = dstpath
code := http.StatusPermanentRedirect
@ -336,7 +356,7 @@ func HandleForward(h *config.WebForward, w http.ResponseWriter, r *http.Request,
if cid <= 0 {
return ""
}
return " (requestid " + mox.ReceivedID(cid) + ")"
return " (id " + mox.ReceivedID(cid) + ")"
}
xr := *r
@ -351,8 +371,7 @@ func HandleForward(h *config.WebForward, w http.ResponseWriter, r *http.Request,
// Remove any forwarded headers passed in by client.
hdr := http.Header{}
for k, vl := range r.Header {
switch k {
case "Forwarded", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto":
if k == "Forwarded" || k == "X-Forwarded" || strings.HasPrefix(k, "X-Forwarded-") {
continue
}
hdr[k] = vl
@ -374,7 +393,11 @@ func HandleForward(h *config.WebForward, w http.ResponseWriter, r *http.Request,
proxy.ErrorLog = golog.New(mlog.ErrWriter(mlog.New("net/http/httputil").WithContext(r.Context()), mlog.LevelDebug, "reverseproxy error"), "", 0)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log().Errorx("forwarding request to backend webserver", err, mlog.Field("url", r.URL))
http.Error(w, "502 - bad gateway"+recvid(), http.StatusBadGateway)
if os.IsTimeout(err) {
http.Error(w, "504 - gateway timeout"+recvid(), http.StatusGatewayTimeout)
} else {
http.Error(w, "502 - bad gateway"+recvid(), http.StatusBadGateway)
}
}
whdr := w.Header()
for k, v := range h.ResponseHeaders {

117
http/webserver_test.go Normal file
View File

@ -0,0 +1,117 @@
package http
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mjl-/mox/mox-"
)
func TestWebserver(t *testing.T) {
os.RemoveAll("../testdata/webserver/data")
mox.ConfigStaticPath = "../testdata/webserver/mox.conf"
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig()
srv := &serve{Webserver: true}
test := func(method, target string, reqhdrs map[string]string, expCode int, expContent string, expHeaders map[string]string) {
t.Helper()
req := httptest.NewRequest(method, target, nil)
for k, v := range reqhdrs {
req.Header.Add(k, v)
}
rw := httptest.NewRecorder()
rw.Body = &bytes.Buffer{}
srv.ServeHTTP(rw, req)
resp := rw.Result()
if resp.StatusCode != expCode {
t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, expCode)
}
if expContent != "" {
s := rw.Body.String()
if s != expContent {
t.Fatalf("got response data %q, expected %q", s, expContent)
}
}
for k, v := range expHeaders {
if xv := resp.Header.Get(k); xv != v {
t.Fatalf("got %q for header %q, expected %q", xv, k, v)
}
}
}
test("GET", "http://redir.mox.example", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://mox.example/"})
test("GET", "http://mox.example/static/", nil, http.StatusOK, "", map[string]string{"X-Test": "mox"}) // index.html
test("GET", "http://mox.example/static/dir/", nil, http.StatusOK, "", map[string]string{"X-Test": "mox"}) // listing
test("GET", "http://mox.example/static/dir", nil, http.StatusTemporaryRedirect, "", map[string]string{"Location": "/static/dir/"}) // redirect to dir
test("GET", "http://mox.example/static/bogus", nil, http.StatusNotFound, "", nil)
test("GET", "http://mox.example/nolist/", nil, http.StatusOK, "", nil) // index.html
test("GET", "http://mox.example/nolist/dir/", nil, http.StatusForbidden, "", nil) // no listing
test("GET", "http://mox.example/tls/", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://mox.example/tls/"}) // redirect to tls
test("GET", "http://mox.example/baseurl/x?y=2", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "https://tls.mox.example/baseurl/x?q=1&y=2#fragment"})
test("GET", "http://mox.example/pathonly/old/x?q=2", nil, http.StatusTemporaryRedirect, "", map[string]string{"Location": "http://mox.example/pathonly/new/x?q=2"})
test("GET", "http://mox.example/baseurlpath/old/x?y=2", nil, http.StatusPermanentRedirect, "", map[string]string{"Location": "//other.mox.example/baseurlpath/new/x?q=1&y=2#fragment"})
test("GET", "http://mox.example/strip/x", nil, http.StatusBadGateway, "", nil) // no server yet
test("GET", "http://mox.example/nostrip/x", nil, http.StatusBadGateway, "", nil) // no server yet
badForwarded := map[string]string{
"Forwarded": "bad",
"X-Forwarded-For": "bad",
"X-Forwarded-Proto": "bad",
"X-Forwarded-Host": "bad",
"X-Forwarded-Ext": "bad",
}
// Server that echoes path, and forwarded request headers.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range badForwarded {
if r.Header.Get(k) == v {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
for k, vl := range r.Header {
if k == "Forwarded" || k == "X-Forwarded" || strings.HasPrefix(k, "X-Forwarded-") {
w.Header()[k] = vl
}
}
w.Write([]byte(r.URL.Path))
}))
defer server.Close()
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parsing url: %v", err)
}
serverURL.Path = "/a"
// warning: it is not normally allowed to access the dynamic config without lock. don't propagate accesses like this!
mox.Conf.Dynamic.WebHandlers[len(mox.Conf.Dynamic.WebHandlers)-2].WebForward.TargetURL = serverURL
mox.Conf.Dynamic.WebHandlers[len(mox.Conf.Dynamic.WebHandlers)-1].WebForward.TargetURL = serverURL
test("GET", "http://mox.example/strip/x", badForwarded, http.StatusOK, "/a/x", map[string]string{
"X-Test": "mox",
"X-Forwarded-For": "192.0.2.1", // IP is hardcoded in Go's src/net/http/httptest/httptest.go
"X-Forwarded-Proto": "http",
"X-Forwarded-Host": "mox.example",
"X-Forwarded-Ext": "",
})
test("GET", "http://mox.example/nostrip/x", map[string]string{"X-OK": "ok"}, http.StatusOK, "/a/nostrip/x", map[string]string{"X-Test": "mox"})
test("GET", "http://mox.example/bogus", nil, http.StatusNotFound, "", nil) // path not registered.
test("GET", "http://bogus.mox.example/static/", nil, http.StatusNotFound, "", nil) // domain not registered.
}