maddy

Fork https://github.com/foxcpp/maddy

git clone git://git.lin.moe/go/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19package command
 20
 21import (
 22	"bufio"
 23	"bytes"
 24	"context"
 25	"errors"
 26	"fmt"
 27	"io"
 28	"net"
 29	"os"
 30	"os/exec"
 31	"regexp"
 32	"runtime/trace"
 33	"strconv"
 34	"strings"
 35
 36	"github.com/emersion/go-message/textproto"
 37	"github.com/foxcpp/maddy/framework/buffer"
 38	"github.com/foxcpp/maddy/framework/config"
 39	modconfig "github.com/foxcpp/maddy/framework/config/module"
 40	"github.com/foxcpp/maddy/framework/exterrors"
 41	"github.com/foxcpp/maddy/framework/log"
 42	"github.com/foxcpp/maddy/framework/module"
 43	"github.com/foxcpp/maddy/internal/target"
 44)
 45
 46const modName = "check.command"
 47
 48type Stage string
 49
 50const (
 51	StageConnection = "conn"
 52	StageSender     = "sender"
 53	StageRcpt       = "rcpt"
 54	StageBody       = "body"
 55)
 56
 57var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`)
 58
 59type Check struct {
 60	instName string
 61	log      log.Logger
 62
 63	stage   Stage
 64	actions map[int]modconfig.FailAction
 65	cmd     string
 66	cmdArgs []string
 67}
 68
 69func New(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
 70	c := &Check{
 71		instName: instName,
 72		actions: map[int]modconfig.FailAction{
 73			1: {
 74				Reject: true,
 75			},
 76			2: {
 77				Quarantine: true,
 78			},
 79		},
 80	}
 81
 82	if len(inlineArgs) == 0 {
 83		return nil, errors.New("command: at least one argument is required (command name)")
 84	}
 85
 86	c.cmd = inlineArgs[0]
 87	c.cmdArgs = inlineArgs[1:]
 88
 89	return c, nil
 90}
 91
 92func (c *Check) Name() string {
 93	return modName
 94}
 95
 96func (c *Check) InstanceName() string {
 97	return c.instName
 98}
 99
100func (c *Check) Init(cfg *config.Map) error {
101	// Check whether the inline argument command is usable.
102	if _, err := exec.LookPath(c.cmd); err != nil {
103		return fmt.Errorf("command: %w", err)
104	}
105
106	cfg.Enum("run_on", false, false,
107		[]string{StageConnection, StageSender, StageRcpt, StageBody}, StageBody,
108		(*string)(&c.stage))
109
110	cfg.AllowUnknown()
111	unknown, err := cfg.Process()
112	if err != nil {
113		return err
114	}
115
116	for _, node := range unknown {
117		switch node.Name {
118		case "code":
119			if len(node.Args) < 2 {
120				return config.NodeErr(node, "at least two arguments are required: <code> <action>")
121			}
122			exitCode, err := strconv.Atoi(node.Args[0])
123			if err != nil {
124				return config.NodeErr(node, "%v", err)
125			}
126			action, err := modconfig.ParseActionDirective(node.Args[1:])
127			if err != nil {
128				return config.NodeErr(node, "%v", err)
129			}
130
131			c.actions[exitCode] = action
132		default:
133			return config.NodeErr(node, "unexpected directive: %v", node.Name)
134		}
135	}
136
137	return nil
138}
139
140type state struct {
141	c       *Check
142	msgMeta *module.MsgMetadata
143	log     log.Logger
144
145	mailFrom string
146	rcpts    []string
147}
148
149func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
150	return &state{
151		c:       c,
152		msgMeta: msgMeta,
153		log:     target.DeliveryLogger(c.log, msgMeta),
154	}, nil
155}
156
157func (s *state) expandCommand(address string) (string, []string) {
158	expArgs := make([]string, len(s.c.cmdArgs))
159
160	for i, arg := range s.c.cmdArgs {
161		expArgs[i] = placeholderRe.ReplaceAllStringFunc(arg, func(placeholder string) string {
162			switch placeholder {
163			case "{auth_user}":
164				if s.msgMeta.Conn == nil {
165					return ""
166				}
167				return s.msgMeta.Conn.AuthUser
168			case "{source_ip}":
169				if s.msgMeta.Conn == nil {
170					return ""
171				}
172				tcpAddr, _ := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
173				if tcpAddr == nil {
174					return ""
175				}
176				return tcpAddr.IP.String()
177			case "{source_host}":
178				if s.msgMeta.Conn == nil {
179					return ""
180				}
181				return s.msgMeta.Conn.Hostname
182			case "{source_rdns}":
183				if s.msgMeta.Conn == nil {
184					return ""
185				}
186				valI, err := s.msgMeta.Conn.RDNSName.Get()
187				if err != nil {
188					return ""
189				}
190				if valI == nil {
191					return ""
192				}
193				return valI.(string)
194			case "{msg_id}":
195				return s.msgMeta.ID
196			case "{sender}":
197				return s.mailFrom
198			case "{rcpts}":
199				return strings.Join(s.rcpts, "\n")
200			case "{address}":
201				return address
202			}
203			return placeholder
204		})
205	}
206
207	return s.c.cmd, expArgs
208}
209
210func (s *state) run(cmdName string, args []string, stdin io.Reader) module.CheckResult {
211	cmd := exec.Command(cmdName, args...)
212	cmd.Stdin = stdin
213	stdout, err := cmd.StdoutPipe()
214	if err != nil {
215		return module.CheckResult{
216			Reason: &exterrors.SMTPError{
217				Code:      450,
218				Message:   "Internal server error",
219				CheckName: "command",
220				Err:       err,
221				Misc: map[string]interface{}{
222					"cmd": cmd.String(),
223				},
224			},
225			Reject: true,
226		}
227	}
228
229	if err := cmd.Start(); err != nil {
230		return module.CheckResult{
231			Reason: &exterrors.SMTPError{
232				Code:      450,
233				Message:   "Internal server error",
234				CheckName: "command",
235				Err:       err,
236				Misc: map[string]interface{}{
237					"cmd": cmd.String(),
238				},
239			},
240			Reject: true,
241		}
242	}
243
244	bufOut := bufio.NewReader(stdout)
245	hdr, err := textproto.ReadHeader(bufOut)
246	if err != nil && !errors.Is(err, io.EOF) {
247		if err := cmd.Process.Signal(os.Interrupt); err != nil {
248			s.log.Error("failed to kill process", err)
249		}
250
251		return module.CheckResult{
252			Reason: &exterrors.SMTPError{
253				Code:      450,
254				Message:   "Internal server error",
255				CheckName: "command",
256				Err:       err,
257				Misc: map[string]interface{}{
258					"cmd": cmd.String(),
259				},
260			},
261			Reject: true,
262		}
263	}
264
265	res := module.CheckResult{}
266	res.Header = hdr
267
268	err = cmd.Wait()
269	if err != nil {
270		if _, ok := err.(*exec.ExitError); !ok {
271			// If that's not ExitError, the process may still be running. We do
272			// not want this.
273			if err := cmd.Process.Signal(os.Interrupt); err != nil {
274				s.log.Error("failed to kill process", err)
275			}
276		}
277		return s.errorRes(err, res, cmd.String())
278	}
279	return res
280}
281
282func (s *state) errorRes(err error, res module.CheckResult, cmdLine string) module.CheckResult {
283	exitErr, ok := err.(*exec.ExitError)
284	if !ok {
285		res.Reason = &exterrors.SMTPError{
286			Code:      450,
287			Message:   "Internal server error",
288			CheckName: "command",
289			Err:       err,
290			Misc: map[string]interface{}{
291				"cmd": cmdLine,
292			},
293		}
294		res.Reject = true
295		return res
296	}
297
298	action, ok := s.c.actions[exitErr.ExitCode()]
299	if !ok {
300		res.Reason = &exterrors.SMTPError{
301			Code:      450,
302			Message:   "Internal server error",
303			CheckName: "command",
304			Err:       err,
305			Reason:    "unexpected exit code",
306			Misc: map[string]interface{}{
307				"cmd":       cmdLine,
308				"exit_code": exitErr.ExitCode(),
309			},
310		}
311		res.Reject = true
312		return res
313	}
314
315	res.Reason = &exterrors.SMTPError{
316		Code:         550,
317		EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
318		Message:      "Message rejected for due to a local policy",
319		CheckName:    "command",
320		Misc: map[string]interface{}{
321			"cmd":       cmdLine,
322			"exit_code": exitErr.ExitCode(),
323		},
324	}
325
326	return action.Apply(res)
327}
328
329func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
330	if s.c.stage != StageConnection {
331		return module.CheckResult{}
332	}
333
334	defer trace.StartRegion(ctx, "command/CheckConnection-"+s.c.cmd).End()
335
336	cmdName, cmdArgs := s.expandCommand("")
337	return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
338}
339
340func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
341	s.mailFrom = addr
342
343	if s.c.stage != StageSender {
344		return module.CheckResult{}
345	}
346
347	defer trace.StartRegion(ctx, "command/CheckSender"+s.c.cmd).End()
348
349	cmdName, cmdArgs := s.expandCommand(addr)
350	return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
351}
352
353func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
354	s.rcpts = append(s.rcpts, addr)
355
356	if s.c.stage != StageRcpt {
357		return module.CheckResult{}
358	}
359	defer trace.StartRegion(ctx, "command/CheckRcpt"+s.c.cmd).End()
360
361	cmdName, cmdArgs := s.expandCommand(addr)
362	return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
363}
364
365func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
366	if s.c.stage != StageBody {
367		return module.CheckResult{}
368	}
369
370	defer trace.StartRegion(ctx, "command/CheckBody"+s.c.cmd).End()
371
372	cmdName, cmdArgs := s.expandCommand("")
373
374	var buf bytes.Buffer
375	_ = textproto.WriteHeader(&buf, hdr)
376	bR, err := body.Open()
377	if err != nil {
378		return module.CheckResult{
379			Reason: &exterrors.SMTPError{
380				Code:      450,
381				Message:   "Internal server error",
382				CheckName: "command",
383				Err:       err,
384				Misc: map[string]interface{}{
385					"cmd": cmdName + " " + strings.Join(cmdArgs, " "),
386				},
387			},
388			Reject: true,
389		}
390	}
391
392	return s.run(cmdName, cmdArgs, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))
393}
394
395func (s *state) Close() error {
396	return nil
397}
398
399func init() {
400	module.Register(modName, New)
401}