1 Commits

Author SHA1 Message Date
Jonathon Reinhart
190c615029 Add SystemD unit file 2021-04-01 23:44:42 -04:00
22 changed files with 395 additions and 1553 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"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '0 15 * * 5'
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
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:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
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.
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -72,4 +63,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
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@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: 'Dependency Review'
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2

View File

@@ -1,26 +1,24 @@
name: Go
on: [push, pull_request]
permissions:
contents: read
on: [push]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
- name: Set up Go 1.15
uses: actions/setup-go@v2.1.3
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
run: go build -v .
- name: Test
run: go test -v .

View File

@@ -4,9 +4,6 @@ on:
release:
types: [created]
# Declare default permissions as read only.
permissions: read-all
jobs:
releases-matrix:
name: Release Go Binary
@@ -14,28 +11,20 @@ jobs:
strategy:
matrix:
goos: [freebsd, linux, windows]
goarch: [amd64, arm64]
permissions:
contents: write
packages: write
goarch: ["386", amd64]
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v2
- name: Set APP_VERSION env
run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV}
- name: Set BUILD_TIME env
run: echo BUILD_TIME=$(date) >> ${GITHUB_ENV}
- uses: wangyoucao577/go-release-action@279495102627de7960cbc33434ab01a12bae144b # v1.55
- uses: wangyoucao577/go-release-action@v1.15
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
goversion: "https://golang.org/dl/go1.15.8.linux-amd64.tar.gz"
extra_files: LICENSE README.md smtprelay.ini
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@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: results.sarif

View File

@@ -1,7 +1,6 @@
# 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
and forwards it directly to another SMTP server.
@@ -17,18 +16,16 @@ configure.
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
via a remote SMTP server without giving away my mail credentials to each
device which produces mail.
to GMail without giving away my GMail credentials to each device which
produces mail.
## Main features
* Simple configuration with ini file .env file or environment variables
* Supports SMTPS/TLS (465), STARTTLS (587) and unencrypted SMTP (25)
* Checks for sender, receiver, client IP
* Authentication support with file (LOGIN, PLAIN)
* 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
* IPv6 support
* Aliases support (dynamic reload when alias file changes)

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.

View File

@@ -1,72 +0,0 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"sync"
)
type AliasMap map[string]string
var (
aliasesMutex sync.RWMutex
)
func AliasLoadFile(file string) (AliasMap, error) {
aliasMap := make(AliasMap)
count := 0
log.Info().
Str("file", file).
Msg("Loading aliases file")
f, err := os.Open(file)
if err != nil {
log.Fatal().
Str("file", file).
Err(err).
Msg("cannot load aliases file")
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
aliasMap[parts[0]] = parts[1]
count++
}
}
log.Info().
Str("file", file).
Msg(fmt.Sprintf("Loaded %d aliases from file", count))
if err := scanner.Err(); err != nil {
log.Fatal().
Str("file", file).
Err(err).
Msg("cannot load aliases file")
}
return aliasMap, nil
}
func LoadAliases(filename string) error {
newAliases, err := AliasLoadFile(filename)
if err != nil {
return err
}
aliasesMutex.Lock()
defer aliasesMutex.Unlock()
// Update the aliases map
aliasesList = newAliases
return nil
}

View File

