Skip to content
Snippets Groups Projects
Commit 76ffe05d authored by ale's avatar ale
Browse files

Initial commit

parents
Branches
No related tags found
No related merge requests found
image: docker:latest
stages:
- build
- release
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
RELEASE_TAG: $CI_REGISTRY_IMAGE:latest
before_script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.git.autistici.org
build:
stage: build
script:
- docker build --pull -t $IMAGE_TAG .
- docker push $IMAGE_TAG
release:
stage: release
script:
- docker pull $IMAGE_TAG
- docker tag $IMAGE_TAG $RELEASE_TAG
- docker push $RELEASE_TAG
only:
- master
FROM golang:latest AS build
ADD . /go/src
WORKDIR /go/src
RUN go build -tags netgo -o /smtpd smtpd.go && strip /smtpd
FROM scratch
COPY --from=build /smtpd /
smtpd-pipe
===
A very minimal SMTP server that will only forward messages to shell
commands via UNIX pipes. Its intended usage is to provide an SMTP
endpoint for containerized services that expect to receive email over
a pipe, without having to run a full MTA.
# Usage
The daemon can be configured with a list of regular expression / shell
command-line pairs: if a message matches a regular expression, the
associated command will be executed. Messages that do not match
anything will be rejected.
Rules (regexp / command pairs) are encoded as a colon-separated string
pair (which implies that the regexp can't contain a colon). They can
be passed to the program via the command-line option *--rule*
(repeated multiple times), or via the environment variable
*SMTP_RULES*, which should be a semicolon-separated list of rules.
Regular expressions are automatically anchored, so they should match
the full recipient address, including the domain part (but there is no
need to include the *^* and *$* anchors). Submatches can be used and
referenced in the command using the `$N` syntax, e.g.:
```
smtpd --rule='archive+(.*)@example.com:/usr/local/bin/archive $1'
```
Further command-line options are available to set network timeouts and
various execution limits, see *smtpd --help* for details.
go.mod 0 → 100644
smtpd.go 0 → 100644
// Simple SMTP server that delivers emails to command-line processors.
//
// Useful to export SMTP APIs in containers, decoupling the message
// delivery from the local system-wide MTA.
//
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/exec"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/chrj/smtpd"
)
type deliveryRule struct {
rx *regexp.Regexp
command string
}
type deliveryRules []deliveryRule
func (m deliveryRules) String() string {
return ""
}
func (m *deliveryRules) Set(value string) error {
// Split on ':'.
n := strings.Index(value, ":")
if n < 0 {
return errors.New("not in 'pattern:command' format")
}
pattern := value[:n]
command := strings.TrimSpace(value[n+1:])
// Auto-anchor the regexps.
rx, err := regexp.Compile(fmt.Sprintf("^%s$", pattern))
if err != nil {
return fmt.Errorf("invalid regexp: %v", err)
}
*m = append(*m, deliveryRule{
rx: rx,
command: command,
})
return nil
}
var (
port = flag.Int("port", getenvInt("SMTP_PORT", 3025), "smtp port")
hostname = flag.String("ehlo", os.Getenv("SMTP_EHLO"), "EHLO hostname")
guardFile = flag.String("guard-file", os.Getenv("SMTP_GUARD_FILE"), "suspend delivery (with a temporary error) if this file exists")
readTimeout = flag.Duration("read-timeout", getenvDuration("SMTP_READ_TIMEOUT", 60*time.Second), "socket read timeout")
writeTimeout = flag.Duration("write-timeout", getenvDuration("SMTP_WRITE_TIMEOUT", 60*time.Second), "socket write timeout")
dataTimeout = flag.Duration("data-timeout", getenvDuration("SMTP_DATA_TIMEOUT", 5*time.Minute), "socket timeout for DATA command")
commandTimeout = flag.Duration("command-timeout", getenvDuration("SMTP_COMMAND_TIMEOUT", 1*time.Minute), "timeout for command execution")
maxConnections = flag.Int("max-connections", getenvInt("SMTP_MAX_CONNECTIONS", 100), "max concurrent connections")
maxRecipients = flag.Int("max-recipients", getenvInt("SMTP_MAX_RECIPIENTS", 100), "max number of recipients per message")
maxMessageSize = flag.Int("max-message-size", getenvInt("SMTP_MAX_MESSAGE_SIZE", 10*1024*1024), "maximum message size, in bytes")
rules deliveryRules
)
func init() {
// Parse the SMTP_RULES environment variable as a
// semicolon-separated list of regex: command patterns.
for _, s := range strings.Split(os.Getenv("SMTP_RULES"), ";") {
rules.Set(s) // nolint: errcheck
}
flag.Var(&rules, "rule", "delivery rules (in 'regexp:command' format)")
}
func getenvInt(key string, dflt int) int {
if s := os.Getenv(key); s != "" {
if n, err := strconv.Atoi(s); err == nil {
return n
}
}
return dflt
}
func getenvDefault(key, dflt string) string {
if s := os.Getenv(key); s != "" {
return s
}
return dflt
}
func getenvDuration(key string, dflt time.Duration) time.Duration {
if s := os.Getenv(key); s != "" {
if d, err := time.ParseDuration(s); err == nil {
return d
}
}
return dflt
}
func isMaintenanceModeOn() bool {
if *guardFile == "" {
return false
}
_, err := os.Stat(*guardFile)
return !os.IsNotExist(err)
}
func runCommand(command string, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), *commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
cmd.Stdin = bytes.NewReader(data)
out, err := cmd.CombinedOutput()
if err != nil {
outStr := strings.Replace(strings.TrimSpace(string(out)), "\n", "; ", -1)
log.Printf("error: command '%s' failed: %v: %s", command, err, outStr)
return smtpd.Error{550, outStr}
}
return nil
}
func findCommandFor(rcpt string) (string, bool) {
for _, rule := range rules {
match := rule.rx.FindStringSubmatchIndex(rcpt)
if len(match) > 0 {
command := rule.rx.ExpandString(nil, rule.command, rcpt, match)
return string(command), true
}
}
return "", false
}
func handleMessage(peer smtpd.Peer, env smtpd.Envelope) error {
if isMaintenanceModeOn() {
return smtpd.Error{421, "Maintenance mode, retry later"}
}
log.Printf("received msg: %s -> %s", env.Sender, strings.Join(env.Recipients, ","))
// Confirm that all recipients are valid before proceeding.
var commands []string
for _, rcpt := range env.Recipients {
c, ok := findCommandFor(rcpt)
if !ok {
log.Printf("unknown recipient %s", rcpt)
return smtpd.Error{550, "Unknown recipient"}
}
commands = append(commands, c)
}
// Now execute all the commands (in series, because it's
// simpler).
for _, c := range commands {
if err := runCommand(c, env.Data); err != nil {
return err
}
}
return nil
}
func main() {
log.SetFlags(0)
flag.Parse()
server := &smtpd.Server{
Hostname: *hostname,
//RecipientChecker: checkRecipient,
Handler: handleMessage,
//ProtocolLogger: log.New(os.Stderr, "smtp: ", 0),
ReadTimeout: *readTimeout,
WriteTimeout: *writeTimeout,
DataTimeout: *dataTimeout,
MaxConnections: *maxConnections,
MaxMessageSize: *maxMessageSize,
MaxRecipients: *maxRecipients,
}
// Create our own listener so we can shut down cleanly.
l, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
log.Fatal(err)
}
sigCh := make(chan os.Signal)
go func() {
<-sigCh
l.Close()
}()
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
if err := server.Serve(l); err != nil {
log.Fatal(err)
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment