resolver: POST /api/resolver/mta-sts-policy endpoint

Adds a backend endpoint that fetches and parses an MTA-STS policy
file at https://mta-sts.<domain>/.well-known/mta-sts.txt per RFC 8461
sec. 3.3, paired with the (existing) MTA-STS TXT validator on the front-end.

- happydns.MTASTSPolicyRequest accepts a {domain}.
- happydns.MTASTSPolicyResponse returns the parsed version / mode / mx /
  max_age plus diagnostic fields (status, httpCode, errorMsg, body)
  so the UI can surface fetch / TLS / parse errors without a second
  request.
- Status values cover dns-error, tls-error, fetch-error, not-found,
  http-error and too-large.
- Hard caps: 64 KiB body, 5 s connect / TLS timeouts, 10 s overall.
  RFC-mandated no-redirect-follow is enforced via CheckRedirect.
- Unknown keys, blank lines, comments, CRLF and case differences in the
  policy file are tolerated; max_age values that do not parse as int
  are dropped silently rather than failing the whole fetch.

Unit tests cover the policy parser (minimal, multi-mx, CRLF + comments,
case folding, junk lines, non-numeric max_age) and the empty-domain
guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-04-27 10:19:11 +07:00
commit c9ad96f4a8
5 changed files with 362 additions and 0 deletions

View file

@ -105,3 +105,34 @@ func (rc *ResolverController) FlattenSPF(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// FetchMTASTSPolicy retrieves and parses the MTA-STS policy file at
// https://mta-sts.<domain>/.well-known/mta-sts.txt.
//
// @Summary Fetch and parse an MTA-STS policy file.
// @Schemes
// @Description Fetch the MTA-STS policy file at https://mta-sts.<domain>/.well-known/mta-sts.txt and return its parsed fields, along with diagnostic information when the fetch or parse fails.
// @Tags resolver
// @Accept json
// @Produce json
// @Param body body happydns.MTASTSPolicyRequest true "MTA-STS policy fetch request"
// @Success 200 {object} happydns.MTASTSPolicyResponse
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /resolver/mta-sts-policy [post]
func (rc *ResolverController) FetchMTASTSPolicy(c *gin.Context) {
var req happydns.MTASTSPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("%s sends invalid MTASTSPolicyRequest JSON: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
resp, err := rc.resolverService.FetchMTASTSPolicy(req)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, resp)
}

View file

@ -33,4 +33,5 @@ func DeclareResolverRoutes(router *gin.RouterGroup, resolverUC happydns.Resolver
router.POST("/resolver", rc.RunResolver)
router.POST("/resolver/spf-flatten", rc.FlattenSPF)
router.POST("/resolver/mta-sts-policy", rc.FetchMTASTSPolicy)
}

View file

@ -0,0 +1,169 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package usecase
import (
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
const (
mtaStsBodySizeCap = 64 * 1024
mtaStsTotalDeadline = 10 * time.Second
mtaStsConnTimeout = 5 * time.Second
)
// FetchMTASTSPolicy implements happydns.ResolverUsecase.
func (us *resolverUsecase) FetchMTASTSPolicy(req happydns.MTASTSPolicyRequest) (*happydns.MTASTSPolicyResponse, error) {
domain := strings.TrimSuffix(strings.TrimSpace(req.Domain), ".")
if domain == "" {
return nil, errors.New("domain is required")
}
host := "mta-sts." + dns.Fqdn(domain)
host = strings.TrimSuffix(host, ".")
url := "https://" + host + "/.well-known/mta-sts.txt"
resp := &happydns.MTASTSPolicyResponse{URL: url}
client := &http.Client{
Timeout: mtaStsTotalDeadline,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: mtaStsConnTimeout,
}).DialContext,
TLSHandshakeTimeout: mtaStsConnTimeout,
ResponseHeaderTimeout: mtaStsConnTimeout,
},
// RFC 8461 sec. 3.3: receivers MUST NOT follow redirects when
// fetching the policy file.
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
httpResp, err := client.Get(url)
if err != nil {
resp.Status, resp.ErrorMsg = classifyFetchError(err)
return resp, nil
}
defer httpResp.Body.Close()
resp.HTTPCode = httpResp.StatusCode
body, readErr := io.ReadAll(io.LimitReader(httpResp.Body, mtaStsBodySizeCap+1))
if readErr != nil {
resp.Status = "fetch-error"
resp.ErrorMsg = readErr.Error()
return resp, nil
}
if len(body) > mtaStsBodySizeCap {
resp.Status = "too-large"
resp.ErrorMsg = fmt.Sprintf("body exceeds %d bytes", mtaStsBodySizeCap)
resp.Body = string(body[:mtaStsBodySizeCap])
return resp, nil
}
resp.Body = string(body)
if httpResp.StatusCode >= 300 && httpResp.StatusCode < 400 {
resp.Status = "http-error"
resp.Redirected = true
resp.ErrorMsg = fmt.Sprintf("server attempted a redirect (HTTP %d)", httpResp.StatusCode)
return resp, nil
}
if httpResp.StatusCode == http.StatusNotFound {
resp.Status = "not-found"
resp.ErrorMsg = "no MTA-STS policy published at this URL"
return resp, nil
}
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
resp.Status = "http-error"
resp.ErrorMsg = fmt.Sprintf("HTTP %d", httpResp.StatusCode)
return resp, nil
}
parseMTASTSBody(string(body), resp)
resp.Status = "ok"
return resp, nil
}
func classifyFetchError(err error) (status, msg string) {
msg = err.Error()
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "fetch-error", "timeout: " + msg
}
var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) {
return "tls-error", msg
}
if strings.Contains(strings.ToLower(msg), "tls") || strings.Contains(strings.ToLower(msg), "certificate") {
return "tls-error", msg
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return "dns-error", msg
}
return "fetch-error", msg
}
// parseMTASTSBody parses the textual MTA-STS policy file (RFC 8461 sec. 3.2)
// and fills the policy fields of resp.
func parseMTASTSBody(body string, resp *happydns.MTASTSPolicyResponse) {
// Lines may be terminated by CRLF or LF; trim both.
lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, ':')
if idx < 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
switch key {
case "version":
resp.Version = value
case "mode":
resp.Mode = strings.ToLower(value)
case "mx":
resp.MX = append(resp.MX, value)
case "max_age":
if n, err := strconv.Atoi(value); err == nil {
resp.MaxAge = n
}
}
}
}

