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:
parent
4498c1f1c4
commit
c9ad96f4a8
5 changed files with 362 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
169
internal/usecase/mta_sts_policy.go
Normal file
169
internal/usecase/mta_sts_policy.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
internal/usecase/mta_sts_policy_test.go
Normal file
116
internal/usecase/mta_sts_policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue