1 Commits

Author SHA1 Message Date
Jonathon Reinhart
190c615029 Add SystemD unit file 2021-04-01 23:44:42 -04:00
20 changed files with 285 additions and 914 deletions

View File

@@ -1,53 +1,44 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL" name: "CodeQL"
on: on:
push: push:
branches: [master]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [master] branches: [master]
schedule: schedule:
- cron: '0 15 * * 5' - cron: '0 15 * * 5'
permissions:
contents: read
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['go'] language: ['go']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps: steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 uses: github/codeql-action/init@v1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +49,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -72,4 +63,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 uses: github/codeql-action/analyze@v1

View File

@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1

View File

@@ -1,26 +1,24 @@
name: Go name: Go
on: [push, pull_request] on: [push]
permissions:
contents: read
jobs: jobs:
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go 1.15
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: actions/setup-go@v2.1.3
with: with:
go-version: 'stable' go-version: 1.15
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v1
- name: Get dependencies
run: |
go get -v -t -d ./...
- name: Build - name: Build
run: go build -v . run: go build -v .
- name: Test
run: go test -v .

View File

@@ -4,9 +4,6 @@ on:
release: release:
types: [created] types: [created]
# Declare default permissions as read only.
permissions: read-all
jobs: jobs:
releases-matrix: releases-matrix:
name: Release Go Binary name: Release Go Binary
@@ -14,29 +11,20 @@ jobs:
strategy: strategy:
matrix: matrix:
goos: [freebsd, linux, windows] goos: [freebsd, linux, windows]
goarch: [amd64, arm64] goarch: ["386", amd64]
permissions:
contents: write
packages: write
steps: steps:
- name: Harden Runner - uses: actions/checkout@v2
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set APP_VERSION env - name: Set APP_VERSION env
run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV} run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV}
- name: Set BUILD_TIME env - name: Set BUILD_TIME env
run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV} run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
- uses: wangyoucao577/go-release-action@481a2c1a0f1be199722e3e9b74d7199acafc30a8 # v1.53 - uses: wangyoucao577/go-release-action@v1.15
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }} goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }} goarch: ${{ matrix.goarch }}
goversion: "1.24" goversion: "https://golang.org/dl/go1.15.8.linux-amd64.tar.gz"
extra_files: LICENSE README.md smtprelay.ini extra_files: LICENSE README.md smtprelay.ini
ldflags: -s -w -X "main.appVersion=${{ env.APP_VERSION }}" -X "main.buildTime=${{ env.BUILD_TIME }}" ldflags: -s -w -X "main.appVersion=${{ env.APP_VERSION }}" -X "main.buildTime=${{ env.BUILD_TIME }}"

View File

@@ -1,81 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '20 7 * * 2'
push:
branches: ["master"]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
contents: read
actions: read
# To allow GraphQL ListCommits to work
issues: read
pull-requests: read
# To detect SAST tools
checks: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecards on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
with:
sarif_file: results.sarif

View File

@@ -1,7 +1,6 @@
# smtprelay # smtprelay
[![Go Report Card](https://goreportcard.com/badge/github.com/decke/smtprelay)](https://goreportcard.com/report/github.com/decke/smtprelay) [![Go Report Card](https://goreportcard.com/badge/github.com/decke/smtprelay)](https://goreportcard.com/report/github.com/decke/smtprelay)
[![OpenSSF Scorecard](https://img.shields.io/ossf-scorecard/github.com/decke/smtprelay?label=openssf%20scorecard&style=flat)](https://scorecard.dev/viewer/?uri=github.com/decke/smtprelay)
Simple Golang based SMTP relay/proxy server that accepts mail via SMTP Simple Golang based SMTP relay/proxy server that accepts mail via SMTP
and forwards it directly to another SMTP server. and forwards it directly to another SMTP server.
@@ -17,17 +16,16 @@ configure.
My use case is simple. I need to send automatically generated mails from My use case is simple. I need to send automatically generated mails from
cron via msmtp/sSMTP/dma, mails from various services and network printers cron via msmtp/sSMTP/dma, mails from various services and network printers
via a remote SMTP server without giving away my mail credentials to each to GMail without giving away my GMail credentials to each device which
device which produces mail. produces mail.
## Main features ## Main features
* Simple configuration with ini file .env file or environment variables
* Supports SMTPS/TLS (465), STARTTLS (587) and unencrypted SMTP (25) * Supports SMTPS/TLS (465), STARTTLS (587) and unencrypted SMTP (25)
* Checks for sender, receiver, client IP * Checks for sender, receiver, client IP
* Authentication support with file (LOGIN, PLAIN) * Authentication support with file (LOGIN, PLAIN)
* Enforce encryption for authentication * Enforce encryption for authentication
* Forwards all mail to a smarthost (any SMTP server) * Forwards all mail to a smarthost (GMail, MailGun or any other SMTP server)
* Small codebase * Small codebase
* IPv6 support * IPv6 support

View File

@@ -1,51 +0,0 @@
# smtprelay Security Policy
This document outlines security procedures and general policies for the
smtprelay project.
## Supported Versions
The latest release is the only supported release.
## Disclosing a security issue
The smtprelay maintainers take all security issues in the project seriously.
Thank you for improving the security of the project! We appreciate your
dedication to responsible disclosure and will make every effort to acknowledge
your contributions.
smtprelay leverages GitHub's private vulnerability reporting.
To learn more about this feature and how to submit a vulnerability report,
review [GitHub's documentation on private reporting](https://docs.github.com/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability).
Here are some helpful details to include in your report:
- a detailed description of the issue
- the steps required to reproduce the issue
- versions of the project that may be affected by the issue
- if known, any mitigations for the issue
A maintainer will acknowledge the report within three (3) business days, and
will send a more detailed response within an additional three (3) business days
indicating the next steps in handling your report.
After the initial reply to your report, the maintainers will endeavor to keep
you informed of the progress towards a fix and full announcement, and may ask
for additional information or guidance.
## Vulnerability management
When the maintainers receive a disclosure report, they will coordinate the
fix and release process, which involves the following steps:
- confirming the issue
- determining affected versions of the project
- auditing code to find any potential similar problems
- preparing fixes for all releases under maintenance
## Suggesting changes
If you have suggestions on how this process could be improved please submit an
issue or pull request.

6
cmd/README.md Normal file
View File

@@ -0,0 +1,6 @@
To run the hasher, do like this
```bash
$ go run hasher.go hunter2
```

22
cmd/hasher.go Normal file
View File

@@ -0,0 +1,22 @@
package main
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "Usage: hasher PASSWORD")
os.Exit(1)
}
password := os.Args[1]
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, "Error generating hash: %s", err)
}
fmt.Println(string(hash))
}

