mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:04:39 +03:00
mox!
This commit is contained in:
114
http/account.go
Normal file
114
http/account.go
Normal 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
214
http/account.html
Normal 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
3
http/account_test.go
Normal 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
25
http/accountapi.json
Normal 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
1382
http/admin.go
Normal file
File diff suppressed because it is too large
Load Diff
1480
http/admin.html
Normal file
1480
http/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
123
http/admin_test.go
Normal file
123
http/admin_test.go
Normal 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
3104
http/adminapi.json
Normal file
File diff suppressed because it is too large
Load Diff
344
http/autoconf.go
Normal file
344
http/autoconf.go
Normal 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
26
http/autoconf_test.go
Normal 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
64
http/mtasts.go
Normal 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
3
http/mtasts_test.go
Normal file
@ -0,0 +1,3 @@
|
||||
package http
|
||||
|
||||
// todo: write tests for mtasts handler
|
240
http/web.go
Normal file
240
http/web.go
Normal 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)
|
||||
}()
|
||||
}
|
Reference in New Issue
Block a user