1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819package command2021import (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"3536 "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)4546const modName = "check.command"4748type Stage string4950const (51 StageConnection = "conn"52 StageSender = "sender"53 StageRcpt = "rcpt"54 StageBody = "body"55)5657var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`)5859type Check struct {60 instName string61 log log.Logger6263 stage Stage64 actions map[int]modconfig.FailAction65 cmd string66 cmdArgs []string67}6869func 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 }8182 if len(inlineArgs) == 0 {83 return nil, errors.New("command: at least one argument is required (command name)")84 }8586 c.cmd = inlineArgs[0]87 c.cmdArgs = inlineArgs[1:]8889 return c, nil90}9192func (c *Check) Name() string {93 return modName94}9596func (c *Check) InstanceName() string {97 return c.instName98}99100func (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 }105106 cfg.Enum("run_on", false, false,107 []string{StageConnection, StageSender, StageRcpt, StageBody}, StageBody,108 (*string)(&c.stage))109110 cfg.AllowUnknown()111 unknown, err := cfg.Process()112 if err != nil {113 return err114 }115116 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 }130131 c.actions[exitCode] = action132 default:133 return config.NodeErr(node, "unexpected directive: %v", node.Name)134 }135 }136137 return nil138}139140type state struct {141 c *Check142 msgMeta *module.MsgMetadata143 log log.Logger144145 mailFrom string146 rcpts []string147}148149func (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 }, nil155}156157func (s *state) expandCommand(address string) (string, []string) {158 expArgs := make([]string, len(s.c.cmdArgs))159160 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.AuthUser168 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.Hostname182 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.ID196 case "{sender}":197 return s.mailFrom198 case "{rcpts}":199 return strings.Join(s.rcpts, "\n")200 case "{address}":201 return address202 }203 return placeholder204 })205 }206207 return s.c.cmd, expArgs208}209210func (s *state) run(cmdName string, args []string, stdin io.Reader) module.CheckResult {211 cmd := exec.Command(cmdName, args...)212 cmd.Stdin = stdin213 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 }228229 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 }243244 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 }250251 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 }264265 res := module.CheckResult{}266 res.Header = hdr267268 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 do272 // 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 res280}281282func (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 = true295 return res296 }297298 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 = true312 return res313 }314315 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 }325326 return action.Apply(res)327}328329func (s *state) CheckConnection(ctx context.Context) module.CheckResult {330 if s.c.stage != StageConnection {331 return module.CheckResult{}332 }333334 defer trace.StartRegion(ctx, "command/CheckConnection-"+s.c.cmd).End()335336 cmdName, cmdArgs := s.expandCommand("")337 return s.run(cmdName, cmdArgs, bytes.NewReader(nil))338}339340func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {341 s.mailFrom = addr342343 if s.c.stage != StageSender {344 return module.CheckResult{}345 }346347 defer trace.StartRegion(ctx, "command/CheckSender"+s.c.cmd).End()348349 cmdName, cmdArgs := s.expandCommand(addr)350 return s.run(cmdName, cmdArgs, bytes.NewReader(nil))351}352353func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {354 s.rcpts = append(s.rcpts, addr)355356 if s.c.stage != StageRcpt {357 return module.CheckResult{}358 }359 defer trace.StartRegion(ctx, "command/CheckRcpt"+s.c.cmd).End()360361 cmdName, cmdArgs := s.expandCommand(addr)362 return s.run(cmdName, cmdArgs, bytes.NewReader(nil))363}364365func (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 }369370 defer trace.StartRegion(ctx, "command/CheckBody"+s.c.cmd).End()371372 cmdName, cmdArgs := s.expandCommand("")373374 var buf bytes.Buffer375 _ = 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 }391392 return s.run(cmdName, cmdArgs, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))393}394395func (s *state) Close() error {396 return nil397}398399func init() {400 module.Register(modName, New)401}