@@ -1,315 +0,0 @@
package main
import (
"io"
"os"
"testing"
"github.com/rs/zerolog"
)
func init() {
// Initialize logger for tests to prevent nil pointer dereference
logger := zerolog.New(io.Discard).With().Timestamp().Logger()
log = &logger
}
func TestAliasLoadFile(t *testing.T) {
tests := []struct {
name string
content string
expected AliasMap
expectError bool
}{
{
name: "valid aliases",
content: "user1 alias1\nuser2 alias2\nuser3 alias3",
expected: AliasMap{
"user1": "alias1",
"user2": "alias2",
"user3": "alias3",
},
expectError: false,
},
{
name: "empty file",
content: "",
expected: AliasMap{},
expectError: false,
},
{
name: "file with empty lines",
content: "user1 alias1\n\nuser2 alias2\n\n",
expected: AliasMap{
"user1": "alias1",
"user2": "alias2",
},
expectError: false,
},
{
name: "file with whitespace",
content: " user1 alias1 \n\t user2\talias2\t",
expected: AliasMap{
"user1": "alias1",
"user2": "alias2",
},
expectError: false,
},
{
name: "extra fields ignored",
content: "user1 alias1 extra field\nuser2 alias2",
expected: AliasMap{
"user1": "alias1",
"user2": "alias2",
},
expectError: false,
},
{
name: "single field line ignored",
content: "user1\nuser2 alias2",
expected: AliasMap{"user2": "alias2"},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(tt.content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
result, err := AliasLoadFile(tmpFile.Name())
if tt.expectError && err == nil {
t.Error("expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(result) != len(tt.expected) {
t.Errorf("expected %d aliases, got %d", len(tt.expected), len(result))
}
for key, expectedValue := range tt.expected {
if actualValue, exists := result[key]; !exists {
t.Errorf("expected key %q not found", key)
} else if actualValue != expectedValue {
t.Errorf("for key %q: expected %q, got %q", key, expectedValue, actualValue)
}
}
})
}
}
func TestLoadAliases(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
content := "user1 alias1\nuser2 alias2"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
err = LoadAliases(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
aliasesMutex.RLock()
defer aliasesMutex.RUnlock()
if len(aliasesList) != 2 {
t.Errorf("expected 2 aliases, got %d", len(aliasesList))
}
if aliasesList["user1"] != "alias1" {
t.Errorf("expected user1 -> alias1, got %q", aliasesList["user1"])
}
if aliasesList["user2"] != "alias2" {
t.Errorf("expected user2 -> alias2, got %q", aliasesList["user2"])
}
}
func TestLoadAliases_EmptyFile(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
tmpFile.Close()
err = LoadAliases(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
aliasesMutex.RLock()
defer aliasesMutex.RUnlock()
if len(aliasesList) != 0 {
t.Errorf("expected 0 aliases, got %d", len(aliasesList))
}
}
func TestLoadAliases_UpdatesExistingAliases(t *testing.T) {
// First load
tmpFile1, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile1.Name())
content1 := "user1 alias1\nuser2 alias2"
if _, err := tmpFile1.WriteString(content1); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile1.Close()
err = LoadAliases(tmpFile1.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Second load with different content
tmpFile2, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile2.Name())
content2 := "user3 alias3"
if _, err := tmpFile2.WriteString(content2); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile2.Close()
err = LoadAliases(tmpFile2.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
aliasesMutex.RLock()
defer aliasesMutex.RUnlock()
if len(aliasesList) != 1 {
t.Errorf("expected 1 alias after reload, got %d", len(aliasesList))
}
if aliasesList["user3"] != "alias3" {
t.Errorf("expected user3 -> alias3, got %q", aliasesList["user3"])
}
if _, exists := aliasesList["user1"]; exists {
t.Error("expected user1 to be removed after reload")
}
}
func TestAliasLoadFile_MultipleSpaces(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
content := "user1 alias1\nuser2 alias2"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
result, err := AliasLoadFile(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result["user1"] != "alias1" {
t.Errorf("expected user1 -> alias1, got %q", result["user1"])
}
if result["user2"] != "alias2" {
t.Errorf("expected user2 -> alias2, got %q", result["user2"])
}
}
func TestAliasLoadFile_TabSeparated(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
content := "user1\talias1\nuser2\t\talias2"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
result, err := AliasLoadFile(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Errorf("expected 2 aliases, got %d", len(result))
}
}
func TestAliasLoadFile_DuplicateKeys(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
content := "user1 alias1\nuser1 alias2\nuser1 alias3"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
result, err := AliasLoadFile(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(result) != 1 {
t.Errorf("expected 1 alias (last one wins), got %d", len(result))
}
if result["user1"] != "alias3" {
t.Errorf("expected user1 -> alias3 (last one), got %q", result["user1"])
}
}
func TestAliasLoadFile_OnlyWhitespace(t *testing.T) {
tmpFile, err := os.CreateTemp("", "aliases-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
content := " \n\t\t\n \t \n"
if _, err := tmpFile.WriteString(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
tmpFile.Close()
result, err := AliasLoadFile(tmpFile.Name())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(result) != 0 {
t.Errorf("expected 0 aliases, got %d", len(result))
}
}

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))
}

300
config.go
View File

@@ -1,17 +1,13 @@
package main
import (
"bufio"
"flag"
"fmt"
"io"
"net"
"os"
"regexp"
"strings"
"time"
"net/smtp"
"github.com/peterbourgon/ff/v3"
"github.com/vharitonsky/iniflags"
"github.com/sirupsen/logrus"
)
var (
@@ -20,84 +16,48 @@ var (
)
var (
flagset = flag.NewFlagSet("smtprelay", flag.ContinueOnError)
// config flags
logFile = flagset.String("logfile", "", "Path to logfile")
logFormat = flagset.String("log_format", "default", "Log output format")
logLevel = flagset.String("log_level", "info", "Minimum log level to output")
hostName = flagset.String("hostname", "localhost.localdomain", "Server hostname")
welcomeMsg = flagset.String("welcome_msg", "", "Welcome message for SMTP session")
listenStr = flagset.String("listen", "127.0.0.1:25 [::1]:25", "Address and port to listen for incoming SMTP")
localCert = flagset.String("local_cert", "", "SSL certificate for STARTTLS/TLS")
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")
aliasFile = flagset.String("aliases_file", "", "Path to aliases file")
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
logFile = flag.String("logfile", "", "Path to logfile")
logFormat = flag.String("log_format", "default", "Log output format")
logLevel = flag.String("log_level", "info", "Minimum log level to output")
hostName = flag.String("hostname", "localhost.localdomain", "Server hostname")
welcomeMsg = flag.String("welcome_msg", "", "Welcome message for SMTP session")
listen = flag.String("listen", "127.0.0.1:25 [::1]:25", "Address and port to listen for incoming SMTP")
localCert = flag.String("local_cert", "", "SSL certificate for STARTTLS/TLS")
localKey = flag.String("local_key", "", "SSL private key for STARTTLS/TLS")
localForceTLS = flag.Bool("local_forcetls", false, "Force STARTTLS (needs local_cert and local_key)")
allowedNetsStr = flag.String("allowed_nets", "127.0.0.0/8 ::1/128", "Networks allowed to send mails")
allowedNets = []*net.IPNet{}
allowedSenderStr = flag.String("allowed_sender", "", "Regular expression for valid FROM EMail addresses")
allowedSender *regexp.Regexp
allowedRecipStr = flag.String("allowed_recipients", "", "Regular expression for valid TO EMail addresses")
allowedRecipients *regexp.Regexp
remotes = []*Remote{}
aliasesList = AliasMap{}
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 setupAliases() {
if *aliasFile != "" {
aliases := make(AliasMap)
aliases, err := AliasLoadFile(*aliasFile)
if err != nil {
log.Fatal().
Str("file", *aliasFile).
Err(err).
Msg("cannot load aliases file")
}
aliasesList = aliases
}
}
func setupAllowedNetworks() {
for _, netstr := range splitstr(*allowedNetsStr, ' ') {
baseIP, allowedNet, err := net.ParseCIDR(netstr)
if err != nil {
log.Fatal().
Str("netstr", netstr).
Err(err).
Msg("Invalid CIDR notation in allowed_nets")
log.WithField("netstr", netstr).
WithError(err).
Fatal("Invalid CIDR notation in allowed_nets")
}
// Reject any network specification where any host bits are set,
// meaning the address refers to a host and not a network.
if !allowedNet.IP.Equal(baseIP) {
log.Fatal().
Str("given_net", netstr).
Str("proper_net", allowedNet.String()).
Msg("Invalid network in allowed_nets (host bits set)")
log.WithFields(logrus.Fields{
"given_net": netstr,
"proper_net": allowedNet,
}).Fatal("Invalid network in allowed_nets (host bits set)")
}
allowedNets = append(allowedNets, allowedNet)
@@ -107,196 +67,80 @@ func setupAllowedNetworks() {
func setupAllowedPatterns() {
var err error
if *allowedSenderStr != "" {
if (*allowedSenderStr != "") {
allowedSender, err = regexp.Compile(*allowedSenderStr)
if err != nil {
log.Fatal().
Str("allowed_sender", *allowedSenderStr).
Err(err).
Msg("allowed_sender pattern invalid")
log.WithField("allowed_sender", *allowedSenderStr).
WithError(err).
Fatal("allowed_sender pattern invalid")
}
}
if *allowedRecipStr != "" {
if (*allowedRecipStr != "") {
allowedRecipients, err = regexp.Compile(*allowedRecipStr)
if err != nil {
log.Fatal().
Str("allowed_recipients", *allowedRecipStr).
Err(err).
Msg("allowed_recipients pattern invalid")
log.WithField("allowed_recipients", *allowedRecipStr).
WithError(err).
Fatal("allowed_recipients pattern invalid")
}
}
}
func setupRemotes() {
logger := log.With().Str("remotes", *remotesStr).Logger()
if *remotesStr != "" {
for _, remoteURL := range strings.Split(*remotesStr, " ") {
r, err := ParseRemote(remoteURL)
if err != nil {
logger.Fatal().Msg(fmt.Sprintf("error parsing url: '%s': %v", remoteURL, err))
}
func setupRemoteAuth() {
logger := log.WithField("remote_auth", *remoteAuthStr)
remotes = append(remotes, r)
// Remote auth disabled?
if *remoteAuthStr == "" || *remoteAuthStr == "none" {
if *remoteUser != "" {
logger.Fatal("remote_user given but not used")
}
}
}
type protoAddr struct {
protocol string
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.Fatal().
Str("address", pa.address).
Msg("Local authentication (via allowed_users file) " +
"not allowed with non-TLS listener")
if *remotePass != "" {
logger.Fatal("remote_pass given but not used")
}
listenAddrs = append(listenAddrs, pa)
// No auth; use empty default
return
}
}
func setupTimeouts() {
var err error
// 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")
}
readTimeout, err = time.ParseDuration(*readTimeoutStr)
host, _, err := net.SplitHostPort(*remoteHost)
if err != nil {
log.Fatal().
Str("read_timeout", *readTimeoutStr).
Err(err).
Msg("read_timeout duration string invalid")
}
if readTimeout.Seconds() < 1 {
log.Fatal().
Str("read_timeout", *readTimeoutStr).
Msg("read_timeout less than one second")
logger.WithField("remote_host", *remoteHost).
Fatal("Invalid remote_host")
}
writeTimeout, err = time.ParseDuration(*writeTimeoutStr)
if err != nil {
log.Fatal().
Str("write_timeout", *writeTimeoutStr).
Err(err).
Msg("write_timeout duration string invalid")
}
if writeTimeout.Seconds() < 1 {
log.Fatal().
Str("write_timeout", *writeTimeoutStr).
Msg("write_timeout less than one second")
}
dataTimeout, err = time.ParseDuration(*dataTimeoutStr)
if err != nil {
log.Fatal().
Str("data_timeout", *dataTimeoutStr).
Err(err).
Msg("data_timeout duration string invalid")
}
if dataTimeout.Seconds() < 1 {
log.Fatal().
Str("data_timeout", *dataTimeoutStr).
Msg("data_timeout less than one second")
switch *remoteAuthStr {
case "plain":
remoteAuth = smtp.PlainAuth("", *remoteUser, *remotePass, host)
case "login":
remoteAuth = LoginAuth(*remoteUser, *remotePass)
default:
logger.Fatal("Invalid remote_auth type")
}
}
func ConfigLoad() {
// use .env file if it exists
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)
}
}
iniflags.Parse()
// Set up logging as soon as possible
setupLogger()
if *versionInfo {
fmt.Printf("smtprelay/%s (%s)\n", appVersion, buildTime)
os.Exit(0)
}
if *remotesStr == "" && *command == "" {
log.Warn().Msg("no remotes or command set; mail will not be forwarded!")
if (*remoteHost == "") {
log.Warn("remote_host not set; mail will not be forwarded!")
}
setupAllowedNetworks()
setupAllowedPatterns()
setupAliases()
setupRemotes()
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
setupRemoteAuth()
}

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)
}
})
}
}

25
go.mod
View File

@@ -1,24 +1,11 @@
module github.com/decke/smtprelay
require (
github.com/DeRuina/timberjack v1.3.9
github.com/chrj/smtpd v0.3.1
github.com/fsnotify/fsnotify v1.9.0
github.com/google/uuid v1.6.0
github.com/peterbourgon/ff/v3 v3.4.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0
github.com/chrj/smtpd v0.3.0
github.com/google/uuid v1.2.0
github.com/sirupsen/logrus v1.8.1
github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/sys v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.24.3
go 1.13

60
go.sum
View File

@@ -1,44 +1,24 @@
github.com/DeRuina/timberjack v1.3.9 h1:6UXZ1I7ExPGTX/1UNYawR58LlOJUHKBPiYC7WQ91eBo=
github.com/DeRuina/timberjack v1.3.9/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/chrj/smtpd v0.3.1 h1:kogHFkbFdKaoH3bgZkqNC9uVtKYOFfM3uV3rroBdooE=
github.com/chrj/smtpd v0.3.1/go.mod h1:JtABvV/LzvLmEIzy0NyDnrfMGOMd8wy5frAokwf6J9Q=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/chrj/smtpd v0.3.0 h1:cw1LSHDOz7N3XbkcZSF/bue9dh7ATKk5ZksfBztV6b0=
github.com/chrj/smtpd v0.3.0/go.mod h1:1hmG9KbrE10JG1SmvG79Krh4F6713oUrw2+gRp1oSYk=
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/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/eaigner/dkim v0.0.0-20150301120808-6fe4a7ee9cfb/go.mod h1:FSCIHbrqk7D01Mj8y/jW+NS1uoCerr+ad+IckTHTFf4=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de h1:fkw+7JkxF3U1GzQoX9h69Wvtvxajo5Rbzy6+YMMzPIg=
github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de/go.mod h1:irMhzlTz8+fVFj6CH2AN2i+WI5S6wWFtK3MBCIxIpyI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -2,84 +2,59 @@ package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/DeRuina/timberjack"
"github.com/rs/zerolog"
"github.com/sirupsen/logrus"
)
var (
rotator *timberjack.Logger
log *zerolog.Logger
log *logrus.Logger
)
func setupLogger() {
zerolog.TimeFieldFormat = time.RFC3339
log = logrus.New()
// Handle logfile
var writer io.Writer
if *logFile == "" {
writer = os.Stderr
if (*logFile == "") {
log.SetOutput(os.Stderr)
} else {
rotator = &timberjack.Logger{
Filename: *logFile,
MaxSize: 10, // megabytes before rotation
MaxBackups: 3,
MaxAge: 30, // days
Compress: true,
BackupTimeFormat: "20060102150405",
writer, err := os.OpenFile(*logFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600)
if err != nil {
fmt.Printf("cannot open log file: %s\n", err)
os.Exit(1)
}
writer = rotator
log.SetOutput(writer)
}
// Handle log_format
switch *logFormat {
case "json":
// zerolog default is JSON
log.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: time.RFC3339Nano,
DisableHTMLEscape: true,
})
case "plain":
writer = zerolog.ConsoleWriter{
Out: writer,
NoColor: true,
TimeFormat: "",
FormatTimestamp: func(i interface{}) string {
return "" // avoid default time
},
}
log.SetFormatter(&logrus.TextFormatter{
DisableTimestamp: true,
})
case "", "default":
writer = zerolog.ConsoleWriter{
Out: writer,
NoColor: true,
TimeFormat: time.RFC3339,
}
case "pretty":
writer = zerolog.ConsoleWriter{
Out: writer,
TimeFormat: time.RFC3339Nano,
}
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
default:
fmt.Fprintf(os.Stderr, "Invalid log_format: %s\n", *logFormat)
os.Exit(1)
}
l := zerolog.New(writer).With().Timestamp().Logger()
log = &l
// Handle log_level
level, err := zerolog.ParseLevel(strings.ToLower(*logLevel))
level, err := logrus.ParseLevel(*logLevel)
if err != nil {
level = zerolog.InfoLevel
log.Warn().Str("given_level", *logLevel).
Msg("could not parse log level, defaulting to 'info'")
}
zerolog.SetGlobalLevel(level)
}
level = logrus.InfoLevel
// Call this on shutdown if you want to close the rotator and stop timers cleanly
func closeLogger() {
if rotator != nil {
rotator.Close()
log.WithField("given_level", *logLevel).
Warn("could not parse log level, defaulting to 'info'")
}
log.SetLevel(level)
}

395
main.go
View File

@@ -1,20 +1,18 @@
package main
import (
"bytes"
"crypto/tls"
"fmt"
"net"
"net/textproto"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"github.com/chrj/smtpd"
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
func connectionChecker(peer smtpd.Peer) error {
@@ -32,9 +30,9 @@ func connectionChecker(peer smtpd.Peer) error {
}
}
log.Warn().
Str("ip", peerIP.String()).
Msg("Connection refused from address outside of allowed_nets")
log.WithFields(logrus.Fields{
"ip": peerIP,
}).Warn("Connection refused from address outside of allowed_nets")
return smtpd.Error{Code: 421, Message: "Denied"}
}
@@ -83,25 +81,23 @@ func addrAllowed(addr string, allowedAddrs []string) bool {
func senderChecker(peer smtpd.Peer, addr string) error {
// check sender address from auth file if user is authenticated
if localAuthRequired() && peer.Username != "" {
if *allowedUsers != "" && peer.Username != "" {
user, err := AuthFetch(peer.Username)
if err != nil {
// Shouldn't happen: authChecker already validated username+password
log.Warn().
Str("peer", peer.Addr.String()).
Str("username", peer.Username).
Err(err).
Msg("could not fetch auth user")
log.WithFields(logrus.Fields{
"peer": peer.Addr,
"username": peer.Username,
}).WithError(err).Warn("could not fetch auth user")
return smtpd.Error{Code: 451, Message: "Bad sender address"}
}
if !addrAllowed(addr, user.allowedAddresses) {
log.Warn().
Str("peer", peer.Addr.String()).
Str("username", peer.Username).
Str("sender_address", addr).
Err(err).
Msg("sender address not allowed for authenticated user")
log.WithFields(logrus.Fields{
"peer": peer.Addr,
"username": peer.Username,
"sender_address": addr,
}).Warn("sender address not allowed for authenticated user")
return smtpd.Error{Code: 451, Message: "Bad sender address"}
}
}
@@ -116,10 +112,10 @@ func senderChecker(peer smtpd.Peer, addr string) error {
return nil
}
log.Warn().
Str("sender_address", addr).
Str("peer", peer.Addr.String()).
Msg("sender address not allowed by allowed_sender pattern")
log.WithFields(logrus.Fields{
"sender_address": addr,
"peer": peer.Addr,
}).Warn("sender address not allowed by allowed_sender pattern")
return smtpd.Error{Code: 451, Message: "Bad sender address"}
}
@@ -134,21 +130,20 @@ func recipientChecker(peer smtpd.Peer, addr string) error {
return nil
}
log.Warn().
Str("peer", peer.Addr.String()).
Str("recipient_address", addr).
Msg("recipient address not allowed by allowed_recipients pattern")
log.WithFields(logrus.Fields{
"peer": peer.Addr,
"recipient_address": addr,
}).Warn("recipient address not allowed by allowed_recipients pattern")
return smtpd.Error{Code: 451, Message: "Bad recipient address"}
}
func authChecker(peer smtpd.Peer, username string, password string) error {
err := AuthCheckPassword(username, password)
if err != nil {
log.Warn().
Str("peer", peer.Addr.String()).
Str("username", username).
Err(err).
Msg("auth error")
log.WithFields(logrus.Fields{
"peer": peer.Addr,
"username": username,
}).WithError(err).Warn("auth error")
return smtpd.Error{Code: 535, Message: "Authentication credentials invalid"}
}
return nil
@@ -160,109 +155,61 @@ func mailHandler(peer smtpd.Peer, env smtpd.Envelope) error {
peerIP = addr.IP.String()
}
// Check for aliases
aliasesMutex.RLock()
for i, recipient := range env.Recipients {
if alias, exists := aliasesList[recipient]; exists {
env.Recipients[i] = alias
log.Info().
Str("original_recipient", recipient).
Str("aliased_recipient", alias).
Msg("Recipient address aliased")
}
}
aliasesMutex.RUnlock()
logger := log.WithFields(logrus.Fields{
"from": env.Sender,
"to": env.Recipients,
"peer": peerIP,
"host": *remoteHost,
"uuid": generateUUID(),
})
logger := log.With().
Str("from", env.Sender).
Strs("to", env.Recipients).
Str("peer", peerIP).
Str("uuid", generateUUID()).
Logger()
var envRemotes []*Remote
if *strictSender {
for _, remote := range remotes {
if remote.Sender == env.Sender {
envRemotes = append(envRemotes, remote)
}
}
} else {
envRemotes = remotes
if (*remoteHost == "") {
logger.Warning("remote_host not set; discarding mail")
return nil
}
if len(envRemotes) == 0 && *command == "" {
logger.Warn().Msg("no remote_host or command set; discarding mail")
return smtpd.Error{Code: 554, Message: "There are no appropriate remote_host or command"}
}
logger.Info("delivering mail from peer using smarthost")
env.AddReceivedLine(peer)
if *command != "" {
cmdLogger := logger.With().Str("command", *command).Logger()
var sender string
var stdout bytes.Buffer
var stderr bytes.Buffer
environ := os.Environ()
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.Error().Err(err).Msg(stderr.String())
return smtpd.Error{Code: 554, Message: "External command failed"}
}
cmdLogger.Info().Msg("pipe command successful: " + stdout.String())
if *remoteSender == "" {
sender = env.Sender
} else {
sender = *remoteSender
}
for _, remote := range envRemotes {
logger = logger.With().Str("host", remote.Addr).Logger()
logger.Info().Msg("delivering mail from peer using smarthost")
err := SendMail(
*remoteHost,
remoteAuth,
sender,
env.Recipients,
env.Data,
)
if err != nil {
var smtpError smtpd.Error
err := SendMail(
remote,
env.Sender,
env.Recipients,
env.Data,
)
if err != nil {
var smtpError smtpd.Error
switch err.(type) {
case *textproto.Error:
err := err.(*textproto.Error)
smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
switch err := err.(type) {
case *textproto.Error:
smtpError = smtpd.Error{Code: err.Code, Message: err.Msg}
logger.WithFields(logrus.Fields{
"err_code": err.Code,
"err_msg": err.Msg,
}).Error("delivery failed")
default:
smtpError = smtpd.Error{Code: 554, Message: "Forwarding failed"}
logger.Error().
Int("err_code", err.Code).
Str("err_msg", err.Msg).
Msg("delivery failed")
default:
smtpError = smtpd.Error{Code: 421, Message: "Forwarding failed"}
logger.Error().
Err(err).
Msg("delivery failed")
}
return smtpError
logger.WithError(err).
Error("delivery failed")
}
logger.Debug().Msg("delivery successful")
return smtpError
}
logger.Debug("delivery successful")
return nil
}
@@ -270,9 +217,8 @@ func generateUUID() string {
uniqueID, err := uuid.NewRandom()
if err != nil {
log.Error().
Err(err).
Msg("could not generate UUIDv4")
log.WithError(err).
Error("could not generate UUIDv4")
return ""
}
@@ -281,167 +227,115 @@ func generateUUID() string {
}
func getTLSConfig() *tls.Config {
// Ciphersuites as defined in stock Go but without 3DES and RC4
// https://golang.org/src/crypto/tls/cipher_suites.go
var tlsCipherSuites = []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256, // does not provide PFS
tls.TLS_RSA_WITH_AES_256_GCM_SHA384, // does not provide PFS
}
if *localCert == "" || *localKey == "" {
log.Fatal().
Str("cert_file", *localCert).
Str("key_file", *localKey).
Msg("TLS certificate/key file not defined in config")
log.WithFields(logrus.Fields{
"cert_file": *localCert,
"key_file": *localKey,
}).Fatal("TLS certificate/key file not defined in config")
}
cert, err := tls.LoadX509KeyPair(*localCert, *localKey)
if err != nil {
log.Fatal().
Err(err).
Msg("cannot load X509 keypair")
log.WithField("error", err).
Fatal("cannot load X509 keypair")
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
}
}
func watchAliasFile() {
if *aliasFile == "" {
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Error().
Err(err).
Msg("failed to create file watcher for alias file")
return
}
go func() {
defer watcher.Close()
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
log.Info().
Str("file", event.Name).
Msg("alias file changed, reloading")
err := LoadAliases(*aliasFile)
if err != nil {
log.Error().
Str("file", *aliasFile).
Err(err).
Msg("failed to reload alias file")
} else {
log.Info().
Int("count", len(aliasesList)).
Msg("alias file reloaded successfully")
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Error().
Err(err).
Msg("file watcher error")
}
}
}()
err = watcher.Add(*aliasFile)
if err != nil {
log.Error().
Str("file", *aliasFile).
Err(err).
Msg("failed to watch alias file")
} else {
log.Info().
Str("file", *aliasFile).
Msg("watching alias file for changes")
PreferServerCipherSuites: true,
MinVersion: tls.VersionTLS12,
CipherSuites: tlsCipherSuites,
Certificates: []tls.Certificate{cert},
}
}
func main() {
ConfigLoad()
log.Debug().
Str("version", appVersion).
Msg("starting smtprelay")
// Load allowed users file
if localAuthRequired() {
err := AuthLoadFile(*allowedUsers)
if err != nil {
log.Fatal().
Str("file", *allowedUsers).
Err(err).
Msg("cannot load allowed users file")
}
if *versionInfo {
fmt.Printf("smtprelay/%s (%s)\n", appVersion, buildTime)
os.Exit(0)
}
// Start watching alias file for changes
watchAliasFile()
log.WithField("version", appVersion).
Debug("starting smtprelay")
// Load allowed users file
if *allowedUsers != "" {
err := AuthLoadFile(*allowedUsers)
if err != nil {
log.WithField("file", *allowedUsers).
WithError(err).
Fatal("cannot load allowed users file")
}
}
var servers []*smtpd.Server
// Create a server for each desired listen address
for _, listen := range listenAddrs {
logger := log.With().Str("address", listen.address).Logger()
for _, listenAddr := range strings.Split(*listen, " ") {
server := &smtpd.Server{
Hostname: *hostName,
WelcomeMessage: *welcomeMsg,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
DataTimeout: dataTimeout,
MaxConnections: *maxConnections,
MaxMessageSize: *maxMessageSize,
MaxRecipients: *maxRecipients,
ConnectionChecker: connectionChecker,
SenderChecker: senderChecker,
RecipientChecker: recipientChecker,
Handler: mailHandler,
}
if localAuthRequired() {
if *allowedUsers != "" {
server.Authenticator = authChecker
}
var lsnr net.Listener
var err error
switch listen.protocol {
case "":
logger.Info().Msg("listening on address")
lsnr, err = net.Listen("tcp", listen.address)
if strings.Index(listenAddr, "://") == -1 {
log.WithField("address", listenAddr).
Info("listening on address")
lsnr, err = net.Listen("tcp", listenAddr)
} else if strings.HasPrefix(listenAddr, "starttls://") {
listenAddr = strings.TrimPrefix(listenAddr, "starttls://")
case "starttls":
server.TLSConfig = getTLSConfig()
server.ForceTLS = *localForceTLS
logger.Info().Msg("listening on address (STARTTLS)")
lsnr, err = net.Listen("tcp", listen.address)
log.WithField("address", listenAddr).
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()
logger.Info().Msg("listening on address (TLS)")
lsnr, err = tls.Listen("tcp", listen.address, server.TLSConfig)
default:
logger.Fatal().
Str("protocol", listen.protocol).
Msg("unknown protocol in listen address")
log.WithField("address", listenAddr).
Info("listening on address (TLS)")
lsnr, err = tls.Listen("tcp", listenAddr, server.TLSConfig)
} else {
log.WithField("address", listenAddr).
Fatal("unknown protocol in listen address")
}
if err != nil {
logger.Fatal().
Err(err).
Msg("error starting listener")
log.WithFields(logrus.Fields{
"address": listenAddr,
}).WithError(err).Fatal("error starting listener")
}
servers = append(servers, server)
@@ -454,31 +348,27 @@ func main() {
// First close the listeners
for _, server := range servers {
logger := log.With().Str("address", server.Address().String()).Logger()
logger.Debug().Msg("Shutting down server")
logger := log.WithField("address", server.Address())
logger.Debug("Shutting down server")
err := server.Shutdown(false)
if err != nil {
logger.Warn().
Err(err).
Msg("Shutdown failed")
logger.WithError(err).
Warning("Shutdown failed")
}
}
// Then wait for the clients to exit
for _, server := range servers {
logger := log.With().Str("address", server.Address().String()).Logger()
logger.Debug().Msg("Waiting for server")
logger := log.WithField("address", server.Address())
logger.Debug("Waiting for server")
err := server.Wait()
if err != nil {
logger.Warn().
Err(err).
Msg("Wait failed")
logger.WithError(err).
Warning("Wait failed")
}
}
log.Debug().Msg("done")
closeLogger()
log.Debug("done")
}
func handleSignals() {
@@ -487,7 +377,6 @@ func handleSignals() {
signal.Notify(sigs, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
sig := <-sigs
log.Info().
Str("signal", sig.String()).
Msg("shutting down in response to received signal")
log.WithField("signal", sig).
Info("shutting down in response to received signal")
}

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

73
smtp.go
View File

@@ -4,17 +4,15 @@
// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321.
// It also implements the following extensions:
//
// 8BITMIME RFC 1652
// AUTH RFC 2554
// STARTTLS RFC 3207
//
// Additional extensions may be handled by clients.
//
// The smtp package is frozen and is not accepting new features.
// Some external packages provide more functionality. See:
//
// https://godoc.org/?q=smtp
// https://godoc.org/?q=smtp
package main
import (
@@ -49,7 +47,7 @@ type Client struct {
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".
func Dial(addr string) (*Client, error) {
conn, err := net.Dial("tcp", addr)
@@ -60,7 +58,7 @@ func Dial(addr string) (*Client, error) {
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.
func NewClient(conn net.Conn, host string) (*Client, error) {
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
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...)
if err != nil {
return 0, "", err
@@ -139,8 +137,12 @@ func (c *Client) ehlo() error {
if len(extList) > 1 {
extList = extList[1:]
for _, line := range extList {
k, v, _ := strings.Cut(line, " ")
ext[k] = v
args := strings.SplitN(line, " ", 2)
if len(args) > 1 {
ext[args[0]] = args[1]
} else {
ext[args[0]] = ""
}
}
}
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.
// The return values are their zero values if [Client.StartTLS] did
// The return values are their zero values if StartTLS did
// not succeed.
func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) {
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)))
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 {
var msg []byte
switch code {
@@ -233,7 +235,7 @@ func (c *Client) Auth(a smtp.Auth) error {
}
resp64 = make([]byte, encoding.EncodedLen(len(resp)))
encoding.Encode(resp64, resp)
code, msg64, err = c.cmd(0, "%s", resp64)
code, msg64, err = c.cmd(0, string(resp64))
}
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
// parameter. If the server supports the SMTPUTF8 extension, Mail adds the
// 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 {
if err := validateLine(from); err != nil {
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.
// A call to Rcpt must be preceded by a call to [Client.Mail] and may be followed by
// a [Client.Data] call or another Rcpt call.
// A call to Rcpt must be preceded by a call to Mail and may be followed by
// a Data call or another Rcpt call.
func (c *Client) Rcpt(to string) error {
if err := validateLine(to); err != nil {
return err
@@ -288,7 +290,7 @@ func (d *dataCloser) Close() error {
// 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
// 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) {
_, _, err := c.cmd(354, "DATA")
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
// functionality. Higher-level packages exist outside of the standard
// library.
func SendMail(r *Remote, from string, to []string, msg []byte) error {
if r.Sender != "" {
from = r.Sender
}
func SendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
if err := validateLine(from); err != nil {
return err
}
@@ -333,19 +331,19 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err
}
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
var c *Client
var err error
if r.Scheme == "smtps" {
config := &tls.Config{
ServerName: r.Hostname,
InsecureSkipVerify: r.SkipVerify,
}
conn, err := tls.Dial("tcp", r.Addr, config)
if port == "465" || port == "smtps" {
config := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, config)
if err != nil {
return err
}
defer conn.Close()
c, err = NewClient(conn, r.Hostname)
c, err = NewClient(conn, host)
if err != nil {
return err
}
@@ -353,7 +351,7 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err
}
} else {
c, err = Dial(r.Addr)
c, err = Dial(addr)
if err != nil {
return err
}
@@ -362,25 +360,20 @@ func SendMail(r *Remote, from string, to []string, msg []byte) error {
return err
}
if ok, _ := c.Extension("STARTTLS"); ok {
config := &tls.Config{
ServerName: c.serverName,
InsecureSkipVerify: r.SkipVerify,
}
config := &tls.Config{ServerName: c.serverName}
if testHookStartTLS != nil {
testHookStartTLS(config)
}
if err = c.StartTLS(config); err != nil {
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 {
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
}
}
@@ -445,7 +438,9 @@ func (c *Client) Noop() error {
// Quit sends the QUIT command and closes the connection to the server.
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")
if err != nil {
return err
@@ -453,7 +448,7 @@ func (c *Client) Quit() error {
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 {
if strings.ContainsAny(line, "\n\r") {
return errors.New("smtp: A line must not contain CR or LF")

View File

@@ -1,28 +1,19 @@
; 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 =
; Log format: default, plain (no timestamp), json
;log_format = default
;log_format = "default"
; Log level: panic, fatal, error, warn, info, debug, trace
;log_level = info
; path to alias file
; alias file format (separated by space):
; fake@email.tld real@email.tld
;aliases_file = aliases.txt
;log_level = "info"
; Hostname for this SMTP server
;hostname = localhost.localdomain
;hostname = "localhost.localdomain"
; Welcome message for clients
;welcome_msg = <hostname> ESMTP ready.
;welcome_msg = "<hostname> ESMTP ready."
; Listen on the following addresses for incoming
; unencrypted connections.
@@ -39,37 +30,6 @@
; accepting mails from client.
;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
; Defaults to localhost. If set to "", then any address is allowed.
;allowed_nets = 127.0.0.0/8 ::1/128
@@ -88,7 +48,7 @@
; authentication before they can send mail.
; File format: username bcrypt-hash [email[,email[,...]]]
; 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:
; - If omitted, user can send from any address
; - If @domain.com is given, user can send from any address @domain.com
@@ -96,40 +56,25 @@
; E.g. "app@example.com,@appsrv.example.com"
;allowed_users =
; Relay all mails to this SMTP servers.
; Relay all mails to this SMTP server.
; 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
;remotes = starttls://user:pass@smtp.gmail.com:587
;remote_host = smtp.gmail.com:587
; Mailgun.org
;remotes = starttls://user:pass@smtp.mailgun.org:587
;remote_host = smtp.mailgun.org:587
; Mailjet.com
;remotes = starttls://user:pass@in-v3.mailjet.com:587
;remote_host = in-v3.mailjet.com:587
; Ignore remote host certificates
;remotes = starttls://user:pass@server:587?skipVerify
; Authentication credentials on outgoing SMTP server
;remote_user =
;remote_pass =
; Login Authentication method on outgoing SMTP server
;remotes = smtp://user:pass@server:2525?auth=login
; Authentication method on outgoing SMTP server
; (none, plain, login)
;remote_auth = none
; Sender e-mail address on outgoing SMTP server
;remotes = smtp://user:pass@server:2525/overridden@email.com?auth=login
; 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
;remote_sender =