This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

114
http/account.go Normal file
View File

@ -0,0 +1,114 @@
package http
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
_ "embed"
"github.com/mjl-/sherpa"
"github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/store"
)
//go:embed accountapi.json
var accountapiJSON []byte
//go:embed account.html
var accountHTML []byte
var accountDoc = mustParseAPI(accountapiJSON)
var accountSherpaHandler http.Handler
func init() {
collector, err := sherpaprom.NewCollector("moxaccount", nil)
if err != nil {
xlog.Fatalx("creating sherpa prometheus collector", err)
}
accountSherpaHandler, err = sherpa.NewHandler("/account/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
if err != nil {
xlog.Fatalx("sherpa handler", err)
}
}
// Account exports web API functions for the account web interface. All its
// methods are exported under /account/api/. Function calls require valid HTTP
// Authentication credentials of a user.
type Account struct{}
func accountHandle(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
log := xlog.WithContext(ctx).Fields(mlog.Field("userauth", ""))
var accountName string
authResult := "error"
defer func() {
metrics.AuthenticationInc("httpaccount", "httpbasic", authResult)
}()
// todo: should probably add a cache here instead of looking up password in database all the time, just like in admin.go
if auth := r.Header.Get("Authorization"); auth == "" || !strings.HasPrefix(auth, "Basic ") {
} else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil {
log.Infox("parsing base64", err)
} else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 {
log.Info("bad user:pass form")
} else if acc, err := store.OpenEmailAuth(t[0], t[1]); err != nil {
if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds"
}
log.Infox("open account", err)
} else {
accountName = acc.Name
authResult = "ok"
}
if accountName == "" {
w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with email address and password"`)
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "http 401 - unauthorized - mox account - login with email address and password")
return
}
if r.Method == "GET" && r.URL.Path == "/account/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache; max-age=0")
f, err := os.Open("http/account.html")
if err == nil {
defer f.Close()
io.Copy(w, f)
} else {
w.Write(accountHTML)
}
return
}
accountSherpaHandler.ServeHTTP(w, r.WithContext(context.WithValue(ctx, authCtxKey, accountName)))
}
type ctxKey string
var authCtxKey ctxKey = "account"
// SetPassword saves a new password for the account, invalidating the previous password.
// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
// Password must be at least 8 characters.
func (Account) SetPassword(ctx context.Context, password string) {
if len(password) < 8 {
panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
}
accountName := ctx.Value(authCtxKey).(string)
acc, err := store.OpenAccount(accountName)
xcheckf(ctx, err, "open account")
defer acc.Close()
err = acc.SetPassword(password)
xcheckf(ctx, err, "setting password")
}

214
http/account.html Normal file
View File

