mirror of
https://github.com/foxcpp/maddy.git
synced 2025-04-05 14:07:38 +03:00
The intention is to keep to repo root clean while the list of packages is slowly growing. Additionally, a bunch of small (~30 LoC) files in the repo root is merged into a single maddy.go file, for the same reason. Most of the internal code is moved into the internal/ directory. Go toolchain will make it impossible to import these packages from external applications. Some packages are renamed and moved into the pkg/ directory in the root. According to https://github.com/golang-standards/project-layout this is the de-facto standard to place "library code that's ok to use by external applications" in. To clearly define the purpose of top-level directories, README.md files are added to each.
362 lines
7.6 KiB
Go
362 lines
7.6 KiB
Go
package command
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/emersion/go-message/textproto"
|
|
"github.com/foxcpp/maddy/internal/buffer"
|
|
"github.com/foxcpp/maddy/internal/check"
|
|
"github.com/foxcpp/maddy/internal/config"
|
|
"github.com/foxcpp/maddy/internal/exterrors"
|
|
"github.com/foxcpp/maddy/internal/log"
|
|
"github.com/foxcpp/maddy/internal/module"
|
|
"github.com/foxcpp/maddy/internal/target"
|
|
)
|
|
|
|
const modName = "command"
|
|
|
|
type Stage string
|
|
|
|
const (
|
|
StageConnection = "conn"
|
|
StageSender = "sender"
|
|
StageRcpt = "rcpt"
|
|
StageBody = "body"
|
|
)
|
|
|
|
var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`)
|
|
|
|
type Check struct {
|
|
instName string
|
|
log log.Logger
|
|
|
|
stage Stage
|
|
actions map[int]check.FailAction
|
|
cmd string
|
|
cmdArgs []string
|
|
}
|
|
|
|
func New(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
|
|
c := &Check{
|
|
instName: instName,
|
|
actions: map[int]check.FailAction{
|
|
1: check.FailAction{
|
|
Reject: true,
|
|
},
|
|
2: check.FailAction{
|
|
Quarantine: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
if len(inlineArgs) == 0 {
|
|
return nil, errors.New("command: at least one argument is required (command name)")
|
|
}
|
|
|
|
c.cmd = inlineArgs[0]
|
|
c.cmdArgs = inlineArgs[1:]
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Check) Name() string {
|
|
return modName
|
|
}
|
|
|
|
func (c *Check) InstanceName() string {
|
|
return c.instName
|
|
}
|
|
|
|
func (c *Check) Init(cfg *config.Map) error {
|
|
// Check whether the inline argument command is usable.
|
|
if _, err := exec.LookPath(c.cmd); err != nil {
|
|
return fmt.Errorf("command: %w", err)
|
|
}
|
|
|
|
cfg.Enum("run_on", false, false,
|
|
[]string{StageConnection, StageSender, StageRcpt, StageBody}, StageBody,
|
|
(*string)(&c.stage))
|
|
|
|
cfg.AllowUnknown()
|
|
unknown, err := cfg.Process()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, node := range unknown {
|
|
switch node.Name {
|
|
case "code":
|
|
if len(node.Args) < 2 {
|
|
return config.NodeErr(&node, "at least two arguments are required: <code> <action>")
|
|
}
|
|
exitCode, err := strconv.Atoi(node.Args[0])
|
|
if err != nil {
|
|
return config.NodeErr(&node, "%v", err)
|
|
}
|
|
action, err := check.ParseActionDirective(node.Args[1:])
|
|
if err != nil {
|
|
return config.NodeErr(&node, "%v", err)
|
|
}
|
|
|
|
c.actions[exitCode] = action
|
|
default:
|
|
return config.NodeErr(&node, "unexpected directive: %v", node.Name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type state struct {
|
|
c *Check
|
|
msgMeta *module.MsgMetadata
|
|
log log.Logger
|
|
|
|
mailFrom string
|
|
rcpts []string
|
|
}
|
|
|
|
func (c *Check) CheckStateForMsg(msgMeta *module.MsgMetadata) (module.CheckState, error) {
|
|
return &state{
|
|
c: c,
|
|
msgMeta: msgMeta,
|
|
log: target.DeliveryLogger(c.log, msgMeta),
|
|
}, nil
|
|
}
|
|
|
|
func (s *state) expandCommand(address string) (string, []string) {
|
|
expArgs := make([]string, len(s.c.cmdArgs))
|
|
|
|
for i, arg := range s.c.cmdArgs {
|
|
expArgs[i] = placeholderRe.ReplaceAllStringFunc(arg, func(placeholder string) string {
|
|
switch placeholder {
|
|
case "{auth_user}":
|
|
if s.msgMeta.Conn == nil {
|
|
return ""
|
|
}
|
|
return s.msgMeta.Conn.AuthUser
|
|
case "{source_ip}":
|
|
if s.msgMeta.Conn == nil {
|
|
return ""
|
|
}
|
|
tcpAddr, _ := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
|
|
if tcpAddr == nil {
|
|
return ""
|
|
}
|
|
return tcpAddr.IP.String()
|
|
case "{source_host}":
|
|
if s.msgMeta.Conn == nil {
|
|
return ""
|
|
}
|
|
return s.msgMeta.Conn.Hostname
|
|
case "{source_rdns}":
|
|
if s.msgMeta.Conn == nil {
|
|
return ""
|
|
}
|
|
val, _ := s.msgMeta.Conn.RDNSName.Get().(string)
|
|
if val == "" {
|
|
return ""
|
|
}
|
|
return ""
|
|
case "{msg_id}":
|
|
return s.msgMeta.ID
|
|
case "{sender}":
|
|
return s.mailFrom
|
|
case "{rcpts}":
|
|
return strings.Join(s.rcpts, "\n")
|
|
case "{address}":
|
|
return address
|
|
}
|
|
return placeholder
|
|
})
|
|
}
|
|
|
|
return s.c.cmd, expArgs
|
|
}
|
|
|
|
func (s *state) run(cmdName string, args []string, stdin io.Reader) module.CheckResult {
|
|
cmd := exec.Command(cmdName, args...)
|
|
cmd.Stdin = stdin
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmd.String(),
|
|
},
|
|
},
|
|
Reject: true,
|
|
}
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmd.String(),
|
|
},
|
|
},
|
|
Reject: true,
|
|
}
|
|
}
|
|
defer cmd.Process.Signal(os.Interrupt)
|
|
|
|
bufOut := bufio.NewReader(stdout)
|
|
hdr, err := textproto.ReadHeader(bufOut)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmd.String(),
|
|
},
|
|
},
|
|
Reject: true,
|
|
}
|
|
}
|
|
|
|
res := module.CheckResult{}
|
|
res.Header = hdr
|
|
|
|
err = cmd.Wait()
|
|
if err != nil {
|
|
return s.errorRes(err, res, cmd.String())
|
|
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (s *state) errorRes(err error, res module.CheckResult, cmdLine string) module.CheckResult {
|
|
exitErr, ok := err.(*exec.ExitError)
|
|
if !ok {
|
|
res.Reason = &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmdLine,
|
|
},
|
|
}
|
|
res.Reject = true
|
|
return res
|
|
}
|
|
|
|
action, ok := s.c.actions[exitErr.ExitCode()]
|
|
if !ok {
|
|
res.Reason = &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Reason: "unexpected exit code",
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmdLine,
|
|
"exit_code": exitErr.ExitCode(),
|
|
},
|
|
}
|
|
res.Reject = true
|
|
return res
|
|
}
|
|
|
|
res.Reason = &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
|
|
Message: "Message rejected for due to a local policy",
|
|
CheckName: "command",
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmdLine,
|
|
"exit_code": exitErr.ExitCode(),
|
|
},
|
|
}
|
|
|
|
return action.Apply(res)
|
|
}
|
|
|
|
func (s *state) CheckConnection() module.CheckResult {
|
|
if s.c.stage != StageConnection {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
cmdName, cmdArgs := s.expandCommand("")
|
|
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
|
|
}
|
|
|
|
func (s *state) CheckSender(addr string) module.CheckResult {
|
|
s.mailFrom = addr
|
|
|
|
if s.c.stage != StageSender {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
cmdName, cmdArgs := s.expandCommand(addr)
|
|
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
|
|
}
|
|
|
|
func (s *state) CheckRcpt(addr string) module.CheckResult {
|
|
s.rcpts = append(s.rcpts, addr)
|
|
|
|
if s.c.stage != StageRcpt {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
cmdName, cmdArgs := s.expandCommand(addr)
|
|
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
|
|
}
|
|
|
|
func (s *state) CheckBody(hdr textproto.Header, body buffer.Buffer) module.CheckResult {
|
|
if s.c.stage != StageBody {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
cmdName, cmdArgs := s.expandCommand("")
|
|
|
|
var buf bytes.Buffer
|
|
_ = textproto.WriteHeader(&buf, hdr)
|
|
bR, err := body.Open()
|
|
if err != nil {
|
|
return module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 450,
|
|
Message: "Internal server error",
|
|
CheckName: "command",
|
|
Err: err,
|
|
Misc: map[string]interface{}{
|
|
"cmd": cmdName + " " + strings.Join(cmdArgs, " "),
|
|
},
|
|
},
|
|
Reject: true,
|
|
}
|
|
}
|
|
|
|
return s.run(cmdName, cmdArgs, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))
|
|
}
|
|
|
|
func (s *state) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
module.Register(modName, New)
|
|
}
|