Python automx2, its database and its automx_rows tool go; the container keeps its name, network and port, gains an http health.
291 lines
9.7 KiB
Go
291 lines
9.7 KiB
Go
// automx answers mail clients' autoconfiguration requests for the domains mailu serves: Thunderbird's
|
|
// /mail/config-v1.1.xml, Outlook's /autodiscover/autodiscover.xml and Apple's /mobileconfig. It replaces the
|
|
// Python automx2 image (novox/hq ADR 0480 clauses 1, 9, 10): standard library only, no database, every answer
|
|
// made from mailu's environment at the start, so a change of the domains, servers or ports reaches clients when
|
|
// the program is started again.
|
|
//
|
|
// The answers are those automx2 2021.6 gave, captured in testdata/captured (CAPTURED, issue 1630), byte for byte,
|
|
// but for the one thing testdata/captured/DIFFERENCES lists: the IMAP server is named, from AMX_IMAP_ADDRESS.
|
|
package main
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The environment, all required: a program that cannot say which servers clients use must not run (failure is
|
|
// loud). The names are those of mailu's files/mailu.env.
|
|
const (
|
|
envDomains = "AMX_MAIL_DOMAINS"
|
|
envImapHost = "AMX_IMAP_ADDRESS"
|
|
envImapPort = "AMX_IMAP_PORT"
|
|
envSmtpHost = "AMX_SMTP_ADDRESS"
|
|
envSmtpPort = "AMX_SMTP_PORT"
|
|
envListen = "AUTOMX_PORT"
|
|
)
|
|
|
|
type config struct {
|
|
domains []string
|
|
imapHost, smtpHost string
|
|
imapPort, smtpPort int
|
|
listen int
|
|
}
|
|
|
|
// configFrom reads the environment; it names every variable that is missing or wrong.
|
|
func configFrom(getenv func(string) string) (config, error) {
|
|
var c config
|
|
var problems []string
|
|
text := func(name string) string {
|
|
v := strings.TrimSpace(getenv(name))
|
|
if v == "" {
|
|
problems = append(problems, name+" is not set")
|
|
}
|
|
return v
|
|
}
|
|
port := func(name string) int {
|
|
v := text(name)
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 || n > 65535 {
|
|
problems = append(problems, fmt.Sprintf("%s is %q, not a port number", name, v))
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
// automx2's add-domains split the list at commas and spaces.
|
|
for _, d := range strings.FieldsFunc(text(envDomains), func(r rune) bool { return r == ',' || r == ' ' }) {
|
|
c.domains = append(c.domains, strings.ToLower(d))
|
|
}
|
|
c.imapHost, c.imapPort = text(envImapHost), port(envImapPort)
|
|
c.smtpHost, c.smtpPort = text(envSmtpHost), port(envSmtpPort)
|
|
c.listen = port(envListen)
|
|
if len(problems) > 0 {
|
|
return config{}, errors.New(strings.Join(problems, "; "))
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// What automx2 sent (Werkzeug), copied from the capture.
|
|
const (
|
|
htmlType = "text/html; charset=utf-8"
|
|
xmlType = "application/xml"
|
|
|
|
notFound = `<!doctype html>
|
|
<html lang=en>
|
|
<title>404 Not Found</title>
|
|
<h1>Not Found</h1>
|
|
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
|
|
`
|
|
notAllowed = `<!doctype html>
|
|
<html lang=en>
|
|
<title>405 Method Not Allowed</title>
|
|
<h1>Method Not Allowed</h1>
|
|
<p>The method is not allowed for the requested URL.</p>
|
|
`
|
|
internalError = `<!doctype html>
|
|
<html lang=en>
|
|
<title>500 Internal Server Error</title>
|
|
<h1>Internal Server Error</h1>
|
|
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
|
|
`
|
|
badRequest = `<!doctype html>
|
|
<html lang=en>
|
|
<title>400 Bad Request</title>
|
|
<h1>Bad Request</h1>
|
|
<p>The browser (or proxy) sent a request that this server could not understand.</p>
|
|
`
|
|
index = `<html><body>Show Thunderbird-style <a href="/mail/config-v1.1.xml?emailaddress=abc@example.com">XML configuration</a> for abc@example.com.</body></html>`
|
|
)
|
|
|
|
func reply(w http.ResponseWriter, status int, contentType, body string) {
|
|
w.Header().Set("Content-Type", contentType)
|
|
if status != http.StatusNoContent {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
}
|
|
w.WriteHeader(status)
|
|
if status != http.StatusNoContent {
|
|
io.WriteString(w, body)
|
|
}
|
|
}
|
|
|
|
func missing(w http.ResponseWriter, argument string) {
|
|
reply(w, http.StatusBadRequest, htmlType, fmt.Sprintf("Missing request argument %q", argument))
|
|
}
|
|
|
|
type handler struct{ config }
|
|
|
|
func newHandler(c config) http.Handler { return handler{c} }
|
|
|
|
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
var allow string
|
|
switch r.URL.Path {
|
|
case "/", "/mail/config-v1.1.xml", "/mobileconfig/":
|
|
allow = "GET, HEAD, OPTIONS"
|
|
case "/autodiscover/autodiscover.xml":
|
|
allow = "POST, OPTIONS"
|
|
case "/mobileconfig":
|
|
h.redirectToSlash(w, r)
|
|
return
|
|
default:
|
|
reply(w, http.StatusNotFound, htmlType, notFound)
|
|
return
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.Header().Set("Allow", allow)
|
|
reply(w, http.StatusOK, htmlType, "")
|
|
return
|
|
}
|
|
if !strings.Contains(", "+allow+",", ", "+r.Method+",") {
|
|
w.Header().Set("Allow", allow)
|
|
reply(w, http.StatusMethodNotAllowed, htmlType, notAllowed)
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/":
|
|
reply(w, http.StatusOK, htmlType, index)
|
|
case "/mail/config-v1.1.xml":
|
|
h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.thunderbird)
|
|
case "/mobileconfig/":
|
|
h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.apple)
|
|
case "/autodiscover/autodiscover.xml":
|
|
h.outlook(w, r)
|
|
}
|
|
}
|
|
|
|
// byAddress is the part the three protocols share: the address is required, and a domain that is not served is
|
|
// answered with no content.
|
|
func (h handler) byAddress(w http.ResponseWriter, address string, present bool, argument string, answer func(http.ResponseWriter, int, string)) {
|
|
if !present {
|
|
missing(w, argument)
|
|
return
|
|
}
|
|
domain := strings.ToLower(address[strings.LastIndex(address, "@")+1:])
|
|
for i, d := range h.domains {
|
|
if strings.Contains(address, "@") && d == domain {
|
|
answer(w, i, d)
|
|
return
|
|
}
|
|
}
|
|
reply(w, http.StatusNoContent, htmlType, "")
|
|
}
|
|
|
|
// thunderbird is Mozilla's autoconfiguration, config-v1.1. The provider id is automx2's: the provider rows were
|
|
// numbered 1, 11, 21 ... by add-domains.
|
|
func (h handler) thunderbird(w http.ResponseWriter, i int, domain string) {
|
|
d := html.EscapeString(domain)
|
|
server := func(kind, host string, port int) string {
|
|
tag := map[string]string{"imap": "incomingServer", "smtp": "outgoingServer"}[kind]
|
|
return fmt.Sprintf(`<%s type="%s"><hostname>%s</hostname><port>%d</port><socketType>STARTTLS</socketType>`+
|
|
`<username>%%EMAILADDRESS%%</username><authentication>password-cleartext</authentication></%s>`,
|
|
tag, kind, html.EscapeString(host), port, tag)
|
|
}
|
|
body := fmt.Sprintf(`<clientConfig version="1.1"><emailProvider id="automx2-%d"><identity /><domain>%s</domain>`+
|
|
`<displayName>%s</displayName><displayShortName>%s</displayShortName>%s%s</emailProvider></clientConfig>`,
|
|
1+10*i, d, d, d, server("imap", h.imapHost, h.imapPort), server("smtp", h.smtpHost, h.smtpPort))
|
|
reply(w, http.StatusOK, xmlType, body)
|
|
}
|
|
|
|
// apple and outlook answer a served domain as automx2 did: with an error (DIFFERENCES, issue 1691). They are
|
|
// kept as captured here, so the move to a Go program changes the answer only where clause 9 says.
|
|
func (h handler) apple(w http.ResponseWriter, _ int, _ string) {
|
|
reply(w, http.StatusBadRequest, htmlType, badRequest)
|
|
}
|
|
|
|
// redirectToSlash is Werkzeug's strict-slash redirect, to the name the client used.
|
|
func (h handler) redirectToSlash(w http.ResponseWriter, r *http.Request) {
|
|
scheme := "http"
|
|
if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" {
|
|
scheme = p
|
|
}
|
|
host := r.Host
|
|
if f := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); f != "" {
|
|
host = f
|
|
}
|
|
target := scheme + "://" + host + "/mobileconfig/"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
w.Header().Set("Location", target)
|
|
e := html.EscapeString(target)
|
|
reply(w, http.StatusPermanentRedirect, htmlType, fmt.Sprintf(`<!doctype html>
|
|
<html lang=en>
|
|
<title>Redirecting...</title>
|
|
<h1>Redirecting...</h1>
|
|
<p>You should be redirected automatically to the target URL: <a href="%s">%s</a>. If not, click the link.
|
|
`, e, e))
|
|
}
|
|
|
|
// outlook reads the request's EMailAddress anywhere in the document, whatever the namespace. A body that is not
|
|
// one XML document is automx2's 500.
|
|
func (h handler) outlook(w http.ResponseWriter, r *http.Request) {
|
|
dec := xml.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
var address string
|
|
var found, elements, inAddress = false, 0, false
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
reply(w, http.StatusInternalServerError, htmlType, internalError)
|
|
return
|
|
}
|
|
switch t := tok.(type) {
|
|
case xml.StartElement:
|
|
elements++
|
|
if t.Name.Local == "EMailAddress" && !found {
|
|
inAddress = true
|
|
}
|
|
case xml.CharData:
|
|
if inAddress {
|
|
address += string(t)
|
|
}
|
|
case xml.EndElement:
|
|
if inAddress && t.Name.Local == "EMailAddress" {
|
|
inAddress, found = false, true
|
|
}
|
|
}
|
|
}
|
|
if elements == 0 {
|
|
reply(w, http.StatusInternalServerError, htmlType, internalError)
|
|
return
|
|
}
|
|
h.byAddress(w, strings.TrimSpace(address), found, "EMailAddress", func(w http.ResponseWriter, _ int, _ string) {
|
|
reply(w, http.StatusInternalServerError, htmlType, internalError)
|
|
})
|
|
}
|
|
|
|
// run is the program: configuration from the environment, then serving. A configuration that cannot be read is
|
|
// said, with the variables' names, and the exit code is not zero.
|
|
func run(getenv func(string) string, out io.Writer, serve func(*http.Server) error) int {
|
|
c, err := configFrom(getenv)
|
|
if err != nil {
|
|
fmt.Fprintf(out, "automx: %v\n", err)
|
|
return 1
|
|
}
|
|
srv := &http.Server{
|
|
Addr: ":" + strconv.Itoa(c.listen),
|
|
Handler: newHandler(c),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
if err := serve(srv); err != nil {
|
|
fmt.Fprintf(out, "automx: %v\n", err)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run(os.Getenv, os.Stderr, func(s *http.Server) error { return s.ListenAndServe() }))
|
|
}
|