@ -0,0 +1,214 @@
<!doctype html>
<html>
<head>
<title>Mox Account</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body, html { padding: 1em; font-size: 16px; }
* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3, h4 { margin-bottom: 1ex; }
h1 { font-size: 1.2rem; }
h2 { font-size: 1.1rem; }
h3, h4 { font-size: 1rem; }
.literal { background-color: #fdfdfd; padding: .5em 1em; border: 1px solid #eee; border-radius: 4px; white-space: pre-wrap; font-family: mono; font-size: 15px; tab-size: 4; }
table td, table th { padding: .2em .5em; }
table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; }
p { margin-bottom: 1em; max-width: 50em; }
[title] { text-decoration: underline; text-decoration-style: dotted; }
fieldset { border: 0; }
#page { opacity: 1; animation: fadein 0.15s ease-in; }
#page.loading { opacity: 0.1; animation: fadeout 1s ease-out; }
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
</style>
<script src="api/sherpa.js"></script>
</head>
<body>
<div id="page">Loading...</div>
<script>
const [dom, style, attr, prop] = (function() {
function _domKids(e, ...kl) {
kl.forEach(k => {
if (typeof k === 'string' || k instanceof String) {
e.appendChild(document.createTextNode(k))
} else if (k instanceof Node) {
e.appendChild(k)
} else if (Array.isArray(k)) {
_domKids(e, ...k)
} else if (typeof k === 'function') {
if (!k.name) {
throw new Error('function without name', k)
}
e.addEventListener(k.name, k)
} else if (typeof k === 'object' && k !== null) {
if (k.root) {
e.appendChild(k.root)
return
}
for (const key in k) {
const value = k[key]
if (key === '_prop') {
for (const prop in value) {
e[prop] = value[prop]
}
} else if (key === '_attr') {
for (const prop in value) {
e.setAttribute(prop, value[prop])
}
} else if (key === '_listen') {
e.addEventListener(...value)
} else {
e.style[key] = value
}
}
} else {
console.log('bad kid', k)
throw new Error('bad kid')
}
})
}
const _dom = (kind, ...kl) => {
const t = kind.split('.')
const e = document.createElement(t[0])
for (let i = 1; i < t.length; i++) {
e.classList.add(t[i])
}
_domKids(e, kl)
return e
}
_dom._kids = function(e, ...kl) {
while(e.firstChild) {
e.removeChild(e.firstChild)
}
_domKids(e, kl)
}
const dom = new Proxy(_dom, {
get: function(dom, prop) {
if (prop in dom) {
return dom[prop]
}
const fn = (...kl) => _dom(prop, kl)
dom[prop] = fn
return fn
},
apply: function(target, that, args) {
if (args.length === 1 && typeof args[0] === 'object' && !Array.isArray(args[0])) {
return {_attr: args[0]}
}
return _dom(...args)
},
})
const style = x => x
const attr = x => { return {_attr: x} }
const prop = x => { return {_prop: x} }
return [dom, style, attr, prop]
})()
const tr = dom.tr
const td = dom.td
const th = dom.th
const crumblink = (text, link) => dom.a(text, attr({href: link}))
const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]
const footer = dom.div(
style({marginTop: '6ex', opacity: 0.75}),
dom.a(attr({href: 'https://github.com/mjl-/mox'}), 'mox'),
' ',
api._sherpa.version,
)
const index = async () => {
let form, fieldset, password1, password2
const blockStyle = style({
display: 'block',
marginBottom: '1ex',
})
const page = document.getElementById('page')
dom._kids(page,
crumbs('Mox Account'),
dom.h2('Change password'),
form=dom.form(
fieldset=dom.fieldset(
dom.label(
style({display: 'inline-block'}),
'New password',
dom.br(),
password1=dom.input(attr({type: 'password', required: ''})),
),
' ',
dom.label(
style({display: 'inline-block'}),
'New password repeat',
dom.br(),
password2=dom.input(attr({type: 'password', required: ''})),
),
' ',
dom.button('Change password'),
),
async function submit(e) {
e.stopPropagation()
e.preventDefault()
if (!password1.value || password1.value !== password2.value) {
window.alert('Passwords do not match.')
return
}
fieldset.disabled = true
try {
await api.SetPassword(password1.value)
window.alert('Password has been changed.')
form.reset()
} catch (err) {
console.log('error', err)
window.alert('Error: ' + err.message)
} finally {
fieldset.disabled = false
}
},
),
footer,
)
}
const init = async () => {
let curhash
const page = document.getElementById('page')
const hashChange = async () => {
if (curhash === window.location.hash) {
return
}
let h = window.location.hash
if (h !== '' && h.substring(0, 1) == '#') {
h = h.substring(1)
}
page.classList.add('loading')
try {
if (h == '') {
await index()
} else {
dom._kids(page, 'page not found')
}
} catch (err) {
console.log('error', err)
window.alert('Error: ' + err.message)
window.location.hash = curhash
curhash = window.location.hash
return
}
curhash = window.location.hash
page.classList.remove('loading')
}
window.addEventListener('hashchange', hashChange)
hashChange()
}
window.addEventListener('load', init)
</script>
</body>
</html>

3
http/account_test.go Normal file
View File

@ -0,0 +1,3 @@
package http
// todo: write test for account api calls, at least for authentation and SetPassword.

25
http/accountapi.json Normal file
View File