View file

@ -0,0 +1,116 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package usecase
import (
"strings"
"testing"
"git.happydns.org/happyDomain/model"
)
func TestParseMTASTSBody_Minimal(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 86400\n", resp)
if resp.Version != "STSv1" {
t.Errorf("version = %q, want STSv1", resp.Version)
}
if resp.Mode != "enforce" {
t.Errorf("mode = %q, want enforce", resp.Mode)
}
if len(resp.MX) != 1 || resp.MX[0] != "mail.example.com" {
t.Errorf("mx = %v, want [mail.example.com]", resp.MX)
}
if resp.MaxAge != 86400 {
t.Errorf("maxAge = %d, want 86400", resp.MaxAge)
}
}
func TestParseMTASTSBody_MultipleMX(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("version: STSv1\nmode: testing\nmx: mail1.example.com\nmx: *.mail.example.com\nmax_age: 604800\n", resp)
if len(resp.MX) != 2 {
t.Fatalf("mx = %v, want 2 entries", resp.MX)
}
if resp.MX[0] != "mail1.example.com" || resp.MX[1] != "*.mail.example.com" {
t.Errorf("mx = %v, unexpected order/values", resp.MX)
}
}
func TestParseMTASTSBody_CRLFAndComments(t *testing.T) {
body := "# example policy\r\nversion: STSv1\r\nmode: enforce\r\n\r\nmx: mail.example.com\r\nmax_age: 3600\r\n"
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody(body, resp)
if resp.Version != "STSv1" || resp.Mode != "enforce" || resp.MaxAge != 3600 || len(resp.MX) != 1 {
t.Errorf("CRLF/comment handling failed: %+v", resp)
}
}
func TestParseMTASTSBody_CaseInsensitiveKeys(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("Version: STSv1\nMode: NONE\nMax_Age: 0\n", resp)
if resp.Version != "STSv1" {
t.Errorf("version = %q", resp.Version)
}
// Mode is lowercased on parse to ease matching.
if resp.Mode != "none" {
t.Errorf("mode = %q, want lowercase 'none'", resp.Mode)
}
if resp.MaxAge != 0 {
t.Errorf("maxAge = %d, want 0", resp.MaxAge)
}
}
func TestParseMTASTSBody_IgnoresUnknownKeys(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("version: STSv1\nfoo: bar\nmode: enforce\n", resp)
if resp.Version != "STSv1" || resp.Mode != "enforce" {
t.Errorf("unexpected: %+v", resp)
}
}
func TestParseMTASTSBody_NonNumericMaxAgeIgnored(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("version: STSv1\nmode: enforce\nmax_age: not-a-number\n", resp)
if resp.MaxAge != 0 {
t.Errorf("maxAge = %d, want 0 when value is non-numeric", resp.MaxAge)
}
}
func TestParseMTASTSBody_LinesWithoutColonIgnored(t *testing.T) {
resp := &happydns.MTASTSPolicyResponse{}
parseMTASTSBody("version: STSv1\nthis is junk\nmode: enforce\n", resp)
if resp.Version != "STSv1" || resp.Mode != "enforce" {
t.Errorf("unexpected: %+v", resp)
}
}
func TestFetchMTASTSPolicy_EmptyDomain(t *testing.T) {
us := &resolverUsecase{}
_, err := us.FetchMTASTSPolicy(happydns.MTASTSPolicyRequest{Domain: ""})
if err == nil {
t.Fatal("expected error on empty domain")
}
if !strings.Contains(err.Error(), "domain") {
t.Errorf("error %q does not mention domain", err)
}
}

