mirror of
https://blitiri.com.ar/repos/chasquid
synced 2026-01-08 17:51:57 +00:00
aliases: Implement "via" aliases
This patch implements "via" aliases, which let us explicitly select a server to use for delivery. This feature is useful in different scenarios, such as a secondary MX server that forwards all incoming email to a primary. For now, it is experimental and the syntax and semantics are subject to change.
This commit is contained in:
@@ -83,6 +83,7 @@ var (
|
||||
// anyway.
|
||||
type Recipient struct {
|
||||
Addr string
|
||||
Via []string // Used when Type == FORWARD.
|
||||
Type RType
|
||||
}
|
||||
|
||||
@@ -91,8 +92,9 @@ type RType string
|
||||
|
||||
// Valid recipient types.
|
||||
const (
|
||||
EMAIL RType = "(email)"
|
||||
PIPE RType = "(pipe)"
|
||||
EMAIL RType = "(email)"
|
||||
PIPE RType = "(pipe)"
|
||||
FORWARD RType = "(forward)"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -215,7 +217,7 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
|
||||
user, domain := envelope.Split(addr)
|
||||
if _, ok := v.domains[domain]; !ok {
|
||||
tr.Debugf("%d| non-local domain, returning %q", rcount, addr)
|
||||
return []Recipient{{addr, EMAIL}}, nil
|
||||
return []Recipient{{addr, nil, EMAIL}}, nil
|
||||
}
|
||||
|
||||
// First, see if there's an exact match in the database.
|
||||
@@ -250,7 +252,7 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
|
||||
}
|
||||
if ok {
|
||||
tr.Debugf("%d| user exists, returning %q", rcount, addr)
|
||||
return []Recipient{{addr, EMAIL}}, nil
|
||||
return []Recipient{{addr, nil, EMAIL}}, nil
|
||||
}
|
||||
|
||||
catchAll, err := v.lookup("*@"+domain, tr)
|
||||
@@ -274,14 +276,14 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
|
||||
// clearer failure, reusing the existing codepaths and simplifying
|
||||
// the logic.
|
||||
tr.Debugf("%d| no catch-all, returning %q", rcount, addr)
|
||||
return []Recipient{{addr, EMAIL}}, nil
|
||||
return []Recipient{{addr, nil, EMAIL}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
ret := []Recipient{}
|
||||
for _, r := range rcpts {
|
||||
// Only recurse for email recipients.
|
||||
if r.Type != EMAIL {
|
||||
// PIPE recipients get added as-is. No modification, and no recursion.
|
||||
if r.Type == PIPE {
|
||||
ret = append(ret, r)
|
||||
continue
|
||||
}
|
||||
@@ -296,6 +298,14 @@ func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipien
|
||||
r.Addr = newAddr
|
||||
}
|
||||
|
||||
// Don't recurse FORWARD recipients, since we explicitly want them to
|
||||
// be sent through the given servers.
|
||||
// Note we do this here and not above so we support the right-side *.
|
||||
if r.Type == FORWARD {
|
||||
ret = append(ret, r)
|
||||
continue
|
||||
}
|
||||
|
||||
ar, err := v.resolve(rcount+1, r.Addr, tr)
|
||||
if err != nil {
|
||||
tr.Debugf("%d| resolve(%q) returned error: %v", rcount, r.Addr, err)
|
||||
@@ -384,8 +394,8 @@ func (v *Resolver) AddAliasesFile(domain, path string) (int, error) {
|
||||
|
||||
// AddAliasForTesting adds an alias to the resolver, for testing purposes.
|
||||
// Not for use in production code.
|
||||
func (v *Resolver) AddAliasForTesting(addr, rcpt string, rType RType) {
|
||||
v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, rType})
|
||||
func (v *Resolver) AddAliasForTesting(addr, rcpt string, via []string, rType RType) {
|
||||
v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, via, rType})
|
||||
}
|
||||
|
||||
// Reload aliases files for all known domains.
|
||||
@@ -473,6 +483,7 @@ func (v *Resolver) parseReader(domain string, r io.Reader) (map[string][]Recipie
|
||||
}
|
||||
|
||||
func parseRHS(rawalias, domain string) ([]Recipient, error) {
|
||||
var err error
|
||||
if len(rawalias) == 0 {
|
||||
// Explicitly allow empty rawalias strings at this point: the file
|
||||
// parsing will prevent this at the upper level, and when we parse the
|
||||
@@ -485,7 +496,7 @@ func parseRHS(rawalias, domain string) ([]Recipient, error) {
|
||||
// A pipe alias without a command is invalid.
|
||||
return nil, fmt.Errorf("the pipe alias is missing a command")
|
||||
}
|
||||
return []Recipient{{cmd, PIPE}}, nil
|
||||
return []Recipient{{cmd, nil, PIPE}}, nil
|
||||
}
|
||||
|
||||
rs := []Recipient{}
|
||||
@@ -497,20 +508,67 @@ func parseRHS(rawalias, domain string) ([]Recipient, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Addresses with no domain get the current one added, so it's
|
||||
// easier to share alias files.
|
||||
if !strings.Contains(a, "@") {
|
||||
a = a + "@" + domain
|
||||
r := Recipient{
|
||||
Addr: a,
|
||||
Via: nil,
|
||||
Type: EMAIL,
|
||||
}
|
||||
a, err := normalize.Addr(a)
|
||||
if strings.Contains(a, " via ") {
|
||||
// It's a FORWARD, so extract the Via part and continue.
|
||||
r.Type = FORWARD
|
||||
r.Addr, r.Via, err = parseForward(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses with no domain get the current one added, so it's
|
||||
// easier to share alias files. Note we also do this for FORWARD
|
||||
// aliases.
|
||||
if !strings.Contains(r.Addr, "@") {
|
||||
r.Addr = r.Addr + "@" + domain
|
||||
}
|
||||
|
||||
r.Addr, err = normalize.Addr(r.Addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalizing address %q: %w", a, err)
|
||||
}
|
||||
rs = append(rs, Recipient{a, EMAIL})
|
||||
|
||||
rs = append(rs, r)
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// Parse a raw FORWARD alias, returning the address and the via part.
|
||||
// Expected format is "address via server1/server2/server3".
|
||||
func parseForward(rawalias string) (string, []string, error) {
|
||||
// Split the alias into the address and the via part.
|
||||
addr, viaS, found := strings.Cut(rawalias, " via ")
|
||||
if !found {
|
||||
return "", nil, fmt.Errorf("via separator not found")
|
||||
}
|
||||
|
||||
// No need to normalize the address, the caller will do it.
|
||||
// We only trim it, and enforce that there is one.
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return "", nil, fmt.Errorf("forwarding alias is missing the address")
|
||||
}
|
||||
|
||||
// The via part is a list of servers, separated by "/". Split it up.
|
||||
// For now we don't validate the servers, but we may in the future.
|
||||
via := []string{}
|
||||
for _, v := range strings.Split(viaS, "/") {
|
||||
server := strings.TrimSpace(v)
|
||||
if server == "" {
|
||||
return "", nil, fmt.Errorf("empty server in via list")
|
||||
}
|
||||
via = append(via, server)
|
||||
}
|
||||
|
||||
return addr, via, nil
|
||||
}
|
||||
|
||||
// removeAllAfter removes everything from s that comes after the separators,
|
||||
// including them.
|
||||
func removeAllAfter(s, seps string) string {
|
||||
|
||||
@@ -86,11 +86,15 @@ func usersWithXErrorYDontExist(tr *trace.Trace, user, domain string) (bool, erro
|
||||
}
|
||||
|
||||
func email(addr string) Recipient {
|
||||
return Recipient{addr, EMAIL}
|
||||
return Recipient{addr, nil, EMAIL}
|
||||
}
|
||||
|
||||
func pipe(addr string) Recipient {
|
||||
return Recipient{addr, PIPE}
|
||||
return Recipient{addr, nil, PIPE}
|
||||
}
|
||||
|
||||
func forward(addr string, via []string) Recipient {
|
||||
return Recipient{addr, via, FORWARD}
|
||||
}
|
||||
|
||||
func TestBasic(t *testing.T) {
|
||||
@@ -101,17 +105,20 @@ func TestBasic(t *testing.T) {
|
||||
"a@localA": {email("c@d"), email("e@localB")},
|
||||
"e@localB": {pipe("cmd")},
|
||||
"cmd@localA": {email("x@y")},
|
||||
"x@localA": {forward("z@localA", []string{"serverX"})},
|
||||
}
|
||||
|
||||
cases := Cases{
|
||||
{"a@localA", []Recipient{email("c@d"), pipe("cmd")}, nil},
|
||||
{"e@localB", []Recipient{pipe("cmd")}, nil},
|
||||
{"x@y", []Recipient{email("x@y")}, nil},
|
||||
{"x@localA", []Recipient{
|
||||
forward("z@localA", []string{"serverX"})}, nil},
|
||||
}
|
||||
cases.check(t, resolver)
|
||||
|
||||
mustExist(t, resolver, "a@localA", "e@localB", "cmd@localA")
|
||||
mustNotExist(t, resolver, "x@y")
|
||||
mustExist(t, resolver, "a@localA", "e@localB", "cmd@localA", "x@localA")
|
||||
mustNotExist(t, resolver, "x@y", "z@localA")
|
||||
}
|
||||
|
||||
func TestCatchAll(t *testing.T) {
|
||||
@@ -157,6 +164,7 @@ func TestRightSideAsterisk(t *testing.T) {
|
||||
resolver.AddDomain("dom3")
|
||||
resolver.AddDomain("dom4")
|
||||
resolver.AddDomain("dom5")
|
||||
resolver.AddDomain("dom6")
|
||||
resolver.aliases = map[string][]Recipient{
|
||||
"a@dom1": {email("aaa@remote")},
|
||||
|
||||
@@ -183,6 +191,10 @@ func TestRightSideAsterisk(t *testing.T) {
|
||||
// This checks which one is used as the "original" user.
|
||||
"a@dom5": {email("b@dom5")},
|
||||
"*@dom5": {email("*@remote")},
|
||||
|
||||
// A forward on the right side.
|
||||
// It forwards to a local domain also check that there's no recursion.
|
||||
"*@dom6": {forward("*@dom1", []string{"server"})},
|
||||
}
|
||||
|
||||
cases := Cases{
|
||||
@@ -218,6 +230,10 @@ func TestRightSideAsterisk(t *testing.T) {
|
||||
{"a@dom5", []Recipient{email("b@remote")}, nil},
|
||||
{"b@dom5", []Recipient{email("b@remote")}, nil},
|
||||
{"c@dom5", []Recipient{email("c@remote")}, nil},
|
||||
|
||||
// Forwarding case.
|
||||
{"a@dom6", []Recipient{forward("a@dom1", []string{"server"})}, nil},
|
||||
{"b@dom6", []Recipient{forward("b@dom1", []string{"server"})}, nil},
|
||||
}
|
||||
cases.check(t, resolver)
|
||||
}
|
||||
@@ -527,6 +543,11 @@ func TestAddFile(t *testing.T) {
|
||||
|
||||
{"a: c@d, e@f, g\n",
|
||||
[]Recipient{email("c@d"), email("e@f"), email("g@dom")}},
|
||||
|
||||
{"a: b@c, b via sA/sB, d\n", []Recipient{
|
||||
email("b@c"),
|
||||
forward("b@dom", []string{"sA", "sB"}),
|
||||
email("d@dom")}},
|
||||
}
|
||||
|
||||
tr := trace.New("test", "TestAddFile")
|
||||
@@ -564,6 +585,7 @@ func TestAddFile(t *testing.T) {
|
||||
{"a@dom: b@c \n", "left-side: cannot contain @"},
|
||||
{"a", "line 1: missing ':' in line"},
|
||||
{"a: x y z\n", "disallowed rune encountered"},
|
||||
{"a: f via sA//sB\n", "empty server in via list"},
|
||||
}
|
||||
|
||||
for _, c := range errcases {
|
||||
@@ -607,6 +629,9 @@ ppp1: p.q+r
|
||||
ppp2: p.q
|
||||
ppp3: ppp2
|
||||
|
||||
# Test some forwarding cases.
|
||||
f1: f2 via server1, f3 via server2/server3, c
|
||||
|
||||
# Finally one to make the file NOT end in \n:
|
||||
y: z`
|
||||
|
||||
@@ -622,8 +647,8 @@ func TestRichFile(t *testing.T) {
|
||||
t.Fatalf("failed to add file: %v", err)
|
||||
}
|
||||
|
||||
if n != 11 {
|
||||
t.Fatalf("expected 11 aliases, got %d", n)
|
||||
if n != 12 {
|
||||
t.Fatalf("expected 12 aliases, got %d", n)
|
||||
}
|
||||
|
||||
cases := Cases{
|
||||
@@ -647,6 +672,12 @@ func TestRichFile(t *testing.T) {
|
||||
{"ppp2@dom", []Recipient{email("pb@dom")}, nil},
|
||||
{"ppp3@dom", []Recipient{email("pb@dom")}, nil},
|
||||
|
||||
{"f1@dom", []Recipient{
|
||||
forward("f2@dom", []string{"server1"}),
|
||||
forward("f3@dom", []string{"server2", "server3"}),
|
||||
email("d@e"), email("f@dom"),
|
||||
}, nil},
|
||||
|
||||
{"y@dom", []Recipient{email("z@dom")}, nil},
|
||||
}
|
||||
cases.check(t, resolver)
|
||||
@@ -779,6 +810,48 @@ func TestHook(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForward(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
addr string
|
||||
via []string
|
||||
err string
|
||||
}{
|
||||
{"", "", nil, "via separator not found"},
|
||||
{"via", "", nil, "via separator not found"},
|
||||
{"via ", "", nil, "via separator not found"},
|
||||
{" via ", "", nil, "forwarding alias is missing the address"},
|
||||
{" via S1", "", nil, "forwarding alias is missing the address"},
|
||||
{"a via ", "", nil, "empty server in via list"},
|
||||
{"a via S1", "a", []string{"S1"}, ""},
|
||||
{"a via S1/S2", "a", []string{"S1", "S2"}, ""},
|
||||
{"a via S1 / S2 ", "a", []string{"S1", "S2"}, ""},
|
||||
{"a via S1/S2/", "", nil, "empty server in via list"},
|
||||
{"a via S1/S2/ ", "", nil, "empty server in via list"},
|
||||
{"a via S1//S2", "", nil, "empty server in via list"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
addr, via, err := parseForward(c.raw)
|
||||
|
||||
if err != nil && !strings.Contains(err.Error(), c.err) {
|
||||
t.Errorf("case %q: got error %v, expected to contain %q",
|
||||
c.raw, err, c.err)
|
||||
continue
|
||||
} else if err == nil && c.err != "" {
|
||||
t.Errorf("case %q: got nil error, expected %q", c.raw, c.err)
|
||||
t.Logf(" got addr: %q, via: %q", addr, via)
|
||||
continue
|
||||
}
|
||||
|
||||
if addr != c.addr {
|
||||
t.Errorf("case %q: got addr %q, expected %q", c.raw, addr, c.addr)
|
||||
}
|
||||
if !reflect.DeepEqual(via, c.via) {
|
||||
t.Errorf("case %q: got via %q, expected %q", c.raw, via, c.via)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzz testing for the parser.
|
||||
func FuzzReader(f *testing.F) {
|
||||
resolver := NewResolver(allUsersExist)
|
||||
|
||||
Reference in New Issue
Block a user