@ -0,0 +1,25 @@
{
"Name": "Account",
"Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under /account/api/. Function calls require valid HTTP\nAuthentication credentials of a user.",
"Functions": [
{
"Name": "SetPassword",
"Docs": "SetPassword saves a new password for the account, invalidating the previous password.\nSessions are not interrupted, and will keep working. New login attempts must use the new password.\nPassword must be at least 8 characters.",
"Params": [
{
"Name": "password",
"Typewords": [
"string"
]
}
],
"Returns": []
}
],
"Sections": [],
"Structs": [],
"Ints": [],
"Strings": [],
"SherpaVersion": 0,
"SherpadocVersion": 1
}

1382
http/admin.go Normal file

File diff suppressed because it is too large Load Diff

1480
http/admin.html Normal file

File diff suppressed because it is too large Load Diff

123
http/admin_test.go Normal file
View File

@ -0,0 +1,123 @@
package http
import (
"context"
"crypto/ed25519"
"net"
"os"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
func TestAdminAuth(t *testing.T) {
test := func(passwordfile, authHdr string, expect bool) {
t.Helper()
ok := checkAdminAuth(context.Background(), passwordfile, authHdr)
if ok != expect {
t.Fatalf("got %v, expected %v", ok, expect)
}
}
const authOK = "Basic YWRtaW46bW94dGVzdDEyMw==" // admin:moxtest123
const authBad = "Basic YWRtaW46YmFkcGFzc3dvcmQ=" // admin:badpassword
const path = "../testdata/http-passwordfile"
os.Remove(path)
defer os.Remove(path)
test(path, authOK, false) // Password file does not exist.
adminpwhash, err := bcrypt.GenerateFromPassword([]byte("moxtest123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
if err := os.WriteFile(path, adminpwhash, 0660); err != nil {
t.Fatalf("write password file: %v", err)
}
// We loop to also exercise the auth cache.
for i := 0; i < 2; i++ {
test(path, "", false) // Empty/missing header.
test(path, "Malformed ", false) // Not "Basic"
test(path, "Basic malformed ", false) // Bad base64.
test(path, "Basic dGVzdA== ", false) // base64 is ok, but wrong tokens inside.
test(path, authBad, false) // Wrong password.
test(path, authOK, true)
}
}
func TestCheckDomain(t *testing.T) {
// NOTE: we aren't currently looking at the results, having the code paths executed is better than nothing.
resolver := dns.MockResolver{
MX: map[string][]*net.MX{
"mox.example.": {{Host: "mail.mox.example.", Pref: 10}},
},
A: map[string][]string{
"mail.mox.example.": {"127.0.0.2"},
},
AAAA: map[string][]string{
"mail.mox.example.": {"127.0.0.2"},
},
TXT: map[string][]string{
"mox.example.": {"v=spf1 mx -all"},
"test._domainkey.mox.example.": {"v=DKIM1;h=sha256;k=ed25519;p=ln5zd/JEX4Jy60WAhUOv33IYm2YZMyTQAdr9stML504="},
"_dmarc.mox.example.": {"v=DMARC1; p=reject; rua=mailto:mjl@mox.example"},
"_smtp._tls.mox.example": {"v=TLSRPTv1; rua=mailto:tlsrpt@mox.example;"},
"_mta-sts.mox.example": {"v=STSv1; id=20160831085700Z"},
},
CNAME: map[string]string{},
}
listener := config.Listener{
IPs: []string{"127.0.0.2"},
Hostname: "mox.example",
HostnameDomain: dns.Domain{ASCII: "mox.example"},
}
listener.SMTP.Enabled = true
listener.AutoconfigHTTPS.Enabled = true
listener.MTASTSHTTPS.Enabled = true
mox.Conf.Static.Listeners = map[string]config.Listener{
"public": listener,
}
domain := config.Domain{
DKIM: config.DKIM{
Selectors: map[string]config.Selector{
"test": {
HashEffective: "sha256",
HeadersEffective: []string{"From", "Date", "Subject"},
Key: ed25519.NewKeyFromSeed(make([]byte, 32)), // warning: fake zero key, do not copy this code.
Domain: dns.Domain{ASCII: "test"},
},
"missing": {
HashEffective: "sha256",
HeadersEffective: []string{"From", "Date", "Subject"},
Key: ed25519.NewKeyFromSeed(make([]byte, 32)), // warning: fake zero key, do not copy this code.
Domain: dns.Domain{ASCII: "missing"},
},
},
Sign: []string{"test", "test2"},
},
}
mox.Conf.Dynamic.Domains = map[string]config.Domain{
"mox.example": domain,
}
// Make a dialer that fails immediately before actually connecting.
done := make(chan struct{})
close(done)
dialer := &net.Dialer{Deadline: time.Now().Add(-time.Second), Cancel: done}
checkDomain(context.Background(), resolver, dialer, "mox.example")
// todo: check returned data
Admin{}.Domains(context.Background()) // todo: check results
dnsblsStatus(context.Background(), resolver) // todo: check results
}

3104
http/adminapi.json Normal file

File diff suppressed because it is too large Load Diff

344
http/autoconf.go Normal file
View File

@ -0,0 +1,344 @@
package http
import (
"encoding/xml"
"fmt"
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
)
var (
metricAutoconf = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_autoconf_request_total",
Help: "Number of autoconf requests.",
},
[]string{"domain"},
)
metricAutodiscover = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_autodiscover_request_total",
Help: "Number of autodiscover requests.",
},
[]string{"domain"},
)
)
// Autoconfiguration/Autodiscovery:
//
// - Thunderbird will request an "autoconfig" xml file.
// - Microsoft tools will request an "autodiscovery" xml file.
// - In my tests on an internal domain, iOS mail only talks to Apple servers, then
// does not attempt autoconfiguration. Possibly due to them being private DNS names.
//
// DNS records seem optional, but autoconfig.<domain> and autodiscover.<domain>
// (both CNAME or A) are useful, and so is SRV _autodiscovery._tcp.<domain> 0 0 443
// autodiscover.<domain> (or just <hostname> directly).
//
// Autoconf/discovery only works with valid TLS certificates, not with self-signed
// certs. So use it on public endpoints with certs signed by common CA's, or run
// your own (internal) CA and import the CA cert on your devices.
//
// Also see https://roll.urown.net/server/mail/autoconfig.html
// Autoconfiguration for Mozilla Thunderbird.
// User should create a DNS record: autoconfig.<domain> (CNAME or A).
// See https://wiki.mozilla.org/Thunderbird:Autoconfiguration:ConfigFileFormat
func autoconfHandle(l config.Listener) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := xlog.WithContext(r.Context())
var addrDom string
defer func() {
metricAutoconf.WithLabelValues(addrDom).Inc()
}()
email := r.FormValue("emailaddress")
log.Debug("autoconfig request", mlog.Field("email", email))
addr, err := smtp.ParseAddress(email)
if err != nil {
http.Error(w, "400 - bad request - invalid parameter emailaddress", http.StatusBadRequest)
return
}
if _, ok := mox.Conf.Domain(addr.Domain); !ok {
http.Error(w, "400 - bad request - unknown domain", http.StatusBadRequest)
return
}
addrDom = addr.Domain.Name()
hostname := l.HostnameDomain
if hostname.IsZero() {
hostname = mox.Conf.Static.HostnameDomain
}
// Thunderbird doesn't seem to allow U-labels, always return ASCII names.
var resp autoconfigResponse
resp.Version = "1.1"
resp.EmailProvider.ID = addr.Domain.ASCII
resp.EmailProvider.Domain = addr.Domain.ASCII
resp.EmailProvider.DisplayName = email
resp.EmailProvider.DisplayShortName = addr.Domain.ASCII
var imapPort int
var imapSocket string
if l.IMAPS.Enabled {
imapPort = config.Port(l.IMAPS.Port, 993)
imapSocket = "SSL"
} else if l.IMAP.Enabled {
imapPort = config.Port(l.IMAP.Port, 143)
if l.TLS != nil {
imapSocket = "STARTTLS"
} else {
imapSocket = "plain"
}
} else {
log.Error("autoconfig: no imap configured?")
}
// todo: specify SCRAM-SHA256 once thunderbird and autoconfig supports it. we could implement CRAM-MD5 and use it.
resp.EmailProvider.IncomingServer.Type = "imap"
resp.EmailProvider.IncomingServer.Hostname = hostname.ASCII
resp.EmailProvider.IncomingServer.Port = imapPort
resp.EmailProvider.IncomingServer.SocketType = imapSocket
resp.EmailProvider.IncomingServer.Username = email
resp.EmailProvider.IncomingServer.Authentication = "password-cleartext"
var smtpPort int
var smtpSocket string
if l.Submissions.Enabled {
smtpPort = config.Port(l.Submissions.Port, 465)
smtpSocket = "SSL"
} else if l.Submission.Enabled {
smtpPort = config.Port(l.Submission.Port, 587)
if l.TLS != nil {
smtpSocket = "STARTTLS"
} else {
smtpSocket = "plain"
}
} else {
log.Error("autoconfig: no smtp submission configured?")
}
resp.EmailProvider.OutgoingServer.Type = "smtp"
resp.EmailProvider.OutgoingServer.Hostname = hostname.ASCII
resp.EmailProvider.OutgoingServer.Port = smtpPort
resp.EmailProvider.OutgoingServer.SocketType = smtpSocket
resp.EmailProvider.OutgoingServer.Username = email
resp.EmailProvider.OutgoingServer.Authentication = "password-cleartext"
// todo: should we put the email address in the URL?
resp.ClientConfigUpdate.URL = fmt.Sprintf("https://%s/mail/config-v1.1.xml", hostname.ASCII)
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
enc := xml.NewEncoder(w)
enc.Indent("", "\t")
fmt.Fprint(w, xml.Header)
if err := enc.Encode(resp); err != nil {
log.Errorx("marshal autoconfig response", err)
}
}
}
// Autodiscover from Microsoft, also used by Thunderbird.
// User should create a DNS record: _autodiscover._tcp.<domain> IN SRV 0 0 443 <hostname or autodiscover.<domain>>
func autodiscoverHandle(l config.Listener) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := xlog.WithContext(r.Context())
var addrDom string
defer func() {
metricAutodiscover.WithLabelValues(addrDom).Inc()
}()
if r.Method != "POST" {
http.Error(w, "405 - method not allowed - post required", http.StatusMethodNotAllowed)
return
}
var req autodiscoverRequest
if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "400 - bad request - parsing autodiscover request: "+err.Error(), http.StatusMethodNotAllowed)
return
}
log.Debug("autodiscover request", mlog.Field("email", req.Request.EmailAddress))
addr, err := smtp.ParseAddress(req.Request.EmailAddress)
if err != nil {
http.Error(w, "400 - bad request - invalid parameter emailaddress", http.StatusBadRequest)
return
}
if _, ok := mox.Conf.Domain(addr.Domain); !ok {
http.Error(w, "400 - bad request - unknown domain", http.StatusBadRequest)
return
}
addrDom = addr.Domain.Name()
hostname := l.HostnameDomain
if hostname.IsZero() {
hostname = mox.Conf.Static.HostnameDomain
}
// The docs are generated and fragmented in many tiny pages, hard to follow.
// High-level starting point, https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/78530279-d042-4eb0-a1f4-03b18143cd19
// Request: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/2096fab2-9c3c-40b9-b123-edf6e8d55a9b
// Response, protocol: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/f4238db6-a983-435c-807a-b4b4a624c65b
// It appears autodiscover does not allow specifying SCRAM-SHA256 as authentication method. See https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
var imapPort int
imapSSL := "off"
var imapEncryption string
if l.IMAPS.Enabled {
imapPort = config.Port(l.IMAPS.Port, 993)
imapSSL = "on"
imapEncryption = "TLS" // Assuming this means direct TLS.
} else if l.IMAP.Enabled {
imapPort = config.Port(l.IMAP.Port, 143)
if l.TLS != nil {
imapSSL = "on"
}
} else {
log.Error("autoconfig: no imap configured?")
}
var smtpPort int
smtpSSL := "off"
var smtpEncryption string
if l.Submissions.Enabled {
smtpPort = config.Port(l.Submissions.Port, 465)
smtpSSL = "on"
smtpEncryption = "TLS" // Assuming this means direct TLS.
} else if l.Submission.Enabled {
smtpPort = config.Port(l.Submission.Port, 587)
if l.TLS != nil {
smtpSSL = "on"
}
} else {
log.Error("autoconfig: no smtp submission configured?")
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
resp := autodiscoverResponse{}
resp.XMLName.Local = "Autodiscover"
resp.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"
resp.Response.XMLName.Local = "Response"
resp.Response.XMLName.Space = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"
resp.Response.Account = autodiscoverAccount{
AccountType: "email",
Action: "settings",
Protocol: []autodiscoverProtocol{
{
Type: "IMAP",
Server: hostname.ASCII,
Port: imapPort,
LoginName: req.Request.EmailAddress,
SSL: imapSSL,
Encryption: imapEncryption,
SPA: "off", // Override default "on", this is Microsofts proprietary authentication protocol.
AuthRequired: "on",
},
{
Type: "SMTP",
Server: hostname.ASCII,
Port: smtpPort,
LoginName: req.Request.EmailAddress,
SSL: smtpSSL,
Encryption: smtpEncryption,
SPA: "off", // Override default "on", this is Microsofts proprietary authentication protocol.
AuthRequired: "on",
},
},
}
enc := xml.NewEncoder(w)
enc.Indent("", "\t")
fmt.Fprint(w, xml.Header)
if err := enc.Encode(resp); err != nil {
log.Errorx("marshal autodiscover response", err)
}
}
}
// Thunderbird requests these URLs for autoconfig/autodiscover:
// https://autoconfig.example.org/mail/config-v1.1.xml?emailaddress=user%40example.org
// https://autodiscover.example.org/autodiscover/autodiscover.xml
// https://example.org/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=user%40example.org
// https://example.org/autodiscover/autodiscover.xml
type autoconfigResponse struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
EmailProvider struct {
ID string `xml:"id,attr"`
Domain string `xml:"domain"`
DisplayName string `xml:"displayName"`
DisplayShortName string `xml:"displayShortName"`
IncomingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
} `xml:"incomingServer"`
OutgoingServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port int `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
} `xml:"outgoingServer"`
} `xml:"emailProvider"`
ClientConfigUpdate struct {
URL string `xml:"url,attr"`
} `xml:"clientConfigUpdate"`
}
type autodiscoverRequest struct {
XMLName xml.Name `xml:"Autodiscover"`
Request struct {
EmailAddress string `xml:"EMailAddress"`
AcceptableResponseSchema string `xml:"AcceptableResponseSchema"`
}
}
type autodiscoverResponse struct {
XMLName xml.Name
Response struct {
XMLName xml.Name
Account autodiscoverAccount
}
}
type autodiscoverAccount struct {
AccountType string
Action string
Protocol []autodiscoverProtocol
}
type autodiscoverProtocol struct {
Type string
Server string
Port int
DirectoryPort int
ReferralPort int
LoginName string
SSL string
Encryption string `xml:",omitempty"`
SPA string
AuthRequired string
}

26
http/autoconf_test.go Normal file
View File

@ -0,0 +1,26 @@
package http
import (
"encoding/xml"
"testing"
)
func TestAutodiscover(t *testing.T) {
// Request by Thunderbird.
const body = `<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>test@example.org</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
</Request>
</Autodiscover>
`
var req autodiscoverRequest
if err := xml.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("unmarshal autodiscover request: %v", err)
}
if req.Request.EmailAddress != "test@example.org" {
t.Fatalf("emailaddress: got %q, expected %q", req.Request.EmailAddress, "test@example.org")
}
}

64
http/mtasts.go Normal file
View File

@ -0,0 +1,64 @@
package http
import (
"net/http"
"strings"
"time"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts"
)
func mtastsPolicyHandle(w http.ResponseWriter, r *http.Request) {
log := xlog.WithCid(mox.Cid())
if !strings.HasPrefix(r.Host, "mta-sts.") {
http.NotFound(w, r)
return
}
domain, err := dns.ParseDomain(strings.TrimPrefix(r.Host, "mta-sts."))
if err != nil {
log.Errorx("mtasts policy request: bad domain", err, mlog.Field("host", r.Host))
http.NotFound(w, r)
return
}
conf, _ := mox.Conf.Domain(domain)
sts := conf.MTASTS
if sts == nil {
http.NotFound(w, r)
return
}
var mxs []mtasts.STSMX
for _, s := range sts.MX {
var mx mtasts.STSMX
if strings.HasPrefix(s, "*.") {
mx.Wildcard = true
s = s[2:]
}
d, err := dns.ParseDomain(s)
if err != nil {
log.Errorx("bad domain in mtasts config", err, mlog.Field("domain", s))
http.Error(w, "500 - internal server error - invalid domain in configuration", http.StatusInternalServerError)
return
}
mx.Domain = d
mxs = append(mxs, mx)
}
if len(mxs) == 0 {
mxs = []mtasts.STSMX{{Domain: mox.Conf.Static.HostnameDomain}}
}
policy := mtasts.Policy{
Version: "STSv1",
Mode: sts.Mode,
MaxAgeSeconds: int(sts.MaxAge / time.Second),
MX: mxs,
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-cache, max-age=0")
w.Write([]byte(policy.String()))
}

3
http/mtasts_test.go Normal file
View File

@ -0,0 +1,3 @@
package http
// todo: write tests for mtasts handler

240
http/web.go Normal file
View File

@ -0,0 +1,240 @@
// Package http provides HTTP listeners/servers, for
// autoconfiguration/autodiscovery, the account and admin web interface and
// MTA-STS policies.
package http
import (
"crypto/tls"
"fmt"
golog "log"
"net"
"net/http"
"strings"
"time"
_ "net/http/pprof"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
)
var xlog = mlog.New("http")
// Set some http headers that should prevent potential abuse. Better safe than sorry.
func safeHeaders(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Frame-Options", "deny")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline' data:")
h.Set("Referrer-Policy", "same-origin")
fn(w, r)
}
}
// ListenAndServe starts listeners for HTTP, including those required for ACME to
// generate TLS certificates.
func ListenAndServe() {
type serve struct {
kinds []string
tlsConfig *tls.Config
mux *http.ServeMux
}
for name, l := range mox.Conf.Static.Listeners {
portServe := map[int]serve{}
var ensureServe func(https bool, port int, kind string) serve
ensureServe = func(https bool, port int, kind string) serve {
s, ok := portServe[port]
if !ok {
s = serve{nil, nil, &http.ServeMux{}}
}
s.kinds = append(s.kinds, kind)
if https && port == 443 && l.TLS.ACME != "" {
s.tlsConfig = l.TLS.ACMEConfig
} else if https {
s.tlsConfig = l.TLS.Config
if l.TLS.ACME != "" {
ensureServe(true, 443, "acme-tls-alpn-01")
}
}
portServe[port] = s
return s
}
if l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.IMAPS.Enabled {
ensureServe(true, 443, "acme-tls-alpn01")
}
if l.AdminHTTP.Enabled {
srv := ensureServe(false, config.Port(l.AdminHTTP.Port, 80), "admin-http")
srv.mux.HandleFunc("/", safeHeaders(adminIndex))
srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle))
srv.mux.HandleFunc("/account/", safeHeaders(accountHandle))
}
if l.AdminHTTPS.Enabled {
srv := ensureServe(true, config.Port(l.AdminHTTPS.Port, 443), "admin-https")
srv.mux.HandleFunc("/", safeHeaders(adminIndex))
srv.mux.HandleFunc("/admin/", safeHeaders(adminHandle))
srv.mux.HandleFunc("/account/", safeHeaders(accountHandle))
}
if l.MetricsHTTP.Enabled {
srv := ensureServe(false, config.Port(l.MetricsHTTP.Port, 8010), "metrics-http")
srv.mux.Handle("/metrics", safeHeaders(promhttp.Handler().ServeHTTP))
srv.mux.HandleFunc("/", safeHeaders(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
} else if r.Method != "GET" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><body>see <a href="/metrics">/metrics</a></body></html>`)
}))
}
if l.AutoconfigHTTPS.Enabled {
srv := ensureServe(true, 443, "autoconfig-https")
srv.mux.HandleFunc("/mail/config-v1.1.xml", safeHeaders(autoconfHandle(l)))
srv.mux.HandleFunc("/autodiscover/autodiscover.xml", safeHeaders(autodiscoverHandle(l)))
}
if l.MTASTSHTTPS.Enabled {
srv := ensureServe(true, 443, "mtasts-https")
srv.mux.HandleFunc("/.well-known/mta-sts.txt", safeHeaders(mtastsPolicyHandle))
}
if l.PprofHTTP.Enabled {
// Importing net/http/pprof registers handlers on the default serve mux.
port := config.Port(l.PprofHTTP.Port, 8011)
if _, ok := portServe[port]; ok {
xlog.Fatal("cannot serve pprof on same endpoint as other http services")
}
portServe[port] = serve{[]string{"pprof-http"}, nil, http.DefaultServeMux}
}
// We'll explicitly ensure these TLS certs exist (e.g. are created with ACME)
// immediately after startup. We only do so for our explicitly hostnames, not for
// autoconfig or mta-sts DNS records, they can be requested on demand (perhaps
// never).
ensureHosts := map[dns.Domain]struct{}{}
if l.TLS != nil && l.TLS.ACME != "" {
m := mox.Conf.Static.ACME[l.TLS.ACME].Manager
m.AllowHostname(mox.Conf.Static.HostnameDomain)
ensureHosts[mox.Conf.Static.HostnameDomain] = struct{}{}
if l.HostnameDomain.ASCII != "" {
m.AllowHostname(l.HostnameDomain)
ensureHosts[l.HostnameDomain] = struct{}{}
}
go func() {
// Just in case someone adds quite some domains to their config. We don't want to
// hit any ACME rate limits.
if len(ensureHosts) > 10 {
return
}
time.Sleep(1 * time.Second)
i := 0
for hostname := range ensureHosts {
if i > 0 {
// Sleep just a little. We don't want to hammer our ACME provider, e.g. Let's Encrypt.
time.Sleep(10 * time.Second)
}
i++
hello := &tls.ClientHelloInfo{
ServerName: hostname.ASCII,
// Make us fetch an ECDSA P256 cert.
// We add TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 to get around the ecDSA check in autocert.
CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_AES_128_GCM_SHA256},
SupportedCurves: []tls.CurveID{tls.CurveP256},
SignatureSchemes: []tls.SignatureScheme{tls.ECDSAWithP256AndSHA256},
SupportedVersions: []uint16{tls.VersionTLS13},
}
xlog.Print("ensuring certificate availability", mlog.Field("hostname", hostname))
if _, err := m.Manager.GetCertificate(hello); err != nil {
xlog.Errorx("requesting automatic certificate", err, mlog.Field("hostname", hostname))
}
}
}()
}
for port, srv := range portServe {
for _, ip := range l.IPs {
listenAndServe(ip, port, srv.tlsConfig, name, srv.kinds, srv.mux)
}
}
}
}
func adminIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != "GET" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
const html = `<!doctype html>
<html>
<head>
<title>mox</title>
<meta name="viewport" content="width=device-width" />
<style>
body, html { font-family: ubuntu, lato, sans-serif; font-size: 16px; padding: 1em; }
* { margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3, h4 { margin-bottom: 1ex; }
h1 { font-size: 1.2rem; }
</style>
</head>
<body>
<h1>mox</h1>
<div><a href="/account/">/account/</a>, for regular login</div>
<div><a href="/admin/">/admin/</a>, for adminstrators</div>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
func listenAndServe(ip string, port int, tlsConfig *tls.Config, name string, kinds []string, mux *http.ServeMux) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
var protocol string
var ln net.Listener
var err error
if tlsConfig == nil {
protocol = "http"
xlog.Print("http listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
ln, err = net.Listen(mox.Network(ip), addr)
if err != nil {
xlog.Fatalx("http: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr))
}
} else {
protocol = "https"
xlog.Print("https listener", mlog.Field("name", name), mlog.Field("kinds", strings.Join(kinds, ",")), mlog.Field("address", addr))
ln, err = tls.Listen(mox.Network(ip), addr, tlsConfig)
if err != nil {
xlog.Fatalx("https: listen"+mox.LinuxSetcapHint(err), err, mlog.Field("addr", addr))
}
}
server := &http.Server{
Handler: mux,
TLSConfig: tlsConfig,
ErrorLog: golog.New(mlog.ErrWriter(xlog.Fields(mlog.Field("pkg", "net/http")), mlog.LevelInfo, protocol+" error"), "", 0),
}
go func() {
err := server.Serve(ln)
xlog.Fatalx(protocol+": serve", err)
}()
}