260
config.go
View File

@@ -1,17 +1,12 @@
package main package main
import ( import (
"bufio"
"flag" "flag"
"fmt"
"io"
"net" "net"
"os"
"regexp" "regexp"
"strings" "net/smtp"
"time"
"github.com/peterbourgon/ff/v3" "github.com/vharitonsky/iniflags"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
@@ -21,50 +16,31 @@ var (
) )
var ( var (
flagset = flag.NewFlagSet("smtprelay", flag.ContinueOnError) logFile = flag.String("logfile", "", "Path to logfile")
logFormat = flag.String("log_format", "default", "Log output format")
// config flags logLevel = flag.String("log_level", "info", "Minimum log level to output")
logFile = flagset.String("logfile", "", "Path to logfile") hostName = flag.String("hostname", "localhost.localdomain", "Server hostname")
logFormat = flagset.String("log_format", "default", "Log output format") welcomeMsg = flag.String("welcome_msg", "", "Welcome message for SMTP session")
logLevel = flagset.String("log_level", "info", "Minimum log level to output") listen = flag.String("listen", "127.0.0.1:25 [::1]:25", "Address and port to listen for incoming SMTP")
hostName = flagset.String("hostname", "localhost.localdomain", "Server hostname") localCert = flag.String("local_cert", "", "SSL certificate for STARTTLS/TLS")
welcomeMsg = flagset.String("welcome_msg", "", "Welcome message for SMTP session") localKey = flag.String("local_key", "", "SSL private key for STARTTLS/TLS")
listenStr = flagset.String("listen", "127.0.0.1:25 [::1]:25", "Address and port to listen for incoming SMTP") localForceTLS = flag.Bool("local_forcetls", false, "Force STARTTLS (needs local_cert and local_key)")
localCert = flagset.String("local_cert", "", "SSL certificate for STARTTLS/TLS") allowedNetsStr = flag.String("allowed_nets", "127.0.0.0/8 ::1/128", "Networks allowed to send mails")
localKey = flagset.String("local_key", "", "SSL private key for STARTTLS/TLS")
localForceTLS = flagset.Bool("local_forcetls", false, "Force STARTTLS (needs local_cert and local_key)")
readTimeoutStr = flagset.String("read_timeout", "60s", "Socket timeout for read operations")
writeTimeoutStr = flagset.String("write_timeout", "60s", "Socket timeout for write operations")
dataTimeoutStr = flagset.String("data_timeout", "5m", "Socket timeout for DATA command")
maxConnections = flagset.Int("max_connections", 100, "Max concurrent connections, use -1 to disable")
maxMessageSize = flagset.Int("max_message_size", 10240000, "Max message size in bytes")
maxRecipients = flagset.Int("max_recipients", 100, "Max RCPT TO calls for each envelope")
allowedNetsStr = flagset.String("allowed_nets", "127.0.0.0/8 ::1/128", "Networks allowed to send mails")
allowedSenderStr = flagset.String("allowed_sender", "", "Regular expression for valid FROM EMail addresses")
allowedRecipStr = flagset.String("allowed_recipients", "", "Regular expression for valid TO EMail addresses")
allowedUsers = flagset.String("allowed_users", "", "Path to file with valid users/passwords")
command = flagset.String("command", "", "Path to pipe command")
remotesStr = flagset.String("remotes", "", "Outgoing SMTP servers")
strictSender = flagset.Bool("strict_sender", false, "Use only SMTP servers with Sender matches to From")
// additional flags
_ = flagset.String("config", "", "Path to config file (ini format)")
versionInfo = flagset.Bool("version", false, "Show version information")
// internal
listenAddrs = []protoAddr{}
readTimeout time.Duration
writeTimeout time.Duration
dataTimeout time.Duration
allowedNets = []*net.IPNet{} allowedNets = []*net.IPNet{}
allowedSenderStr = flag.String("allowed_sender", "", "Regular expression for valid FROM EMail addresses")
allowedSender *regexp.Regexp allowedSender *regexp.Regexp
allowedRecipStr = flag.String("allowed_recipients", "", "Regular expression for valid TO EMail addresses")
allowedRecipients *regexp.Regexp allowedRecipients *regexp.Regexp
remotes = []*Remote{} allowedUsers = flag.String("allowed_users", "", "Path to file with valid users/passwords")
remoteHost = flag.String("remote_host", "", "Outgoing SMTP server")
remoteUser = flag.String("remote_user", "", "Username for authentication on outgoing SMTP server")
remotePass = flag.String("remote_pass", "", "Password for authentication on outgoing SMTP server")
remoteAuthStr = flag.String("remote_auth", "none", "Auth method on outgoing SMTP server (none, plain, login)")
remoteAuth smtp.Auth
remoteSender = flag.String("remote_sender", "", "Sender e-mail address on outgoing SMTP server")
versionInfo = flag.Bool("version", false, "Show version information")
) )
func localAuthRequired() bool {
return *allowedUsers != ""
}
func setupAllowedNetworks() { func setupAllowedNetworks() {
for _, netstr := range splitstr(*allowedNetsStr, ' ') { for _, netstr := range splitstr(*allowedNetsStr, ' ') {
@@ -91,7 +67,7 @@ func setupAllowedNetworks() {
func setupAllowedPatterns() { func setupAllowedPatterns() {
var err error var err error
if *allowedSenderStr != "" { if (*allowedSenderStr != "") {
allowedSender, err = regexp.Compile(*allowedSenderStr) allowedSender, err = regexp.Compile(*allowedSenderStr)
if err != nil { if err != nil {
log.WithField("allowed_sender", *allowedSenderStr). log.WithField("allowed_sender", *allowedSenderStr).
@@ -100,7 +76,7 @@ func setupAllowedPatterns() {
} }
} }
if *allowedRecipStr != "" { if (*allowedRecipStr != "") {
allowedRecipients, err = regexp.Compile(*allowedRecipStr) allowedRecipients, err = regexp.Compile(*allowedRecipStr)
if err != nil { if err != nil {
log.WithField("allowed_recipients", *allowedRecipStr). log.WithField("allowed_recipients", *allowedRecipStr).
@@ -110,167 +86,61 @@ func setupAllowedPatterns() {
} }
} }
func setupRemotes() {
logger := log.WithField("remotes", *remotesStr)
if *remotesStr != "" { func setupRemoteAuth() {
for _, remoteURL := range strings.Split(*remotesStr, " ") { logger := log.WithField("remote_auth", *remoteAuthStr)
r, err := ParseRemote(remoteURL)
// Remote auth disabled?
if *remoteAuthStr == "" || *remoteAuthStr == "none" {
if *remoteUser != "" {
logger.Fatal("remote_user given but not used")
}
if *remotePass != "" {
logger.Fatal("remote_pass given but not used")
}
// No auth; use empty default
return
}
// We need a username, password, and remote host
if *remoteUser == "" {
logger.Fatal("remote_user required but empty")
}
if *remotePass == "" {
logger.Fatal("remote_pass required but empty")
}
if *remoteHost == "" {
logger.Fatal("remote_auth without remote_host is pointless")
}
host, _, err := net.SplitHostPort(*remoteHost)
if err != nil { if err != nil {
logger.Fatal(fmt.Sprintf("error parsing url: '%s': %v", remoteURL, err)) logger.WithField("remote_host", *remoteHost).
Fatal("Invalid remote_host")
} }
remotes = append(remotes, r) switch *remoteAuthStr {
} case "plain":
} remoteAuth = smtp.PlainAuth("", *remoteUser, *remotePass, host)
} case "login":
remoteAuth = LoginAuth(*remoteUser, *remotePass)
type protoAddr struct { default:
protocol string logger.Fatal("Invalid remote_auth type")
address string
}
func splitProto(s string) protoAddr {
idx := strings.Index(s, "://")
if idx == -1 {
return protoAddr{
address: s,
}
}
return protoAddr{
protocol: s[0:idx],
address: s[idx+3:],
}
}
func setupListeners() {
for _, listenAddr := range strings.Split(*listenStr, " ") {
pa := splitProto(listenAddr)
if localAuthRequired() && pa.protocol == "" {
log.WithField("address", pa.address).
Fatal("Local authentication (via allowed_users file) " +
"not allowed with non-TLS listener")
}
listenAddrs = append(listenAddrs, pa)
}
}
func setupTimeouts() {
var err error
readTimeout, err = time.ParseDuration(*readTimeoutStr)
if err != nil {
log.WithField("read_timeout", *readTimeoutStr).
WithError(err).
Fatal("read_timeout duration string invalid")
}
if readTimeout.Seconds() < 1 {
log.WithField("read_timeout", *readTimeoutStr).
Fatal("read_timeout less than one second")
}
writeTimeout, err = time.ParseDuration(*writeTimeoutStr)
if err != nil {
log.WithField("write_timeout", *writeTimeoutStr).
WithError(err).
Fatal("write_timeout duration string invalid")
}
if writeTimeout.Seconds() < 1 {
log.WithField("write_timeout", *writeTimeoutStr).
Fatal("write_timeout less than one second")
}
dataTimeout, err = time.ParseDuration(*dataTimeoutStr)
if err != nil {
log.WithField("data_timeout", *dataTimeoutStr).
WithError(err).
Fatal("data_timeout duration string invalid")
}
if dataTimeout.Seconds() < 1 {
log.WithField("data_timeout", *dataTimeoutStr).
Fatal("data_timeout less than one second")
} }
} }
func ConfigLoad() { func ConfigLoad() {
// use .env file if it exists iniflags.Parse()
if _, err := os.Stat(".env"); err == nil {
if err := ff.Parse(flagset, os.Args[1:],
ff.WithEnvVarPrefix("smtprelay"),
ff.WithConfigFile(".env"),
ff.WithConfigFileParser(ff.EnvParser),
); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
} else {
// use env variables and smtprelay.ini file
if err := ff.Parse(flagset, os.Args[1:],
ff.WithEnvVarPrefix("smtprelay"),
ff.WithConfigFileFlag("config"),
ff.WithConfigFileParser(IniParser),
); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
// Set up logging as soon as possible // Set up logging as soon as possible
setupLogger() setupLogger()
if *versionInfo { if (*remoteHost == "") {
fmt.Printf("smtprelay/%s (%s)\n", appVersion, buildTime) log.Warn("remote_host not set; mail will not be forwarded!")
os.Exit(0)
}
if *remotesStr == "" && *command == "" {
log.Warn("no remotes or command set; mail will not be forwarded!")
} }
setupAllowedNetworks() setupAllowedNetworks()
setupAllowedPatterns() setupAllowedPatterns()
setupRemotes() setupRemoteAuth()
setupListeners()
setupTimeouts()
}
// IniParser is a parser for config files in classic key/value style format. Each
// line is tokenized as a single key/value pair. The first "=" delimited
// token in the line is interpreted as the flag name, and all remaining tokens
// are interpreted as the value. Any leading hyphens on the flag name are
// ignored.
func IniParser(r io.Reader, set func(name, value string) error) error {
s := bufio.NewScanner(r)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" {
continue // skip empties
}
if line[0] == '#' || line[0] == ';' {
continue // skip comments
}
var (
name string
value string
index = strings.IndexRune(line, '=')
)
if index < 0 {
name, value = line, "true" // boolean option
} else {
name, value = strings.TrimSpace(line[:index]), strings.Trim(strings.TrimSpace(line[index+1:]), "\"")
}
if i := strings.Index(value, " #"); i >= 0 {
value = strings.TrimSpace(value[:i])
}
if err := set(name, value); err != nil {
return err
}
}
return nil
} }

View File

@@ -1,44 +0,0 @@
package main
import (
"testing"
)
func TestSplitProto(t *testing.T) {
var tests = []struct {
input string
proto string
addr string
}{
{
input: "localhost",
proto: "",
addr: "localhost",
},
{
input: "tls://my.local.domain",
proto: "tls",
addr: "my.local.domain",
},
{
input: "starttls://my.local.domain",
proto: "starttls",
addr: "my.local.domain",
},
}
for i, test := range tests {
testName := test.input
t.Run(testName, func(t *testing.T) {
pa := splitProto(test.input)
if pa.protocol != test.proto {
t.Errorf("Testcase %d: Incorrect proto: expected %v, got %v",
i, test.proto, pa.protocol)
}
if pa.address != test.addr {
t.Errorf("Testcase %d: Incorrect addr: expected %v, got %v",
i, test.addr, pa.address)
}
})
}
}

22
go.mod
View File

@@ -1,21 +1,11 @@
module github.com/decke/smtprelay module github.com/decke/smtprelay
require ( require (
github.com/chrj/smtpd v0.3.1 github.com/chrj/smtpd v0.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.2.0
github.com/peterbourgon/ff/v3 v3.4.0 github.com/sirupsen/logrus v1.8.1
github.com/sirupsen/logrus v1.9.3 github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de
github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
golang.org/x/crypto v0.38.0
) )
require ( go 1.13
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.24.3

55
go.sum
View File

@@ -1,39 +1,24 @@
github.com/chrj/smtpd v0.3.1 h1:kogHFkbFdKaoH3bgZkqNC9uVtKYOFfM3uV3rroBdooE= github.com/chrj/smtpd v0.3.0 h1:cw1LSHDOz7N3XbkcZSF/bue9dh7ATKk5ZksfBztV6b0=
github.com/chrj/smtpd v0.3.1/go.mod h1:JtABvV/LzvLmEIzy0NyDnrfMGOMd8wy5frAokwf6J9Q= github.com/chrj/smtpd v0.3.0/go.mod h1:1hmG9KbrE10JG1SmvG79Krh4F6713oUrw2+gRp1oSYk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/eaigner/dkim v0.0.0-20150301120808-6fe4a7ee9cfb/go.mod h1:FSCIHbrqk7D01Mj8y/jW+NS1uoCerr+ad+IckTHTFf4=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de h1:fkw+7JkxF3U1GzQoX9h69Wvtvxajo5Rbzy6+YMMzPIg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de/go.mod h1:irMhzlTz8+fVFj6CH2AN2i+WI5S6wWFtK3MBCIxIpyI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -16,7 +16,7 @@ func setupLogger() {
log = logrus.New() log = logrus.New()
// Handle logfile // Handle logfile
if *logFile == "" { if (*logFile == "") {
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
} else { } else {
writer, err := os.OpenFile(*logFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600) writer, err := os.OpenFile(*logFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)

123
main.go
View File

@@ -1,13 +1,11 @@
package main package main
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
"net/textproto" "net/textproto"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
@@ -83,7 +81,7 @@ func addrAllowed(addr string, allowedAddrs []string) bool {
func senderChecker(peer smtpd.Peer, addr string) error { func senderChecker(peer smtpd.Peer, addr string) error {
// check sender address from auth file if user is authenticated // check sender address from auth file if user is authenticated
if localAuthRequired() && peer.Username != "" { if *allowedUsers != "" && peer.Username != "" {
user, err := AuthFetch(peer.Username) user, err := AuthFetch(peer.Username)
if err != nil { if err != nil {
// Shouldn't happen: authChecker already validated username+password // Shouldn't happen: authChecker already validated username+password
@@ -161,72 +159,40 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
"from": env.Sender, "from": env.Sender,
"to": env.Recipients, "to": env.Recipients,
"peer": peerIP, "peer": peerIP,
"host": *remoteHost,
"uuid": generateUUID(), "uuid": generateUUID(),
}) })
var envRemotes []*Remote if (*remoteHost == "") {
logger.Warning("remote_host not set; discarding mail")
if *strictSender { return nil
for _, remote := range remotes {
if remote.Sender == env.Sender {
envRemotes = append(envRemotes, remote)
}
}
} else {
envRemotes = remotes
} }
if len(envRemotes) == 0 && *command == "" { logger.Info("delivering mail from peer using smarthost")
logger.Warning("no remote_host or command set; discarding mail")
return smtpd.Error{Code: 554, Message: "There are no appropriate remote_host or command"}
}
env.AddReceivedLine(peer) env.AddReceivedLine(peer)
if *command != "" { var sender string
cmdLogger := logger.WithField("command", *command)
var stdout bytes.Buffer if *remoteSender == "" {
var stderr bytes.Buffer sender = env.Sender
} else {
environ := os.Environ() sender = *remoteSender
environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_FROM", env.Sender))
environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_TO", env.Recipients))
environ = append(environ, fmt.Sprintf("%s=%s", "SMTPRELAY_PEER", peerIP))
cmd := exec.Cmd{
Env: environ,
Path: *command,
} }
cmd.Stdin = bytes.NewReader(env.Data)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
cmdLogger.WithError(err).Error(stderr.String())
return smtpd.Error{Code: 554, Message: "External command failed"}
}
cmdLogger.Info("pipe command successful: " + stdout.String())
}
for _, remote := range envRemotes {
logger = logger.WithField("host", remote.Addr)
logger.Info("delivering mail from peer using smarthost")
err := SendMail( err := SendMail(
remote, *remoteHost,
env.Sender, remoteAuth,
sender,
env.Recipients, env.Recipients,
env.Data, env.Data,
) )
if err != nil { if err != nil {
var smtpError smtpd.Error var smtpError smtpd.Error
switch err := err.(type) { switch err.(type) {
case *textproto.Error: case *textproto.Error:
err := err.(*textproto.Error)
smtpError = smtpd.Error{Code: err.Code, Message: err.Msg} smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
logger.WithFields(logrus.Fields{ logger.WithFields(logrus.Fields{
@@ -234,7 +200,7 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
"err_msg": err.Msg, "err_msg": err.Msg,
}).Error("delivery failed") }).Error("delivery failed")
default: default:
smtpError = smtpd.Error{Code: 421, Message: "Forwarding failed"} smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}
logger.WithError(err). logger.WithError(err).
Error("delivery failed") Error("delivery failed")
@@ -244,8 +210,6 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
} }
logger.Debug("delivery successful") logger.Debug("delivery successful")
}
return nil return nil
} }
@@ -303,11 +267,16 @@ func getTLSConfig() *tls.Config {
func main() { func main() {
ConfigLoad() ConfigLoad()
if *versionInfo {
fmt.Printf("smtprelay/%s (%s)\n", appVersion, buildTime)
os.Exit(0)
}
log.WithField("version", appVersion). log.WithField("version", appVersion).
Debug("starting smtprelay") Debug("starting smtprelay")
// Load allowed users file // Load allowed users file
if localAuthRequired() { if *allowedUsers != "" {
err := AuthLoadFile(*allowedUsers) err := AuthLoadFile(*allowedUsers)
if err != nil { if err != nil {
log.WithField("file", *allowedUsers). log.WithField("file", *allowedUsers).
@@ -319,56 +288,54 @@ func main() {
var servers []*smtpd.Server var servers []*smtpd.Server
// Create a server for each desired listen address // Create a server for each desired listen address
for _, listen := range listenAddrs { for _, listenAddr := range strings.Split(*listen, " ") {
logger := log.WithField("address", listen.address)
server := &smtpd.Server{ server := &smtpd.Server{
Hostname: *hostName, Hostname: *hostName,
WelcomeMessage: *welcomeMsg, WelcomeMessage: *welcomeMsg,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
DataTimeout: dataTimeout,
MaxConnections: *maxConnections,
MaxMessageSize: *maxMessageSize,
MaxRecipients: *maxRecipients,
ConnectionChecker: connectionChecker, ConnectionChecker: connectionChecker,
SenderChecker: senderChecker, SenderChecker: senderChecker,
RecipientChecker: recipientChecker, RecipientChecker: recipientChecker,
Handler: mailHandler, Handler: mailHandler,
} }
if localAuthRequired() { if *allowedUsers != "" {
server.Authenticator = authChecker server.Authenticator = authChecker
} }
var lsnr net.Listener var lsnr net.Listener
var err error var err error
switch listen.protocol { if strings.Index(listenAddr, "://") == -1 {
case "": log.WithField("address", listenAddr).
logger.Info("listening on address") Info("listening on address")
lsnr, err = net.Listen("tcp", listen.address)
lsnr, err = net.Listen("tcp", listenAddr)
} else if strings.HasPrefix(listenAddr, "starttls://") {
listenAddr = strings.TrimPrefix(listenAddr, "starttls://")
case "starttls":
server.TLSConfig = getTLSConfig() server.TLSConfig = getTLSConfig()
server.ForceTLS = *localForceTLS server.ForceTLS = *localForceTLS
logger.Info("listening on address (STARTTLS)") log.WithField("address", listenAddr).
lsnr, err = net.Listen("tcp", listen.address) Info("listening on address (STARTTLS)")
lsnr, err = net.Listen("tcp", listenAddr)
} else if strings.HasPrefix(listenAddr, "tls://") {
listenAddr = strings.TrimPrefix(listenAddr, "tls://")
case "tls":
server.TLSConfig = getTLSConfig() server.TLSConfig = getTLSConfig()
logger.Info("listening on address (TLS)") log.WithField("address", listenAddr).
lsnr, err = tls.Listen("tcp", listen.address, server.TLSConfig) Info("listening on address (TLS)")
lsnr, err = tls.Listen("tcp", listenAddr, server.TLSConfig)
default: } else {
logger.WithField("protocol", listen.protocol). log.WithField("address", listenAddr).
Fatal("unknown protocol in listen address") Fatal("unknown protocol in listen address")
} }
if err != nil { if err != nil {
logger.WithError(err).Fatal("error starting listener") log.WithFields(logrus.Fields{
"address": listenAddr,
}).WithError(err).Fatal("error starting listener")
} }
servers = append(servers, server) servers = append(servers, server)

View File

@@ -1,83 +0,0 @@
package main
import (
"fmt"
"net/smtp"
"net/url"
)
type Remote struct {
SkipVerify bool
Auth smtp.Auth
Scheme string
Hostname string
Port string
Addr string
Sender string
}
// ParseRemote creates a remote from a given url in the following format:
//
// smtp://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
// smtps://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
// starttls://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
//
// Supported Params:
// - skipVerify: can be "true" or empty to prevent ssl verification of remote server's certificate.
// - auth: can be "login" to trigger "LOGIN" auth instead of "PLAIN" auth
func ParseRemote(remoteURL string) (*Remote, error) {
u, err := url.Parse(remoteURL)
if err != nil {
return nil, err
}
if u.Scheme != "smtp" && u.Scheme != "smtps" && u.Scheme != "starttls" {
return nil, fmt.Errorf("'%s' is not a supported relay scheme", u.Scheme)
}
hostname, port := u.Hostname(), u.Port()
if port == "" {
switch u.Scheme {
case "smtp":
port = "25"
case "smtps":
port = "465"
case "starttls":
port = "587"
}
}
q := u.Query()
r := &Remote{
Scheme: u.Scheme,
Hostname: hostname,
Port: port,
Addr: fmt.Sprintf("%s:%s", hostname, port),
}
if u.User != nil {
pass, _ := u.User.Password()
user := u.User.Username()
if hasAuth, authVal := q.Has("auth"), q.Get("auth"); hasAuth {
if authVal != "login" {
return nil, fmt.Errorf("Auth must be login or not present, received '%s'", authVal)
}
r.Auth = LoginAuth(user, pass)
} else {
r.Auth = smtp.PlainAuth("", user, pass, u.Hostname())
}
}
if hasVal, skipVerify := q.Has("skipVerify"), q.Get("skipVerify"); hasVal && skipVerify != "false" {
r.SkipVerify = true
}
if u.Path != "" {
r.Sender = u.Path[1:]
}
return r, nil
}

View File

@@ -1,114 +0,0 @@
package main
import (
"net/smtp"
"testing"
"github.com/stretchr/testify/assert"
)
func AssertRemoteUrlEquals(t *testing.T, expected *Remote, remotUrl string) {
actual, err := ParseRemote(remotUrl)
assert.Nil(t, err)
assert.NotNil(t, actual)
assert.Equal(t, expected.Scheme, actual.Scheme, "Scheme %s", remotUrl)
assert.Equal(t, expected.Addr, actual.Addr, "Addr %s", remotUrl)
assert.Equal(t, expected.Hostname, actual.Hostname, "Hostname %s", remotUrl)
assert.Equal(t, expected.Port, actual.Port, "Port %s", remotUrl)
assert.Equal(t, expected.Sender, actual.Sender, "Sender %s", remotUrl)
assert.Equal(t, expected.SkipVerify, actual.SkipVerify, "SkipVerify %s", remotUrl)
if expected.Auth != nil || actual.Auth != nil {
assert.NotNil(t, expected, "Auth %s", remotUrl)
assert.NotNil(t, actual, "Auth %s", remotUrl)
assert.IsType(t, expected.Auth, actual.Auth)
}
}
func TestValidRemoteUrls(t *testing.T) {
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtp",
SkipVerify: false,
Auth: nil,
Hostname: "email.com",
Port: "25",
Addr: "email.com:25",
Sender: "",
}, "smtp://email.com")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtp",
SkipVerify: true,
Auth: nil,
Hostname: "email.com",
Port: "25",
Addr: "email.com:25",
Sender: "",
}, "smtp://email.com?skipVerify")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtp",
SkipVerify: false,
Auth: smtp.PlainAuth("", "user", "pass", ""),
Hostname: "email.com",
Port: "25",
Addr: "email.com:25",
Sender: "",
}, "smtp://user:pass@email.com")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtp",
SkipVerify: false,
Auth: LoginAuth("user", "pass"),
Hostname: "email.com",
Port: "25",
Addr: "email.com:25",
Sender: "",
}, "smtp://user:pass@email.com?auth=login")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtp",
SkipVerify: false,
Auth: LoginAuth("user", "pass"),
Hostname: "email.com",
Port: "25",
Addr: "email.com:25",
Sender: "sender@website.com",
}, "smtp://user:pass@email.com/sender@website.com?auth=login")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtps",
SkipVerify: false,
Auth: LoginAuth("user", "pass"),
Hostname: "email.com",
Port: "465",
Addr: "email.com:465",
Sender: "sender@website.com",
}, "smtps://user:pass@email.com/sender@website.com?auth=login")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "smtps",
SkipVerify: true,
Auth: LoginAuth("user", "pass"),
Hostname: "email.com",
Port: "8425",
Addr: "email.com:8425",
Sender: "sender@website.com",
}, "smtps://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify")
AssertRemoteUrlEquals(t, &Remote{
Scheme: "starttls",
SkipVerify: true,
Auth: LoginAuth("user", "pass"),
Hostname: "email.com",
Port: "8425",
Addr: "email.com:8425",
Sender: "sender@website.com",
}, "starttls://user:pass@email.com:8425/sender@website.com?auth=login&skipVerify")
}
func TestMissingScheme(t *testing.T) {
_, err := ParseRemote("http://user:pass@email.com:8425/sender@website.com")
assert.NotNil(t, err, "Err must be present")
assert.Equal(t, err.Error(), "'http' is not a supported relay scheme")
}

View File

@@ -0,0 +1,11 @@
[Unit]
Description=SMTP Relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/smtprelay/bin/smtprelay -config /etc/smtprelay/smtprelay.ini
[Install]
WantedBy=multi-user.target

71
smtp.go
View File

@@ -4,11 +4,9 @@
// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321. // Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321.
// It also implements the following extensions: // It also implements the following extensions:
//
// 8BITMIME RFC 1652 // 8BITMIME RFC 1652
// AUTH RFC 2554 // AUTH RFC 2554
// STARTTLS RFC 3207 // STARTTLS RFC 3207
//
// Additional extensions may be handled by clients. // Additional extensions may be handled by clients.
// //
// The smtp package is frozen and is not accepting new features. // The smtp package is frozen and is not accepting new features.
@@ -49,7 +47,7 @@ type Client struct {
helloError error // the error from the hello helloError error // the error from the hello
} }
// Dial returns a new [Client] connected to an SMTP server at addr. // Dial returns a new Client connected to an SMTP server at addr.
// The addr must include a port, as in "mail.example.com:smtp". // The addr must include a port, as in "mail.example.com:smtp".
func Dial(addr string) (*Client, error) { func Dial(addr string) (*Client, error) {
conn, err := net.Dial("tcp", addr) conn, err := net.Dial("tcp", addr)
@@ -60,7 +58,7 @@ func Dial(addr string) (*Client, error) {
return NewClient(conn, host) return NewClient(conn, host)
} }
// NewClient returns a new [Client] using an existing connection and host as a // NewClient returns a new Client using an existing connection and host as a
// server name to be used when authenticating. // server name to be used when authenticating.
func NewClient(conn net.Conn, host string) (*Client, error) { func NewClient(conn net.Conn, host string) (*Client, error) {
text := textproto.NewConn(conn) text := textproto.NewConn(conn)
@@ -108,7 +106,7 @@ func (c *Client) Hello(localName string) error {
} }
// cmd is a convenience function that sends a command and returns the response // cmd is a convenience function that sends a command and returns the response
func (c *Client) cmd(expectCode int, format string, args ...any) (int, string, error) { func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
id, err := c.Text.Cmd(format, args...) id, err := c.Text.Cmd(format, args...)
if err != nil { if err != nil {
return 0, "", err return 0, "", err
@@ -139,8 +137,12 @@ func (c *Client) ehlo() error {
if len(extList) > 1 { if len(extList) > 1 {
extList = extList[1:] extList = extList[1:]
for _, line := range extList { for _, line := range extList {
k, v, _ := strings.Cut(line, " ") args := strings.SplitN(line, " ", 2)
ext[k] = v if len(args) > 1 {
ext[args[0]] = args[1]
} else {
ext[args[0]] = ""
}
} }
} }
if mechs, ok := ext["AUTH"]; ok { if mechs, ok := ext["AUTH"]; ok {
@@ -167,7 +169,7 @@ func (c *Client) StartTLS(config *tls.Config) error {
} }
// TLSConnectionState returns the client's TLS connection state. // TLSConnectionState returns the client's TLS connection state.
// The return values are their zero values if [Client.StartTLS] did // The return values are their zero values if StartTLS did
// not succeed. // not succeed.
func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) {
tc, ok := c.conn.(*tls.Conn) tc, ok := c.conn.(*tls.Conn)
@@ -207,7 +209,7 @@ func (c *Client) Auth(a smtp.Auth) error {
} }
resp64 := make([]byte, encoding.EncodedLen(len(resp))) resp64 := make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp) encoding.Encode(resp64, resp)
code, msg64, err := c.cmd(0, "%s", strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))) code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64)))
for err == nil { for err == nil {
var msg []byte var msg []byte
switch code { switch code {
@@ -233,7 +235,7 @@ func (c *Client) Auth(a smtp.Auth) error {
} }
resp64 = make([]byte, encoding.EncodedLen(len(resp))) resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp) encoding.Encode(resp64, resp)
code, msg64, err = c.cmd(0, "%s", resp64) code, msg64, err = c.cmd(0, string(resp64))
} }
return err return err
} }
@@ -242,7 +244,7 @@ func (c *Client) Auth(a smtp.Auth) error {
// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME // If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME
// parameter. If the server supports the SMTPUTF8 extension, Mail adds the // parameter. If the server supports the SMTPUTF8 extension, Mail adds the
// SMTPUTF8 parameter. // SMTPUTF8 parameter.
// This initiates a mail transaction and is followed by one or more [Client.Rcpt] calls. // This initiates a mail transaction and is followed by one or more Rcpt calls.
func (c *Client) Mail(from string) error { func (c *Client) Mail(from string) error {
if err := validateLine(from); err != nil { if err := validateLine(from); err != nil {
return err return err
@@ -264,8 +266,8 @@ func (c *Client) Mail(from string) error {
} }
// Rcpt issues a RCPT command to the server using the provided email address. // Rcpt issues a RCPT command to the server using the provided email address.
// A call to Rcpt must be preceded by a call to [Client.Mail] and may be followed by // A call to Rcpt must be preceded by a call to Mail and may be followed by
// a [Client.Data] call or another Rcpt call. // a Data call or another Rcpt call.
func (c *Client) Rcpt(to string) error { func (c *Client) Rcpt(to string) error {
if err := validateLine(to); err != nil { if err := validateLine(to); err != nil {
return err return err
@@ -288,7 +290,7 @@ func (d *dataCloser) Close() error {
// Data issues a DATA command to the server and returns a writer that // Data issues a DATA command to the server and returns a writer that
// can be used to write the mail headers and body. The caller should // can be used to write the mail headers and body. The caller should
// close the writer before calling any more methods on c. A call to // close the writer before calling any more methods on c. A call to
// Data must be preceded by one or more calls to [Client.Rcpt]. // Data must be preceded by one or more calls to Rcpt.
func (c *Client) Data() (io.WriteCloser, error) { func (c *Client) Data() (io.WriteCloser, error) {
_, _, err := c.cmd(354, "DATA") _, _, err := c.cmd(354, "DATA")
if err != nil { if err != nil {
@@ -320,11 +322,7 @@ var testHookStartTLS func(*tls.Config) // nil, except for tests
// attachments (see the mime/multipart package), or other mail // attachments (see the mime/multipart package), or other mail
// functionality. Higher-level packages exist outside of the standard // functionality. Higher-level packages exist outside of the standard
// library. // library.
func SendMail(r *Remote, from string, to []string, msg []byte) error { func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
if r.Sender != "" {
from = r.Sender
}
if err := validateLine(from); err != nil { if err := validateLine(from); err != nil {
return err return err
} }
@@ -333,19 +331,19 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err return err
} }
} }
var c *Client host, port, err := net.SplitHostPort(addr)
var err error if err != nil {
if r.Scheme == "smtps" { return err
config := &tls.Config{
ServerName: r.Hostname,
InsecureSkipVerify: r.SkipVerify,
} }
conn, err := tls.Dial("tcp", r.Addr, config) var c *Client
if port == "465" || port == "smtps" {
config := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, config)
if err != nil { if err != nil {
return err return err
} }
defer conn.Close() defer conn.Close()
c, err = NewClient(conn, r.Hostname) c, err = NewClient(conn, host)
if err != nil { if err != nil {
return err return err
} }
@@ -353,7 +351,7 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err return err
} }
} else { } else {
c, err = Dial(r.Addr) c, err = Dial(addr)
if err != nil { if err != nil {
return err return err
} }
@@ -362,25 +360,20 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err return err
} }
if ok, _ := c.Extension("STARTTLS"); ok { if ok, _ := c.Extension("STARTTLS"); ok {
config := &tls.Config{ config := &tls.Config{ServerName: c.serverName}
ServerName: c.serverName,
InsecureSkipVerify: r.SkipVerify,
}
if testHookStartTLS != nil { if testHookStartTLS != nil {
testHookStartTLS(config) testHookStartTLS(config)
} }
if err = c.StartTLS(config); err != nil { if err = c.StartTLS(config); err != nil {
return err return err
} }
} else if r.Scheme == "starttls" {
return errors.New("starttls: server does not support extension, check remote scheme")
} }
} }
if r.Auth != nil && c.ext != nil { if a != nil && c.ext != nil {
if _, ok := c.ext["AUTH"]; !ok { if _, ok := c.ext["AUTH"]; !ok {
return errors.New("smtp: server doesn't support AUTH") return errors.New("smtp: server doesn't support AUTH")
} }
if err = c.Auth(r.Auth); err != nil { if err = c.Auth(a); err != nil {
return err return err
} }
} }
@@ -445,7 +438,9 @@ func (c *Client) Noop() error {
// Quit sends the QUIT command and closes the connection to the server. // Quit sends the QUIT command and closes the connection to the server.
func (c *Client) Quit() error { func (c *Client) Quit() error {
c.hello() // ignore error; we're quitting anyhow if err := c.hello(); err != nil {
return err
}
_, _, err := c.cmd(221, "QUIT") _, _, err := c.cmd(221, "QUIT")
if err != nil { if err != nil {
return err return err
@@ -453,7 +448,7 @@ func (c *Client) Quit() error {
return c.Text.Close() return c.Text.Close()
} }
// validateLine checks to see if a line has CR or LF as per RFC 5321. // validateLine checks to see if a line has CR or LF as per RFC 5321
func validateLine(line string) error { func validateLine(line string) error {
if strings.ContainsAny(line, "\n\r") { if strings.ContainsAny(line, "\n\r") {
return errors.New("smtp: A line must not contain CR or LF") return errors.New("smtp: A line must not contain CR or LF")

View File

@@ -1,23 +1,19 @@
; smtprelay configuration ; smtprelay configuration
;
; All config parameters can also be provided as environment
; variables in uppercase and the prefix "SMTPRELAY_".
; (eg. SMTPRELAY_LOGFILE, SMTPRELAY_LOG_FORMAT)
; Logfile (blank/default is stderr) ; Logfile (blank/default is stderr)
;logfile = ;logfile =
; Log format: default, plain (no timestamp), json ; Log format: default, plain (no timestamp), json
;log_format = default ;log_format = "default"
; Log level: panic, fatal, error, warn, info, debug, trace ; Log level: panic, fatal, error, warn, info, debug, trace
;log_level = info ;log_level = "info"
; Hostname for this SMTP server ; Hostname for this SMTP server
;hostname = localhost.localdomain ;hostname = "localhost.localdomain"
; Welcome message for clients ; Welcome message for clients
;welcome_msg = <hostname> ESMTP ready. ;welcome_msg = "<hostname> ESMTP ready."
; Listen on the following addresses for incoming ; Listen on the following addresses for incoming
; unencrypted connections. ; unencrypted connections.
@@ -34,37 +30,6 @@
; accepting mails from client. ; accepting mails from client.
;local_forcetls = false ;local_forcetls = false
; Only use remotes where FROM EMail address in received
; EMail matches remote_sender.
;strict_sender = false
; Socket timeout for read operations
; Duration string as sequence of decimal numbers,
; each with optional fraction and a unit suffix.
; Valid time units are "ns", "us", "ms", "s", "m", "h".
;read_timeout = 60s
; Socket timeout for write operations
; Duration string as sequence of decimal numbers,
; each with optional fraction and a unit suffix.
; Valid time units are "ns", "us", "ms", "s", "m", "h".
;write_timeout = 60s
; Socket timeout for DATA command
; Duration string as sequence of decimal numbers,
; each with optional fraction and a unit suffix.
; Valid time units are "ns", "us", "ms", "s", "m", "h".
;data_timeout = 5m
; Max concurrent connections, use -1 to disable
;max_connections = 100
; Max message size in bytes
;max_message_size = 10240000
; Max RCPT TO calls for each envelope
;max_recipients = 100
; Networks that are allowed to send mails to us ; Networks that are allowed to send mails to us
; Defaults to localhost. If set to "", then any address is allowed. ; Defaults to localhost. If set to "", then any address is allowed.
;allowed_nets = 127.0.0.0/8 ::1/128 ;allowed_nets = 127.0.0.0/8 ::1/128
@@ -83,7 +48,7 @@
; authentication before they can send mail. ; authentication before they can send mail.
; File format: username bcrypt-hash [email[,email[,...]]] ; File format: username bcrypt-hash [email[,email[,...]]]
; username: The SMTP auth username ; username: The SMTP auth username
; bcrypt-hash: The bcrypt hash of the pasword ; bcrypt-hash: The bcrypt hash of the pasword (generate with "./hasher password")
; email: Comma-separated list of allowed "from" addresses: ; email: Comma-separated list of allowed "from" addresses:
; - If omitted, user can send from any address ; - If omitted, user can send from any address
; - If @domain.com is given, user can send from any address @domain.com ; - If @domain.com is given, user can send from any address @domain.com
@@ -91,40 +56,25 @@
; E.g. "app@example.com,@appsrv.example.com" ; E.g. "app@example.com,@appsrv.example.com"
;allowed_users = ;allowed_users =
; Relay all mails to this SMTP servers. ; Relay all mails to this SMTP server.
; If not set, mails are discarded. ; If not set, mails are discarded.
;
; Format:
; protocol://[user[:password]@][netloc][:port][/remote_sender][?param1=value1&...]
;
; protocol: smtp (unencrypted), smtps (TLS), starttls (STARTTLS)
; user: Username for authentication
; password: Password for authentication
; remote_sender: Email address to use as FROM
; params:
; skipVerify: "true" or empty to prevent ssl verification of remote server's certificate
; auth: "login" to use LOGIN authentication
; GMail ; GMail
;remotes = starttls://user:pass@smtp.gmail.com:587 ;remote_host = smtp.gmail.com:587
; Mailgun.org ; Mailgun.org
;remotes = starttls://user:pass@smtp.mailgun.org:587 ;remote_host = smtp.mailgun.org:587
; Mailjet.com ; Mailjet.com
;remotes = starttls://user:pass@in-v3.mailjet.com:587 ;remote_host = in-v3.mailjet.com:587
; Ignore remote host certificates ; Authentication credentials on outgoing SMTP server
;remotes = starttls://user:pass@server:587?skipVerify ;remote_user =
;remote_pass =
; Login Authentication method on outgoing SMTP server ; Authentication method on outgoing SMTP server
;remotes = smtp://user:pass@server:2525?auth=login ; (none, plain, login)
;remote_auth = none
; Sender e-mail address on outgoing SMTP server ; Sender e-mail address on outgoing SMTP server
;remotes = smtp://user:pass@server:2525/overridden@email.com?auth=login ;remote_sender =
; Multiple remotes, space delimited
;remotes = smtp://127.0.0.1:1025 starttls://user:pass@smtp.mailgun.org:587
; Pipe messages to external command
;command = /usr/local/bin/script