happyDomain/services/dmarc.go

170 lines
5.6 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 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 svcs
import (
"fmt"
"strconv"
"strings"
"git.happydns.org/happyDomain/services/common"
)
type DMARC struct {
Version uint `json:"version" happydomain:"label=Version,placeholder=1,required,description=The version of DMARC to use.,default=1,hidden"`
Request string `json:"p" happydomain:"label=Requested Mail Receiver policy,choices=none;quarantine;reject,description=Indicates the policy to be enacted by the Receiver,required"`
SRequest string `json:"sp" happydomain:"label=Requested Mail Receiver policy for all subdomains,choices=;none;quarantaine;reject,description=Indicates the policy to be enacted by the Receiver when it receives mail for a subdomain"`
AURI []string `json:"rua" happydomain:"label=RUA,description=Addresses for aggregate feedback,placeholder=mailto:name@example.com"`
FURI []string `json:"ruf" happydomain:"label=RUF,description=Addresses for message-specific failure information,placeholder=mailto:name@example.com"`
ADKIM bool `json:"adkim" happydomain:"label=Strict DKIM Alignment"`
ASPF bool `json:"aspf" happydomain:"label=Strict SPF Alignment"`
AInterval common.Duration `json:"ri" happydomain:"label=Interval between aggregate reports"`
FailureOptions []string `json:"fo" happydomain:"label=Failure reporting options,choices=0;1;d;s"`
RegisteredFormats []string `json:"rf" happydomain:"label=Format of the failure reports,choices=;afrf"`
Percent uint8 `json:"pct" happydomain:"label=Policy applies on,description=Percentage of messages to which the DMARC policy is to be applied.,unit=%"`
}
func analyseFields(txt string) map[string]string {
ret := map[string]string{}
for _, f := range strings.Split(txt, ";") {
f = strings.TrimSpace(f)
kv := strings.SplitN(f, "=", 2)
if len(kv) == 1 {
ret[strings.TrimSpace(kv[0])] = ""
} else {
ret[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
return ret
}
func (t *DMARC) Analyze(txt string) error {
fields := analyseFields(txt)
if v, ok := fields["v"]; ok {
if !strings.HasPrefix(v, "DMARC") {
return fmt.Errorf("not a valid DMARC record: should begin with v=DMARCv1, seen v=%q", v)
}
version, err := strconv.ParseUint(v[5:], 10, 32)
if err != nil {
return fmt.Errorf("not a valid DMARC record: bad version number: %w", err)
}
t.Version = uint(version)
} else {
return fmt.Errorf("not a valid DMARC record: version not found")
}
if p, ok := fields["p"]; ok {
t.Request = p
}
if sp, ok := fields["sp"]; ok {
t.SRequest = sp
}
if rua, ok := fields["rua"]; ok {
t.AURI = strings.Split(rua, ",")
}
if ruf, ok := fields["ruf"]; ok {
t.FURI = strings.Split(ruf, ",")
}
if adkim, ok := fields["adkim"]; ok && adkim == "s" {
t.ADKIM = true
}
if aspf, ok := fields["aspf"]; ok && aspf == "s" {
t.ASPF = true
}
if ri, ok := fields["ri"]; ok {
v, err := strconv.ParseUint(ri, 10, 32)
if err != nil {
return fmt.Errorf("not a valid DMARC record: bad interval value (ri): %w", err)
}
t.AInterval = common.Duration(v)
} else {
t.AInterval = 86400
}
if fo, ok := fields["fo"]; ok {
t.FailureOptions = strings.Split(fo, ":")
}
if rf, ok := fields["rf"]; ok {
t.RegisteredFormats = strings.Split(rf, ":")
}
if pct, ok := fields["pct"]; ok {
v, err := strconv.ParseUint(pct, 10, 8)
if err != nil {
return fmt.Errorf("not a valid DMARC record: bad percent value (prc): %w", err)
}
t.Percent = uint8(v)
} else {
t.Percent = 100
}
return nil
}
func (t *DMARC) String() string {
fields := []string{
fmt.Sprintf("v=DMARC%d", t.Version),
}
if t.Request != "" {
fields = append(fields, fmt.Sprintf("p=%s", t.Request))
}
if t.SRequest != "" {
fields = append(fields, fmt.Sprintf("sp=%s", t.SRequest))
}
if len(t.AURI) > 0 {
fields = append(fields, fmt.Sprintf("rua=%s", strings.Join(t.AURI, ",")))
}
if len(t.FURI) > 0 {
fields = append(fields, fmt.Sprintf("ruf=%s", strings.Join(t.FURI, ",")))
}
if t.ADKIM {
fields = append(fields, "adkim=s")
} else {
fields = append(fields, "adkim=r")
}
if t.ASPF {
fields = append(fields, "aspf=s")
} else {
fields = append(fields, "aspf=r")
}
if t.AInterval != 86400 && t.AInterval != 0 {
fields = append(fields, fmt.Sprintf("ri=%d", t.AInterval))
}
if len(t.FailureOptions) > 0 {
fields = append(fields, fmt.Sprintf("fo=%s", strings.Join(t.FailureOptions, ":")))
}
if len(t.RegisteredFormats) > 0 {
fields = append(fields, fmt.Sprintf("rf=%s", strings.Join(t.RegisteredFormats, ":")))
}
if t.Percent != 100 {
fields = append(fields, fmt.Sprintf("pct=%d", t.Percent))
}
return strings.Join(fields, ";")
}