View file

@ -87,6 +87,7 @@ func NewResolverResponseFromMsg(msg *dns.Msg) *ResolverResponse {
type ResolverUsecase interface {
ResolveQuestion(ResolverRequest) (*dns.Msg, error)
FlattenSPF(SPFFlattenRequest) (*SPFFlattenResponse, error)
FetchMTASTSPolicy(MTASTSPolicyRequest) (*MTASTSPolicyResponse, error)
}
// SPFFlattenRequest asks the backend to recursively walk an SPF record and
@ -137,6 +138,50 @@ type SPFNode struct {
Children []*SPFNode `json:"children,omitempty"`
}
// MTASTSPolicyRequest asks the backend to fetch and parse the MTA-STS policy
// file published at https://mta-sts.<Domain>/.well-known/mta-sts.txt
// (RFC 8461 sec. 3.3).
type MTASTSPolicyRequest struct {
// Domain is the FQDN whose MTA-STS policy should be fetched.
Domain string `json:"domain"`
}
// MTASTSPolicyResponse is the result of an MTA-STS policy fetch.
type MTASTSPolicyResponse struct {
// URL is the policy URL that was fetched.
URL string `json:"url"`
// Status is the high-level outcome:
// "ok" policy fetched and parsed
// "dns-error" could not resolve mta-sts.<domain>
// "tls-error" TLS handshake failed
// "not-found" HTTP 404 (no policy published)
// "http-error" any other non-2xx status
// "fetch-error" connection refused, timeout, etc.
// "too-large" body exceeded the size cap before parsing
Status string `json:"status"`
// HTTPCode is the response status code when an HTTP exchange completed.
HTTPCode int `json:"httpCode,omitempty"`
// ErrorMsg gives a short human-readable reason when Status != "ok".
ErrorMsg string `json:"errorMsg,omitempty"`
// Body is the raw response body (truncated to the size cap) returned for
// diagnostic display. Set even when parsing failed.
Body string `json:"body,omitempty"`
// Parsed policy fields. Empty unless Status is "ok".
Version string `json:"version,omitempty"`
Mode string `json:"mode,omitempty"`
MX []string `json:"mx,omitempty"`
MaxAge int `json:"maxAge,omitempty"`
// Redirected is true when the server tried to redirect us; per RFC 8461
// sec. 3.3 we MUST NOT follow.
Redirected bool `json:"redirected,omitempty"`
}
// SPFFlattenResponse is the result of a recursive SPF flatten.
type SPFFlattenResponse struct {
// Record is the root SPF record that was evaluated.