diff --git a/.build.yml b/.build.yml deleted file mode 100644 index 605b150ce..000000000 --- a/.build.yml +++ /dev/null @@ -1,36 +0,0 @@ -image: archlinux -packages: - - go - - pam - - scdoc - - curl -sources: - - https://github.com/foxcpp/maddy -tasks: - - build: | - cd maddy - go build ./... - - buildsh: | - cd maddy - ./build.sh - ./build.sh --destdir destdir/ install - find destdir/ - - test: | - cd maddy - go test ./... -coverprofile=coverage.out -covermode=atomic -race - - integration-test: | - cd maddy/tests - ./run.sh - - lint: | - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.33.0 - cd maddy/ - $(go env GOPATH)/bin/golangci-lint run || true - - build-man-pages: | - cd maddy/docs/man - for f in *.scd; do scdoc < $f> /dev/null; done - - upload-coverage: | - export CODECOV_TOKEN=a4598288-4c29-4da7-87cf-64a36e23d245 - cd maddy/ - bash <(curl https://codecov.io/bash) -f coverage.out -F unit - cd tests/ - bash <(curl https://codecov.io/bash) -f coverage.out -F integration diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..ef99e5a5b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +testdata/ +cmd/maddy/maddy +maddy +tests/maddy.cover diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 8a28a4d5d..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -custom: "https://foxcpp.dev/donate" -liberapay: foxcpp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..6ec83b753 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +name: "Prepare release artifacts" + +on: + push: + tags: [ "v*" ] + +permissions: + id-token: write + contents: read + attestations: write + packages: write + +jobs: + artifact-builder-x86: + name: "Prepare release artifacts (x86)" + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + container: + image: "alpine:edge" + steps: + - uses: actions/checkout@v1 # v2 does not work with containers + - name: "Install build dependencies" + run: | + apk add --no-cache gcc go zstd + - name: "Create and package build tree" + run: | + ./build.sh --builddir ~/package-output/ --static build + ver=$(cat .version) + if [ "v$ver" != "${{github.ref_name}}" ]; then echo ".version does not match the Git tag"; exit 1; fi + mv ~/package-output/ ~/maddy-$ver-x86_64-linux-musl + cd ~ + tar c ./maddy-$ver-x86_64-linux-musl | zstd> ~/maddy-x86_64-linux-musl.tar.zst + cd - + - name: "Save source tree" + run: | + rm -rf .git + ver=$(cat .version) + cp -r . ~/maddy-$ver-src + cd ~ + tar c ./maddy-$ver-src | zstd> ~/maddy-src.tar.zst + cd - + - name: "Upload source tree" + uses: actions/upload-artifact@v4 + with: + name: maddy-src.tar.zst + path: '~/maddy-src.tar.zst' + if-no-files-found: error + - name: "Upload binary tree" + uses: actions/upload-artifact@v4 + with: + name: maddy-binary.tar.zst + path: '~/maddy-x86_64-linux-musl.tar.zst' + if-no-files-found: error + - name: "Generate artifact attestation" + uses: actions/attest-build-provenance@v2 + with: + subject-path: '~/maddy-x86_64-linux-musl.tar.zst' + artifact-builder-arm: + name: "Prepare release artifacts (aarch64)" + if: github.ref_type == 'tag' + runs-on: ubuntu-22.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # Building in a Docker container is a workaround for the issue of + # JavaScript-based GitHub Actions not being supported in Alpine + # containers on the Arm64 platform. Otherwise, we could completely reuse + # artifact-builder-x86 as a matrix job by running it on an Arm runner. + - name: Build in Docker container + run: | + # Create Dockerfile for the build + cat> Dockerfile << 'EOF' + FROM alpine:edge + RUN apk add --no-cache gcc go zstd musl-dev scdoc + WORKDIR /build + COPY . . + RUN ./build.sh --builddir /package-output/ --static build && \ + ver=$(cat .version) && \ + if [ "v$ver" != "${{github.ref_name}}" ]; then echo ".version does not match the Git tag"; exit 1; fi && \ + mv /package-output/ /maddy-$ver-aarch64-linux-musl && \ + cd / && \ + tar c ./maddy-$ver-aarch64-linux-musl | zstd> /maddy-aarch64-linux-musl.tar.zst + EOF + # Build the image, create a temporary container and copy the artifact. + docker build -t maddy-builder . + container_id=$(docker create maddy-builder) + docker cp $container_id:/maddy-aarch64-linux-musl.tar.zst . + docker rm $container_id + - name: Upload binary tree + uses: actions/upload-artifact@v4 + with: + name: maddy-binary-aarch64.tar.zst + path: maddy-aarch64-linux-musl.tar.zst + if-no-files-found: error + - name: "Generate artifact attestation" + uses: actions/attest-build-provenance@v2 + with: + subject-path: 'maddy-aarch64-linux-musl.tar.zst' + docker-builder: + name: "Build & push Docker image" + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: "Set up QEMU" + uses: docker/setup-qemu-action@v1 + with: + platforms: arm64 + - name: "Set up Docker Buildx" + id: buildx + uses: docker/setup-buildx-action@v3 + - name: "Login to Docker Hub" + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + logout: false + - name: "Login to GitHub Container Registry" + uses: docker/login-action@v3 + with: + registry: "ghcr.io" + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: false # https://news.ycombinator.com/item?id=28607735 + - name: "Generate container metadata" + uses: docker/metadata-action@v5 + id: meta + with: + images: | + foxcpp/maddy + ghcr.io/foxcpp/maddy + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + labels: | + org.opencontainers.image.title=Maddy Mail Server + org.opencontainers.image.documentation=https://maddy.email/docker/ + org.opencontainers.image.url=https://maddy.email + - name: "Build and push" + uses: docker/build-push-action@v6 + id: docker + with: + context: . + platforms: linux/amd64,linux/arm64 + file: Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: "Generate container attestation" + uses: actions/attest-build-provenance@v2 + with: + subject-name: ghcr.io/foxcpp/maddy + subject-digest: ${{ steps.docker.outputs.digest }} + push-to-registry: true + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..4cf136f1b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,66 @@ +name: "Testing" + +on: + push: + branches: [ master, dev ] + tags: [ "v*" ] + pull_request: + branches: [ master, dev ] + +permissions: + contents: read + pull-requests: read + checks: write + +jobs: + golangci: + name: Lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: "Install libpam" + run: | + sudo apt-get update + sudo apt-get install -y libpam-dev + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.11 + buildsh: + name: "Verify build.sh" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: "Install libpam" + run: | + sudo apt-get update + sudo apt-get install -y libpam-dev + - name: "Verify build.sh" + run: | + ./build.sh + ./build.sh --destdir destdir/ install + find destdir/ + test: + name: "Build and test" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: "Install libpam" + run: | + sudo apt-get update + sudo apt-get install -y libpam-dev + - name: "Unit & module tests" + run: | + go test ./... -coverprofile=coverage.out -covermode=atomic + - name: "Integration tests" + run: | + cd tests/ + ./run.sh diff --git a/.gitignore b/.gitignore index 6b204b664..790b848a4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,16 +16,16 @@ _testmain.go *.exe~ *.test *.prof +**/.envrc +**/.DS_Store # Tests coverage *.out # Compiled binaries cmd/maddy/maddy -cmd/maddyctl/maddyctl cmd/maddy-*-helper/maddy-*-helper -maddy -maddyctl +/maddy # Man pages docs/man/*.1 diff --git a/.golangci.yml b/.golangci.yml index 47b5bb3e0..9200934ab 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,20 +1,25 @@ +version: "2" linters: enable: - - gosimple - - structcheck - - varcheck - errcheck - staticcheck - ineffassign - - deadcode - - typecheck - govet - unused - - goimports - prealloc - unconvert - misspell - whitespace - nakedret - dogsled - - exportloopref + - copyloopvar + - sqlclosecheck + - testifylint + - rowserrcheck + - recvcheck + settings: + errcheck: + disable-default-exclusions: false +formatters: + enable: + - goimports diff --git a/.mkdocs.yml b/.mkdocs.yml index 240c655fa..3e90eec17 100644 --- a/.mkdocs.yml +++ b/.mkdocs.yml @@ -2,44 +2,83 @@ site_name: maddy repo_url: https://github.com/foxcpp/maddy -theme: readthedocs +theme: alb markdown_extensions: - codehilite: guess_lang: false nav: + - faq.md - Tutorials: - tutorials/setting-up.md - tutorials/building-from-source.md - tutorials/alias-to-remote.md - tutorials/pam.md - Release builds: 'https://maddy.email/builds/' - - Integration with software: - - third-party/dovecot.md - - third-party/smtp-servers.md - - third-party/rspamd.md - - third-party/mailman3.md - - seclevels.md - - faq.md - multiple-domains.md - - unicode.md - upgrading.md - - specifications.md - - openmetrics.md - - Manual pages: - - man/_generated_maddy.1.md - - man/_generated_maddy.5.md - - man/_generated_maddy-auth.5.md - - man/_generated_maddy-blob.5.md - - man/_generated_maddy-config.5.md - - man/_generated_maddy-filters.5.md - - man/_generated_maddy-imap.5.md - - man/_generated_maddy-smtp.5.md - - man/_generated_maddy-storage.5.md - - man/_generated_maddy-targets.5.md - - man/_generated_maddy-tables.5.md - - man/_generated_maddy-tls.5.md + - seclevels.md + - docker.md + - Reference manual: + - reference/modules.md + - reference/global-config.md + - reference/tls.md + - reference/tls-acme.md + - Endpoints configuration: + - reference/endpoints/imap.md + - reference/endpoints/smtp.md + - reference/endpoints/openmetrics.md + - IMAP storage: + - reference/storage/imap-filters.md + - reference/storage/imapsql.md + - Blob storage: + - reference/blob/fs.md + - reference/blob/s3.md + - reference/smtp-pipeline.md + - SMTP targets: + - reference/targets/queue.md + - reference/targets/remote.md + - reference/targets/smtp.md + - SMTP checks: + - reference/checks/actions.md + - reference/checks/dkim.md + - reference/checks/spf.md + - reference/checks/milter.md + - reference/checks/rspamd.md + - reference/checks/dnsbl.md + - reference/checks/command.md + - reference/checks/authorize_sender.md + - reference/checks/misc.md + - SMTP modifiers: + - reference/modifiers/dkim.md + - reference/modifiers/envelope.md + - Lookup tables (string translation): + - reference/table/static.md + - reference/table/regexp.md + - reference/table/file.md + - reference/table/sql_query.md + - reference/table/chain.md + - reference/table/email_localpart.md + - reference/table/email_with_domain.md + - reference/table/auth.md + - Authentication providers: + - reference/auth/pass_table.md + - reference/auth/pam.md + - reference/auth/shadow.md + - reference/auth/external.md + - reference/auth/ldap.md + - reference/auth/dovecot_sasl.md + - reference/auth/plain_separate.md + - reference/auth/netauth.md + - reference/config-syntax.md + - Integration with software: + - third-party/dovecot.md + - third-party/smtp-servers.md + - third-party/rspamd.md + - third-party/mailman3.md - Internals: + - internals/specifications.md + - internals/unicode.md - internals/quirks.md - internals/sqlite.md diff --git a/.version b/.version index 7d8568351..b0bb87854 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.5.4 +0.9.5 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ea3ec1f75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — Maddy Mail Server + +## Architecture + +Maddy is a composable all-in-one mail server (MTA/MX/IMAP) written in Go. The core abstraction is the **module system**: every functional component (auth, storage, checks, targets, endpoints) implements `module.Module` from `framework/module/module.go` and registers itself via `module.Register(name, factory)` in an `init()` function. + +- **`framework/`** — Stable, reusable packages (config parsing, module interfaces, address handling, error types, logging). Interfaces live here to avoid circular imports. +- **`internal/`** — All module implementations. Subdirectories map to module roles: `endpoint/` (protocol listeners), `target/` (delivery destinations), `auth/`, `check/` (message inspectors), `modify/` (header modifiers), `storage/`, `table/` (string→string lookups). +- **`maddy.go`** — Side-effect imports that pull all `internal/` modules into the binary, plus the `Run`/`moduleConfigure`/`RegisterModules` startup sequence. +- **`cmd/maddy/main.go`** — Thin entrypoint; imports root package for module registration, then calls `maddycli.Run()`. + +Modules are wired together at runtime via `maddy.conf` configuration. Top-level blocks are lazily initialized through `module.Registry`. The **message pipeline** (`internal/msgpipeline/`) routes messages from endpoints through checks, modifiers, and to delivery targets based on sender/recipient matching rules. + +## Build & Test + +```sh +# Build (produces ./build/maddy by default): +./build.sh build + +# Build with specific tags (e.g. for Docker): +./build.sh --tags "docker" build + +# Unit tests (standard Go): +go test ./... + +# Integration tests +cd tests && ./run.sh +``` + +The build embeds version via `-ldflags -X github.com/foxcpp/maddy.Version=...`. A C compiler is needed for SQLite support (`mattn/go-sqlite3`). + +## Adding a New Module + +1. Create a package under the appropriate `internal/` subdirectory (e.g. `internal/check/mycheck/`). +2. Implement `module.Module` plus the relevant role interface (`module.Check`, `module.DeliveryTarget`, `module.PlainAuth`, `module.Table`, etc.) from `framework/module/`. +3. Register in `init()`: `module.Register("check.mycheck", NewMyCheck)`. Use naming convention: `check.`, `target.`, `auth.`, `table.`, `modify.` prefixes. +4. Add a blank import `_ "github.com/foxcpp/maddy/internal/check/mycheck"` in `maddy.go`. +5. For checks: use the skeleton at `internal/check/skeleton.go` or `check.RegisterStatelessCheck` (see `internal/check/dns/` for a stateless example). + +## Error Handling + +Use `framework/exterrors` — not bare `fmt.Errorf`. Errors crossing module boundaries must carry: +- SMTP status info via `exterrors.SMTPError{Code, EnhancedCode, Message, CheckName/TargetName}` +- Temporary flag via `exterrors.WithTemporary` +- Module name field + +Keep SMTP error messages generic (no server config details). Use `exterrors.WithFields` for unexpected errors. See `HACKING.md` for full guidelines. + +## Key Conventions + +- **No shared state between messages** — check/modifier code runs in parallel across messages. +- **Panic recovery** — any goroutine you spawn must recover panics to avoid crashing the server. +- **Address normalization** — domain parts must be U-labels with NFC normalization and case-folding. Use `framework/address.CleanDomain`. +- **Configuration parsing** — modules receive config via `config.Map` in their `Configure` method. See `framework/config/` and existing modules for the pattern. +- **Logging** — use `framework/log.Logger`, not `log` stdlib. Per-delivery loggers via `target.DeliveryLogger(...)`. + diff --git a/Dockerfile b/Dockerfile index 3d9d19e53..2da6211f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,32 @@ -FROM golang:1.17-alpine AS build-env +FROM golang:1.23-alpine AS build-env -RUN set -ex ;\ - apk upgrade --no-cache --available ;\ - apk add --no-cache bash git build-base +ARG ADDITIONAL_BUILD_TAGS="" + +RUN set -ex && \ + apk upgrade --no-cache --available && \ + apk add --no-cache build-base WORKDIR /maddy -ADD go.mod go.sum ./ -ENV LDFLAGS -static + +COPY go.mod go.sum ./ RUN go mod download -ADD . ./ -RUN mkdir -p /pkg/data -COPY maddy.conf /pkg/data/maddy.conf -# Monkey-patch config to use environment. -RUN sed -Ei 's!\$\(hostname\) = .+!$(hostname) = {env:MADDY_HOSTNAME}!' /pkg/data/maddy.conf -RUN sed -Ei 's!\$\(primary_domain\) = .+!$(primary_domain) = {env:MADDY_DOMAIN}!' /pkg/data/maddy.conf -RUN sed -Ei 's!^tls .+!tls file /data/tls_cert.pem /data/tls_key.pem!' /pkg/data/maddy.conf -RUN ./build.sh --builddir /tmp --destdir /pkg/ --tags docker build install +COPY . ./ +RUN mkdir -p /pkg/data && \ + cp maddy.conf.docker /pkg/data/maddy.conf && \ + ./build.sh --builddir /tmp --destdir /pkg/ --tags "docker ${ADDITIONAL_BUILD_TAGS}" build install -FROM alpine:3.15.0 +FROM alpine:3.21.2 LABEL maintainer="fox.cpp@disroot.org" LABEL org.opencontainers.image.source=https://github.com/foxcpp/maddy -RUN set -ex ;\ - apk upgrade --no-cache --available ;\ +RUN set -ex && \ + apk upgrade --no-cache --available && \ apk --no-cache add ca-certificates COPY --from=build-env /pkg/data/maddy.conf /data/maddy.conf -COPY --from=build-env /pkg/usr/local/bin/maddy /pkg/usr/local/bin/maddyctl /bin/ +COPY --from=build-env /pkg/usr/local/bin/maddy /bin/ EXPOSE 25 143 993 587 465 VOLUME ["/data"] ENTRYPOINT ["/bin/maddy", "-config", "/data/maddy.conf"] +CMD ["run"] diff --git a/README.md b/README.md index cb1b4c33a..312fb8482 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ daemon with uniform configuration and minimal maintenance cost. feature-packed implementation you may want to use Dovecot instead. maddy still can handle message delivery business. -[![builds.sr.ht status](https://builds.sr.ht/~emersion/maddy.svg)](https://builds.sr.ht/~emersion/maddy?) -[![Issues tracker](https://img.shields.io/github/issues/foxcpp/maddy)](https://github.com/foxcpp/maddy) +[![CI status](https://img.shields.io/github/actions/workflow/status/foxcpp/maddy/cicd.yml?style=flat-square)](https://github.com/foxcpp/maddy/actions/workflows/cicd.yml) +[![Issues tracker](https://img.shields.io/github/issues/foxcpp/maddy?style=flat-square)](https://github.com/foxcpp/maddy) * [Setup tutorial](https://maddy.email/tutorials/setting-up/) * [Documentation](https://maddy.email/) diff --git a/build.sh b/build.sh index 9c4e8cc41..419dc1f63 100755 --- a/build.sh +++ b/build.sh @@ -76,6 +76,7 @@ while :; do shift done +configdir="${destdir}etc/maddy" if [ "$version" = "" ]; then version=unknown @@ -104,9 +105,6 @@ build_man_pages() { for f in ./docs/man/*.1.scd; do scdoc < "$f"> "${builddir}/man/$(basename "$f" .scd)" done - for f in ./docs/man/*.5.scd; do - scdoc < "$f"> "${builddir}/man/$(basename "$f" .scd)" - done } build() { @@ -123,15 +121,9 @@ build() { go build -trimpath -buildmode pie -tags "$tags osusergo netgo static_build" \ -ldflags "-extldflags '-fno-PIC -static' -X \"github.com/foxcpp/maddy.Version=${version}\"" \ -o "${builddir}/maddy" ${GOFLAGS} ./cmd/maddy - echo "-- Building management utility (maddyctl)...">&2 - go build -trimpath -buildmode pie -tags "$tags osusergo netgo static_build" \ - -ldflags "-extldflags '-fno-PIC -static' -X \"github.com/foxcpp/maddy.Version=${version}\"" \ - -o "${builddir}/maddyctl" ${GOFLAGS} ./cmd/maddyctl else echo "-- Building main server executable...">&2 go build -tags "$tags" -trimpath -ldflags="-X \"github.com/foxcpp/maddy.Version=${version}\"" -o "${builddir}/maddy" ${GOFLAGS} ./cmd/maddy - echo "-- Building management utility (maddyctl)...">&2 - go build -tags "$tags" -trimpath -ldflags="-X \"github.com/foxcpp/maddy.Version=${version}\"" -o "${builddir}/maddyctl" ${GOFLAGS} ./cmd/maddyctl fi build_man_pages @@ -147,16 +139,39 @@ install() { echo "-- Installing built files...">&2 command install -m 0755 -d "${destdir}/${prefix}/bin/" - command install -m 0755 "${builddir}/maddy" "${builddir}/maddyctl" "${destdir}/${prefix}/bin/" - command install -m 0755 -d "${destdir}/etc/maddy/" - command install -m 0644 ./maddy.conf "${destdir}/etc/maddy/maddy.conf" + command install -m 0755 "${builddir}/maddy" "${destdir}/${prefix}/bin/" + command ln -sf maddy "${destdir}/${prefix}/bin/maddyctl" + command install -m 0755 -d "${configdir}" + + + # We do not want to overwrite existing configuration. + # If the file exists, then save it with .default suffix and warn user. + if [ ! -e "${configdir}/maddy.conf" ]; then + command install -m 0644 ./maddy.conf "${configdir}/maddy.conf" + else + echo "-- [!] Configuration file ${configdir}/maddy.conf exists, saving to ${configdir}/maddy.conf.default">&2 + command install -m 0644 ./maddy.conf "${configdir}/maddy.conf.default" + fi # Attempt to install systemd units only for Linux. # Check is done using GOOS instead of uname -s to account for possible # package cross-compilation. - if [ "$(go env GOOS)" = "linux" ]; then - command install -m 0755 -d "${destdir}/${prefix}/lib/systemd/system/" - command install -m 0644 "${builddir}"/systemd/*.service "${destdir}/${prefix}/lib/systemd/system/" + # Though go command might be unavailable if build.sh is run + # with sudo and go installation is user-specific, so fallback + # to using uname -s in the end. + set +e + if command -v go>/dev/null 2>/dev/null; then + set -e + if [ "$(go env GOOS)" = "linux" ]; then + command install -m 0755 -d "${destdir}/${prefix}/lib/systemd/system/" + command install -m 0644 "${builddir}"/systemd/*.service "${destdir}/${prefix}/lib/systemd/system/" + fi + else + set -e + if [ "$(uname -s)" = "Linux" ]; then + command install -m 0755 -d "${destdir}/${prefix}/lib/systemd/system/" + command install -m 0644 "${builddir}"/systemd/*.service "${destdir}/${prefix}/lib/systemd/system/" + fi fi if [ -e "${builddir}"/man ]; then @@ -164,10 +179,6 @@ install() { for f in "${builddir}"/man/*.1; do command install -m 0644 "$f" "${destdir}/${prefix}/share/man/man1/" done - command install -m 0755 -d "${destdir}/${prefix}/share/man/man5/" - for f in "${builddir}"/man/*.5; do - command install -m 0644 "$f" "${destdir}/${prefix}/share/man/man5/" - done fi } diff --git a/cmd/README.md b/cmd/README.md index cff7ad64f..3132fea0a 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -5,14 +5,7 @@ maddy executables Main server executable. -### maddyctl - -IMAP index and authentication database inspection and manipulation utility. - ### maddy-pam-helper, maddy-shadow-helper -__Deprecated: Currently they are unusable due to changes made to the storage -implementation.__ - Utilities compatible with the auth.external module that call libpam or read /etc/shadow on Unix systems. diff --git a/cmd/maddy/main.go b/cmd/maddy/main.go index 2d8047e17..1007417e1 100644 --- a/cmd/maddy/main.go +++ b/cmd/maddy/main.go @@ -19,11 +19,11 @@ along with this program. If not, see . package main import ( - "os" - - "github.com/foxcpp/maddy" + _ "github.com/foxcpp/maddy" + maddycli "github.com/foxcpp/maddy/internal/cli" + _ "github.com/foxcpp/maddy/internal/cli/ctl" ) func main() { - os.Exit(maddy.Run()) + maddycli.Run() } diff --git a/cmd/maddyctl/imap.go b/cmd/maddyctl/imap.go deleted file mode 100644 index 02fe06710..000000000 --- a/cmd/maddyctl/imap.go +++ /dev/null @@ -1,511 +0,0 @@ -/* -Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -package main - -import ( - "bytes" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/emersion/go-imap" - imapsql "github.com/foxcpp/go-imap-sql" - "github.com/foxcpp/maddy/cmd/maddyctl/clitools" - "github.com/foxcpp/maddy/framework/module" - "github.com/urfave/cli" -) - -func FormatAddress(addr *imap.Address) string { - return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName) -} - -func FormatAddressList(addrs []*imap.Address) string { - res := make([]string, 0, len(addrs)) - for _, addr := range addrs { - res = append(res, FormatAddress(addr)) - } - return strings.Join(res, ", ") -} - -func mboxesList(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mboxes, err := u.ListMailboxes(ctx.Bool("subscribed,s")) - if err != nil { - return err - } - - if len(mboxes) == 0 && !ctx.GlobalBool("quiet") { - fmt.Fprintln(os.Stderr, "No mailboxes.") - } - - for _, mbox := range mboxes { - info, err := mbox.Info() - if err != nil { - return err - } - - if len(info.Attributes) != 0 { - fmt.Print(info.Name, "\t", info.Attributes, "\n") - } else { - fmt.Println(info.Name) - } - } - - return nil -} - -func mboxesCreate(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - name := ctx.Args().Get(1) - if name == "" { - return errors.New("Error: NAME is required") - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - if ctx.IsSet("special") { - attr := "\\" + strings.Title(ctx.String("special")) - - suu, ok := u.(SpecialUseUser) - if !ok { - return errors.New("Error: storage backend does not support SPECIAL-USE IMAP extension") - } - - return suu.CreateMailboxSpecial(name, attr) - } - - return u.CreateMailbox(name) -} - -func mboxesRemove(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - name := ctx.Args().Get(1) - if name == "" { - return errors.New("Error: NAME is required") - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(name) - if err != nil { - return err - } - - if !ctx.Bool("yes,y") { - status, err := mbox.Status([]imap.StatusItem{imap.StatusMessages}) - if err != nil { - return err - } - - if status.Messages != 0 { - fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages) - } - - if !clitools.Confirmation("Are you sure you want to delete that mailbox?", false) { - return errors.New("Cancelled") - } - } - - return u.DeleteMailbox(name) -} - -func mboxesRename(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - oldName := ctx.Args().Get(1) - if oldName == "" { - return errors.New("Error: OLDNAME is required") - } - newName := ctx.Args().Get(2) - if newName == "" { - return errors.New("Error: NEWNAME is required") - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - return u.RenameMailbox(oldName, newName) -} - -func msgsAdd(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - name := ctx.Args().Get(1) - if name == "" { - return errors.New("Error: MAILBOX is required") - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(name) - if err != nil { - return err - } - - flags := ctx.StringSlice("flag") - if flags == nil { - flags = []string{} - } - - date := time.Now() - if ctx.IsSet("date") { - date = time.Unix(ctx.Int64("date"), 0) - } - - buf := bytes.Buffer{} - if _, err := io.Copy(&buf, os.Stdin); err != nil { - return err - } - - if buf.Len() == 0 { - return errors.New("Error: Empty message, refusing to continue") - } - - status, err := mbox.Status([]imap.StatusItem{imap.StatusUidNext}) - if err != nil { - return err - } - - if err := mbox.CreateMessage(flags, date, &buf); err != nil { - return err - } - - fmt.Println(status.UidNext) - - return nil -} - -func msgsRemove(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - name := ctx.Args().Get(1) - if name == "" { - return errors.New("Error: MAILBOX is required") - } - seqset := ctx.Args().Get(2) - if seqset == "" { - return errors.New("Error: SEQSET is required") - } - - seq, err := imap.ParseSeqSet(seqset) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(name) - if err != nil { - return err - } - - if !ctx.Bool("yes") { - if !clitools.Confirmation("Are you sure you want to delete these messages?", false) { - return errors.New("Cancelled") - } - } - - mboxB := mbox.(*imapsql.Mailbox) - return mboxB.DelMessages(ctx.Bool("uid"), seq) -} - -func msgsCopy(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - srcName := ctx.Args().Get(1) - if srcName == "" { - return errors.New("Error: SRCMAILBOX is required") - } - seqset := ctx.Args().Get(2) - if seqset == "" { - return errors.New("Error: SEQSET is required") - } - tgtName := ctx.Args().Get(3) - if tgtName == "" { - return errors.New("Error: TGTMAILBOX is required") - } - - seq, err := imap.ParseSeqSet(seqset) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - srcMbox, err := u.GetMailbox(srcName) - if err != nil { - return err - } - - return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName) -} - -func msgsMove(be module.Storage, ctx *cli.Context) error { - if ctx.Bool("y,yes") || !clitools.Confirmation("Currently, it is unsafe to remove messages from mailboxes used by connected clients, continue?", false) { - return errors.New("Cancelled") - } - - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - srcName := ctx.Args().Get(1) - if srcName == "" { - return errors.New("Error: SRCMAILBOX is required") - } - seqset := ctx.Args().Get(2) - if seqset == "" { - return errors.New("Error: SEQSET is required") - } - tgtName := ctx.Args().Get(3) - if tgtName == "" { - return errors.New("Error: TGTMAILBOX is required") - } - - seq, err := imap.ParseSeqSet(seqset) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - srcMbox, err := u.GetMailbox(srcName) - if err != nil { - return err - } - - moveMbox := srcMbox.(*imapsql.Mailbox) - - return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName) -} - -func msgsList(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - mboxName := ctx.Args().Get(1) - if mboxName == "" { - return errors.New("Error: MAILBOX is required") - } - seqset := ctx.Args().Get(2) - if seqset == "" { - seqset = "1:*" - } - - seq, err := imap.ParseSeqSet(seqset) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(mboxName) - if err != nil { - return err - } - - ch := make(chan *imap.Message, 10) - go func() { - err = mbox.ListMessages(ctx.Bool("uid"), seq, []imap.FetchItem{imap.FetchEnvelope, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchFlags, imap.FetchUid}, ch) - }() - - for msg := range ch { - if !ctx.Bool("full") { - fmt.Printf("UID %d: %s - %s\n %v, %v\n\n", msg.Uid, FormatAddressList(msg.Envelope.From), msg.Envelope.Subject, msg.Flags, msg.Envelope.Date) - continue - } - - fmt.Println("- Server meta-data:") - fmt.Println("UID:", msg.Uid) - fmt.Println("Sequence number:", msg.SeqNum) - fmt.Println("Flags:", msg.Flags) - fmt.Println("Body size:", msg.Size) - fmt.Println("Internal date:", msg.InternalDate.Unix(), msg.InternalDate) - fmt.Println("- Envelope:") - if len(msg.Envelope.From) != 0 { - fmt.Println("From:", FormatAddressList(msg.Envelope.From)) - } - if len(msg.Envelope.To) != 0 { - fmt.Println("To:", FormatAddressList(msg.Envelope.To)) - } - if len(msg.Envelope.Cc) != 0 { - fmt.Println("CC:", FormatAddressList(msg.Envelope.Cc)) - } - if len(msg.Envelope.Bcc) != 0 { - fmt.Println("BCC:", FormatAddressList(msg.Envelope.Bcc)) - } - if msg.Envelope.InReplyTo != "" { - fmt.Println("In-Reply-To:", msg.Envelope.InReplyTo) - } - if msg.Envelope.MessageId != "" { - fmt.Println("Message-Id:", msg.Envelope.MessageId) - } - if !msg.Envelope.Date.IsZero() { - fmt.Println("Date:", msg.Envelope.Date.Unix(), msg.Envelope.Date) - } - if msg.Envelope.Subject != "" { - fmt.Println("Subject:", msg.Envelope.Subject) - } - fmt.Println() - } - return err -} - -func msgsDump(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - mboxName := ctx.Args().Get(1) - if mboxName == "" { - return errors.New("Error: MAILBOX is required") - } - seqset := ctx.Args().Get(2) - if seqset == "" { - seqset = "*" - } - - seq, err := imap.ParseSeqSet(seqset) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(mboxName) - if err != nil { - return err - } - - ch := make(chan *imap.Message, 10) - go func() { - err = mbox.ListMessages(ctx.Bool("uid"), seq, []imap.FetchItem{imap.FetchRFC822}, ch) - }() - - for msg := range ch { - for _, v := range msg.Body { - if _, err := io.Copy(os.Stdout, v); err != nil { - return err - } - } - } - return err -} - -func msgsFlags(be module.Storage, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - name := ctx.Args().Get(1) - if name == "" { - return errors.New("Error: MAILBOX is required") - } - seqStr := ctx.Args().Get(2) - if seqStr == "" { - return errors.New("Error: SEQ is required") - } - - seq, err := imap.ParseSeqSet(seqStr) - if err != nil { - return err - } - - u, err := be.GetIMAPAcct(username) - if err != nil { - return err - } - - mbox, err := u.GetMailbox(name) - if err != nil { - return err - } - - flags := ctx.Args()[3:] - if len(flags) == 0 { - return errors.New("Error: at least once FLAG is required") - } - - var op imap.FlagsOp - switch ctx.Command.Name { - case "add-flags": - op = imap.AddFlags - case "rem-flags": - op = imap.RemoveFlags - case "set-flags": - op = imap.SetFlags - default: - panic("unknown command: " + ctx.Command.Name) - } - - return mbox.UpdateMessagesFlags(ctx.IsSet("uid"), seq, op, flags) -} diff --git a/cmd/maddyctl/imapacct.go b/cmd/maddyctl/imapacct.go deleted file mode 100644 index 69ca27f55..000000000 --- a/cmd/maddyctl/imapacct.go +++ /dev/null @@ -1,136 +0,0 @@ -/* -Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -package main - -import ( - "errors" - "fmt" - "os" - - specialuse "github.com/emersion/go-imap-specialuse" - "github.com/foxcpp/maddy/cmd/maddyctl/clitools" - "github.com/foxcpp/maddy/framework/module" - "github.com/urfave/cli" -) - -type SpecialUseUser interface { - CreateMailboxSpecial(name, specialUseAttr string) error -} - -func imapAcctList(be module.Storage, ctx *cli.Context) error { - mbe, ok := be.(module.ManageableStorage) - if !ok { - return errors.New("Error: storage backend does not support accounts management using maddyctl") - } - - list, err := mbe.ListIMAPAccts() - if err != nil { - return err - } - - if len(list) == 0 && !ctx.GlobalBool("quiet") { - fmt.Fprintln(os.Stderr, "No users.") - } - - for _, user := range list { - fmt.Println(user) - } - return nil -} - -func imapAcctCreate(be module.Storage, ctx *cli.Context) error { - mbe, ok := be.(module.ManageableStorage) - if !ok { - return errors.New("Error: storage backend does not support accounts management using maddyctl") - } - - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - if err := mbe.CreateIMAPAcct(username); err != nil { - return err - } - - act, err := mbe.GetIMAPAcct(username) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) - } - - suu, ok := act.(SpecialUseUser) - if !ok { - fmt.Fprintf(os.Stderr, "Note: Storage backend does not support SPECIAL-USE IMAP extension") - } - - createMbox := func(name, specialUseAttr string) error { - if suu == nil { - return act.CreateMailbox(name) - } - return suu.CreateMailboxSpecial(name, specialUseAttr) - } - - if name := ctx.String("sent-name"); name != "" { - if err := createMbox(name, specialuse.Sent); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create sent folder: %v", err) - } - } - if name := ctx.String("trash-name"); name != "" { - if err := createMbox(name, specialuse.Trash); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create trash folder: %v", err) - } - } - if name := ctx.String("junk-name"); name != "" { - if err := createMbox(name, specialuse.Junk); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create junk folder: %v", err) - } - } - if name := ctx.String("drafts-name"); name != "" { - if err := createMbox(name, specialuse.Drafts); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create drafts folder: %v", err) - } - } - if name := ctx.String("archive-name"); name != "" { - if err := createMbox(name, specialuse.Archive); err != nil { - fmt.Fprintf(os.Stderr, "Failed to create archive folder: %v", err) - } - } - - return nil -} - -func imapAcctRemove(be module.Storage, ctx *cli.Context) error { - mbe, ok := be.(module.ManageableStorage) - if !ok { - return errors.New("Error: storage backend does not support accounts management using maddyctl") - } - - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - if !ctx.Bool("yes") { - if !clitools.Confirmation("Are you sure you want to delete this user account?", false) { - return errors.New("Cancelled") - } - } - - return mbe.DeleteIMAPAcct(username) -} diff --git a/cmd/maddyctl/main.go b/cmd/maddyctl/main.go deleted file mode 100644 index 026af1d45..000000000 --- a/cmd/maddyctl/main.go +++ /dev/null @@ -1,792 +0,0 @@ -/* -Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -package main - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/foxcpp/maddy" - parser "github.com/foxcpp/maddy/framework/cfgparser" - "github.com/foxcpp/maddy/framework/config" - "github.com/foxcpp/maddy/framework/hooks" - "github.com/foxcpp/maddy/framework/module" - "github.com/foxcpp/maddy/internal/updatepipe" - "github.com/urfave/cli" - "golang.org/x/crypto/bcrypt" -) - -func closeIfNeeded(i interface{}) { - if c, ok := i.(io.Closer); ok { - c.Close() - } -} - -func main() { - app := cli.NewApp() - app.Name = "maddyctl" - app.Usage = "maddy mail server administration utility" - app.Version = maddy.BuildInfo() - - app.Flags = []cli.Flag{ - cli.StringFlag{ - Name: "config", - Usage: "Configuration file to use", - EnvVar: "MADDY_CONFIG", - Value: filepath.Join(maddy.ConfigDirectory, "maddy.conf"), - }, - } - - app.Commands = []cli.Command{ - { - Name: "creds", - Usage: "Local credentials management", - Subcommands: []cli.Command{ - { - Name: "list", - Usage: "List created credentials", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_authdb", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openUserDB(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return usersList(be, ctx) - }, - }, - { - Name: "create", - Usage: "Create user account", - Description: "Reads password from stdin", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_authdb", - }, - cli.StringFlag{ - Name: "password,p", - Usage: "Use `PASSWORD instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", - }, - cli.BoolFlag{ - Name: "null,n", - Usage: "Create account with null password", - }, - cli.StringFlag{ - Name: "hash", - Usage: "Use specified hash algorithm. Valid values: sha3-512, bcrypt", - Value: "bcrypt", - }, - cli.IntFlag{ - Name: "bcrypt-cost", - Usage: "Specify bcrypt cost value", - Value: bcrypt.DefaultCost, - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openUserDB(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return usersCreate(be, ctx) - }, - }, - { - Name: "remove", - Usage: "Delete user account", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_authdb", - }, - cli.BoolFlag{ - Name: "yes,y", - Usage: "Don't ask for confirmation", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openUserDB(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return usersRemove(be, ctx) - }, - }, - { - Name: "password", - Usage: "Change account password", - Description: "Reads password from stdin", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_authdb", - }, - cli.StringFlag{ - Name: "password,p", - Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openUserDB(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return usersPassword(be, ctx) - }, - }, - }, - }, - { - Name: "imap-acct", - Usage: "IMAP storage accounts management", - Subcommands: []cli.Command{ - { - Name: "list", - Usage: "List storage accounts", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return imapAcctList(be, ctx) - }, - }, - { - Name: "create", - Usage: "Create IMAP storage account", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.StringFlag{ - Name: "sent-name", - Usage: "Name of special mailbox for sent messages, use empty string to not create any", - Value: "Sent", - }, - cli.StringFlag{ - Name: "trash-name", - Usage: "Name of special mailbox for trash, use empty string to not create any", - Value: "Trash", - }, - cli.StringFlag{ - Name: "junk-name", - Usage: "Name of special mailbox for 'junk' (spam), use empty string to not create any", - Value: "Junk", - }, - cli.StringFlag{ - Name: "drafts-name", - Usage: "Name of special mailbox for drafts, use empty string to not create any", - Value: "Drafts", - }, - cli.StringFlag{ - Name: "archive-name", - Usage: "Name of special mailbox for archive, use empty string to not create any", - Value: "Archive", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return imapAcctCreate(be, ctx) - }, - }, - { - Name: "remove", - Usage: "Delete IMAP storage account", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "yes,y", - Usage: "Don't ask for confirmation", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return imapAcctRemove(be, ctx) - }, - }, - { - Name: "appendlimit", - Usage: "Query or set accounts's APPENDLIMIT value", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.IntFlag{ - Name: "value,v", - Usage: "Set APPENDLIMIT to specified value (in bytes)", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return imapAcctAppendlimit(be, ctx) - }, - }, - }, - }, - { - Name: "imap-mboxes", - Usage: "IMAP mailboxes (folders) management", - Subcommands: []cli.Command{ - { - Name: "list", - Usage: "Show mailboxes of user", - ArgsUsage: "USERNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "subscribed,s", - Usage: "List only subscribed mailboxes", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return mboxesList(be, ctx) - }, - }, - { - Name: "create", - Usage: "Create mailbox", - ArgsUsage: "USERNAME NAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.StringFlag{ - Name: "special", - Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return mboxesCreate(be, ctx) - }, - }, - { - Name: "remove", - Usage: "Remove mailbox", - Description: "WARNING: All contents of mailbox will be irrecoverably lost.", - ArgsUsage: "USERNAME MAILBOX", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "yes,y", - Usage: "Don't ask for confirmation", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return mboxesRemove(be, ctx) - }, - }, - { - Name: "rename", - Usage: "Rename mailbox", - Description: "Rename may cause unexpected failures on client-side so be careful.", - ArgsUsage: "USERNAME OLDNAME NEWNAME", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return mboxesRename(be, ctx) - }, - }, - }, - }, - { - Name: "imap-msgs", - Usage: "IMAP messages management", - Subcommands: []cli.Command{ - { - Name: "add", - Usage: "Add message to mailbox", - ArgsUsage: "USERNAME MAILBOX", - Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.StringSliceFlag{ - Name: "flag,f", - Usage: "Add flag to message. Can be specified multiple times", - }, - cli.Int64Flag{ - Name: "date,d", - Usage: "Set internal date value to specified UNIX timestamp", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsAdd(be, ctx) - }, - }, - { - Name: "add-flags", - Usage: "Add flags to messages", - ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", - Description: "Add flags to all messages matched by SEQ.", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsFlags(be, ctx) - }, - }, - { - Name: "rem-flags", - Usage: "Remove flags from messages", - ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", - Description: "Remove flags from all messages matched by SEQ.", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsFlags(be, ctx) - }, - }, - { - Name: "set-flags", - Usage: "Set flags on messages", - ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", - Description: "Set flags on all messages matched by SEQ.", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsFlags(be, ctx) - }, - }, - { - Name: "remove", - Usage: "Remove messages from mailbox", - ArgsUsage: "USERNAME MAILBOX SEQSET", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - cli.BoolFlag{ - Name: "yes,y", - Usage: "Don't ask for confirmation", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsRemove(be, ctx) - }, - }, - { - Name: "copy", - Usage: "Copy messages between mailboxes", - Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", - ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsCopy(be, ctx) - }, - }, - { - Name: "move", - Usage: "Move messages between mailboxes", - Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", - ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - cli.BoolFlag{ - Name: "yes,y", - Usage: "Don't ask for confirmation", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsMove(be, ctx) - }, - }, - { - Name: "list", - Usage: "List messages in mailbox", - Description: "If SEQSET is specified - only show messages that match it.", - ArgsUsage: "USERNAME MAILBOX [SEQSET]", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQSET instead of sequence numbers", - }, - cli.BoolFlag{ - Name: "full,f", - Usage: "Show entire envelope and all server meta-data", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsList(be, ctx) - }, - }, - { - Name: "dump", - Usage: "Dump message body", - Description: "If passed SEQ matches multiple messages - they will be joined.", - ArgsUsage: "USERNAME MAILBOX SEQ", - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "cfg-block", - Usage: "Module configuration block to use", - EnvVar: "MADDY_CFGBLOCK", - Value: "local_mailboxes", - }, - cli.BoolFlag{ - Name: "uid,u", - Usage: "Use UIDs for SEQ instead of sequence numbers", - }, - }, - Action: func(ctx *cli.Context) error { - be, err := openStorage(ctx) - if err != nil { - return err - } - defer closeIfNeeded(be) - return msgsDump(be, ctx) - }, - }, - }, - }, - { - Name: "hash", - Usage: "Generate password hashes for use with pass_table", - Action: hashCommand, - Flags: []cli.Flag{ - cli.StringFlag{ - Name: "password,p", - Usage: "Use `PASSWORD instead of reading password from stdin\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", - }, - cli.StringFlag{ - Name: "hash", - Usage: "Use specified hash algorithm", - Value: "bcrypt", - }, - cli.IntFlag{ - Name: "bcrypt-cost", - Usage: "Specify bcrypt cost value", - Value: bcrypt.DefaultCost, - }, - cli.IntFlag{ - Name: "argon2-time", - Usage: "Time factor for Argon2id", - Value: 3, - }, - cli.IntFlag{ - Name: "argon2-memory", - Usage: "Memory in KiB to use for Argon2id", - Value: 1024, - }, - cli.IntFlag{ - Name: "argon2-threads", - Usage: "Threads to use for Argon2id", - Value: 1, - }, - }, - }, - } - - if err := app.Run(os.Args); err != nil { - fmt.Fprintln(os.Stderr, err) - } -} - -func getCfgBlockModule(ctx *cli.Context) (map[string]interface{}, *maddy.ModInfo, error) { - cfgPath := ctx.GlobalString("config") - if cfgPath == "" { - return nil, nil, errors.New("Error: config is required") - } - cfgFile, err := os.Open(cfgPath) - if err != nil { - return nil, nil, fmt.Errorf("Error: failed to open config: %w", err) - } - defer cfgFile.Close() - cfgNodes, err := parser.Read(cfgFile, cfgFile.Name()) - if err != nil { - return nil, nil, fmt.Errorf("Error: failed to parse config: %w", err) - } - - globals, cfgNodes, err := maddy.ReadGlobals(cfgNodes) - if err != nil { - return nil, nil, err - } - - if err := maddy.InitDirs(); err != nil { - return nil, nil, err - } - - module.NoRun = true - _, mods, err := maddy.RegisterModules(globals, cfgNodes) - if err != nil { - return nil, nil, err - } - defer hooks.RunHooks(hooks.EventShutdown) - - cfgBlock := ctx.String("cfg-block") - if cfgBlock == "" { - return nil, nil, errors.New("Error: cfg-block is required") - } - var mod maddy.ModInfo - for _, m := range mods { - if m.Instance.InstanceName() == cfgBlock { - mod = m - break - } - } - if mod.Instance == nil { - return nil, nil, fmt.Errorf("Error: unknown configuration block: %s", cfgBlock) - } - - return globals, &mod, nil -} - -func openStorage(ctx *cli.Context) (module.Storage, error) { - globals, mod, err := getCfgBlockModule(ctx) - if err != nil { - return nil, err - } - - storage, ok := mod.Instance.(module.Storage) - if !ok { - return nil, fmt.Errorf("Error: configuration block %s is not an IMAP storage", ctx.String("cfg-block")) - } - - if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil { - return nil, fmt.Errorf("Error: module initialization failed: %w", err) - } - - if updStore, ok := mod.Instance.(updatepipe.Backend); ok { - if err := updStore.EnableUpdatePipe(updatepipe.ModePush); err != nil && !errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(os.Stderr, "Failed to initialize update pipe, do not remove messages from mailboxes open by clients: %v\n", err) - } - } else { - fmt.Fprintf(os.Stderr, "No update pipe support, do not remove messages from mailboxes open by clients\n") - } - - return storage, nil -} - -func openUserDB(ctx *cli.Context) (module.PlainUserDB, error) { - globals, mod, err := getCfgBlockModule(ctx) - if err != nil { - return nil, err - } - - userDB, ok := mod.Instance.(module.PlainUserDB) - if !ok { - return nil, fmt.Errorf("Error: configuration block %s is not a local credentials store", ctx.String("cfg-block")) - } - - if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil { - return nil, fmt.Errorf("Error: module initialization failed: %w", err) - } - - return userDB, nil -} diff --git a/cmd/maddyctl/users.go b/cmd/maddyctl/users.go deleted file mode 100644 index 6201f38ee..000000000 --- a/cmd/maddyctl/users.go +++ /dev/null @@ -1,100 +0,0 @@ -/* -Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -package main - -import ( - "errors" - "fmt" - "os" - - "github.com/foxcpp/maddy/cmd/maddyctl/clitools" - "github.com/foxcpp/maddy/framework/module" - "github.com/urfave/cli" -) - -func usersList(be module.PlainUserDB, ctx *cli.Context) error { - list, err := be.ListUsers() - if err != nil { - return err - } - - if len(list) == 0 && !ctx.GlobalBool("quiet") { - fmt.Fprintln(os.Stderr, "No users.") - } - - for _, user := range list { - fmt.Println(user) - } - return nil -} - -func usersCreate(be module.PlainUserDB, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - var pass string - if ctx.IsSet("password") { - pass = ctx.String("password") - } else { - var err error - pass, err = clitools.ReadPassword("Enter password for new user") - if err != nil { - return err - } - } - - return be.CreateUser(username, pass) -} - -func usersRemove(be module.PlainUserDB, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - if !ctx.Bool("yes") { - if !clitools.Confirmation("Are you sure you want to delete this user account?", false) { - return errors.New("Cancelled") - } - } - - return be.DeleteUser(username) -} - -func usersPassword(be module.PlainUserDB, ctx *cli.Context) error { - username := ctx.Args().First() - if username == "" { - return errors.New("Error: USERNAME is required") - } - - var pass string - if ctx.IsSet("password") { - pass = ctx.String("password") - } else { - var err error - pass, err = clitools.ReadPassword("Enter new password") - if err != nil { - return err - } - } - - return be.SetUserPassword(username, pass) -} diff --git a/config.go b/config.go index 2f57b4723..8b5044f2e 100644 --- a/config.go +++ b/config.go @@ -71,7 +71,7 @@ func LogOutputOption(args []string) (log.Output, error) { } return log.NopOutput{}, nil default: - // Log file paths are converted to absolute to make sure + // log file paths are converted to absolute to make sure // we will be able to recreate them in right location // after changing working directory to the state dir. absPath, err := filepath.Abs(arg) @@ -98,7 +98,7 @@ func LogOutputOption(args []string) (log.Output, error) { } func defaultLogOutput() (interface{}, error) { - return log.DefaultLogger.Out, nil + return nil, nil } func reinitLogging() { @@ -114,7 +114,9 @@ func reinitLogging() { return } - out.Close() + if err := out.Close(); err != nil { + log.Println("Can't close old logger:", err) + } log.DefaultLogger.Out = newOut } diff --git a/contrib/kubernetes/chart/README.md b/contrib/kubernetes/chart/README.md index 58d175fe9..14e2e74bc 100644 --- a/contrib/kubernetes/chart/README.md +++ b/contrib/kubernetes/chart/README.md @@ -9,7 +9,7 @@ load balancer in front of the nodes. ## Requirement -In order to run maddy properly, you need to have TLS secret undet name maddy present in the cluster. If you have commercial +In order to run maddy properly, you need to have TLS secret under name maddy present in the cluster. If you have commercial certificate, you can create it by the following command: ```sh @@ -20,9 +20,9 @@ If you use cert-manager, just create the secret under name maddy. ## Replication -Default for this chart is 1 replica of maddy. If you try to increse this, you will probably get an error because of +Default for this chart is 1 replica of maddy. If you try to increase this, you will probably get an error because of the busy ports 25, 143, 587, etc. We do not support this feature at the moment, so please use just 1 replica. Like said -at the begining of this document, multiple replicas would probably require to switch do DaemonSet which would further require +at the beginning of this document, multiple replicas would probably require to switch do DaemonSet which would further require to have TCP load balancer and shared storage between all replicas. This is not supported by this chart, sorry. This chart is used on one node cluster and then installation is straight forward, like described bellow, but if you have multiple node cluster, please use taints and tolerations to select the desired node. This chart supports tolerations to diff --git a/contrib/kubernetes/chart/files/maddy.conf b/contrib/kubernetes/chart/files/maddy.conf index e4adab84d..6e8b66c18 100644 --- a/contrib/kubernetes/chart/files/maddy.conf +++ b/contrib/kubernetes/chart/files/maddy.conf @@ -1,6 +1,6 @@ ## maddy 0.3 - default configuration file (2020年05月31日) # Suitable for small-scale deployments. Uses its own format for local users DB, -# should be managed via maddyctl utility. +# should be managed via maddy subcommands. # # See tutorials at https://foxcpp.dev/maddy for guidance on typical # configuration changes. @@ -28,7 +28,7 @@ tls file /etc/maddy/certs/fullchain.pem /etc/maddy/certs/privkey.pem # PAM, /etc/shadow file). # # If table module supports it (sql_table does) - credentials can be managed -# using 'maddyctl creds' command. +# using 'maddy creds' command. auth.pass_table local_authdb { table sql_table { @@ -43,7 +43,7 @@ auth.pass_table local_authdb { # also by SMTP & Submission endpoints for delivery of local messages. # # IMAP accounts, mailboxes and all message metadata can be inspected using -# imap-* subcommands of maddyctl utility. +# imap-* subcommands of maddy. storage.imapsql local_mailboxes { driver sqlite3 diff --git a/directories.go b/directories.go index 8cc64d1b9..b3930db67 100644 --- a/directories.go +++ b/directories.go @@ -1,4 +1,5 @@ -//+build !docker +//go:build !docker +// +build !docker package maddy diff --git a/directories_docker.go b/directories_docker.go index 16a9b372a..4869f600e 100644 --- a/directories_docker.go +++ b/directories_docker.go @@ -1,4 +1,5 @@ -//+build docker +//go:build docker +// +build docker package maddy diff --git a/dist/README.md b/dist/README.md index 60e1cff9f..d057f0ef4 100644 --- a/dist/README.md +++ b/dist/README.md @@ -22,7 +22,7 @@ Additionally, unit files apply strict sandboxing, limiting maddy permissions on the system to a bare minimum. Subset of these options makes it impossible for privileged authentication helper binaries to gain required permissions, so you may have to disable it when using system account-based authentication with -maddy running as a unprivilieged user. +maddy running as a unprivileged user. ## fail2ban configuration diff --git a/dist/apparmor/dev.foxcpp.maddyctl b/dist/apparmor/dev.foxcpp.maddyctl deleted file mode 100644 index 86cd08730..000000000 --- a/dist/apparmor/dev.foxcpp.maddyctl +++ /dev/null @@ -1,24 +0,0 @@ -# AppArmor profile for maddyctl management utility. -# vim:syntax=apparmor:ts=2:sw=2:et - -#include - -profile dev.foxcpp.maddyctl /usr{/local,}/bin/maddyctl { - #include - - /etc/resolv.conf r, - /proc/sys/net/core/somaxconn r, - /sys/kernel/mm/transparent_hugepage/hpage_pmd_size r, - deny ptrace, - network unix, - deny unix, - - /etc/maddy/** r, - owner /run/maddy/ rw, - owner /run/maddy/** rwkl, - owner /var/lib/maddy/ rw, - owner /var/lib/maddy/** rwk, - owner /var/lib/maddy/**.db-{wal,shm} rmk, - - #include if exists -} diff --git a/dist/fail2ban/jail.d/maddy-dictonary-attack.conf b/dist/fail2ban/jail.d/maddy-dictonary-attack.conf index c4f7ff3f3..ebeb33fa3 100644 --- a/dist/fail2ban/jail.d/maddy-dictonary-attack.conf +++ b/dist/fail2ban/jail.d/maddy-dictonary-attack.conf @@ -2,6 +2,6 @@ port = 993,465,25 filter = maddy-dictonary-attack bantime = 72h -maxtries = 3 +maxretry = 3 findtime = 6h backend = systemd diff --git a/dist/systemd/maddy.service b/dist/systemd/maddy.service index 2377a9e46..182365681 100644 --- a/dist/systemd/maddy.service +++ b/dist/systemd/maddy.service @@ -3,7 +3,7 @@ Description=maddy mail server Documentation=man:maddy(1) Documentation=man:maddy.conf(5) Documentation=https://maddy.email -After=network.target +After=network-online.target [Service] Type=notify @@ -54,8 +54,9 @@ KillSignal=SIGTERM AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE -# Force all files created by maddy to be only readable by it. -UMask=0027 +# Force all files created by maddy to be only readable by it +# and maddy group. +UMask=0007 # Bump FD limitations. Even idle mail server can have a lot of FDs open (think # of idle IMAP connections, especially ones abandoned on the other end and @@ -72,9 +73,8 @@ Restart=on-failure # ... Unless it is a configuration problem. RestartPreventExitStatus=2 -ExecStart=/usr/local/bin/maddy +ExecStart=/usr/local/bin/maddy run -ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/bin/kill -USR2 $MAINPID [Install] diff --git a/dist/systemd/maddy@.service b/dist/systemd/maddy@.service index b056b6d33..4ba3b54d8 100644 --- a/dist/systemd/maddy@.service +++ b/dist/systemd/maddy@.service @@ -3,7 +3,7 @@ Description=maddy mail server (using %i.conf) Documentation=man:maddy(1) Documentation=man:maddy.conf(5) Documentation=https://maddy.email -After=network.target +After=network-online.target [Service] Type=notify @@ -50,8 +50,9 @@ KillSignal=SIGTERM AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE -# Force all files created by maddy to be only readable by it. -UMask=0027 +# Force all files created by maddy to be only readable by it and +# maddy group. +UMask=0007 # Bump FD limitations. Even idle mail server can have a lot of FDs open (think # of idle IMAP connections, especially ones abandoned on the other end and @@ -68,9 +69,8 @@ Restart=on-failure # ... Unless it is a configuration problem. RestartPreventExitStatus=2 -ExecStart=/usr/local/bin/maddy -config /etc/maddy/%i.conf +ExecStart=/usr/local/bin/maddy --config /etc/maddy/%i.conf run -ExecReload=/bin/kill -USR1 $MAINPID ExecReload=/bin/kill -USR2 $MAINPID [Install] diff --git a/dist/vim/syntax/maddy-conf.vim b/dist/vim/syntax/maddy-conf.vim index d59e7990f..9e56cdd8b 100644 --- a/dist/vim/syntax/maddy-conf.vim +++ b/dist/vim/syntax/maddy-conf.vim @@ -183,6 +183,7 @@ syn keyword maddyModDir \ quarantine_threshold \ read_timeout \ reject_threshold + \ reject_action \ relaxed_requiretls \ required_fields \ require_sender_match @@ -198,6 +199,7 @@ syn keyword maddyModDir \ sig_expiry \ sign_fields \ sign_subdomains + \ soft_reject_action \ softfail_action \ SOME_action \ source diff --git a/docker-build-multiarch.sh b/docker-build-multiarch.sh deleted file mode 100755 index cd7bb60c5..000000000 --- a/docker-build-multiarch.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -set -eEuo pipefail - -AMD64_DOCKER_HOST=${AMD64_DOCKER_HOST:-"unix:///var/run/docker.sock"} -ARM_DOCKER_HOST=${ARM_DOCKER_HOST:-"tcp://raspberrypi.local:2375"} - -if [ ! -x ${HOME}/.docker/cli-plugins/docker-buildx ]; then - mkdir -p ${HOME}/.docker/cli-plugins/ - wget https://github.com/docker/buildx/releases/download/v0.7.0/buildx-v0.7.0.linux-amd64 -O ${HOME}/.docker/cli-plugins/docker-buildx - chmod +x ${HOME}/.docker/cli-plugins/docker-buildx -fi - -docker buildx version - -BUILDER="multiarch-builder" -CONFIG=${PWD}/multiarch/buildkitd.toml -docker buildx create --name ${BUILDER} --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' --config=${CONFIG} --driver=docker-container --driver-opt image=moby/buildkit:latest,network=host --platform=linux/amd64 --use ${AMD64_DOCKER_HOST} -docker buildx create --name ${BUILDER} --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' --config=${CONFIG} --driver=docker-container --driver-opt image=moby/buildkit:latest,network=host --platform=linux/arm64,linux/arm/v7,linux/arm/v6 --append ${ARM_DOCKER_HOST} -stopbuilders() { - set +x - echo stopping builders - docker buildx stop ${BUILDER} - docker buildx rm ${BUILDER} -} -trap stopbuilders INT TERM EXIT - -docker buildx inspect --bootstrap --builder=${BUILDER} - -PLATFORM="${PLATFORM:-"linux/amd64,linux/arm/v7,linux/arm64"}" - -docker --log-level=debug \ - buildx build ${PWD} \ - --builder=${BUILDER} \ - --allow security.insecure \ - --platform=${PLATFORM} \ - $@ \ No newline at end of file diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..d2bc34688 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,81 @@ +# Docker + +Official Docker image is available from Docker Hub. + +It expects configuration file to be available at /data/maddy.conf. + +If /data is a Docker volume, then default configuration will be placed there +automatically. If it is used, then MADDY_HOSTNAME, MADDY_DOMAIN environment +variables control the host name and primary domain for the server. TLS +certificate should be placed in /data/tls/fullchain.pem, private key in +/data/tls/privkey.pem + +DKIM keys are generated in /data/dkim_keys directory. + +## Image tags + +- `latest` - A latest stable release. May contain breaking changes. +- `X.Y` - A specific feature branch, it is recommended to use these tags to + receive bugfixes without the risk of feature-related regressions or breaking + changes. +- `X.Y.Z` - A specific stable release + +## Ports + +All standard ports, as described in maddy docs. + +- `25` - SMTP inbound port. +- `465`, `587` - SMTP Submission ports +- `993`, `143` - IMAP4 ports + +## Volumes + +`/data` - maddy state directory. Databases, queues, etc are stored here. You +might want to mount a named volume there. The main configuration file is stored +here too (`/data/maddy.conf`). + +## Management utility + +To run management commands, create a temporary container with the same +/data directory and put the command after the image name, like this: + +``` +docker run --rm -it -v maddydata:/data foxcpp/maddy:0.7 creds create foxcpp@maddy.test +docker run --rm -it -v maddydata:/data foxcpp/maddy:0.7 imap-acct create foxcpp@maddy.test +``` + +Use the same image version as the running server. Things may break badly +otherwise. + +Note that, if you modify messages using maddy subcommands while the server is running - +you must ensure that /tmp from the server is accessible for the management +command. One way to it is to run it using `docker exec` instead of `docker run`: +``` +docker exec -it container_name_here maddy creds create foxcpp@maddy.test +``` + +## Build Tags + +Some Maddy features (such as automatic certificate management via ACME with [a non-default libdns provider](../reference/tls-acme/#dns-providers)) require build tags to be passed to Maddy's `build.sh`, as this is run in the Dockerfile you must compile your own Docker image. Build tags can be set via the docker build argument `ADDITIONAL_BUILD_TAGS` e.g. `docker build --build-arg ADDITIONAL_BUILD_TAGS="libdns_acmedns libdns_route53" -t yourorgname/maddy:yourtagname .`. + + +## TL;DR + +``` +docker volume create maddydata +docker run \ + --name maddy \ + -e MADDY_HOSTNAME=mx.maddy.test \ + -e MADDY_DOMAIN=maddy.test \ + -v maddydata:/data \ + -p 25:25 \ + -p 143:143 \ + -p 465:465 \ + -p 587:587 \ + -p 993:993 \ + foxcpp/maddy:0.7 +``` + +It will fail on first startup. Copy TLS certificate to /data/tls/fullchain.pem +and key to /data/tls/privkey.pem. Run the server again. Finish DNS configuration +(DKIM keys, etc) as described in [tutorials/setting-up/](../tutorials/setting-up/). diff --git a/docs/faq.md b/docs/faq.md index 0aacdc369..b9d755c24 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,16 +1,42 @@ # Frequently Asked Questions -## Why? +## I configured maddy as recommended and gmail still puts my messages in spam -For fun. Turned out to be a rather convenient approach to -self-hosted email. +Unfortunately, GMail policies are opaque so we cannot tell why this happens. -## Is it caddy for email? +Verify that you have a rDNS record set for the IP used +by sender server. Also some IPs may just happen to +have bad reputation - check it with various DNSBLs. In this +case you do not have much of a choice but to replace it. -No. It was intended to be one but developers quickly acknowledged -the fact email cannot be easily abstracted behind some magic. +Additionally, you may try marking multiple messages sent from +your domain as "not spam" in GMail UI. -## How it compares to MailCow or Mail-In-The-Box? +## Message sending fails with `dial tcp X.X.X.X:25: connect: connection timed out` in log + +Your provider is blocking outbound SMTP traffic on port 25. + +You either have to ask them to unblock it or forward +all outbound messages via a "smart-host". + +## What is resource usage of maddy? + +For a small personal server, you do not need much more than a +single 1 GiB of RAM and disk space. + +## How to setup a catchall address? + +https://github.com/foxcpp/maddy/issues/243#issuecomment-655694512 + +## maddy command prints a "permission denied" error + +Run maddy command under the same user as maddy itself. +E.g. +``` +sudo -u maddy maddy creds ... +``` + +## How maddy compares to MailCow or Mail-In-The-Box? MailCow and MIAB are bundles of well-known email-related software configured to work together. maddy is a single piece of software implementing subset of what @@ -62,13 +88,6 @@ ZoneMTA has a number of features that may make it easier to integrate with HTTP-based services. maddy speaks standard email protocols (SMTP, Submission). -## What is the scope of project? - -1. Implement a usable SMTP + Submission server that can both accept - and send email as secure as possible with todays state of - relevant protocols. -2. Implement a meaningful subset of IMAP for access to local storage. - ## Is there a webmail? No, at least currently. @@ -98,37 +117,3 @@ of bugs in one component. Besides, you are not required to use a single process, it is easy to launch maddy with a non-default configuration path and connect multiple instances together using off-the-shelf protocols. - -## Can I do X with maddy? - -Ask on #maddy. - -maddy is less feature-packed than other SMTP/IMAP server -implementations but it is not completely useless for anything other than -its default configuration. - -## Can you implement X? - -"Umbrella" projects like maddy are susceptible to scope -creep unless maintainers apply a lot of skepticism to proposed -features. - -If X is essential for providing email security or extends the space of useful -configurations significantly and does not require major design changes - -we can talk, go to #maddy. Otherwise the likely answer is no. - -## Are you breaking things between releases? - -maddy releases follow Semantic Versioning 2.0.0 specification. -It is expected that 0.X releases may not be compatible with each -other. I attempt to minimize such breakage unless there is a significant -benefit. - -## 1.0 when? - -When no more backward-incompatible changes will be needed. maddy releases follow -Semantic Versioning 2.0.0 specification. - -## maddy is bad name, it is almost impossible to Google! - -Call it Maddy Mail Server. diff --git a/docs/index.md b/docs/index.md index 901f7230f..a9b7ee681 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,10 +13,11 @@ daemon with uniform configuration and minimal maintenance cost. feature-packed implementation you may want to use Dovecot instead. maddy still can handle message delivery business. -[![builds.sr.ht status](https://builds.sr.ht/~emersion/maddy.svg)](https://builds.sr.ht/~emersion/maddy?) -[![License text](https://img.shields.io/github/license/foxcpp/maddy)](https://github.com/foxcpp/maddy/blob/master/LICENSE) -[![Issues tracker](https://img.shields.io/github/issues/foxcpp/maddy)](https://github.com/foxcpp/maddy) +[![CI status](https://img.shields.io/github/actions/workflow/status/foxcpp/maddy/cicd.yml?style=flat-square)](https://github.com/foxcpp/maddy/actions/workflows/cicd.yml) +[![Issues tracker](https://img.shields.io/github/issues/foxcpp/maddy?style=flat-square)](https://github.com/foxcpp/maddy) * [Setup tutorial](https://maddy.email/tutorials/setting-up/) +* [Documentation](https://maddy.email/) + * [IRC channel](https://webchat.oftc.net/?channels=maddy&uio=MT11bmRlZmluZWQb1) * [Mailing list](https://lists.sr.ht/~foxcpp/maddy) diff --git a/docs/internals/quirks.md b/docs/internals/quirks.md index d4f00195a..bc343cc60 100644 --- a/docs/internals/quirks.md +++ b/docs/internals/quirks.md @@ -14,17 +14,10 @@ interoperability. ### `sql` -- `\Recent` flag is not implemented and it always set. +- `\Recent` flag is not reset in all cases. This _does not_ break [RFC 3501]. Clients relying on it will work (much) less efficiently. -- Sequence numbers don't stay consistent between SELECT/CHECK commands. - - This _does not_ break [RFC 3501] which is unclear about synchronization - issues, however it deviates from behavior implemented by most servers. This - can lead to operations applied to the wrong messages if sequence numbers are - used by multiple clients connected at the same time. - [RFC 2821]: https://tools.ietf.org/html/rfc2821 [RFC 3501]: https://tools.ietf.org/html/rfc3501 diff --git a/docs/specifications.md b/docs/internals/specifications.md similarity index 98% rename from docs/specifications.md rename to docs/internals/specifications.md index c27217d87..c042cabae 100644 --- a/docs/specifications.md +++ b/docs/internals/specifications.md @@ -20,9 +20,7 @@ maddy along with any known deviations. ## IMAP - [RFC 3501] - Internet Message Access Protocol - Version 4rev1 - * **Broken**: Unilateral updates are sent immedately, sequence numbers are not frozen. - [GH 188] - * **Partial**: `\Recent` flag is not implemented. + * **Partial**: `\Recent` flag is not reset sometimes. - [RFC 2152] - UTF-7 ### Extensions diff --git a/docs/unicode.md b/docs/internals/unicode.md similarity index 96% rename from docs/unicode.md rename to docs/internals/unicode.md index 1a7e2a205..91997624e 100644 --- a/docs/unicode.md +++ b/docs/internals/unicode.md @@ -1,7 +1,7 @@ # Unicode support maddy has the first-class Unicode support in all components (modules). You do -have to take any actions to make it work with internationalized domains, +not have to take any actions to make it work with internationalized domains, mailbox names or non-ASCII message headers. Internally, all text fields in maddy are represented in UTF-8 and handled using @@ -93,4 +93,4 @@ mentioned above). Clients that want to implement proper handling for Unicode strings may assume maddy does not handle them properly in e.g. SEARCH commands and so such clients -may download messsages and process them locally. +may download messages and process them locally. diff --git a/docs/man/maddy-auth.5.scd b/docs/man/maddy-auth.5.scd deleted file mode 100644 index a8473a8c2..000000000 --- a/docs/man/maddy-auth.5.scd +++ /dev/null @@ -1,373 +0,0 @@ -maddy-auth(5) "maddy mail server" "maddy authentication backends" - -; TITLE Authentication backends - -# Introduction - -Modules described in this man page can be used to provide functionality to -check validity of username-password pairs in accordance with some database. -That is, they authenticate users. - -Most likely, you are going to use these modules with 'auth' directive of IMAP -(*maddy-imap*(5)) or SMTP endpoint (*maddy-smtp*(5)). - -Most modules listed here are also usable as a table (see *maddy-tables*(5)) -that contains all usernames known to the module. Exceptions are auth.external and -pam as underlying interfaces do not define a way to check credentials -existence. - -# External authentication module (auth.external) - -Module for authentication using external helper binary. It looks for binary -named maddy-auth-helper in $PATH and libexecdir and uses it for authentication -using username/password pair. - -The protocol is very simple: -Program is launched for each authentication. Username and password are written -to stdin, adding \\n to the end. If binary exits with 0 status code - -authentication is considered successful. If the status code is 1 - -authentication is failed. If the status code is 2 - another unrelated error has -happened. Additional information should be written to stderr. - -``` -auth.external { - helper /usr/bin/ldap-helper - perdomain no - domains example.org -} -``` - -## Configuration directives - -*Syntax*: helper _file_path_ - -Location of the helper binary. *Required.* - -*Syntax*: perdomain _boolean_ ++ -*Default*: no - -Don't remove domain part of username when authenticating and require it to be -present. Can be used if you want user@domain1 and user@domain2 to be different -accounts. - -*Syntax*: domains _domains..._ ++ -*Default*: not specified - -Domains that should be allowed in username during authentication. - -For example, if 'domains' is set to "domain1 domain2", then -username, username@domain1 and username@domain2 will be accepted as valid login -name in addition to just username. - -If used without 'perdomain', domain part will be removed from login before -check with underlying auth. mechanism. If 'perdomain' is set, then -domains must be also set and domain part WILL NOT be removed before check. - -# PAM module (auth.pam) - -Implements authentication using libpam. Alternatively it can be configured to -use helper binary like auth.external module does. - -maddy should be built with libpam build tag to use this module without -'use_helper' directive. -``` -go get -tags 'libpam' ... -``` - -``` -auth.pam { - debug no - use_helper no -} -``` - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: no - -Enable verbose logging for all modules. You don't need that unless you are -reporting a bug. - -*Syntax*: use_helper _boolean_ ++ -*Default*: no - -Use LibexecDirectory/maddy-pam-helper instead of directly calling libpam. -You need to use that if: -1. maddy is not compiled with libpam, but maddy-pam-helper is built separately. -2. maddy is running as an unprivileged user and used PAM configuration requires additional -privileges (e.g. when using system accounts). - -For 2, you need to make maddy-pam-helper binary setuid, see -README.md in source tree for details. - -TL;DR (assuming you have the maddy group): -``` -chown root:maddy /usr/lib/maddy/maddy-pam-helper -chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-pam-helper -``` - -# Shadow database authentication module (auth.shadow) - -Implements authentication by reading /etc/shadow. Alternatively it can be -configured to use helper binary like auth.external does. - -``` -auth.shadow { - debug no - use_helper no -} -``` - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: no - -Enable verbose logging for all modules. You don't need that unless you are -reporting a bug. - -*Syntax*: use_helper _boolean_ ++ -*Default*: no - -Use LibexecDirectory/maddy-shadow-helper instead of directly reading /etc/shadow. -You need to use that if maddy is running as an unprivileged user -privileges (e.g. when using system accounts). - -You need to make maddy-shadow-helper binary setuid, see -cmd/maddy-shadow-helper/README.md in source tree for details. - -TL;DR (assuming you have maddy group): -``` -chown root:maddy /usr/lib/maddy/maddy-shadow-helper -chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-shadow-helper -``` - -# Table-based password hash lookup (auth.pass_table) - -This module implements username:password authentication by looking up the -password hash using a table module (maddy-tables(5)). It can be used -to load user credentials from text file (file module) or SQL query -(sql_table module). - - -Definition: -``` -auth.pass_table [block name] { - table - -} -``` -Shortened variant for inline use: -``` -pass_table
[table arguments] { - [additional table config] -} -``` - -Example, read username:password pair from the text file: -``` -smtp tcp://0.0.0.0:587 { - auth pass_table file /etc/maddy/smtp_passwd - ... -} -``` - -## Password hashes - -pass_table expects the used table to contain certain structured values with -hash algorithm name, salt and other necessary parameters. - -You should use 'maddyctl hash' command to generate suitable values. -See 'maddyctl hash --help' for details. - -## maddyctl creds - -If the underlying table is a "mutable" table (see maddy-tables(5)) then -the 'maddyctl creds' command can be used to modify the underlying tables -via pass_table module. It will act a "local credentials store" and will write -appropriate hash values to the table. - -# Separate username and password lookup (auth.plain_separate) - -This module implements authentication using username:password pairs but can -use zero or more "table modules" (maddy-tables(5)) and one or more -authentication providers to verify credentials. - -``` -auth.plain_separate { - user ... - user ... - ... - pass ... - pass ... - ... -} -``` - -How it works: -- Initial username input is normalized using PRECIS UsernameCaseMapped profile. -- Each table specified with the 'user' directive looked up using normalized - username. If match is not found in any table, authentication fails. -- Each authentication provider specified with the 'pass' directive is tried. - If authentication with all providers fails - an error is returned. - -## Configuration directives - -**Syntax:** user _table module_ - -Configuration block for any module from maddy-tables(5) can be used here. - -Example: -``` -user file /etc/maddy/allowed_users -``` - -**Syntax:** pass _auth provider_ - -Configuration block for any auth. provider module can be used here, even -'plain_split' itself. - -The used auth. provider must provide username:password pair-based -authentication. - -# Dovecot authentication client (auth.dovecot_sasl) - -The 'dovecot_sasl' module implements the client side of the Dovecot -authentication protocol, allowing maddy to use it as a credentials source. - -Currently SASL mechanisms support is limited to mechanisms supported by maddy -so you cannot get e.g. SCRAM-MD5 this way. - -``` -auth.dovecot_sasl { - endpoint unix://socket_path -} - -dovecot_sasl unix://socket_path -``` - -## Configuration directives - -*Syntax*: endpoint _schema://address_ ++ -*Default*: not set - -Set the address to use to contact Dovecot SASL server in the standard endpoint -format. - -tcp://10.0.0.1:2222 for TCP, unix:///var/lib/dovecot/auth.sock for Unix -domain sockets. - -# LDAP BindDN authentication (EXPERIMENTAL) (auth.ldap) - -maddy supports authentication via LDAP using DN binding. Passwords are verified -by the LDAP server. - -maddy needs to know the DN to use for binding. It can be obtained either by -directory search or template . - -Note that storage backends conventionally use email addresses, if you use -non-email identifiers as usernames then you should map them onto -emails on delivery by using auth_map (see *maddy-storage*(5)). - -auth.ldap also can be a used as a table module. This way you can check -whether the account exists. It works only if DN template is not used. - -``` -auth.ldap { - urls ldap://maddy.test:389 - - # Specify initial bind credentials. Not required ('bind off') - # if DN template is used. - bind plain "cn=maddy,ou=people,dc=maddy,dc=test" "123456" - - # Specify DN template to skip lookup. - dn_template "cn={username},ou=people,dc=maddy,dc=test" - - # Specify base_dn and filter to lookup DN. - base_dn "ou=people,dc=maddy,dc=test" - filter "(&(objectClass=posixAccount)(uid={username}))" - - tls_client { ... } - starttls off - debug off - connect_timeout 1m -} -``` -``` -auth.ldap ldap://maddy.test.389 { - ... -} -``` - -## Configuration directives - -*Syntax:* urls _servers..._ - -REQUIRED. - -URLs of the directory servers to use. First available server -is used - no load-balancing is done. - -URLs should use 'ldap://', 'ldaps://', 'ldapi://' schemes. - -*Syntax:* bind off ++ - bind unauth ++ - bind external ++ - bind plain _username_ _password_ ++ -*Default:* off - -Credentials to use for initial binding. Required if DN lookup is used. - -'unauth' performs unauthenticated bind. 'external' performs external binding -which is useful for Unix socket connections (ldapi://) or TLS client certificate -authentication (cert. is set using tls_client directive). 'plain' performs a -simple bind using provided credentials. - -*Syntax:* dn_template _template_ - -DN template to use for binding. '{username}' is replaced with the -username specified by the user. - -*Syntax:* base_dn _dn_ - -Base DN to use for lookup. - -*Syntax:* filter _str_ - -DN lookup filter. '{username}' is replaced with the username specified -by the user. - -Example: -``` -(&(objectClass=posixAccount)(uid={username})) -``` - -Example (using ActiveDirectory): -``` -(&(objectCategory=Person)(memberOf=CN=user-group,OU=example,DC=example,DC=org)(sAMAccountName={username})(!(UserAccountControl:1.2.840.113556.1.4.803:=2))) -``` - -Example: -``` -(&(objectClass=Person)(mail={username})) -``` - -*Syntax:* starttls _bool_ ++ -*Default:* off - -Whether to upgrade connection to TLS using STARTTLS. - -*Syntax:* tls_client { ... } - -Advanced TLS client configuration. See *maddy-tls*(5) for details. - -*Syntax:* connect_timeout _duration_ ++ -*Default:* 1m - -Timeout for initial connection to the directory server. - -*Syntax:* request_timeout _duration_ ++ -*Default:* 1m - -Timeout for each request (binding, lookup). \ No newline at end of file diff --git a/docs/man/maddy-blob.5.scd b/docs/man/maddy-blob.5.scd deleted file mode 100644 index dcc9a11bd..000000000 --- a/docs/man/maddy-blob.5.scd +++ /dev/null @@ -1,113 +0,0 @@ -maddy-blob(5) "maddy mail server" "maddy reference documentation" - -; TITLE Message blob storage - -Some IMAP storage backends support pluggable message storage that allows -message contents to be stored separately from IMAP index. - -Modules described in this page are what can be used with such storage backends. -In most cases they have to be specified using the 'msg_store' directive, like -this: -``` -storage.imapsql local_mailboxes { - msg_store fs /var/lib/email -} -``` - -Unless explicitly configured, storage backends with pluggable storage will -store messages in state_dir/messages (e.g. /var/lib/maddy/messages) FS -directory. - -# FS directory storage (storage.blob.fs) - -This module stores message bodies in a file system directory. - -``` -storage.blob.fs { - root -} -``` -``` -storage.blob.fs -``` - -## Configuration directives - -*Syntax:* root _path_ ++ -*Default:* not set - -Path to the FS directory. Must be readable and writable by the server process. -If it does not exist - it will be created (parent directory should be writable -for this). Relative paths are interpreted relatively to server state directory. - -# Amazon S3 storage (storage.blob.s3) - -This modules stores messages bodies in a bucket on S3-compatible storage. - -``` -storage.blob.s3 { - endpoint play.min.io - secure yes - access_key "Q3AM3UQ867SPQQA43P2F" - secret_key "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG" - bucket maddy-test - - # optional - region eu-central-1 - object_prefix maddy/ -} -``` - -Example: -``` -storage.imapsql local_mailboxes { - ... - msg_store s3 { - endpoint s3.amazonaws.com - access_key "..." - secret_key "..." - bucket maddy-messages - region us-west-2 - } -} -``` - -## Configuration directives - -*Syntax:* endpoint _address:port_ - -REQUIRED. - -Root S3 endpoint. e.g. s3.amazonaws.com - -*Syntax:* secure _boolean_ ++ -*Default:* yes - -Whether TLS should be used. - -*Syntax:* access_key _string_ ++ -*Syntax:* secret_key _string_ - -REQUIRED. - -Static S3 credentials. - -*Syntax:* bucket _name_ - -REQUIRED. - -S3 bucket name. The bucket must exist and -be read-writable. - -*Syntax:* region _string_ ++ -*Default:* not set - -S3 bucket location. May be called "endpoint" -in some manuals. - -*Syntax:* object_prefix _string_ ++ -*Default:* empty string - -String to add to all keys stored by maddy. - -Can be useful when S3 is used as a file system. \ No newline at end of file diff --git a/docs/man/maddy-filters.5.scd b/docs/man/maddy-filters.5.scd deleted file mode 100644 index 6fc436311..000000000 --- a/docs/man/maddy-filters.5.scd +++ /dev/null @@ -1,953 +0,0 @@ -maddy-filters(5) "maddy mail server" "maddy reference documentation" - -; TITLE Message filtering - -maddy does have two distinct types of modules that do message filtering. -"Checks" and "modifiers". - -"Checks" are meant to be used to reject or quarantine -messages that are unwanted, such as potential spam or messages with spoofed -sender address. They are limited in ways they can modify the message and their -execution is heavily parallelized to improve performance. - -"Modifiers" are executed serially in order they are referenced in the -configuration and are allowed to modify the message data and meta-data. - -# Check actions - -When a certain check module thinks the message is "bad", it takes some actions -depending on its configuration. Most checks follow the same configuration -structure and allow following actions to be taken on check failure: - -- Do nothing ('action ignore') - -Useful for testing deployment of new checks. Check failures are still logged -but they have no effect on message delivery. - -- Reject the message ('action reject') - -Reject the message at connection time. No bounce is generated locally. - -- Quarantine the message ('action quarantine') - -Mark message as 'quarantined'. If message is then delivered to the local -storage, the storage backend can place the message in the 'Junk' mailbox. -Another thing to keep in mind that 'remote' module (see *maddy-targets*(5)) -will refuse to send quarantined messages. - -# Simple checks - -## Configuration directives - -Following directives are defined for all modules listed below. - -*Syntax*: ++ - fail_action ignore ++ - fail_action reject ++ - fail_action quarantine ++ -*Default*: quarantine - -Action to take when check fails. See Check actions for details. - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Log both sucessfull and unsucessfull check executions instead of just -unsucessfull. - -## require_mx_record - -Check that domain in MAIL FROM command does have a MX record and none of them -are "null" (contain a single dot as the host). - -By default, quarantines messages coming from servers missing MX records, -use 'fail_action' directive to change that. - -## require_matching_rdns - -Check that source server IP does have a PTR record point to the domain -specified in EHLO/HELO command. - -By default, quarantines messages coming from servers with mismatched or missing -PTR record, use 'fail_action' directive to change that. - -## require_tls - -Check that the source server is connected via TLS; either directly, or by using -the STARTTLS command. - -By default, rejects messages coming from unencrypted servers. Use the -'fail_action' directive to change that. - -# DKIM authentication module (check.dkim) - -This is the check module that performs verification of the DKIM signatures -present on the incoming messages. - -``` -check.dkim { - debug no - required_fields From Subject - allow_body_subset no - no_sig_action ignore - broken_sig_action ignore - fail_open no -} -``` - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Log both sucessfull and unsucessfull check executions instead of just -unsucessfull. - -*Syntax*: required_fields _string..._ ++ -*Default*: From Subject - -Header fields that should be included in each signature. If signature -lacks any field listed in that directive, it will be considered invalid. - -Note that From is always required to be signed, even if it is not included in -this directive. - -*Syntax*: no_sig_action _action_ ++ -*Default*: ignore (recommended by RFC 6376) - -Action to take when message without any signature is received. - -Note that DMARC policy of the sender domain can request more strict handling of -missing DKIM signatures. - -*Syntax*: broken_sig_action _action_ ++ -*Default*: ignore (recommended by RFC 6376) - -Action to take when there are not valid signatures in a message. - -Note that DMARC policy of the sender domain can request more strict handling of -broken DKIM signatures. - -*Syntax*: fail_open _boolean_ ++ -*Default*: no - -Whether to accept the message if a temporary error occurs during DKIM -verification. Rejecting the message with a 4xx code will require the sender -to resend it later in a hope that the problem will be resolved. - -# SPF policy enforcement module (check.spf) - -This is the check module that verifies whether IP address of the client is -authorized to send messages for domain in MAIL FROM address. - -``` -check.spf { - debug no - enforce_early no - fail_action quarantine - softfail_action ignore - permerr_action reject - temperr_action reject -} -``` - -## DMARC override - -It is recommended by the DMARC standard to don't fail delivery based solely on -SPF policy and always check DMARC policy and take action based on it. - -If enforce_early is no, check.spf module will not take any action on SPF -policy failure if sender domain does have a DMARC record with 'quarantine' or -'reject' policy. Instead it will rely on DMARC support to take necesary -actions using SPF results as an input. - -Disabling enforce_early without enabling DMARC support will make SPF policies -no-op and is considered insecure. - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging for check.spf. - -*Syntax*: enforce_early _boolean_ ++ -*Default*: no - -Make policy decision on MAIL FROM stage (before the message body is received). -This makes it impossible to apply DMARC override (see above). - -*Syntax*: none_action reject|qurantine|ignore ++ -*Default*: ignore - -Action to take when SPF policy evaluates to a 'none' result. - -See https://tools.ietf.org/html/rfc7208#section-2.6 for meaning of -SPF results. - -*Syntax*: neutral_action reject|qurantine|ignore ++ -*Default*: ignore - -Action to take when SPF policy evaluates to a 'neutral' result. - -See https://tools.ietf.org/html/rfc7208#section-2.6 for meaning of -SPF results. - -*Syntax*: fail_action reject|qurantine|ignore ++ -*Default*: quarantine - -Action to take when SPF policy evaluates to a 'fail' result. - -*Syntax*: softfail_action reject|qurantine|ignore ++ -*Default*: ignore - -Action to take when SPF policy evaluates to a 'softfail' result. - -*Syntax*: permerr_action reject|qurantine|ignore ++ -*Default*: reject - -Action to take when SPF policy evaluates to a 'permerror' result. - -*Syntax*: temperr_action reject|qurantine|ignore ++ -*Default*: reject - -Action to take when SPF policy evaluates to a 'temperror' result. - -# DNSBL lookup module (check.dnsbl) - -The dnsbl module implements checking of source IP and hostnames against a set -of DNS-based Blackhole lists (DNSBLs). - -Its configuration consists of module configuration directives and a set -of blocks specifing lists to use and kind of lookups to perform on them. - -``` -check.dnsbl { - debug no - check_early no - - quarantine_threshold 1 - reject_threshold 1 - - # Lists configuration example. - dnsbl.example.org { - client_ipv4 yes - client_ipv6 no - ehlo no - mailfrom no - score 1 - } - hsrbl.example.org { - client_ipv4 no - client_ipv6 no - ehlo yes - mailfrom yes - score 1 - } -} -``` - -## Arguments - -Arguments specify the list of IP-based BLs to use. - -The following configurations are equivalent. - -``` -check { - dnsbl dnsbl.example.org dnsbl2.example.org -} -``` - -``` -check { - dnsbl { - dnsbl.example.org dnsbl2.example.org { - client_ipv4 yes - client_ipv6 no - ehlo no - mailfrom no - score 1 - } - } -} -``` - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: check_early _boolean_ ++ -*Default*: no - -Check BLs before mail delivery starts and silently reject blacklisted clients. - -For this to work correctly, check should not be used in source/destination -pipeline block. - -In particular, this means: -- No logging is done for rejected messages. -- No action is taken if quarantine_threshold is hit, only reject_threshold - applies. -- defer_sender_reject from SMTP configuration takes no effect. -- MAIL FROM is not checked, even if specified. - -If you often get hit by spam attacks, this is recommended to enable this -setting to save server resources. - -*Syntax*: quarantine_threshold _integer_ ++ -*Default*: 1 - -DNSBL score needed (equals-or-higher) to quarantine the message. - -*Syntax*: reject_threshold _integer_ ++ -*Default*: 9999 - -DNSBL score needed (equals-or-higher) to reject the message. - -## List configuration - -``` -dnsbl.example.org dnsbl.example.com { - client_ipv4 yes - client_ipv6 no - ehlo no - mailfrom no - responses 127.0.0.1/24 - score 1 -} -``` - -Directive name and arguments specify the actual DNS zone to query when checking -the list. Using multiple arguments is equivalent to specifying the same -configuration separately for each list. - -*Syntax*: client_ipv4 _boolean_ ++ -*Default*: yes - -Whether to check address of the IPv4 clients against the list. - -*Syntax*: client_ipv6 _boolean_ ++ -*Default*: yes - -Whether to check address of the IPv6 clients against the list. - -*Syntax*: ehlo _boolean_ ++ -*Default*: no - -Whether to check hostname specified n the HELO/EHLO command -against the list. - -This works correctly only with domain-based DNSBLs. - -*Syntax*: mailfrom _boolean_ ++ -*Default*: no - -Whether to check domain part of the MAIL FROM address against the list. - -This works correctly only with domain-based DNSBLs. - -*Syntax*: responses _cidr|ip..._ ++ -*Default*: 127.0.0.1/24 - -IP networks (in CIDR notation) or addresses to permit in list lookup results. -Addresses not matching any entry in this directives will be ignored. - -*Syntax*: score _integer_ ++ -*Default*: 1 - -Score value to add for the message if it is listed. - -If sum of list scores is equals or higher than quarantine_threshold, the -message will be quarantined. - -If sum of list scores is equals or higher than rejected_threshold, the message -will be rejected. - -It is possible to specify a negative value to make list act like a whitelist -and override results of other blocklists. - -# DKIM signing module (modify.dkim) - -modify.dkim module is a modifier that signs messages using DKIM -protocol (RFC 6376). - -``` -modify.dkim { - debug no - domains example.org example.com - selector default - key_path dkim-keys/{domain}-{selector}.key - oversign_fields ... - sign_fields ... - header_canon relaxed - body_canon relaxed - sig_expiry 120h # 5 days - hash sha256 - newkey_algo rsa2048 -} -``` - -## Arguments - -domains and selector can be specified in arguments, so actual modify.dkim use can -be shortened to the following: -``` -modify { - dkim example.org selector -} -``` - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: domains _string list_ ++ -*Default*: not specified - -*REQUIRED.* - -ADministrative Management Domains (ADMDs) taking responsibility for messages. - -A key will be generated or read for each domain specified here, the key to use -for each message will be selected based on the SMTP envelope sender. Exception -for that is that for domain-less postmaster address and null address, the -key for the first domain will be used. If domain in envelope sender -does not match any of loaded keys, message will not be signed. - -Should be specified either as a directive or as an argument. - -*Syntax*: selector _string_ ++ -*Default*: not specified - -*REQUIRED.* - -Identifier of used key within the ADMD. -Should be specified either as a directive or as an argument. - -*Syntax*: key_path _string_ ++ -*Default*: dkim_keys/{domain}\_{selector}.key - -Path to private key. It should be in PKCS#8 format wrapped in PAM encoding. -If key does not exist, it will be generated using algorithm specified -in newkey_algo. - -Placeholders '{domain}' and '{selector}' will be replaced with corresponding -values from domain and selector directives. - -Additionally, keys in PKCS#1 ("RSA PRIVATE KEY") and -RFC 5915 ("EC PRIVATE KEY") can be read by modify.dkim. Note, however that -newly generated keys are always in PKCS#8. - -*Syntax*: oversign_fields _list..._ ++ -*Default*: see below - -Header fields that should be signed n+1 times where n is times they are -present in the message. This makes it impossible to replace field -value by prepending another field with the same name to the message. - -Fields specified here don't have to be also specified in sign_fields. - -Default set of oversigned fields: -- Subject -- To -- From -- Date -- MIME-Version -- Content-Type -- Content-Transfer-Encoding -- Reply-To -- Message-Id -- References -- Autocrypt -- Openpgp - -*Syntax*: sign_fields _list..._ ++ -*Default*: see below - -Header fields that should be signed n+1 times where n is times they are -present in the message. For these fields, additional values can be prepended -by intermediate relays, but existing values can't be changed. - -Default set of signed fields: -- List-Id -- List-Help -- List-Unsubscribe -- List-Post -- List-Owner -- List-Archive -- Resent-To -- Resent-Sender -- Resent-Message-Id -- Resent-Date -- Resent-From -- Resent-Cc - -*Syntax*: header_canon relaxed|simple ++ -*Default*: relaxed - -Canonicalization algorithm to use for header fields. With 'relaxed', whitespace within -fields can be modified without breaking the signature, with 'simple' no -modifications are allowed. - -*Syntax*: body_canon relaxed|simple ++ -*Default*: relaxed - -Canonicalization algorithm to use for message body. With 'relaxed', whitespace within -can be modified without breaking the signature, with 'simple' no -modifications are allowed. - -*Syntax*: sig_expiry _duration_ ++ -*Default*: 120h - -Time for which signature should be considered valid. Mainly used to prevent -unauthorized resending of old messages. - -*Syntax*: hash _hash_ ++ -*Default*: sha256 - -Hash algorithm to use when computing body hash. - -sha256 is the only supported algorithm now. - -*Syntax*: newkey_algo rsa4096|rsa2048|ed25519 ++ -*Default*: rsa2048 - -Algorithm to use when generating a new key. - -*Syntax*: require_sender_match _ids..._ ++ -*Default*: envelope auth - -Require specified identifiers to match From header field and key domain, -otherwise - don't sign the message. - -If From field contains multiple addresses, message will not be -signed unless allow_multiple_from is also specified. In that -case only first address will be compared. - -Matching is done in a case-insensitive way. - -Valid values: -- off + - Disable check, always sign. -- envelope + - Require MAIL FROM address to match From header. -- auth + - If authorization identity contains @ - then require it to - fully match From header. Otherwise, check only local-part - (username). - -*Syntax*: allow_multiple_from _boolean_ ++ -*Default*: no - -Allow multiple addresses in From header field for purposes of -require_sender_match checks. Only first address will be checked, however. - -*Syntax*: sign_subdomains _boolean_ ++ -*Default*: no - -Sign emails from subdomains using a top domain key. - -Allows only one domain to be specified (can be workarounded using modify.dkim -multiple times). - -# Envelope sender / recipient rewriting (modify.replace_sender, modify.replace_rcpt) - -'replace_sender' and 'replace_rcpt' modules replace SMTP envelope addresses -based on the mapping defined by the table module (maddy-tables(5)). Currently, -only 1:1 mappings are supported (that is, it is not possible to specify -multiple replacements for a single address). - -The address is normalized before lookup (Punycode in domain-part is decoded, -Unicode is normalized to NFC, the whole string is case-folded). - -First, the whole address is looked up. If there is no replacement, local-part -of the address is looked up separately and is replaced in the address while -keeping the domain part intact. Replacements are not applied recursively, that -is, lookup is not repeated for the replacement. - -Recipients are not deduplicated after expansion, so message may be delivered -multiple times to a single recipient. However, used delivery target can apply -such deduplication (imapsql storage does it). - -Definition: -``` -replace_rcpt
[table arguments] { - [extended table config] -} -replace_sender
[table arguments] { - [extended table config] -} -``` - -Use examples: -``` -modify { - replace_rcpt file /etc/maddy/aliases - replace_rcpt static { - entry a@example.org b@example.org - } - replace_rcpt regexp "(.+)@example.net" "1ドル@example.org" -} -``` - -Possible contents of /etc/maddy/aliases in the example above: -``` -# Replace 'cat' with any domain to 'dog'. -# E.g. cat@example.net -> dog@example.net -cat: dog - -# Replace cat@example.org with cat@example.com. -# Takes priority over the previous line. -cat@example.org: cat@example.com -``` - -# System command filter (check.command) - -This module executes an arbitrary system command during a specified stage of -checks execution. - -``` -command executable_name arg0 arg1 ... { - run_on body - - code 1 reject - code 2 quarantine -} -``` - -## Arguments - -The module arguments specify the command to run. If the first argument is not -an absolute path, it is looked up in the Libexec Directory (/usr/lib/maddy on -Linux) and in $PATH (in that ordering). Note that no additional handling -of arguments is done, especially, the command is executed directly, not via the -system shell. - -There is a set of special strings that are replaced with the corresponding -message-specific values: - -- {source_ip} - - IPv4/IPv6 address of the sending MTA. - -- {source_host} - - Hostname of the sending MTA, from the HELO/EHLO command. - -- {source_rdns} - - PTR record of the sending MTA IP address. - -- {msg_id} - - Internal message identifier. Unique for each delivery. - -- {auth_user} - - Client username, if authenticated using SASL PLAIN - -- {sender} - - Message sender address, as specified in the MAIL FROM SMTP command. - -- {rcpts} - - List of accepted recipient addresses, including the currently handled - one. - -- {address} - - Currently handled address. This is a recipient address if the command - is called during RCPT TO command handling ('run_on rcpt') or a sender - address if the command is called during MAIL FROM command handling ('run_on - sender'). - - -If value is undefined (e.g. {source_ip} for a message accepted over a Unix -socket) or unavailable (the command is executed too early), the placeholder -is replaced with an empty string. Note that it can not remove the argument. -E.g. -i {source_ip} will not become just -i, it will be -i "" - -Undefined placeholders are not replaced. - -## Command stdout - -The command stdout must be either empty or contain a valid RFC 5322 header. -If it contains a byte stream that does not look a valid header, the message -will be rejected with a temporary error. - -The header from stdout will be *prepended* to the message header. - -## Configuration directives - -*Syntax*: run_on conn|sender|rcpt|body ++ -*Default*: body - -When to run the command. This directive also affects the information visible -for the message. - -- conn - - Run before the sender address (MAIL FROM) is handled. - - *Stdin*: Empty ++ -*Available placeholders*: {source_ip}, {source_host}, {msg_id}, {auth_user}. - -- sender - - Run during sender address (MAIL FROM) handling. - - *Stdin*: Empty ++ -*Available placeholders*: conn placeholders + {sender}, {address}. - - The {address} placeholder contains the MAIL FROM address. - -- rcpt - - Run during recipient address (RCPT TO) handling. The command is executed - once for each RCPT TO command, even if the same recipient is specified - multiple times. - - *Stdin*: Empty ++ -*Available placeholders*: sender placeholders + {rcpts}. - - The {address} placeholder contains the recipient address. - -- body - - Run during message body handling. - - *Stdin*: The message header + body ++ -*Available placeholders*: all except for {address}. - -*Syntax*: ++ - code _integer_ ignore ++ - code _integer_ quarantine ++ - code _integer_ reject [SMTP code] [SMTP enhanced code] [SMTP message] - -This directives specified the mapping from the command exit code _integer_ to -the message pipeline action. - -Two codes are defined implicitly, exit code 1 causes the message to be rejected -with a permanent error, exit code 2 causes the message to be quarantined. Both -action can be overriden using the 'code' directive. - -## Milter protocol check (check.milter) - -The 'milter' implements subset of Sendmail's milter protocol that can be used -to integrate external software in maddy. - -Notable limitations of protocol implementation in maddy include: -1. Changes of envelope sender address are not supported -2. Removal and addition of envelope recipients is not supported -3. Removal and replacement of header fields is not supported -4. Headers fields can be inserted only on top -5. Milter does not receive some "macros" provided by sendmail. - -Restrictions 1 and 2 are inherent to the maddy checks interface and cannot be -removed without major changes to it. Restrictions 3, 4 and 5 are temporary due to -incomplete implementation. - -``` -check.milter { - endpoint - fail_open false -} - -milter -``` - -## Arguments - -When defined inline, the first argument specifies endpoint to access milter -via. See below. - -## Configuration directives - -**Syntax:** endpoint _scheme://path_ ++ -**Default:** not set - -Specifies milter protocol endpoint to use. -The endpoit is specified in standard URL-like format: -'tcp://127.0.0.1:6669' or 'unix:///var/lib/milter/filter.sock' - -**Syntax:** fail_open _boolean_ ++ -**Default:** false - -Toggles behavior on milter I/O errors. If false ("fail closed") - message is -rejected with temporary error code. If true ("fail open") - check is skipped. - -## rspamd check (check.rspamd) - -The 'rspamd' module implements message filtering by contacting the rspamd -server via HTTP API. - -``` -check.rspamd { - tls_client { ... } - api_path http://127.0.0.1:11333 - settings_id whatever - tag maddy - hostname mx.example.org - io_error_action ignore - error_resp_action ignore - add_header_action quarantine - rewrite_subj_action quarantine - flags pass_all -} - -rspamd http://127.0.0.1:11333 -``` - -## Configuration directives - -*Syntax:* tls_client { ... } ++ -*Default:* not set - -Configure TLS client if HTTPS is used, see *maddy-tls*(5) for details. - -*Syntax:* api_path _url_ ++ -*Default:* http://127.0.0.1:11333 - -URL of HTTP API endpoint. Supports both HTTP and HTTPS and can include -path element. - -*Syntax:* settings_id _string_ ++ -*Default:* not set - -Settings ID to pass to the server. - -*Syntax:* tag _string_ ++ -*Default:* maddy - -Value to send in MTA-Tag header field. - -*Syntax:* hostname _string_ ++ -*Default:* value of global directive - -Value to send in MTA-Name header field. - -*Syntax:* io_error_action _action_ ++ -*Default:* ignore - -Action to take in case of inability to contact the rspamd server. - -*Syntax:* error_resp_action _action_ ++ -*Default:* ignore - -Action to take in case of 5xx or 4xx response received from the rspamd server. - -*Syntax:* add_header_action _action_ ++ -*Default:* quarantine - -Action to take when rspamd requests to "add header". - -X-Spam-Flag and X-Spam-Score are added to the header irregardless of value. - -*Syntax:* rewrite_subj_action _action_ ++ -*Default:* quarantine - -Action to take when rspamd requests to "rewrite subject". - -X-Spam-Flag and X-Spam-Score are added to the header irregardless of value. - -*Syntax:* flags _string list..._ ++ -*Default:* pass_all - -Flags to pass to the rspamd server. -See https://rspamd.com/doc/architecture/protocol.html for details. - -## MAIL FROM and From authorization (check.authorize_sender) - -This check verifies that envelope and header sender addresses belong -to the authenticated user. Address ownership is established via table -that maps each user account to a email address it is allowed to use. -There are some special cases, see user_to_email description below. - -``` -check.authorize_sender { - prepare_email identity - user_to_email identity - check_header yes - - unauth_action reject - no_match_action reject - malformed_action reject - err_action reject - - auth_normalize precis_casefold_email - from_normalize precis_casefold_email -} -``` -``` -check { - authorize_sender { ... } -} -``` - -## Configuration directives - -*Syntax:* user_to_email _table_ ++ -*Default:* identity - -Table to use for lookups. Result of the lookup should contain either the -domain name, the full email address or "*" string. If it is just domain - user -will be allowed to use any mailbox within a domain as a sender address. -If result contains "*" - user will be allowed to use any address. - -*Syntax:* check_header _boolean_ ++ -*Default:* yes - -Whether to verify header sender in addition to envelope. - -Either Sender or From field value should match the -authorization identity. - -*Syntax:* unauth_action _action_ ++ -*Default:* reject - -What to do if the user is not authenticated at all. - -*Syntax:* no_match_action _action_ ++ -*Default:* reject - -What to do if user is not allowed to use the sender address specified. - -*Syntax:* malformed_action _action_ ++ -*Default:* reject - -What to do if From or Sender header fields contain malformed values. - -*Syntax:* err_action _action_ ++ -*Default:* reject - -What to do if error happens during prepare_email or user_to_email lookup. - -*Syntax:* auth_normalize _action_ ++ -*Default:* precis_casefold_email - -Normalization function to apply to authorization username before -further processing. - -Available options: -- precis_casefold_email PRECIS UsernameCaseMapped profile + U-labels form for domain -- precis_casefold PRECIS UsernameCaseMapped profile for the entire string -- precis_email PRECIS UsernameCasePreserved profile + U-labels form for domain -- precis PRECIS UsernameCasePreserved profile for the entire string -- casefold Convert to lower case -- noop Nothing - -*Syntax:* from_normalize _action_ ++ -*Default:* precis_casefold_email - -Normalization function to apply to email addresses before -further processing. - -Available options are same as for auth_normalize. \ No newline at end of file diff --git a/docs/man/maddy-imap.5.scd b/docs/man/maddy-imap.5.scd deleted file mode 100644 index e701a6e20..000000000 --- a/docs/man/maddy-imap.5.scd +++ /dev/null @@ -1,130 +0,0 @@ -maddy-imap(5) "maddy mail server" "maddy reference documentation" - -; TITLE IMAP endpoint module - -Module 'imap' is a listener that implements IMAP4rev1 protocol and provides -access to local messages storage specified by 'storage' directive. See -*maddy-storage*(5) for support storage backends and corresponding -configuration options. - -``` -imap tcp://0.0.0.0:143 tls://0.0.0.0:993 { - tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key - io_debug no - debug no - insecure_auth no - auth pam - storage &local_mailboxes -} -``` - -## Configuration directives - -*Syntax*: tls _certificate_path_ _key_path_ { ... } ++ -*Default*: global directive value - -TLS certificate & key to use. Fine-tuning of other TLS properties is possible -by specifing a configuration block and options inside it: -``` -tls cert.crt key.key { - protocols tls1.2 tls1.3 -} -``` -See section 'TLS configuration' in *maddy*(1) for valid options. - -*Syntax*: io_debug _boolean_ ++ -*Default*: no - -Write all commands and responses to stderr. - -*Syntax*: io_errors _boolean_ ++ -*Default*: no - -Log I/O errors. - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: insecure_auth _boolean_ ++ -*Default*: no (yes if TLS is disabled) - -*Syntax*: auth _module_reference_ - -Use the specified module for authentication. -*Required.* - -*Syntax*: storage _module_reference_ - -Use the specified module for message storage. -*Required.* - -## IMAP filters - -Most storage backends support application of custom code late in delivery -process. As opposed to using SMTP pipeline modifiers or checks, it allows -modifying IMAP-specific message attributes. In particular, it allows -code to change target folder and add IMAP flags (keywords) to the message. - -There is no way to reject message using IMAP filters, this should be done -eariler in SMTP pipeline logic. Quarantined messages are not processed -by IMAP filters and are unconditionally delivered to Junk folder (or other -folder with \Junk special-use attribute). - -To use an IMAP filter, specify it in the 'imap_filter' directive for the -used storage backend, like this: -``` -storage.imapsql local_mailboxes { - ... - - imap_filter { - command /etc/maddy/sieve.sh {account_name} - } -} -``` - -## System command filter (imap.filter.command) - -This filter is similar to check.command module described in *maddy-filters*(5) -and runs system command - -Usage: -``` -command executable_name args... { } -``` - -Same as check.command, following placeholders are supported for command -arguments: {source_ip}, {source_host}, {source_rdns}, {msg_id}, {auth_user}, -{sender}. Note: placeholders in command name are not processed to avoid -possible command injection attacks. - -Additionally, for imap.filter.command, {account_name} placeholder is replaced -with effective IMAP account name. - -Note that if you use provided systemd units on Linux, maddy executable is -sandboxed - all commands will be executed with heavily restricted filesystem -acccess and other privileges. Notably, /tmp is isolated and all directories -except for /var/lib/maddy and /run/maddy are read-only. You will need to modify -systemd unit if your command needs more privileges. - -Command output should consist of zero or more lines. First one, if non-empty, overrides -destination folder. All other lines contain additional IMAP flags to add -to the message. If command wants to add flags without changing folder - first -line should be empty. - -It is valid for command to not write anything to stdout. In this case its -execution will have no effect on delivery. - -Output example: -``` -Junk -``` -In this case, message will be placed in the Junk folder. - -``` - -$Label1 -``` -In this case, message will be placed in inbox and will have -'$Label1' added. diff --git a/docs/man/maddy-smtp.5.scd b/docs/man/maddy-smtp.5.scd deleted file mode 100644 index 899ae044f..000000000 --- a/docs/man/maddy-smtp.5.scd +++ /dev/null @@ -1,642 +0,0 @@ -maddy-smtp(5) "maddy mail server" "maddy reference documentation" - -; TITLE SMTP endpoint module - -# SMTP endpoint module (smtp) - -Module 'smtp' is a listener that implements ESMTP protocol with optional -authentication, LMTP and Submission support. Incoming messages are processed in -accordance with pipeline rules (explained in Message pipeline section below). - -``` -smtp tcp://0.0.0.0:25 { - hostname example.org - tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key - io_debug no - debug no - insecure_auth no - read_timeout 10m - write_timeout 1m - max_message_size 32M - max_header_size 1M - auth pam - defer_sender_reject yes - dmarc yes - smtp_max_line_length 4000 - limits { - endpoint rate 10 - endpoint concurrency 500 - } - - # Example pipeline ocnfiguration. - destination example.org { - deliver_to &local_mailboxes - } - default_destination { - reject - } -} -``` - -## Configuration directives - -*Syntax*: hostname _string_ ++ -*Default*: global directive value - -Server name to use in SMTP banner. - -``` -220 example.org ESMTP Service Ready -``` - -*Syntax*: tls _certificate_path_ _key_path_ { ... } ++ -*Default*: global directive value - -TLS certificate & key to use. Fine-tuning of other TLS properties is possible -by specifing a configuration block and options inside it: -``` -tls cert.crt key.key { - protocols tls1.2 tls1.3 -} -``` -See section 'TLS configuration' in *maddy*(1) for valid options. - -*Syntax*: io_debug _boolean_ ++ -*Default*: no - -Write all commands and responses to stderr. - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: insecure_auth _boolean_ ++ -*Default*: no (yes if TLS is disabled) - -Allow plain-text authentication over unencrypted connections. Not recommended! - -*Syntax*: read_timeout _duration_ ++ -*Default*: 10m - -I/O read timeout. - -*Syntax*: write_timeout _duration_ ++ -*Default*: 1m - -I/O write timeout. - -*Syntax*: max_message_size _size_ ++ -*Default*: 32M - -Limit the size of incoming messages to 'size'. - -*Syntax*: max_header_size _size_ ++ -*Default*: 1M - -Limit the size of incoming message headers to 'size'. - -*Syntax*: auth _module_reference_ ++ -*Default*: not specified - -Use the specified module for authentication. - -*Syntax*: defer_sender_reject _boolean_ ++ -*Default*: yes - -Apply sender-based checks and routing logic when first RCPT TO command -is received. This allows maddy to log recipient address of the rejected -message and also improves interoperability with (improperly implemented) -clients that don't expect an error early in session. - -*Syntax*: max_logged_rcpt_errors _integer_ ++ -*Default*: 5 - -Amount of RCPT-time errors that should be logged. Further errors will be -handled silently. This is to prevent log flooding during email dictonary -attacks (address probing). - -*Syntax*: max_received _integer_ ++ -*Default*: 50 - -Max. amount of Received header fields in the message header. If the incoming -message has more fields than this number, it will be rejected with the permanent error -5.4.6 ("Routing loop detected"). - -*Syntax*: ++ - buffer ram ++ - buffer fs _[path]_ ++ - buffer auto _max_size_ _[path]_ ++ -*Default*: auto 1M StateDirectory/buffer - -Temporary storage to use for the body of accepted messages. - -- ram - -Store the body in RAM. - -- fs - -Write out the message to the FS and read it back as needed. -_path_ can be omitted and defaults to StateDirectory/buffer. - -- auto - -Store message bodies smaller than _max_size_ entirely in RAM, otherwise write -them out to the FS. -_path_ can be omitted and defaults to StateDirectory/buffer. - -*Syntax*: smtp_max_line_length _integer_ ++ -*Default*: 4000 - -The maximum line length allowed in the SMTP input stream. If client sends a -longer line - connection will be closed and message (if any) will be rejected -with a permanent error. - -RFC 5321 has the recommended limit of 998 bytes. Servers are not required -to handle longer lines correctly but some senders may produce them. - -Unless BDAT extension is used by the sender, this limitation also applies to -the message body. - -*Syntax*: dmarc _boolean_ ++ -*Default*: yes - -Enforce sender's DMARC policy. Due to implementation limitations, it is not a -check module. - -*NOTE*: Report generation is not implemented now. - -*NOTE*: DMARC needs SPF and DKIM checks to function correctly. -Without these, DMARC check will not run. - -## Rate & concurrency limiting - -*Syntax*: limits _config block_ ++ -*Default*: no limits - -This allows configuring a set of message flow restrictions including -max. concurrency and rate per-endpoint, per-source, per-destination. - -Limits are specified as directives inside the block: -``` -limits { - all rate 20 - destination concurrency 5 -} -``` - -Supported limits: - -- Rate limit - -*Syntax*: _scope_ rate _burst_ _[period]_ ++ -Restrict the amount of messages processed in _period_ to _burst_ messages. -If period is not specified, 1 second is used. - -- Concurrency limit - -*Syntax*: _scope_ concurrency _max_ ++ -Restrict the amount of messages processed in parallel to _max_. - -For each supported limitation, _scope_ determines whether it should be applied -for all messages ("all"), per-sender IP ("ip"), per-sender domain ("source") or -per-recipient domain ("destination"). Having a scope other than "all" means -that the restriction will be enforced independently for each group determined -by scope. E.g. "ip rate 20" means that the same IP cannot send more than 20 -messages in a scond. "destination concurrency 5" means that no more than 5 -messages can be sent in parallel to a single domain. - -*Note*: At the moment, SMTP endpoint on its own does not support per-recipient -limits. They will be no-op. If you want to enforce a per-recipient restriction -on outbound messages, do so using 'limits' directive for the 'remote' module -(see *maddy-targets*(5)). - -It is possible to share limit counters between multiple endpoints (or any other -modules). To do so define a top-level configuration block for module "limits" -and reference it where needed using standard & syntax. E.g. -``` -limits inbound_limits { - all rate 20 -} - -smtp smtp://0.0.0.0:25 { - limits &inbound_limits - ... -} - -submission tls://0.0.0.0:465 { - limits &inbound_limits - ... -} -``` -Using an "all rate" restriction in such way means that no more than 20 -messages can enter the server through both endpoints in one second. - -# Submission module (submission) - -Module 'submission' implements all functionality of the 'smtp' module and adds -certain message preprocessing on top of it, additionaly authentication is -always required. - -'submission' module checks whether addresses in header fields From, Sender, To, -Cc, Bcc, Reply-To are correct and adds Message-ID and Date if it is missing. - -``` -submission tcp://0.0.0.0:587 tls://0.0.0.0:465 { - # ... same as smtp ... -} -``` - -# LMTP module (lmtp) - -Module 'lmtp' implements all functionality of the 'smtp' module but uses -LMTP (RFC 2033) protocol. - -``` -lmtp unix://lmtp.sock { - # ... same as smtp ... -} -``` - -## Limitations of LMTP implementation - -- Can't be used with TCP. - -- Per-recipient status is not supported. - -- Delivery to 'sql' module storage is always atomic, either all recipients will - succeed or none of them will. - -# Mesage pipeline - -Message pipeline is a set of module references and associated rules that -describe how to handle messages. - -The pipeline is responsible for -- Running message filters (called "checks"), (e.g. DKIM signature verification, - DNSBL lookup and so on). - -- Running message modifiers (e.g. DKIM signature creation). - -- Assocating each message recipient with one or more delivery targets. - Delivery target is a module that does final processing (delivery) of the - message. - -Message handling flow is as follows: -- Execute checks referenced in top-level 'check' blocks (if any) - -- Execute modifiers referenced in top-level 'modify' blocks (if any) - -- If there are 'source' blocks - select one that matches message sender (as - specified in MAIL FROM). If there are no 'source' blocks - entire - configuration is assumed to be the 'default_source' block. - -- Execute checks referenced in 'check' blocks inside selected 'source' block - (if any). - -- Execute modifiers referenced in 'modify' blocks inside selected 'source' - block (if any). - -Then, for each recipient: -- Select 'destination' block that matches it. If there are - no 'destination' blocks - entire used 'source' block is interpreted as if it - was a 'default_destination' block. - -- Execute checks referenced in 'check' block inside selected 'destination' block - (if any). - -- Execute modifiers referenced in 'modify' block inside selected 'destination' - block (if any). - -- If used block contains 'reject' directive - reject the recipient with - specified SMTP status code. - -- If used block contains 'deliver_to' directive - pass the message to the - specified target module. Only recipients that are handled - by used block are visible to the target. - -Each recipient is handled only by a single 'destination' block, in case of -overlapping 'destination' - first one takes priority. -``` -destination example.org { - deliver_to targetA -} -destination example.org { # ambiguous and thus not allowed - deliver_to targetB -} -``` -Same goes for 'source' blocks, each message is handled only by a single block. - -Each recipient block should contain at least one 'deliver_to' directive or -'reject' directive. If 'destination' blocks are used, then -'default_destination' block should also be used to specify behavior for -unmatched recipients. Same goes for source blocks, 'default_source' should be -used if 'source' is used. - -That is, pipeline configuration should explicitly specify behavior for each -possible sender/recipient combination. - -Additionally, directives that specify final handling decision ('deliver_to', -'reject') can't be used at the same level as source/destination rules. -Consider example: -``` -destination example.org { - deliver_to local_mboxes -} -reject -``` -It is not obvious whether 'reject' applies to all recipients or -just for non-example.org ones, hence this is not allowed. - -Complete configuration example using all of the mentioned directives: -``` -check { - # Run a check to make sure source SMTP server identification - # is legit. - require_matching_ehlo -} - -# Messages coming from senders at example.org will be handled in -# accordance with the following configuration block. -source example.org { - # We are example.com, so deliver all messages with recipients - # at example.com to our local mailboxes. - destination example.com { - deliver_to &local_mailboxes - } - - # We don't do anything with recipients at different domains - # because we are not an open relay, thus we reject them. - default_destination { - reject 521 5.0.0 "User not local" - } -} - -# We do our business only with example.org, so reject all -# other senders. -default_source { - reject -} -``` - -## Directives - -*Syntax*: check _block name_ { ... } ++ -*Context*: pipeline configuration, source block, destination block - -List of the module references for checks that should be executed on -messages handled by block where 'check' is placed in. - -Note that message body checks placed in destination block are currently -ignored. Due to the way SMTP protocol is defined, they would cause message to -be rejected for all recipients which is not what you usually want when using -such configurations. - -Example: -``` -check { - # Reference implicitly defined default configuration for check. - require_matching_ehlo - - # Inline definition of custom config. - require_source_mx { - # Configuration for require_source_mx goes here. - fail_action reject - } -} -``` - -It is also possible to define the block of checks at the top level -as "checks" module and reference it using & syntax. Example: -``` -checks inbound_checks { - require_matching_ehlo -} - -# ... somewhere else ... -{ - ... - check &inbound_checks -} -``` - -*Syntax*: modify { ... } ++ -*Default*: not specified ++ -*Context*: pipeline configuration, source block, destination block - -List of the module references for modifiers that should be executed on -messages handled by block where 'modify' is placed in. - -Message modifiers are similar to checks with the difference in that checks -purpose is to verify whether the message is legitimate and valid per local -policy, while modifier purpose is to post-process message and its metadata -before final delivery. - -For example, modifier can replace recipient address to make message delivered -to the different mailbox or it can cryptographically sign outgoing message -(e.g. using DKIM). Some modifier can perform multiple unrelated modifications -on the message. - -*Note*: Modifiers that affect source address can be used only globally or on -per-source basis, they will be no-op inside destination blocks. Modifiers that -affect the message header will affect it for all recipients. - -It is also possible to define the block of modifiers at the top level -as "modiifers" module and reference it using & syntax. Example: -``` -modifiers local_modifiers { - replace_rcpt file /etc/maddy/aliases -} - -# ... somewhere else ... -{ - ... - modify &local_modifiers -} -``` - -*Syntax*: ++ - reject _smtp_code_ _smtp_enhanced_code_ _error_description_ ++ - reject _smtp_code_ _smtp_enhanced_code_ ++ - reject _smtp_code_ ++ - reject ++ -*Context*: destination block - -Messages handled by the configuration block with this directive will be -rejected with the specified SMTP error. - -If you aren't sure which codes to use, use 541 and 5.4.0 with your message or -just leave all arguments out, the error description will say "message is -rejected due to policy reasons" which is usually what you want to mean. - -'reject' can't be used in the same block with 'deliver_to' or -'destination/source' directives. - -Example: -``` -reject 541 5.4.0 "We don't like example.org, go away" -``` - -*Syntax*: deliver_to _target-config-block_ ++ -*Context*: pipeline configuration, source block, destination block - -Deliver the message to the referenced delivery target. What happens next is -defined solely by used target. If deliver_to is used inside 'destination' -block, only matching recipients will be passed to the target. - -*Syntax*: source_in _table reference_ { ... } ++ -*Context*: pipeline configuration - -Handle messages with envelope senders present in the specified table in -accordance with the specified configuration block. - -Takes precedence over all 'sender' directives. - -Example: -``` -source_in file /etc/maddy/banned_addrs { - reject 550 5.7.0 "You are not welcome here" -} -source example.org { - ... -} -... -``` - -See 'destination_in' documentation for note about table configuration. - -*Syntax*: source _rules..._ { ... } ++ -*Context*: pipeline configuration - -Handle messages with MAIL FROM value (sender address) matching any of the rules -in accordance with the specified configuration block. - -"Rule" is either a domain or a complete address. In case of overlapping -'rules', first one takes priority. Matching is case-insensitive. - -Example: -``` -# All messages coming from example.org domain will be delivered -# to local_mailboxes. -source example.org { - deliver_to &local_mailboxes -} -# Messages coming from different domains will be rejected. -default_source { - reject 521 5.0.0 "You were not invited" -} -``` - -*Syntax*: reroute { ... } ++ -*Context*: pipeline configuration, source block, destination block - -This directive allows to make message routing decisions based on the -result of modifiers. The block can contain all pipeline directives and they -will be handled the same with the exception that source and destination rules -will use the final recipient and sender values (e.g. after all modifiers are -applied). - -Here is the concrete example how it can be useful: -``` -destination example.org { - modify { - replace_rcpt file /etc/maddy/aliases - } - reroute { - destination example.org { - deliver_to &local_mailboxes - } - default_destination { - deliver_to &remote_queue - } - } -} -``` - -This configuration allows to specify alias local addresses to remote ones -without being an open relay, since remote_queue can be used only if remote -address was introduced as a result of rewrite of local address. - -*WARNING*: If you have DMARC enabled (default), results generated by SPF -and DKIM checks inside a reroute block *will not* be considered in DMARC -evaluation. - -*Syntax*: destination_in _table reference_ { ... } ++ -*Context*: pipeline configuration, source block - -Handle messages with envelope recipients present in the specified table in -accordance with the specified configuration block. - -Takes precedence over all 'destination' directives. - -Example: -``` -destination_in file /etc/maddy/remote_addrs { - deliver_to smtp tcp://10.0.0.7:25 -} -destination example.com { - deliver_to &local_mailboxes -} -... -``` - -Note that due to the syntax restrictions, it is not possible to specify -extended configuration for table module. E.g. this is not valid: -``` -destination_in sql_table { - dsn ... - driver ... -} { - deliver_to whatever -} -``` - -In this case, configuration should be specified separately and be referneced -using '&' syntax: -``` -table.sql_table remote_addrs { - dsn ... - driver ... -} - -whatever { - destination_in &remote_addrs { - deliver_to whatever - } -} -``` - -*Syntax*: destination _rule..._ { ... } ++ -*Context*: pipeline configuration, source block - -Handle messages with RCPT TO value (recipient address) matching any of the -rules in accordance with the specified configuration block. - -"Rule" is either a domain or a complete address. Duplicate rules are not -allowed. Matching is case-insensitive. - -Note that messages with multiple recipients are split into multiple messages if -they have recipients matched by multiple blocks. Each block will see the -message only with recipients matched by its rules. - -Example: -``` -# Messages with recipients at example.com domain will be -# delivered to local_mailboxes target. -destination example.com { - deliver_to &local_mailboxes -} - -# Messages with other recipients will be rejected. -default_destination { - rejected 541 5.0.0 "User not local" -} -``` - -## Reusable pipeline parts (msgpipeline module) - -The message pipeline can be used independently of the SMTP module in other -contexts that require a delivery target. - -Full pipeline functionality can be used where a delivery target is expected. diff --git a/docs/man/maddy-storage.5.scd b/docs/man/maddy-storage.5.scd deleted file mode 100644 index c22f9cf35..000000000 --- a/docs/man/maddy-storage.5.scd +++ /dev/null @@ -1,201 +0,0 @@ -maddy-targets(5) "maddy mail server" "maddy reference documentation" - -; TITLE Storage backends - -maddy storage interface is built with IMAP in mind and directly represents -IMAP data model. That is, maddy storage does have the concept of folders, -flags, message UIDs, etc defined as in RFC 3501. - -This man page lists supported storage backends along with supported -configuration directives for each. - -Most likely, you are going to use modules listed here in 'storage' directive -for IMAP endpoint module (see *maddy-imap*(5)). - -In most cases, local storage modules will auto-create accounts when they are -accessed via IMAP. This relies on authentication provider used by IMAP endpoint -to provide what essentially is access control. There is a caveat, however: this -auto-creation will not happen when delivering incoming messages via SMTP as -there is no authentication to confirm that this account should indeed be -created. - -# SQL-based database module (storage.imapsql) - -The imapsql module implements database for IMAP index and message -metadata using SQL-based relational database. - -Message contents are stored in an "external store" defined by msg_store -directive. By default this is a file system directory under /var/lib/maddy. - -Supported RDBMS: -- SQLite 3.25.0 -- PostgreSQL 9.6 or newer - -Account names are required to have the form of a email address and are -case-insensitive. UTF-8 names are supported with restrictions defined in the -PRECIS UsernameCaseMapped profile. - -``` -storage.imapsql { - driver sqlite3 - dsn imapsql.db - msg_store fs messages/ -} -``` - -imapsql module also can be used as a lookup table (*maddy-table*(5)). -It returns empty string values for existing usernames. This might be useful -with destination_in directive (*maddy-smtp*(5)) e.g. to implement catch-all -addresses (this is a bad idea to do so, this is just an example): -``` -destination_in &local_mailboxes { - deliver_to &local_mailboxes -} -destination example.org { - modify { - replace_rcpt regexp ".*" "catchall@example.org" - } - deliver_to &local_mailboxes -} -``` - - -## Arguments - -Specify the driver and DSN. - -## Configuration directives - -*Syntax*: driver _string_ ++ -*Default*: not specified - -REQUIRED. - -Use a specified driver to communicate with the database. Supported values: -sqlite3, postgres. - -Should be specified either via an argument or via this directive. - -*Syntax*: dsn _string_ ++ -*Default*: not specified - -REQUIRED. - -Data Source Name, the driver-specific value that specifies the database to use. - -For SQLite3 this is just a file path. -For PostgreSQL: https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters - -Should be specified either via an argument or via this directive. - -*Syntax*: msg_store _store_ ++ -*Default*: fs messages/ - -Module to use for message bodies storage. - -See *maddy-blob*(5) for details. - -*Syntax*: ++ - compression off ++ - compression _algorithm_ ++ - compression _algorithm_ _level_ ++ -*Default*: off - -Apply compression to message contents. -Supported algorithms: lz4, zstd. - -*Syntax*: appendlimit _size_ ++ -*Default*: 32M - -Don't allow users to add new messages larger than 'size'. - -This does not affect messages added when using module as a delivery target. -Use 'max_message_size' directive in SMTP endpoint module to restrict it too. - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: junk_mailbox _name_ ++ -*Default*: Junk - -The folder to put quarantined messages in. Thishis setting is not used if user -does have a folder with "Junk" special-use attribute. - -*Syntax*: sqlite_exclusive_lock _boolean_ ++ -*Default*: no - -SQLite-specific performance tuning option. Slightly decereases ovehead of -DB locking at cost of making DB inaccessible for other processes (including -maddyctl utility). - -*Syntax*: sqlite_cache_size _integer_ ++ -*Default*: defined by SQLite - -SQLite page cache size. If positive - specifies amount of pages (1 page - 4 -KiB) to keep in cache. If negative - specifies approximate upper bound -of cache size in KiB. - -*Syntax*: sqlite_busy_timeout _integer_ ++ -*Default*: 5000000 - -SQLite-specific performance tuning option. Amount of milliseconds to wait -before giving up on DB lock. - -*Syntax*: imap_filter { ... } ++ -*Default*: not set - -Specifies IMAP filters to apply for messages delivered from SMTP pipeline. - -See *maddy-imap*(5) for filter modules usable here. - -Ex. -``` -imap_filter { - command /etc/maddy/sieve.sh {account_name} -} -``` - -*Syntax:* delivery_map *table* ++ -*Default:* identity - -Use specified table module (*maddy-tables*(5)) to map recipient -addresses from incoming messages to mailbox names. - -Normalization algorithm specified in delivery_normalize is appied before -delivery_map. - -*Syntax:* delivery_normalize _name_ ++ -*Default:* precis_casefold_email - -Normalization function to apply to email addresses before mapping them -to mailboxes. - -See auth_normalize. - -*Syntax*: auth_map *table* ++ -*Default*: identity - -Use specified table module (*maddy-tables*(5)) to map authentication -usernames to mailbox names. - -Normalization algorithm specified in auth_normalize is applied before -auth_map. - -*Syntax*: auth_normalize _name_ ++ -*Default*: precis_casefold_email - -Normalization function to apply to authentication usernames before mapping -them to mailboxes. - -Available options: -- precis_casefold_email PRECIS UsernameCaseMapped profile + U-labels form for domain -- precis_casefold PRECIS UsernameCaseMapped profile for the entire string -- precis_email PRECIS UsernameCasePreserved profile + U-labels form for domain -- precis PRECIS UsernameCasePreserved profile for the entire string -- casefold Convert to lower case -- noop Nothing - -Note: On message delivery, recipient address is unconditionally normalized -using precis_casefold_email function. diff --git a/docs/man/maddy-tables.5.scd b/docs/man/maddy-tables.5.scd deleted file mode 100644 index 19a27e0e0..000000000 --- a/docs/man/maddy-tables.5.scd +++ /dev/null @@ -1,314 +0,0 @@ -maddy-tables(5) "maddy mail server" "maddy reference documentation" - -; TITLE String-string translation - -Whenever you need to replace one string with another when handling anything in -maddy, you can use any of the following modules to obtain the replacement -string. They are commonly called "table modules" or just "tables". - -Some table modules implement write options allowing other maddy modules to -change the source of data, effectively turning the table into a complete -interface to a key-value store for maddy. Such tables are referred to as -"mutable tables". - -# File mapping (table.file) - -This module builds string-string mapping from a text file. - -File is reloaded every 15 seconds if there are any changes (detected using -modification time). No changes are applied if file contains syntax errors. - -Definition: -``` -file -``` -or -``` -file { - file -} -``` - -Usage example: -``` -# Resolve SMTP address aliases using text file mapping. -modify { - replace_rcpt file /etc/maddy/aliases -} -``` - -## Syntax - -Better demonstrated by examples: - -``` -# Lines starting with # are ignored. - -# And so are lines only with whitespace. - -# Whenever 'aaa' is looked up, return 'bbb' -aaa: bbb - - # Trailing and leading whitespace is ignored. - ccc: ddd - -# If there is no colon, the string is translated into "" -# That is, the following line is equivalent to -# aaa: -aaa - -# If the same key is used multiple times - table.file will return -# multiple values when queries. Note that this is not used by -# most modules. E.g. replace_rcpt does not (intentionally) support -# 1-to-N alias expansion. -ddd: firstvalue -ddd: secondvalue -``` - -# SQL query mapping (table.sql_query) - -The sql_query module implements table interface using SQL queries. - -Definition: -``` -table.sql_query { - driver - dsn - lookup - - # Optional: - init - list - add - del - set -} -``` - -Usage example: -``` -# Resolve SMTP address aliases using PostgreSQL DB. -modify { - replace_rcpt sql_query { - driver postgres - dsn "dbname=maddy user=maddy" - lookup "SELECT alias FROM aliases WHERE address = 1ドル" - } -} -``` - -## Configuration directives - -**Syntax**: driver _driver name_ ++ -**REQUIRED** - -Driver to use to access the database. - -Supported drivers: postgres, sqlite3 (if compiled with C support) - -**Syntax**: dsn _data source name_ ++ -**REQUIRED** - -Data Source Name to pass to the driver. For SQLite3 this is just a path to DB -file. For Postgres, see -https://pkg.go.dev/github.com/lib/pq?tab=doc#hdr-Connection_String_Parameters - -**Syntax**: lookup _query_ ++ -**REQUIRED** - -SQL query to use to obtain the lookup result. - -It will get one named argument containing the lookup key. Use :key -placeholder to access it in SQL. The result row set should contain one row, one -column with the string that will be used as a lookup result. If there are more -rows, they will be ignored. If there are more columns, lookup will fail. If -there are no rows, lookup returns "no results". If there are any error - lookup -will fail. - -**Syntax**: init _queries..._ ++ -**Default**: empty - -List of queries to execute on initialization. Can be used to configure RDBMS. - -Example, to improve SQLite3 performance: -``` -table.sql_query { - driver sqlite3 - dsn whatever.db - init "PRAGMA journal_mode=WAL" \ - "PRAGMA synchronous=NORMAL" - lookup "SELECT alias FROM aliases WHERE address = 1ドル" -} -``` - -*Syntax:* named_args _boolean_ ++ -*Default:* yes - -Whether to use named parameters binding when executing SQL queries -or not. - -Note that maddy's PostgreSQL driver does not support named parameters and -SQLite3 driver has issues handling numbered parameters: -https://github.com/mattn/go-sqlite3/issues/472 - -**Syntax:** add _query_ ++ -**Syntax:** list _query_ ++ -**Syntax:** set _query_ ++ -**Syntax:** del _query_ ++ -**Default:** none - -If queries are set to implement corresponding table operations - table becomes -"mutable" and can be used in contexts that require writable key-value store. - -'add' query gets :key, :value named arguments - key and value strings to store. -They should be added to the store. The query *should* not add multiple values -for the same key and *should* fail if the key already exists. - -'list' query gets no arguments and should return a column with all keys in -the store. - -'set' query gets :key, :value named arguments - key and value and should replace the existing -entry in the database. - -'del' query gets :key argument - key and should remove it from the database. - -If named_args is set to "no" - key is passed as the first numbered parameter -(1ドル), value is passed as the second numbered parameter (2ドル). - -# Static table (table.static) - -The 'static' module implements table lookups using key-value pairs in its -configuration. - -``` -table.static { - entry KEY1 VALUE1 - entry KEY2 VALUE2 - ... -} -``` - -## Configuration directives - -**Syntax**: entry _key_ _value_ - -Add an entry to the table. - -If the same key is used multiple times, the last one takes effect. - -# Regexp rewrite table (table.regexp) - -The 'regexp' module implements table lookups by applying a regular expression -to the key value. If it matches - 'replacement' value is returned with $N -placeholders being replaced with corresponding capture groups from the match. -Otherwise, no value is returned. - -The regular expression syntax is the subset of PCRE. See -https://golang.org/pkg/regexp/syntax/ for details. - -``` -table.regexp [replacement] { - full_match yes - case_insensitive yes - expand_placeholders yes -} -``` - -Note that [replacement] is optional. If it is not included - table.regexp -will return the original string, therefore acting as a regexp match check. -This can be useful in combination in destination_in (*maddy-smtp*(5)) for -advanced matching: -``` -destination_in regexp ".*-bounce+.*@example.com" { - ... -} -``` - -## Configuration directives - -**Syntax**: full_match _boolean_ ++ -**Default**: yes - -Whether to implicitly add start/end anchors to the regular expression. -That is, if 'full_match' is yes, then the provided regular expression should -match the whole string. With no - partial match is enough. - -**Syntax**: case_insensitive _boolean_ ++ -**Default**: yes - -Whether to make matching case-insensitive. - -**Syntax**: expand_placeholders _boolean_ ++ -**Default**: yes - -Replace '$name' and '${name}' in the replacement string with contents of -corresponding capture groups from the match. - -To insert a literal $ in the output, use $$ in the template. - -# Identity table (table.identity) - -The module 'identity' is a table module that just returns the key looked up. - -``` -table.identity { } -``` - -# No-op table (dummy) - -The module 'dummy' represents an empty table. - -``` -dummy { } -``` - -# Email local part (table.email_localpart) - -The module 'email_localpart' extracts and unescaped local ("username") part -of the email address. - -E.g. -test@example.org => test -"test @ a"@example.org => test @ a - -``` -table.email_localpart { } -``` - -# Table chaining module (table.chain) - -The table.chain module allows chaining together multiple table modules -by using value returned by a previous table as an input for the second -table. - -Example: -``` -table.chain { - step regexp "(.+)(\\+[^+"@]+)?@example.org" "1ドル@example.org" - step file /etc/maddy/emails -} -``` -This will strip +prefix from mailbox before looking it up -in /etc/maddy/emails list. - -## Configuration directives - -*Syntax*: step _table_ - -Adds a table module to the chain. If input value is not in the table -(e.g. file) - return "not exists" error. - -*Syntax*: optional_step _table_ - -Same as step but if input value is not in the table - it is passed to the -next step without changes. - -Example: -Something like this can be used to map emails to usernames -after translating them via aliases map: -``` -table.chain { - optional_step file /etc/maddy/aliases - step regexp "(.+)@(.+)" "1ドル" -} -``` diff --git a/docs/man/maddy-targets.5.scd b/docs/man/maddy-targets.5.scd deleted file mode 100644 index eddcc0cca..000000000 --- a/docs/man/maddy-targets.5.scd +++ /dev/null @@ -1,468 +0,0 @@ -maddy-targets(5) "maddy mail server" "maddy reference documentation" - -; TITLE Delivery targets - -This man page describes modules that can used with 'deliver_to' directive -of SMTP endpoint module. - -# SQL module (target.imapsql) - -SQL module described in *maddy-storage*(5) can also be used as a delivery -target. - -# Queue module (target.queue) - -Queue module buffers messages on disk and retries delivery multiple times to -another target to ensure reliable delivery. - -``` -target.queue { - target remote - location ... - max_parallelism 16 - max_tries 4 - bounce { - destination example.org { - deliver_to &local_mailboxes - } - default_destination { - reject - } - } - - autogenerated_msg_domain example.org - debug no -} -``` - -## Arguments - -First argument specifies directory to use for storage. -Relative paths are relative to the StateDirectory. - -## Configuration directives - -*Syntax*: target _block_name_ ++ -*Default*: not specified - -REQUIRED. - -Delivery target to use for final delivery. - -*Syntax*: location _directory_ ++ -*Default*: StateDirectory/configuration_block_name - -File system directory to use to store queued messages. -Relative paths are relative to the StateDirectory. - -*Syntax*: max_parallelism _integer_ ++ -*Default*: 16 - -Start up to _integer_ goroutines for message processing. Basically, this option -limits amount of messages tried to be delivered concurrently. - -*Syntax*: max_tries _integer_ ++ -*Default*: 20 - -Attempt delivery up to _integer_ times. Note that no more attempts will be done -is permanent error occured during previous attempt. - -Delay before the next attempt will be increased exponentally using the -following formula: 15mins \* 1.2 ^ (n - 1) where n is the attempt number. -This gives you approximately the following sequence of delays: -18mins, 21mins, 25mins, 31mins, 37mins, 44mins, 53mins, 64mins, ... - -*Syntax*: bounce { ... } ++ -*Default*: not specified - -This configuration contains pipeline configuration to be used for generated DSN -(Delivery Status Notifiaction) messages. - -If this is block is not present in configuration, DSNs will not be generated. -Note, however, this is not what you want most of the time. - -*Syntax*: autogenerated_msg_domain _domain_ ++ -*Default*: global directive value - -Domain to use in sender address for DSNs. Should be specified too if 'bounce' -block is specified. - -*Syntax*: debug _boolean_ ++ -*Default*: no - -Enable verbose logging. - -# Remote MX module (remote) - -Module that implements message delivery to remote MTAs discovered via DNS MX -records. You probably want to use it with queue module for reliability. - -``` -target.remote { - hostname mx.example.org - debug no -} -``` - -If a message check marks a message as 'quarantined', remote module -will refuse to deliver it. - -## Configuration directives - -*Syntax*: hostname _domain_ ++ -*Default*: global directive value - -Hostname to use client greeting (EHLO/HELO command). Some servers require it to -be FQDN, SPF-capable servers check whether it corresponds to the server IP -address, so it is better to set it to a domain that resolves to the server IP. - -*Syntax*: limits _config block_ ++ -*Default*: no limits - -See 'limits' directive in *maddy-smtp*(5) for SMTP endpoint. -It works the same except for address domains used for -per-source/per-destination are as observed when message exits the server. - -*Syntax*: local_ip _IP address_ ++ -*Default*: empty - -Choose the local IP to bind for outbound SMTP connections. - -*Syntax*: force_ipv4 _boolean_ ++ -*Default*: false - -Force resolving outbound SMTP domains to IPv4 addresses. Some server providers -do not offer a way to properly set reverse PTR domains for IPv6 addresses; this -option makes maddy only connect to IPv4 addresses so that its public IPv4 address -is used to connect to that server, and thus reverse PTR checks are made against -its IPv4 address. - -Warning: this may break sending outgoing mail to IPv6-only SMTP servers. - -*Syntax*: connect_timeout _duration_ ++ -*Default*: 5m - -Timeout for TCP connection establishment. - -RFC 5321 recommends 5 minutes for "initial greeting" that includes TCP -handshake. maddy uses two separate timers - one for "dialing" (DNS A/AAAA -lookup + TCP handshake) and another for "initial greeting". This directive -configures the former. The latter is not configurable and is hardcoded to be -5 minutes. - -*Syntax*: command_timeout _duration_ ++ -*Default*: 5m - -Timeout for any SMTP command (EHLO, MAIL, RCPT, DATA, etc). - -If STARTTLS is used this timeout also applies to TLS handshake. - -RFC 5321 recommends 5 minutes for MAIL/RCPT and 3 minutes for -DATA. - -*Syntax*: submission_timeout _duration_ ++ -*Default*: 12m - -Time to wait after the entire message is sent (after "final dot"). - -RFC 5321 recommends 10 minutes. - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: requiretls_override _boolean_ ++ -*Default*: true - -Allow local security policy to be disabled using 'TLS-Required' header field in -sent messages. Note that the field has no effect if transparent forwarding is -used, message body should be processed before outbound delivery starts for it -to take effect (e.g. message should be queued using 'queue' module). - -*Syntax*: relaxed_requiretls _boolean_ ++ -*Default*: true - -This option disables strict conformance with REQUIRETLS specification and -allows forwarding of messages 'tagged' with REQUIRETLS to MXes that are not -advertising REQUIRETLS support. It is meant to allow REQUIRETLS use without the -need to have support from all servers. It is based on the assumption that -server referenced by MX record is likely the final destination and therefore -there is only need to secure communication towards it and not beyond. - -*Syntax*: conn_reuse_limit _integer_ ++ -*Default*: 10 - -Amount of times the same SMTP connection can be used. -Connections are never reused if the previous DATA command failed. - -*Syntax*: conn_max_idle_count _integer_ ++ -*Default*: 10 - -Max. amount of idle connections per recipient domains to keep in cache. - -*Syntax*: conn_max_idle_time _integer_ ++ -*Default*: 150 (2.5 min) - -Amount of time the idle connection is still considered potentially usable. - -## Security policies - -*Syntax*: mx_auth _config block_ ++ -*Default*: no policies - -'remote' module implements a number of of schemes and protocols necessary to -ensure security of message delivery. Most of these schemes are concerned with -authentication of recipient server and TLS enforcement. - -To enable mechanism, specify its name in the mx_auth directive block: -``` -mx_auth { - dane - mtasts -} -``` -Additional configuration is possible if supported by the mechanism by -specifying additional options as a block for the corresponding mechanism. -E.g. -``` -mtasts { - cache ram -} -``` - -If the mx_auth directive is not specified, no mechanisms are enabled. Note -that, however, this makes outbound SMTP vulnerable to a numberous downgrade -attacks and hence not recommended. - -It is possible to share the same set of policies for multiple 'remote' module -instances by defining it at the top-level using 'mx_auth' module and then -referencing it using standard & syntax: -``` -mx_auth outbound_policy { - dane - mtasts { - cache ram - } -} - -# ... somewhere else ... - -deliver_to remote { - mx_auth &outbound_policy -} - -# ... somewhere else ... - -deliver_to remote { - mx_auth &outbound_policy - tls_client { ... } -} -``` - -## Security policies: MTA-STS - -Checks MTA-STS policy of the recipient domain. Provides proper authentication -and TLS enforcement for delivery, but partially vulnerable to persistent active -attacks. - -Sets MX level to "mtasts" if the used MX matches MTA-STS policy even if it is -not set to "enforce" mode. - -``` -mtasts { - cache fs - fs_dir StateDirectory/mtasts_cache -} -``` - -*Syntax*: cache fs|ram ++ -*Default*: fs - -Storage to use for MTA-STS cache. 'fs' is to use a filesystem directory, 'ram' -to store the cache in memory. - -It is recommended to use 'fs' since that will not discard the cache (and thus -cause MTA-STS security to disappear) on server restart. However, using the RAM -cache can make sense for high-load configurations with good uptime. - -*Syntax*: fs_dir _directory_ ++ -*Default*: StateDirectory/mtasts_cache - -Filesystem directory to use for policies caching if 'cache' is set to 'fs'. - -## Security policies: DNSSEC - -Checks whether MX records are signed. Sets MX level to "dnssec" is they are. - -maddy does not validate DNSSEC signatures on its own. Instead it reslies on -the upstream resolver to do so by causing lookup to fail when verification -fails and setting the AD flag for signed and verfified zones. As a safety -measure, if the resolver is not 127.0.0.1 or ::1, the AD flag is ignored. - -DNSSEC is currently not supported on Windows and other platforms that do not -have the /etc/resolv.conf file in the standard format. - -``` -dnssec { } -``` - -## Security policies: DANE - -Checks TLSA records for the recipient MX. Provides downgrade-resistant TLS -enforcement. - -Sets TLS level to "authenticated" if a valid and matching TLSA record uses -DANE-EE or DANE-TA usage type. - -See above for notes on DNSSEC. DNSSEC support is required for DANE to work. - -``` -dane { } -``` - -## Security policies: Local policy - -Checks effective TLS and MX levels (as set by other policies) against local -configuration. - -``` -local_policy { - min_tls_level none - min_mx_level none -} -``` - -Using 'local_policy off' is equivalent to setting both directives to 'none'. - -*Syntax*: min_tls_level none|encrypted|authenticated ++ -*Default*: none - -Set the minimal TLS security level required for all outbound messages. - -See [Security levels](../../seclevels) page for details. - -*Syntax*: min_mx_level: none|mtasts|dnssec ++ -*Default*: none - -Set the minimal MX security level required for all outbound messages. - -See [Security levels](../../seclevels) page for details. - -# SMTP transparent forwarding module (target.smtp) - -Module that implements transparent forwarding of messages over SMTP. - -Use in pipeline configuration: -``` -deliver_to smtp tcp://127.0.0.1:5353 -# or -deliver_to smtp tcp://127.0.0.1:5353 { - # Other settings, see below. -} -``` - -``` -target.smtp { - debug no - tls_client { - ... - } - attempt_starttls yes - require_tls no - auth off - targets tcp://127.0.0.1:2525 - connect_timeout 5m - command_timeout 5m - submission_timeout 12m -} -``` - -Endpoint addresses use format described in *maddy-config*(5). - -## Configuration directives - -*Syntax*: debug _boolean_ ++ -*Default*: global directive value - -Enable verbose logging. - -*Syntax*: tls_client { ... } ++ -*Default*: not specified - -Advanced TLS client configuration options. See *maddy-tls*(5) for details. - -*Syntax*: attempt_starttls _boolean_ ++ -*Default*: yes (no for target.lmtp) - -Attempt to use STARTTLS if it is supported by the remote server. -If TLS handshake fails, connection will be retried without STARTTLS -unless 'require_tls' is also specified. - -*Syntax*: require_tls _boolean_ ++ -*Default*: no - -Refuse to pass messages over plain-text connections. - -*Syntax*: ++ - auth off ++ - plain _username_ _password_ ++ - forward ++ - external ++ -*Default*: off - -Specify the way to authenticate to the remote server. -Valid values: - -- off - - No authentication. - -- plain - - Authenticate using specified username-password pair. - *Don't use* this without enforced TLS ('require_tls'). - -- forward - - Forward credentials specified by the client. - *Don't use* this without enforced TLS ('require_tls'). - -- external - - Request "external" SASL authentication. This is usually used for - authentication using TLS client certificates. See *maddy-tls*(5) - for how to specify the client certificate. - -*Syntax*: targets _endpoints..._ ++ -*Default:* not specified - -REQUIRED. - -List of remote server addresses to use. See Address definitions in -*maddy-config*(5) for syntax to use. Basically, it is 'tcp://ADDRESS:PORT' -for plain SMTP and 'tls://ADDRESS:PORT' for SMTPS (aka SMTP with Implicit -TLS). - -Multiple addresses can be specified, they will be tried in order until connection to -one succeeds (including TLS handshake if TLS is required). - -*Syntax*: connect_timeout _duration_ ++ -*Default*: 5m - -Same as for target.remote. - -*Syntax*: command_timeout _duration_ ++ -*Default*: 5m - -Same as for target.remote. - -*Syntax*: submission_timeout _duration_ ++ -*Default*: 12m - -Same as for target.remote. - -# LMTP transparent forwarding module (target.lmtp) - -The 'target.lmtp' module is similar to 'target.smtp' and supports all -its options and syntax but speaks LMTP instead of SMTP. diff --git a/docs/man/maddy-tls.5.scd b/docs/man/maddy-tls.5.scd deleted file mode 100644 index 3307820f7..000000000 --- a/docs/man/maddy-tls.5.scd +++ /dev/null @@ -1,379 +0,0 @@ -maddy-tls(5) "maddy mail server" "maddy reference documentation" - -; TITLE Advanced TLS configuration - -# TLS server configuration - -TLS certificates are obtained by modules called "certificate loaders". 'tls' directive -arguments specify name of loader to use and arguments. Due to syntax limitations -advanced configuration for loader should be specified using 'loader' directive, see -below. - -``` -tls file cert.pem key.pem { - protocols tls1.2 tls1.3 - curve X25519 - ciphers ... -} - -tls { - loader file cert.pem key.pem { - # Options for loader go here. - } - protocols tls1.2 tls1.3 - curve X25519 - ciphers ... -} -``` - -## Available certificate loaders - -- file - - Accepts argument pairs specifying certificate and then key. - E.g. 'tls file certA.pem keyA.pem certB.pem keyB.pem' - - If multiple certificates are listed, SNI will be used. - -- acme - - Automatically obtains a certificate using ACME protocol (Let's Encrypt) - - See below for details. - -- off - - Not really a loader but a special value for tls directive, explicitly disables TLS for - endpoint(s). - -## Advanced TLS configuration - -*Note: maddy uses secure defaults and TLS handshake is resistant to active downgrade attacks.* -*There is no need to change anything in most cases.* - -*Syntax*: ++ - protocols _min_version_ _max_version_ ++ - protocols _version_ ++ -*Default*: tls1.0 tls1.3 - -Minimum/maximum accepted TLS version. If only one value is specified, it will -be the only one usable version. - -Valid values are: tls1.0, tls1.1, tls1.2, tls1.3 - -*Syntax*: ciphers _ciphers..._ ++ -*Default*: Go version-defined set of 'secure ciphers', ordered by hardware -performance - -List of supported cipher suites, in preference order. Not used with TLS 1.3. - -Valid values: - -- RSA-WITH-RC4128-SHA -- RSA-WITH-3DES-EDE-CBC-SHA -- RSA-WITH-AES128-CBC-SHA -- RSA-WITH-AES256-CBC-SHA -- RSA-WITH-AES128-CBC-SHA256 -- RSA-WITH-AES128-GCM-SHA256 -- RSA-WITH-AES256-GCM-SHA384 -- ECDHE-ECDSA-WITH-RC4128-SHA -- ECDHE-ECDSA-WITH-AES128-CBC-SHA -- ECDHE-ECDSA-WITH-AES256-CBC-SHA -- ECDHE-RSA-WITH-RC4128-SHA -- ECDHE-RSA-WITH-3DES-EDE-CBC-SHA -- ECDHE-RSA-WITH-AES128-CBC-SHA -- ECDHE-RSA-WITH-AES256-CBC-SHA -- ECDHE-ECDSA-WITH-AES128-CBC-SHA256 -- ECDHE-RSA-WITH-AES128-CBC-SHA256 -- ECDHE-RSA-WITH-AES128-GCM-SHA256 -- ECDHE-ECDSA-WITH-AES128-GCM-SHA256 -- ECDHE-RSA-WITH-AES256-GCM-SHA384 -- ECDHE-ECDSA-WITH-AES256-GCM-SHA384 -- ECDHE-RSA-WITH-CHACHA20-POLY1305 -- ECDHE-ECDSA-WITH-CHACHA20-POLY1305 - -*Syntax*: curve _curves..._ ++ -*Default*: defined by Go version - -The elliptic curves that will be used in an ECDHE handshake, in preference -order. - -Valid values: p256, p384, p521, X25519. - -# TLS client configuration - -tls_client directive allows to customize behavior of TLS client implementation, -notably adjusting minimal and maximal TLS versions and allowed cipher suites, -enabling TLS client authentication. - -``` -tls_client { - protocols tls1.2 tls1.3 - ciphers ... - curve X25519 - root_ca /etc/ssl/cert.pem - - cert /etc/ssl/private/maddy-client.pem - key /etc/ssl/private/maddy-client.pem -} -``` - -*Syntax*: ++ - protocols _min_version_ _max_version_ ++ - protocols _version_ ++ -*Default*: tls1.0 tls1.3 - -Minimum/maximum accepted TLS version. If only one value is specified, it will -be the only one usable version. - -Valid values are: tls1.0, tls1.1, tls1.2, tls1.3 - -*Syntax*: ciphers _ciphers..._ ++ -*Default*: Go version-defined set of 'secure ciphers', ordered by hardware -performance - -List of supported cipher suites, in preference order. Not used with TLS 1.3. - -See TLS server configuration for list of supported values. - -*Syntax*: curve _curves..._ ++ -*Default*: defined by Go version - -The elliptic curves that will be used in an ECDHE handshake, in preference -order. - -Valid values: p256, p384, p521, X25519. - -*Syntax*: root_ca _paths..._ ++ -*Default*: system CA pool - -List of files with PEM-encoded CA certificates to use when verifying -server certificates. - -*Syntax*: ++ - cert _cert_path_ ++ - key _key_path_ ++ -*Default*: not specified - -Present the specified certificate when server requests a client certificate. -Files should use PEM format. Both directives should be specified. - -# Automatic certificate management via ACME - -``` -tls.loader.acme { - debug off - hostname example.maddy.invalid - store_path /var/lib/maddy/acme - ca https://acme-v02.api.letsencrypt.org/directory - test_ca https://acme-staging-v02.api.letsencrypt.org/directory - email test@maddy.invalid - agreed off - challenge dns-01 - dns ... -} -``` - -Maddy supports obtaining certificates using ACME protocol. - -To use it, create a configuration name for tls.loader.acme -and reference it from endpoints that should use automatically -configured certificates: -``` -tls.loader.acme local_tls { - email put-your-email-here@example.org - agreed # indicate your agreement with Let's Encrypt ToS - challenge dns-01 -} - -smtp tcp://127.0.0.1:25 { - tls &local_tls - ... -} -``` - -Currently the only supported challenge is dns-01 one therefore -you also need to configure the DNS provider: -``` -tls.loader.acme local_tls { - email maddy-acme@example.org - agreed - challenge dns-01 - dns PROVIDER_NAME { - ... - } -} -``` -See below for supported providers and necessary configuration -for each. - -## Configuration directives - -*Syntax:* debug _boolean_ ++ -*Default:* global directive value - -Enable debug logging. - -*Syntax:* hostname _str_ ++ -*Default:* global directive value - -Domain name to issue certificate for. Required. - -*Syntax:* store_path _path_ ++ -*Default:* state_dir/acme - -Where to store issued certificates and associated metadata. -Currently only filesystem-based store is supported. - -*Syntax:* ca _url_ ++ -*Default:* Let's Encrypt production CA - -URL of ACME directory to use. - -*Syntax:* test_ca _url_ ++ -*Default:* Let's Encrypt staging CA - -URL of ACME directory to use for retries should -primary CA fail. - -maddy will keep attempting to issues certificates -using test_ca until it succeeds then it will switch -back to the one configured via 'ca' option. - -This avoids rate limit issues with production CA. - -*Syntax:* email _str_ ++ -*Default:* not set - -Email to pass while registering an ACME account. - -*Syntax:* agreed _boolean_ ++ -*Default:* false - -Whether you agreed to ToS of the CA service you are using. - -*Syntax:* challenge dns-01 ++ -*Default:* not set - -Challenge(s) to use while performing domain verification. - -## DNS providers - -Support for some providers is not provided by standard builds. -To be able to use these, you need to compile maddy -with "libdns_PROVIDER" build tag. -E.g. -``` -./build.sh -tags 'libdns_googleclouddns' -``` - -- gandi - -``` -dns gandi { - api_token "token" -} -``` - -- digitalocean - -``` -dns digitalocean { - api_token "..." -} -``` - -- cloudflare - -See https://github.com/libdns/cloudflare#authenticating - -``` -dns cloudflare { - api_token "..." -} -``` - -- vultr - -``` -dns vultr { - api_token "..." -} -``` - -- hetzner - -``` -dns hetzner { - api_token "..." -} -``` - -- namecheap - -``` -dns namecheap { - api_key "..." - api_username "..." - - # optional: API endpoint, production one is used if not set. - endpoint "https://api.namecheap.com/xml.response" - - # optional: your public IP, discovered using icanhazip.com if not set - client_ip 1.2.3.4 -} -``` - -- googleclouddns (non-default) - -``` -dns googleclouddns { - project "project_id" - service_account_json "path" -} -``` - -- route53 (non-default) - -``` -dns route53 { - secret_access_key "..." - access_key_id "..." - # or use environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY -} -``` - -- leaseweb (non-default) - -``` -dns leaseweb { - api_key "key" -} -``` - -- metaname (non-default) - -``` -dns metaname { - api_key "key" - account_ref "reference" -} -``` - -- alidns (non-default) - -``` -dns alidns { - key_id "..." - key_secret "..." -} -``` - -- namedotcom (non-default) - -``` -dns namedotcom { - user "..." - token "..." -} -``` diff --git a/docs/man/maddy.5.scd b/docs/man/maddy.5.scd deleted file mode 100644 index 06926b41e..000000000 --- a/docs/man/maddy.5.scd +++ /dev/null @@ -1,228 +0,0 @@ -maddy(1) "maddy mail server" "maddy reference documentation" - -; TITLE Introduction - -# Modules - -maddy is built of many small components called "modules". Each module does one -certain well-defined task. Modules can be connected to each other in arbitrary -ways to achieve wanted functionality. Default configuration file defines -set of modules that together implement typical email server stack. - -To specify the module that should be used by another module for something, look -for configuration directives with "module reference" argument. Then -put the module name as an argument for it. Optionally, if referenced module -needs that, put additional arguments after the name. You can also put a -configuration block with additional directives specifing the module -configuration. - -Here are some examples: - -``` -smtp ... { - # Deliver messages to the 'dummy' module with the default configuration. - deliver_to dummy - - # Deliver messages to the 'target.smtp' module with - # 'tcp://127.0.0.1:1125' argument as a configuration. - deliver_to smtp tcp://127.0.0.1:1125 - - # Deliver messages to the 'queue' module with the specified configuration. - deliver_to queue { - target ... - max_tries 10 - } -} -``` - -Additionally, module configuration can be placed in a separate named block -at the top-level and merely referenced by its name where it is needed. - -Here is the example: -``` -storage.imapsql local_mailboxes { - driver sqlite3 - dsn all.db -} - -smtp ... { - deliver_to &local_mailboxes -} -``` - -It is recommended to use this syntax for modules that are 'expensive' to -initialize such as storage backends and authentication providers. - -For top-level configuration block definition, syntax is as follows: -``` -namespace.module_name config_block_name... { - module_configuration -} -``` -If config_block_name is omitted, it will be the same as module_name. Multiple -names can be specified. All names must be unique. - -Note the "storage." prefix. The actual module name is this and includes -"namespace". It is a little cheating to make more concise names and can -be omitted when you reference the module where it is used since it can -be implied (e.g. putting module reference in "check{}" likely means you want -something with "check." prefix) - -Usual module arguments can't be specified when using this syntax, however, -modules usually provide explicit directives that allow to specify the needed -values. For example 'sql sqlite3 all.db' is equivalent to -``` -storage.imapsql { - driver sqlite3 - dsn all.db -} -``` - -# Reference documentation conventions - -## Syntax descriptions for directives - -Underlined values are placeholders and should be replaced by your values. -_boolean_ is either 'yes' or 'no' string. - -Ellipsis (_smth..._) means that multiple values can be specified - -Multiple values listed with '|' (pipe) separator mean that any of them -can be used. - -# Global directives - -These directives applied for all configuration blocks that don't override it. - -*Syntax*: state_dir _path_ ++ -*Default*: /var/lib/maddy - -The path to the state directory. This directory will be used to store all -persistent data and should be writable. - -*Syntax*: runtime_dir _path_ ++ -*Default*: /run/maddy - -The path to the runtime directory. Used for Unix sockets and other temporary -objects. Should be writable. - -*Syntax*: hostname _domain_ ++ -*Default*: not specified - -Internet hostname of this mail server. Typicall FQDN is used. It is recommended -to make sure domain specified here resolved to the public IP of the server. - -*Syntax*: autogenerated_msg_domain _domain_ ++ -*Default*: not specified - -Domain that is used in From field for auto-generated messages (such as Delivery -Status Notifications). - -*Syntax*: ++ - tls file _cert_file_ _pkey_file_ ++ - tls _module reference_ ++ - tls off ++ -*Default*: not specified - -Default TLS certificate to use for all endpoints. - -Must be present in either all endpoint modules configuration blocks or as -global directive. - -You can also specify other configuration options such as cipher suites and TLS -version. See maddy-tls(5) for details. maddy uses reasonable -cipher suites and TLS versions by default so you generally don't have to worry -about it. - -*Syntax*: tls_client { ... } ++ -*Default*: not specified - -This is optional block that specifies various TLS-related options to use when -making outbound connections. See TLS client configuration for details on -directives that can be used in it. maddy uses reasonable cipher suites and TLS -versions by default so you generally don't have to worry about it. - -*Syntax*: ++ - log _targets..._ ++ - log off ++ -*Default*: stderr - -Write log to one of more "targets". - -The target can be one or the following: - -- stderr - - Write logs to stderr. - -- stderr_ts - - Write logs to stderr with timestamps. - -- syslog - - Send logs to the local syslog daemon. - -- _file path_ - - Write (append) logs to file. - -Example: -``` -log syslog /var/log/maddy.log -``` - -*Note:* Maddy does not perform log files rotation, this is the job of the -logrotate daemon. Send SIGUSR1 to maddy process to make it reopen log files. - -*Syntax*: debug _boolean_ ++ -*Default*: no - -Enable verbose logging for all modules. You don't need that unless you are -reporting a bug. - -# Prometheus/OpenMetrics endpoint - -``` -openmetrics tcp://127.0.0.1:9749 { } -``` - -This will enable HTTP listener that will serve telemetry in OpenMetrics format. -(It is compatible with Prometheus). - -See openmetrics.md documentation page the list of metrics exposed. - -# Signals - -*SIGTERM, SIGINT, SIGHUP* - -Stop the server process gracefully. Send the signal second time to force -immediate shutdown (likely unclean). - -*SIGUSR1* - -Reopen log files, if any are used. - -*SIGUSR2* - -Reload some files from disk, including alias mappings and TLS certificates. -This does not include the main configuration, though. - -# Authors - -Maintained by Max Mazurov . Project includes contributions -made by other people. - -Source code is available at https://github.com/foxcpp/maddy. - -# See also - -*maddy-config*(5) - Detailed configuration syntax description ++ -*maddy-imap*(5) - IMAP endpoint module reference ++ -*maddy-smtp*(5) - SMTP & Submission endpoint module reference ++ -*maddy-targets*(5) - Delivery targets reference ++ -*maddy-storage*(5) - Storage modules reference ++ -*maddy-auth*(5) - Authentication modules reference ++ -*maddy-filters*(5) - Message filtering modules reference ++ -*maddy-tables*(5) - Table modules reference ++ -*maddy-tls*(5) - Advanced TLS client & server configuration diff --git a/docs/multiple-domains.md b/docs/multiple-domains.md index a7425334a..46fabf025 100644 --- a/docs/multiple-domains.md +++ b/docs/multiple-domains.md @@ -1,56 +1,157 @@ # Multiple domains configuration -## Separate account namespaces +By default, maddy uses email addresses as account identifiers for both +authentication and storage purposes. Therefore, account named `user@example.org` +is completely independent from `user@example.com`. They must be created +separately, may have different credentials and have separate IMAP mailboxes. -Given two domains, example.org and example.com. foo@example.org and -foo@example.com are different and completely independent accounts. +This makes it extremely easy to setup maddy to manage multiple otherwise +independent domains. -All changes needed to make it work is to make sure all domains are specified in -the `$(local_domains)` macro in the main configuration file. Note that you need -to pick one domain as a "primary" for use in auto-generated messages. +Default configuration file contains two macros - `$(primary_domain)` and +`$(local_domains)`. They are used to used in several places thorough the +file to configure message routing, security checks, etc. + +In general, you should just add all domains you want maddy to manage to +`$(local_domains)`, like this: ``` $(primary_domain) = example.org $(local_domains) = $(primary_domain) example.com ``` +Note that you need to pick one domain as a "primary" for use in +auto-generated messages. + +With that done, you can create accounts using both domains in the name, send +and receive messages and so on. Do not forget to configure corresponding SPF, +DMARC and MTA-STS records as was recommended in +the [introduction tutorial](tutorials/setting-up.md). -The base configuration is done. You can create accounts using maddyctl using -both domains in the name, send and receive messages and so on. Do not forget -to configure corresponding SPF, DMARC and MTA-STS records as was -recommended in the [introduction tutorial](tutorials/setting-up.md). +Also note that you do not really need a separate TLS certificate for each +managed domain. You can have one hostname e.g. mail.example.org set as an MX +record for multiple domains. -## Single account namespace +**If you want multiple domains to share username namespace**, you should change +several more options. -You can configure maddy to only use local part of the email -as an account identifier instead of the complete email. +You can make "user@example.org" and "user@example.com" users share the same +credentials of user "user" but have different IMAP mailboxes ("user@example.org" +and "user@example.com" correspondingly). For that, it is enough to set `auth_map` +globally to use `email_localpart` table: +``` +auth_map email_localpart +``` +This way, when user logs in as "user@example.org", "user" will be passed +to the authentication provider, but "user@example.org" will be passed to the +storage backend. You should create accounts like this: +``` +maddy creds create user +maddy imap-acct create user@example.org +maddy imap-acct create user@example.com +``` -This needs two changes to default configuration: -``` +**If you want accounts to also share the same IMAP storage of account named +"user"**, you can set `storage_map` in IMAP endpoint and `delivery_map` in +storage backend to use `email_locapart`: +``` storage.imapsql local_mailboxes { - ... - delivery_map email_localpart - auth_normalize precis_casefold + ... + delivery_map email_localpart # deliver "user@*" to "user" +} +imap tls://0.0.0.0:993 { + ... + storage &local_mailboxes + ... + storage_map email_localpart # "user@*" accesses "user" mailbox } ``` +You also might want to make it possible to log in without +specifying a domain at all. In this case, use `email_localpart_optional` for +both `auth_map` and `storage_map`. + You also need to make `authorize_sender` check (used in `submission` endpoint) accept non-email usernames: ``` authorize_sender { ... - auth_normalize precis_casefold - user_to_email regexp "(.*)" "1ドル@$(primary_domain)" + user_to_email chain { + step email_localpart_optional # remove domain from username if present + step email_with_domain $(local_domains) # expand username with all allowed domains + } } ``` -Note that is would work only if clients use only one domain as sender (`$(primary_domain)`). -If you want to allow sending from all domains, you need to remove `authorize_sender` check -altogether since it is not currently supported. -After that you can create accounts without specifying the domain part: -``` -maddyctl imap-acct create foxcpp -maddyctl creds create foxcpp +## TL;DR + +Your options: + +**"user@example.org" and "user@example.com" have distinct credentials and +distinct mailboxes.** + +``` +$(primary_domain) = example.org +$(local_domains) = example.org example.com +``` + +Create accounts as: + +```shell +maddy creds create user@example.org +maddy imap-acct create user@example.org +maddy creds create user@example.com +maddy imap-acct create user@example.com +``` + +**"user@example.org" and "user@example.com" have same credentials but +distinct mailboxes.** + +``` +$(primary_domain) = example.org +$(local_domains) = example.org example.com +auth_map email_localpart +``` + +Create accounts as: +```shell +maddy creds create user +maddy imap-acct create user@example.org +maddy imap-acct create user@example.com ``` -And authenticate using "foxcpp" in email clients. -Messages for any foxcpp@* address with a domain in `$(local_domains)` -will be delivered to that mailbox. +**"user@example.org", "user@example.com", "user" have same credentials and same +mailboxes.** + +``` + $(primary_domain) = example.org + $(local_domains) = example.org example.com + auth_map email_localpart_optional # authenticating as "user@*" checks credentials for "user" + + storage.imapsql local_mailboxes { + ... + delivery_map email_localpart_optional # deliver "user@*" to "user" mailbox + } + + imap tls://0.0.0.0:993 { + ... + storage_map email_localpart_optional # authenticating as "user@*" accesses "user" mailboxes + } + + submission tls://0.0.0.0:465 { + check { + authorize_sender { + ... + user_to_email chain { + step email_localpart_optional # remove domain from username if present + step email_with_domain $(local_domains) # expand username with all allowed domains + } + } + } + ... + } +``` + +Create accounts as: +```shell +maddy creds create user +maddy imap-acct create user +``` diff --git a/docs/reference/auth/dovecot_sasl.md b/docs/reference/auth/dovecot_sasl.md new file mode 100644 index 000000000..919d42b8d --- /dev/null +++ b/docs/reference/auth/dovecot_sasl.md @@ -0,0 +1,26 @@ +# Dovecot SASL + +The 'auth.dovecot_sasl' module implements the client side of the Dovecot +authentication protocol, allowing maddy to use it as a credentials source. + +Currently SASL mechanisms support is limited to mechanisms supported by maddy +so you cannot get e.g. SCRAM-MD5 this way. + +``` +auth.dovecot_sasl { + endpoint unix://socket_path +} + +dovecot_sasl unix://socket_path +``` + +## Configuration directives + +### endpoint _schema://address_ +Default: not set + +Set the address to use to contact Dovecot SASL server in the standard endpoint +format. + +`tcp://10.0.0.1:2222` for TCP, `unix:///var/lib/dovecot/auth.sock` for Unix +domain sockets. diff --git a/docs/reference/auth/external.md b/docs/reference/auth/external.md new file mode 100644 index 000000000..9b9659ef2 --- /dev/null +++ b/docs/reference/auth/external.md @@ -0,0 +1,52 @@ +# System command + +auth.external module for authentication using external helper binary. It looks for binary +named `maddy-auth-helper` in $PATH and libexecdir and uses it for authentication +using username/password pair. + +The protocol is very simple: +Program is launched for each authentication. Username and password are written +to stdin, adding \n to the end. If binary exits with 0 status code - +authentication is considered successful. If the status code is 1 - +authentication is failed. If the status code is 2 - another unrelated error has +happened. Additional information should be written to stderr. + +``` +auth.external { + helper /usr/bin/ldap-helper + perdomain no + domains example.org +} +``` + +## Configuration directives + +### helper _file_path_ + +**Required.**
+Location of the helper binary. + +--- + +### perdomain _boolean_ +Default: `no` + +Don't remove domain part of username when authenticating and require it to be +present. Can be used if you want user@domain1 and user@domain2 to be different +accounts. + +--- + +### domains _domains..._ +Default: not specified + +Domains that should be allowed in username during authentication. + +For example, if 'domains' is set to "domain1 domain2", then +username, username@domain1 and username@domain2 will be accepted as valid login +name in addition to just username. + +If used without 'perdomain', domain part will be removed from login before +check with underlying auth. mechanism. If 'perdomain' is set, then +domains must be also set and domain part **will not** be removed before check. + diff --git a/docs/reference/auth/ldap.md b/docs/reference/auth/ldap.md new file mode 100644 index 000000000..a4ced5514 --- /dev/null +++ b/docs/reference/auth/ldap.md @@ -0,0 +1,130 @@ +# LDAP BindDN + +maddy supports authentication via LDAP using DN binding. Passwords are verified +by the LDAP server. + +maddy needs to know the DN to use for binding. It can be obtained either by +directory search or template . + +Note that storage backends conventionally use email addresses, if you use +non-email identifiers as usernames then you should map them onto +emails on delivery by using `auth_map` (see documentation page for used storage backend). + +auth.ldap also can be a used as a table module. This way you can check +whether the account exists. It works only if DN template is not used. + +``` +auth.ldap { + urls ldap://maddy.test:389 + + # Specify initial bind credentials. Not required ('bind off') + # if DN template is used. + bind plain "cn=maddy,ou=people,dc=maddy,dc=test" "123456" + + # Specify DN template to skip lookup. + dn_template "cn={username},ou=people,dc=maddy,dc=test" + + # Specify base_dn and filter to lookup DN. + base_dn "ou=people,dc=maddy,dc=test" + filter "(&(objectClass=posixAccount)(uid={username}))" + + tls_client { ... } + starttls off + debug off + connect_timeout 1m +} +``` +``` +auth.ldap ldap://maddy.test.389 { + ... +} +``` + +## Configuration directives + +### urls _servers..._ + +**Required.** + +URLs of the directory servers to use. First available server +is used - no load-balancing is done. + +URLs should use `ldap://`, `ldaps://`, `ldapi://` schemes. + +--- + +### bind `off` | `unauth` | `external` | `plain` _username_ _password_ + +Default: `off` + +Credentials to use for initial binding. Required if DN lookup is used. + +`unauth` performs unauthenticated bind. `external` performs external binding +which is useful for Unix socket connections (`ldapi://`) or TLS client certificate +authentication (cert. is set using tls_client directive). `plain` performs a +simple bind using provided credentials. + +--- + +### dn_template _template_ + +DN template to use for binding. `{username}` is replaced with the +username specified by the user. + +--- + +### base_dn _dn_ + +Base DN to use for lookup. + +--- + +### filter _str_ + +DN lookup filter. `{username}` is replaced with the username specified +by the user. + +Example: + +``` +(&(objectClass=posixAccount)(uid={username})) +``` + +Example (using ActiveDirectory): + +``` +(&(objectCategory=Person)(memberOf=CN=user-group,OU=example,DC=example,DC=org)(sAMAccountName={username})(!(UserAccountControl:1.2.840.113556.1.4.803:=2))) +``` + +Example: + +``` +(&(objectClass=Person)(mail={username})) +``` + +--- + +### starttls _bool_ +Default: `off` + +Whether to upgrade connection to TLS using STARTTLS. + +--- + +### tls_client { ... } + +Advanced TLS client configuration. See [TLS configuration / Client](/reference/tls/#client) for details. + +--- + +### connect_timeout _duration_ +Default: `1m` + +Timeout for initial connection to the directory server. + +--- + +### request_timeout _duration_ +Default: `1m` + +Timeout for each request (binding, lookup). diff --git a/docs/reference/auth/netauth.md b/docs/reference/auth/netauth.md new file mode 100644 index 000000000..4c2681085 --- /dev/null +++ b/docs/reference/auth/netauth.md @@ -0,0 +1,50 @@ +# Native NetAuth + +maddy supports authentication via NetAuth using direct entity +authentication checks. Passwords are verified by the NetAuth server. + +maddy needs to know the Entity ID to use for authentication. It must +match the string the user provides for the Local Atom part of their +mail address. + +Note that storage backends conventionally use email addresses. Since NetAuth +recommends *nix compatible usernames. You will need to either map email +identifiers specified by user to NetAuth Entity IDs using `auth_map` in +endpoint.smtp/imap configuration (recommended) or you would need to use +`storage_map` in storage backend configuration to map NetAuth Entity ID +specified by user back to appropriate storage backend account names. + +auth.netauth also can be used as a table module. This way you can +check whether the account exists. + +Note that the configuration fragment provided below is very sparse. +This is because NetAuth expects to read most of its common +configuration values from the system NetAuth config file located at +`/etc/netauth/config.toml`. + +``` +auth.netauth { + require_group "maddy-users" + debug off +} +``` + +``` +auth.netauth {} +``` + +## Configuration directives + +### require_group _group_ + +Optional. + +Group that entities must possess to be able to use maddy services. +This can be used to provide email to just a subset of the entities +present in NetAuth. + +--- + +### debug `on` | `off` + +Default: `off` diff --git a/docs/reference/auth/pam.md b/docs/reference/auth/pam.md new file mode 100644 index 000000000..89f0f3e3d --- /dev/null +++ b/docs/reference/auth/pam.md @@ -0,0 +1,48 @@ +# PAM + +auth.pam module implements authentication using libpam. Alternatively it can be configured to +use helper binary like auth.external module does. + +maddy should be built with libpam build tag to use this module without +'use_helper' directive. + +``` +go get -tags 'libpam' ... +``` + +``` +auth.pam { + debug no + use_helper no +} +``` + +## Configuration directives + +### debug _boolean_ +Default: `no` + +Enable verbose logging for all modules. You don't need that unless you are +reporting a bug. + +--- + +### use_helper _boolean_ +Default: `no` + +Use `LibexecDirectory/maddy-pam-helper` instead of directly calling libpam. +You need to use that if: + +1. maddy is not compiled with libpam, but `maddy-pam-helper` is built separately. +2. maddy is running as an unprivileged user and used PAM configuration requires additional privileges (e.g. when using system accounts). + +For 2, you need to make `maddy-pam-helper` binary setuid, see +README.md in source tree for details. + +TL;DR (assuming you have the maddy group): + +``` +chown root:maddy /usr/lib/maddy/maddy-pam-helper +chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-pam-helper +``` + diff --git a/docs/reference/auth/pass_table.md b/docs/reference/auth/pass_table.md new file mode 100644 index 000000000..39fea6fcc --- /dev/null +++ b/docs/reference/auth/pass_table.md @@ -0,0 +1,44 @@ +# Password table + +auth.pass_table module implements username:password authentication by looking up the +password hash using a table module (maddy-tables(5)). It can be used +to load user credentials from text file (via table.file module) or SQL query +(via table.sql_table module). + + +Definition: +``` +auth.pass_table [block name] { + table
+ +} +``` +Shortened variant for inline use: +``` +pass_table
[table arguments] { + [additional table config] +} +``` + +Example, read username:password pair from the text file: +``` +smtp tcp://0.0.0.0:587 { + auth pass_table file /etc/maddy/smtp_passwd + ... +} +``` + +## Password hashes + +pass_table expects the used table to contain certain structured values with +hash algorithm name, salt and other necessary parameters. + +You should use `maddy hash` command to generate suitable values. +See `maddy hash --help` for details. + +## maddy creds + +If the underlying table is a "mutable" table (see maddy-tables(5)) then +the `maddy creds` command can be used to modify the underlying tables +via pass_table module. It will act on a "local credentials store" and will write +appropriate hash values to the table. diff --git a/docs/reference/auth/plain_separate.md b/docs/reference/auth/plain_separate.md new file mode 100644 index 000000000..f5b576675 --- /dev/null +++ b/docs/reference/auth/plain_separate.md @@ -0,0 +1,45 @@ +# Separate username and password lookup + +auth.plain_separate module implements authentication using username:password pairs but can +use zero or more "table modules" (maddy-tables(5)) and one or more +authentication providers to verify credentials. + +``` +auth.plain_separate { + user ... + user ... + ... + pass ... + pass ... + ... +} +``` + +How it works: +- Initial username input is normalized using PRECIS UsernameCaseMapped profile. +- Each table specified with the 'user' directive looked up using normalized + username. If match is not found in any table, authentication fails. +- Each authentication provider specified with the 'pass' directive is tried. + If authentication with all providers fails - an error is returned. + +## Configuration directives + +### user _table-module_ + +Configuration block for any module from maddy-tables(5) can be used here. + +Example: + +``` +user file /etc/maddy/allowed_users +``` + +--- + +### pass _auth-provider_ + +Configuration block for any auth. provider module can be used here, even +'plain_split' itself. + +The used auth. provider must provide username:password pair-based +authentication. diff --git a/docs/reference/auth/shadow.md b/docs/reference/auth/shadow.md new file mode 100644 index 000000000..0fc3e89b3 --- /dev/null +++ b/docs/reference/auth/shadow.md @@ -0,0 +1,40 @@ +# /etc/shadow + +auth.shadow module implements authentication by reading /etc/shadow. Alternatively it can be +configured to use helper binary like auth.external does. + +``` +auth.shadow { + debug no + use_helper no +} +``` + +## Configuration directives + +### debug _boolean_ + +Default: `no` + +Enable verbose logging for all modules. You don't need that unless you are +reporting a bug. + +--- + +### use_helper _boolean_ +Default: `no` + +Use `LibexecDirectory/maddy-shadow-helper` instead of directly reading `/etc/shadow`. +You need to use that if maddy is running as an unprivileged user +privileges (e.g. when using system accounts). + +You need to make `maddy-shadow-helper` binary setuid, see +cmd/maddy-shadow-helper/README.md in source tree for details. + +TL;DR (assuming you have maddy group): + +``` +chown root:maddy /usr/lib/maddy/maddy-shadow-helper +chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-shadow-helper +``` + diff --git a/docs/reference/blob/fs.md b/docs/reference/blob/fs.md new file mode 100644 index 000000000..ef94b54b4 --- /dev/null +++ b/docs/reference/blob/fs.md @@ -0,0 +1,23 @@ +# Filesystem + +This module stores message bodies in a file system directory. + +``` +storage.blob.fs { + root +} +``` + +``` +storage.blob.fs +``` + +## Configuration directives + +### root _path_ +Default: not set + +Path to the FS directory. Must be readable and writable by the server process. +If it does not exist - it will be created (parent directory should be writable +for this). Relative paths are interpreted relatively to server state directory. + diff --git a/docs/reference/blob/s3.md b/docs/reference/blob/s3.md new file mode 100644 index 000000000..54b6a4e2f --- /dev/null +++ b/docs/reference/blob/s3.md @@ -0,0 +1,98 @@ +# Amazon S3 + +storage.blob.s3 module stores messages bodies in a bucket on S3-compatible storage. + +``` +storage.blob.s3 { + endpoint play.min.io + secure yes + access_key "Q3AM3UQ867SPQQA43P2F" + secret_key "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG" + bucket maddy-test + + # optional + region eu-central-1 + object_prefix maddy/ + creds access_key +} +``` + +Example: + +``` +storage.imapsql local_mailboxes { + ... + msg_store s3 { + endpoint s3.amazonaws.com + access_key "..." + secret_key "..." + bucket maddy-messages + region us-west-2 + creds access_key + } +} +``` + +## Configuration directives + +### endpoint _address:port_ + +**Required**. + +Root S3 endpoint. e.g. `s3.amazonaws.com` + +--- + +### secure _boolean_ +Default: `yes` + +Whether TLS should be used. + +--- + +### access_key _string_
secret_key _string_ + +**Required**. + +Static S3 credentials. + +--- + +### bucket _name_ + +**Required**. + +S3 bucket name. The bucket must exist and +be read-writable. + +--- + +### region _string_ +Default: not set + +S3 bucket location. May be called "endpoint" in some manuals. + +--- + +### object_prefix _string_ +Default: empty string + +String to add to all keys stored by maddy. + +Can be useful when S3 is used as a file system. + +--- + +### creds `access_key` | `file_minio` | `file_aws` | `iam` +Default: `access_key` + +Credentials to use for accessing the S3 Bucket. + +Credential Types: + + - `access_key`: use AWS access key and secret access key + - `file_minio`: use credentials for Minio present at ~/.mc/config.json + - `file_aws`: use credentials for AWS S3 present at ~/.aws/credentials + - `iam`: use AWS IAM instance profile for credentials. + +By default, access_key is used with the access key and secret access key present in the config. diff --git a/docs/reference/checks/actions.md b/docs/reference/checks/actions.md new file mode 100644 index 000000000..d9e9f9c91 --- /dev/null +++ b/docs/reference/checks/actions.md @@ -0,0 +1,21 @@ +# Check actions + +When a certain check module thinks the message is "bad", it takes some actions +depending on its configuration. Most checks follow the same configuration +structure and allow following actions to be taken on check failure: + +- Do nothing (`action ignore`) + +Useful for testing deployment of new checks. Check failures are still logged +but they have no effect on message delivery. + +- Reject the message (`action reject`) + +Reject the message at connection time. No bounce is generated locally. + +- Quarantine the message (`action quarantine`) + +Mark message as 'quarantined'. If message is then delivered to the local +storage, the storage backend can place the message in the 'Junk' mailbox. +Another thing to keep in mind that 'target.remote' module +will refuse to send quarantined messages. \ No newline at end of file diff --git a/docs/reference/checks/authorize_sender.md b/docs/reference/checks/authorize_sender.md new file mode 100644 index 000000000..4ddd78629 --- /dev/null +++ b/docs/reference/checks/authorize_sender.md @@ -0,0 +1,132 @@ +# MAIL FROM and From authorization + +Module check.authorize_sender verifies that envelope and header sender addresses belong +to the authenticated user. Address ownership is established via table +that maps each user account to a email address it is allowed to use. +There are some special cases, see `user_to_email` description below. + +``` +check.authorize_sender { + prepare_email identity + user_to_email identity + check_header yes + + unauth_action reject + no_match_action reject + malformed_action reject + err_action reject + + auth_normalize auto + from_normalize auto +} +``` +``` +check { + authorize_sender { ... } +} +``` + +## Configuration directives + +### user_to_email _table_ +Default: `identity` + +Table that maps authorization username to the list of sender emails +the user is allowed to use. + +In additional to email addresses, the table can contain domain names or +special string "\*" as a value. If the value is a domain - user +will be allowed to use any mailbox within it as a sender address. +If it is "\*" - user will be allowed to use any address. + +By default, table.identity is used, meaning that username should +be equal to the sender email. + +Before username is looked up via the table, normalization algorithm +defined by auth_normalize is applied to it. + +--- + +### prepare_email _table_ +Default: `identity` + +Table that is used to translate email addresses before they +are matched against user_to_email values. + +Typically used to allow users to use their aliases as sender +addresses - prepare_email in this case should translate +aliases to "canonical" addresses. This is how it is +done in default configuration. + +If table does not contain any mapping for the used sender +address, it will be used as is. + +--- + +### check_header _boolean_ +Default: `yes` + +Whether to verify header sender in addition to envelope. + +Either Sender or From field value should match the +authorization identity. + +--- + +### unauth_action _action_ +Default: `reject` + +What to do if the user is not authenticated at all. + +--- + +### no_match_action _action_ +Default: `reject` + +What to do if user is not allowed to use the sender address specified. + +--- + +### malformed_action _action_ +Default: `reject` + +What to do if From or Sender header fields contain malformed values. + +--- + +### err_action _action_ +Default: `reject` + +What to do if error happens during prepare_email or user_to_email lookup. + +--- + +### auth_normalize _action_ +Default: `auto` + +Normalization function to apply to authorization username before +further processing. + +Available options: + +- `auto` `precis_casefold_email` for valid emails, `precis_casefold` otherwise. +- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain +- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string +- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain +- `precis` PRECIS UsernameCasePreserved profile for the entire string +- `casefold` Convert to lower case +- `noop` Nothing + +PRECIS profiles are defined by RFC 8265. In short, they make sure +that Unicode strings that look the same will be compared as if they were +the same. CaseMapped profiles also convert strings to lower case. + +--- + +### from_normalize _action_ +Default: `auto` + +Normalization function to apply to email addresses before +further processing. + +Available options are same as for `auth_normalize`. diff --git a/docs/reference/checks/command.md b/docs/reference/checks/command.md new file mode 100644 index 000000000..6475efcf4 --- /dev/null +++ b/docs/reference/checks/command.md @@ -0,0 +1,96 @@ +# System command filter + +This module executes an arbitrary system command during a specified stage of +checks execution. + +``` +command executable_name arg0 arg1 ... { + run_on body + + code 1 reject + code 2 quarantine +} +``` + +## Arguments + +The module arguments specify the command to run. If the first argument is not +an absolute path, it is looked up in the Libexec Directory (/usr/lib/maddy on +Linux) and in $PATH (in that ordering). Note that no additional handling +of arguments is done, especially, the command is executed directly, not via the +system shell. + +There is a set of special strings that are replaced with the corresponding +message-specific values: + +- `{source_ip}` – IPv4/IPv6 address of the sending MTA. +- `{source_host}` – Hostname of the sending MTA, from the HELO/EHLO command. +- `{source_rdns}` – PTR record of the sending MTA IP address. +- `{msg_id}` – Internal message identifier. Unique for each delivery. +- `{auth_user}` – Client username, if authenticated using SASL PLAIN +- `{sender}` – Message sender address, as specified in the MAIL FROM SMTP command. +- `{rcpts}` – List of accepted recipient addresses, including the currently handled + one. +- `{address}` – Currently handled address. This is a recipient address if the command + is called during RCPT TO command handling (`run_on rcpt`) or a sender + address if the command is called during MAIL FROM command handling (`run_on + sender`). + +If value is undefined (e.g. `{source_ip}` for a message accepted over a Unix +socket) or unavailable (the command is executed too early), the placeholder +is replaced with an empty string. Note that it can not remove the argument. +E.g. `-i {source_ip}` will not become just `-i`, it will be `-i ""` + +Undefined placeholders are not replaced. + +## Command stdout + +The command stdout must be either empty or contain a valid RFC 5322 header. +If it contains a byte stream that does not look a valid header, the message +will be rejected with a temporary error. + +The header from stdout will be **prepended** to the message header. + +## Configuration directives + +### run_on `conn` | `sender` | `rcpt` | `body` +Default: `body` + +When to run the command. This directive also affects the information visible +for the message. + +- `conn`
+ Run before the sender address (MAIL FROM) is handled.
+ **Stdin**: Empty
+ **Available placeholders**: {source_ip}, {source_host}, {msg_id}, {auth_user}. + +- `sender`
+ Run during sender address (MAIL FROM) handling.
+ **Stdin**: Empty
+ **Available placeholders**: conn placeholders + {sender}, {address}. + The {address} placeholder contains the MAIL FROM address. + +- `rcpt`
+ Run during recipient address (RCPT TO) handling. The command is executed + once for each RCPT TO command, even if the same recipient is specified + multiple times.
+ **Stdin**: Empty
+ **Available placeholders**: sender placeholders + {rcpts}. + The {address} placeholder contains the recipient address. + +- `body`
+ Run during message body handling.
+ **Stdin**: The message header + body
+ **Available placeholders**: all except for {address}. + +--- + +### code _integer_ ignore
code _integer_ quarantine
code _integer_ reject _smtp-code_ _smtp-enhanced-code_ _smtp-message_ + +This directive specifies the mapping from the command exit code _integer_ to +the message pipeline action. + +Two codes are defined implicitly, exit code 1 causes the message to be rejected +with a permanent error, exit code 2 causes the message to be quarantined. Both +actions can be overridden using the 'code' directive. + diff --git a/docs/reference/checks/dkim.md b/docs/reference/checks/dkim.md new file mode 100644 index 000000000..7ab14a6d0 --- /dev/null +++ b/docs/reference/checks/dkim.md @@ -0,0 +1,63 @@ +# DKIM + +This is the check module that performs verification of the DKIM signatures +present on the incoming messages. + +## Configuration directives + +``` +check.dkim { + debug no + required_fields From Subject + allow_body_subset no + no_sig_action ignore + broken_sig_action ignore + fail_open no +} +``` + +### debug _boolean_ +Default: global directive value + +Log both successful and unsuccessful check executions instead of just +unsuccessful. + +--- + +### required_fields _string..._ +Default: `From Subject` + +Header fields that should be included in each signature. If signature +lacks any field listed in that directive, it will be considered invalid. + +Note that From is always required to be signed, even if it is not included in +this directive. + +--- + +### no_sig_action _action_ +Default: `ignore` (recommended by RFC 6376) + +Action to take when message without any signature is received. + +Note that DMARC policy of the sender domain can request more strict handling of +missing DKIM signatures. + +--- + +### broken_sig_action _action_ +Default: `ignore` (recommended by RFC 6376) + +Action to take when there are not valid signatures in a message. + +Note that DMARC policy of the sender domain can request more strict handling of +broken DKIM signatures. + +--- + +### fail_open _boolean_ +Default: `no` + +Whether to accept the message if a temporary error occurs during DKIM +verification. Rejecting the message with a 4xx code will require the sender +to resend it later in a hope that the problem will be resolved. diff --git a/docs/reference/checks/dnsbl.md b/docs/reference/checks/dnsbl.md new file mode 100644 index 000000000..d7bb74cfa --- /dev/null +++ b/docs/reference/checks/dnsbl.md @@ -0,0 +1,258 @@ +# DNSBL lookup + +The check.dnsbl module implements checking of source IP and hostnames against a set +of DNS-based Blackhole lists (DNSBLs). + +Its configuration consists of module configuration directives and a set +of blocks specifying lists to use and kind of lookups to perform on them. + +``` +check.dnsbl { + debug no + check_early no + + quarantine_threshold 1 + reject_threshold 1 + + # Lists configuration example. + dnsbl.example.org { + client_ipv4 yes + client_ipv6 no + ehlo no + mailfrom no + score 1 + } + hsrbl.example.org { + client_ipv4 no + client_ipv6 no + ehlo yes + mailfrom yes + score 1 + } + + # Example with per-response-code scoring (new in 0.8) + zen.spamhaus.org { + client_ipv4 yes + client_ipv6 yes + + # SBL - Spamhaus Block List (known spam sources) + response 127.0.0.2 127.0.0.3 { + score 10 + message "Listed in Spamhaus SBL. See https://check.spamhaus.org/" + } + + # XBL - Exploits Block List (compromised hosts) + response 127.0.0.4 127.0.0.5 127.0.0.6 127.0.0.7 { + score 10 + message "Listed in Spamhaus XBL. See https://check.spamhaus.org/" + } + + # PBL - Policy Block List (dynamic IPs) + response 127.0.0.10 127.0.0.11 { + score 5 + message "Listed in Spamhaus PBL. See https://check.spamhaus.org/" + } + } +} +``` + +## Arguments + +Arguments specify the list of IP-based BLs to use. + +The following configurations are equivalent. + +``` +check { + dnsbl dnsbl.example.org dnsbl2.example.org +} +``` + +``` +check { + dnsbl { + dnsbl.example.org dnsbl2.example.org { + client_ipv4 yes + client_ipv6 no + ehlo no + mailfrom no + score 1 + } + } +} +``` + +## Configuration directives + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### check_early _boolean_ +Default: `no` + +Check BLs before mail delivery starts and silently reject blacklisted clients. + +For this to work correctly, check should not be used in source/destination +pipeline block. + +In particular, this means: + +- No logging is done for rejected messages. +- No action is taken if `quarantine_threshold` is hit, only `reject_threshold` + applies. +- `defer_sender_reject` from SMTP configuration takes no effect. +- MAIL FROM is not checked, even if specified. + +If you often get hit by spam attacks, it is recommended to enable this +setting to save server resources. + +--- + +### quarantine_threshold _integer_ +Default: `1` + +DNSBL score needed (equals-or-higher) to quarantine the message. + +--- + +### reject_threshold _integer_ +Default: `9999` + +DNSBL score needed (equals-or-higher) to reject the message. + +## List configuration + +``` +dnsbl.example.org dnsbl.example.com { + client_ipv4 yes + client_ipv6 no + ehlo no + mailfrom no + responses 127.0.0.1/24 + score 1 +} +``` + +Directive name and arguments specify the actual DNS zone to query when checking +the list. Using multiple arguments is equivalent to specifying the same +configuration separately for each list. + +### client_ipv4 _boolean_ +Default: `yes` + +Whether to check address of the IPv4 clients against the list. + +--- + +### client_ipv6 _boolean_ +Default: `yes` + +Whether to check address of the IPv6 clients against the list. + +--- + +### ehlo _boolean_ +Default: `no` + +Whether to check hostname specified n the HELO/EHLO command +against the list. + +This works correctly only with domain-based DNSBLs. + +--- + +### mailfrom _boolean_ +Default: `no` + +Whether to check domain part of the MAIL FROM address against the list. + +This works correctly only with domain-based DNSBLs. + +--- + +### responses _cidr_ | _ip..._ +Default: `127.0.0.1/24` + +IP networks (in CIDR notation) or addresses to permit in list lookup results. +Addresses not matching any entry in this directives will be ignored. + +--- + +### score _integer_ +Default: `1` + +Score value to add for the message if it is listed. + +If sum of list scores is equals or higher than `quarantine_threshold`, the +message will be quarantined. + +If sum of list scores is equals or higher than `rejected_threshold`, the message +will be rejected. + +It is possible to specify a negative value to make list act like a whitelist +and override results of other blocklists. + +**Note:** When using `response` blocks (see below), the score from matching response +rules is used instead of this flat score value. + +--- + +### response _ip..._ + +Defines per-response-code rules for scoring and custom messages. This is useful +for combined DNSBLs like Spamhaus ZEN that return different codes for different +listing types. + +This works for both IP-based lookups (client_ipv4, client_ipv6) and domain-based +lookups (ehlo, mailfrom). + +Each `response` block takes one or more IP addresses or CIDR ranges as arguments +and contains the following directives: + +#### score _integer_ +**Required** + +Score to add when this response code is returned. If multiple response codes +are returned by the DNSBL, and they match different rules, the scores from +all matched rules are summed together. Each rule is counted only once, even +if multiple returned IPs match networks within that rule. + +#### message _string_ +**Optional** + +Custom rejection or quarantine message to include when this response code +matches. This message is shown to the client or logged when the threshold +is reached. + +**Example:** + +``` +zen.spamhaus.org { + client_ipv4 yes + + # High severity - known spam sources + response 127.0.0.2 127.0.0.3 { + score 10 + message "Listed in Spamhaus SBL" + } + + # Lower severity - dynamic IPs + response 127.0.0.10 127.0.0.11 { + score 5 + message "Listed in Spamhaus PBL" + } +} +``` + +**Scoring behavior:** +- If DNSBL returns `127.0.0.2` only → Score: 10 (matches first rule) +- If DNSBL returns `127.0.0.11` only → Score: 5 (matches second rule) +- If DNSBL returns both `127.0.0.2` and `127.0.0.11` → Score: 15 (both rules match, scores sum) +- If DNSBL returns both `127.0.0.2` and `127.0.0.3` → Score: 10 (same rule matches, counted once) + +**Backwards compatibility:** When `response` blocks are not used, the legacy +`responses` and `score` directives work as before. diff --git a/docs/reference/checks/milter.md b/docs/reference/checks/milter.md new file mode 100644 index 000000000..8286a79b1 --- /dev/null +++ b/docs/reference/checks/milter.md @@ -0,0 +1,49 @@ +# Milter client + +The 'milter' implements subset of Sendmail's milter protocol that can be used +to integrate external software with maddy. +maddy implements version 6 of the protocol, older versions are +not supported. + +Notable limitations of protocol implementation in maddy include: +1. Changes of envelope sender address are not supported +2. Removal and addition of envelope recipients is not supported +3. Removal and replacement of header fields is not supported +4. Headers fields can be inserted only on top +5. Milter does not receive some "macros" provided by sendmail. + +Restrictions 1 and 2 are inherent to the maddy checks interface and cannot be +removed without major changes to it. Restrictions 3, 4 and 5 are temporary due to +incomplete implementation. + +``` +check.milter { + endpoint + fail_open false +} + +milter +``` + +## Arguments + +When defined inline, the first argument specifies endpoint to access milter +via. See below. + +## Configuration directives + +### endpoint _scheme://path_ +Default: not set + +Specifies milter protocol endpoint to use. +The endpoit is specified in standard URL-like format: +`tcp://127.0.0.1:6669` or `unix:///var/lib/milter/filter.sock` + +--- + +### fail_open _boolean_ +Default: `false` + +Toggles behavior on milter I/O errors. If false ("fail closed") - message is +rejected with temporary error code. If true ("fail open") - check is skipped. + diff --git a/docs/reference/checks/misc.md b/docs/reference/checks/misc.md new file mode 100644 index 000000000..19c71ad09 --- /dev/null +++ b/docs/reference/checks/misc.md @@ -0,0 +1,48 @@ +# Misc checks + +## Configuration directives + +Following directives are defined for all modules listed below. + +### fail_action `ignore` | `reject` | `quarantine` +Default: `quarantine` + +Action to take when check fails. See [Check actions](../actions/) for details. + +--- + +### debug _boolean_ +Default: global directive value + +Log both successful and unsuccessful check executions instead of just +unsuccessful. + +--- + +### require_mx_record + +Check that domain in MAIL FROM command does have a MX record and none of them +are "null" (contain a single dot as the host). + +By default, quarantines messages coming from servers missing MX records, +use `fail_action` directive to change that. + +--- + +### require_matching_rdns + +Check that source server IP does have a PTR record point to the domain +specified in EHLO/HELO command. + +By default, quarantines messages coming from servers with mismatched or missing +PTR record, use `fail_action` directive to change that. + +--- + +### require_tls + +Check that the source server is connected via TLS; either directly, or by using +the STARTTLS command. + +By default, rejects messages coming from unencrypted servers. Use the +`fail_action` directive to change that. \ No newline at end of file diff --git a/docs/reference/checks/rspamd.md b/docs/reference/checks/rspamd.md new file mode 100644 index 000000000..f37f5dcc0 --- /dev/null +++ b/docs/reference/checks/rspamd.md @@ -0,0 +1,113 @@ +# rspamd + +The 'rspamd' module implements message filtering by contacting the rspamd +server via HTTP API. + +``` +check.rspamd { + tls_client { ... } + api_path http://127.0.0.1:11333 + settings_id whatever + tag maddy + hostname mx.example.org + io_error_action ignore + error_resp_action ignore + add_header_action quarantine + rewrite_subj_action quarantine + reject_action reject + soft_reject_action reject + flags pass_all +} + +rspamd http://127.0.0.1:11333 +``` + +## Configuration directives + +### tls_client { ... } +Default: not set + +Configure TLS client if HTTPS is used. See [TLS configuration / Client](/reference/tls/#client) for details. + +--- + +### api_path _url_ +Default: `http://127.0.0.1:11333` + +URL of HTTP API endpoint. Supports both HTTP and HTTPS and can include +path element. + +--- + +### settings_id _string_ +Default: not set + +Settings ID to pass to the server. + +--- + +### tag _string_ +Default: `maddy` + +Value to send in MTA-Tag header field. + +--- + +### hostname _string_
+Default: value of global directive + +Value to send in MTA-Name header field. + +--- + +### io_error_action _action_ +Default: `ignore` + +Action to take in case of inability to contact the rspamd server. + +--- + +### error_resp_action _action_ +Default: `ignore` + +Action to take in case of 5xx or 4xx response received from the rspamd server. + +--- + +### add_header_action _action_ +Default: `quarantine` + +Action to take when rspamd requests to "add header". + +X-Spam-Flag and X-Spam-Score are added to the header irregardless of value. + +--- + +### rewrite_subj_action _action_ +Default: `quarantine` + +Action to take when rspamd requests to "rewrite subject". + +X-Spam-Flag and X-Spam-Score are added to the header irregardless of value. + +--- + +### reject_action _action_ +Default: `reject` + +Action to take when rspamd requests to "reject". + +--- + +### soft_reject_action _action_ +Default: `reject` + +Action to take when rspamd requests to "soft reject". + +--- + +### flags _string-list..._ +Default: `pass_all` + +Flags to pass to the rspamd server. +See [https://rspamd.com/doc/architecture/protocol.html](https://rspamd.com/doc/architecture/protocol.html) for details. diff --git a/docs/reference/checks/spf.md b/docs/reference/checks/spf.md new file mode 100644 index 000000000..f0afb3473 --- /dev/null +++ b/docs/reference/checks/spf.md @@ -0,0 +1,97 @@ +# SPF + +check.spf the check module that verifies whether IP address of the client is +authorized to send messages for domain in MAIL FROM address. + +SPF statuses are mapped to maddy check actions in a way +specified by \*_action directives. By default, SPF failure +results in the message being quarantined and errors (both permanent and +temporary) cause message to be rejected. +Authentication-Results field is generated irregardless of status. + +## DMARC override + +It is recommended by the DMARC standard to don't fail delivery based solely on +SPF policy and always check DMARC policy and take action based on it. + +If `enforce_early` is `no`, check.spf module will not take any action on SPF +policy failure if sender domain does have a DMARC record with 'quarantine' or +'reject' policy. Instead it will rely on DMARC support to take necesary +actions using SPF results as an input. + +Disabling `enforce_early` without enabling DMARC support will make SPF policies +no-op and is considered insecure. + +## Configuration directives + +``` +check.spf { + debug no + enforce_early no + fail_action quarantine + softfail_action ignore + permerr_action reject + temperr_action reject +} +``` + +### debug _boolean_ +Default: global directive value + +Enable verbose logging for check.spf. + +--- + +### enforce_early _boolean_ +Default: `no` + +Make policy decision on MAIL FROM stage (before the message body is received). +This makes it impossible to apply DMARC override (see above). + +--- + +### none_action `reject` | `quarantine` | `ignore` +Default: `ignore` + +Action to take when SPF policy evaluates to a 'none' result. + +See [https://tools.ietf.org/html/rfc7208#section-2.6](https://tools.ietf.org/html/rfc7208#section-2.6) for meaning of +SPF results. + +--- + +### neutral_action `reject` | `quarantine` | `ignore` +Default: `ignore` + +Action to take when SPF policy evaluates to a 'neutral' result. + +See [https://tools.ietf.org/html/rfc7208#section-2.6](https://tools.ietf.org/html/rfc7208#section-2.6) for meaning of +SPF results. + +--- + +### fail_action `reject` | `quarantine` | `ignore` +Default: `quarantine` + +Action to take when SPF policy evaluates to a 'fail' result. + +--- + +### softfail_action `reject` | `quarantine` | `ignore` +Default: `ignore` + +Action to take when SPF policy evaluates to a 'softfail' result. + +--- + +### permerr_action `reject` | `quarantine` | `ignore` +Default: `reject` + +Action to take when SPF policy evaluates to a 'permerror' result. + +--- + +### temperr_action `reject` | `quarantine` | `ignore` +Default: `reject` + +Action to take when SPF policy evaluates to a 'temperror' result. diff --git a/docs/man/maddy-config.5.scd b/docs/reference/config-syntax.md similarity index 92% rename from docs/man/maddy-config.5.scd rename to docs/reference/config-syntax.md index ba29a38a1..72e18d411 100644 --- a/docs/man/maddy-config.5.scd +++ b/docs/reference/config-syntax.md @@ -1,6 +1,7 @@ -maddy-config(5) "maddy mail server" "maddy reference documentation" +# Configuration files syntax -; TITLE Configuration files syntax +**Note:** This file is a technical document describing how +maddy parses configuration files. Configuration consists of newline-delimited "directives". Each directive can have zero or more arguments. @@ -181,24 +182,19 @@ Also note that the following is not valid, unlike Duration values syntax: 32M5K ``` -# ADDRESS DEFINITIONS +## Address Definitions Maddy configuration uses URL-like syntax to specify network addresses. -- unix://file_path - Unix domain socket. Relative paths are relative to runtime directory - (/run/maddy). +- `unix://file_path` – Unix domain socket. Relative paths are relative to runtime directory (`/run/maddy`). +- `tcp://ADDRESS:PORT` – TCP/IP socket. +- `tls://ADDRESS:PORT` – TCP/IP socket using TLS. -- tcp://ADDRESS:PORT - TCP/IP socket. - -- tls://ADDRESS:PORT - TCP/IP socket using TLS. - -# DUMMY MODULE +## Dummy Module No-op module. It doesn't need to be configured explicitly and can be referenced using "dummy" name. It can act as a delivery target or auth. provider. In the latter case, it will accept any credentials, allowing any client to authenticate using any username and password (use with care!). + diff --git a/docs/reference/endpoints/imap.md b/docs/reference/endpoints/imap.md new file mode 100644 index 000000000..ec0d2e602 --- /dev/null +++ b/docs/reference/endpoints/imap.md @@ -0,0 +1,164 @@ +# IMAP4rev1 endpoint + +Module 'imap' is a listener that implements IMAP4rev1 protocol and provides +access to local messages storage specified by 'storage' directive. + +In most cases, local storage modules will auto-create accounts when they are +accessed via IMAP. This relies on authentication provider used by IMAP endpoint +to provide what essentially is access control. There is a caveat, however: this +auto-creation will not happen when delivering incoming messages via SMTP as +there is no authentication to confirm that this account should indeed be +created. + +## Configuration directives + +``` +imap tcp://0.0.0.0:143 tls://0.0.0.0:993 { + tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key + io_debug no + debug no + insecure_auth no + sasl_login no + auth pam + storage &local_mailboxes + auth_map identity + auth_map_normalize auto + storage_map identity + storage_map_normalize auto +} +``` + +### tls _certificate-path_ _key-path_ { ... } +Default: global directive value + +TLS certificate & key to use. Fine-tuning of other TLS properties is possible +by specifying a configuration block and options inside it: + +``` +tls cert.crt key.key { + protocols tls1.2 tls1.3 +} +``` + +See [TLS configuration / Server](/reference/tls/#server-side) for details. + +--- + +### proxy_protocol _trusted ips..._ { ... } +Default: not enabled + +Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols. +If a list of trusted IP addresses or subnets is provided, only connections +from those will be trusted. + +TLS for the channel between the proxies and maddy can be configured +using a 'tls' directive: +``` +proxy_protocol { + trust 127.0.0.1 ::1 192.168.0.1/24 + tls &proxy_tls +} +``` +Note that the top-level 'tls' directive is not inherited here. If you +need TLS on top of the PROXY protocol, securing the protocol header, +you must declare TLS explicitly. + +--- + +### io_debug _boolean_ +Default: `no` + +Write all commands and responses to stderr. + +--- + +### io_errors _boolean_ +Default: `no` + +Log I/O errors. + +--- + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### insecure_auth _boolean_ +Default: `no` (`yes` if TLS is disabled) + +Allow plain-text authentication over unencrypted connections. + +--- + +### sasl_login _boolean_ +Default: `no` + +Enable support for SASL LOGIN authentication mechanism used by +some outdated clients. + +--- + +### auth _module-reference_ +**Required.** + +Use the specified module for authentication. + +--- + +### storage _module-reference_ +**Required.** + +Use the specified module for message storage. + +--- + +### storage_map _module-reference_ +Default: `identity` + +Use the specified table to map SASL usernames to storage account names. + +Before username is looked up, it is normalized using function defined by +`storage_map_normalize`. + +This directive is useful if you want users user@example.org and user@example.com +to share the same storage account named "user". In this case, use + +``` + storage_map email_localpart +``` + +Note that `storage_map` does not affect the username passed to the +authentication provider. + +It also does not affect how message delivery is handled, you should specify +`delivery_map` in storage module to define how to map email addresses +to storage accounts. E.g. + +``` + storage.imapsql local_mailboxes { + ... + delivery_map email_localpart # deliver "user@*" to mailbox for "user" + } +``` + +--- + +### storage_map_normalize _function_ +Default: `auto` + +Same as `auth_map_normalize` but for `storage_map`. + +--- + +### auth_map_normalize _function_ +Default: `auto` + +Overrides global `auth_map_normalize` value for this endpoint. + +See [Global configuration](/reference/global-config) for details. + + + diff --git a/docs/openmetrics.md b/docs/reference/endpoints/openmetrics.md similarity index 99% rename from docs/openmetrics.md rename to docs/reference/endpoints/openmetrics.md index df77665cd..f455f716a 100644 --- a/docs/openmetrics.md +++ b/docs/reference/endpoints/openmetrics.md @@ -4,6 +4,7 @@ Various server statistics are provided in OpenMetrics format by the "openmetrics" module. To enable it, add the following line to the server config: + ``` openmetrics tcp://127.0.0.1:9749 { } ``` diff --git a/docs/reference/endpoints/smtp.md b/docs/reference/endpoints/smtp.md new file mode 100644 index 000000000..f5fb7b4ae --- /dev/null +++ b/docs/reference/endpoints/smtp.md @@ -0,0 +1,321 @@ +# SMTP/LMTP/Submission endpoint + +Module 'smtp' is a listener that implements ESMTP protocol with optional +authentication, LMTP and Submission support. Incoming messages are processed in +accordance with pipeline rules (explained in Message pipeline section below). + +``` +smtp tcp://0.0.0.0:25 { + hostname example.org + tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key + io_debug no + debug no + insecure_auth no + sasl_login no + read_timeout 10m + write_timeout 1m + shutdown_timeout 3m + max_message_size 32M + max_header_size 1M + auth pam + defer_sender_reject yes + dmarc yes + smtp_max_line_length 4000 + limits { + endpoint rate 10 + endpoint concurrency 500 + } + + # Example pipeline configuration. + destination example.org { + deliver_to &local_mailboxes + } + default_destination { + reject + } +} +``` + +## Configuration directives + +### hostname _string_ +Default: global directive value + +Server name to use in SMTP banner. + +``` +220 example.org ESMTP Service Ready +``` + +--- + +### tls _certificate-path_ _key-path_ { ... } +Default: global directive value + +TLS certificate & key to use. Fine-tuning of other TLS properties is possible +by specifying a configuration block and options inside it: + +``` +tls cert.crt key.key { + protocols tls1.2 tls1.3 +} +``` + +See [TLS configuration / Server](/reference/tls/#server-side) for details. + +--- + +### proxy_protocol _trusted ips..._ { ... }
+Default: not enabled + +Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols. +If a list of trusted IP addresses or subnets is provided, only connections +from those will be trusted. + +TLS for the channel between the proxies and maddy can be configured +using a 'tls' directive: +``` +proxy_protocol { + trust 127.0.0.1 ::1 192.168.0.1/24 + tls &proxy_tls +} +``` + +--- + +### io_debug _boolean_ +Default: `no` + +Write all commands and responses to stderr. + +--- + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### insecure_auth _boolean_ +Default: `no` (`yes` if TLS is disabled) + +Allow plain-text authentication over unencrypted connections. Not recommended! + +--- + +### sasl_login _boolean_ +Default: `no` + +Enable support for SASL LOGIN authentication mechanism used by +some outdated clients. + +--- + +### read_timeout _duration_ +Default: `10m` + +I/O read timeout. + +--- + +### write_timeout _duration_ +Default: `1m` + +I/O write timeout. + +--- + +### shutdown_timeout _duration_ +Default: `3m` + +Time to wait until forcibly closing connections on server shutdown +or configuration reload. + +--- + +### max_message_size _size_ +Default: `32M` + +Limit the size of incoming messages to 'size'. + +--- + +### max_header_size _size_ +Default: `1M` + +Limit the size of incoming message headers to 'size'. + +--- + +### auth _module-reference_ +Default: not specified + +Use the specified module for authentication. + +--- + +### defer_sender_reject _boolean_ +Default: `yes` + +Apply sender-based checks and routing logic when first RCPT TO command +is received. This allows maddy to log recipient address of the rejected +message and also improves interoperability with (improperly implemented) +clients that don't expect an error early in session. + +--- + +### max_logged_rcpt_errors _integer_ +Default: `5` + +Amount of RCPT-time errors that should be logged. Further errors will be +handled silently. This is to prevent log flooding during email dictionary +attacks (address probing). + +--- + +### max_received _integer_ +Default: `50` + +Max. amount of Received header fields in the message header. If the incoming +message has more fields than this number, it will be rejected with the permanent error +5.4.6 ("Routing loop detected"). + +--- + +### buffer `ram`
buffer `fs` _path_
buffer `auto` _max-size_ _path_ +Default: `auto 1M StateDirectory/buffer` + +Temporary storage to use for the body of accepted messages. + +- `ram` – Store the body in RAM. +- `fs` – Write out the message to the FS and read it back as needed. +_path_ can be omitted and defaults to StateDirectory/buffer. +- `auto` – Store message bodies smaller than `_max_size_` entirely in RAM, +otherwise write them out to the FS. _path_ can be omitted and defaults to `StateDirectory/buffer`. + +--- + +### smtp_max_line_length _integer_ +Default: `4000` + +The maximum line length allowed in the SMTP input stream. If client sends a +longer line - connection will be closed and message (if any) will be rejected +with a permanent error. + +RFC 5321 has the recommended limit of 998 bytes. Servers are not required +to handle longer lines correctly but some senders may produce them. + +Unless BDAT extension is used by the sender, this limitation also applies to +the message body. + +--- + +### dmarc _boolean_ +Default: `yes` + +Enforce sender's DMARC policy. Due to implementation limitations, it is not a +check module. + +**Note**: Report generation is not implemented now. + +**Note**: DMARC needs SPF and DKIM checks to function correctly. +Without these, DMARC check will not run. + +--- + +## Rate & concurrency limiting + +### limits { ... } +Default: no limits + +This allows configuring a set of message flow restrictions including +max. concurrency and rate per-endpoint, per-source, per-destination. + +Limits are specified as directives inside the block: + +``` +limits { + all rate 20 + destination concurrency 5 +} +``` + +Supported limits: + +### _scope_ rate _burst_ _period_ + +Rate limit. Restrict the amount of messages processed in _period_ to +_burst_ messages. If period is not specified, 1 second is used. + +### _scope_ concurrency _max_ +Concurrency limit. Restrict the amount of messages processed in parallel +to _max_. + +For each supported limitation, _scope_ determines whether it should be applied +for all messages ("all"), per-sender IP ("ip"), per-sender domain ("source") or +per-recipient domain ("destination"). Having a scope other than "all" means +that the restriction will be enforced independently for each group determined +by scope. E.g. "ip rate 20" means that the same IP cannot send more than 20 +messages per second. "destination concurrency 5" means that no more than 5 +messages can be sent in parallel to a single domain. + +**Note**: At the moment, SMTP endpoint on its own does not support per-recipient +limits. They will be no-op. If you want to enforce a per-recipient restriction +on outbound messages, do so using 'limits' directive for the 'table.remote' module + +It is possible to share limit counters between multiple endpoints (or any other +modules). To do so define a top-level configuration block for module "limits" +and reference it where needed using standard & syntax. E.g. + +``` +limits inbound_limits { + all rate 20 +} + +smtp smtp://0.0.0.0:25 { + limits &inbound_limits + ... +} + +submission tls://0.0.0.0:465 { + limits &inbound_limits + ... +} +``` + +Using an "all rate" restriction in such way means that no more than 20 +messages can enter the server through both endpoints in one second. + +# Submission module (submission) + +Module 'submission' implements all functionality of the 'smtp' module and adds +certain message preprocessing on top of it, additionally authentication is +always required. + +'submission' module checks whether addresses in header fields From, Sender, To, +Cc, Bcc, Reply-To are correct and adds Message-ID and Date if it is missing. + +``` +submission tcp://0.0.0.0:587 tls://0.0.0.0:465 { + # ... same as smtp ... +} +``` + +# LMTP module (lmtp) + +Module 'lmtp' implements all functionality of the 'smtp' module but uses +LMTP (RFC 2033) protocol. + +``` +lmtp unix://lmtp.sock { + # ... same as smtp ... +} +``` + +## Limitations of LMTP implementation + +- Can't be used with TCP. +- Delivery to 'sql' module storage is always atomic, either all recipients will + succeed or none of them will. + diff --git a/docs/reference/global-config.md b/docs/reference/global-config.md new file mode 100644 index 000000000..db0ec1a34 --- /dev/null +++ b/docs/reference/global-config.md @@ -0,0 +1,153 @@ +# Global configuration directives + +These directives can be specified outside of any +configuration blocks and they are applied to all modules. + +Some directives can be overridden on per-module basis (e.g. hostname). + +### state_dir _path_ +Default: `/var/lib/maddy` + +The path to the state directory. This directory will be used to store all +persistent data and should be writable. + +--- + +### runtime_dir _path_ +Default: `/run/maddy` + +The path to the runtime directory. Used for Unix sockets and other temporary +objects. Should be writable. + +--- + +### hostname _domain_ +Default: not specified + +Internet hostname of this mail server. Typicall FQDN is used. It is recommended +to make sure domain specified here resolved to the public IP of the server. + +--- + +### auth_map _module-reference_ +Default: `identity` + +Use the specified table to translate SASL usernames before passing it to the +authentication provider. + +Before username is looked up, it is normalized using function defined by +`auth_map_normalize`. + +Note that `auth_map` does not affect the storage account name used. You probably +should also use `storage_map` in IMAP config block to handle this. + +This directive is useful if used authentication provider does not support +using emails as usernames but you still want users to have separate mailboxes +on separate domains. In this case, use it with `email_localpart` table: + +``` + auth_map email_localpart +``` + +With this configuration, `user@example.org` and `user@example.com` will use +`user` credentials when authenticating, but will access `user@example.org` and +`user@example.com` mailboxes correspondingly. If you want to also accept +`user` as a username, use `auth_map email_localpart_optional`. + +If you want `user@example.org` and `user@example.com` to have the same mailbox, +also set `storage_map` in IMAP config block to use `email_localpart` +(or `email_localpart_optional` if you want to also accept just "user"): + +``` + storage_map email_localpart +``` + +In this case you will need to create storage accounts without domain part in +the name: + +``` +maddy imap-acct create user # instead of user@example.org +``` + +--- + +### auth_map_normalize _function_ +Default: `auto` + +Normalization function to apply to SASL usernames before mapping +them to storage accounts. + +Available options: + +- `auto` `precis_casefold_email` for valid emails, `precis_casefold` otherwise. +- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain +- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string +- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain +- `precis` PRECIS UsernameCasePreserved profile for the entire string +- `casefold` Convert to lower case +- `noop` Nothing + +--- + +### autogenerated_msg_domain _domain_ +Default: not specified + +Domain that is used in From field for auto-generated messages (such as Delivery +Status Notifications). + +--- + +### tls `file` _cert-file_ _pkey-file_ | _module-reference_ | `off` +Default: not specified + +Default TLS certificate to use for all endpoints. + +Must be present in either all endpoint modules configuration blocks or as +global directive. + +You can also specify other configuration options such as cipher suites and TLS +version. See maddy-tls(5) for details. maddy uses reasonable +cipher suites and TLS versions by default so you generally don't have to worry +about it. + +--- + +### tls_client { ... } +Default: not specified + +This is optional block that specifies various TLS-related options to use when +making outbound connections. See TLS client configuration for details on +directives that can be used in it. maddy uses reasonable cipher suites and TLS +versions by default so you generally don't have to worry about it. + +--- + +### log _targets..._ | `off` +Default: `stderr` + +Write log to one of more "targets". + +The target can be one or the following: + +- `stderr` – Write logs to stderr. +- `stderr_ts` – Write logs to stderr with timestamps. +- `syslog` – Send logs to the local syslog daemon. +- _file path_ – Write (append) logs to file. + +Example: + +``` +log syslog /var/log/maddy.log +``` + +**Note:** Maddy does not perform log files rotation, this is the job of the +logrotate daemon. Send SIGUSR1 to maddy process to make it reopen log files. + +--- + +### debug _boolean_ +Default: `no` + +Enable verbose logging for all modules. You don't need that unless you are +reporting a bug. + diff --git a/docs/reference/modifiers/dkim.md b/docs/reference/modifiers/dkim.md new file mode 100644 index 000000000..36fffe275 --- /dev/null +++ b/docs/reference/modifiers/dkim.md @@ -0,0 +1,225 @@ +# DKIM signing + +modify.dkim module is a modifier that signs messages using DKIM +protocol (RFC 6376). + +Each configuration block specifies a single selector +and one or more domains. + +A key will be generated or read for each domain, the key to use +for each message will be selected based on the SMTP envelope sender. Exception +for that is that for domain-less postmaster address and null address, the +key for the first domain will be used. If domain in envelope sender +does not match any of loaded keys, message will not be signed. +Additionally, for each messages From header is checked to +match MAIL FROM and authorization identity (username sender is logged in as). +This can be controlled using require_sender_match directive. + +Generated private keys are stored in unencrypted PKCS#8 format +in state_directory/dkim_keys (`/var/lib/maddy/dkim_keys`). +In the same directory .dns files are generated that contain +public key for each domain formatted in the form of a DNS record. + +## Arguments + +domains and selector can be specified in arguments, so actual modify.dkim use can +be shortened to the following: + +``` +modify { + dkim example.org selector +} +``` + +## Configuration directives + +``` +modify.dkim { + debug no + domains example.org example.com + selector default + key_path dkim-keys/{domain}-{selector}.key + oversign_fields ... + sign_fields ... + header_canon relaxed + body_canon relaxed + sig_expiry 120h # 5 days + hash sha256 + newkey_algo rsa2048 +} +``` + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### domains _string-list_ +**Required**.
+Default: not specified + + +ADministrative Management Domains (ADMDs) taking responsibility for messages. + +Should be specified either as a directive or as an argument. + +--- + +### selector _string_ +**Required**.
+Default: not specified + +Identifier of used key within the ADMD. +Should be specified either as a directive or as an argument. + +--- + +### key_path _string_ +Default: `dkim_keys/{domain}_{selector}.key` + +Path to private key. It should be in PKCS#8 format wrapped in PAM encoding. +If key does not exist, it will be generated using algorithm specified +in newkey_algo. + +Placeholders '{domain}' and '{selector}' will be replaced with corresponding +values from domain and selector directives. + +Additionally, keys in PKCS#1 ("RSA PRIVATE KEY") and +RFC 5915 ("EC PRIVATE KEY") can be read by modify.dkim. Note, however that +newly generated keys are always in PKCS#8. + +--- + +### oversign_fields _list..._ +Default: see below + +Header fields that should be signed n+1 times where n is times they are +present in the message. This makes it impossible to replace field +value by prepending another field with the same name to the message. + +Fields specified here don't have to be also specified in `sign_fields`. + +Default set of oversigned fields: + +- Subject +- To +- From +- Date +- MIME-Version +- Content-Type +- Content-Transfer-Encoding +- Reply-To +- Message-Id +- References +- Autocrypt +- Openpgp + +--- + +### sign_fields _list..._ +Default: see below + +Header fields that should be signed n times where n is times they are +present in the message. For these fields, additional values can be prepended +by intermediate relays, but existing values can't be changed. + +Default set of signed fields: + +- List-Id +- List-Help +- List-Unsubscribe +- List-Post +- List-Owner +- List-Archive +- Resent-To +- Resent-Sender +- Resent-Message-Id +- Resent-Date +- Resent-From +- Resent-Cc + +--- + +### header_canon `relaxed` | `simple` +Default: `relaxed` + +Canonicalization algorithm to use for header fields. With `relaxed`, whitespace within +fields can be modified without breaking the signature, with `simple` no +modifications are allowed. + +--- + +### body_canon `relaxed` | `simple` +Default: `relaxed` + +Canonicalization algorithm to use for message body. With `relaxed`, whitespace within +can be modified without breaking the signature, with `simple` no +modifications are allowed. + +--- + +### sig_expiry _duration_ +Default: `120h` + +Time for which signature should be considered valid. Mainly used to prevent +unauthorized resending of old messages. + +--- + +### hash _hash_ +Default: `sha256` + +Hash algorithm to use when computing body hash. + +sha256 is the only supported algorithm now. + +--- + +### newkey_algo `rsa4096` | `rsa2048` | `ed25519` +Default: `rsa2048` + +Algorithm to use when generating a new key. + +Currently ed25519 is **not** supported by most platforms. + +--- + +### require_sender_match _ids..._ +Default: `envelope auth` + +Require specified identifiers to match From header field and key domain, +otherwise - don't sign the message. + +If From field contains multiple addresses, message will not be +signed unless `allow_multiple_from` is also specified. In that +case only first address will be compared. + +Matching is done in a case-insensitive way. + +Valid values: + +- `off` – Disable check, always sign. +- `envelope` – Require MAIL FROM address to match From header. +- `auth` – If authorization identity contains @ - then require it to + fully match From header. Otherwise, check only local-part + (username). + +--- + +### allow_multiple_from _boolean_ +Default: `no` + +Allow multiple addresses in From header field for purposes of +`require_sender_match` checks. Only first address will be checked, however. + +--- + +### sign_subdomains _boolean_ +Default: `no` + +Sign emails from subdomains using a top domain key. + +Allows only one domain to be specified (can be worked around by using `modify.dkim` +multiple times). diff --git a/docs/reference/modifiers/envelope.md b/docs/reference/modifiers/envelope.md new file mode 100644 index 000000000..0e101cf62 --- /dev/null +++ b/docs/reference/modifiers/envelope.md @@ -0,0 +1,63 @@ +# Envelope sender / recipient rewriting + +`replace_sender` and `replace_rcpt` modules replace SMTP envelope addresses +based on the mapping defined by the table module (maddy-tables(5)). It is possible +to specify 1:N mappings. This allows, for example, implementing mailing lists. + +The address is normalized before lookup (Punycode in domain-part is decoded, +Unicode is normalized to NFC, the whole string is case-folded). + +First, the whole address is looked up. If there is no replacement, local-part +of the address is looked up separately and is replaced in the address while +keeping the domain part intact. Replacements are not applied recursively, that +is, lookup is not repeated for the replacement. + +Recipients are not deduplicated after expansion, so message may be delivered +multiple times to a single recipient. However, used delivery target can apply +such deduplication (imapsql storage does it). + +Definition: + +``` +replace_rcpt
[table arguments] { + [extended table config] +} +replace_sender
[table arguments] { + [extended table config] +} +``` + +Use examples: + +``` +modify { + replace_rcpt file /etc/maddy/aliases + replace_rcpt static { + entry a@example.org b@example.org + entry c@example.org c1@example.org c2@example.org + } + replace_rcpt regexp "(.+)@example.net" "1ドル@example.org" + replace_rcpt regexp "(.+)@example.net" "1ドル@example.org" "1ドル@example.com" +} +``` + +Possible contents of /etc/maddy/aliases in the example above: + +``` +# Replace 'cat' with any domain to 'dog'. +# E.g. cat@example.net -> dog@example.net +cat: dog + +# Replace cat@example.org with cat@example.com. +# Takes priority over the previous line. +cat@example.org: cat@example.com + +# Using aliases in multiple lines +cat2: dog +cat2: mouse +cat2@example.org: cat@example.com +cat2@example.org: cat@example.net +# Comma-separated aliases in multiple lines +cat3: dog , mouse +cat3@example.org: cat@example.com , cat@example.net +``` \ No newline at end of file diff --git a/docs/reference/modules.md b/docs/reference/modules.md new file mode 100644 index 000000000..f327e86ca --- /dev/null +++ b/docs/reference/modules.md @@ -0,0 +1,76 @@ +# Modules introduction + +maddy is built of many small components called "modules". Each module does one +certain well-defined task. Modules can be connected to each other in arbitrary +ways to achieve wanted functionality. Default configuration file defines +set of modules that together implement typical email server stack. + +To specify the module that should be used by another module for something, look +for configuration directives with "module reference" argument. Then +put the module name as an argument for it. Optionally, if referenced module +needs that, put additional arguments after the name. You can also put a +configuration block with additional directives specifing the module +configuration. + +Here are some examples: + +``` +smtp ... { + # Deliver messages to the 'dummy' module with the default configuration. + deliver_to dummy + + # Deliver messages to the 'target.smtp' module with + # 'tcp://127.0.0.1:1125' argument as a configuration. + deliver_to smtp tcp://127.0.0.1:1125 + + # Deliver messages to the 'queue' module with the specified configuration. + deliver_to queue { + target ... + max_tries 10 + } +} +``` + +Additionally, module configuration can be placed in a separate named block +at the top-level and referenced by its name where it is needed. + +Here is the example: +``` +storage.imapsql local_mailboxes { + driver sqlite3 + dsn all.db +} + +smtp ... { + deliver_to &local_mailboxes +} +``` + +It is recommended to use this syntax for modules that are 'expensive' to +initialize such as storage backends and authentication providers. + +For top-level configuration block definition, syntax is as follows: +``` +namespace.module_name config_block_name... { + module_configuration +} +``` +If config\_block\_name is omitted, it will be the same as module\_name. Multiple +names can be specified. All names must be unique. + +Note the "storage." prefix. This is the actual module name and includes +"namespace". It is a little cheating to make more concise names and can +be omitted when you reference the module where it is used since it can +be implied (e.g. putting module reference in "check{}" likely means you want +something with "check." prefix) + +Usual module arguments can't be specified when using this syntax, however, +modules usually provide explicit directives that allow to specify the needed +values. For example 'sql sqlite3 all.db' is equivalent to +``` +storage.imapsql { + driver sqlite3 + dsn all.db +} +``` + diff --git a/docs/reference/smtp-pipeline.md b/docs/reference/smtp-pipeline.md new file mode 100644 index 000000000..b094343fe --- /dev/null +++ b/docs/reference/smtp-pipeline.md @@ -0,0 +1,408 @@ +# SMTP message routing (pipeline) + +# Message pipeline + +A message pipeline is a set of module references and associated rules that +describe how to handle messages. + +The pipeline is responsible for + +- Running message filters (called "checks"), (e.g. DKIM signature verification, + DNSBL lookup, and so on). +- Running message modifiers (e.g. DKIM signature creation). +- Associating each message recipient with one or more delivery targets. + Delivery target is a module that does the final processing (delivery) of the + message. + +Message handling flow is as follows: + +- Execute checks referenced in top-level `check` blocks (if any) +- Execute modifiers referenced in top-level `modify` blocks (if any) +- If there are `source` blocks - select one that matches the message sender (as + specified in MAIL FROM). If there are no `source` blocks - the entire + configuration is assumed to be the `default_source` block. +- Execute checks referenced in `check` blocks inside the selected `source` block + (if any). +- Execute modifiers referenced in `modify` blocks inside selected `source` + block (if any). + +Then, for each recipient: + +- Select the `destination` block that matches it. If there are + no `destination` blocks - the entire used `source` block is interpreted as if it + was a `default_destination` block. +- Execute checks referenced in the `check` block inside the selected `destination` + block (if any). +- Execute modifiers referenced in `modify` block inside the selected `destination` + block (if any). +- If the used block contains the `reject` directive - reject the recipient with + the specified SMTP status code. +- If the used block contains the `deliver_to` directive - pass the message to the + specified target module. Only recipients that are handled + by the used block are visible to the target. + +Each recipient is handled only by a single `destination` block, in case of +overlapping `destination` - the first one takes priority. + +``` +destination example.org { + deliver_to targetA +} +destination example.org { # ambiguous and thus not allowed + deliver_to targetB +} +``` + +Same goes for `source` blocks, each message is handled only by a single block. + +Each recipient block should contain at least one `deliver_to` directive or +`reject` directive. If `destination` blocks are used, then +`default_destination` block should also be used to specify behavior for +unmatched recipients. Same goes for source blocks, `default_source` should be +used if `source` is used. + +That is, pipeline configuration should explicitly specify behavior for each +possible sender/recipient combination. + +Additionally, directives that specify final handling decision (`deliver_to`, +`reject`) can't be used at the same level as source/destination rules. +Consider example: + +``` +destination example.org { + deliver_to local_mboxes +} +reject +``` + +It is not obvious whether `reject` applies to all recipients or +just for non-example.org ones, hence this is not allowed. + +Complete configuration example using all of the mentioned directives: + +``` +check { + # Run a check to make sure source SMTP server identification + # is legit. + spf +} + +# Messages coming from senders at example.org will be handled in +# accordance with the following configuration block. +source example.org { + # We are example.com, so deliver all messages with recipients + # at example.com to our local mailboxes. + destination example.com { + deliver_to &local_mailboxes + } + + # We don't do anything with recipients at different domains + # because we are not an open relay, thus we reject them. + default_destination { + reject 521 5.0.0 "User not local" + } +} + +# We do our business only with example.org, so reject all +# other senders. +default_source { + reject +} +``` + +## Directives + + +### check _block name_ { ... } +Context: pipeline configuration, source block, destination block + +List of the module references for checks that should be executed on +messages handled by block where 'check' is placed in. + +Note that message body checks placed in destination block are currently +ignored. Due to the way SMTP protocol is defined, they would cause message to +be rejected for all recipients which is not what you usually want when using +such configurations. + +Example: + +``` +check { + # Reference implicitly defined default configuration for check. + spf + + # Inline definition of custom config. + spf { + # Configuration for spf goes here. + permerr_action reject + } +} +``` + +It is also possible to define the block of checks at the top level +as "checks" module and reference it using & syntax. Example: + +``` +checks inbound_checks { + spf + dkim +} + +# ... somewhere else ... +{ + ... + check &inbound_checks +} +``` + +--- + +### modify { ... } +Default: not specified
+Context: pipeline configuration, source block, destination block + +List of the module references for modifiers that should be executed on +messages handled by block where 'modify' is placed in. + +Message modifiers are similar to checks with the difference in that checks +purpose is to verify whether the message is legitimate and valid per local +policy, while modifier purpose is to post-process message and its metadata +before final delivery. + +For example, modifier can replace recipient address to make message delivered +to the different mailbox or it can cryptographically sign outgoing message +(e.g. using DKIM). Some modifier can perform multiple unrelated modifications +on the message. + +**Note**: Modifiers that affect source address can be used only globally or on +per-source basis, they will be no-op inside destination blocks. Modifiers that +affect the message header will affect it for all recipients. + +It is also possible to define the block of modifiers at the top level +as "modiifers" module and reference it using & syntax. Example: + +``` +modifiers local_modifiers { + replace_rcpt file /etc/maddy/aliases +} + +# ... somewhere else ... +{ + ... + modify &local_modifiers +} +``` + +--- + +### reject _smtp-code_ _smtp-enhanced-code_ _error-description_
reject _smtp-code_ _smtp-enhanced-code_
reject _smtp-code_
reject +Context: destination block + +Messages handled by the configuration block with this directive will be +rejected with the specified SMTP error. + +If you aren't sure which codes to use, use 541 and 5.4.0 with your message or +just leave all arguments out, the error description will say "message is +rejected due to policy reasons" which is usually what you want to mean. + +`reject` can't be used in the same block with `deliver_to` or +`destination`/`source` directives. + +Example: + +``` +reject 541 5.4.0 "We don't like example.org, go away" +``` + +--- + +### deliver_to _target-config-block_ +Context: pipeline configuration, source block, destination block + +Deliver the message to the referenced delivery target. What happens next is +defined solely by used target. If `deliver_to` is used inside `destination` +block, only matching recipients will be passed to the target. + +--- + +### source_in _table-reference_ { ... } +Context: pipeline configuration + +Handle messages with envelope senders present in the specified table in +accordance with the specified configuration block. + +Takes precedence over all `sender` directives. + +Example: + +``` +source_in file /etc/maddy/banned_addrs { + reject 550 5.7.0 "You are not welcome here" +} +source example.org { + ... +} +... +``` + +See `destination_in` documentation for note about table configuration. + +--- + +### source _rules..._ { ... } +Context: pipeline configuration + +Handle messages with MAIL FROM value (sender address) matching any of the rules +in accordance with the specified configuration block. + +"Rule" is either a domain or a complete address. In case of overlapping +'rules', first one takes priority. Matching is case-insensitive. + +Example: + +``` +# All messages coming from example.org domain will be delivered +# to local_mailboxes. +source example.org { + deliver_to &local_mailboxes +} +# Messages coming from different domains will be rejected. +default_source { + reject 521 5.0.0 "You were not invited" +} +``` + +--- + +### reroute { ... } +Context: pipeline configuration, source block, destination block + +This directive allows to make message routing decisions based on the +result of modifiers. The block can contain all pipeline directives and they +will be handled the same with the exception that source and destination rules +will use the final recipient and sender values (e.g. after all modifiers are +applied). + +Here is the concrete example how it can be useful: + +``` +destination example.org { + modify { + replace_rcpt file /etc/maddy/aliases + } + reroute { + destination example.org { + deliver_to &local_mailboxes + } + default_destination { + deliver_to &remote_queue + } + } +} +``` + +This configuration allows to specify alias local addresses to remote ones +without being an open relay, since remote_queue can be used only if remote +address was introduced as a result of rewrite of local address. + +**Warning**: If you have DMARC enabled (default), results generated by SPF +and DKIM checks inside a reroute block **will not** be considered in DMARC +evaluation. + +--- + +### destination_in _table-reference_ { ... } +Context: pipeline configuration, source block + +Handle messages with envelope recipients present in the specified table in +accordance with the specified configuration block. + +Takes precedence over all 'destination' directives. + +Example: + +``` +destination_in file /etc/maddy/remote_addrs { + deliver_to smtp tcp://10.0.0.7:25 +} +destination example.com { + deliver_to &local_mailboxes +} +... +``` + +Note that due to the syntax restrictions, it is not possible to specify +extended configuration for table module. E.g. this is not valid: + +``` +destination_in sql_table { + dsn ... + driver ... +} { + deliver_to whatever +} +``` + +In this case, configuration should be specified separately and be referneced +using '&' syntax: + +``` +table.sql_table remote_addrs { + dsn ... + driver ... +} + +whatever { + destination_in &remote_addrs { + deliver_to whatever + } +} +``` + +--- + +### destination _rule..._ { ... } +Context: pipeline configuration, source block + +Handle messages with RCPT TO value (recipient address) matching any of the +rules in accordance with the specified configuration block. + +"Rule" is either a domain or a complete address. Duplicate rules are not +allowed. Matching is case-insensitive. + +Note that messages with multiple recipients are split into multiple messages if +they have recipients matched by multiple blocks. Each block will see the +message only with recipients matched by its rules. + +Example: + +``` +# Messages with recipients at example.com domain will be +# delivered to local_mailboxes target. +destination example.com { + deliver_to &local_mailboxes +} + +# Messages with other recipients will be rejected. +default_destination { + rejected 541 5.0.0 "User not local" +} +``` + +## Reusable pipeline snippets (msgpipeline module) + +The message pipeline can be used independently of the SMTP module in other +contexts that require a delivery target via `msgpipeline` module. + +Example: + +``` +msgpipeline local_routing { + destination whatever.com { + deliver_to dummy + } +} + +# ... somewhere else ... +deliver_to &local_routing +``` \ No newline at end of file diff --git a/docs/reference/storage/imap-filters.md b/docs/reference/storage/imap-filters.md new file mode 100644 index 000000000..b125a07ea --- /dev/null +++ b/docs/reference/storage/imap-filters.md @@ -0,0 +1,70 @@ +# IMAP filters + +Most storage backends support application of custom code late in delivery +process. As opposed to using SMTP pipeline modifiers or checks, it allows +modifying IMAP-specific message attributes. In particular, it allows +code to change target folder and add IMAP flags (keywords) to the message. + +There is no way to reject message using IMAP filters, this should be done +earlier in SMTP pipeline logic. Quarantined messages are not processed +by IMAP filters and are unconditionally delivered to Junk folder (or other +folder with \Junk special-use attribute). + +To use an IMAP filter, specify it in the 'imap\_filter' directive for the +used storage backend, like this: +``` +storage.imapsql local_mailboxes { + ... + + imap_filter { + command /etc/maddy/sieve.sh {account_name} + } +} +``` + +## System command filter (imap.filter.command) + +This filter is similar to check.command module +and runs a system command to obtain necessary information. + +Usage: +``` +command executable_name args... { } +``` + +Same as check.command, following placeholders are supported for command +arguments: {source\_ip}, {source\_host}, {source\_rdns}, {msg\_id}, {auth\_user}, +{sender}. Note: placeholders +in command name are not processed to avoid possible command injection attacks. + +Additionally, for imap.filter.command, {account\_name} placeholder is replaced +with effective IMAP account name, {rcpt_to}, {original_rcpt_to} provide +access to the SMTP envelope recipient (before and after any rewrites), +{subject} is replaced with the Subject header, if it is present. + +Note that if you use provided systemd units on Linux, maddy executable is +sandboxed - all commands will be executed with heavily restricted filesystem +access and other privileges. Notably, /tmp is isolated and all directories +except for /var/lib/maddy and /run/maddy are read-only. You will need to modify +systemd unit if your command needs more privileges. + +Command output should consist of zero or more lines. First one, if non-empty, overrides +destination folder. All other lines contain additional IMAP flags to add +to the message. If command wants to add flags without changing folder - first +line should be empty. + +It is valid for command to not write anything to stdout. In this case its +execution will have no effect on delivery. + +Output example: +``` +Junk +``` +In this case, message will be placed in the Junk folder. + +``` + +$Label1 +``` +In this case, message will be placed in inbox and will have +'$Label1' added. diff --git a/docs/reference/storage/imapsql.md b/docs/reference/storage/imapsql.md new file mode 100644 index 000000000..f1abbb372 --- /dev/null +++ b/docs/reference/storage/imapsql.md @@ -0,0 +1,208 @@ +# SQL-indexed storage + +The imapsql module implements database for IMAP index and message +metadata using SQL-based relational database. + +Message contents are stored in an "blob store" defined by msg_store +directive. By default this is a file system directory under /var/lib/maddy. + +Supported RDBMS: +- SQLite 3.25.0 +- PostgreSQL 9.6 or newer +- CockroachDB 20.1.5 or newer + +Account names are required to have the form of a email address (unless configured otherwise) +and are case-insensitive. UTF-8 names are supported with restrictions defined in the +PRECIS UsernameCaseMapped profile. + +``` +storage.imapsql { + driver sqlite3 + dsn imapsql.db + msg_store fs messages/ +} +``` + +imapsql module also can be used as a lookup table. +It returns empty string values for existing usernames. This might be useful +with `destination_in` directive e.g. to implement catch-all +addresses (this is a bad idea to do so, this is just an example): +``` +destination_in &local_mailboxes { + deliver_to &local_mailboxes +} +destination example.org { + modify { + replace_rcpt regexp ".*" "catchall@example.org" + } + deliver_to &local_mailboxes +} +``` + + +## Arguments + +Specify the driver and DSN. + +## Configuration directives + +### driver _string_ +**Required.**
+Default: not specified + +Use a specified driver to communicate with the database. Supported values: +sqlite3, postgres. + +Should be specified either via an argument or via this directive. + +--- + +### dsn _string_ +**Required.**
+Default: not specified + +Data Source Name, the driver-specific value that specifies the database to use. + +For SQLite3 this is just a file path. +For PostgreSQL: [https://godoc.org/github.com/lib/pq#hdr-Connection\_String\_Parameters](https://godoc.org/github.com/lib/pq#hdr-Connection\_String\_Parameters) + +Should be specified either via an argument or via this directive. + +--- + +### msg_store _store_ +Default: `fs messages/` + +Module to use for message bodies storage. + +See "Blob storage" section for what you can use here. + +--- + +### compression `off`
compression _algorithm_
compression _algorithm_ _level_ +Default: `off` + +Apply compression to message contents. +Supported algorithms: `lz4`, `zstd`. + +--- + +### appendlimit _size_ +Default: `32M` + +Don't allow users to add new messages larger than 'size'. + +This does not affect messages added when using module as a delivery target. +Use `max_message_size` directive in SMTP endpoint module to restrict it too. + +--- + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### junk_mailbox _name_ +Default: `Junk` + +The folder to put quarantined messages in. Thishis setting is not used if user +does have a folder with "Junk" special-use attribute. + +--- + +### disable_recent _boolean_ +Default: `true` + +Disable RFC 3501-conforming handling of \Recent flag. + +This significantly improves storage performance when SQLite3 or CockroackDB is +used at the cost of confusing clients that use this flag. + +--- + +### sqlite_cache_size _integer_ +Default: defined by SQLite + +SQLite page cache size. If positive - specifies amount of pages (1 page - 4 +KiB) to keep in cache. If negative - specifies approximate upper bound +of cache size in KiB. + +--- + +### sqlite_busy_timeout _integer_ +Default: `5000000` + +SQLite-specific performance tuning option. Amount of milliseconds to wait +before giving up on DB lock. + +--- + +### imap_filter { ... } +Default: not set + +Specifies IMAP filters to apply for messages delivered from SMTP pipeline. + +Ex. + +``` +imap_filter { + command /etc/maddy/sieve.sh {account_name} +} +``` + +--- + +### delivery_map _table_ +Default: `identity` + +Use specified table module to map recipient +addresses from incoming messages to mailbox names. + +Normalization algorithm specified in `delivery_normalize` is appied before +`delivery_map`. + +--- + +### delivery_normalize _name_ +Default: `precis_casefold_email` + +Normalization function to apply to email addresses before mapping them +to mailboxes. + +See `auth_normalize`. + +--- + +### auth_map _table_ +**Deprecated:** Use `storage_map` in imap config instead.
+Default: `identity` + +Use specified table module to map authentication +usernames to mailbox names. + +Normalization algorithm specified in auth_normalize is applied before +auth_map. + +--- + +### auth_normalize _name_ +**Deprecated:** Use `storage_map_normalize` in imap config instead.
+**Default**: `precis_casefold_email` + +Normalization function to apply to authentication usernames before mapping +them to mailboxes. + +Available options: + +- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain +- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string +- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain +- `precis` PRECIS UsernameCasePreserved profile for the entire string +- `casefold` Convert to lower case +- `noop` Nothing + +Note: On message delivery, recipient address is unconditionally normalized +using `precis_casefold_email` function. + diff --git a/docs/reference/table/auth.md b/docs/reference/table/auth.md new file mode 100644 index 000000000..4bfe4bd58 --- /dev/null +++ b/docs/reference/table/auth.md @@ -0,0 +1,6 @@ +# Authentication providers + +Most authentication providers are also usable as a table +that contains all usernames known to the module. Exceptions are auth.external and +pam as underlying interfaces do not define a way to check credentials +existence. diff --git a/docs/reference/table/chain.md b/docs/reference/table/chain.md new file mode 100644 index 000000000..1cbc24c12 --- /dev/null +++ b/docs/reference/table/chain.md @@ -0,0 +1,41 @@ +# Table chaining + +The table.chain module allows chaining together multiple table modules +by using value returned by a previous table as an input for the second +table. + +Example: +``` +table.chain { + step regexp "(.+)(\\+[^+"@]+)?@example.org" "1ドル@example.org" + step file /etc/maddy/emails +} +``` +This will strip +prefix from mailbox before looking it up +in /etc/maddy/emails list. + +## Configuration directives + +### step _table_ + +Adds a table module to the chain. If input value is not in the table +(e.g. file) - return "not exists" error. + +--- + +### optional_step _table_ + +Same as step but if input value is not in the table - it is passed to the +next step without changes. + +Example: +Something like this can be used to map emails to usernames +after translating them via aliases map: + +``` +table.chain { + optional_step file /etc/maddy/aliases + step regexp "(.+)@(.+)" "1ドル" +} +``` + diff --git a/docs/reference/table/email_localpart.md b/docs/reference/table/email_localpart.md new file mode 100644 index 000000000..19b90f191 --- /dev/null +++ b/docs/reference/table/email_localpart.md @@ -0,0 +1,20 @@ +# Email local part + +The module `table.email_localpart` extracts and unescapes local ("username") part +of the email address. + +E.g. + +* `test@example.org` => `test` +* `"test @ a"@example.org` => `test @ a` + +Mappings for invalid emails are not defined (will be treated as non-existing +values). + +``` +table.email_localpart { } +``` + +`table.email_localpart_optional` works the same, but returns non-email strings +as is. This can be used if you want to accept both `user@example.org` and +`user` somewhere and treat it the same. diff --git a/docs/reference/table/email_with_domain.md b/docs/reference/table/email_with_domain.md new file mode 100644 index 000000000..6a719e0e0 --- /dev/null +++ b/docs/reference/table/email_with_domain.md @@ -0,0 +1,37 @@ +# Email with domain + +The table module `table.email_with_domain` appends one or more +domains (allowing 1:N expansion) to the specified value. + +``` +table.email_with_domain DOMAIN DOMAIN... { } +``` + +It can be used to implement domain-level expansion for aliases if used together +with `table.chain`. Example: + +``` +modify { + replace_rcpt chain { + step email_local_part + step email_with_domain example.org example.com + } +} +``` + +This configuration will alias `anything@anydomain` to `anything@example.org` +and `anything@example.com`. + +It is also useful with `authorize_sender` to authorize sending using multiple +addresses under different domains if non-email usernames are used for +authentication: + +``` +check.authorize_sender { + ... + user_to_email email_with_domain example.org example.com +} +``` + +This way, user authenticated as `user` will be allowed to use +`user@example.org` or `user@example.com` as a sender address. diff --git a/docs/reference/table/file.md b/docs/reference/table/file.md new file mode 100644 index 000000000..03254f088 --- /dev/null +++ b/docs/reference/table/file.md @@ -0,0 +1,58 @@ +# File + +table.file module builds string-string mapping from a text file. + +File is reloaded every 15 seconds if there are any changes (detected using +modification time). No changes are applied if file contains syntax errors. + +Definition: +``` +file +``` +or +``` +file { + file +} +``` + +Usage example: +``` +# Resolve SMTP address aliases using text file mapping. +modify { + replace_rcpt file /etc/maddy/aliases +} +``` + +## Syntax + +Better demonstrated by examples: + +``` +# Lines starting with # are ignored. + +# And so are lines only with whitespace. + +# Whenever 'aaa' is looked up, return 'bbb' +aaa: bbb + + # Trailing and leading whitespace is ignored. + ccc: ddd + +# If there is no colon, the string is translated into "" +# That is, the following line is equivalent to +# aaa: +aaa + +# If the same key is used multiple times - table.file will return +# multiple values when queries. +ddd: firstvalue +ddd: secondvalue + +# Alternatively, multiple values can be specified +# using a comma. There is no support for escaping +# so you would have to use a different format if you require +# comma-separated values. +ddd: firstvalue, secondvalue +``` + diff --git a/docs/reference/table/regexp.md b/docs/reference/table/regexp.md new file mode 100644 index 000000000..39e873db9 --- /dev/null +++ b/docs/reference/table/regexp.md @@ -0,0 +1,63 @@ +# Regexp rewrite table + +The 'regexp' module implements table lookups by applying a regular expression +to the key value. If it matches - 'replacement' value is returned with $N +placeholders being replaced with corresponding capture groups from the match. +Otherwise, no value is returned. + +The regular expression syntax is the subset of PCRE. See +[https://golang.org/pkg/regexp/syntax](https://golang.org/pkg/regexp/syntax)/ for details. + +``` +table.regexp [replacement] { + full_match yes + case_insensitive yes + expand_placeholders yes +} +``` + +Note that [replacement] is optional. If it is not included - table.regexp +will return the original string, therefore acting as a regexp match check. +This can be useful in combination in `destination_in` for +advanced matching: + +``` +destination_in regexp ".*-bounce+.*@example.com" { + ... +} +``` + +## Configuration directives + +### full_match _boolean_ +Default: `yes` + +Whether to implicitly add start/end anchors to the regular expression. +That is, if `full_match` is `yes`, then the provided regular expression should +match the whole string. With `no` - partial match is enough. + +--- + +### case_insensitive _boolean_ +Default: `yes` + +Whether to make matching case-insensitive. + +--- + +### expand_placeholders _boolean_ +Default: `yes` + +Replace '$name' and '${name}' in the replacement string with contents of +corresponding capture groups from the match. + +To insert a literal $ in the output, use $$ in the template. + +## Identity table (table.identity) + +The module 'identity' is a table module that just returns the key looked up. + +``` +table.identity { } +``` + diff --git a/docs/reference/table/sql_query.md b/docs/reference/table/sql_query.md new file mode 100644 index 000000000..9b3b9ebfc --- /dev/null +++ b/docs/reference/table/sql_query.md @@ -0,0 +1,120 @@ +# SQL query mapping + +The table.sql_query module implements table interface using SQL queries. + +Definition: + +``` +table.sql_query { + driver + dsn + lookup + + # Optional: + init + list + add + del + set +} +``` + +Usage example: + +``` +# Resolve SMTP address aliases using PostgreSQL DB. +modify { + replace_rcpt sql_query { + driver postgres + dsn "dbname=maddy user=maddy" + lookup "SELECT alias FROM aliases WHERE address = 1ドル" + } +} +``` + +## Configuration directives + +### driver _driver name_ +**Required.** + +Driver to use to access the database. + +Supported drivers: `postgres`, `sqlite3` (if compiled with C support) + +--- + +### dsn _data source name_ +**Required.** + +Data Source Name to pass to the driver. For SQLite3 this is just a path to DB +file. For Postgres, see +[https://pkg.go.dev/github.com/lib/pq?tab=doc#hdr-Connection\_String\_Parameters](https://pkg.go.dev/github.com/lib/pq?tab=doc#hdr-Connection\_String\_Parameters) + +--- + +### lookup _query_ +**Required.** + +SQL query to use to obtain the lookup result. + +It will get one named argument containing the lookup key. Use :key +placeholder to access it in SQL. The result row set should contain one row, one +column with the string that will be used as a lookup result. If there are more +rows, they will be ignored. If there are more columns, lookup will fail. If +there are no rows, lookup returns "no results". If there are any error - lookup +will fail. + +--- + +### init _queries..._ +Default: empty + +List of queries to execute on initialization. Can be used to configure RDBMS. + +Example, to improve SQLite3 performance: + +``` +table.sql_query { + driver sqlite3 + dsn whatever.db + init "PRAGMA journal_mode=WAL" \ + "PRAGMA synchronous=NORMAL" + lookup "SELECT alias FROM aliases WHERE address = 1ドル" +} +``` + +--- + +### named_args _boolean_ +Default: `yes` + +Whether to use named parameters binding when executing SQL queries +or not. + +Note that maddy's PostgreSQL driver does not support named parameters and +SQLite3 driver has issues handling numbered parameters: +[https://github.com/mattn/go-sqlite3/issues/472](https://github.com/mattn/go-sqlite3/issues/472) + +--- + +### add _query_
list _query_
set _query_
del _query_ +Default: none + +If queries are set to implement corresponding table operations - table becomes +"mutable" and can be used in contexts that require writable key-value store. + +'add' query gets :key, :value named arguments - key and value strings to store. +They should be added to the store. The query **should** not add multiple values +for the same key and **should** fail if the key already exists. + +'list' query gets no arguments and should return a column with all keys in +the store. + +'set' query gets :key, :value named arguments - key and value and should replace the existing +entry in the database. + +'del' query gets :key argument - key and should remove it from the database. + +If `named_args` is set to `no` - key is passed as the first numbered parameter +(1ドル), value is passed as the second numbered parameter (2ドル). + diff --git a/docs/reference/table/static.md b/docs/reference/table/static.md new file mode 100644 index 000000000..e71b448f8 --- /dev/null +++ b/docs/reference/table/static.md @@ -0,0 +1,21 @@ +# Static table + +The 'static' module implements table lookups using key-value pairs in its +configuration. + +``` +table.static { + entry KEY1 VALUE1 + entry KEY2 VALUE2 + ... +} +``` + +## Configuration directives + +### entry _key_ _value_ + +Add an entry to the table. + +If the same key is used multiple times, the last one takes effect. + diff --git a/docs/reference/targets/queue.md b/docs/reference/targets/queue.md new file mode 100644 index 000000000..373ff1a45 --- /dev/null +++ b/docs/reference/targets/queue.md @@ -0,0 +1,95 @@ +# Local queue + +Queue module buffers messages on disk and retries delivery multiple times to +another target to ensure reliable delivery. + +It is also responsible for generation of DSN messages +in case of delivery failures. + +## Arguments + +First argument specifies directory to use for storage. +Relative paths are relative to the StateDirectory. + +## Configuration directives + +``` +target.queue { + target remote + location ... + max_parallelism 16 + max_tries 4 + bounce { + destination example.org { + deliver_to &local_mailboxes + } + default_destination { + reject + } + } + + autogenerated_msg_domain example.org + debug no +} +``` + +### target _block_name_ +**Required.**
+Default: not specified + +Delivery target to use for final delivery. + +--- + +### location _directory_ +Default: `StateDirectory/configuration_block_name` + +File system directory to use to store queued messages. +Relative paths are relative to the StateDirectory. + +--- + +### max_parallelism _integer_ +Default: `16` + +Start up to _integer_ goroutines for message processing. Basically, this option +limits amount of messages tried to be delivered concurrently. + +--- + +### max_tries _integer_ +Default: `20` + +Attempt delivery up to _integer_ times. Note that no more attempts will be done +is permanent error occurred during previous attempt. + +Delay before the next attempt will be increased exponentially using the +following formula: 15mins * 1.2 ^ (n - 1) where n is the attempt number. +This gives you approximately the following sequence of delays: +18mins, 21mins, 25mins, 31mins, 37mins, 44mins, 53mins, 64mins, ... + +--- + +### bounce { ... } +Default: not specified + +This configuration contains pipeline configuration to be used for generated DSN +(Delivery Status Notification) messages. + +If this is block is not present in configuration, DSNs will not be generated. +Note, however, this is not what you want most of the time. + +--- + +### autogenerated_msg_domain _domain_ +Default: global directive value + +Domain to use in sender address for DSNs. Should be specified too if 'bounce' +block is specified. + +--- + +### debug _boolean_ +Default: `no` + +Enable verbose logging. \ No newline at end of file diff --git a/docs/reference/targets/remote.md b/docs/reference/targets/remote.md new file mode 100644 index 000000000..9a1b60616 --- /dev/null +++ b/docs/reference/targets/remote.md @@ -0,0 +1,295 @@ +# Remote MX delivery + +Module that implements message delivery to remote MTAs discovered via DNS MX +records. You probably want to use it with queue module for reliability. + +If a message check marks a message as 'quarantined', remote module +will refuse to deliver it. + +## Configuration directives + +``` +target.remote { + hostname mx.example.org + debug no +} +``` + +### hostname _domain_ +Default: global directive value + +Hostname to use client greeting (EHLO/HELO command). Some servers require it to +be FQDN, SPF-capable servers check whether it corresponds to the server IP +address, so it is better to set it to a domain that resolves to the server IP. + +--- + +### limits { ... } +Default: no limits + +See ['limits' directive for SMTP endpoint](/reference/endpoints/smtp/#rate-concurrency-limiting). +It works the same except for address domains used for +per-source/per-destination are as observed when message exits the server. + +--- + +### local_ip _ip-address_ +Default: empty + +Choose the local IP to bind for outbound SMTP connections. + +--- + +### force_ipv4 _boolean_ +Default: `false` + +Force resolving outbound SMTP domains to IPv4 addresses. Some server providers +do not offer a way to properly set reverse PTR domains for IPv6 addresses; this +option makes maddy only connect to IPv4 addresses so that its public IPv4 address +is used to connect to that server, and thus reverse PTR checks are made against +its IPv4 address. + +Warning: this may break sending outgoing mail to IPv6-only SMTP servers. + +--- + +### connect_timeout _duration_ +Default: `5m` + +Timeout for TCP connection establishment. + +RFC 5321 recommends 5 minutes for "initial greeting" that includes TCP +handshake. maddy uses two separate timers - one for "dialing" (DNS A/AAAA +lookup + TCP handshake) and another for "initial greeting". This directive +configures the former. The latter is not configurable and is hardcoded to be +5 minutes. + +--- + +### command_timeout _duration_ +Default: `5m` + +Timeout for any SMTP command (EHLO, MAIL, RCPT, DATA, etc). + +If STARTTLS is used this timeout also applies to TLS handshake. + +RFC 5321 recommends 5 minutes for MAIL/RCPT and 3 minutes for +DATA. + +--- + +### submission_timeout _duration_ +Default: `12m` + +Time to wait after the entire message is sent (after "final dot"). + +RFC 5321 recommends 10 minutes. + +--- + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### requiretls_override _boolean_ +Default: `true` + +Allow local security policy to be disabled using 'TLS-Required' header field in +sent messages. Note that the field has no effect if transparent forwarding is +used, message body should be processed before outbound delivery starts for it +to take effect (e.g. message should be queued using 'queue' module). + +--- + +### relaxed_requiretls _boolean_ +Default: `true` + +This option disables strict conformance with REQUIRETLS specification and +allows forwarding of messages 'tagged' with REQUIRETLS to MXes that are not +advertising REQUIRETLS support. It is meant to allow REQUIRETLS use without the +need to have support from all servers. It is based on the assumption that +server referenced by MX record is likely the final destination and therefore +there is only need to secure communication towards it and not beyond. + +--- + +### conn_reuse_limit _integer_ +Default: `10` + +Amount of times the same SMTP connection can be used. +Connections are never reused if the previous DATA command failed. + +--- + +### conn_max_idle_count _integer_ +Default: `10` + +Max. amount of idle connections per recipient domains to keep in cache. + +--- + +### conn_max_idle_time _integer_ +Default: `150` (2.5 min) + +Amount of time the idle connection is still considered potentially usable. + +--- + +## Security policies + +### mx_auth { ... } +Default: no policies + +'remote' module implements a number of of schemes and protocols necessary to +ensure security of message delivery. Most of these schemes are concerned with +authentication of recipient server and TLS enforcement. + +To enable mechanism, specify its name in the `mx_auth` directive block: + +``` +mx_auth { + dane + mtasts +} +``` + +Additional configuration is possible if supported by the mechanism by +specifying additional options as a block for the corresponding mechanism. +E.g. + +``` +mtasts { + cache ram +} +``` + +If the `mx_auth` directive is not specified, no mechanisms are enabled. Note +that, however, this makes outbound SMTP vulnerable to a numerous downgrade +attacks and hence not recommended. + +It is possible to share the same set of policies for multiple 'remote' module +instances by defining it at the top-level using `mx_auth` module and then +referencing it using standard & syntax: + +``` +mx_auth outbound_policy { + dane + mtasts { + cache ram + } +} + +# ... somewhere else ... + +deliver_to remote { + mx_auth &outbound_policy +} + +# ... somewhere else ... + +deliver_to remote { + mx_auth &outbound_policy + tls_client { ... } +} +``` + +--- + +### MTA-STS + +Checks MTA-STS policy of the recipient domain. Provides proper authentication +and TLS enforcement for delivery, but partially vulnerable to persistent active +attacks. + +Sets MX level to "mtasts" if the used MX matches MTA-STS policy even if it is +not set to "enforce" mode. + +``` +mtasts { + cache fs + fs_dir StateDirectory/mtasts_cache +} +``` + +### cache `fs` | `ram` +Default: `fs` + +Storage to use for MTA-STS cache. 'fs' is to use a filesystem directory, 'ram' +to store the cache in memory. + +It is recommended to use 'fs' since that will not discard the cache (and thus +cause MTA-STS security to disappear) on server restart. However, using the RAM +cache can make sense for high-load configurations with good uptime. + +### fs_dir _directory_ +Default: `StateDirectory/mtasts_cache` + +Filesystem directory to use for policies caching if 'cache' is set to 'fs'. + +--- + +### DNSSEC + +Checks whether MX records are signed. Sets MX level to "dnssec" is they are. + +maddy does not validate DNSSEC signatures on its own. Instead it relies on +the upstream resolver to do so by causing lookup to fail when verification +fails and setting the AD flag for signed and verified zones. As a safety +measure, if the resolver is not 127.0.0.1 or ::1, the AD flag is ignored. + +DNSSEC is currently not supported on Windows and other platforms that do not +have the /etc/resolv.conf file in the standard format. + +``` +dnssec { } +``` + +--- + +### DANE + +Checks TLSA records for the recipient MX. Provides downgrade-resistant TLS +enforcement. + +Sets TLS level to "authenticated" if a valid and matching TLSA record uses +DANE-EE or DANE-TA usage type. + +See above for notes on DNSSEC. DNSSEC support is required for DANE to work. + +``` +dane { } +``` + +--- + +### Local policy + +Checks effective TLS and MX levels (as set by other policies) against local +configuration. + +``` +local_policy { + min_tls_level none + min_mx_level none +} +``` + +Using `local_policy off` is equivalent to setting both directives to `none`. + +### min_tls_level `none` | `encrypted` | `authenticated` +Default: `encrypted` + +Set the minimal TLS security level required for all outbound messages. + +See [Security levels](/seclevels) page for details. + +### min_mx_level `none` | `mtasts` | `dnssec` +Default: `none` + +Set the minimal MX security level required for all outbound messages. + +See [Security levels](/seclevels) page for details. + diff --git a/docs/reference/targets/smtp.md b/docs/reference/targets/smtp.md new file mode 100644 index 000000000..ebbc4308b --- /dev/null +++ b/docs/reference/targets/smtp.md @@ -0,0 +1,123 @@ +# SMTP & LMTP transparent forwarding + +Module that implements transparent forwarding of messages over SMTP. + +Use in pipeline configuration: + +``` +deliver_to smtp tcp://127.0.0.1:5353 +# or +deliver_to smtp tcp://127.0.0.1:5353 { + # Other settings, see below. +} +``` + +target.lmtp can be used instead of target.smtp to +use LMTP protocol. + +Endpoint addresses use format described in [Configuration files syntax / Address definitions](/reference/config-syntax/#address-definitions). + +## Configuration directives + +``` +target.smtp { + debug no + tls_client { + ... + } + attempt_starttls yes + require_tls no + auth off + targets tcp://127.0.0.1:2525 + connect_timeout 5m + command_timeout 5m + submission_timeout 12m +} +``` + +### debug _boolean_ +Default: global directive value + +Enable verbose logging. + +--- + +### tls_client { ... } +Default: not specified + +Advanced TLS client configuration options. See [TLS configuration / Client](/reference/tls/#client) for details. + +--- + +### starttls _boolean_ +Default: `yes` (`no` for `target.lmtp`) + +Use STARTTLS to enable TLS encryption. If STARTTLS is not supported +by the remote server - connection will fail. + +maddy will use `localhost` as HELO hostname before STARTTLS +and will only send its actual hostname after STARTTLS. + +### attempt_starttls _boolean_ +Default: `yes` (`no` for `target.lmtp`) + +DEPRECATED: Equivalent to `starttls`. Plaintext fallback is no longer +supported. + +--- + +### require_tls _boolean_ +Default: `no` + +DEPRECATED: Ignored. Set `starttls yes` to use STARTLS. + +--- + +### auth `off` | `plain` _username_ _password_ | `forward` | `external` +Default: `off` + +Specify the way to authenticate to the remote server. +Valid values: + +- `off` – No authentication. +- `plain` – Authenticate using specified username-password pair. + **Don't use** this without enforced TLS (`require_tls`). +- `forward` – Forward credentials specified by the client. + **Don't use** this without enforced TLS (`require_tls`). +- `external` – Request "external" SASL authentication. This is usually used for + authentication using TLS client certificates. See [TLS configuration / Client](/reference/tls/#client) for details. + +--- + +### targets _endpoints..._ +**Required.**
+Default: not specified + +List of remote server addresses to use. See [Address definitions](/reference/config-syntax/#address-definitions) +for syntax to use. Basically, it is `tcp://ADDRESS:PORT` +for plain SMTP and `tls://ADDRESS:PORT` for SMTPS (aka SMTP with Implicit +TLS). + +Multiple addresses can be specified, they will be tried in order until connection to +one succeeds (including TLS handshake if TLS is required). + +--- + +### connect_timeout _duration_ +Default: `5m` + +Same as for target.remote. + +--- + +### command_timeout _duration_ +Default: `5m` + +Same as for target.remote. + +--- + +### submission_timeout _duration_ +Default: `12m` + +Same as for target.remote. diff --git a/docs/reference/tls-acme.md b/docs/reference/tls-acme.md new file mode 100644 index 000000000..a7be47ded --- /dev/null +++ b/docs/reference/tls-acme.md @@ -0,0 +1,290 @@ +# Automatic certificate management via ACME + +Maddy supports obtaining certificates using ACME protocol. + +To use it, create a configuration name for `tls.loader.acme` +and reference it from endpoints that should use automatically +configured certificates: + +``` +tls.loader.acme local_tls { + email put-your-email-here@example.org + agreed # indicate your agreement with Let's Encrypt ToS + challenge dns-01 +} + +smtp tcp://127.0.0.1:25 { + tls &local_tls + ... +} +``` + +You can also use a global `tls` directive to use automatically +obtained certificates for all endpoints: + +``` +tls { + loader acme { + email maddy-acme@example.org + agreed + challenge dns-01 + } +} +``` + +Note: `tls &local_tls` as a global directive won't work because +global directives are initialized before other configuration blocks. + +Currently the only supported challenge is `dns-01` one therefore +you also need to configure the DNS provider: + +``` +tls.loader.acme local_tls { + email maddy-acme@example.org + agreed + challenge dns-01 + dns PROVIDER_NAME { + ... + } +} +``` + +See below for supported providers and necessary configuration +for each. + +## Configuration directives + +``` +tls.loader.acme { + debug off + hostname example.maddy.invalid + store_path /var/lib/maddy/acme + ca https://acme-v02.api.letsencrypt.org/directory + test_ca https://acme-staging-v02.api.letsencrypt.org/directory + email test@maddy.invalid + agreed off + challenge dns-01 + dns ... +} +``` + +### debug _boolean_ +Default: global directive value + +Enable debug logging. + +--- + +### hostname _str_ +**Required.**
+Default: global directive value + +Domain name to issue certificate for. + +--- + +### store_path _path_ +Default: `state_dir/acme` + +Where to store issued certificates and associated metadata. +Currently only filesystem-based store is supported. + +--- + +### ca _url_ +Default: Let's Encrypt production CA + +URL of ACME directory to use. + +--- + +### test_ca _url_ +Default: Let's Encrypt staging CA + +URL of ACME directory to use for retries should +primary CA fail. + +maddy will keep attempting to issues certificates +using `test_ca` until it succeeds then it will switch +back to the one configured via 'ca' option. + +This avoids rate limit issues with production CA. + +--- + +### override_domain _domain_ +Default: not set + +Override the domain to set the TXT record on for DNS-01 challenge. +This is to delegate the challenge to a different domain. + +See https://www.eff.org/deeplinks/2018/02/technical-deep-dive-securing-automation-acme-dns-challenge-validation +for explanation why this might be useful. + +--- + +### email _str_ +Default: not set + +Email to pass while registering an ACME account. + +--- + +### agreed _boolean_ +Default: false + +Whether you agreed to ToS of the CA service you are using. + +--- + +### challenge `dns-01` +Default: not set + +Challenge(s) to use while performing domain verification. + +## DNS providers + +Support for some providers is not provided by standard builds. +To be able to use these, you need to compile maddy +with "libdns_PROVIDER" build tag. +E.g. +``` +./build.sh --tags 'libdns_googleclouddns' +``` + +- gandi + +``` +dns gandi { + api_token "token" +} +``` + +- digitalocean + +``` +dns digitalocean { + api_token "..." +} +``` + +- cloudflare + +See [https://github.com/libdns/cloudflare#authenticating](https://github.com/libdns/cloudflare#authenticating) + +``` +dns cloudflare { + api_token "..." +} +``` + +- vultr + +``` +dns vultr { + api_token "..." +} +``` + +- hetzner + +``` +dns hetzner { + api_token "..." +} +``` + +- namecheap + +``` +dns namecheap { + api_key "..." + api_username "..." + + # optional: API endpoint, production one is used if not set. + endpoint "https://api.namecheap.com/xml.response" + + # optional: your public IP, discovered using icanhazip.com if not set + client_ip 1.2.3.4 +} +``` + +- googleclouddns (non-default) + +``` +dns googleclouddns { + project "project_id" + service_account_json "path" +} +``` + +- route53 (non-default) + +``` +dns route53 { + secret_access_key "..." + access_key_id "..." + # or use environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY +} +``` + +- leaseweb (non-default) + +``` +dns leaseweb { + api_key "key" +} +``` + +- metaname (non-default) + +``` +dns metaname { + api_key "key" + account_ref "reference" +} +``` + +- alidns (non-default) + +``` +dns alidns { + key_id "..." + key_secret "..." +} +``` + +- namedotcom (non-default) + +``` +dns namedotcom { + user "..." + token "..." +} +``` + +- rfc2136 (non-default) + +``` +dns rfc2136 { + key_name "..." + # Secret + key "..." + # HMAC algorithm used to generate the key, lowercase, e.g. hmac-sha512 + key_alg "..." + # server to which the dynamic update will be sent, e.g. 127.0.0.1 + # you can also specify the port: 127.0.0.1:53 + server "..." +} +``` + +- acmedns (non-default) + +``` +dns acmedns { + username "..." + password "..." + subdomain "..." + server_url "..." +} +``` diff --git a/docs/reference/tls.md b/docs/reference/tls.md new file mode 100644 index 000000000..954b0e066 --- /dev/null +++ b/docs/reference/tls.md @@ -0,0 +1,155 @@ +# TLS configuration + +## Server-side + +TLS certificates are obtained by modules called "certificate loaders". 'tls' directive +arguments specify name of loader to use and arguments. Due to syntax limitations +advanced configuration for loader should be specified using 'loader' directive, see +below. + +``` +tls file cert.pem key.pem { + protocols tls1.2 tls1.3 + curves X25519 + ciphers ... +} + +tls { + loader file cert.pem key.pem { + # Options for loader go here. + } + protocols tls1.2 tls1.3 + curves X25519 + ciphers ... +} +``` + +### Available certificate loaders + +- `file` – Accepts argument pairs specifying certificate and then key. + E.g. `tls file certA.pem keyA.pem certB.pem keyB.pem`. + If multiple certificates are listed, SNI will be used. +- `acme` – Automatically obtains a certificate using ACME protocol (Let's Encrypt) +- `off` – Not really a loader but a special value for tls directive, + explicitly disables TLS for endpoint(s). + +## Advanced TLS configuration + +**Note: maddy uses secure defaults and TLS handshake is resistant to active downgrade attacks. There is no need to change anything in most cases.** + +--- + +### protocols _min-version_ _max-version_ | _version_ +Default: `tls1.0 tls1.3` + +Minimum/maximum accepted TLS version. If only one value is specified, it will +be the only one usable version. + +Valid values are: `tls1.0`, `tls1.1`, `tls1.2`, `tls1.3` + +--- + +### ciphers _ciphers..._ +Default: Go version-defined set of 'secure ciphers', ordered by hardware +performance + +List of supported cipher suites, in preference order. Not used with TLS 1.3. + +Valid values: + +- `RSA-WITH-RC4128-SHA` +- `RSA-WITH-3DES-EDE-CBC-SHA` +- `RSA-WITH-AES128-CBC-SHA` +- `RSA-WITH-AES256-CBC-SHA` +- `RSA-WITH-AES128-CBC-SHA256` +- `RSA-WITH-AES128-GCM-SHA256` +- `RSA-WITH-AES256-GCM-SHA384` +- `ECDHE-ECDSA-WITH-RC4128-SHA` +- `ECDHE-ECDSA-WITH-AES128-CBC-SHA` +- `ECDHE-ECDSA-WITH-AES256-CBC-SHA` +- `ECDHE-RSA-WITH-RC4128-SHA` +- `ECDHE-RSA-WITH-3DES-EDE-CBC-SHA` +- `ECDHE-RSA-WITH-AES128-CBC-SHA` +- `ECDHE-RSA-WITH-AES256-CBC-SHA` +- `ECDHE-ECDSA-WITH-AES128-CBC-SHA256` +- `ECDHE-RSA-WITH-AES128-CBC-SHA256` +- `ECDHE-RSA-WITH-AES128-GCM-SHA256` +- `ECDHE-ECDSA-WITH-AES128-GCM-SHA256` +- `ECDHE-RSA-WITH-AES256-GCM-SHA384` +- `ECDHE-ECDSA-WITH-AES256-GCM-SHA384` +- `ECDHE-RSA-WITH-CHACHA20-POLY1305` +- `ECDHE-ECDSA-WITH-CHACHA20-POLY1305` + +--- + +### curves _curves..._ +Default: defined by Go version + +The elliptic curves that will be used in an ECDHE handshake, in preference +order. + +Valid values: `p256`, `p384`, `p521`, `X25519`. + +## Client + +`tls_client` directive allows to customize behavior of TLS client implementation, +notably adjusting minimal and maximal TLS versions and allowed cipher suites, +enabling TLS client authentication. + +``` +tls_client { + protocols tls1.2 tls1.3 + ciphers ... + curves X25519 + root_ca /etc/ssl/cert.pem + + cert /etc/ssl/private/maddy-client.pem + key /etc/ssl/private/maddy-client.pem +} +``` + +--- + +### protocols _min-version_ _max-version_ | _version_ +Default: `tls1.0 tls1.3` + +Minimum/maximum accepted TLS version. If only one value is specified, it will +be the only one usable version. + +Valid values are: `tls1.0`, `tls1.1`, `tls1.2`, `tls1.3` + +--- + +### ciphers _ciphers..._ +Default: Go version-defined set of 'secure ciphers', ordered by hardware +performance + +List of supported cipher suites, in preference order. Not used with TLS 1.3. + +See TLS server configuration for list of supported values. + +--- + +### curves _curves..._ +Default: defined by Go version + +The elliptic curves that will be used in an ECDHE handshake, in preference +order. + +Valid values: `p256`, `p384`, `p521`, `X25519`. + +--- + +### root_ca _paths..._ +Default: system CA pool + +List of files with PEM-encoded CA certificates to use when verifying +server certificates. + +--- + +### cert _cert-path_
key _key-path_ +Default: not specified + +Present the specified certificate when server requests a client certificate. +Files should use PEM format. Both directives should be specified. diff --git a/docs/seclevels.md b/docs/seclevels.md index a26dda77c..984be4a4f 100644 --- a/docs/seclevels.md +++ b/docs/seclevels.md @@ -1,4 +1,4 @@ -# Security levels +# Outbound delivery security maddy implements a number of schemes and protocols for discovery and enforcement of security features supported by the recipient MTA. @@ -45,7 +45,7 @@ maddy defines two values indicating how "secure" delivery of message will be: - TLS security level These values correspond to the problems described above. On delivery, the -estabilished connection to the remote server is "ranked" using these values and +established connection to the remote server is "ranked" using these values and then they are compared against a number of policies (including local configuration). If the effective value is lower than the required one, the connection is closed and next candidate server is used. If all connections fail @@ -67,14 +67,14 @@ attacks - MX level: None. MX candidate was returned as a result of DNS lookup for the recipient domain, no additional checks done. - MX level: MTA-STS. Used MX matches the MTA-STS policy published by the - recepient domain (even one in testing mode). + recipient domain (even one in testing mode). - MX level: DNSSEC. MX record is signed. -- TLS level: None. Plaintext connection was estabilished, TLS is not available +- TLS level: None. Plaintext connection was established, TLS is not available or failed. -- TLS level: Encrypted. TLS connection was estabilished, the server certificate +- TLS level: Encrypted. TLS connection was established, the server certificate failed X.509 and DANE verification. -- TLS level: Authenticated. TLS connection was estabilished, the server +- TLS level: Authenticated. TLS connection was established, the server certificate passes X.509 **or** DANE verification. **Note 1:** Persistent attacker able to control network connection can @@ -83,8 +83,7 @@ passive attacks. ## maddy security policies -See [**maddy-targets(5)**](../man/\_generated\_maddy-targets.5) page for -description of configuration options available for each policy mechanism +See [Remote MX delivery](/reference/targets/remote/) for description of configuration options available for each policy mechanism supported by maddy. [RFC 8461 Section 10.2]: https://www.rfc-editor.org/rfc/rfc8461.html#section-10.2 (SMTP MTA Strict Transport Security - 10.2. Preventing Policy Discovery) diff --git a/docs/third-party/dovecot.md b/docs/third-party/dovecot.md index c922f5773..22d51c32e 100644 --- a/docs/third-party/dovecot.md +++ b/docs/third-party/dovecot.md @@ -1,7 +1,7 @@ # Dovecot Builtin maddy IMAP server may not match your requirements in terms of -performance, reliabilty or anything. For this reason it is possible to +performance, reliability or anything. For this reason it is possible to integrate it with any external IMAP server that implements necessary protocols. Here is how to do it for Dovecot. @@ -69,7 +69,7 @@ smtp tcp://127.0.0.1:587 { deliver_to &remote_queue } ``` -And configure IMAP servers's Submission service to forward outbound messages +And configure IMAP server's Submission service to forward outbound messages there. Depending on how Submission service is implemented you may also need to route diff --git a/docs/third-party/mailman3.md b/docs/third-party/mailman3.md index 27f636779..a29d71e12 100644 --- a/docs/third-party/mailman3.md +++ b/docs/third-party/mailman3.md @@ -20,7 +20,7 @@ lmtp_port: 8024 After that, you will need to configure maddy to send messages to Mailman. -The preferrable way of doing so is destination_in and table.regexp: +The preferable way of doing so is destination_in and table.regexp: ``` msgpipeline local_routing { destination_in regexp "first-mailinglist(-(bounces\+.*|confirm\+.*|join|leave|owner|request|subscribe|unsubscribe))?@lists.example.org" { diff --git a/docs/third-party/rspamd.md b/docs/third-party/rspamd.md index 43cf3d89a..3d2ce4837 100644 --- a/docs/third-party/rspamd.md +++ b/docs/third-party/rspamd.md @@ -7,7 +7,7 @@ If rspamd is running locally, it is enough to just add `rspamd` check with default configuration into appropriate check block (probably in local_routing): ``` -checks { +check { ... rspamd } @@ -35,8 +35,4 @@ Default mapping of rspamd action -> maddy action is as follows: - "rewrite subject" => Quarantine - "soft reject" => Reject with temporary error - "reject" => Reject with permanent error -- "greylist" => Ignored - -This and additional data to pass to rspamd (MTA name, settings ID, etc) -can be configured as described in -[**maddy-checks**(5)](/man/_generated_maddy-filters.5/#rspamd-check-checkrspamd). +- "greylist" => Ignored \ No newline at end of file diff --git a/docs/third-party/smtp-servers.md b/docs/third-party/smtp-servers.md index 813dcf1f7..599a00d6a 100644 --- a/docs/third-party/smtp-servers.md +++ b/docs/third-party/smtp-servers.md @@ -43,7 +43,7 @@ lmtp unix:/run/maddy/lmtp.sock { Look up documentation for your SMTP server on how to make it send messages using LMTP to /run/maddy/lmtp.sock. -To handle authentiation for Submission (client-server SMTP) SMTP server +To handle authentication for Submission (client-server SMTP) SMTP server needs to access credentials database used by maddy. maddy implements server side of Dovecot authentication protocol so you can use it if SMTP server implements "Dovecot SASL" client. diff --git a/docs/tutorials/alias-to-remote.md b/docs/tutorials/alias-to-remote.md index 0b0601b58..ddbc76b9c 100644 --- a/docs/tutorials/alias-to-remote.md +++ b/docs/tutorials/alias-to-remote.md @@ -88,7 +88,7 @@ msgpipeline local_routing { ## Bounce handling Once the message is delivered to `remote_queue`, it will follow the usual path -for outbound delivery, including queueing and multiple attempts. This also +for outbound delivery, including queuing and multiple attempts. This also means bounce messages will be generated on failures. When accepting messages from arbitrary senders via the 25 port, the DSN recipient will be whatever sender specifies in the MAIL FROM command. This is prone to [collateral spam] diff --git a/docs/tutorials/building-from-source.md b/docs/tutorials/building-from-source.md index b3afd839f..55b6852e2 100644 --- a/docs/tutorials/building-from-source.md +++ b/docs/tutorials/building-from-source.md @@ -6,22 +6,25 @@ You need C toolchain, Go toolchain and Make: On Debian-based system this should work: ``` -apt-get install golang-1.15 gcc libc6-dev make +apt-get install golang-1.23 gcc libc6-dev make ``` Additionally, if you want manual pages, you should also have scdoc installed. Figuring out the appropriate way to get scdoc is left as an exercise for -reader (for Ubuntu 19.10 it is in repositories). +reader (for Ubuntu 22.04 LTS it is in repositories). ## Recent Go toolchain maddy depends on a rather recent Go toolchain version that may not be available in some distributions (*cough* Debian *cough*). -It should not be hard to grab a recent built toolchain from golang.org: +`go` command in Go 1.21 or newer will automatically download up-to-date +toolchain to build maddy. It is necessary to run commands below only +if you have `go` command version older than 1.21. + ``` -wget "https://dl.google.com/go/go1.15.6.linux-amd64.tar.gz" -tar xf "go1.15.6.linux-amd64.tar.gz" +wget "https://go.dev/dl/go1.23.5.linux-amd64.tar.gz" +tar xf "go1.23.5.linux-amd64.tar.gz" export GOROOT="$PWD/go" export PATH="$PWD/go/bin:$PATH" ``` @@ -34,17 +37,19 @@ $ git clone https://github.com/foxcpp/maddy.git $ cd maddy ``` -3. Select the appropriate version to build: +2. Select the appropriate version to build: ``` -$ git checkout v0.4.0 # a specific release +$ git checkout v0.8.0 # a specific release $ git checkout master # next bugfix release $ git checkout dev # next feature release ``` -2. Build & install it +3. Build & install it ``` $ ./build.sh -# ./build.sh install +$ sudo ./build.sh install ``` -3. Have fun! +4. Finish setup as described in [Setting up](../setting-up) (starting from System configuration). + + diff --git a/docs/tutorials/pam.md b/docs/tutorials/pam.md index 6edd7d971..a189a337a 100644 --- a/docs/tutorials/pam.md +++ b/docs/tutorials/pam.md @@ -4,7 +4,7 @@ maddy supports user authentication using PAM infrastructure via `auth.pam` module. In order to use it, however, either maddy itself should be compiled -with libpam support or a helper executable should be built and +with libpam support or a helper executable should be built and installed into an appropriate directory. It is recommended to use builtin libpam support if you are using @@ -13,7 +13,7 @@ supported by maddy. If PAM authentication requires privileged access on the host system (e.g. pam_unix.so aka /etc/shadow) then it is recommended to use -a privileged helper executable since maddy process itself won't +a privileged helper executable since maddy process itself won't have access to it. ## Built-in PAM support @@ -23,7 +23,7 @@ libpam support. You should build maddy from source. See [here](../building-from-source) for detailed instructions. -You should have libpam development files installed (`libpam-dev` +You should have libpam development files installed (`libpam-dev` package on Ubuntu/Debian). Then add `--tags 'libpam'` to the build command: @@ -48,9 +48,9 @@ cd maddy/cmd/maddy-pam-helper gcc pam.c main.c -lpam -o maddy-pam-helper ``` -Copy the resulting executable into /usr/lib/maddy/ and make +Copy the resulting executable into /usr/lib/maddy/ and make it setuid-root so it can read /etc/shadow (if that's necessary): -``` +``` chown root:maddy /usr/lib/maddy/maddy-pam-helper chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-pam-helper ``` @@ -65,23 +65,19 @@ auth.pam local_authdb { ## Account names -Since PAM does not use emails for authentication you should also -switch storage backend to using usernames for authentication: -``` -storage.imapsql local_mailboxes { - ... - delivery_map email_localpart - auth_normalize precis_casefold -} -``` -(See [Multiple domains](../../multiple-domains) for details) +Since PAM does not use emails for authentication you should configure +maddy to either strip domain part when checking credentials or do not +use email when authenticating. + +See [Multiple domains configuration](/multiple-domains) for how to configure +authentication. ## PAM service You should create a PAM configuration file for maddy to use. Place it into /etc/pam.d/maddy. Here is the minimal example using pam_unix (shadow database). -``` +``` #%PAM-1.0 auth required pam_unix.so account required pam_unix.so @@ -89,7 +85,7 @@ account required pam_unix.so Here is the configuration example you could use on Ubuntu to use the authentication config system itself uses: -``` +``` #%PAM-1.0 @include common-auth diff --git a/docs/tutorials/setting-up.md b/docs/tutorials/setting-up.md index 5bc66489f..04de75fd7 100644 --- a/docs/tutorials/setting-up.md +++ b/docs/tutorials/setting-up.md @@ -35,7 +35,7 @@ Your options are: Available on [GitHub](https://github.com/foxcpp/maddy/releases) or [maddy.email/builds](https://maddy.email/builds/). - The tarball includes maddy and maddyctl executables you can + The tarball includes maddy executable you can copy into /usr/local/bin as well as systemd unit file you can use on systemd-based distributions for automatic startup and service supervision. You should also create "maddy" user and group. @@ -44,11 +44,10 @@ Your options are: * Docker image (Linux, amd64) ``` - docker pull foxcpp/maddy:latest + docker pull foxcpp/maddy:0.6 ``` - See README at [hub.docker.com](https://hub.docker.com/r/foxcpp/maddy) - for Docker-specific instructions. + See [here](../../docker) for Docker-specific instructions. * Building from source @@ -58,7 +57,7 @@ Your options are: For Arch Linux users, `maddy` and `maddy-git` PKGBUILDs are available in AUR. Additionally, binary packages are available in 3rd-party - repository at https://foxcpp.dev/archlinux/ + repository at [https://maddy.email/archlinux/](https://maddy.email/archlinux/) ## System configuration (systemd-based distribution) @@ -104,9 +103,14 @@ one as "primary". Add all other domains to the `local_domains` line: $(local_domains) = $(primary_domain) example.com other.example.com ``` +Do not forget to set a suitable rDNS (PTR) record for your server's IP address +to reduce the chances of outgoing mails getting marked as spam or being +downright rejected. Ideally, the PTR record should match whatever you specified +in `$(hostname)`. + ## TLS certificates -One thing that can't be automagically configured is TLS certs. If you already +One thing that can't be automatically configured is TLS certs. If you already have them somewhere - use them, open /etc/maddy/maddy.conf and put the right paths in. You need to make sure maddy can read them while running as unprivileged user (maddy never runs as root, even during start-up), one way to @@ -169,7 +173,7 @@ mx1.example.org. AAAA 2001:beef::1 ; for this domain, and nobody else. example.org. TXT "v=spf1 mx ~all" ; It is recommended to server SPF record for both domain and MX hostname -mx1.example.org. TXT "v=spf1 mx ~all" +mx1.example.org. TXT "v=spf1 a ~all" ; Opt-in into DMARC with permissive policy and request reports about broken ; messages. @@ -216,14 +220,14 @@ mx: mx2.example.org ``` It is also recommended to set a TLSA (DANE) record. -Use https://www.huque.com/bin/gen_tlsa to generate one. +Use https://www.huque.com/bin/gen_tlsa to generate one. Set port to 25, Transport Protocol to "tcp" and Domain Name to **the MX hostname**. Example of a valid record: ``` _25._tcp.mx1.example.org. TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96e3498238 ``` -## User accounts and maddyctl +## User accounts and maddy command A mail server is useless without mailboxes, right? Unlike software like postfix and dovecot, maddy uses "virtual users" by default, meaning it does not care or @@ -231,10 +235,10 @@ know about system users. IMAP mailboxes ("accounts") and authentication credentials are kept separate. -To register user credentials, use `maddyctl creds create` command. +To register user credentials, use `maddy creds create` command. Like that: ``` -$ maddyctl creds create postmaster@example.org +$ maddy creds create postmaster@example.org ``` Note the username is a e-mail address. This is required as username is used to @@ -244,14 +248,17 @@ described here). After registering the user credentials, you also need to create a local storage account: ``` -$ maddyctl imap-acct create postmaster@example.org +$ maddy imap-acct create postmaster@example.org ``` +Note: to run `maddy` CLI commands, your user should be in the `maddy` +group. Alternatively, just use `sudo -u maddy`. + That is it. Now you have your first e-mail address. when authenticating using your e-mail client, do not forget the username is "postmaster@example.org", not just "postmaster". -You may find running `maddyctl creds --help` and `maddyctl imap-acct --help` +You may find running `maddy creds --help` and `maddy imap-acct --help` useful to learn about other commands. Note that IMAP accounts and credentials are managed separately yet usernames should match by default for things to work. diff --git a/docs/upgrading.md b/docs/upgrading.md index 25fdfaef6..88fe4c7a8 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -74,7 +74,7 @@ pass_table local_authdb { } ``` -6. Use `maddyctl creds create ACCOUNT_NAME` to add credentials to `pass_table` +6. Use `maddy creds create ACCOUNT_NAME` to add credentials to `pass_table` store. 7. Start the server back. diff --git a/framework/address/norm.go b/framework/address/norm.go index 510fb63bc..6998e662e 100644 --- a/framework/address/norm.go +++ b/framework/address/norm.go @@ -36,6 +36,10 @@ import ( // // On error, case-folded addr is also returned. func ForLookup(addr string) (string, error) { + if addr == "" { // Null return-path case. + return "", nil + } + mbox, domain, err := Split(addr) if err != nil { return strings.ToLower(addr), err @@ -64,6 +68,10 @@ func ForLookup(addr string) (string, error) { // // Original value is also returned on the error. func CleanDomain(addr string) (string, error) { + if addr == "" { // Null return-path + return "", nil + } + mbox, domain, err := Split(addr) if err != nil { return addr, err diff --git a/framework/address/split.go b/framework/address/split.go index 16e4f967b..88b8d514a 100644 --- a/framework/address/split.go +++ b/framework/address/split.go @@ -101,3 +101,32 @@ func UnquoteMbox(mbox string) (string, error) { return mailboxB.String(), nil } + +// "specials" from RFC5322 grammar with dot removed (it is defined in grammar separately, for some reason) +var mboxSpecial = map[rune]struct{}{ + '(': {}, ')': {}, '<': {}, '>': {}, + '[': {}, ']': {}, ':': {}, ';': {}, + '@': {}, '\\': {}, ',': {}, + '"': {}, ' ': {}, +} + +func QuoteMbox(mbox string) string { + var mailboxEsc strings.Builder + mailboxEsc.Grow(len(mbox)) + quoted := false + for _, ch := range mbox { + if _, ok := mboxSpecial[ch]; ok { + if ch == '\\' || ch == '"' { + mailboxEsc.WriteRune('\\') + } + mailboxEsc.WriteRune(ch) + quoted = true + } else { + mailboxEsc.WriteRune(ch) + } + } + if quoted { + return `"` + mailboxEsc.String() + `"` + } + return mbox +} diff --git a/framework/address/split_test.go b/framework/address/split_test.go index 03e13f766..b5a8df5ab 100644 --- a/framework/address/split_test.go +++ b/framework/address/split_test.go @@ -90,3 +90,21 @@ func TestUnquoteMbox(t *testing.T) { test(`postmaster`, "postmaster", false) test(`foo`, "foo", false) } + +func TestQuoteMbox(t *testing.T) { + test := func(inputMbox, expectedMbox string) { + t.Helper() + + actualMbox := QuoteMbox(inputMbox) + if actualMbox != expectedMbox { + t.Errorf("wrong local part, want %s, got %s", actualMbox, actualMbox) + } + } + + test(`no"no`, `"no\"no"`) + test(`no@no`, `"no@no"`) + test(`no no`, `"no no"`) + test(`no\no`, `"no\\no"`) + test("postmaster", `postmaster`) + test("foo", `foo`) +} diff --git a/framework/address/validation.go b/framework/address/validation.go index 4e2b38a66..a165adcb6 100644 --- a/framework/address/validation.go +++ b/framework/address/validation.go @@ -20,6 +20,8 @@ package address import ( "strings" + + "golang.org/x/net/idna" ) /* @@ -109,17 +111,23 @@ func ValidMailboxName(mbox string) bool { // ValidDomain checks whether the specified string is a valid DNS domain. func ValidDomain(domain string) bool { - if len(domain)> 255 { + if len(domain)> 255 || len(domain) == 0 { return false } - if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + if strings.HasPrefix(domain, ".") { return false } if strings.Contains(domain, "..") { return false } - labels := strings.Split(domain, ".") + // Length checks are to be applied to A-labels form. + // maddy uses U-labels representation across the code (for lookups, etc). + domainASCII, err := idna.ToASCII(domain) + if err != nil { + return false + } + labels := strings.Split(domainASCII, ".") for _, label := range labels { if len(label)> 64 { return false diff --git a/framework/address/validation_test.go b/framework/address/validation_test.go index 9416e55fc..fdac83493 100644 --- a/framework/address/validation_test.go +++ b/framework/address/validation_test.go @@ -1,6 +1,7 @@ package address_test import ( + "strings" "testing" "github.com/foxcpp/maddy/framework/address" @@ -11,3 +12,22 @@ func TestValidMailboxName(t *testing.T) { t.Error("caddy.bug should be valid mailbox name") } } + +func TestValidDomain(t *testing.T) { + for _, c := range []struct { + Domain string + Valid bool + }{ + {Domain: "maddy.email", Valid: true}, + {Domain: "", Valid: false}, + {Domain: "maddy.email.", Valid: true}, + {Domain: "..", Valid: false}, + {Domain: strings.Repeat("a", 256), Valid: false}, + {Domain: "äõäoaõoäaõaäõaoäaoaäõoaäooaoaoiuaiauäõiuüõaõäiauõaaa.tld", Valid: true}, // https://github.com/foxcpp/maddy/issues/554 + {Domain: "xn--oaoaaaoaoaoaooaoaoiuaiauiuaiauaaa-f1cadccdcmd01eddchqcbe07a.tld", Valid: true}, // https://github.com/foxcpp/maddy/issues/554 + } { + if actual := address.ValidDomain(c.Domain); actual != c.Valid { + t.Errorf("expected domain %v to be valid=%v, but got %v", c.Domain, c.Valid, actual) + } + } +} diff --git a/framework/buffer/file.go b/framework/buffer/file.go index dc2b73057..00259849c 100644 --- a/framework/buffer/file.go +++ b/framework/buffer/file.go @@ -19,10 +19,10 @@ along with this program. If not, see . package buffer import ( + "crypto/rand" "encoding/hex" "fmt" "io" - "math/rand" "os" "path/filepath" ) diff --git a/framework/buffer/memory.go b/framework/buffer/memory.go index 997a2dd1a..dafd67795 100644 --- a/framework/buffer/memory.go +++ b/framework/buffer/memory.go @@ -20,7 +20,6 @@ package buffer import ( "io" - "io/ioutil" ) // MemoryBuffer implements Buffer interface using byte slice. @@ -43,7 +42,7 @@ func (mb MemoryBuffer) Remove() error { // BufferInMemory is a convenience function which creates MemoryBuffer with // contents of the passed io.Reader. func BufferInMemory(r io.Reader) (Buffer, error) { - blob, err := ioutil.ReadAll(r) + blob, err := io.ReadAll(r) if err != nil { return nil, err } diff --git a/framework/cfgparser/imports.go b/framework/cfgparser/imports.go index 97f11ac9b..1f9dccd1c 100644 --- a/framework/cfgparser/imports.go +++ b/framework/cfgparser/imports.go @@ -79,14 +79,17 @@ func (ctx *parseContext) resolveImport(node Node, name string, expansionDepth in return subtree, nil } - file := filepath.Join(filepath.Dir(ctx.fileLocation), name) + file := name + if !filepath.IsAbs(name) { + file = filepath.Join(filepath.Dir(ctx.fileLocation), name) + } src, err := os.Open(file) if err != nil { if os.IsNotExist(err) { src, err = os.Open(file + ".conf") if err != nil { if os.IsNotExist(err) { - return nil, NodeErr(node, "unknown import: "+name) + return nil, NodeErr(node, "unknown import: %s", name) } return nil, err } @@ -166,7 +169,7 @@ func (ctx *parseContext) expandSingleValueMacro(arg string) (string, error) { value = ctx.macros[macroName][0] } - arg = strings.Replace(arg, "$("+macroName+")", value, -1) + arg = strings.ReplaceAll(arg, "$("+macroName+")", value) } return arg, nil diff --git a/framework/cfgparser/parse.go b/framework/cfgparser/parse.go index 5eabc3c0a..aed01e3df 100644 --- a/framework/cfgparser/parse.go +++ b/framework/cfgparser/parse.go @@ -31,10 +31,10 @@ import ( // Node struct describes a parsed configurtion block or a simple directive. // -// name arg0 arg1 { -// children0 -// children1 -// } +// name arg0 arg1 { +// children0 +// children1 +// } type Node struct { // Name is the first string at node's line. Name string @@ -209,9 +209,10 @@ func (ctx *parseContext) parseAsMacro(node *Node) (macroName string, args []stri // // The lexer's cursor should point to the opening brace // name arg0 arg1 { #< this one -// c0 -// c1 -// } +// +// c0 +// c1 +// } // // To stay consistent with readNode after this function returns the lexer's cursor points // to the last token of the black (closing brace). diff --git a/framework/cfgparser/parse_test.go b/framework/cfgparser/parse_test.go index 9488e5802..3cf20baea 100644 --- a/framework/cfgparser/parse_test.go +++ b/framework/cfgparser/parse_test.go @@ -23,6 +23,8 @@ import ( "reflect" "strings" "testing" + + "github.com/stretchr/testify/require" ) var cases = []struct { @@ -579,11 +581,10 @@ func printTree(t *testing.T, root Node, indent int) { } func TestRead(t *testing.T) { - os.Setenv("TESTING_VARIABLE", "ABCDEF") - os.Setenv("TESTING_VARIABLE2", "ABC2 DEF2") + require.NoError(t, os.Setenv("TESTING_VARIABLE", "ABCDEF")) + require.NoError(t, os.Setenv("TESTING_VARIABLE2", "ABC2 DEF2")) for _, case_ := range cases { - case_ := case_ t.Run(case_.name, func(t *testing.T) { tree, err := Read(strings.NewReader(case_.cfg), "test") if !case_.fail && err != nil { diff --git a/framework/config/map.go b/framework/config/map.go index f56e59fae..c85c19fe4 100644 --- a/framework/config/map.go +++ b/framework/config/map.go @@ -20,6 +20,7 @@ package config import ( "errors" + "fmt" "reflect" "strconv" "strings" @@ -134,6 +135,59 @@ func (m *Map) Enum(name string, inheritGlobal, required bool, allowed []string, }, store) } +// EnumMapped is similar to Map.Enum but maps a stirng to a custom type. +func EnumMapped[V any](m *Map, name string, inheritGlobal, required bool, mapped map[string]V, defaultVal V, store *V) { + m.Custom(name, inheritGlobal, required, func() (interface{}, error) { + return defaultVal, nil + }, func(_ *Map, node Node) (interface{}, error) { + if len(node.Children) != 0 { + return nil, NodeErr(node, "can't declare a block here") + } + if len(node.Args) != 1 { + return nil, NodeErr(node, "expected exactly one argument") + } + + val, ok := mapped[node.Args[0]] + if !ok { + validValues := make([]string, 0, len(mapped)) + for k := range mapped { + validValues = append(validValues, k) + } + return nil, NodeErr(node, "invalid argument, valid values are: %v", validValues) + } + + return val, nil + }, store) +} + +// EnumListMapped is similar to Map.EnumList but maps a stirng to a custom type. +func EnumListMapped[V any](m *Map, name string, inheritGlobal, required bool, mapped map[string]V, defaultVal []V, store *[]V) { + m.Custom(name, inheritGlobal, required, func() (interface{}, error) { + return defaultVal, nil + }, func(_ *Map, node Node) (interface{}, error) { + if len(node.Children) != 0 { + return nil, NodeErr(node, "can't declare a block here") + } + if len(node.Args) == 0 { + return nil, NodeErr(node, "expected at least one argument") + } + + values := make([]V, 0, len(node.Args)) + for _, arg := range node.Args { + val, ok := mapped[arg] + if !ok { + validValues := make([]string, 0, len(mapped)) + for k := range mapped { + validValues = append(validValues, k) + } + return nil, NodeErr(node, "invalid argument, valid values are: %v", validValues) + } + values = append(values, val) + } + return values, nil + }, store) +} + // Duration maps configuration directive to a time.Duration variable. // // Directive must be in form 'name duration' where duration is any string accepted by @@ -231,7 +285,7 @@ func ParseDataSize(s string) (int, error) { // data unit and allows multiple arguments (they will be added together). // // See Map.Custom for description of arguments. -func (m *Map) DataSize(name string, inheritGlobal, required bool, defaultVal int, store *int) { +func (m *Map) DataSize(name string, inheritGlobal, required bool, defaultVal int64, store *int64) { m.Custom(name, inheritGlobal, required, func() (interface{}, error) { return defaultVal, nil }, func(_ *Map, node Node) (interface{}, error) { @@ -248,10 +302,20 @@ func (m *Map) DataSize(name string, inheritGlobal, required bool, defaultVal int return nil, NodeErr(node, "%v", err) } - return dur, nil + return int64(dur), nil }, store) } +func ParseBool(s string) (bool, error) { + switch strings.ToLower(s) { + case "1", "true", "on", "yes": + return true, nil + case "0", "false", "off", "no": + return false, nil + } + return false, fmt.Errorf("bool argument should be 'yes' or 'no'") +} + // Bool maps presence of some configuration directive to a boolean variable. // Additionally, 'name yes' and 'name no' are mapped to true and false // correspondingly. @@ -274,13 +338,11 @@ func (m *Map) Bool(name string, inheritGlobal, defaultVal bool, store *bool) { return nil, NodeErr(node, "expected exactly 1 argument") } - switch strings.ToLower(node.Args[0]) { - case "1", "true", "on", "yes": - return true, nil - case "0", "false", "off", "no": - return false, nil + b, err := ParseBool(node.Args[0]) + if err != nil { + return nil, NodeErr(node, "bool argument should be 'yes' or 'no'") } - return nil, NodeErr(node, "bool argument should be 'yes' or 'no'") + return b, nil }, store) } diff --git a/framework/config/module/check_action.go b/framework/config/module/check_action.go index 5061674ad..cac3278c8 100644 --- a/framework/config/module/check_action.go +++ b/framework/config/module/check_action.go @@ -36,19 +36,22 @@ import ( // returns. It is intended to be used as follows: // // Add the configuration directive to allow user to specify the action: -// cfg.Custom("SOME_action", false, false, -// func() (interface{}, error) { -// return modconfig.FailAction{Quarantine: true}, nil -// }, modconfig.FailActionDirective, &yourModule.SOMEAction) +// +// cfg.Custom("SOME_action", false, false, +// func() (interface{}, error) { +// return modconfig.FailAction{Quarantine: true}, nil +// }, modconfig.FailActionDirective, &yourModule.SOMEAction) +// // return in func literal is the default value, you might want to adjust it. // // Call yourModule.SOMEAction.Apply on CheckResult containing only the // Reason field: -// func (yourModule YourModule) CheckConnection() module.CheckResult { -// return yourModule.SOMEAction.Apply(module.CheckResult{ -// Reason: ..., -// }) -// } +// +// func (yourModule YourModule) CheckConnection() module.CheckResult { +// return yourModule.SOMEAction.Apply(module.CheckResult{ +// Reason: ..., +// }) +// } type FailAction struct { Quarantine bool Reject bool diff --git a/framework/config/module/interfaces.go b/framework/config/module/interfaces.go index 8ce421a6e..caf17c54c 100644 --- a/framework/config/module/interfaces.go +++ b/framework/config/module/interfaces.go @@ -31,13 +31,14 @@ func MessageCheck(globals map[string]interface{}, args []string, block config.No return check, nil } -// deliveryDirective is a callback for use in config.Map.Custom. +// DeliveryDirective is a callback for use in config.Map.Custom. // // It does all work necessary to create a module instance from the config // directive with the following structure: -// directive_name mod_name [inst_name] [{ -// inline_mod_config -// }] +// +// directive_name mod_name [inst_name] [{ +// inline_mod_config +// }] // // Note that if used configuration structure lacks directive_name before mod_name - this function // should not be used (call DeliveryTarget directly). @@ -77,6 +78,17 @@ func StorageDirective(m *config.Map, node config.Node) (interface{}, error) { return backend, nil } +// Table is a convenience wrapper for TableDirective. +// +// cfg.Bool(...) +// modconfig.Table(cfg, "auth_map", false, false, nil, &mod.authMap) +// cfg.Process() +func Table(cfg *config.Map, name string, inheritGlobal, required bool, defaultVal module.Table, store *module.Table) { + cfg.Custom(name, inheritGlobal, required, func() (interface{}, error) { + return defaultVal, nil + }, TableDirective, store) +} + func TableDirective(m *config.Map, node config.Node) (interface{}, error) { var tbl module.Table if err := ModuleFromNode("table", node.Args, node, m.Globals, &tbl); err != nil { diff --git a/framework/config/module/modconfig.go b/framework/config/module/modconfig.go index 3183bb935..2b4eab6cc 100644 --- a/framework/config/module/modconfig.go +++ b/framework/config/module/modconfig.go @@ -28,32 +28,32 @@ package modconfig import ( "fmt" - "io" "reflect" "strings" parser "github.com/foxcpp/maddy/framework/cfgparser" "github.com/foxcpp/maddy/framework/config" - "github.com/foxcpp/maddy/framework/hooks" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // createInlineModule is a helper function for config matchers that can create inline modules. -func createInlineModule(preferredNamespace, modName string, args []string) (module.Module, error) { - var newMod module.FuncNewModule +func createInlineModule(c *container.C, preferredNamespace, modName string) (module.Module, error) { + var newMod modules.FuncNewModule originalModName := modName // First try to extend the name with preferred namespace unless the name // already contains it. if !strings.Contains(modName, ".") && preferredNamespace != "" { modName = preferredNamespace + "." + modName - newMod = module.Get(modName) + newMod = modules.Get(modName) } // Then try global namespace for compatibility and complex modules. if newMod == nil { - newMod = module.Get(originalModName) + newMod = modules.Get(originalModName) } // Bail if both failed. @@ -61,26 +61,21 @@ func createInlineModule(preferredNamespace, modName string, args []string) (modu return nil, fmt.Errorf("unknown module: %s (namespace: %s)", originalModName, preferredNamespace) } - return newMod(modName, "", nil, args) + return newMod(c, modName, "") } -// initInlineModule constructs "faked" config tree and passes it to module +// configureInlineModule constructs "faked" config tree and passes it to module // Init function to make it look like it is defined at top-level. // -// args must contain at least one argument, otherwise initInlineModule panics. -func initInlineModule(modObj module.Module, globals map[string]interface{}, block config.Node) error { - err := modObj.Init(config.NewMap(globals, block)) +// args must contain at least one argument, otherwise configureInlineModule panics. +func configureInlineModule(modObj module.Module, args []string, globals map[string]interface{}, block config.Node) error { + err := modObj.Configure(args, config.NewMap(globals, block)) if err != nil { return err } - if closer, ok := modObj.(io.Closer); ok { - hooks.AddHook(hooks.EventShutdown, func() { - log.Debugf("close %s (%s)", modObj.Name(), modObj.InstanceName()) - if err := closer.Close(); err != nil { - log.Printf("module %s (%s) close failed: %v", modObj.Name(), modObj.InstanceName(), err) - } - }) + if li, ok := modObj.(container.LifetimeModule); ok { + container.Global.Lifetime.Add(li) } return nil @@ -117,11 +112,11 @@ func ModuleFromNode(preferredNamespace string, args []string, inlineCfg config.N if len(args) != 1 || inlineCfg.Children != nil { return parser.NodeErr(inlineCfg, "exactly one argument is required to use existing config block") } - modObj, err = module.GetInstance(args[0][1:]) + modObj, err = container.Global.Modules.Get(args[0][1:]) log.Debugf("%s:%d: reference %s", inlineCfg.File, inlineCfg.Line, args[0]) } else { log.Debugf("%s:%d: new module %s %v", inlineCfg.File, inlineCfg.Line, args[0], args[1:]) - modObj, err = createInlineModule(preferredNamespace, args[0], args[1:]) + modObj, err = createInlineModule(container.Global, preferredNamespace, args[0]) } if err != nil { return err @@ -144,7 +139,7 @@ func ModuleFromNode(preferredNamespace string, args []string, inlineCfg config.N reflect.ValueOf(moduleIface).Elem().Set(reflect.ValueOf(modObj)) if !referenceExisting { - if err := initInlineModule(modObj, globals, inlineCfg); err != nil { + if err := configureInlineModule(modObj, args[1:], globals, inlineCfg); err != nil { return err } } diff --git a/framework/config/tls/client.go b/framework/config/tls/client.go index b93e41c05..cf21b3cbb 100644 --- a/framework/config/tls/client.go +++ b/framework/config/tls/client.go @@ -22,7 +22,7 @@ import ( "crypto/tls" "crypto/x509" "fmt" - "io/ioutil" + "os" "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/log" @@ -58,7 +58,7 @@ func TLSClientBlock(_ *config.Map, node config.Node) (interface{}, error) { if len(rootCAPaths) != 0 { pool := x509.NewCertPool() for _, path := range rootCAPaths { - blob, err := ioutil.ReadFile(path) + blob, err := os.ReadFile(path) if err != nil { return nil, err } diff --git a/framework/config/tls/server.go b/framework/config/tls/server.go index f40b3fd75..4fe8e8d31 100644 --- a/framework/config/tls/server.go +++ b/framework/config/tls/server.go @@ -69,7 +69,10 @@ func TLSDirective(m *config.Map, node config.Node) (interface{}, error) { } func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSConfig, error) { - baseCfg := tls.Config{} + baseCfg := tls.Config{ + // Workaround for issue https://github.com/foxcpp/maddy/issues/730 + SessionTicketsDisabled: true, + } var loader module.TLSLoader if len(blockNode.Args)> 0 { @@ -95,7 +98,7 @@ func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSCo }, &loader) childM.Custom("protocols", false, false, func() (interface{}, error) { - return [2]uint16{0, 0}, nil + return [2]uint16{tls.VersionTLS10, 0}, nil }, TLSVersionsDirective, &tlsVersions) childM.Custom("ciphers", false, false, func() (interface{}, error) { @@ -110,10 +113,6 @@ func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSCo return nil, err } - if len(baseCfg.CipherSuites) != 0 { - baseCfg.PreferServerCipherSuites = true - } - baseCfg.MinVersion = tlsVersions[0] baseCfg.MaxVersion = tlsVersions[1] log.Debugf("tls: min version: %x, max version: %x", tlsVersions[0], tlsVersions[1]) diff --git a/framework/container/container.go b/framework/container/container.go new file mode 100644 index 000000000..256905228 --- /dev/null +++ b/framework/container/container.go @@ -0,0 +1,70 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package container + +import ( + "github.com/foxcpp/maddy/framework/log" +) + +type GlobalConfig struct { + // StateDirectory contains the path to the directory that + // should be used to store any data that should be + // preserved between sessions. + // + // Value of this variable must not change after initialization + // in cmd/maddy/main.go. + StateDirectory string + + // RuntimeDirectory contains the path to the directory that + // should be used to store any temporary data. + // + // It should be preferred over os.TempDir, which is + // global and world-readable on most systems, while + // RuntimeDirectory can be dedicated for maddy. + // + // Value of this variable must not change after initialization + // in cmd/maddy/main.go. + RuntimeDirectory string + + // LibexecDirectory contains the path to the directory + // where helper binaries should be searched. + // + // Value of this variable must not change after initialization + // in cmd/maddy/main.go. + LibexecDirectory string +} + +type C struct { + Config GlobalConfig + DefaultLogger *log.Logger + Modules *Registry + Lifetime *LifetimeTracker +} + +func New() *C { + rootLog := log.DefaultLogger.Sublogger("") + return &C{ + DefaultLogger: rootLog, + Modules: NewRegistry(rootLog.Sublogger("registry")), + Lifetime: NewLifetime(rootLog.Sublogger("lifetime")), + } +} + +// Global is the default instance while refactoring is in progress. +var Global *C diff --git a/framework/container/lifetime.go b/framework/container/lifetime.go new file mode 100644 index 000000000..993f6d946 --- /dev/null +++ b/framework/container/lifetime.go @@ -0,0 +1,164 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2025 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package container + +import ( + "fmt" + + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" +) + +// LifetimeModule is a stateful module that needs to have post-configuration +// startup and graceful shutdown functionality. +type LifetimeModule interface { + module.Module + Start() error + Stop() error +} + +type ReloadModule interface { + module.Module + Reload() error +} + +// EarlyStopModule is a LifetimeModule that needs to do some bookkeeping +// before new server instance starts during reload. +type EarlyStopModule interface { + LifetimeModule + EarlyStop() error +} + +type LifetimeTracker struct { + logger *log.Logger + instances []*struct { + mod LifetimeModule + started bool + earlyStopped bool + } +} + +func (lt *LifetimeTracker) Add(mod LifetimeModule) { + lt.instances = append(lt.instances, &struct { + mod LifetimeModule + started bool + earlyStopped bool + }{mod: mod, started: false}) +} + +// StartAll calls Start for all registered LifetimeModule instances. +func (lt *LifetimeTracker) StartAll() error { + for _, entry := range lt.instances { + if entry.started { + continue + } + + lt.logger.DebugMsg("starting module", + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + + if err := entry.mod.Start(); err != nil { + if err := lt.StopAll(); err != nil { + lt.logger.Error("StopAll failed after Start fail", err) + } + return fmt.Errorf("failed to start module %v: %w", + entry.mod.InstanceName(), err) + } + lt.logger.DebugMsg("module started", + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + entry.started = true + } + return nil +} + +func (lt *LifetimeTracker) ReloadAll() error { + for _, entry := range lt.instances { + if !entry.started { + continue + } + + rm, ok := entry.mod.(ReloadModule) + if !ok { + continue + } + + if err := rm.Reload(); err != nil { + lt.logger.Error("module reload failed", err, + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + continue + } + + lt.logger.DebugMsg("module reloaded", + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + } + return nil +} + +func (lt *LifetimeTracker) EarlyStopAll() error { + for i := len(lt.instances) - 1; i>= 0; i-- { + entry := lt.instances[i] + + if !entry.started { + continue + } + + rsm, ok := entry.mod.(EarlyStopModule) + if !ok { + continue + } + + if err := rsm.EarlyStop(); err != nil { + lt.logger.Error("module early stop failed", err, + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + continue + } + lt.logger.DebugMsg("module early stopped", + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + + entry.earlyStopped = true + } + return nil +} + +// StopAll calls Stop for all registered LifetimeModule instances. +func (lt *LifetimeTracker) StopAll() error { + for i := len(lt.instances) - 1; i>= 0; i-- { + entry := lt.instances[i] + + if !entry.started { + continue + } + + if err := entry.mod.Stop(); err != nil { + lt.logger.Error("module stop failed", err, + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + continue + } + lt.logger.DebugMsg("module stopped", + "mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName()) + + entry.started = false + } + return nil +} + +func NewLifetime(log *log.Logger) *LifetimeTracker { + return &LifetimeTracker{ + logger: log, + } +} diff --git a/framework/container/registry.go b/framework/container/registry.go new file mode 100644 index 000000000..5cdf9f00c --- /dev/null +++ b/framework/container/registry.go @@ -0,0 +1,148 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package container + +import ( + "errors" + + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" +) + +var ( + ErrInstanceNameDuplicate = errors.New("instance name already registered") + ErrInstanceUnknown = errors.New("no such instance registered") +) + +type registryEntry struct { + Mod module.Module + LazyInit func() error +} + +type Registry struct { + logger *log.Logger + instances map[string]registryEntry + initialized map[string]struct{} + started map[string]struct{} + aliases map[string]string +} + +func NewRegistry(log *log.Logger) *Registry { + return &Registry{ + logger: log, + instances: make(map[string]registryEntry), + initialized: make(map[string]struct{}), + started: make(map[string]struct{}), + aliases: make(map[string]string), + } +} + +// Register adds not-initialized (configured) module into registry. +// +// lazyInit function will be called on first request to get the module from +// registry. +func (r *Registry) Register(mod module.Module, lazyInit func() error) error { + instName := mod.InstanceName() + if instName == "" { + panic("module with empty instance name cannot be added to the registry") + } + + _, ok := r.instances[instName] + if ok { + return ErrInstanceNameDuplicate + } + + r.instances[instName] = registryEntry{ + Mod: mod, + LazyInit: lazyInit, + } + return nil +} + +func (r *Registry) AddAlias(instanceName string, alias string) error { + if instanceName == "" { + panic("cannot add an alias for empty instance name") + } + if alias == "" { + panic("cannot add an empty alias") + } + _, ok := r.aliases[alias] + if ok { + return ErrInstanceNameDuplicate + } + _, ok = r.instances[instanceName] + if ok { + return ErrInstanceNameDuplicate + } + + r.aliases[alias] = instanceName + return nil +} + +func (r *Registry) ensureInitialized(name string, entry *registryEntry) error { + _, ok := r.initialized[name] + if ok { + return nil + } + if entry.LazyInit == nil { + return nil + } + + r.logger.DebugMsg("module configure", + "mod_name", entry.Mod.Name(), "inst_name", entry.Mod.InstanceName()) + r.initialized[name] = struct{}{} + err := entry.LazyInit() + if err != nil { + return err + } + + return nil +} + +func (r *Registry) Get(name string) (module.Module, error) { + if name == "" { + panic("cannot get module with empty name") + } + aliasedName := r.aliases[name] + if aliasedName != "" { + name = aliasedName + } + + mod, ok := r.instances[name] + if !ok { + return nil, ErrInstanceUnknown + } + + if err := r.ensureInitialized(name, &mod); err != nil { + return nil, err + } + + return mod.Mod, nil +} + +func (r *Registry) NotInitialized() []module.Module { + notinit := make([]module.Module, 0, len(r.instances)-len(r.initialized)) + for name, mod := range r.instances { + if _, ok := r.initialized[name]; ok { + continue + } + notinit = append(notinit, mod.Mod) + } + return notinit +} diff --git a/framework/dns/debugflags.go b/framework/dns/debugflags.go index 41e30fd15..937edc97f 100644 --- a/framework/dns/debugflags.go +++ b/framework/dns/debugflags.go @@ -1,4 +1,5 @@ -//+build debugflags +//go:build debugflags +// +build debugflags /* Maddy Mail Server - Composable all-in-one email server. @@ -21,9 +22,22 @@ along with this program. If not, see . package dns import ( - "flag" + maddycli "github.com/foxcpp/maddy/internal/cli" + "github.com/urfave/cli/v2" ) func init() { - flag.StringVar(&overrideServ, "debug.dnsoverride", "system-default", "replace the DNS resolver address") + maddycli.AddGlobalFlag(&cli.StringFlag{ + Name: "debug.dnsoverride", + Usage: "replace the DNS resolver address", + Value: "system-default", + Destination: &overrideServ, + Action: func(context *cli.Context, s string) error { + if s != "" && s != "system-default" { + override(s) + } + overrideServ = s + return nil + }, + }) } diff --git a/framework/dns/dnssec.go b/framework/dns/dnssec.go index 74982beff..b8e9c19d5 100644 --- a/framework/dns/dnssec.go +++ b/framework/dns/dnssec.go @@ -229,7 +229,7 @@ func (e ExtResolver) CheckCNAMEAD(ctx context.Context, host string) (ad bool, rn if rname == "" { // IPv6-only host? Try to find out rname using AAAA lookup. msg := new(dns.Msg) - msg.SetQuestion(dns.Fqdn(host), dns.TypeA) + msg.SetQuestion(dns.Fqdn(host), dns.TypeAAAA) msg.SetEdns0(4096, false) msg.AuthenticatedData = true resp, err := e.exchange(ctx, msg) diff --git a/framework/dns/dnssec_test.go b/framework/dns/dnssec_test.go index 774897bf0..8a6de4266 100644 --- a/framework/dns/dnssec_test.go +++ b/framework/dns/dnssec_test.go @@ -11,6 +11,7 @@ import ( "github.com/foxcpp/maddy/framework/log" "github.com/miekg/dns" + "github.com/stretchr/testify/require" ) type TestSrvAction int @@ -55,8 +56,8 @@ func (s *IPAddrTestServer) Run() { go s.udpServ.ActivateAndServe() //nolint:errcheck } -func (s *IPAddrTestServer) Close() { - s.udpServ.PacketConn.Close() +func (s *IPAddrTestServer) Close() error { + return s.udpServ.PacketConn.Close() } func (s *IPAddrTestServer) Addr() *net.UDPAddr { @@ -128,7 +129,11 @@ func TestExtResolver_AuthLookupIPAddr(t *testing.T) { // AD flag handling for use in DANE algorithms. // Silence log messages about disregarded I/O errors. - log.DefaultLogger.Out = nil + oldLog := log.DefaultLogger + log.DefaultLogger = log.NopLogger + t.Cleanup(func() { + log.DefaultLogger = oldLog + }) test := func(aAct, aaaaAct TestSrvAction, aAD, aaaaAD, ad bool, addrs []net.IP, err bool) { t.Helper() @@ -141,7 +146,9 @@ func TestExtResolver_AuthLookupIPAddr(t *testing.T) { s.aAD = aAD s.aaaaAD = aaaaAD s.Run() - defer s.Close() + defer func() { + require.NoError(t, s.Close()) + }() res := ExtResolver{ cl: new(dns.Client), Cfg: &dns.ClientConfig{ diff --git a/framework/dns/override.go b/framework/dns/override.go index 25f0da080..0f073afd0 100644 --- a/framework/dns/override.go +++ b/framework/dns/override.go @@ -32,7 +32,7 @@ var overrideServ string // // The server argument is in form of "IP:PORT". It is expected that the server // will be available both using TCP and UDP on the same port. -func override(server string) { +func override(server string) { // nolint: unused // used in debugflags.go net.DefaultResolver.PreferGo = true net.DefaultResolver.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) { dialer := net.Dialer{ diff --git a/framework/dns/resolver.go b/framework/dns/resolver.go index f1393fe68..416873110 100644 --- a/framework/dns/resolver.go +++ b/framework/dns/resolver.go @@ -53,9 +53,5 @@ func LookupAddr(ctx context.Context, r Resolver, ip net.IP) (string, error) { } func DefaultResolver() Resolver { - if overrideServ != "" && overrideServ != "system-default" { - override(overrideServ) - } - return net.DefaultResolver } diff --git a/framework/log/log.go b/framework/log/log.go index c3fa3af8b..f17a6a0e7 100644 --- a/framework/log/log.go +++ b/framework/log/log.go @@ -22,7 +22,6 @@ package log import ( "fmt" "io" - "io/ioutil" "os" "strings" "time" @@ -43,6 +42,8 @@ import ( // No serialization is provided by Logger, its log.Output responsibility to // ensure goroutine-safety if necessary. type Logger struct { + Parent *Logger + Out Output Name string Debug bool @@ -52,48 +53,53 @@ type Logger struct { Fields map[string]interface{} } -func (l Logger) Zap() *zap.Logger { +func (l *Logger) Zap() *zap.Logger { // TODO: Migrate to using zap natively. return zap.New(zapLogger{L: l}) } -func (l Logger) Debugf(format string, val ...interface{}) { - if !l.Debug { +func (l *Logger) IsDebug() bool { + return l.Debug || (l.Parent != nil && l.Parent.IsDebug()) +} + +func (l *Logger) Debugf(format string, val ...interface{}) { + if !l.IsDebug() { return } l.log(true, l.formatMsg(fmt.Sprintf(format, val...), nil)) } -func (l Logger) Debugln(val ...interface{}) { - if !l.Debug { +func (l *Logger) Debugln(val ...interface{}) { + if !l.IsDebug() { return } l.log(true, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil)) } -func (l Logger) Printf(format string, val ...interface{}) { +func (l *Logger) Printf(format string, val ...interface{}) { l.log(false, l.formatMsg(fmt.Sprintf(format, val...), nil)) } -func (l Logger) Println(val ...interface{}) { +func (l *Logger) Println(val ...interface{}) { l.log(false, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil)) } // Msg writes an event log message in a machine-readable format (currently // JSON). -// name: msg\t{"key":"value","key2":"value2"} +// +// name: msg\t{"key":"value","key2":"value2"} // // Key-value pairs are built from fields slice which should contain key strings // followed by corresponding values. That is, for example, []interface{"key", // "value", "key2", "value2"}. // -// If value in fields implements LogFormatter, it will be represented by the +// If value in fields implements Formatter, it will be represented by the // string returned by FormatLog method. Same goes for fmt.Stringer and error // interfaces. // // Additionally, time.Time is written as a string in ISO 8601 format. // time.Duration follows fmt.Stringer rule above. -func (l Logger) Msg(msg string, fields ...interface{}) { +func (l *Logger) Msg(msg string, fields ...interface{}) { m := make(map[string]interface{}, len(fields)/2) fieldsToMap(fields, m) l.log(false, l.formatMsg(msg, m)) @@ -103,14 +109,16 @@ func (l Logger) Msg(msg string, fields ...interface{}) { // JSON) containing information about the error. If err does have a Fields // method that returns map[string]interface{}, its result will be added to the // message. -// name: msg\t{"key":"value","key2":"value2"} +// +// name: msg\t{"key":"value","key2":"value2"} +// // Additionally, values from fields will be added to it, as handled by // Logger.Msg. // // In the context of Error method, "msg" typically indicates the top-level // context in which the error is *handled*. For example, if error leads to // rejection of SMTP DATA command, msg will probably be "DATA error". -func (l Logger) Error(msg string, err error, fields ...interface{}) { +func (l *Logger) Error(msg string, err error, fields ...interface{}) { if err == nil { return } @@ -131,8 +139,8 @@ func (l Logger) Error(msg string, err error, fields ...interface{}) { l.log(false, l.formatMsg(msg, allFields)) } -func (l Logger) DebugMsg(kind string, fields ...interface{}) { - if !l.Debug { +func (l *Logger) DebugMsg(kind string, fields ...interface{}) { + if !l.IsDebug() { return } m := make(map[string]interface{}, len(fields)/2) @@ -160,7 +168,7 @@ func fieldsToMap(fields []interface{}, out map[string]interface{}) { } } -func (l Logger) formatMsg(msg string, fields map[string]interface{}) string { +func (l *Logger) formatMsg(msg string, fields map[string]interface{}) string { formatted := strings.Builder{} formatted.WriteString(msg) @@ -182,46 +190,92 @@ func (l Logger) formatMsg(msg string, fields map[string]interface{}) string { return formatted.String() } -type LogFormatter interface { +type Formatter interface { FormatLog() string } // Write implements io.Writer, all bytes sent // to it will be written as a separate log messages. // No line-buffering is done. -func (l Logger) Write(s []byte) (int, error) { +func (l *Logger) Write(s []byte) (int, error) { + if !l.IsDebug() { + return len(s), nil + } l.log(false, strings.TrimRight(string(s), "\n")) return len(s), nil } +// Close closes underlying output in Out. +func (l *Logger) Close() error { + if l.Out == nil { + return nil + } + + return l.Out.Close() +} + // DebugWriter returns a writer that will act like Logger.Write // but will use debug flag on messages. If Logger.Debug is false, // Write method of returned object will be no-op. -func (l Logger) DebugWriter() io.Writer { - if !l.Debug { - return ioutil.Discard +func (l *Logger) DebugWriter() io.Writer { + l2 := l.Sublogger("") + l2.Debug = true + return l2 +} + +func (l *Logger) output() Output { + if l.Out != nil { + return l.Out + } + if l.Parent != nil { + return l.Parent.output() + } + + if DefaultLogger.Out == nil { + panic("DefaultLogger.Out is not set") } - l.Debug = true - return &l + if l.Parent == nil && l != &DefaultLogger { + DefaultLogger.Out.Write(time.Now(), true, "logger "+l.Name+" has no parent, this is a bug") + } + return DefaultLogger.Out } -func (l Logger) log(debug bool, s string) { +func (l *Logger) log(debug bool, s string) { if l.Name != "" { s = l.Name + ": " + s } - if l.Out != nil { - l.Out.Write(time.Now(), debug, s) - return + out := l.output() + out.Write(time.Now(), debug, s) + + // Logging is disabled - do nothing. +} + +func (l *Logger) logNameOverwrite(loggerName string, debug bool, s string) { + if loggerName == "" { + loggerName = l.Name } - if DefaultLogger.Out != nil { - DefaultLogger.Out.Write(time.Now(), debug, s) - return + if loggerName != "" { + s = loggerName + ": " + s } + out := l.output() + out.Write(time.Now(), debug, s) + // Logging is disabled - do nothing. } + +func (l *Logger) Sublogger(name string) *Logger { + if l.Name != "" && name != "" { + name = l.Name + "/" + name + } + return &Logger{ + Parent: l, + Name: name, + } +} + // DefaultLogger is the global Logger object that is used by // package-level logging functions. // @@ -229,6 +283,12 @@ func (l Logger) log(debug bool, s string) { // however underlying log.Output may provide necessary serialization. var DefaultLogger = Logger{Out: WriterOutput(os.Stderr, false)} +// NopLogger is the logger that discards all messages written to it. +var NopLogger = Logger{ + Parent: &DefaultLogger, + Out: NopOutput{}, +} + func Debugf(format string, val ...interface{}) { DefaultLogger.Debugf(format, val...) } func Debugln(val ...interface{}) { DefaultLogger.Debugln(val...) } func Printf(format string, val ...interface{}) { DefaultLogger.Printf(format, val...) } diff --git a/framework/log/orderedjson.go b/framework/log/orderedjson.go index 834f3a6c9..387253c5c 100644 --- a/framework/log/orderedjson.go +++ b/framework/log/orderedjson.go @@ -31,6 +31,11 @@ import ( // human-readable when values from multiple messages are lined up to each // other. +type module interface { + Name() string + InstanceName() string +} + func marshalOrderedJSON(output *strings.Builder, m map[string]interface{}) error { order := make([]string, 0, len(m)) for k := range m { @@ -58,10 +63,12 @@ func marshalOrderedJSON(output *strings.Builder, m map[string]interface{}) error val = casted.Format("2006-01-02T15:04:05.000") case time.Duration: val = casted.String() - case LogFormatter: + case Formatter: val = casted.FormatLog() case fmt.Stringer: val = casted.String() + case module: + val = casted.Name() + "/" + casted.InstanceName() case error: val = casted.Error() } diff --git a/framework/log/syslog.go b/framework/log/syslog.go index 7e63a9ddf..d608f557d 100644 --- a/framework/log/syslog.go +++ b/framework/log/syslog.go @@ -1,4 +1,5 @@ -//+build !windows,!plan9 +//go:build !windows && !plan9 +// +build !windows,!plan9 /* Maddy Mail Server - Composable all-in-one email server. diff --git a/framework/log/syslog_stub.go b/framework/log/syslog_stub.go index 79a196986..bc4861661 100644 --- a/framework/log/syslog_stub.go +++ b/framework/log/syslog_stub.go @@ -1,4 +1,5 @@ -//+build windows plan9 +//go:build windows || plan9 +// +build windows plan9 /* Maddy Mail Server - Composable all-in-one email server. diff --git a/framework/log/zap.go b/framework/log/zap.go index 23821f845..4dff15105 100644 --- a/framework/log/zap.go +++ b/framework/log/zap.go @@ -7,7 +7,7 @@ import ( // TODO: Migrate to using actual zapcore to improve logging performance type zapLogger struct { - L Logger + L *Logger } func (l zapLogger) Enabled(level zapcore.Level) bool { @@ -46,9 +46,14 @@ func (l zapLogger) Write(entry zapcore.Entry, fields []zapcore.Field) error { f.AddTo(enc) } if entry.LoggerName != "" { - l.L.Name += "/" + entry.LoggerName + l.L.logNameOverwrite( + l.L.Name+"/"+entry.LoggerName, + entry.Level == zapcore.DebugLevel, + l.L.formatMsg(entry.Message, enc.Fields), + ) + } else { + l.L.log(entry.Level == zapcore.DebugLevel, l.L.formatMsg(entry.Message, enc.Fields)) } - l.L.log(entry.Level == zapcore.DebugLevel, l.L.formatMsg(entry.Message, enc.Fields)) return nil } diff --git a/framework/module/auth.go b/framework/module/auth.go index 7f35888a1..6e8d08945 100644 --- a/framework/module/auth.go +++ b/framework/module/auth.go @@ -33,7 +33,7 @@ type PlainAuth interface { AuthPlain(username, password string) error } -// PlainUserDB is a local credentials store that can be managed using maddyctl +// PlainUserDB is a local credentials store that can be managed using maddy command // utility. type PlainUserDB interface { PlainAuth diff --git a/framework/module/check.go b/framework/module/check.go index e2139d6c5..4802f6eed 100644 --- a/framework/module/check.go +++ b/framework/module/check.go @@ -23,7 +23,6 @@ import ( "github.com/emersion/go-message/textproto" "github.com/emersion/go-msgauth/authres" - "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" ) @@ -52,15 +51,20 @@ type Check interface { // The Status of this check is accept (no error) or reject (error) only, no // advanced handling is available (such as 'quarantine' action and headers // prepending). +// +// If it s necessary to defer or affect further message processing +// without outright killing the session, ConnState.ModData can be +// used to store necessary information. +// +// It may be called multiple times for the same connection if TLS is negotiated +// via STARTTLS. In this case, no state will be passed between before-TLS +// context to the TLS one. type EarlyCheck interface { - CheckConnection(ctx context.Context, state *smtp.ConnectionState) error + CheckConnection(ctx context.Context, state *ConnState) error } type CheckState interface { // CheckConnection is executed once when client sends a new message. - // - // Result may be cached for the whole client connection so this function - // may not be called sometimes. CheckConnection(ctx context.Context) CheckResult // CheckSender is executed once when client sends the message sender diff --git a/framework/module/delivery_target.go b/framework/module/delivery_target.go index 94757a657..40e5881ae 100644 --- a/framework/module/delivery_target.go +++ b/framework/module/delivery_target.go @@ -22,6 +22,7 @@ import ( "context" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" ) @@ -32,12 +33,12 @@ import ( // Modules implementing this interface should be registered with "target." // prefix in name. type DeliveryTarget interface { - // Start starts the delivery of a new message. + // StartDelivery starts the delivery of a new message. // // The domain part of the MAIL FROM address is assumed to be U-labels with // NFC normalization and case-folding applied. The message source should // ensure that by calling address.CleanDomain if necessary. - Start(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error) + StartDelivery(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error) } type Delivery interface { @@ -53,10 +54,10 @@ type Delivery interface { // however. They should be silently ignored. // // Implementation should do as much checks as possible here and reject - // recipients that can't be used. Note: MsgMetadata object passed to Start + // recipients that can't be used. Note: MsgMetadata object passed to StartDelivery // contains BodyLength field. If it is non-zero, it can be used to check // storage quota for the user before Body. - AddRcpt(ctx context.Context, rcptTo string) error + AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error // Body sets the body and header contents for the message. // If this method fails, message is assumed to be undeliverable diff --git a/framework/module/imap_filter.go b/framework/module/imap_filter.go index 01b67ba76..6b0fd46a5 100644 --- a/framework/module/imap_filter.go +++ b/framework/module/imap_filter.go @@ -39,5 +39,5 @@ type IMAPFilter interface { // // Errors returned by IMAPFilter will be just logged and will not cause delivery // to fail. - IMAPFilter(accountName string, meta *MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) + IMAPFilter(accountName string, rcptTo string, meta *MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) } diff --git a/framework/module/instances.go b/framework/module/instances.go deleted file mode 100644 index aa6f14892..000000000 --- a/framework/module/instances.go +++ /dev/null @@ -1,105 +0,0 @@ -/* -Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -*/ - -package module - -import ( - "fmt" - "io" - - "github.com/foxcpp/maddy/framework/config" - "github.com/foxcpp/maddy/framework/hooks" - "github.com/foxcpp/maddy/framework/log" -) - -var ( - instances = make(map[string]struct { - mod Module - cfg *config.Map - }) - aliases = make(map[string]string) - - Initialized = make(map[string]bool) -) - -// RegisterInstance adds module instance to the global registry. -// -// Instance name must be unique. Second RegisterInstance with same instance -// name will replace previous. -func RegisterInstance(inst Module, cfg *config.Map) { - instances[inst.InstanceName()] = struct { - mod Module - cfg *config.Map - }{inst, cfg} -} - -// RegisterAlias creates an association between a certain name and instance name. -// -// After RegisterAlias, module.GetInstance(aliasName) will return the same -// result as module.GetInstance(instName). -func RegisterAlias(aliasName, instName string) { - aliases[aliasName] = instName -} - -func HasInstance(name string) bool { - aliasedName := aliases[name] - if aliasedName != "" { - name = aliasedName - } - - _, ok := instances[name] - return ok -} - -// GetInstance returns module instance from global registry, initializing it if -// necessary. -// -// Error is returned if module initialization fails or module instance does not -// exists. -func GetInstance(name string) (Module, error) { - aliasedName := aliases[name] - if aliasedName != "" { - name = aliasedName - } - - mod, ok := instances[name] - if !ok { - return nil, fmt.Errorf("unknown config block: %s", name) - } - - // Break circular dependencies. - if Initialized[name] { - return mod.mod, nil - } - - Initialized[name] = true - if err := mod.mod.Init(mod.cfg); err != nil { - return mod.mod, err - } - - if closer, ok := mod.mod.(io.Closer); ok { - hooks.AddHook(hooks.EventShutdown, func() { - log.Debugf("close %s (%s)", mod.mod.Name(), mod.mod.InstanceName()) - if err := closer.Close(); err != nil { - log.Printf("module %s (%s) close failed: %v", mod.mod.Name(), mod.mod.InstanceName(), err) - } - }) - } - - return mod.mod, nil -} diff --git a/framework/module/modifier.go b/framework/module/modifier.go index f4e5561d9..2a5b10f5f 100644 --- a/framework/module/modifier.go +++ b/framework/module/modifier.go @@ -63,12 +63,12 @@ type ModifierState interface { RewriteSender(ctx context.Context, mailFrom string) (string, error) // RewriteRcpt replaces RCPT TO value. - // If no changed are required, this method returns its argument, otherwise - // it returns a new value. + // If no changed are required, this method returns its argument as slice, + // otherwise it returns a slice with 1 or more new values. // // MsgPipeline will take of populating MsgMeta.OriginalRcpts. RewriteRcpt // doesn't do it. - RewriteRcpt(ctx context.Context, rcptTo string) (string, error) + RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) // RewriteBody modifies passed Header argument and may optionally // inspect the passed body buffer to make a decision on new header field values. diff --git a/framework/module/module.go b/framework/module/module.go index 2dbb45e98..2af39990c 100644 --- a/framework/module/module.go +++ b/framework/module/module.go @@ -38,22 +38,8 @@ import ( ) // Module is the interface implemented by all maddy module instances. -// -// It defines basic methods used to identify instances. -// -// Additionally, module can implement io.Closer if it needs to perform clean-up -// on shutdown. If module starts long-lived goroutines - they should be stopped -// *before* Close method returns to ensure graceful shutdown. type Module interface { - // Init performs actual initialization of the module. - // - // It is not done in FuncNewModule so all module instances are - // registered at time of initialization, thus initialization does not - // depends on ordering of configuration blocks and modules can reference - // each other without any problems. - // - // Module can use passed config.Map to read its configuration variables. - Init(*config.Map) error + Configure(inlineArgs []string, config *config.Map) error // Name method reports module name. // @@ -64,27 +50,3 @@ type Module interface { // string if module instance is unnamed. InstanceName() string } - -// FuncNewModule is function that creates new instance of module with specified name. -// -// Module.InstanceName() of the returned module object should return instName. -// aliases slice contains other names that can be used to reference created -// module instance. -// -// If module is defined inline, instName will be empty and all values -// specified after module name in configuration will be in inlineArgs. -type FuncNewModule func(modName, instName string, aliases, inlineArgs []string) (Module, error) - -// FuncNewEndpoint is a function that creates new instance of endpoint -// module. -// -// Compared to regular modules, endpoint module instances are: -// - Not registered in the global registry. -// - Can't be defined inline. -// - Don't have an unique name -// - All config arguments are always passed as an 'addrs' slice and not used as -// names. -// -// As a consequence of having no per-instance name, InstanceName of the module -// object always returns the same value as Name. -type FuncNewEndpoint func(modName string, addrs []string) (Module, error) diff --git a/framework/module/module_specific_data.go b/framework/module/module_specific_data.go new file mode 100644 index 000000000..4155a61a0 --- /dev/null +++ b/framework/module/module_specific_data.go @@ -0,0 +1,63 @@ +package module + +import ( + "encoding/json" + "fmt" + "sync" +) + +// ModSpecificData is a container that allows modules to attach +// additional context data to framework objects such as SMTP connections +// without conflicting with each other and ensuring each module +// gets its own namespace. +// +// It must not be used to store stateful objects that may need +// a specific cleanup routine as ModSpecificData does not provide +// any lifetime management. +// +// Stored data must be serializable to JSON for state persistence +// e.g. when message is stored in a on-disk queue. +type ModSpecificData struct { + modDataLck sync.RWMutex + modData map[string]interface{} +} + +func (msd *ModSpecificData) modKey(m Module, perInstance bool) string { + if !perInstance { + return m.Name() + } + instName := m.InstanceName() + if instName == "" { + instName = fmt.Sprintf("%x", m) + } + return m.Name() + "/" + instName +} + +func (msd *ModSpecificData) MarshalJSON() ([]byte, error) { + msd.modDataLck.RLock() + defer msd.modDataLck.RUnlock() + return json.Marshal(msd.modData) +} + +func (msd *ModSpecificData) UnmarshalJSON(b []byte) error { + msd.modDataLck.Lock() + defer msd.modDataLck.Unlock() + return json.Unmarshal(b, &msd.modData) +} + +func (msd *ModSpecificData) Set(m Module, perInstance bool, value interface{}) { + key := msd.modKey(m, perInstance) + msd.modDataLck.Lock() + defer msd.modDataLck.Unlock() + if msd.modData == nil { + msd.modData = make(map[string]interface{}) + } + msd.modData[key] = value +} + +func (msd *ModSpecificData) Get(m Module, perInstance bool) interface{} { + key := msd.modKey(m, perInstance) + msd.modDataLck.RLock() + defer msd.modDataLck.RUnlock() + return msd.modData[key] +} diff --git a/framework/module/dummy.go b/framework/module/modules/dummy.go similarity index 74% rename from framework/module/dummy.go rename to framework/module/modules/dummy.go index 16ed1500f..7d2051d17 100644 --- a/framework/module/dummy.go +++ b/framework/module/modules/dummy.go @@ -16,14 +16,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package module +package modules import ( "context" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/module" ) // Dummy is a struct that implements PlainAuth and DeliveryTarget @@ -41,6 +44,10 @@ func (d *Dummy) Lookup(_ context.Context, _ string) (string, bool, error) { return "", false, nil } +func (d *Dummy) LookupMulti(_ context.Context, _ string) ([]string, error) { + return []string{""}, nil +} + func (d *Dummy) Name() string { return "dummy" } @@ -49,17 +56,17 @@ func (d *Dummy) InstanceName() string { return d.instName } -func (d *Dummy) Init(_ *config.Map) error { +func (d *Dummy) Configure(_ []string, _ *config.Map) error { return nil } -func (d *Dummy) Start(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error) { +func (d *Dummy) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { return dummyDelivery{}, nil } type dummyDelivery struct{} -func (dd dummyDelivery) AddRcpt(ctx context.Context, to string) error { +func (dd dummyDelivery) AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error { return nil } @@ -75,8 +82,6 @@ func (dd dummyDelivery) Commit(ctx context.Context) error { return nil } -func init() { - Register("dummy", func(_, instName string, _, _ []string) (Module, error) { - return &Dummy{instName: instName}, nil - }) +func NewDummy(_ *container.C, _, instName string) (module.Module, error) { + return &Dummy{instName: instName}, nil } diff --git a/framework/module/registry.go b/framework/module/modules/modules.go similarity index 68% rename from framework/module/registry.go rename to framework/module/modules/modules.go index c52210f8a..44604ab90 100644 --- a/framework/module/registry.go +++ b/framework/module/modules/modules.go @@ -16,22 +16,39 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package module +package modules import ( "sync" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" ) -var ( - // NoRun makes sure modules do not start any bacground tests. - // - // If it set - modules should not perform any actual work and should stop - // once the configuration is read and verified to be correct. - // TODO: Replace it with separation of Init and Run at interface level. - NoRun = false +// FuncNewModule is function that creates new instance of module with specified name. +// +// Module.InstanceName() of the returned module object should return instName. +// If module is defined inline, instName will be empty. +// +// Returned Module may additionally implement LifetimeModule. +type FuncNewModule func(c *container.C, modName, instName string) (module.Module, error) +// FuncNewEndpoint is a function that creates new instance of endpoint +// module. +// +// Compared to regular modules, endpoint module instances are: +// - Not registered in the global registry. +// - Can't be defined inline. +// - Don't have an unique name +// - All config arguments are always passed as an 'addrs' slice and not used as +// names. +// +// As a consequence of having no per-instance name, InstanceName of the module +// object always returns the same value as Name. +type FuncNewEndpoint func(c *container.C, modName string, addrs []string) (container.LifetimeModule, error) + +var ( modules = make(map[string]FuncNewModule) endpoints = make(map[string]FuncNewEndpoint) modulesLock sync.RWMutex @@ -59,9 +76,9 @@ func Register(name string, factory FuncNewModule) { // It prints warning to the log about name being deprecated and suggests using // a new name. func RegisterDeprecated(name, newName string, factory FuncNewModule) { - Register(name, func(modName, instName string, aliases, inlineArgs []string) (Module, error) { + Register(name, func(c *container.C, modName, instName string) (module.Module, error) { log.Printf("module initialized via deprecated name %s, %s should be used instead; deprecated name may be removed in the next version", name, newName) - return factory(modName, instName, aliases, inlineArgs) + return factory(c, modName, instName) }) } @@ -77,7 +94,7 @@ func Get(name string) FuncNewModule { return modules[name] } -// GetEndpoints returns an endpoint module from global registry. +// GetEndpoint returns an endpoint module from global registry. // // Nil is returned if no module with specified name is registered. func GetEndpoint(name string) FuncNewEndpoint { @@ -101,3 +118,7 @@ func RegisterEndpoint(name string, factory FuncNewEndpoint) { endpoints[name] = factory } + +func init() { + Register("dummy", NewDummy) +} diff --git a/framework/module/msgmetadata.go b/framework/module/msgmetadata.go index 79d65b172..bfbe634af 100644 --- a/framework/module/msgmetadata.go +++ b/framework/module/msgmetadata.go @@ -20,8 +20,10 @@ package module import ( "crypto/rand" + "crypto/tls" "encoding/hex" "io" + "net" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/future" @@ -37,7 +39,10 @@ type ConnState struct { // Information about the SMTP connection, including HELO hostname and // source IP. Valid only if Proto refers the SMTP protocol or its variant // (e.g. LMTP). - smtp.ConnectionState + Hostname string + LocalAddr net.Addr + RemoteAddr net.Addr + TLS tls.ConnectionState // The RDNSName field contains the result of Reverse DNS lookup on the // client IP. @@ -61,6 +66,8 @@ type ConnState struct { // If the client successfully authenticated using a username/password pair. // This field should be cleaned if the ConnState object is serialized AuthPassword string + + ModData ModSpecificData } // MsgMetadata structure contains all information about the origin of diff --git a/framework/module/mxauth.go b/framework/module/mxauth.go index fb09e4c07..ac2167ca5 100644 --- a/framework/module/mxauth.go +++ b/framework/module/mxauth.go @@ -39,7 +39,9 @@ const ( TLSNone TLSLevel = iota TLSEncrypted TLSAuthenticated +) +const ( MXNone MXLevel = iota MX_MTASTS MX_DNSSEC @@ -94,7 +96,7 @@ type ( // Modules implementing this interface should be registered with "mx_auth." // prefix in name. MXAuthPolicy interface { - Start(*MsgMetadata) DeliveryMXAuthPolicy + StartDelivery(*MsgMetadata) DeliveryMXAuthPolicy // Weight is an integer in range 0-1000 that represents relative // ordering of policy application. @@ -113,11 +115,11 @@ type ( // CheckConn call. PrepareDomain(ctx context.Context, domain string) - // PrepareDomain is called before connection and may asynchronously + // PrepareConn is called before connection and may asynchronously // start additional lookups necessary for policy application in // CheckConn. // - // If there any errors - they should be deferred to the CheckConn + // If there are any errors - they should be deferred to the CheckConn // call. PrepareConn(ctx context.Context, mx string) diff --git a/framework/module/partial_delivery.go b/framework/module/partial_delivery.go index beeb46e84..fa1f74718 100644 --- a/framework/module/partial_delivery.go +++ b/framework/module/partial_delivery.go @@ -45,7 +45,7 @@ type StatusCollector interface { } // PartialDelivery is an optional interface that may be implemented -// by the object returned by DeliveryTarget.Start. See PartialDelivery.BodyNonAtomic +// by the object returned by DeliveryTarget.StartDelivery. See PartialDelivery.BodyNonAtomic // documentation for details. type PartialDelivery interface { // BodyNonAtomic is similar to Body method of the regular Delivery interface diff --git a/framework/resource/netresource/dup.go b/framework/resource/netresource/dup.go new file mode 100644 index 000000000..00d047dc5 --- /dev/null +++ b/framework/resource/netresource/dup.go @@ -0,0 +1,27 @@ +package netresource + +import "net" + +func dupTCPListener(l *net.TCPListener) (*net.TCPListener, error) { + f, err := l.File() + if err != nil { + return nil, err + } + l2, err := net.FileListener(f) + if err != nil { + return nil, err + } + return l2.(*net.TCPListener), nil +} + +func dupUnixListener(l *net.UnixListener) (*net.UnixListener, error) { + f, err := l.File() + if err != nil { + return nil, err + } + l2, err := net.FileListener(f) + if err != nil { + return nil, err + } + return l2.(*net.UnixListener), nil +} diff --git a/framework/resource/netresource/fd.go b/framework/resource/netresource/fd.go new file mode 100644 index 000000000..8c2d881ad --- /dev/null +++ b/framework/resource/netresource/fd.go @@ -0,0 +1,55 @@ +package netresource + +import ( + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +func ListenFD(fd uint) (net.Listener, error) { + file := os.NewFile(uintptr(fd), strconv.FormatUint(uint64(fd), 10)) + defer func() { + if err := file.Close(); err != nil { + panic(err) + } + }() + return net.FileListener(file) +} + +func ListenFDName(name string) (net.Listener, error) { + listenPDStr := os.Getenv("LISTEN_PID") + if listenPDStr == "" { + return nil, errors.New("$LISTEN_PID is not set") + } + listenPid, err := strconv.Atoi(listenPDStr) + if err != nil { + return nil, errors.New("$LISTEN_PID is not integer") + } + if listenPid != os.Getpid() { + return nil, fmt.Errorf("$LISTEN_PID (%d) is not our PID (%d)", listenPid, os.Getpid()) + } + + names := strings.Split(os.Getenv("LISTEN_FDNAMES"), ":") + fd := uintptr(0) + for i, fdName := range names { + if fdName == name { + fd = uintptr(3 + i) + break + } + } + + if fd == 0 { + return nil, fmt.Errorf("name %s not found in $LISTEN_FDNAMES", name) + } + + file := os.NewFile(3+fd, name) + defer func() { + if err := file.Close(); err != nil { + panic(err) + } + }() + return net.FileListener(file) +} diff --git a/framework/resource/netresource/listen.go b/framework/resource/netresource/listen.go new file mode 100644 index 000000000..23fea4a5f --- /dev/null +++ b/framework/resource/netresource/listen.go @@ -0,0 +1,42 @@ +package netresource + +import ( + "fmt" + "net" + "strconv" + + "github.com/foxcpp/maddy/framework/log" +) + +var ( + tracker = NewListenerTracker(log.DefaultLogger.Sublogger("netresource")) +) + +func CloseUnusedListeners() error { + return tracker.CloseUnused() +} + +func CloseAllListeners() error { + return tracker.Close() +} + +func ResetListenersUsage() { + tracker.ResetUsage() +} + +func Listen(network, addr string) (net.Listener, error) { + switch network { + case "fd": + fd, err := strconv.ParseUint(addr, 10, strconv.IntSize) + if err != nil { + return nil, fmt.Errorf("invalid FD number: %v", addr) + } + return ListenFD(uint(fd)) + case "fdname": + return ListenFDName(addr) + case "tcp", "tcp4", "tcp6", "unix": + return tracker.Get(network, addr) + default: + return nil, fmt.Errorf("unsupported network: %v", network) + } +} diff --git a/framework/resource/netresource/tracker.go b/framework/resource/netresource/tracker.go new file mode 100644 index 000000000..2099f7a78 --- /dev/null +++ b/framework/resource/netresource/tracker.go @@ -0,0 +1,97 @@ +package netresource + +import ( + "fmt" + "net" + "net/netip" + + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/resource" +) + +type ListenerTracker struct { + logger *log.Logger + tcp *resource.Tracker[*net.TCPListener] + unix *resource.Tracker[*net.UnixListener] +} + +func (lt *ListenerTracker) Get(network, addr string) (net.Listener, error) { + switch network { + case "tcp", "tcp4", "tcp6": + l, err := lt.tcp.GetOpen(addr, func() (*net.TCPListener, error) { + addrPort, err := netip.ParseAddrPort(addr) + if err != nil { + return nil, err + } + lt.logger.DebugMsg("new listener", "network", network, "address", addr) + return net.ListenTCP(network, net.TCPAddrFromAddrPort(addrPort)) + }) + if err != nil { + return nil, err + } + + // We return duplicated listener so when listener is closed by user endpoint + // the tracked resource remains available and listening on the port doesn't + // actually stop. + l2, err := dupTCPListener(l) + if err != nil { + return nil, err + } + return l2, nil + case "unix": + l, err := lt.unix.GetOpen(addr, func() (*net.UnixListener, error) { + addr, err := net.ResolveUnixAddr(network, addr) + if err != nil { + return nil, err + } + lt.logger.DebugMsg("new listener", "network", network, "address", addr) + return net.ListenUnix(network, addr) + }) + if err != nil { + return nil, err + } + + l2, err := dupUnixListener(l) + if err != nil { + return nil, err + } + return l2, nil + default: + return nil, fmt.Errorf("unsupported network type: %s", network) + } +} + +func (lt *ListenerTracker) ResetUsage() { + lt.tcp.MarkAllUnused() + lt.unix.MarkAllUnused() +} + +func (lt *ListenerTracker) CloseUnused() error { + if err := lt.tcp.CloseUnused(func(key string) bool { return true }); err != nil { + lt.logger.Error("CloseUnused for TCP failed", err) + } + if err := lt.unix.CloseUnused(func(key string) bool { return true }); err != nil { + lt.logger.Error("CloseUnused for Unix failed", err) + } + return nil +} + +func (lt *ListenerTracker) Close() error { + if err := lt.tcp.Close(); err != nil { + lt.logger.Error("Close for TCP failed", err) + } + if err := lt.unix.Close(); err != nil { + lt.logger.Error("Close for Unix failed", err) + } + return nil +} + +func NewListenerTracker(log *log.Logger) *ListenerTracker { + lt := &ListenerTracker{ + logger: log, + tcp: resource.NewTracker[*net.TCPListener](resource.NewSingleton[*net.TCPListener](log.Sublogger("tcp"))), + unix: resource.NewTracker[*net.UnixListener](resource.NewSingleton[*net.UnixListener](log.Sublogger("unix"))), + } + + return lt +} diff --git a/framework/resource/resource.go b/framework/resource/resource.go new file mode 100644 index 000000000..a34942a62 --- /dev/null +++ b/framework/resource/resource.go @@ -0,0 +1,18 @@ +package resource + +import ( + "io" +) + +type Resource = io.Closer + +type CheckableResource interface { + Resource + IsUsable() bool +} + +type Container[T Resource] interface { + io.Closer + GetOpen(key string, open func() (T, error)) (T, error) + CloseUnused(isUsed func(key string) bool) error +} diff --git a/framework/resource/singleton.go b/framework/resource/singleton.go new file mode 100644 index 000000000..33b71db56 --- /dev/null +++ b/framework/resource/singleton.go @@ -0,0 +1,76 @@ +package resource + +import ( + "sync" + + "github.com/foxcpp/maddy/framework/log" +) + +// Singleton represents a set of resources identified by an unique key. +type Singleton[T Resource] struct { + log *log.Logger + lock sync.RWMutex + resources map[string]T +} + +func NewSingleton[T Resource](log *log.Logger) *Singleton[T] { + return &Singleton[T]{ + log: log, + resources: make(map[string]T), + } +} + +func (s *Singleton[T]) GetOpen(key string, open func() (T, error)) (T, error) { + s.lock.Lock() + defer s.lock.Unlock() + + existing, ok := s.resources[key] + if ok { + s.log.DebugMsg("resource reused", "key", key) + return existing, nil + } + + res, err := open() + if err != nil { + var empty T + return empty, err + } + + s.log.DebugMsg("new resource", "key", key) + s.resources[key] = res + + return res, nil +} + +func (s *Singleton[T]) CloseUnused(isUsed func(key string) bool) error { + s.lock.Lock() + defer s.lock.Unlock() + + for key, res := range s.resources { + if isUsed(key) { + continue + } + if err := res.Close(); err != nil { + s.log.Error("resource close failed", err, "key", key) + } + s.log.DebugMsg("resource released", "key", key) + delete(s.resources, key) + } + + return nil +} + +func (s *Singleton[T]) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + for key, res := range s.resources { + if err := res.Close(); err != nil { + s.log.Error("resource close failed", err, "key", key) + } + s.log.DebugMsg("resource released", "key", key) + delete(s.resources, key) + } + + return nil +} diff --git a/framework/resource/tracker.go b/framework/resource/tracker.go new file mode 100644 index 000000000..ae2d97967 --- /dev/null +++ b/framework/resource/tracker.go @@ -0,0 +1,51 @@ +package resource + +import ( + "sync" +) + +// Tracker is a container wrapper that tracks whether resources were used since +// last MarkAllUnused call. +type Tracker[T Resource] struct { + C Container[T] + + usedLock sync.Mutex + used map[string]bool +} + +func NewTracker[T Resource](c Container[T]) *Tracker[T] { + return &Tracker[T]{C: c, used: make(map[string]bool)} +} + +func (t *Tracker[T]) Close() error { + return t.C.Close() +} + +func (t *Tracker[T]) MarkAllUnused() { + t.usedLock.Lock() + defer t.usedLock.Unlock() + + t.used = make(map[string]bool) +} + +func (t *Tracker[T]) GetOpen(key string, open func() (T, error)) (T, error) { + t.usedLock.Lock() + t.used[key] = true + t.usedLock.Unlock() + + return t.C.GetOpen(key, open) +} + +func (t *Tracker[T]) CloseUnused(isUsed func(key string) bool) error { + t.usedLock.Lock() + defer t.usedLock.Unlock() + + return t.C.CloseUnused(func(key string) bool { + used := t.used[key] + used = used && isUsed(key) + if !used { + delete(t.used, key) + } + return used + }) +} diff --git a/go.mod b/go.mod index af91f6261..d3d6e9971 100644 --- a/go.mod +++ b/go.mod @@ -1,78 +1,177 @@ module github.com/foxcpp/maddy -go 1.15 +go 1.23.1 + +toolchain go1.23.5 require ( - blitiri.com.ar/go/spf v1.3.0 - cloud.google.com/go/compute v1.5.0 // indirect - github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect - github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 - github.com/aws/aws-sdk-go v1.43.12 // indirect - github.com/caddyserver/certmagic v0.15.3 - github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect - github.com/digitalocean/godo v1.75.0 // indirect - github.com/emersion/go-imap v1.2.1-0.20220119134953-dcd9ee65c8c7 - github.com/emersion/go-imap-appendlimit v0.0.0-20210907172056-e3baed77bbe4 + blitiri.com.ar/go/spf v1.5.1 + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 + github.com/c0va23/go-proxyprotocol v0.9.1 + github.com/caddyserver/certmagic v0.21.7 + github.com/emersion/go-imap v1.2.2-0.20220928192137-6fac715be9cf github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9 - github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872 github.com/emersion/go-imap-sortthread v1.2.0 - github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e - github.com/emersion/go-imap-unselect v0.0.0-20210907172115-4c2c4843bf69 - github.com/emersion/go-message v0.15.0 - github.com/emersion/go-milter v0.3.2 - github.com/emersion/go-msgauth v0.6.5 - github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac - github.com/emersion/go-smtp v0.15.1-0.20220119142625-1c322d2783aa - github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf - github.com/foxcpp/go-imap-backend-tests v0.0.0-20200617132817-958ea5829771 + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-milter v0.4.1 + github.com/emersion/go-msgauth v0.6.8 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/emersion/go-smtp v0.21.3 + github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba + github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 - github.com/foxcpp/go-imap-namespace v0.0.0-20200722130255-93092adf35f1 - github.com/foxcpp/go-imap-sql v0.5.1-0.20210828123943-f74ead8f06cd - github.com/foxcpp/go-mockdns v1.0.0 - github.com/foxcpp/go-mtasts v0.0.0-20191219193356-62bc3f1f74b8 - github.com/go-asn1-ber/asn1-ber v1.5.3 // indirect - github.com/go-ldap/ldap/v3 v3.4.2 - github.com/go-sql-driver/mysql v1.6.0 - github.com/google/uuid v1.3.0 + github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 + github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed + github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d + github.com/foxcpp/go-mockdns v1.1.0 + github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932 + github.com/go-ldap/ldap/v3 v3.4.10 + github.com/go-sql-driver/mysql v1.8.1 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-hclog v1.6.3 github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c - github.com/klauspost/compress v1.15.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.11 // indirect - github.com/lib/pq v1.10.4 - github.com/libdns/alidns v1.0.2 - github.com/libdns/cloudflare v0.1.0 - github.com/libdns/digitalocean v0.0.0-20210310230526-186c4ebd2215 - github.com/libdns/gandi v1.0.2 - github.com/libdns/googleclouddns v1.0.1 + github.com/lib/pq v1.10.9 + github.com/libdns/acmedns v0.2.0 + github.com/libdns/alidns v1.0.3 + github.com/libdns/cloudflare v0.1.1 + github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea + github.com/libdns/gandi v1.0.3 + github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20 + github.com/libdns/googleclouddns v1.1.0 github.com/libdns/hetzner v0.0.1 - github.com/libdns/leaseweb v0.2.1 - github.com/libdns/libdns v0.2.1 + github.com/libdns/leaseweb v0.4.0 + github.com/libdns/libdns v0.2.2 github.com/libdns/metaname v0.3.0 github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e github.com/libdns/namedotcom v0.3.3 - github.com/libdns/route53 v1.1.2 - github.com/libdns/vultr v0.0.0-20211122184636-cd4cb5c12e51 - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-sqlite3 v2.0.3+incompatible - github.com/mholt/acmez v1.0.2 // indirect - github.com/miekg/dns v1.1.46 + github.com/libdns/rfc2136 v0.1.1 + github.com/libdns/route53 v1.5.1 + github.com/libdns/vultr v1.0.0 + github.com/mattn/go-sqlite3 v1.14.24 + github.com/miekg/dns v1.1.63 + github.com/minio/minio-go/v7 v7.0.84 + github.com/netauth/netauth v0.6.2 + github.com/prometheus/client_golang v1.20.5 + github.com/stretchr/testify v1.10.0 + github.com/urfave/cli/v2 v2.27.5 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.32.0 + golang.org/x/net v0.34.0 + golang.org/x/sync v0.10.0 + golang.org/x/text v0.21.0 + modernc.org/sqlite v1.34.5 +) + +require ( + cloud.google.com/go/auth v0.14.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/G-Core/gcore-dns-sdk-go v0.2.9 // indirect + github.com/aws/aws-sdk-go v1.44.40 // indirect + github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.54 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/digitalocean/godo v1.134.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jimlambrt/gldap v0.1.14 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/magiconair/properties v1.8.9 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mholt/acmez/v3 v3.0.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.0.23 - github.com/minio/sha256-simd v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pierrec/lz4 v2.6.1+incompatible // indirect - github.com/prometheus/client_golang v1.12.1 - github.com/rs/xid v1.3.0 // indirect - github.com/urfave/cli v1.22.5 - github.com/vultr/govultr/v2 v2.14.1 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.8.0 // indirect - go.uber.org/zap v1.21.0 - golang.org/x/crypto v0.0.0-20220214200702-86341886e292 - golang.org/x/net v0.0.0-20220225172249-27dd8689420f - golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect - golang.org/x/text v0.3.7 - golang.org/x/tools v0.1.9 // indirect - google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8 // indirect - gopkg.in/ini.v1 v1.66.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/vultr/govultr/v3 v3.14.1 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/oauth2 v0.25.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.29.0 // indirect + google.golang.org/api v0.218.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect + google.golang.org/grpc v1.70.0 // indirect + google.golang.org/protobuf v1.36.4 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools v2.2.0+incompatible // indirect + modernc.org/libc v1.61.9 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect ) + +replace github.com/emersion/go-imap => github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 + +replace github.com/emersion/go-smtp => github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23 // v1.21.3+maddy.1 + +replace github.com/libdns/gandi => github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e // v1.0.3+maddy.1 diff --git a/go.sum b/go.sum index 61ab65e85..e5bf2edba 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -blitiri.com.ar/go/spf v1.3.0 h1:KCBeatOXlg1OsRHHWMSPs0KAe0dfXuoQ1xGASgwd0Hs= -blitiri.com.ar/go/spf v1.3.0/go.mod h1:/wDIKCvGkTlOLcCjV9yvSZcRy5cM15fpUpAhff8Zjbk= +blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE= +blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -20,7 +20,6 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA= cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= @@ -29,62 +28,220 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= -cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= +cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= +cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= +cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= +cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= +cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= +cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= +cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= +cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= +cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= +cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= +cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= +cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= +cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= +cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= +cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= +cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM= +cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= +cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= +cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= +cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= +cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= +cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= +cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= +cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= -cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM= cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= +cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= +cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= +cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= +cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= +cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= +cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= +cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= +cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= +cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= +cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= +cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= +cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= +cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= +cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= +cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= +cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= +cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= +cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= +cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= +cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= +cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= +cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= +cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= +cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= +cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= +cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= +cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= +cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= +cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= +cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= +cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= +cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= +cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= +cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= +cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= +cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= +cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= +cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= +cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= +cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= +cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= +cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= +cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= +cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= +cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= +cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= +cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= +cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= +cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= +cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= +cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= +cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= +cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= +cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= +cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= +cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= +cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= +cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= +cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= +cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= +cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= +cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= +cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= +cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= +cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= +cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= +cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= +cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= +cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= +cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= +cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= +cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= +cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= +cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= +cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= +cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= +cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= +cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= +cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= +cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= +cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= +cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= +cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= +cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= +cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= +cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= +cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= +cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= +cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= +cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e h1:ZU22z/2YRFLyf/P4ZwUYSdNCWsMEI0VeyrFoI2rAhJQ= -github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw= -github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo= +github.com/G-Core/gcore-dns-sdk-go v0.2.9 h1:LMMZIRX8y3aJJuAviNSpFmLbovZUw+6Om+8VElp1F90= +github.com/G-Core/gcore-dns-sdk-go v0.2.9/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.41.14/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go v1.43.12 h1:wOdx6+reSDpUBFEuJDA6edCrojzy8rOtMzhS2rD9+7M= -github.com/aws/aws-sdk-go v1.43.12/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/aws/aws-sdk-go v1.44.40 h1:MR0qefjBJrZuXE0VoeKMQFtjS2tUeVpbQNfb7NzQNgI= +github.com/aws/aws-sdk-go v1.44.40/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go-v2 v1.33.0 h1:Evgm4DI9imD81V0WwD+TN4DCwjUMdc94TrduMLbgZJs= +github.com/aws/aws-sdk-go-v2 v1.33.0/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.29.1 h1:JZhGawAyZ/EuJeBtbQYnaoftczcb2drR2Iq36Wgz4sQ= +github.com/aws/aws-sdk-go-v2/config v1.29.1/go.mod h1:7bR2YD5euaxBhzt2y/oDkt3uNRb6tjFp98GlTFueRwk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.54 h1:4UmqeOqJPvdvASZWrKlhzpRahAulBfyTJQUaYy4+hEI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.54/go.mod h1:RTdfo0P0hbbTxIhmQrOsC/PquBZGabEPnCaxxKRPSnI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 h1:5grmdTdMsovn9kPZPI23Hhvp0ZyNm5cRO+IZFIYiAfw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24/go.mod h1:zqi7TVKTswH3Ozq28PkmBmgzG1tona7mo9G2IJg4Cis= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 h1:igORFSiH3bfq4lxKFkTSYDhJEUCYo6C8VKiWJjYwQuQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28/go.mod h1:3So8EA/aAYm36L7XIvCVwLa0s5N0P7o2b1oqnx/2R4g= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 h1:1mOW9zAUMhTSrMDssEHS/ajx8JcAj/IcftzcmNlmVLI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28/go.mod h1:kGlXVIWDfvt2Ox5zEaNglmq0hXPHgQFNMix33Tw22jA= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 h1:TQmKDyETFGiXVhZfQ/I0cCFziqqX58pi4tKJGYGFSz0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9/go.mod h1:HVLPK2iHQBUx7HfZeOQSEu3v2ubZaAY2YPbAm5/WUyY= +github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2 h1:Rxg1R0CHxVb9ggQLufOkr4an3yFEkTDN+N5+LFU4aEg= +github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2/go.mod h1:TN4PcCL0lvqmYcv+AV8iZFC4Sd0FM06QDaoBXrFEftU= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 h1:kuIyu4fTT38Kj7YCC7ouNbVZSSpqkZ+LzIfhCr6Dg+I= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.11/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 h1:l+dgv/64iVlQ3WsBbnn+JSbkj01jIi+SM0wYsj3y/hY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 h1:BRVDbewN6VZcwr+FBOszDKvYeXY1kJ+GGMCcpghlw0U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.9/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/caddyserver/certmagic v0.15.3 h1:ScY3KVV1eMIUfW74i20kDnD4eWL8T0rG6S6Wnc6nc9U= -github.com/caddyserver/certmagic v0.15.3/go.mod h1:qhkAOthf72ufAcp3Y5jF2RaGE96oip3UbEQRIzwe3/8= +github.com/c0va23/go-proxyprotocol v0.9.1 h1:5BCkp0fDJOhzzH1lhjUgHhmZz9VvRMMif1U2D31hb34= +github.com/c0va23/go-proxyprotocol v0.9.1/go.mod h1:TNjUV+llvk8TvWJxlPYAeAYZgSzT/iicNr3nWBWX320= +github.com/caddyserver/certmagic v0.21.7 h1:66KJioPFJwttL43KYSWk7ErSmE6LfaJgCQuhm8Sg6fg= +github.com/caddyserver/certmagic v0.21.7/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -96,64 +253,41 @@ github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XP github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= -github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitalocean/godo v1.41.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= -github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y= -github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/emersion/go-imap v1.0.0-beta.4.0.20190504114255-4d5af3d05147/go.mod h1:mOPegfAgLVXbhRm1bh2JTX08z2Y3HYmKYpbrKDeAzsQ= -github.com/emersion/go-imap v1.0.0/go.mod h1:MEiDDwwQFcZ+L45Pa68jNGv0qU9kbW+SJzwDpvSfX1s= -github.com/emersion/go-imap v1.0.3/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= -github.com/emersion/go-imap v1.0.4/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= -github.com/emersion/go-imap v1.0.5/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= -github.com/emersion/go-imap v1.2.1-0.20220119134953-dcd9ee65c8c7 h1:2hV3AkHAODve7a+HTzLGZ0k1Rprh+9KbYWl+r06bdMA= -github.com/emersion/go-imap v1.2.1-0.20220119134953-dcd9ee65c8c7/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/digitalocean/godo v1.134.0 h1:dT7aQR9jxNOQEZwzP+tAYcxlj5szFZScC33+PAYGQVM= +github.com/digitalocean/godo v1.134.0/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ= -github.com/emersion/go-imap-appendlimit v0.0.0-20210907172056-e3baed77bbe4 h1:U6LL6F1dYqXpVTwEbXhcfU8hgpNvmjB9xeOAiHN695o= -github.com/emersion/go-imap-appendlimit v0.0.0-20210907172056-e3baed77bbe4/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ= github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9 h1:7dmV11mle4UAQ7lX+Hdzx6akKFg3hVm/UUmQ7t6VgTQ= github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9/go.mod h1:2Ro1PbmiqYiRe5Ct2sGR5hHaKSVHeRpVZwXx8vyYt98= github.com/emersion/go-imap-move v0.0.0-20180601155324-5eb20cb834bf/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w= -github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872 h1:HGBfonz0q/zq7y3ew+4oy4emHSvk6bkmV0mdDG3E77M= -github.com/emersion/go-imap-move v0.0.0-20210907172020-fe4558f9c872/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w= -github.com/emersion/go-imap-sortthread v1.1.1-0.20200727121200-18e5fb409fed/go.mod h1:opHOzblOHZKQM1JEy+GPk1217giNLa7kleyWTN06qnc= github.com/emersion/go-imap-sortthread v1.2.0 h1:EMVEJXPWAhXMWECjR82Rn/tza6MddcvTwGAdTu1vJKU= github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84INUCsyAK1MLpogv14pE= -github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4= -github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e h1:AwVkRMFFUMNu+tx0jchwyoXhS2VClQSzTtByVuzxbsE= -github.com/emersion/go-imap-specialuse v0.0.0-20201101201809-1ab93d3d150e/go.mod h1:/nybxhI8kXom8Tw6BrHMl42usALvka6meORflnnYwe4= -github.com/emersion/go-imap-unselect v0.0.0-20210907172115-4c2c4843bf69 h1:ltTnRlPdSMMb0a/pg7S31T3g+syYeSS5UVJtiR7ez1Y= -github.com/emersion/go-imap-unselect v0.0.0-20210907172115-4c2c4843bf69/go.mod h1:+gnnZx3Mg3MnCzZrv0eZdp5puxXQUgGT/6N6L7ShKfM= -github.com/emersion/go-message v0.9.1/go.mod h1:m3cK90skCWxm5sIMs1sXxly4Tn9Plvcf6eayHZJ1NzM= -github.com/emersion/go-message v0.10.3/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c= -github.com/emersion/go-message v0.10.4-0.20190609165112-592ace5bc1ca/go.mod h1:3h+HsGTCFHmk4ngJ2IV/YPhdlaOcR6hcgqM3yca9v7c= -github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= -github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= -github.com/emersion/go-message v0.14.1/go.mod h1:N1JWdZQ2WRUalmdHAX308CWBq747VJ8oUorFI3VCBwU= -github.com/emersion/go-message v0.15.0 h1:urgKGqt2JAc9NFJcgncQcohHdiYb803YTH9OQwHBHIY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= -github.com/emersion/go-milter v0.3.2 h1:j8hrLXf8PAHFhRHDdBoBKluQveMZYoaK7aRIqvaoRTA= -github.com/emersion/go-milter v0.3.2/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= -github.com/emersion/go-msgauth v0.6.5 h1:UaXBtrjYBM3SWw9BBODeSp0uYtScx3CuIF7/RQfkeWo= -github.com/emersion/go-msgauth v0.6.5/go.mod h1:/jbQISFJgtT12T8akRs20l+wI4HcyN/kWy7VRdHEAmA= -github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= -github.com/emersion/go-sasl v0.0.0-20190520160400-47d427600317/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= -github.com/emersion/go-sasl v0.0.0-20190817083125-240c8404624e/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M= +github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI= +github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A= +github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc= github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs= -github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-smtp v0.15.1-0.20220119142625-1c322d2783aa h1:PZiDDRpQS7p6nFZFt9Pbco8a5FYa5kMhu6V7fTsYE4k= -github.com/emersion/go-smtp v0.15.1-0.20220119142625-1c322d2783aa/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= -github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= -github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -163,49 +297,71 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf h1:rmBPY5fryjp9zLQYsUmQqqgsYq7qeVfrjtr96Tf9vD8= github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= -github.com/foxcpp/go-imap-backend-tests v0.0.0-20200617132817-958ea5829771 h1:xemWCEhBz86Y8v5YgRBnqf6PdZg+ilVgn2jxWVoLOGo= -github.com/foxcpp/go-imap-backend-tests v0.0.0-20200617132817-958ea5829771/go.mod h1:yUISYv/uXLQ6tQZcds/p/hdcZ5JzrEUifyED2VffWpc= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba h1:yxQhqX9RQCvECZKBtqwCZoKy/6CLaozDZeWH9Lvndy0= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= +github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 h1:qUoaaHyrRpQw85ru6VQcC6JowdhrWl7lSbI1zRX1FTM= +github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 h1:qheFPDpteiUy7Ym18R68OYenpk85UyKYGkhYTmddSBg= +github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16/go.mod h1:OPP1AgKxMPo3aHX5pcEZLQhhh5sllFcB8aUN9f6a6X8= github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 h1:pfoFtkTTQ473qStSN79jhCFBWqMQt/3DQ3NGuXvT+50= github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005/go.mod h1:34FwxnjC2N+EFs2wMtsHevrZLWRKRuVU8wEcHWKq/nE= -github.com/foxcpp/go-imap-namespace v0.0.0-20200722130255-93092adf35f1 h1:B4zNQ2r4qC7FLn8J8+LWt09fFW0tXddypBPS0+HI50s= -github.com/foxcpp/go-imap-namespace v0.0.0-20200722130255-93092adf35f1/go.mod h1:WJYkFIdxyljR/byiqcYMKUF4iFDej4CaIKe2JJrQxu8= -github.com/foxcpp/go-imap-sql v0.5.1-0.20210828123943-f74ead8f06cd h1:4vpPV74xAqiJD6AVGIK6jucz/Frq70sYRcOAU5FLz6I= -github.com/foxcpp/go-imap-sql v0.5.1-0.20210828123943-f74ead8f06cd/go.mod h1:1dHCAq3XRkYRwTDOtL/vCgvvQ13gLqNt2+nLjL1UHyk= +github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 h1:fw9OWfPxP1CK4D+XAEEg0JzhvFGo04L+F5Xw55t9s3E= +github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613/go.mod h1:P/O/qz4gaVkefzJ40BUtN/ZzBnaEg0YYe1no/SMp7Aw= +github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed h1:1Jo7geyvunrPSjL6F6D9EcXoNApS5v3LQaro7aUNPnE= +github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed/go.mod h1:Shows1vmkBWO40ChOClaUe6DUnZrsP1UPAuoWzIUdgQ= +github.com/foxcpp/go-imap-sql v0.5.1-0.20250124140007-8da5567429d5 h1:jMxhw9qmwqg70qfMDWq0ImRHAduQjkTZOC9vBs5t2ug= +github.com/foxcpp/go-imap-sql v0.5.1-0.20250124140007-8da5567429d5/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw= +github.com/foxcpp/go-imap-sql v0.5.1-0.20260412133145-20097edd35ec h1:Jm71K60qrrnyISeLXMYKzSZe0RVco+aO/RJugJvafIM= +github.com/foxcpp/go-imap-sql v0.5.1-0.20260412133145-20097edd35ec/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw= +github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d h1:oiq5MLSSqd3sl4VNHKTlrwszWTHIx8+x8y/olInMJRo= +github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw= github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f/go.mod h1:tPg4cp4nseejPd+UKxtCVQ2hUxNTZ7qQZJa7CLriIeo= -github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= -github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= -github.com/foxcpp/go-mtasts v0.0.0-20191219193356-62bc3f1f74b8 h1:k8w0iy6GP9oeSZWUH3p2DqZHaXDKZGNs3NZGZMGfQHc= -github.com/foxcpp/go-mtasts v0.0.0-20191219193356-62bc3f1f74b8/go.mod h1:HO1YOCbBM8KjpgThMMFejHx6K/UsnEv2Oh9YGtBIlOU= -github.com/frankban/quicktest v1.5.0 h1:Tb4jWdSpdjKzTUicPnY61PZxKbDoGa7ABbrReT3gQVY= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932 h1:p04U/s8IZEc+PVWIDWGUgdqGq3xsixI7XRZ6Bp/xZbQ= +github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932/go.mod h1:RtHIZCsScdjIzXpTTjmEljtUrIjQbPBTvw7F1tKQbKk= +github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23 h1:JSnsCrRrHNBlgfKVFBxFzp3fN/wS21t8fAHcZ9B1uWI= +github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e h1:hKk+CGUtwnKDGKINPEojeo91kx0tnV6V4tlzHehJPfg= +github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e/go.mod h1:G6dw58Xnji2xX+lb+uZxGbtmfxKllm1CGHE2bOPG3WA= github.com/frankban/quicktest v1.5.0/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-asn1-ber/asn1-ber v1.5.3 h1:u7utq56RUFiynqUzgVMFDymapcOtQ/MZkh3H4QYkxag= -github.com/go-asn1-ber/asn1-ber v1.5.3/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= +github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-ldap/ldap/v3 v3.4.2 h1:zFZKcXKLqZpFMrMQGHeHWKXbDTdNCmhGY9AK41zPh+8= -github.com/go-ldap/ldap/v3 v3.4.2/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= +github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -215,6 +371,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -232,9 +389,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -250,18 +407,21 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -273,33 +433,67 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= +github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= -github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jimlambrt/gldap v0.1.14 h1:InG9kldhIu6OoQK0hvfkW1Lqpc5eLJhxiiDTNmRnrDM= +github.com/jimlambrt/gldap v0.1.14/go.mod h1:yobW9JIAmqe23dVNOaMWewPaff6jGaHgYjspPIIgYmg= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= @@ -309,181 +503,182 @@ github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c h1:lx/uPI+m github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c/go.mod h1:LIAXxPvcUXwOcTIj9LSNSUpE9/eMHalTWxsP/kmWxQI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.5/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= -github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s= -github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.11 h1:i2lw1Pm7Yi/4O6XCSyJWqEHI2MDw2FzUK6o/D21xn2A= -github.com/klauspost/cpuid/v2 v2.0.11/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= -github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/libdns/alidns v1.0.2 h1:WiT1cO2LWY95YNocTVBGipHjvRaFQOxMQ9X5bTiryRo= -github.com/libdns/alidns v1.0.2/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE= -github.com/libdns/cloudflare v0.1.0 h1:93WkJaGaiXCe353LHEP36kAWCUw0YjFqwhkBkU2/iic= -github.com/libdns/cloudflare v0.1.0/go.mod h1:a44IP6J1YH6nvcNl1PverfJviADgXUnsozR3a7vBKN8= -github.com/libdns/digitalocean v0.0.0-20210310230526-186c4ebd2215 h1:JYi/h0UEECrxY2JCi5FIfZEDFuUJvwihUWdm3bnDu2A= -github.com/libdns/digitalocean v0.0.0-20210310230526-186c4ebd2215/go.mod h1:GEZlJR69sPAUWjb77eLyeDczZNL+ezbo5UGIY2/xZXA= -github.com/libdns/gandi v1.0.2 h1:1Ts8UpI1x5PVKpOjKC7Dn4+EObndz9gm6vdZnloHSKQ= -github.com/libdns/gandi v1.0.2/go.mod h1:hxpbQKcQFgQrTS5lV4tAgn6QoL6HcCnoBJaW5nOW4Sk= -github.com/libdns/googleclouddns v1.0.1 h1:g3BO+c4W4NYl8vkJk5sKLYwVTmOtGpnI2GryqSzJgkk= -github.com/libdns/googleclouddns v1.0.1/go.mod h1:y6uAE0hE+uUwsP6BOm0Gym+I71gO65v9VZci25wRkkw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/libdns/acmedns v0.2.0 h1:zTXdHZwe3r2issdVRyqt5/4X2yHpiBVmFnTrwBA29ik= +github.com/libdns/acmedns v0.2.0/go.mod h1:XlKHilQQK/IGHYY//vCb903PdG4Wc/XnDQzcMp2hV3g= +github.com/libdns/alidns v1.0.3 h1:LFHuGnbseq5+HCeGa1aW8awyX/4M2psB9962fdD2+yQ= +github.com/libdns/alidns v1.0.3/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE= +github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054= +github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU= +github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea h1:IGlMNZCUp8Ho7NYYorpP5ZJgg2mFXARs6eHs/pSqFkA= +github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea/go.mod h1:B2TChhOTxvBflpRTHlguXWtwa1Ha5WI6JkB6aCViM+0= +github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20 h1:bQwFw+C9sX/zYZlV53ey0KnNkxrfWYIFpvptuAVhJ1Y= +github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20/go.mod h1:JGoT1mbmqQwtYQqN5F/vGc9j4TTTMKw/hDm5vXADHUI= +github.com/libdns/googleclouddns v1.1.0 h1:murPR1LfTZZObLV2OLxUVmymWH25glkMFKpDjkk2m0E= +github.com/libdns/googleclouddns v1.1.0/go.mod h1:3tzd056dfqKlf71V8Oy19En4WjJ3ybyuWx6P9bQSCIw= github.com/libdns/hetzner v0.0.1 h1:WsmcsOKnfpKmzwhfyqhGQEIlEeEaEUvb7ezoJgBKaqU= github.com/libdns/hetzner v0.0.1/go.mod h1:Jj12aJipO9Ir7OGaXueJ5J1RnerFMD0auGa6k9kujG4= -github.com/libdns/leaseweb v0.2.1 h1:bQ759T44Tpmzd7mmMEgaLimSztPIRaMk1k6X4UXuJOA= -github.com/libdns/leaseweb v0.2.1/go.mod h1:OeZtd+s2M1RfC3wIJF9SHZDFpD7H5RRiC6OPK3AWYjA= -github.com/libdns/libdns v0.0.0-20200501023120-186724ffc821/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/leaseweb v0.4.0 h1:WG9R5AwewpYM4goymFwnG2SB0qwL8gMsSzwRHZHee/U= +github.com/libdns/leaseweb v0.4.0/go.mod h1:dvTvEn11JN6+ebhAQ60l+jiaBiEqyJFs3EIo0YBcQkU= github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= -github.com/libdns/libdns v0.2.1 h1:Wu59T7wSHRgtA0cfxC+n1c/e+O3upJGWytknkmFEDis= github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s= +github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/metaname v0.3.0 h1:HJudLYthdv52TupOPczojip/nEQHW7xqk5+whGReva4= github.com/libdns/metaname v0.3.0/go.mod h1:a3hqEgj59tjWaWlF4WxQGhvMVtjz1E4Ngs1GfVS+VhQ= github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e h1:WCcKyxiiK/sJnST1ulVBKNg4J8luCYDdgUrp2ySMO2s= github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e/go.mod h1:dED6sMLZxIcilF1GjrcpwgVoCglXGMn86irqQzRhqRY= github.com/libdns/namedotcom v0.3.3 h1:R10C7+IqQGVeC4opHHMiFNBxdNBg1bi65ZwqLESl+jE= github.com/libdns/namedotcom v0.3.3/go.mod h1:GbYzsAF2yRUpI0WgIK5fs5UX+kDVUPaYCFLpTnKQm0s= -github.com/libdns/route53 v1.1.2 h1:etUVkopzG9xGEt34xfmYbpz6rTgAnv+n0vcV/1Xdc7c= -github.com/libdns/route53 v1.1.2/go.mod h1:sSTy167w3QYL2Xn8ksdAT4WHTZQcX6XTbKhLhUCT4cc= -github.com/libdns/vultr v0.0.0-20211122184636-cd4cb5c12e51 h1:ds9Nu9RwQWIHXM/e7264RiUfdyAgNdoZkiWXt0xSIzY= -github.com/libdns/vultr v0.0.0-20211122184636-cd4cb5c12e51/go.mod h1:HXpNE79BzPq3UumCELwGB7E9HD9Ie10D2o3e56CGkdE= -github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/libdns/rfc2136 v0.1.1 h1:GKh2r08xt4aYeGlXR9eFrJMfFKD5i9QHBOpT1FIww/U= +github.com/libdns/rfc2136 v0.1.1/go.mod h1:tgXWavE+5OiAfdKxBnuG8OBEwQFAu7uuiS3+laspAGs= +github.com/libdns/route53 v1.5.1 h1:dkdcc2CKY/EHBBzAKqE0Cko7MKR8uVJ3GvpzwKu/UKM= +github.com/libdns/route53 v1.5.1/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q= +github.com/libdns/vultr v1.0.0 h1:W8B4+k2bm9ro3bZLSZV9hMOQI+uO6Svu+GmD+Olz7ZI= +github.com/libdns/vultr v1.0.0/go.mod h1:8K1HJExcbeHS4YPkFHRZpqpXZzZ+DZAA0m0VikJgEqk= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/martinlindhe/base36 v0.0.0-20190418230009-7c6542dfbb41/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= -github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= -github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= -github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mholt/acmez v1.0.1/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM= -github.com/mholt/acmez v1.0.2 h1:C8wsEBIUVi6e0DYoxqCcFuXtwc4AWXL/jgcDjF7mjVo= -github.com/mholt/acmez v1.0.2/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +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/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mholt/acmez/v3 v3.0.1 h1:4PcjKjaySlgXK857aTfDuRbmnM5gb3Ruz3tvoSJAUp8= +github.com/mholt/acmez/v3 v3.0.1/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= -github.com/miekg/dns v1.1.46 h1:uzwpxRtSVxtcIZmz/4Uz6/Rn7G11DvsaslXoy5LxQio= -github.com/miekg/dns v1.1.46/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/minio/md5-simd v1.1.0/go.mod h1:XpBqgZULrMYD3R+M28PcmP0CkI7PEMzB3U77ZrKZ0Gw= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.23 h1:NleyGQvAn9VQMU+YHVrgV4CX+EPtxPt/78lHOOTncy4= -github.com/minio/minio-go/v7 v7.0.23/go.mod h1:ei5JjmxwHaMrgsMrn4U/+Nmg+d8MKS1U2DAn1ou4+Do= -github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/minio/minio-go/v7 v7.0.84 h1:D1HVmAF8JF8Bpi6IU4V9vIEj+8pc+xU88EWMs2yed0E= +github.com/minio/minio-go/v7 v7.0.84/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/netauth/netauth v0.6.2 h1:Gtx/Xxa6YUaGny+iVvWyp+FAmtLQ1IlbB2uWTZEpWxQ= +github.com/netauth/netauth v0.6.2/go.mod h1:4PEbISVqRCQaXaDAt289w3nK9UhoF8/ZOLy31Hbv7ds= +github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd h1:4yVpQ/+li28lQ/daYCWeDB08obRmjaoAw2qfFFaCQ40= +github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd/go.mod h1:wpK5wqysOJU1w2OxgG65du8M7UqBkxzsNaJdjwiRqAs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= -github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 h1:J6qvD6rbmOil46orKqJaRPG+zTpoGlBTUdyv8ki63L0= github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63/go.mod h1:n+VKSARF5y/tS9XFSP7vWDfS+GUC5vs/YT7M5XDTUEM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= -github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/vultr/govultr/v2 v2.11.0/go.mod h1:JjUljQdSZx+MELCAJvZ/JH32bJotmflnsyS0NOjb8Jg= -github.com/vultr/govultr/v2 v2.14.1 h1:Z4nd9mXNQ5wd63aw0MZOalFeTkJ8L6Sed3PTqagp4TA= -github.com/vultr/govultr/v2 v2.14.1/go.mod h1:JjUljQdSZx+MELCAJvZ/JH32bJotmflnsyS0NOjb8Jg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/vultr/govultr/v3 v3.14.1 h1:9BpyZgsWasuNoR39YVMcq44MSaF576Z4D+U3ro58eJQ= +github.com/vultr/govultr/v3 v3.14.1/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= @@ -491,38 +686,47 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= -go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= -go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -533,6 +737,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= +golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -546,7 +752,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= @@ -559,11 +764,16 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -572,7 +782,6 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -595,21 +804,36 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -621,14 +845,22 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -639,15 +871,22 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -656,11 +895,10 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -672,8 +910,6 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -681,19 +917,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -701,32 +932,71 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5-0.20201125200606-c27b9fd57aec/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -734,7 +1004,6 @@ golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f/go.mod h1:25r3+/G6/xytQM8i golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -745,8 +1014,6 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -783,14 +1050,21 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= -golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -812,7 +1086,6 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I= google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= @@ -824,15 +1097,30 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= -google.golang.org/api v0.70.0 h1:67zQnAE0T2rB0A3CwLSas0K+SbVzSxP+zTLkQLexeiw= google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= +google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= +google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= +google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= +google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA= +google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -873,11 +1161,9 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210524171403-669157292da3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= @@ -901,8 +1187,47 @@ google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8 h1:U9V52f6rAgINH7kT+musA1qF8kWyVOxzF8eYuOVuFwQ= google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= +google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= +google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= +google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= +google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221018160656-63c7b68cfc55/go.mod h1:45EK0dUbEZ2NHjCeAd2LXmyjAgGUGrpGROgjhC3ADck= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -929,8 +1254,17 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg= google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -944,30 +1278,27 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4= -gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -977,6 +1308,30 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019年2月3日/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020年1月3日/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020年1月4日/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.13 h1:PFiaemQwE/jdwi8XEHyEV+qYWoIuikLP3T4rvDeJb00= +modernc.org/ccgo/v4 v4.23.13/go.mod h1:vdN4h2WR5aEoNondUx26K7G8X+nuBscYnAEWSRmN2/0= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.1 h1:+Qf6xdG8l7B27TQ8D8lw/iFMUj1RXRBOuMUWziJOsk8= +modernc.org/gc/v2 v2.6.1/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.61.9 h1:PLSBXVkifXGELtJ5BOnBUyAHr7lsatNwFU/RRo4kfJM= +modernc.org/libc v1.61.9/go.mod h1:61xrnzk/aR8gr5bR7Uj/lLFLuXu2/zMpIjcry63Eumk= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/README.md b/internal/README.md index 882eed441..da5d57c9f 100644 --- a/internal/README.md +++ b/internal/README.md @@ -4,7 +4,7 @@ maddy source tree Main maddy code base lives here. No packages are intended to be used in third-party software hence API is not stable. -Subdirectories are organised as follows: +Subdirectories are organized as follows: ``` / auxiliary libraries diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 61fcd3640..65ffee4e2 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -75,7 +75,6 @@ func TestCheckDomainAuth(t *testing.T) { } for _, case_ := range cases { - case_ := case_ t.Run(fmt.Sprintf("%+v", case_), func(t *testing.T) { loginName, allowed := CheckDomainAuth(case_.rawUsername, case_.perDomain, case_.allowedDomains) if case_.loginName != "" && !allowed { diff --git a/internal/auth/dovecot_sasl/dovecot_sasl.go b/internal/auth/dovecot_sasl/dovecot_sasl.go index c7dd6cc90..9b30f9248 100644 --- a/internal/auth/dovecot_sasl/dovecot_sasl.go +++ b/internal/auth/dovecot_sasl/dovecot_sasl.go @@ -25,16 +25,18 @@ import ( "github.com/emersion/go-sasl" dovecotsasl "github.com/foxcpp/go-dovecot-sasl" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/auth" ) type Auth struct { instName string serverEndpoint string - log log.Logger + log *log.Logger network string addr string @@ -44,18 +46,10 @@ type Auth struct { const modName = "dovecot_sasl" -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, _, instName string) (module.Module, error) { a := &Auth{ instName: instName, - log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, - } - - switch len(inlineArgs) { - case 0: - case 1: - a.serverEndpoint = inlineArgs[0] - default: - return nil, fmt.Errorf("%s: one or none arguments needed", modName) + log: c.DefaultLogger.Sublogger(modName), } return a, nil @@ -85,10 +79,20 @@ func (a *Auth) getConn() (*dovecotsasl.Client, error) { } func (a *Auth) returnConn(cl *dovecotsasl.Client) { - cl.Close() + if err := cl.Close(); err != nil { + a.log.Error("connection close failed", err) + } } -func (a *Auth) Init(cfg *config.Map) error { +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + switch len(inlineArgs) { + case 0: + case 1: + a.serverEndpoint = inlineArgs[0] + default: + return fmt.Errorf("%s: one or none arguments needed", modName) + } + cfg.String("endpoint", false, false, a.serverEndpoint, &a.serverEndpoint) if _, err := cfg.Process(); err != nil { return err @@ -113,7 +117,11 @@ func (a *Auth) Init(cfg *config.Map) error { return fmt.Errorf("%s: unable to contact server: %v", modName, err) } - defer cl.Close() + defer func() { + if err := cl.Close(); err != nil { + a.log.Error("connection close failed", err) + } + }() a.mechanisms = make(map[string]dovecotsasl.Mechanism, len(cl.ConnInfo().Mechs)) for name, mech := range cl.ConnInfo().Mechs { if mech.Private { @@ -156,5 +164,5 @@ func (a *Auth) AuthPlain(username, password string) error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/auth/external/externalauth.go b/internal/auth/external/externalauth.go index 59d71fb35..1d3f4ae31 100644 --- a/internal/auth/external/externalauth.go +++ b/internal/auth/external/externalauth.go @@ -25,8 +25,10 @@ import ( "path/filepath" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/auth" ) @@ -38,18 +40,14 @@ type ExternalAuth struct { perDomain bool domains []string - Log log.Logger + log *log.Logger } -func NewExternalAuth(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { ea := &ExternalAuth{ modName: modName, instName: instName, - Log: log.Logger{Name: modName}, - } - - if len(inlineArgs) != 0 { - return nil, errors.New("external: inline arguments are not used") + log: c.DefaultLogger.Sublogger(modName), } return ea, nil @@ -63,8 +61,12 @@ func (ea *ExternalAuth) InstanceName() string { return ea.instName } -func (ea *ExternalAuth) Init(cfg *config.Map) error { - cfg.Bool("debug", false, false, &ea.Log.Debug) +func (ea *ExternalAuth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("external: inline arguments are not used") + } + + cfg.Bool("debug", false, false, &ea.log.Debug) cfg.Bool("perdomain", false, false, &ea.perDomain) cfg.StringList("domains", false, false, nil, &ea.domains) cfg.String("helper", false, false, "", &ea.helperPath) @@ -76,7 +78,7 @@ func (ea *ExternalAuth) Init(cfg *config.Map) error { } if ea.helperPath != "" { - ea.Log.Debugln("using helper:", ea.helperPath) + ea.log.Debugln("using helper:", ea.helperPath) } else { ea.helperPath = filepath.Join(config.LibexecDirectory, "maddy-auth-helper") } @@ -84,7 +86,7 @@ func (ea *ExternalAuth) Init(cfg *config.Map) error { return fmt.Errorf("%s doesn't exist", ea.helperPath) } - ea.Log.Debugln("using helper:", ea.helperPath) + ea.log.Debugln("using helper:", ea.helperPath) return nil } @@ -99,5 +101,5 @@ func (ea *ExternalAuth) AuthPlain(username, password string) error { } func init() { - module.Register("auth.external", NewExternalAuth) + modules.Register("auth.external", New) } diff --git a/internal/auth/ldap/ldap.go b/internal/auth/ldap/ldap.go index e2132fa81..af8c63045 100644 --- a/internal/auth/ldap/ldap.go +++ b/internal/auth/ldap/ldap.go @@ -12,8 +12,10 @@ import ( "github.com/foxcpp/maddy/framework/config" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/go-ldap/ldap/v3" ) @@ -25,7 +27,7 @@ type Auth struct { urls []string readBind func(*ldap.Conn) error startls bool - tlsCfg tls.Config + tlsCfg *tls.Config dialer *net.Dialer requestTimeout time.Duration @@ -37,23 +39,24 @@ type Auth struct { conn *ldap.Conn connLock sync.Mutex - log log.Logger + log *log.Logger } -func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { return &Auth{ instName: instName, - log: log.Logger{Name: modName}, - urls: inlineArgs, + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (a *Auth) Init(cfg *config.Map) error { +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + a.urls = inlineArgs + a.dialer = &net.Dialer{} cfg.Bool("debug", true, false, &a.log.Debug) cfg.Custom("tls_client", true, false, func() (interface{}, error) { - return tls.Config{}, nil + return &tls.Config{}, nil }, tls2.TLSClientBlock, &a.tlsCfg) cfg.Callback("urls", func(m *config.Map, node config.Node) error { a.urls = append(a.urls, node.Args...) @@ -87,15 +90,6 @@ func (a *Auth) Init(cfg *config.Map) error { } } - if module.NoRun { - return nil - } - - var err error - a.conn, err = a.newConn() - if err != nil { - return fmt.Errorf("auth.ldap: %w", err) - } return nil } @@ -107,7 +101,14 @@ func readBindDirective(c *config.Map, n config.Node) (interface{}, error) { case "off": return func(*ldap.Conn) error { return nil }, nil case "unauth": - return (*ldap.Conn).UnauthenticatedBind, nil + if len(n.Args) == 2 { + return func(c *ldap.Conn) error { + return c.UnauthenticatedBind(n.Args[1]) + }, nil + } + return func(c *ldap.Conn) error { + return c.UnauthenticatedBind("") + }, nil case "plain": if len(n.Args) != 3 { return nil, fmt.Errorf("auth.ldap: username and password expected for plaintext bind") @@ -140,12 +141,12 @@ func (a *Auth) newConn() (*ldap.Conn, error) { return nil, fmt.Errorf("auth.ldap: invalid server URL: %w", err) } hostname := parsedURL.Host + a.tlsCfg.ServerName = strings.Split(hostname, ":")[0] tlsCfg = a.tlsCfg.Clone() - a.tlsCfg.ServerName = hostname conn, err = ldap.DialURL(u, ldap.DialWithDialer(a.dialer), ldap.DialWithTLSConfig(tlsCfg)) if err != nil { - a.log.Msg("cannot contact directory server", err, "url", u) + a.log.Error("cannot contact directory server", err, "url", u) continue } break @@ -176,14 +177,18 @@ func (a *Auth) getConn() (*ldap.Conn, error) { if a.conn == nil { conn, err := a.newConn() if err != nil { + a.connLock.Unlock() return nil, err } a.conn = conn } if a.conn.IsClosing() { - a.conn.Close() + if err := a.conn.Close(); err != nil { + a.log.Error("Connection close failed", err) + } conn, err := a.newConn() if err != nil { + a.connLock.Unlock() return nil, err } a.conn = conn @@ -195,11 +200,15 @@ func (a *Auth) returnConn(conn *ldap.Conn) { defer a.connLock.Unlock() if err := a.readBind(conn); err != nil { a.log.Error("failed to rebind for reading", err) - conn.Close() + if err := a.conn.Close(); err != nil { + a.log.Error("Connection close failed", err) + } a.conn = nil } if a.conn != conn { - a.conn.Close() + if err := a.conn.Close(); err != nil { + a.log.Error("Connection close failed", err) + } } a.conn = conn } @@ -218,7 +227,7 @@ func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) req := ldap.NewSearchRequest( a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 0, false, - strings.ReplaceAll(a.filterTemplate, "{username}", username), + strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)), []string{"dn"}, nil) res, err := conn.Search(req) if err != nil { @@ -245,12 +254,12 @@ func (a *Auth) AuthPlain(username, password string) error { var userDN string if a.dnTemplate != "" { - userDN = strings.ReplaceAll(a.dnTemplate, "{username}", username) + userDN = strings.ReplaceAll(a.dnTemplate, "{username}", ldap.EscapeDN(username)) } else { req := ldap.NewSearchRequest( a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 2, 0, false, - strings.ReplaceAll(a.filterTemplate, "{username}", username), + strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)), []string{"dn"}, nil) res, err := conn.Search(req) if err != nil { @@ -272,9 +281,24 @@ func (a *Auth) AuthPlain(username, password string) error { return nil } +func (a *Auth) Start() error { + var err error + a.conn, err = a.newConn() + if err != nil { + return fmt.Errorf("auth.ldap: %w", err) + } + return nil +} + +func (a *Auth) Stop() error { + a.connLock.Lock() + defer a.connLock.Unlock() + return a.conn.Close() +} + func init() { var _ module.PlainAuth = &Auth{} var _ module.Table = &Auth{} - module.Register(modName, New) - module.Register("table.ldap", New) + modules.Register(modName, New) + modules.Register("table.ldap", New) } diff --git a/internal/auth/netauth/netauth.go b/internal/auth/netauth/netauth.go new file mode 100644 index 000000000..ce6715e66 --- /dev/null +++ b/internal/auth/netauth/netauth.go @@ -0,0 +1,120 @@ +package netauth + +import ( + "context" + "fmt" + + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/hashicorp/go-hclog" + "github.com/netauth/netauth/pkg/netauth" +) + +const modName = "auth.netauth" + +func init() { + var _ module.PlainAuth = &Auth{} + var _ module.Table = &Auth{} + modules.Register(modName, New) + modules.Register("table.netauth", New) +} + +// Auth binds all methods related to the NetAuth client library. +type Auth struct { + instName string + mustGroup string + + nacl *netauth.Client + + log *log.Logger +} + +// New creates a new instance of the NetAuth module. +func New(c *container.C, modName, instName string) (module.Module, error) { + return &Auth{ + instName: instName, + log: c.DefaultLogger.Sublogger(modName), + }, nil +} + +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs)> 0 { + return fmt.Errorf("%s: inline arguments are not used", modName) + } + + l := hclog.New(&hclog.LoggerOptions{Output: a.log}) + n, err := netauth.NewWithLog(l) + if err != nil { + return err + } + a.nacl = n + a.nacl.SetServiceName("maddy") + cfg.String("require_group", false, false, "", &a.mustGroup) + cfg.Bool("debug", true, false, &a.log.Debug) + if _, err := cfg.Process(); err != nil { + return err + } + + return nil +} + +// Name returns "auth.netauth" as the fixed module name. +func (a *Auth) Name() string { + return modName +} + +// InstanceName returns the configured name for this instance of the +// plugin. Given the way that NetAuth works it doesn't really make +// sense to have more than one instance, but this is part of the API. +func (a *Auth) InstanceName() string { + return a.instName +} + +// Lookup requests the entity from the remote NetAuth server, +// potentially returning that the user does not exist at all. +func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error) { + e, err := a.nacl.EntityInfo(ctx, username) + if err != nil { + return "", false, fmt.Errorf("%s: search: %w", modName, err) + } + + if a.mustGroup != "" { + if err := a.checkMustGroup(username); err != nil { + return "", false, err + } + } + return e.GetID(), true, nil +} + +// AuthPlain attempts straightforward authentication of the entity on +// the remote NetAuth server. +func (a *Auth) AuthPlain(username, password string) error { + a.log.Debugf("attempting to auth user: %s", username) + if err := a.nacl.AuthEntity(context.Background(), username, password); err != nil { + return module.ErrUnknownCredentials + } + a.log.Debugln("netauth returns successful auth") + if a.mustGroup != "" { + if err := a.checkMustGroup(username); err != nil { + return err + } + } + return nil +} + +func (a *Auth) checkMustGroup(username string) error { + a.log.Debugf("Performing require_group check: must=%s", a.mustGroup) + groups, err := a.nacl.EntityGroups(context.Background(), username) + if err != nil { + return fmt.Errorf("%s: groups: %w", modName, err) + } + for _, g := range groups { + if g.GetName() == a.mustGroup { + return nil + } + } + return fmt.Errorf("%s: missing required group (%s not in %s)", modName, username, a.mustGroup) +} diff --git a/internal/auth/pam/module.go b/internal/auth/pam/module.go index c93269d3f..12977a52f 100644 --- a/internal/auth/pam/module.go +++ b/internal/auth/pam/module.go @@ -25,8 +25,10 @@ import ( "path/filepath" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/auth/external" ) @@ -35,16 +37,13 @@ type Auth struct { useHelper bool helperPath string - Log log.Logger + Log *log.Logger } -func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, errors.New("pam: inline arguments are not used") - } +func New(c *container.C, modName, instName string) (module.Module, error) { return &Auth{ instName: instName, - Log: log.Logger{Name: modName}, + Log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -56,7 +55,11 @@ func (a *Auth) InstanceName() string { return a.instName } -func (a *Auth) Init(cfg *config.Map) error { +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("pam: inline arguments are not used") + } + cfg.Bool("debug", true, false, &a.Log.Debug) cfg.Bool("use_helper", false, false, &a.useHelper) if _, err := cfg.Process(); err != nil { @@ -90,5 +93,5 @@ func (a *Auth) AuthPlain(username, password string) error { } func init() { - module.Register("auth.pam", New) + modules.Register("auth.pam", New) } diff --git a/internal/auth/pam/pam.go b/internal/auth/pam/pam.go index 85e2eff8f..2b5b0efd7 100644 --- a/internal/auth/pam/pam.go +++ b/internal/auth/pam/pam.go @@ -1,4 +1,5 @@ -//+build cgo,libpam +//go:build cgo && libpam +// +build cgo,libpam /* Maddy Mail Server - Composable all-in-one email server. diff --git a/internal/auth/pam/pam_stub.go b/internal/auth/pam/pam_stub.go index a9b943133..fb7542141 100644 --- a/internal/auth/pam/pam_stub.go +++ b/internal/auth/pam/pam_stub.go @@ -1,4 +1,5 @@ -//+build !cgo !libpam +//go:build !cgo || !libpam +// +build !cgo !libpam /* Maddy Mail Server - Composable all-in-one email server. diff --git a/internal/auth/pass_table/table.go b/internal/auth/pass_table/table.go index 0bef271c6..8d3cea34e 100644 --- a/internal/auth/pass_table/table.go +++ b/internal/auth/pass_table/table.go @@ -25,30 +25,30 @@ import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "golang.org/x/crypto/bcrypt" "golang.org/x/text/secure/precis" ) type Auth struct { - modName string - instName string - inlineArgs []string + modName string + instName string table module.Table } -func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func New(_ *container.C, modName, instName string) (module.Module, error) { return &Auth{ - modName: modName, - instName: instName, - inlineArgs: inlineArgs, + modName: modName, + instName: instName, }, nil } -func (a *Auth) Init(cfg *config.Map) error { - if len(a.inlineArgs) != 0 { - return modconfig.ModuleFromNode("table", a.inlineArgs, cfg.Block, cfg.Globals, &a.table) +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return modconfig.ModuleFromNode("table", inlineArgs, cfg.Block, cfg.Globals, &a.table) } cfg.Custom("table", false, true, nil, modconfig.TableDirective, &a.table) @@ -112,11 +112,21 @@ func (a *Auth) ListUsers() ([]string, error) { } func (a *Auth) CreateUser(username, password string) error { + return a.CreateUserHash(username, password, HashBcrypt, HashOpts{ + BcryptCost: bcrypt.DefaultCost, + }) +} + +func (a *Auth) CreateUserHash(username, password string, hashAlgo string, opts HashOpts) error { tbl, ok := a.table.(module.MutableTable) if !ok { return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName) } + if _, ok := HashCompute[hashAlgo]; !ok { + return fmt.Errorf("%s: unknown hash function: %v", a.modName, hashAlgo) + } + key, err := precis.UsernameCaseMapped.CompareKey(username) if err != nil { return fmt.Errorf("%s: create user %s (raw): %w", a.modName, username, err) @@ -130,15 +140,12 @@ func (a *Auth) CreateUser(username, password string) error { return fmt.Errorf("%s: credentials for %s already exist", a.modName, key) } - // TODO: Allow to customize hash function. - hash, err := HashCompute[HashBcrypt](HashOpts{ - BcryptCost: bcrypt.DefaultCost, - }, password) + hash, err := HashCompute[hashAlgo](opts, password) if err != nil { return fmt.Errorf("%s: create user %s: hash generation: %w", a.modName, key, err) } - if err := tbl.SetKey(key, "bcrypt:"+hash); err != nil { + if err := tbl.SetKey(key, hashAlgo+":"+hash); err != nil { return fmt.Errorf("%s: create user %s: %w", a.modName, key, err) } return nil @@ -187,5 +194,5 @@ func (a *Auth) DeleteUser(username string) error { } func init() { - module.Register("auth.pass_table", New) + modules.Register("auth.pass_table", New) } diff --git a/internal/auth/pass_table/table_test.go b/internal/auth/pass_table/table_test.go index 666e2b60b..7c8cf6149 100644 --- a/internal/auth/pass_table/table_test.go +++ b/internal/auth/pass_table/table_test.go @@ -22,17 +22,18 @@ import ( "testing" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/internal/testutils" ) func TestAuth_AuthPlain(t *testing.T) { addSHA256() - mod, err := New("pass_table", "", nil, []string{"dummy"}) + mod, err := New(container.New(), "pass_table", "") if err != nil { t.Fatal(err) } - err = mod.Init(config.NewMap(nil, config.Node{ + err = mod.Configure([]string{"dummy"}, config.NewMap(nil, config.Node{ Children: []config.Node{}, })) if err != nil { diff --git a/internal/auth/plain_separate/plain_separate.go b/internal/auth/plain_separate/plain_separate.go index 893d1f012..ae4371437 100644 --- a/internal/auth/plain_separate/plain_separate.go +++ b/internal/auth/plain_separate/plain_separate.go @@ -25,8 +25,10 @@ import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Auth struct { @@ -38,19 +40,15 @@ type Auth struct { onlyFirstID bool - Log log.Logger + log *log.Logger } -func NewAuth(modName, instName string, _, inlinargs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { a := &Auth{ modName: modName, instName: instName, onlyFirstID: false, - Log: log.Logger{Name: modName}, - } - - if len(inlinargs) != 0 { - return nil, errors.New("plain_separate: inline arguments are not used") + log: c.DefaultLogger.Sublogger(modName), } return a, nil @@ -64,8 +62,12 @@ func (a *Auth) InstanceName() string { return a.instName } -func (a *Auth) Init(cfg *config.Map) error { - cfg.Bool("debug", false, false, &a.Log.Debug) +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("plain_separate: inline arguments are not used") + } + + cfg.Bool("debug", false, false, &a.log.Debug) cfg.Callback("user", func(m *config.Map, node config.Node) error { var tbl module.Table err := modconfig.ModuleFromNode("table", node.Args, node, m.Globals, &tbl) @@ -141,5 +143,5 @@ func (a *Auth) AuthPlain(username, password string) error { } func init() { - module.Register("auth.plain_separate", NewAuth) + modules.Register("auth.plain_separate", New) } diff --git a/internal/auth/sasl.go b/internal/auth/sasl.go index 06417ce04..591e37647 100644 --- a/internal/auth/sasl.go +++ b/internal/auth/sasl.go @@ -19,6 +19,7 @@ along with this program. If not, see . package auth import ( + "context" "errors" "fmt" "net" @@ -28,21 +29,32 @@ import ( modconfig "github.com/foxcpp/maddy/framework/config/module" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/auth/sasllogin" + "github.com/foxcpp/maddy/internal/authz" ) var ( - ErrUnsupportedMech = errors.New("Unsupported SASL mechanism") + ErrUnsupportedMech = errors.New("unsupported SASL mechanism") ErrInvalidAuthCred = errors.New("auth: invalid credentials") ) // SASLAuth is a wrapper that initializes sasl.Server using authenticators that // call maddy module objects. // +// It also handles username translation using auth_map and auth_map_normalize +// (AuthMap and AuthMapNormalize should be set). +// // It supports reporting of multiple authorization identities so multiple // accounts can be associated with a single set of credentials. type SASLAuth struct { - Log log.Logger + Log *log.Logger OnlyFirstID bool + EnableLogin bool + + AuthMap module.Table + AuthNormalize authz.NormalizeFunc + + ErrorMap func(err error) error Plain []module.PlainAuth } @@ -51,12 +63,43 @@ func (s *SASLAuth) SASLMechanisms() []string { var mechs []string if len(s.Plain) != 0 { - mechs = append(mechs, sasl.Plain, sasl.Login) + mechs = append(mechs, sasl.Plain) + if s.EnableLogin { + mechs = append(mechs, sasl.Login) + } } return mechs } +func (s *SASLAuth) usernameForAuth(ctx context.Context, saslUsername string) (string, error) { + if s.AuthNormalize != nil { + var err error + saslUsername, err = s.AuthNormalize(saslUsername) + if err != nil { + return "", err + } + } + + if s.AuthMap == nil { + return saslUsername, nil + } + + mapped, ok, err := s.AuthMap.Lookup(ctx, saslUsername) + if err != nil { + return "", err + } + if !ok { + return "", ErrInvalidAuthCred + } + + if saslUsername != mapped { + s.Log.DebugMsg("using mapped username for authentication", "username", saslUsername, "mapped_username", mapped) + } + + return mapped, nil +} + func (s *SASLAuth) AuthPlain(username, password string) error { if len(s.Plain) == 0 { return ErrUnsupportedMech @@ -64,7 +107,16 @@ func (s *SASLAuth) AuthPlain(username, password string) error { var lastErr error for _, p := range s.Plain { - lastErr = p.AuthPlain(username, password) + mappedUsername, err := s.usernameForAuth(context.TODO(), username) + if err != nil { + return err + } + + s.Log.DebugMsg("attempting authentication", + "mapped_username", mappedUsername, "original_username", username, + "module", p) + + lastErr = p.AuthPlain(mappedUsername, password) if lastErr == nil { return nil } @@ -73,32 +125,73 @@ func (s *SASLAuth) AuthPlain(username, password string) error { return fmt.Errorf("no auth. provider accepted creds, last err: %w", lastErr) } +type ContextData struct { + // Authentication username. May be different from identity. + Username string + + // Password used for password-based mechanisms. + Password string +} + // CreateSASL creates the sasl.Server instance for the corresponding mechanism. -func (s *SASLAuth) CreateSASL(mech string, remoteAddr net.Addr, successCb func(identity string) error) sasl.Server { +func (s *SASLAuth) CreateSASL( + mech string, remoteAddr net.Addr, + successCb func(identity string, data ContextData) error, +) sasl.Server { switch mech { case sasl.Plain: return sasl.NewPlainServer(func(identity, username, password string) error { if identity == "" { identity = username } + if identity != username { + if s.ErrorMap != nil { + return s.ErrorMap(ErrInvalidAuthCred) + } + return ErrInvalidAuthCred + } err := s.AuthPlain(username, password) if err != nil { s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr) + if s.ErrorMap != nil { + return s.ErrorMap(ErrInvalidAuthCred) + } return ErrInvalidAuthCred } - return successCb(identity) + return successCb(identity, ContextData{ + Username: username, + Password: password, + }) }) case sasl.Login: - return sasl.NewLoginServer(func(username, password string) error { - err := s.AuthPlain(username, password) + if !s.EnableLogin { + return FailingSASLServ{Err: ErrUnsupportedMech} + } + + return sasllogin.NewLoginServer(func(username, password string) error { + username, err := s.usernameForAuth(context.Background(), username) + if err != nil { + if s.ErrorMap != nil { + return s.ErrorMap(ErrInvalidAuthCred) + } + return err + } + + err = s.AuthPlain(username, password) if err != nil { s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr) + if s.ErrorMap != nil { + return s.ErrorMap(ErrInvalidAuthCred) + } return ErrInvalidAuthCred } - return successCb(username) + return successCb(username, ContextData{ + Username: username, + Password: password, + }) }) } return FailingSASLServ{Err: ErrUnsupportedMech} diff --git a/internal/auth/sasl_test.go b/internal/auth/sasl_test.go index 625f0bb1a..a59cfc791 100644 --- a/internal/auth/sasl_test.go +++ b/internal/auth/sasl_test.go @@ -52,7 +52,7 @@ func TestCreateSASL(t *testing.T) { } t.Run("XWHATEVER", func(t *testing.T) { - srv := a.CreateSASL("XWHATEVER", &net.TCPAddr{}, func(string) error { return nil }) + srv := a.CreateSASL("XWHATEVER", &net.TCPAddr{}, func(string, ContextData) error { return nil }) _, _, err := srv.Next([]byte("")) if err == nil { t.Error("No error for XWHATEVER use") @@ -60,7 +60,7 @@ func TestCreateSASL(t *testing.T) { }) t.Run("PLAIN", func(t *testing.T) { - srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string) error { + srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error { if id != "user1" { t.Fatal("Wrong auth. identities passed to callback:", id) } @@ -74,14 +74,14 @@ func TestCreateSASL(t *testing.T) { }) t.Run("PLAIN with authorization identity", func(t *testing.T) { - srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string) error { - if id != "user1a" { + srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error { + if id != "user1" { t.Fatal("Wrong authorization identity passed:", id) } return nil }) - _, _, err := srv.Next([]byte("user1a\x00user1\x00aa")) + _, _, err := srv.Next([]byte("user1\x00user1\x00aa")) if err != nil { t.Error("Unexpected error:", err) } diff --git a/internal/auth/sasllogin/sasllogin.go b/internal/auth/sasllogin/sasllogin.go new file mode 100644 index 000000000..fac50260c --- /dev/null +++ b/internal/auth/sasllogin/sasllogin.go @@ -0,0 +1,54 @@ +package sasllogin + +import "github.com/emersion/go-sasl" + +// Copy-pasted from old emersion/go-sasl version + +// Authenticates users with an username and a password. +type LoginAuthenticator func(username, password string) error +type loginState int + +const ( + loginNotStarted loginState = iota + loginWaitingUsername + loginWaitingPassword +) + +type loginServer struct { + state loginState + username, password string + authenticate LoginAuthenticator +} + +// A server implementation of the LOGIN authentication mechanism, as described +// in https://tools.ietf.org/html/draft-murchison-sasl-login-00. +// +// LOGIN is obsolete and should only be enabled for legacy clients that cannot +// be updated to use PLAIN. +func NewLoginServer(authenticator LoginAuthenticator) sasl.Server { + return &loginServer{authenticate: authenticator} +} + +func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) { + switch a.state { + case loginNotStarted: + // Check for initial response field, as per RFC4422 section 3 + if response == nil { + challenge = []byte("Username:") + break + } + a.state++ + fallthrough + case loginWaitingUsername: + a.username = string(response) + challenge = []byte("Password:") + case loginWaitingPassword: + a.password = string(response) + err = a.authenticate(a.username, a.password) + done = true + default: + err = sasl.ErrUnexpectedClientResponse + } + a.state++ + return +} diff --git a/internal/auth/shadow/module.go b/internal/auth/shadow/module.go index 1a6931fbb..8223af28b 100644 --- a/internal/auth/shadow/module.go +++ b/internal/auth/shadow/module.go @@ -1,4 +1,5 @@ -//+build !windows +//go:build !windows +// +build !windows /* Maddy Mail Server - Composable all-in-one email server. @@ -27,8 +28,10 @@ import ( "path/filepath" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/auth/external" ) @@ -37,16 +40,13 @@ type Auth struct { useHelper bool helperPath string - Log log.Logger + log *log.Logger } -func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, errors.New("shadow: inline arguments are not used") - } +func New(c *container.C, modName, instName string) (module.Module, error) { return &Auth{ instName: instName, - Log: log.Logger{Name: modName}, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -58,8 +58,12 @@ func (a *Auth) InstanceName() string { return a.instName } -func (a *Auth) Init(cfg *config.Map) error { - cfg.Bool("debug", true, false, &a.Log.Debug) +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("shadow: inline arguments are not used") + } + + cfg.Bool("debug", true, false, &a.log.Debug) cfg.Bool("use_helper", false, false, &a.useHelper) if _, err := cfg.Process(); err != nil { return err @@ -78,7 +82,9 @@ func (a *Auth) Init(cfg *config.Map) error { } return fmt.Errorf("shadow: can't read /etc/shadow: %v", err) } - f.Close() + if err := f.Close(); err != nil { + a.log.Error("can't close /etc/shadow file", err) + } } return nil @@ -133,5 +139,5 @@ func (a *Auth) AuthPlain(username, password string) error { } func init() { - module.Register("auth.shadow", New) + modules.Register("auth.shadow", New) } diff --git a/internal/authz/lookup.go b/internal/authz/lookup.go index 503244e8e..f19c3f8ac 100644 --- a/internal/authz/lookup.go +++ b/internal/authz/lookup.go @@ -8,14 +8,11 @@ import ( "github.com/foxcpp/maddy/framework/module" ) -func AuthorizeEmailUse(ctx context.Context, username, addr string, mapping module.Table) (bool, error) { - _, domain, err := address.Split(addr) - if err != nil { - return false, fmt.Errorf("authz: %w", err) - } - +func AuthorizeEmailUse(ctx context.Context, username string, addrs []string, mapping module.Table) (bool, error) { var validEmails []string + if multi, ok := mapping.(module.MultiTable); ok { + var err error validEmails, err = multi.LookupMulti(ctx, username) if err != nil { return false, fmt.Errorf("authz: %w", err) @@ -30,9 +27,16 @@ func AuthorizeEmailUse(ctx context.Context, username, addr string, mapping modul } } - for _, ent := range validEmails { - if ent == domain || ent == "*" || ent == addr { - return true, nil + for _, addr := range addrs { + _, domain, err := address.Split(addr) + if err != nil { + return false, fmt.Errorf("authz: %w", err) + } + + for _, ent := range validEmails { + if ent == domain || ent == "*" || ent == addr { + return true, nil + } } } diff --git a/internal/authz/normalization.go b/internal/authz/normalization.go index 310daded5..99c46d065 100644 --- a/internal/authz/normalization.go +++ b/internal/authz/normalization.go @@ -7,9 +7,25 @@ import ( "golang.org/x/text/secure/precis" ) +type NormalizeFunc func(string) (string, error) + +func NormalizeNoop(s string) (string, error) { + return s, nil +} + +// NormalizeAuto applies address.PRECISFold to valid emails and +// plain UsernameCaseMapped profile to other strings. +func NormalizeAuto(s string) (string, error) { + if address.Valid(s) { + return address.PRECISFold(s) + } + return precis.UsernameCaseMapped.CompareKey(s) +} + // NormalizeFuncs defines configurable normalization functions to be used // in authentication and authorization routines. -var NormalizeFuncs = map[string]func(string) (string, error){ +var NormalizeFuncs = map[string]NormalizeFunc{ + "auto": NormalizeAuto, "precis_casefold_email": address.PRECISFold, "precis_casefold": precis.UsernameCaseMapped.CompareKey, "precis_email": address.PRECIS, @@ -17,7 +33,5 @@ var NormalizeFuncs = map[string]func(string) (string, error){ "casefold": func(s string) (string, error) { return strings.ToLower(s), nil }, - "noop": func(s string) (string, error) { - return s, nil - }, + "noop": NormalizeNoop, } diff --git a/internal/check/authorize_sender/authorize_sender.go b/internal/check/authorize_sender/authorize_sender.go index aa1c01ef8..e1785fdb0 100644 --- a/internal/check/authorize_sender/authorize_sender.go +++ b/internal/check/authorize_sender/authorize_sender.go @@ -27,9 +27,11 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/authz" "github.com/foxcpp/maddy/internal/table" "github.com/foxcpp/maddy/internal/target" @@ -39,7 +41,7 @@ const modName = "check.authorize_sender" type Check struct { instName string - log log.Logger + log *log.Logger checkHeader bool emailPrepare module.Table @@ -49,13 +51,14 @@ type Check struct { noMatchAction modconfig.FailAction errAction modconfig.FailAction - fromNorm func(string) (string, error) - authNorm func(string) (string, error) + fromNorm authz.NormalizeFunc + authNorm authz.NormalizeFunc } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { return &Check{ instName: instName, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -67,7 +70,11 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return fmt.Errorf("%s: inline arguments are not used", modName) + } + cfg.Bool("debug", true, false, &c.log.Debug) cfg.Bool("check_header", false, true, &c.checkHeader) @@ -89,36 +96,22 @@ func (c *Check) Init(cfg *config.Map) error { return modconfig.FailAction{Reject: true}, nil }, modconfig.FailActionDirective, &c.errAction) - var ( - authNormalize string - fromNormalize string - ok bool - ) - cfg.String("auth_normalize", false, false, - "precis_casefold_email", &authNormalize) - cfg.String("from_normalize", false, false, - "precis_casefold_email", &fromNormalize) + config.EnumMapped(cfg, "auth_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &c.authNorm) + config.EnumMapped(cfg, "from_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &c.fromNorm) if _, err := cfg.Process(); err != nil { return err } - c.authNorm, ok = authz.NormalizeFuncs[authNormalize] - if !ok { - return fmt.Errorf("%v: unknown normalization function: %v", modName, authNormalize) - } - c.fromNorm, ok = authz.NormalizeFuncs[fromNormalize] - if !ok { - return fmt.Errorf("%v: unknown normalization function: %v", modName, fromNormalize) - } - return nil } type state struct { c *Check msgMeta *module.MsgMetadata - log log.Logger + log *log.Logger } func (c *Check) CheckStateForMsg(_ context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) { @@ -162,9 +155,18 @@ func (s *state) authzSender(ctx context.Context, authName, email string) module. }}) } + var preparedEmail []string + var ok bool s.log.DebugMsg("normalized names", "from", fromEmailNorm, "auth", authNameNorm) - - preparedEmail, ok, err := s.c.emailPrepare.Lookup(ctx, fromEmailNorm) + if emailPrepareMulti, isMulti := s.c.emailPrepare.(module.MultiTable); isMulti { + preparedEmail, err = emailPrepareMulti.LookupMulti(ctx, fromEmailNorm) + ok = len(preparedEmail)> 0 + } else { + var preparedEmail_single string + preparedEmail_single, ok, err = s.c.emailPrepare.Lookup(ctx, fromEmailNorm) + preparedEmail = []string{preparedEmail_single} + } + s.log.DebugMsg("authorized emails", "preparedEmail", preparedEmail, "ok", ok) if err != nil { return s.c.errAction.Apply(module.CheckResult{ Reason: &exterrors.SMTPError{ @@ -176,7 +178,7 @@ func (s *state) authzSender(ctx context.Context, authName, email string) module. }}) } if !ok { - preparedEmail = fromEmailNorm + preparedEmail = []string{fromEmailNorm} } ok, err = authz.AuthorizeEmailUse(ctx, authNameNorm, preparedEmail, s.c.userToEmail) @@ -307,5 +309,5 @@ func (s *state) Close() error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/command/command.go b/internal/check/command/command.go index 60937607d..a69db4fd2 100644 --- a/internal/check/command/command.go +++ b/internal/check/command/command.go @@ -37,9 +37,11 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -58,7 +60,7 @@ var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`) type Check struct { instName string - log log.Logger + log *log.Logger stage Stage actions map[int]modconfig.FailAction @@ -66,9 +68,10 @@ type Check struct { cmdArgs []string } -func New(modName, instName string, aliases, inlineArgs []string) (module.Module, error) { - c := &Check{ +func New(c *container.C, modName, instName string) (module.Module, error) { + chk := &Check{ instName: instName, + log: c.DefaultLogger.Sublogger(modName), actions: map[int]modconfig.FailAction{ 1: { Reject: true, @@ -79,14 +82,7 @@ func New(modName, instName string, aliases, inlineArgs []string) (module.Module, }, } - if len(inlineArgs) == 0 { - return nil, errors.New("command: at least one argument is required (command name)") - } - - c.cmd = inlineArgs[0] - c.cmdArgs = inlineArgs[1:] - - return c, nil + return chk, nil } func (c *Check) Name() string { @@ -97,7 +93,14 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) == 0 { + return errors.New("command: at least one argument is required (command name)") + } + + c.cmd = inlineArgs[0] + c.cmdArgs = inlineArgs[1:] + // Check whether the inline argument command is usable. if _, err := exec.LookPath(c.cmd); err != nil { return fmt.Errorf("command: %w", err) @@ -140,7 +143,7 @@ func (c *Check) Init(cfg *config.Map) error { type state struct { c *Check msgMeta *module.MsgMetadata - log log.Logger + log *log.Logger mailFrom string rcpts []string @@ -397,5 +400,5 @@ func (s *state) Close() error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/dkim/dkim.go b/internal/check/dkim/dkim.go index 563fc5b1e..9178cb984 100644 --- a/internal/check/dkim/dkim.go +++ b/internal/check/dkim/dkim.go @@ -33,16 +33,18 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) type Check struct { instName string - log log.Logger + log *log.Logger requiredFields map[string]struct{} brokenSigAction modconfig.FailAction @@ -52,18 +54,19 @@ type Check struct { resolver dns.Resolver } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, errors.New("check.dkim: inline arguments are not used") - } +func New(c *container.C, modName, instName string) (module.Module, error) { return &Check{ instName: instName, - log: log.Logger{Name: "check.dkim"}, + log: c.DefaultLogger.Sublogger(modName), resolver: dns.DefaultResolver(), }, nil } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("check.dkim: inline arguments are not used") + } + var requiredFields []string cfg.Bool("debug", true, false, &c.log.Debug) @@ -101,7 +104,7 @@ func (c *Check) InstanceName() string { type dkimCheckState struct { c *Check msgMeta *module.MsgMetadata - log log.Logger + log *log.Logger } func (d *dkimCheckState) CheckConnection(ctx context.Context) module.CheckResult { @@ -268,5 +271,5 @@ func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadat } func init() { - module.Register("check.dkim", New) + modules.Register("check.dkim", New) } diff --git a/internal/check/dkim/dkim_test.go b/internal/check/dkim/dkim_test.go index 020d50f54..70fafa53b 100644 --- a/internal/check/dkim/dkim_test.go +++ b/internal/check/dkim/dkim_test.go @@ -28,6 +28,7 @@ import ( "github.com/foxcpp/go-mockdns" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" @@ -84,7 +85,7 @@ Joe. func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) *Check { t.Helper() - mod, err := New("check.dkim", "", nil, nil) + mod, err := New(container.New(), "check.dkim", "") if err != nil { t.Fatal(err) } @@ -92,7 +93,7 @@ func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) * check.resolver = &mockdns.Resolver{Zones: zones} check.log = testutils.Logger(t, mod.Name()) - if err := check.Init(config.NewMap(nil, config.Node{Children: cfg})); err != nil { + if err := check.Configure(nil, config.NewMap(nil, config.Node{Children: cfg})); err != nil { t.Fatal(err) } diff --git a/internal/check/dns/dns.go b/internal/check/dns/dns.go index 01b75b369..80c89ed15 100644 --- a/internal/check/dns/dns.go +++ b/internal/check/dns/dns.go @@ -19,7 +19,6 @@ along with this program. If not, see . package dns import ( - "net" "strings" "github.com/foxcpp/maddy/framework/address" @@ -156,92 +155,9 @@ func requireMXRecord(ctx check.StatelessCheckContext, mailFrom string) module.Ch return module.CheckResult{} } - -func requireMatchingEHLO(ctx check.StatelessCheckContext) module.CheckResult { - ctx.Logger.Printf("require_matching_echo is deprecated and will be removed in the next release") - - if ctx.MsgMeta.Conn == nil { - ctx.Logger.Printf("locally-generated message, skipping") - return module.CheckResult{} - } - - tcpAddr, ok := ctx.MsgMeta.Conn.RemoteAddr.(*net.TCPAddr) - if !ok { - ctx.Logger.Printf("non-TCP/IP source, skipped") - return module.CheckResult{} - } - - ehlo := ctx.MsgMeta.Conn.Hostname - - if strings.HasPrefix(ehlo, "[") && strings.HasSuffix(ehlo, "]") { - // IP in EHLO, checking against source IP directly. - - ehlo = ehlo[1 : len(ehlo)-1] - ehlo = strings.TrimPrefix(ehlo, "IPv6:") - ehloIP := net.ParseIP(ehlo) - - if ehloIP == nil { - return module.CheckResult{ - Reason: &exterrors.SMTPError{ - Code: 550, - EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "Malformed IP in EHLO", - CheckName: "require_matching_ehlo", - }, - } - } - - if !ehloIP.Equal(tcpAddr.IP) { - return module.CheckResult{ - Reason: &exterrors.SMTPError{ - Code: 550, - EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "IP in EHLO is not the same as the actual client IP", - CheckName: "require_matching_ehlo", - }, - } - } - - return module.CheckResult{} - } - - srcIPs, err := ctx.Resolver.LookupIPAddr(ctx, dns.FQDN(ehlo)) - if err != nil { - reason, misc := exterrors.UnwrapDNSErr(err) - return module.CheckResult{ - Reason: &exterrors.SMTPError{ - Code: exterrors.SMTPCode(err, 450, 550), - EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 0}), - Message: "DNS error during policy check", - CheckName: "require_matching_ehlo", - Err: err, - Reason: reason, - Misc: misc, - }, - } - } - - for _, ip := range srcIPs { - if tcpAddr.IP.Equal(ip.IP) { - ctx.Logger.Debugf("A/AAA record found for %s for %s domain", tcpAddr.IP, ehlo) - return module.CheckResult{} - } - } - return module.CheckResult{ - Reason: &exterrors.SMTPError{ - Code: 550, - EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "No matching A/AAA records found for the EHLO hostname", - CheckName: "require_matching_ehlo", - }, - } -} - func init() { check.RegisterStatelessCheck("require_matching_rdns", modconfig.FailAction{Quarantine: true}, requireMatchingRDNS, nil, nil, nil) check.RegisterStatelessCheck("require_mx_record", modconfig.FailAction{Quarantine: true}, nil, requireMXRecord, nil, nil) - check.RegisterStatelessCheck("require_matching_ehlo", modconfig.FailAction{Quarantine: true}, - requireMatchingEHLO, nil, nil, nil) } diff --git a/internal/check/dns/dns_test.go b/internal/check/dns/dns_test.go index 3d34efad4..ca6c34809 100644 --- a/internal/check/dns/dns_test.go +++ b/internal/check/dns/dns_test.go @@ -22,7 +22,6 @@ import ( "net" "testing" - "github.com/emersion/go-smtp" "github.com/foxcpp/go-mockdns" "github.com/foxcpp/maddy/framework/future" "github.com/foxcpp/maddy/framework/module" @@ -51,11 +50,9 @@ func TestRequireMatchingRDNS(t *testing.T) { }, MsgMeta: &module.MsgMetadata{ Conn: &module.ConnState{ - ConnectionState: smtp.ConnectionState{ - RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555}, - Hostname: srcHost, - }, - RDNSName: rdnsFut, + RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555}, + Hostname: srcHost, + RDNSName: rdnsFut, }, }, Logger: testutils.Logger(t, "require_matching_rdns"), @@ -92,9 +89,7 @@ func TestRequireMXRecord(t *testing.T) { }, MsgMeta: &module.MsgMetadata{ Conn: &module.ConnState{ - ConnectionState: smtp.ConnectionState{ - RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555}, - }, + RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555}, }, }, Logger: testutils.Logger(t, "require_mx_record"), @@ -119,67 +114,3 @@ func TestRequireMXRecord(t *testing.T) { test("", "", nil, false) // Permit for bounces. test("foo@example.org", "example.org", []net.MX{{Host: "."}}, true) } - -func TestMatchingEHLO(t *testing.T) { - test := func(srcHost string, srcIP net.IP, a, aaaa []string, fail bool) { - zones := map[string]mockdns.Zone{} - if a != nil && aaaa != nil { - zones[srcHost+"."] = mockdns.Zone{ - A: a, - AAAA: aaaa, - } - } - - res := requireMatchingEHLO(check.StatelessCheckContext{ - Resolver: &mockdns.Resolver{ - Zones: zones, - }, - MsgMeta: &module.MsgMetadata{ - Conn: &module.ConnState{ - ConnectionState: smtp.ConnectionState{ - RemoteAddr: &net.TCPAddr{IP: srcIP, Port: 55555}, - Hostname: srcHost, - }, - }, - }, - Logger: testutils.Logger(t, "require_matching_helo"), - }) - - actualFail := res.Reason != nil - if fail && !actualFail { - t.Errorf("srcHost %v, srcIP %v, a %v, aaaa %v: expected failure but check succeeded", srcHost, srcIP, a, aaaa) - } - if !fail && actualFail { - t.Errorf("srcHost %v, srcIP %v, a %v, aaaa %v: unexpected failure", srcHost, srcIP, a, aaaa) - } - } - - test("mx.example.org", net.IPv4(1, 2, 3, 4), - nil, nil, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{}, []string{}, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{"2.3.4.5"}, nil, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{"2.3.4.5"}, []string{"beef::1"}, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{"2.3.4.5"}, []string{"beef::1"}, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{"1.2.3.4"}, nil, true) - test("mx.example.org", net.IPv4(1, 2, 3, 4), - []string{"1.2.3.4"}, []string{"beef::1"}, false) - test("[1.2.3.5]", net.IPv4(1, 2, 3, 4), - nil, nil, true) - test("[not valid]", net.IPv4(1, 2, 3, 4), - nil, nil, true) - test("[1.2.3.4]", net.IPv4(1, 2, 3, 4), - nil, nil, false) - test("[IPv6:beef::1]", net.IPv4(1, 2, 3, 4), - nil, nil, true) - test("[IPv6:NOT VALID]", net.IPv4(1, 2, 3, 4), - nil, nil, true) - test("[IPv6:beef::1]", net.ParseIP("beef::2"), - nil, nil, true) - test("[IPv6:beef::1]", net.ParseIP("beef::1"), - nil, nil, false) -} diff --git a/internal/check/dnsbl/common.go b/internal/check/dnsbl/common.go index 7b874cb94..ae43b23ad 100644 --- a/internal/check/dnsbl/common.go +++ b/internal/check/dnsbl/common.go @@ -32,9 +32,15 @@ type ListedErr struct { Identity string List string Reason string + Score int + Message string } func (le ListedErr) Fields() map[string]interface{} { + msg := "Client identity listed in the used DNSBL" + if le.Message != "" { + msg = le.Message + } return map[string]interface{}{ "check": "dnsbl", "list": le.List, @@ -42,7 +48,7 @@ func (le ListedErr) Fields() map[string]interface{} { "reason": le.Reason, "smtp_code": 554, "smtp_enchcode": exterrors.EnhancedCode{5, 7, 0}, - "smtp_msg": "Client identity listed in the used DNSBL", + "smtp_msg": msg, } } @@ -66,28 +72,85 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, cfg List, domain st return nil } - // Attempt to extract explanation string. - txts, err := resolver.LookupTXT(context.Background(), query) - if err != nil || len(txts) == 0 { - // Not significant, include addresses as reason. Usually they are - // mapped to some predefined 'reasons' by BL. - return ListedErr{ - Identity: domain, - List: cfg.Zone, - Reason: strings.Join(addrs, "; "), + var score int + var customMessage string + var filteredAddrs []string + + // If ResponseRules is configured, use new behavior + if len(cfg.ResponseRules)> 0 { + // Convert string addresses to IPAddr for matching + ipAddrs := make([]net.IPAddr, 0, len(addrs)) + for _, addr := range addrs { + if ip := net.ParseIP(addr); ip != nil { + ipAddrs = append(ipAddrs, net.IPAddr{IP: ip}) + } } + + matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(ipAddrs, cfg.ResponseRules) + if !matched { + return nil + } + score = matchedScore + + // Use first matched message if available + if len(matchedMessages)> 0 { + customMessage = matchedMessages[0] + } + + filteredAddrs = matchedReasons + } else { + // Legacy behavior: accept all addresses + filteredAddrs = addrs } - // Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so - // don't mangle them by joining with "", instead join with "; ". + // Attempt to extract explanation string from TXT records (shared by both paths) + txts, err := resolver.LookupTXT(ctx, query) + var reason string + if err == nil && len(txts)> 0 { + reason = strings.Join(txts, "; ") + } else { + // Not significant, include addresses as reason. Usually they are + // mapped to some predefined 'reasons' by BL. + reason = strings.Join(filteredAddrs, "; ") + } return ListedErr{ Identity: domain, List: cfg.Zone, - Reason: strings.Join(txts, "; "), + Reason: reason, + Score: score, + Message: customMessage, } } +func matchResponseRules(addrs []net.IPAddr, rules []ResponseRule) (score int, messages []string, reasons []string, matched bool) { + // Track which rules have been matched to avoid counting the same rule multiple times + matchedRules := make(map[int]bool) + + for _, addr := range addrs { + for ruleIdx, rule := range rules { + // Skip if this rule has already been matched + if matchedRules[ruleIdx] { + continue + } + + for _, respNet := range rule.Networks { + if respNet.Contains(addr.IP) { + score += rule.Score + if rule.Message != "" { + messages = append(messages, rule.Message) + } + reasons = append(reasons, addr.IP.String()) + matchedRules[ruleIdx] = true + matched = true + break // Move to next rule + } + } + } + } + return +} + func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) error { ipv6 := true if ipv4 := ip.To4(); ipv4 != nil { @@ -113,52 +176,72 @@ func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) er return err } - filteredAddrs := make([]net.IPAddr, 0, len(addrs)) -addrsLoop: - for _, addr := range addrs { - // No responses whitelist configured - permit all. - if len(cfg.Responses) == 0 { - filteredAddrs = append(filteredAddrs, addr) - continue + var filteredAddrs []net.IPAddr + var score int + var customMessage string + + // If ResponseRules is configured, use new behavior + if len(cfg.ResponseRules)> 0 { + matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(addrs, cfg.ResponseRules) + if !matched { + return nil } + score = matchedScore - for _, respNet := range cfg.Responses { - if respNet.Contains(addr.IP) { + // Use first matched message if available + if len(matchedMessages)> 0 { + customMessage = matchedMessages[0] + } + + // Build filteredAddrs from matched reasons for TXT lookup fallback + for _, reason := range matchedReasons { + filteredAddrs = append(filteredAddrs, net.IPAddr{IP: net.ParseIP(reason)}) + } + } else { + // Legacy behavior: use flat Responses filter + filteredAddrs = make([]net.IPAddr, 0, len(addrs)) + addrsLoop: + for _, addr := range addrs { + // No responses whitelist configured - permit all. + if len(cfg.Responses) == 0 { filteredAddrs = append(filteredAddrs, addr) - continue addrsLoop + continue + } + + for _, respNet := range cfg.Responses { + if respNet.Contains(addr.IP) { + filteredAddrs = append(filteredAddrs, addr) + continue addrsLoop + } } } - } - if len(filteredAddrs) == 0 { - return nil + if len(filteredAddrs) == 0 { + return nil + } } - // Attempt to extract explanation string. + // Attempt to extract explanation string from TXT records (shared by both paths) txts, err := resolver.LookupTXT(ctx, query) - if err != nil || len(txts) == 0 { + var reason string + if err == nil && len(txts)> 0 { + reason = strings.Join(txts, "; ") + } else { // Not significant, include addresses as reason. Usually they are // mapped to some predefined 'reasons' by BL. - reasonParts := make([]string, 0, len(filteredAddrs)) for _, addr := range filteredAddrs { reasonParts = append(reasonParts, addr.IP.String()) } - - return ListedErr{ - Identity: ip.String(), - List: cfg.Zone, - Reason: strings.Join(reasonParts, "; "), - } + reason = strings.Join(reasonParts, "; ") } - // Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so - // don't mangle them by joining with "", instead join with "; ". - return ListedErr{ Identity: ip.String(), List: cfg.Zone, - Reason: strings.Join(txts, "; "), + Reason: reason, + Score: score, + Message: customMessage, } } diff --git a/internal/check/dnsbl/common_test.go b/internal/check/dnsbl/common_test.go index caa06575c..8d2b24e32 100644 --- a/internal/check/dnsbl/common_test.go +++ b/internal/check/dnsbl/common_test.go @@ -236,3 +236,104 @@ func TestCheckIP(t *testing.T) { Reason: "127.0.0.1", }) } + +func TestCheckDomainWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, cfg List, domain string, expectedErr error) { + t.Helper() + resolver := mockdns.Resolver{Zones: zones} + err := checkDomain(context.Background(), &resolver, cfg, domain) + if expectedErr == nil { + if err != nil { + t.Errorf("expected no error, got '%#v'", err) + } + } else { + if err == nil { + t.Errorf("expected err to be '%#v', got nil", expectedErr) + } else { + expectedLE, okExpected := expectedErr.(ListedErr) + actualLE, okActual := err.(ListedErr) + if !okExpected || !okActual { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } else { + if expectedLE.Identity != actualLE.Identity || + expectedLE.List != actualLE.List || + expectedLE.Score != actualLE.Score || + expectedLE.Message != actualLE.Message { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } + } + } + } + } + + // Test domain with single response code and custom message + test(map[string]mockdns.Zone{ + "spam.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Domain listed as spam source", + }, + }, + }, "spam.example.com", ListedErr{ + Identity: "spam.example.com", + List: "dnsbl.example.org", + Score: 10, + Message: "Domain listed as spam source", + }) + + // Test domain with multiple response codes - scores should sum + test(map[string]mockdns.Zone{ + "multi.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.2", "127.0.0.11"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "High severity", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Low severity", + }, + }, + }, "multi.example.com", ListedErr{ + Identity: "multi.example.com", + List: "dnsbl.example.org", + Score: 15, // 10 + 5 + Message: "High severity", + }) + + // Test domain with no matching response codes + test(map[string]mockdns.Zone{ + "unknown.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.99"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed", + }, + }, + }, "unknown.example.com", nil) +} diff --git a/internal/check/dnsbl/dnsbl.go b/internal/check/dnsbl/dnsbl.go index 1a68ea4a3..ae2921859 100644 --- a/internal/check/dnsbl/dnsbl.go +++ b/internal/check/dnsbl/dnsbl.go @@ -27,18 +27,25 @@ import ( "sync" "github.com/emersion/go-message/textproto" - "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/address" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" "golang.org/x/sync/errgroup" ) +type ResponseRule struct { + Networks []net.IPNet + Score int + Message string +} + type List struct { Zone string @@ -50,6 +57,8 @@ type List struct { ScoreAdj int Responses []net.IPNet + + ResponseRules []ResponseRule } var defaultBL = List{ @@ -59,23 +68,21 @@ var defaultBL = List{ type DNSBL struct { instName string checkEarly bool - inlineBls []string bls []List quarantineThres int rejectThres int resolver dns.Resolver - log log.Logger + log *log.Logger } -func NewDNSBL(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { return &DNSBL{ - instName: instName, - inlineBls: inlineArgs, + instName: instName, resolver: dns.DefaultResolver(), - log: log.Logger{Name: "dnsbl"}, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -87,7 +94,7 @@ func (bl *DNSBL) InstanceName() string { return bl.instName } -func (bl *DNSBL) Init(cfg *config.Map) error { +func (bl *DNSBL) Configure(inlineArgs []string, cfg *config.Map) error { cfg.Bool("debug", false, false, &bl.log.Debug) cfg.Bool("check_early", false, false, &bl.checkEarly) cfg.Int("quarantine_threshold", false, false, 1, &bl.quarantineThres) @@ -98,7 +105,7 @@ func (bl *DNSBL) Init(cfg *config.Map) error { return err } - for _, inlineBl := range bl.inlineBls { + for _, inlineBl := range inlineArgs { cfg := defaultBL cfg.Zone = inlineBl go bl.testList(cfg) @@ -127,6 +134,14 @@ func (bl *DNSBL) readListCfg(node config.Node) error { cfg.Bool("mailfrom", false, defaultBL.EHLO, &listCfg.MAILFROM) cfg.Int("score", false, false, 1, &listCfg.ScoreAdj) cfg.StringList("responses", false, false, []string{"127.0.0.1/24"}, &responseNets) + cfg.Callback("response", func(_ *config.Map, node config.Node) error { + rule, err := parseResponseRule(node) + if err != nil { + return err + } + listCfg.ResponseRules = append(listCfg.ResponseRules, rule) + return nil + }) if _, err := cfg.Process(); err != nil { return err } @@ -145,6 +160,11 @@ func (bl *DNSBL) readListCfg(node config.Node) error { listCfg.Responses = append(listCfg.Responses, *ipNet) } + // Warn if both response and responses are configured + if len(listCfg.ResponseRules)> 0 && len(responseNets)> 0 { + bl.log.Msg("both 'response' blocks and 'responses' directive are specified, 'response' blocks take precedence", "list", node.Name) + } + for _, zone := range append([]string{node.Name}, node.Args...) { zoneCfg := listCfg zoneCfg.Zone = zone @@ -174,6 +194,44 @@ func (bl *DNSBL) readListCfg(node config.Node) error { return nil } +func parseResponseRule(node config.Node) (ResponseRule, error) { + var rule ResponseRule + + if len(node.Args) == 0 { + return rule, config.NodeErr(node, "response block requires at least one IP address or CIDR as argument") + } + + // Parse IP addresses/CIDRs from arguments + for _, arg := range node.Args { + // If there is no / - it is a plain IP address, append '/32' or '/128' + resp := arg + if !strings.Contains(resp, "/") { + // Check if it's IPv6 to determine the mask + if strings.Contains(resp, ":") { + resp += "/128" + } else { + resp += "/32" + } + } + + _, ipNet, err := net.ParseCIDR(resp) + if err != nil { + return rule, config.NodeErr(node, "invalid IP address or CIDR: %s: %v", arg, err) + } + rule.Networks = append(rule.Networks, *ipNet) + } + + // Parse directives within the response block + cfg := config.NewMap(nil, node) + cfg.Int("score", false, true, 0, &rule.Score) + cfg.String("message", false, false, "", &rule.Message) + if _, err := cfg.Process(); err != nil { + return rule, err + } + + return rule, nil +} + func (bl *DNSBL) testList(listCfg List) { // Check RFC 5782 Section 5 requirements. @@ -299,10 +357,10 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin score int listedOn []string reasons []string + messages []string ) for _, list := range bl.bls { - list := list eg.Go(func() error { err := bl.checkList(ctx, list, ip, ehlo, mailFrom) if err != nil { @@ -315,7 +373,18 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin defer lck.Unlock() listedOn = append(listedOn, listErr.List) reasons = append(reasons, listErr.Reason) - score += list.ScoreAdj + + // Use score from ListedErr if set (new behavior), otherwise use legacy ScoreAdj + if listErr.Score != 0 { + score += listErr.Score + } else { + score += list.ScoreAdj + } + + // Collect custom messages if available + if listErr.Message != "" { + messages = append(messages, listErr.Message) + } } return nil }) @@ -336,13 +405,19 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin } } + // Use custom message if available, otherwise use default + message := "Client identity is listed in the used DNSBL" + if len(messages)> 0 { + message = strings.Join(messages, "; ") + } + if score>= bl.rejectThres { return module.CheckResult{ Reject: true, Reason: &exterrors.SMTPError{ Code: 554, EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "Client identity is listed in the used DNSBL", + Message: message, Err: err, CheckName: "dnsbl", }, @@ -354,7 +429,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin Reason: &exterrors.SMTPError{ Code: 554, EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "Client identity is listed in the used DNSBL", + Message: message, Err: err, CheckName: "dnsbl", }, @@ -365,11 +440,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin } // CheckConnection implements module.EarlyCheck. -func (bl *DNSBL) CheckConnection(ctx context.Context, state *smtp.ConnectionState) error { - if !bl.checkEarly { - return nil - } - +func (bl *DNSBL) CheckConnection(ctx context.Context, state *module.ConnState) error { defer trace.StartRegion(ctx, "dnsbl/CheckConnection (Early)").End() ip, ok := state.RemoteAddr.(*net.TCPAddr) @@ -381,17 +452,19 @@ func (bl *DNSBL) CheckConnection(ctx context.Context, state *smtp.ConnectionStat } result := bl.checkLists(ctx, ip.IP, state.Hostname, "") - if result.Reject { + if result.Reject && bl.checkEarly { return result.Reason } + state.ModData.Set(bl, true, result) + return nil } type state struct { bl *DNSBL msgMeta *module.MsgMetadata - log log.Logger + log *log.Logger } func (bl *DNSBL) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) { @@ -403,11 +476,6 @@ func (bl *DNSBL) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetada } func (s *state) CheckConnection(ctx context.Context) module.CheckResult { - if s.bl.checkEarly { - // Already checked before. - return module.CheckResult{} - } - defer trace.StartRegion(ctx, "dnsbl/CheckConnection").End() if s.msgMeta.Conn == nil { @@ -415,13 +483,12 @@ func (s *state) CheckConnection(ctx context.Context) module.CheckResult { return module.CheckResult{} } - ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr) - if !ok { - s.log.Msg("non-TCP/IP source") - return module.CheckResult{} + result := s.msgMeta.Conn.ModData.Get(s.bl, true) + if result != nil { + return result.(module.CheckResult) } - return s.bl.checkLists(ctx, ip.IP, s.msgMeta.Conn.Hostname, s.msgMeta.OriginalFrom) + return module.CheckResult{} } func (*state) CheckSender(context.Context, string) module.CheckResult { @@ -441,5 +508,5 @@ func (*state) Close() error { } func init() { - module.Register("check.dnsbl", NewDNSBL) + modules.Register("check.dnsbl", New) } diff --git a/internal/check/dnsbl/dnsbl_test.go b/internal/check/dnsbl/dnsbl_test.go index 1845aeb7f..d4cd72387 100644 --- a/internal/check/dnsbl/dnsbl_test.go +++ b/internal/check/dnsbl/dnsbl_test.go @@ -211,3 +211,282 @@ func TestCheckLists(t *testing.T) { true, false, ) } + +func TestCheckIPWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, cfg List, ip net.IP, expectedErr error) { + t.Helper() + resolver := mockdns.Resolver{Zones: zones} + err := checkIP(context.Background(), &resolver, cfg, ip) + if expectedErr == nil { + if err != nil { + t.Errorf("expected no error, got '%#v'", err) + } + } else { + if err == nil { + t.Errorf("expected err to be '%#v', got nil", expectedErr) + } else { + expectedLE, okExpected := expectedErr.(ListedErr) + actualLE, okActual := err.(ListedErr) + if !okExpected || !okActual { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } else { + if expectedLE.Identity != actualLE.Identity || + expectedLE.List != actualLE.List || + expectedLE.Score != actualLE.Score || + expectedLE.Message != actualLE.Message { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } + } + } + } + } + + // Test single response code with score and message + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 10, + Message: "Listed in SBL", + }) + + // Test multiple response codes with different scores - scores should sum + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2", "127.0.0.11"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 3), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 15, // 10 + 5 + Message: "Listed in SBL", + }) + + // Test response code that doesn't match any rule - should return nil + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.99"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), nil) + + // Test low severity only - should get score 5 + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.10"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 5, + Message: "Listed in PBL", + }) + + // Test high severity - should get score 10 + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 10, + Message: "Listed in SBL", + }) +} + +func TestCheckListsWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, bls []List, ip net.IP, ehlo, mailFrom string, reject, quarantine bool) { + mod := &DNSBL{ + bls: bls, + resolver: &mockdns.Resolver{Zones: zones}, + log: testutils.Logger(t, "dnsbl"), + quarantineThres: 5, + rejectThres: 10, + } + result := mod.checkLists(context.Background(), ip, ehlo, mailFrom) + + if result.Reject && !reject { + t.Errorf("Expected message to not be rejected") + } + if !result.Reject && reject { + t.Errorf("Expected message to be rejected") + } + if result.Quarantine && !quarantine { + t.Errorf("Expected message to not be quarantined") + } + if !result.Quarantine && quarantine { + t.Errorf("Expected message to be quarantined") + } + } + + // Test: Only low-severity code returned -> quarantine but not reject + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.11"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true) + + // Test: High-severity code returned -> reject + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false) + + // Test: Legacy configuration without response blocks -> existing behavior preserved + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.1"}, + }, + }, []List{ + { + Zone: "example.org", + ClientIPv4: true, + ScoreAdj: 10, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false) + + // Test: Mixed configuration (some lists with response blocks, some without) -> both work correctly + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.11"}, + }, + "4.3.2.1.legacy.example.org.": { + A: []string{"127.0.0.1"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, + { + Zone: "legacy.example.org", + ClientIPv4: true, + ScoreAdj: 3, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true) // 5 + 3 = 8, quarantine but not reject +} diff --git a/internal/check/milter/milter.go b/internal/check/milter/milter.go index c0f3700d3..2a6d6e9e7 100644 --- a/internal/check/milter/milter.go +++ b/internal/check/milter/milter.go @@ -30,9 +30,11 @@ import ( "github.com/emersion/go-milter" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -43,22 +45,16 @@ type Check struct { milterUrl string failOpen bool instName string - log log.Logger + log *log.Logger } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - c := &Check{ +func New(c *container.C, _, instName string) (module.Module, error) { + chk := &Check{ instName: instName, - log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), } - switch len(inlineArgs) { - case 1: - c.milterUrl = inlineArgs[0] - case 0: - default: - return nil, fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName) - } - return c, nil + + return chk, nil } func (c *Check) Name() string { @@ -69,7 +65,15 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + switch len(inlineArgs) { + case 1: + c.milterUrl = inlineArgs[0] + case 0: + default: + return fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName) + } + cfg.String("endpoint", false, false, c.milterUrl, &c.milterUrl) cfg.Bool("fail_open", false, false, &c.failOpen) if _, err := cfg.Process(); err != nil { @@ -90,9 +94,6 @@ func (c *Check) Init(cfg *config.Map) error { default: return fmt.Errorf("%s: scheme unsupported: %v", modName, endp.Scheme) } - if endp.Path != "" { - return fmt.Errorf("%s: stray path in endpoint: %v", modName, endp) - } c.cl = milter.NewClientWithOptions(endp.Network(), endp.Address(), milter.ClientOptions{ Dialer: &net.Dialer{ @@ -112,7 +113,7 @@ type state struct { session *milter.ClientSession msgMeta *module.MsgMetadata skipChecks bool - log log.Logger + log *log.Logger } func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) { @@ -445,5 +446,5 @@ var ( ) func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/milter/milter_test.go b/internal/check/milter/milter_test.go new file mode 100644 index 000000000..50e3954e9 --- /dev/null +++ b/internal/check/milter/milter_test.go @@ -0,0 +1,61 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package milter + +import ( + "testing" + + "github.com/foxcpp/maddy/framework/config" +) + +func TestAcceptValidEndpoints(t *testing.T) { + for _, endpoint := range []string{ + "tcp://0.0.0.0:10025", + "tcp://[::]:10025", + "tcp:127.0.0.1:10025", + "unix://path", + "unix:path", + "unix:/path", + "unix:///path", + "unix://also/path", + "unix:///also/path", + } { + c := &Check{milterUrl: endpoint} + + err := c.Configure(nil, &config.Map{}) + if err != nil { + t.Errorf("Unexpected failure for %s: %v", endpoint, err) + return + } + } +} + +func TestRejectInvalidEndpoints(t *testing.T) { + for _, endpoint := range []string{ + "tls://0.0.0.0:10025", + "tls:0.0.0.0:10025", + } { + c := &Check{milterUrl: endpoint} + err := c.Configure(nil, &config.Map{}) + if err == nil { + t.Errorf("Accepted invalid endpoint: %s", endpoint) + return + } + } +} diff --git a/internal/check/rspamd/rspamd.go b/internal/check/rspamd/rspamd.go index e6afad687..f03bdeb3c 100644 --- a/internal/check/rspamd/rspamd.go +++ b/internal/check/rspamd/rspamd.go @@ -35,9 +35,11 @@ import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -45,7 +47,7 @@ const modName = "check.rspamd" type Check struct { instName string - log log.Logger + log *log.Logger apiPath string flags string @@ -57,27 +59,20 @@ type Check struct { errorRespAction modconfig.FailAction addHdrAction modconfig.FailAction rewriteSubjAction modconfig.FailAction + rejectAction modconfig.FailAction + softRejectAction modconfig.FailAction client *http.Client } -func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { - c := &Check{ +func New(c *container.C, modName, instName string) (module.Module, error) { + chk := &Check{ instName: instName, client: http.DefaultClient, - log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), } - switch len(inlineArgs) { - case 1: - c.apiPath = inlineArgs[0] - case 0: - c.apiPath = "http://127.0.0.1:11333" - default: - return nil, fmt.Errorf("%s: unexpected amount of inline arguments", modName) - } - - return c, nil + return chk, nil } func (c *Check) Name() string { @@ -88,14 +83,23 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + switch len(inlineArgs) { + case 1: + c.apiPath = inlineArgs[0] + case 0: + c.apiPath = "http://127.0.0.1:11333" + default: + return fmt.Errorf("%s: unexpected amount of inline arguments", modName) + } + var ( - tlsConfig tls.Config + tlsConfig *tls.Config flags []string ) cfg.Custom("tls_client", true, false, func() (interface{}, error) { - return tls.Config{}, nil + return &tls.Config{}, nil }, tls2.TLSClientBlock, &tlsConfig) cfg.String("api_path", false, false, c.apiPath, &c.apiPath) cfg.String("settings_id", false, false, "", &c.settingsID) @@ -117,6 +121,15 @@ func (c *Check) Init(cfg *config.Map) error { func() (interface{}, error) { return modconfig.FailAction{Quarantine: true}, nil }, modconfig.FailActionDirective, &c.rewriteSubjAction) + cfg.Custom("reject_action", false, false, + func() (interface{}, error) { + return modconfig.FailAction{Reject: true}, nil + }, modconfig.FailActionDirective, &c.rejectAction) + cfg.Custom("soft_reject_action", false, false, + func() (interface{}, error) { + return modconfig.FailAction{Reject: true}, nil + }, modconfig.FailActionDirective, &c.softRejectAction) + cfg.StringList("flags", false, false, []string{"pass_all"}, &flags) if _, err := cfg.Process(); err != nil { return err @@ -124,7 +137,7 @@ func (c *Check) Init(cfg *config.Map) error { c.client = &http.Client{ Transport: &http.Transport{ - TLSClientConfig: &tlsConfig, + TLSClientConfig: tlsConfig, }, } c.flags = strings.Join(flags, ",") @@ -135,7 +148,7 @@ func (c *Check) Init(cfg *config.Map) error { type state struct { c *Check msgMeta *module.MsgMetadata - log log.Logger + log *log.Logger mailFrom string rcpt []string @@ -266,7 +279,11 @@ func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer }, }) } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + s.log.Error("failed to close response body", err) + } + }() var respData response if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { @@ -320,7 +337,7 @@ func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer Header: hdrAdd, }) case "soft reject": - return module.CheckResult{ + return s.c.softRejectAction.Apply(module.CheckResult{ Reject: true, Reason: &exterrors.SMTPError{ Code: 450, @@ -329,9 +346,9 @@ func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer CheckName: modName, Misc: map[string]interface{}{"action": "soft reject"}, }, - } + }) case "reject": - return module.CheckResult{ + return s.c.rejectAction.Apply(module.CheckResult{ Reject: true, Reason: &exterrors.SMTPError{ Code: 550, @@ -340,7 +357,7 @@ func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer CheckName: modName, Misc: map[string]interface{}{"action": "reject"}, }, - } + }) } s.log.Msg("unhandled action", "action", respData.Action) @@ -363,5 +380,5 @@ func (s *state) Close() error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/skeleton.go b/internal/check/skeleton.go index 77a34e70b..3bfc9cdea 100644 --- a/internal/check/skeleton.go +++ b/internal/check/skeleton.go @@ -1,4 +1,5 @@ -//+build ignore +//go:build ignore +// +build ignore /* Maddy Mail Server - Composable all-in-one email server. @@ -33,6 +34,7 @@ import ( "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -96,5 +98,5 @@ func (s *state) Close() error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/spf/spf.go b/internal/check/spf/spf.go index 69781d3f1..01154b7df 100644 --- a/internal/check/spf/spf.go +++ b/internal/check/spf/spf.go @@ -34,10 +34,12 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" maddydmarc "github.com/foxcpp/maddy/internal/dmarc" "github.com/foxcpp/maddy/internal/target" "golang.org/x/net/idna" @@ -56,14 +58,14 @@ type Check struct { permerrAction modconfig.FailAction temperrAction modconfig.FailAction - log log.Logger + log *log.Logger resolver dns.Resolver } -func New(_, instName string, _, _ []string) (module.Module, error) { +func New(c *container.C, _, instName string) (module.Module, error) { return &Check{ instName: instName, - log: log.Logger{Name: modName}, + log: c.DefaultLogger.Sublogger(modName), resolver: dns.DefaultResolver(), }, nil } @@ -76,7 +78,7 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { cfg.Bool("debug", true, false, &c.log.Debug) cfg.Bool("enforce_early", true, false, &c.enforceEarly) cfg.Custom("none_action", false, false, @@ -97,11 +99,11 @@ func (c *Check) Init(cfg *config.Map) error { }, modconfig.FailActionDirective, &c.softfailAction) cfg.Custom("permerr_action", false, false, func() (interface{}, error) { - return modconfig.FailAction{Reject: true}, nil + return modconfig.FailAction{}, nil }, modconfig.FailActionDirective, &c.permerrAction) cfg.Custom("temperr_action", false, false, func() (interface{}, error) { - return modconfig.FailAction{Reject: true}, nil + return modconfig.FailAction{}, nil }, modconfig.FailActionDirective, &c.temperrAction) _, err := cfg.Process() if err != nil { @@ -120,7 +122,7 @@ type state struct { c *Check msgMeta *module.MsgMetadata spfFetch chan spfRes - log log.Logger + log *log.Logger skip bool } @@ -314,7 +316,17 @@ func (s *state) CheckConnection(ctx context.Context) module.CheckResult { return module.CheckResult{} } - mailFrom, err := prepareMailFrom(s.msgMeta.OriginalFrom) + mailFromOriginal := s.msgMeta.OriginalFrom + if mailFromOriginal == "" { + // RFC 7208 Section 2.4. + //>When the reverse-path is null, this document + //>defines the "MAIL FROM" identity to be the mailbox composed of the + //>local-part "postmaster" and the "HELO" identity (which might or might + //>not have been checked separately before). + mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname + } + + mailFrom, err := prepareMailFrom(mailFromOriginal) if err != nil { s.skip = true return module.CheckResult{ @@ -406,5 +418,5 @@ func (s *state) Close() error { } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/check/stateless_check.go b/internal/check/stateless_check.go index 4c5d2d569..cda7faf6e 100644 --- a/internal/check/stateless_check.go +++ b/internal/check/stateless_check.go @@ -27,9 +27,11 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -47,7 +49,7 @@ type ( // Logger that should be used by the check for logging, note that it is // already wrapped to append Msg ID to all messages so check code // should not do the same. - Logger log.Logger + Logger *log.Logger } FuncConnCheck func(checkContext StatelessCheckContext) module.CheckResult FuncSenderCheck func(checkContext StatelessCheckContext, mailFrom string) module.CheckResult @@ -59,7 +61,7 @@ type statelessCheck struct { modName string instName string resolver dns.Resolver - logger log.Logger + logger *log.Logger // One used by Init if config option is not passed by a user. defaultFailAction modconfig.FailAction @@ -152,7 +154,11 @@ func (c *statelessCheck) CheckStateForMsg(ctx context.Context, msgMeta *module.M }, nil } -func (c *statelessCheck) Init(cfg *config.Map) error { +func (c *statelessCheck) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return fmt.Errorf("%s: inline arguments are not used", c.modName) + } + cfg.Bool("debug", true, false, &c.logger.Debug) cfg.Custom("fail_action", false, false, func() (interface{}, error) { @@ -181,15 +187,12 @@ func (c *statelessCheck) InstanceName() string { // code doesn't need to know about it. It should assume that it is always "Reject" and hence it should // populate Reason field of the result object with the relevant error description. func RegisterStatelessCheck(name string, defaultFailAction modconfig.FailAction, connCheck FuncConnCheck, senderCheck FuncSenderCheck, rcptCheck FuncRcptCheck, bodyCheck FuncBodyCheck) { - module.Register(name, func(modName, instName string, aliases, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, fmt.Errorf("%s: inline arguments are not used", modName) - } + modules.Register(name, func(c *container.C, modName, instName string) (module.Module, error) { return &statelessCheck{ modName: modName, instName: instName, resolver: dns.DefaultResolver(), - logger: log.Logger{Name: modName}, + logger: c.DefaultLogger.Sublogger(modName), defaultFailAction: defaultFailAction, diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 000000000..2e6011e68 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,108 @@ +package maddycli + +import ( + "errors" + "fmt" + "os" + + "github.com/foxcpp/maddy/framework/log" + "github.com/urfave/cli/v2" +) + +var app *cli.App + +func init() { + app = cli.NewApp() + app.Usage = "composable all-in-one mail server" + app.Description = `Maddy is Mail Transfer agent (MTA), Mail Delivery Agent (MDA), Mail Submission +Agent (MSA), IMAP server and a set of other essential protocols/schemes +necessary to run secure email server implemented in one executable. + +This executable can be used to start the server ('run') and to manipulate +databases used by it (all other subcommands). +` + app.Authors = []*cli.Author{ + { + Name: "Maddy Mail Server maintainers & contributors", + Email: "~foxcpp/maddy@lists.sr.ht", + }, + } + app.ExitErrHandler = func(c *cli.Context, err error) { + if err == nil { + return + } + + var exitErr cli.ExitCoder + if errors.As(err, &exitErr) { + if err.Error() != "" { + if _, ok := exitErr.(cli.ErrorFormatter); ok { + _, _ = fmt.Fprintf(os.Stderr, "Error: %+v\n", err) + } else { + _, _ = fmt.Fprintln(os.Stderr, "Error:", err) + } + } + cli.OsExiter(exitErr.ExitCode()) + return + } + } + app.EnableBashCompletion = true + app.Commands = []*cli.Command{ + { + Name: "generate-man", + Hidden: true, + Action: func(c *cli.Context) error { + man, err := app.ToMan() + if err != nil { + return err + } + fmt.Println(man) + return nil + }, + }, + { + Name: "generate-fish-completion", + Hidden: true, + Action: func(c *cli.Context) error { + cp, err := app.ToFishCompletion() + if err != nil { + return err + } + fmt.Println(cp) + return nil + }, + }, + } +} + +func AddGlobalFlag(f cli.Flag) { + app.Flags = append(app.Flags, f) +} + +func AddSubcommand(cmd *cli.Command) { + app.Commands = append(app.Commands, cmd) +} + +// RunWithoutExit is like Run but returns exit code instead of calling os.Exit +// To be used in maddy.cover. +func RunWithoutExit() int { + code := 0 + + cli.OsExiter = func(c int) { code = c } + defer func() { + cli.OsExiter = os.Exit + }() + + Run() + + return code +} + +func Run() { + mapStdlibFlags(app) + + // Actual entry point is registered in maddy.go. + + if err := app.Run(os.Args); err != nil { + log.DefaultLogger.Error("app.Run failed", err) + } +} diff --git a/cmd/maddyctl/clitools/clitools.go b/internal/cli/clitools/clitools.go similarity index 100% rename from cmd/maddyctl/clitools/clitools.go rename to internal/cli/clitools/clitools.go diff --git a/cmd/maddyctl/clitools/termios.go b/internal/cli/clitools/termios.go similarity index 98% rename from cmd/maddyctl/clitools/termios.go rename to internal/cli/clitools/termios.go index 926d2681f..cf817d1f8 100644 --- a/cmd/maddyctl/clitools/termios.go +++ b/internal/cli/clitools/termios.go @@ -1,4 +1,5 @@ -//+build linux +//go:build linux +// +build linux /* Maddy Mail Server - Composable all-in-one email server. diff --git a/cmd/maddyctl/clitools/termios_stub.go b/internal/cli/clitools/termios_stub.go similarity index 97% rename from cmd/maddyctl/clitools/termios_stub.go rename to internal/cli/clitools/termios_stub.go index 0d8ef3be8..03397fa46 100644 --- a/cmd/maddyctl/clitools/termios_stub.go +++ b/internal/cli/clitools/termios_stub.go @@ -1,4 +1,5 @@ -//+build !linux +//go:build !linux +// +build !linux /* Maddy Mail Server - Composable all-in-one email server. diff --git a/cmd/maddyctl/appendlimit.go b/internal/cli/ctl/appendlimit.go similarity index 87% rename from cmd/maddyctl/appendlimit.go rename to internal/cli/ctl/appendlimit.go index effe58101..ce0e3c4bb 100644 --- a/cmd/maddyctl/appendlimit.go +++ b/internal/cli/ctl/appendlimit.go @@ -16,15 +16,14 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package main +package ctl import ( - "errors" "fmt" - appendlimit "github.com/emersion/go-imap-appendlimit" + imapbackend "github.com/emersion/go-imap/backend" "github.com/foxcpp/maddy/framework/module" - "github.com/urfave/cli" + "github.com/urfave/cli/v2" ) // Copied from go-imap-backend-tests. @@ -32,7 +31,7 @@ import ( // AppendLimitUser is extension for backend.User interface which allows to // set append limit value for testing and administration purposes. type AppendLimitUser interface { - appendlimit.User + imapbackend.AppendLimitUser // SetMessageLimit sets new value for limit. // nil pointer means no limit. @@ -42,7 +41,7 @@ type AppendLimitUser interface { func imapAcctAppendlimit(be module.Storage, ctx *cli.Context) error { username := ctx.Args().First() if username == "" { - return errors.New("Error: USERNAME is required") + return cli.Exit("Error: USERNAME is required", 2) } u, err := be.GetIMAPAcct(username) @@ -51,7 +50,7 @@ func imapAcctAppendlimit(be module.Storage, ctx *cli.Context) error { } userAL, ok := u.(AppendLimitUser) if !ok { - return errors.New("Error: module.Storage does not support per-user append limit") + return cli.Exit("Error: module.Storage does not support per-user append limit", 2) } if ctx.IsSet("value") { diff --git a/cmd/maddyctl/hash.go b/internal/cli/ctl/hash.go similarity index 53% rename from cmd/maddyctl/hash.go rename to internal/cli/ctl/hash.go index f4ce5f5c9..c43717805 100644 --- a/cmd/maddyctl/hash.go +++ b/internal/cli/ctl/hash.go @@ -16,20 +16,61 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package main +package ctl import ( - "errors" "fmt" "os" "strings" - "github.com/foxcpp/maddy/cmd/maddyctl/clitools" "github.com/foxcpp/maddy/internal/auth/pass_table" - "github.com/urfave/cli" + maddycli "github.com/foxcpp/maddy/internal/cli" + clitools2 "github.com/foxcpp/maddy/internal/cli/clitools" + "github.com/urfave/cli/v2" "golang.org/x/crypto/bcrypt" ) +func init() { + maddycli.AddSubcommand( + &cli.Command{ + Name: "hash", + Usage: "Generate password hashes for use with pass_table", + Action: hashCommand, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Use `PASSWORD instead of reading password from stdin\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", + }, + &cli.StringFlag{ + Name: "hash", + Usage: "Use specified hash algorithm", + Value: "bcrypt", + }, + &cli.IntFlag{ + Name: "bcrypt-cost", + Usage: "Specify bcrypt cost value", + Value: bcrypt.DefaultCost, + }, + &cli.IntFlag{ + Name: "argon2-time", + Usage: "Time factor for Argon2id", + Value: 3, + }, + &cli.IntFlag{ + Name: "argon2-memory", + Usage: "Memory in KiB to use for Argon2id", + Value: 1024, + }, + &cli.IntFlag{ + Name: "argon2-threads", + Usage: "Threads to use for Argon2id", + Value: 1, + }, + }, + }) +} + func hashCommand(ctx *cli.Context) error { hashFunc := ctx.String("hash") if hashFunc == "" { @@ -38,12 +79,12 @@ func hashCommand(ctx *cli.Context) error { hashCompute := pass_table.HashCompute[hashFunc] if hashCompute == nil { - var funcs []string + funcs := make([]string, 0, len(pass_table.HashCompute)) for k := range pass_table.HashCompute { funcs = append(funcs, k) } - return fmt.Errorf("Error: Unknown hash function, available: %s", strings.Join(funcs, ", ")) + return cli.Exit(fmt.Sprintf("Error: Unknown hash function, available: %s", strings.Join(funcs, ", ")), 2) } opts := pass_table.HashOpts{ @@ -54,10 +95,10 @@ func hashCommand(ctx *cli.Context) error { } if ctx.IsSet("bcrypt-cost") { if ctx.Int("bcrypt-cost")> bcrypt.MaxCost { - return errors.New("Error: too big bcrypt cost") + return cli.Exit("Error: too big bcrypt cost", 2) } if ctx.Int("bcrypt-cost") < bcrypt.MinCost { - return errors.New("Error: too small bcrypt cost") + return cli.Exit("Error: too small bcrypt cost", 2) } opts.BcryptCost = ctx.Int("bcrypt-cost") } @@ -65,7 +106,7 @@ func hashCommand(ctx *cli.Context) error { opts.Argon2Memory = uint32(ctx.Int("argon2-memory")) } if ctx.IsSet("argon2-time") { - opts.Argon2Memory = uint32(ctx.Int("argon2-time")) + opts.Argon2Time = uint32(ctx.Int("argon2-time")) } if ctx.IsSet("argon2-threads") { opts.Argon2Threads = uint8(ctx.Int("argon2-threads")) @@ -76,14 +117,17 @@ func hashCommand(ctx *cli.Context) error { pass = ctx.String("password") } else { var err error - pass, err = clitools.ReadPassword("Password") + pass, err = clitools2.ReadPassword("Password") if err != nil { return err } } if pass == "" { - fmt.Fprintln(os.Stderr, "WARNING: This is the hash of empty string") + fmt.Fprintln(os.Stderr, "WARNING: This is the hash of an empty string") + } + if strings.TrimSpace(pass) != pass { + fmt.Fprintln(os.Stderr, "WARNING: There is leading/trailing whitespace in the string") } hash, err := hashCompute(opts, pass) diff --git a/internal/cli/ctl/imap.go b/internal/cli/ctl/imap.go new file mode 100644 index 000000000..ea8f8207b --- /dev/null +++ b/internal/cli/ctl/imap.go @@ -0,0 +1,893 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package ctl + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/emersion/go-imap" + imapsql "github.com/foxcpp/go-imap-sql" + "github.com/foxcpp/maddy/framework/module" + maddycli "github.com/foxcpp/maddy/internal/cli" + clitools2 "github.com/foxcpp/maddy/internal/cli/clitools" + "github.com/urfave/cli/v2" +) + +func init() { + maddycli.AddSubcommand( + &cli.Command{ + Name: "imap-mboxes", + Usage: "IMAP mailboxes (folders) management", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "Show mailboxes of user", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "subscribed", + Aliases: []string{"s"}, + Usage: "List only subscribed mailboxes", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return mboxesList(be, ctx) + }, + }, + { + Name: "create", + Usage: "Create mailbox", + ArgsUsage: "USERNAME NAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.StringFlag{ + Name: "special", + Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return mboxesCreate(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Remove mailbox", + Description: "WARNING: All contents of mailbox will be irrecoverably lost.", + ArgsUsage: "USERNAME MAILBOX", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return mboxesRemove(be, ctx) + }, + }, + { + Name: "rename", + Usage: "Rename mailbox", + Description: "Rename may cause unexpected failures on client-side so be careful.", + ArgsUsage: "USERNAME OLDNAME NEWNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return mboxesRename(be, ctx) + }, + }, + }, + }) + maddycli.AddSubcommand(&cli.Command{ + Name: "imap-msgs", + Usage: "IMAP messages management", + Subcommands: []*cli.Command{ + { + Name: "add", + Usage: "Add message to mailbox", + ArgsUsage: "USERNAME MAILBOX", + Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.StringSliceFlag{ + Name: "flag", + Aliases: []string{"f"}, + Usage: "Add flag to message. Can be specified multiple times", + }, + &cli.TimestampFlag{ + Layout: time.RFC3339, + Name: "date", + Aliases: []string{"d"}, + Usage: "Set internal date value to specified one in ISO 8601 format (2006年01月02日T15:04:05Z07:00)", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsAdd(be, ctx) + }, + }, + { + Name: "add-flags", + Usage: "Add flags to messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Add flags to all messages matched by SEQ.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsFlags(be, ctx) + }, + }, + { + Name: "rem-flags", + Usage: "Remove flags from messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Remove flags from all messages matched by SEQ.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsFlags(be, ctx) + }, + }, + { + Name: "set-flags", + Usage: "Set flags on messages", + ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...", + Description: "Set flags on all messages matched by SEQ.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsFlags(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Remove messages from mailbox", + ArgsUsage: "USERNAME MAILBOX SEQSET", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid,u", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsRemove(be, ctx) + }, + }, + { + Name: "copy", + Usage: "Copy messages between mailboxes", + Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", + ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsCopy(be, ctx) + }, + }, + { + Name: "move", + Usage: "Move messages between mailboxes", + Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.", + ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsMove(be, ctx) + }, + }, + { + Name: "list", + Usage: "List messages in mailbox", + Description: "If SEQSET is specified - only show messages that match it.", + ArgsUsage: "USERNAME MAILBOX [SEQSET]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQSET instead of sequence numbers", + }, + &cli.BoolFlag{ + Name: "full,f", + Aliases: []string{"f"}, + Usage: "Show entire envelope and all server meta-data", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsList(be, ctx) + }, + }, + { + Name: "dump", + Usage: "Dump message body", + Description: "If passed SEQ matches multiple messages - they will be joined.", + ArgsUsage: "USERNAME MAILBOX SEQ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "uid", + Aliases: []string{"u"}, + Usage: "Use UIDs for SEQ instead of sequence numbers", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return msgsDump(be, ctx) + }, + }, + }, + }) +} + +func FormatAddress(addr *imap.Address) string { + return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName) +} + +func FormatAddressList(addrs []*imap.Address) string { + res := make([]string, 0, len(addrs)) + for _, addr := range addrs { + res = append(res, FormatAddress(addr)) + } + return strings.Join(res, ", ") +} + +func mboxesList(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + mboxes, err := u.ListMailboxes(ctx.Bool("subscribed,s")) + if err != nil { + return err + } + + if len(mboxes) == 0 && !ctx.Bool("quiet") { + fmt.Fprintln(os.Stderr, "No mailboxes.") + } + + for _, info := range mboxes { + if len(info.Attributes) != 0 { + fmt.Print(info.Name, "\t", info.Attributes, "\n") + } else { + fmt.Println(info.Name) + } + } + + return nil +} + +func mboxesCreate(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + name := ctx.Args().Get(1) + if name == "" { + return cli.Exit("Error: NAME is required", 2) + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + if ctx.IsSet("special") { + attr := "\\" + strings.Title(ctx.String("special")) //nolint:staticcheck + // (nolint) strings.Title is perfectly fine there since special mailbox tags will never use Unicode. + + suu, ok := u.(SpecialUseUser) + if !ok { + return cli.Exit("Error: storage backend does not support SPECIAL-USE IMAP extension", 2) + } + + return suu.CreateMailboxSpecial(name, attr) + } + + return u.CreateMailbox(name) +} + +func mboxesRemove(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + name := ctx.Args().Get(1) + if name == "" { + return cli.Exit("Error: NAME is required", 2) + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + if !ctx.Bool("yes") { + status, err := u.Status(name, []imap.StatusItem{imap.StatusMessages}) + if err != nil { + return err + } + + if status.Messages != 0 { + fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages) + } + + if !clitools2.Confirmation("Are you sure you want to delete that mailbox?", false) { + return errors.New("Cancelled") + } + } + + return u.DeleteMailbox(name) +} + +func mboxesRename(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + oldName := ctx.Args().Get(1) + if oldName == "" { + return cli.Exit("Error: OLDNAME is required", 2) + } + newName := ctx.Args().Get(2) + if newName == "" { + return cli.Exit("Error: NEWNAME is required", 2) + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + return u.RenameMailbox(oldName, newName) +} + +func msgsAdd(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + name := ctx.Args().Get(1) + if name == "" { + return cli.Exit("Error: MAILBOX is required", 2) + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + flags := ctx.StringSlice("flag") + if flags == nil { + flags = []string{} + } + + date := time.Now() + if ctx.IsSet("date") { + date = *ctx.Timestamp("date") + } + + buf := bytes.Buffer{} + if _, err := io.Copy(&buf, os.Stdin); err != nil { + return err + } + + if buf.Len() == 0 { + return cli.Exit("Error: Empty message, refusing to continue", 2) + } + + status, err := u.Status(name, []imap.StatusItem{imap.StatusUidNext}) + if err != nil { + return err + } + + if err := u.CreateMessage(name, flags, date, &buf, nil); err != nil { + return err + } + + // TODO: Use APPENDUID + fmt.Println(status.UidNext) + + return nil +} + +func msgsRemove(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + name := ctx.Args().Get(1) + if name == "" { + return cli.Exit("Error: MAILBOX is required", 2) + } + seqset := ctx.Args().Get(2) + if seqset == "" { + return cli.Exit("Error: SEQSET is required", 2) + } + + if !ctx.Bool("uid") { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqset) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, mbox, err := u.GetMailbox(name, true, nil) + if err != nil { + return err + } + + if !ctx.Bool("yes") { + if !clitools2.Confirmation("Are you sure you want to delete these messages?", false) { + return errors.New("Cancelled") + } + } + + mboxB := mbox.(*imapsql.Mailbox) + return mboxB.DelMessages(ctx.Bool("uid"), seq) +} + +func msgsCopy(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + srcName := ctx.Args().Get(1) + if srcName == "" { + return cli.Exit("Error: SRCMAILBOX is required", 2) + } + seqset := ctx.Args().Get(2) + if seqset == "" { + return cli.Exit("Error: SEQSET is required", 2) + } + tgtName := ctx.Args().Get(3) + if tgtName == "" { + return cli.Exit("Error: TGTMAILBOX is required", 2) + } + + if !ctx.Bool("uid") { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqset) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, srcMbox, err := u.GetMailbox(srcName, true, nil) + if err != nil { + return err + } + + return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName) +} + +func msgsMove(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + srcName := ctx.Args().Get(1) + if srcName == "" { + return cli.Exit("Error: SRCMAILBOX is required", 2) + } + seqset := ctx.Args().Get(2) + if seqset == "" { + return cli.Exit("Error: SEQSET is required", 2) + } + tgtName := ctx.Args().Get(3) + if tgtName == "" { + return cli.Exit("Error: TGTMAILBOX is required", 2) + } + + if !ctx.Bool("uid") { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqset) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, srcMbox, err := u.GetMailbox(srcName, true, nil) + if err != nil { + return err + } + + moveMbox := srcMbox.(*imapsql.Mailbox) + + return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName) +} + +func msgsList(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + mboxName := ctx.Args().Get(1) + if mboxName == "" { + return cli.Exit("Error: MAILBOX is required", 2) + } + seqset := ctx.Args().Get(2) + uid := ctx.Bool("uid") + if seqset == "" { + seqset = "1:*" + uid = true + } else if !uid { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqset) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, mbox, err := u.GetMailbox(mboxName, true, nil) + if err != nil { + return err + } + + ch := make(chan *imap.Message, 10) + go func() { + err = mbox.ListMessages(uid, seq, []imap.FetchItem{imap.FetchEnvelope, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchFlags, imap.FetchUid}, ch) + }() + + for msg := range ch { + if !ctx.Bool("full") { + fmt.Printf("UID %d: %s - %s\n %v, %v\n\n", msg.Uid, FormatAddressList(msg.Envelope.From), msg.Envelope.Subject, msg.Flags, msg.Envelope.Date) + continue + } + + fmt.Println("- Server meta-data:") + fmt.Println("UID:", msg.Uid) + fmt.Println("Sequence number:", msg.SeqNum) + fmt.Println("Flags:", msg.Flags) + fmt.Println("Body size:", msg.Size) + fmt.Println("Internal date:", msg.InternalDate.Unix(), msg.InternalDate) + fmt.Println("- Envelope:") + if len(msg.Envelope.From) != 0 { + fmt.Println("From:", FormatAddressList(msg.Envelope.From)) + } + if len(msg.Envelope.To) != 0 { + fmt.Println("To:", FormatAddressList(msg.Envelope.To)) + } + if len(msg.Envelope.Cc) != 0 { + fmt.Println("CC:", FormatAddressList(msg.Envelope.Cc)) + } + if len(msg.Envelope.Bcc) != 0 { + fmt.Println("BCC:", FormatAddressList(msg.Envelope.Bcc)) + } + if msg.Envelope.InReplyTo != "" { + fmt.Println("In-Reply-To:", msg.Envelope.InReplyTo) + } + if msg.Envelope.MessageId != "" { + fmt.Println("Message-Id:", msg.Envelope.MessageId) + } + if !msg.Envelope.Date.IsZero() { + fmt.Println("Date:", msg.Envelope.Date.Unix(), msg.Envelope.Date) + } + if msg.Envelope.Subject != "" { + fmt.Println("Subject:", msg.Envelope.Subject) + } + fmt.Println() + } + return err +} + +func msgsDump(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + mboxName := ctx.Args().Get(1) + if mboxName == "" { + return cli.Exit("Error: MAILBOX is required", 2) + } + seqset := ctx.Args().Get(2) + uid := ctx.Bool("uid") + if seqset == "" { + seqset = "1:*" + uid = true + } else if !uid { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqset) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, mbox, err := u.GetMailbox(mboxName, true, nil) + if err != nil { + return err + } + + ch := make(chan *imap.Message, 10) + go func() { + err = mbox.ListMessages(uid, seq, []imap.FetchItem{imap.FetchRFC822}, ch) + }() + + for msg := range ch { + for _, v := range msg.Body { + if _, err := io.Copy(os.Stdout, v); err != nil { + return err + } + } + } + return err +} + +func msgsFlags(be module.Storage, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + name := ctx.Args().Get(1) + if name == "" { + return cli.Exit("Error: MAILBOX is required", 2) + } + seqStr := ctx.Args().Get(2) + if seqStr == "" { + return cli.Exit("Error: SEQ is required", 2) + } + + if !ctx.Bool("uid") { + fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7") + } + + seq, err := imap.ParseSeqSet(seqStr) + if err != nil { + return err + } + + u, err := be.GetIMAPAcct(username) + if err != nil { + return err + } + + _, mbox, err := u.GetMailbox(name, false, nil) + if err != nil { + return err + } + + flags := ctx.Args().Slice()[3:] + if len(flags) == 0 { + return cli.Exit("Error: at least once FLAG is required", 2) + } + + var op imap.FlagsOp + switch ctx.Command.Name { + case "add-flags": + op = imap.AddFlags + case "rem-flags": + op = imap.RemoveFlags + case "set-flags": + op = imap.SetFlags + default: + panic("unknown command: " + ctx.Command.Name) + } + + return mbox.UpdateMessagesFlags(ctx.Bool("uid"), seq, op, true, flags) +} diff --git a/internal/cli/ctl/imapacct.go b/internal/cli/ctl/imapacct.go new file mode 100644 index 000000000..2541a228c --- /dev/null +++ b/internal/cli/ctl/imapacct.go @@ -0,0 +1,301 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package ctl + +import ( + "errors" + "fmt" + "os" + + "github.com/emersion/go-imap" + "github.com/foxcpp/maddy/framework/module" + maddycli "github.com/foxcpp/maddy/internal/cli" + clitools2 "github.com/foxcpp/maddy/internal/cli/clitools" + "github.com/urfave/cli/v2" +) + +func init() { + maddycli.AddSubcommand( + &cli.Command{ + Name: "imap-acct", + Usage: "IMAP storage accounts management", + Description: `These subcommands can be used to list/create/delete IMAP storage +accounts for any storage backend supported by maddy. + +The corresponding storage backend should be configured in maddy.conf and be +defined in a top-level configuration block. By default, the name of that +block should be local_mailboxes but this can be changed using --cfg-block +flag for subcommands. + +Note that in default configuration it is not enough to create an IMAP storage +account to grant server access. Additionally, user credentials should +be created using 'creds' subcommand. +`, + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List storage accounts", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return imapAcctList(be, ctx) + }, + }, + { + Name: "create", + Usage: "Create IMAP storage account", + Description: `In addition to account creation, this command +creates a set of default folder (mailboxes) with special-use attribute set.`, + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "no-specialuse", + Usage: "Do not create special-use folders", + Value: false, + }, + &cli.StringFlag{ + Name: "sent-name", + Usage: "Name of special mailbox for sent messages, use empty string to not create any", + Value: "Sent", + }, + &cli.StringFlag{ + Name: "trash-name", + Usage: "Name of special mailbox for trash, use empty string to not create any", + Value: "Trash", + }, + &cli.StringFlag{ + Name: "junk-name", + Usage: "Name of special mailbox for 'junk' (spam), use empty string to not create any", + Value: "Junk", + }, + &cli.StringFlag{ + Name: "drafts-name", + Usage: "Name of special mailbox for drafts, use empty string to not create any", + Value: "Drafts", + }, + &cli.StringFlag{ + Name: "archive-name", + Usage: "Name of special mailbox for archive, use empty string to not create any", + Value: "Archive", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return imapAcctCreate(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Delete IMAP storage account", + Description: `If IMAP connections are open and using the specified account, +messages access will be killed off immediately though connection will remain open. No cache +or other buffering takes effect.`, + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return imapAcctRemove(be, ctx) + }, + }, + { + Name: "appendlimit", + Usage: "Query or set accounts's APPENDLIMIT value", + Description: `APPENDLIMIT value determines the size of a message that +can be saved into a mailbox using IMAP APPEND command. This does not affect the size +of messages that can be delivered to the mailbox from non-IMAP sources (e.g. SMTP). + +Global APPENDLIMIT value set via server configuration takes precedence over +per-account values configured using this command. + +APPENDLIMIT value (either global or per-account) cannot be larger than +4 GiB due to IMAP protocol limitations. +`, + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_mailboxes", + }, + &cli.IntFlag{ + Name: "value", + Aliases: []string{"v"}, + Usage: "Set APPENDLIMIT to specified value (in bytes)", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openStorage(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return imapAcctAppendlimit(be, ctx) + }, + }, + }, + }) +} + +type SpecialUseUser interface { + CreateMailboxSpecial(name, specialUseAttr string) error +} + +func imapAcctList(be module.Storage, ctx *cli.Context) error { + mbe, ok := be.(module.ManageableStorage) + if !ok { + return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2) + } + + list, err := mbe.ListIMAPAccts() + if err != nil { + return err + } + + if len(list) == 0 && !ctx.Bool("quiet") { + fmt.Fprintln(os.Stderr, "No users.") + } + + for _, user := range list { + fmt.Println(user) + } + return nil +} + +func imapAcctCreate(be module.Storage, ctx *cli.Context) error { + mbe, ok := be.(module.ManageableStorage) + if !ok { + return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2) + } + + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + + if err := mbe.CreateIMAPAcct(username); err != nil { + return err + } + + act, err := mbe.GetIMAPAcct(username) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + suu, ok := act.(SpecialUseUser) + if !ok { + fmt.Fprintf(os.Stderr, "Note: Storage backend does not support SPECIAL-USE IMAP extension") + } + + if ctx.Bool("no-specialuse") { + return nil + } + + createMbox := func(name, specialUseAttr string) error { + if suu == nil { + return act.CreateMailbox(name) + } + return suu.CreateMailboxSpecial(name, specialUseAttr) + } + + if name := ctx.String("sent-name"); name != "" { + if err := createMbox(name, imap.SentAttr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create sent folder: %v", err) + } + } + if name := ctx.String("trash-name"); name != "" { + if err := createMbox(name, imap.TrashAttr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create trash folder: %v", err) + } + } + if name := ctx.String("junk-name"); name != "" { + if err := createMbox(name, imap.JunkAttr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create junk folder: %v", err) + } + } + if name := ctx.String("drafts-name"); name != "" { + if err := createMbox(name, imap.DraftsAttr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create drafts folder: %v", err) + } + } + if name := ctx.String("archive-name"); name != "" { + if err := createMbox(name, imap.ArchiveAttr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create archive folder: %v", err) + } + } + + return nil +} + +func imapAcctRemove(be module.Storage, ctx *cli.Context) error { + mbe, ok := be.(module.ManageableStorage) + if !ok { + return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2) + } + + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + + if !ctx.Bool("yes") { + if !clitools2.Confirmation("Are you sure you want to delete this user account?", false) { + return errors.New("Cancelled") + } + } + + return mbe.DeleteIMAPAcct(username) +} diff --git a/internal/cli/ctl/moduleinit.go b/internal/cli/ctl/moduleinit.go new file mode 100644 index 000000000..f280b181b --- /dev/null +++ b/internal/cli/ctl/moduleinit.go @@ -0,0 +1,179 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package ctl + +import ( + "errors" + "fmt" + "os" + + "github.com/foxcpp/maddy" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/updatepipe" + "github.com/urfave/cli/v2" +) + +func closeIfNeeded(i any) { + if c, ok := i.(container.LifetimeModule); ok { + if err := c.Stop(); err != nil { + log.DefaultLogger.Error("failed to stop module", err) + } + } +} + +type managedStorage struct { + module.ManageableStorage + started bool +} + +func (m *managedStorage) Close() error { + if !m.started { + return nil + } + if lm, ok := m.ManageableStorage.(container.LifetimeModule); ok { + return lm.Stop() + } + return nil +} + +type managedUserDB struct { + module.PlainUserDB + started bool +} + +func (m *managedUserDB) Close() error { + if !m.started { + return nil + } + if lm, ok := m.PlainUserDB.(container.LifetimeModule); ok { + return lm.Stop() + } + return nil +} + +func getCfgBlockModule(ctx *cli.Context) (*container.C, module.Module, error) { + cfgPath := ctx.String("config") + if cfgPath == "" { + return nil, nil, cli.Exit("Error: config is required", 2) + } + + c := container.New() + container.Global = c + + cfg, err := maddy.ReadConfig(cfgPath) + if err != nil { + return nil, nil, cli.Exit(fmt.Sprintf("Error: failed to open config: %v", err), 2) + } + + globals, cfgNodes, err := maddy.ReadGlobals(c, cfg) + if err != nil { + return nil, nil, err + } + + // For CLI management we force-rollback configured logger and consider only + // --log so messages relevant to command execution will go where admin would + // see them. + c.DefaultLogger.Out = log.DefaultLogger.Out + + if err := maddy.InitDirs(c); err != nil { + return nil, nil, err + } + + err = maddy.RegisterModules(c, globals, cfgNodes) + if err != nil { + return nil, nil, err + } + + cfgBlock := ctx.String("cfg-block") + if cfgBlock == "" { + return nil, nil, cli.Exit("Error: cfg-block is required", 2) + } + + mod, err := c.Modules.Get(cfgBlock) + if err != nil { + if errors.Is(err, container.ErrInstanceUnknown) { + return nil, nil, cli.Exit(fmt.Sprintf("Error: unknown configuration block: %s", cfgBlock), 2) + } + return nil, nil, err + } + + return c, mod, nil +} + +func openStorage(ctx *cli.Context) (module.Storage, error) { + _, mod, err := getCfgBlockModule(ctx) + if err != nil { + return nil, err + } + + storage, ok := mod.(module.Storage) + if !ok { + return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not an IMAP storage", ctx.String("cfg-block")), 2) + } + + started := false + if lt, ok := storage.(container.LifetimeModule); ok { + if err := lt.Start(); err != nil { + return nil, err + } + started = true + } + + if updStore, ok := mod.(updatepipe.Backend); ok { + if err := updStore.EnableUpdatePipe(updatepipe.ModePush); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "Failed to initialize update pipe, do not remove messages from mailboxes open by clients: %v\n", err) + } + } else { + fmt.Fprintf(os.Stderr, "No update pipe support, do not remove messages from mailboxes open by clients\n") + } + + if started { + if ms, ok := storage.(module.ManageableStorage); ok { + return &managedStorage{ManageableStorage: ms, started: started}, nil + } + } + return storage, nil +} + +func openUserDB(ctx *cli.Context) (module.PlainUserDB, error) { + _, mod, err := getCfgBlockModule(ctx) + if err != nil { + return nil, err + } + + userDB, ok := mod.(module.PlainUserDB) + if !ok { + return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not a local credentials store", ctx.String("cfg-block")), 2) + } + + started := false + if lt, ok := userDB.(container.LifetimeModule); ok { + if err := lt.Start(); err != nil { + return nil, err + } + started = true + } + + if started { + return &managedUserDB{PlainUserDB: userDB, started: started}, nil + } + return userDB, nil +} diff --git a/internal/cli/ctl/users.go b/internal/cli/ctl/users.go new file mode 100644 index 000000000..13bccd790 --- /dev/null +++ b/internal/cli/ctl/users.go @@ -0,0 +1,246 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package ctl + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/auth/pass_table" + maddycli "github.com/foxcpp/maddy/internal/cli" + clitools2 "github.com/foxcpp/maddy/internal/cli/clitools" + "github.com/urfave/cli/v2" + "golang.org/x/crypto/bcrypt" +) + +func init() { + maddycli.AddSubcommand( + &cli.Command{ + Name: "creds", + Usage: "Local credentials management", + Description: `These commands manipulate credential databases used by +maddy mail server. + +Corresponding credential database should be defined in maddy.conf as +a top-level config block. By default the block name should be local_authdb ( +can be changed using --cfg-block argument for subcommands). + +Note that it is not enough to create user credentials in order to grant +IMAP access - IMAP account should be also created using 'imap-acct create' subcommand. +`, + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List created credentials", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_authdb", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return usersList(be, ctx) + }, + }, + { + Name: "create", + Usage: "Create user account", + Description: `Reads password from stdin. + +If configuration block uses auth.pass_table, then hash algorithm can be configured +using command flags. Otherwise, these options cannot be used. +`, + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_authdb", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Use `PASSWORD instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", + }, + &cli.StringFlag{ + Name: "hash", + Usage: "Use specified hash algorithm. Valid values: " + strings.Join(pass_table.Hashes, ", "), + Value: "bcrypt", + }, + &cli.IntFlag{ + Name: "bcrypt-cost", + Usage: "Specify bcrypt cost value", + Value: bcrypt.DefaultCost, + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return usersCreate(be, ctx) + }, + }, + { + Name: "remove", + Usage: "Delete user account", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_authdb", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Don't ask for confirmation", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return usersRemove(be, ctx) + }, + }, + { + Name: "password", + Usage: "Change account password", + Description: "Reads password from stdin", + ArgsUsage: "USERNAME", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "cfg-block", + Usage: "Module configuration block to use", + EnvVars: []string{"MADDY_CFGBLOCK"}, + Value: "local_authdb", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", + }, + }, + Action: func(ctx *cli.Context) error { + be, err := openUserDB(ctx) + if err != nil { + return err + } + defer closeIfNeeded(be) + return usersPassword(be, ctx) + }, + }, + }, + }) +} + +func usersList(be module.PlainUserDB, ctx *cli.Context) error { + list, err := be.ListUsers() + if err != nil { + return err + } + + if len(list) == 0 && !ctx.Bool("quiet") { + fmt.Fprintln(os.Stderr, "No users.") + } + + for _, user := range list { + fmt.Println(user) + } + return nil +} + +func usersCreate(be module.PlainUserDB, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return cli.Exit("Error: USERNAME is required", 2) + } + + var pass string + if ctx.IsSet("password") { + pass = ctx.String("password") + } else { + var err error + pass, err = clitools2.ReadPassword("Enter password for new user") + if err != nil { + return err + } + } + + if beHash, ok := be.(*pass_table.Auth); ok { + return beHash.CreateUserHash(username, pass, ctx.String("hash"), pass_table.HashOpts{ + BcryptCost: ctx.Int("bcrypt-cost"), + }) + } else if ctx.IsSet("hash") || ctx.IsSet("bcrypt-cost") { + return cli.Exit("Error: --hash cannot be used with non-pass_table credentials DB", 2) + } else { + return be.CreateUser(username, pass) + } +} + +func usersRemove(be module.PlainUserDB, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("error: USERNAME is required") + } + + if !ctx.Bool("yes") { + if !clitools2.Confirmation("Are you sure you want to delete this user account?", false) { + return errors.New("cancelled") + } + } + + return be.DeleteUser(username) +} + +func usersPassword(be module.PlainUserDB, ctx *cli.Context) error { + username := ctx.Args().First() + if username == "" { + return errors.New("error: USERNAME is required") + } + + var pass string + if ctx.IsSet("password") { + pass = ctx.String("password") + } else { + var err error + pass, err = clitools2.ReadPassword("Enter new password") + if err != nil { + return err + } + } + + return be.SetUserPassword(username, pass) +} diff --git a/internal/cli/extflag.go b/internal/cli/extflag.go new file mode 100644 index 000000000..8cfc27c3e --- /dev/null +++ b/internal/cli/extflag.go @@ -0,0 +1,60 @@ +package maddycli + +import ( + "flag" + + "github.com/urfave/cli/v2" +) + +// extFlag implements cli.Flag via standard flag.Flag. +type extFlag struct { + f *flag.Flag +} + +func (e *extFlag) Apply(fs *flag.FlagSet) error { + fs.Var(e.f.Value, e.f.Name, e.f.Usage) + return nil +} + +func (e *extFlag) Names() []string { + return []string{e.f.Name} +} + +func (e *extFlag) IsSet() bool { + return false +} + +func (e *extFlag) String() string { + return cli.FlagStringer(e) +} + +func (e *extFlag) IsVisible() bool { + return true +} + +func (e *extFlag) TakesValue() bool { + return false +} + +func (e *extFlag) GetUsage() string { + return e.f.Usage +} + +func (e *extFlag) GetValue() string { + return e.f.Value.String() +} + +func (e *extFlag) GetDefaultText() string { + return e.f.DefValue +} + +func (e *extFlag) GetEnvVars() []string { + return nil +} + +func mapStdlibFlags(app *cli.App) { + // Modified AllowExtFlags from cli lib with -test.* exception removed. + flag.VisitAll(func(f *flag.Flag) { + app.Flags = append(app.Flags, &extFlag{f}) + }) +} diff --git a/internal/dmarc/evaluate.go b/internal/dmarc/evaluate.go index adae7b8c8..ff978e5a7 100644 --- a/internal/dmarc/evaluate.go +++ b/internal/dmarc/evaluate.go @@ -207,6 +207,10 @@ func isAligned(fromDomain, authDomain string, mode AlignmentMode) bool { return strings.EqualFold(fromDomain, authDomain) } + tld, _ := publicsuffix.PublicSuffix(fromDomain) + if strings.EqualFold(fromDomain, tld) { + return strings.EqualFold(fromDomain, authDomain) + } orgDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(fromDomain) if err != nil { return false diff --git a/internal/dmarc/evaluate_test.go b/internal/dmarc/evaluate_test.go index a40f1f6a1..c44bbbedc 100644 --- a/internal/dmarc/evaluate_test.go +++ b/internal/dmarc/evaluate_test.go @@ -315,24 +315,6 @@ func TestEvaluateAlignment(t *testing.T) { output: authres.ResultFail, }, { // 16 - fromDomain: "example.com", - record: &Record{ - SPFAlignment: dmarc.AlignmentStrict, - }, - results: []authres.Result{ - &authres.SPFResult{ - Value: authres.ResultPass, - From: "", - Helo: "mx.example.com", - }, - &authres.DKIMResult{ - Value: authres.ResultNone, - Domain: "example.org", - }, - }, - output: authres.ResultFail, - }, - { // 17 fromDomain: "example.com", record: &Record{}, results: []authres.Result{ @@ -348,7 +330,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultTempError, }, - { // 18 + { // 17 fromDomain: "example.com", record: &Record{}, results: []authres.Result{ @@ -364,7 +346,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultTempError, }, - { // 19 + { // 18 fromDomain: "example.com", record: &Record{}, results: []authres.Result{ @@ -380,7 +362,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultPass, }, - { // 20 + { // 19 fromDomain: "example.com", record: &Record{}, results: []authres.Result{ @@ -396,7 +378,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultPass, }, - { // 21 + { // 20 fromDomain: "example.org", record: &Record{}, results: []authres.Result{ @@ -416,7 +398,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultPass, }, - { // 22 + { // 21 fromDomain: "example.org", record: &Record{}, results: []authres.Result{ @@ -436,7 +418,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultTempError, }, - { // 23 + { // 22 fromDomain: "example.org", record: &Record{}, results: []authres.Result{ @@ -452,7 +434,7 @@ func TestEvaluateAlignment(t *testing.T) { }, output: authres.ResultFail, }, - { // 21 + { // 23 fromDomain: "sub.example.org", record: &Record{}, results: []authres.Result{ diff --git a/internal/dmarc/verifier_test.go b/internal/dmarc/verifier_test.go index 1adfe996f..911cdae09 100644 --- a/internal/dmarc/verifier_test.go +++ b/internal/dmarc/verifier_test.go @@ -29,13 +29,16 @@ import ( "github.com/emersion/go-message/textproto" "github.com/emersion/go-msgauth/authres" "github.com/foxcpp/go-mockdns" + "github.com/stretchr/testify/require" ) func TestDMARC(t *testing.T) { test := func(zones map[string]mockdns.Zone, hdr string, authres []authres.Result, policyApplied Policy, dmarcRes authres.ResultValue) { t.Helper() v := NewVerifier(&mockdns.Resolver{Zones: zones}) - defer v.Close() + defer func() { + require.NoError(t, v.Close()) + }() hdrParsed, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(hdr))) if err != nil { diff --git a/internal/dsn/dsn.go b/internal/dsn/dsn.go index 59707a7fe..db2e9c3d2 100644 --- a/internal/dsn/dsn.go +++ b/internal/dsn/dsn.go @@ -202,15 +202,16 @@ func GenerateDSN(utf8 bool, envelope Envelope, mtaInfo ReportingMTAInfo, rcptsIn reportHeader.Add("From", envelope.From) reportHeader.Add("Subject", "Undelivered Mail Returned to Sender") - defer partWriter.Close() - if err := writeHumanReadablePart(partWriter, mtaInfo, rcptsInfo); err != nil { return textproto.Header{}, err } if err := writeMachineReadablePart(utf8, partWriter, mtaInfo, rcptsInfo); err != nil { return textproto.Header{}, err } - return reportHeader, writeHeader(utf8, partWriter, failedHeader) + if err := writeHeader(utf8, partWriter, failedHeader); err != nil { + return textproto.Header{}, err + } + return reportHeader, partWriter.Close() } func writeHeader(utf8 bool, w *textproto.MultipartWriter, header textproto.Header) error { diff --git a/internal/endpoint/dovecot_sasld/dovecot_sasl.go b/internal/endpoint/dovecot_sasld/dovecot_sasl.go index feb8864da..215b85082 100644 --- a/internal/endpoint/dovecot_sasld/dovecot_sasl.go +++ b/internal/endpoint/dovecot_sasld/dovecot_sasl.go @@ -28,30 +28,36 @@ import ( "github.com/emersion/go-sasl" dovecotsasl "github.com/foxcpp/go-dovecot-sasl" "github.com/foxcpp/maddy/framework/config" + modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" - "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/framework/resource/netresource" "github.com/foxcpp/maddy/internal/auth" + "github.com/foxcpp/maddy/internal/authz" ) const modName = "dovecot_sasld" type Endpoint struct { addrs []string - log log.Logger + log *log.Logger saslAuth auth.SASLAuth + endpoints []config.Endpoint listenersWg sync.WaitGroup srv *dovecotsasl.Server } -func New(_ string, addrs []string) (module.Module, error) { +func New(c *container.C, _ string, addrs []string) (container.LifetimeModule, error) { + logger := c.DefaultLogger.Sublogger(modName) return &Endpoint{ addrs: addrs, saslAuth: auth.SASLAuth{ - Log: log.Logger{Name: modName + "/saslauth"}, + Log: logger.Sublogger("sasl"), }, - log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + log: logger, }, nil } @@ -63,26 +69,30 @@ func (endp *Endpoint) InstanceName() string { return modName } -func (endp *Endpoint) Init(cfg *config.Map) error { +func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { cfg.Callback("auth", func(m *config.Map, node config.Node) error { return endp.saslAuth.AddProvider(m, node) }) + cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin) + config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.saslAuth.AuthNormalize) + modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap) if _, err := cfg.Process(); err != nil { return err } endp.srv = dovecotsasl.NewServer() + endp.saslAuth.Log.Debug = endp.log.Debug endp.srv.Log = stdlog.New(endp.log, "", 0) for _, mech := range endp.saslAuth.SASLMechanisms() { - mech := mech endp.srv.AddMechanism(mech, mechInfo[mech], func(req *dovecotsasl.AuthReq) sasl.Server { var remoteAddr net.Addr if req.RemoteIP != nil && req.RemotePort != 0 { remoteAddr = &net.TCPAddr{IP: req.RemoteIP, Port: int(req.RemotePort)} } - return endp.saslAuth.CreateSASL(mech, remoteAddr, func(_ string) error { return nil }) + return endp.saslAuth.CreateSASL(mech, remoteAddr, func(_ string, _ auth.ContextData) error { return nil }) }) } @@ -92,12 +102,20 @@ func (endp *Endpoint) Init(cfg *config.Map) error { return fmt.Errorf("%s: %v", modName, err) } - l, err := net.Listen(parsed.Network(), parsed.Address()) + endp.endpoints = append(endp.endpoints, parsed) + } + + return nil +} + +func (endp *Endpoint) Start() error { + for _, addr := range endp.endpoints { + l, err := netresource.Listen(addr.Network(), addr.Address()) if err != nil { return fmt.Errorf("%s: %v", modName, err) } - endp.log.Printf("listening on %v", l.Addr()) + endp.log.Printf("listening on %v", l.Addr()) endp.listenersWg.Add(1) go func() { defer endp.listenersWg.Done() @@ -108,14 +126,14 @@ func (endp *Endpoint) Init(cfg *config.Map) error { } }() } - return nil } -func (endp *Endpoint) Close() error { +func (endp *Endpoint) Stop() error { + defer endp.listenersWg.Wait() return endp.srv.Close() } func init() { - module.RegisterEndpoint(modName, New) + modules.RegisterEndpoint(modName, New) } diff --git a/internal/endpoint/imap/imap.go b/internal/endpoint/imap/imap.go index 3820b6f1f..afc68a796 100644 --- a/internal/endpoint/imap/imap.go +++ b/internal/endpoint/imap/imap.go @@ -19,6 +19,7 @@ along with this program. If not, see . package imap import ( + "context" "crypto/tls" "errors" "fmt" @@ -27,12 +28,8 @@ import ( "sync" "github.com/emersion/go-imap" - appendlimit "github.com/emersion/go-imap-appendlimit" compress "github.com/emersion/go-imap-compress" - move "github.com/emersion/go-imap-move" sortthread "github.com/emersion/go-imap-sortthread" - specialuse "github.com/emersion/go-imap-specialuse" - unselect "github.com/emersion/go-imap-unselect" imapbackend "github.com/emersion/go-imap/backend" imapserver "github.com/emersion/go-imap/server" "github.com/emersion/go-message" @@ -40,44 +37,53 @@ import ( "github.com/emersion/go-sasl" i18nlevel "github.com/foxcpp/go-imap-i18nlevel" namespace "github.com/foxcpp/go-imap-namespace" - "github.com/foxcpp/go-imap-sql/children" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/framework/resource/netresource" "github.com/foxcpp/maddy/internal/auth" + "github.com/foxcpp/maddy/internal/authz" + "github.com/foxcpp/maddy/internal/proxy_protocol" "github.com/foxcpp/maddy/internal/updatepipe" ) type Endpoint struct { - addrs []string - serv *imapserver.Server - listeners []net.Listener - Store module.Storage - - updater imapbackend.BackendUpdater - tlsConfig *tls.Config + addrs []string + serv *imapserver.Server + proxyProtocol *proxy_protocol.ProxyProtocol + Store module.Storage + tlsConfig *tls.Config + + endpoints []config.Endpoint + listeners []net.Listener listenersWg sync.WaitGroup saslAuth auth.SASLAuth - Log log.Logger + storageNormalize authz.NormalizeFunc + storageMap module.Table + + log *log.Logger } -func New(modName string, addrs []string) (module.Module, error) { +func New(c *container.C, modName string, addrs []string) (container.LifetimeModule, error) { + logger := c.DefaultLogger.Sublogger(modName) endp := &Endpoint{ addrs: addrs, - Log: log.Logger{Name: modName}, + log: logger, saslAuth: auth.SASLAuth{ - Log: log.Logger{Name: modName + "/sasl"}, + Log: logger.Sublogger("sasl"), }, } return endp, nil } -func (endp *Endpoint) Init(cfg *config.Map) error { +func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { var ( insecureAuth bool ioDebug bool @@ -87,33 +93,25 @@ func (endp *Endpoint) Init(cfg *config.Map) error { cfg.Callback("auth", func(m *config.Map, node config.Node) error { return endp.saslAuth.AddProvider(m, node) }) + cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin) cfg.Custom("storage", false, true, nil, modconfig.StorageDirective, &endp.Store) cfg.Custom("tls", true, true, nil, tls2.TLSDirective, &endp.tlsConfig) + cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol) cfg.Bool("insecure_auth", false, false, &insecureAuth) cfg.Bool("io_debug", false, false, &ioDebug) cfg.Bool("io_errors", false, false, &ioErrors) - cfg.Bool("debug", true, false, &endp.Log.Debug) + cfg.Bool("debug", true, false, &endp.log.Debug) + config.EnumMapped(cfg, "storage_map_normalize", false, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.storageNormalize) + modconfig.Table(cfg, "storage_map", false, false, nil, &endp.storageMap) + config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.saslAuth.AuthNormalize) + modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap) if _, err := cfg.Process(); err != nil { return err } - var ok bool - endp.updater, ok = endp.Store.(imapbackend.BackendUpdater) - if !ok { - return fmt.Errorf("imap: storage module %T does not implement imapbackend.BackendUpdater", endp.Store) - } - - if updBe, ok := endp.Store.(updatepipe.Backend); ok { - if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil { - endp.Log.Error("failed to initialize updates pipe", err) - } - } - - // Call Updates once at start, some storage backends initialize update - // channel lazily and may not generate updates at all unless it is called. - if endp.updater.Updates() == nil { - return fmt.Errorf("imap: failed to init backend: nil update channel") - } + endp.saslAuth.Log.Debug = endp.log.Debug addresses := make([]config.Endpoint, 0, len(endp.addrs)) for _, addr := range endp.addrs { @@ -121,20 +119,24 @@ func (endp *Endpoint) Init(cfg *config.Map) error { if err != nil { return fmt.Errorf("imap: invalid address: %s", addr) } + if saddr.IsTLS() && endp.tlsConfig == nil { + return errors.New("imap: can't bind on IMAPS endpoint without TLS configuration") + } addresses = append(addresses, saddr) } + endp.endpoints = addresses endp.serv = imapserver.New(endp) endp.serv.AllowInsecureAuth = insecureAuth endp.serv.TLSConfig = endp.tlsConfig if ioErrors { - endp.serv.ErrorLog = &endp.Log + endp.serv.ErrorLog = endp.log } else { - endp.serv.ErrorLog = log.Logger{Out: log.NopOutput{}} + endp.serv.ErrorLog = &log.NopLogger } if ioDebug { - endp.serv.Debug = endp.Log.DebugWriter() - endp.Log.Println("I/O debugging is on! It may leak passwords in logs, be careful!") + endp.serv.Debug = endp.log.DebugWriter() + endp.log.Println("I/O debugging is on! It may leak passwords in logs, be careful!") } if err := endp.enableExtensions(); err != nil { @@ -142,26 +144,49 @@ func (endp *Endpoint) Init(cfg *config.Map) error { } for _, mech := range endp.saslAuth.SASLMechanisms() { - mech := mech endp.serv.EnableAuth(mech, func(c imapserver.Conn) sasl.Server { - return endp.saslAuth.CreateSASL(mech, c.Info().RemoteAddr, func(identity string) error { + return endp.saslAuth.CreateSASL(mech, c.Info().RemoteAddr, func(identity string, data auth.ContextData) error { return endp.openAccount(c, identity) }) }) } - return endp.setupListeners(addresses) + if endp.serv.AllowInsecureAuth { + endp.log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!") + } + if endp.serv.TLSConfig == nil { + endp.log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!") + endp.serv.AllowInsecureAuth = true + } + + return nil +} + +func (endp *Endpoint) Start() error { + if updBe, ok := endp.Store.(updatepipe.Backend); ok { + if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil { + endp.log.Error("failed to initialize updates pipe", err) + } + } + + if err := endp.setupListeners(endp.endpoints); err != nil { + if err := endp.Stop(); err != nil { + endp.log.Error("failed to stop after setupListeners error", err) + } + return err + } + return nil } func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { for _, addr := range addresses { var l net.Listener var err error - l, err = net.Listen(addr.Network(), addr.Address()) + l, err = netresource.Listen(addr.Network(), addr.Address()) if err != nil { return fmt.Errorf("imap: %v", err) } - endp.Log.Printf("listening on %v", addr) + endp.log.Printf("listening on %v", addr) if addr.IsTLS() { if endp.tlsConfig == nil { @@ -170,33 +195,23 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { l = tls.NewListener(l, endp.tlsConfig) } - endp.listeners = append(endp.listeners, l) + if endp.proxyProtocol != nil { + l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.log) + } + endp.listeners = append(endp.listeners, l) endp.listenersWg.Add(1) - addr := addr go func() { + defer endp.listenersWg.Done() if err := endp.serv.Serve(l); err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") { - endp.Log.Printf("imap: failed to serve %s: %s", addr, err) + endp.log.Printf("imap: failed to serve %s: %s", addr, err) } - endp.listenersWg.Done() }() } - if endp.serv.AllowInsecureAuth { - endp.Log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!") - } - if endp.serv.TLSConfig == nil { - endp.Log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!") - endp.serv.AllowInsecureAuth = true - } - return nil } -func (endp *Endpoint) Updates() <-chan imapbackend.Update { - return endp.updater.Updates() -} - func (endp *Endpoint) Name() string { return "imap" } @@ -205,9 +220,11 @@ func (endp *Endpoint) InstanceName() string { return "imap" } -func (endp *Endpoint) Close() error { +func (endp *Endpoint) Stop() error { for _, l := range endp.listeners { - l.Close() + if err := l.Close(); err != nil { + endp.log.Error("failed to close listener", err) + } } if err := endp.serv.Close(); err != nil { return err @@ -216,8 +233,42 @@ func (endp *Endpoint) Close() error { return nil } +func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername string) (string, error) { + saslUsername, err := endp.storageNormalize(saslUsername) + if err != nil { + return "", err + } + + if endp.storageMap == nil { + return saslUsername, nil + } + + mapped, ok, err := endp.storageMap.Lookup(ctx, saslUsername) + if err != nil { + return "", err + } + if !ok { + return "", imapbackend.ErrInvalidCredentials + } + + if saslUsername != mapped { + endp.log.DebugMsg("using mapped username for storage", "username", saslUsername, "mapped_username", mapped) + } + + return mapped, nil +} + func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error { - u, err := endp.Store.GetOrCreateIMAPAcct(identity) + username, err := endp.usernameForStorage(context.TODO(), identity) + if err != nil { + if errors.Is(err, imapbackend.ErrInvalidCredentials) { + return err + } + endp.log.Error("failed to determine storage account name", err, "username", username) + return fmt.Errorf("internal server error") + } + + u, err := endp.Store.GetOrCreateIMAPAcct(username) if err != nil { return err } @@ -228,17 +279,23 @@ func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error { } func (endp *Endpoint) Login(connInfo *imap.ConnInfo, username, password string) (imapbackend.User, error) { + // saslAuth handles AuthMap calling. err := endp.saslAuth.AuthPlain(username, password) if err != nil { - endp.Log.Error("authentication failed", err, "username", username, "src_ip", connInfo.RemoteAddr) + endp.log.Error("authentication failed", err, "username", username, "src_ip", connInfo.RemoteAddr) return nil, imapbackend.ErrInvalidCredentials } - return endp.Store.GetOrCreateIMAPAcct(username) -} + storageUsername, err := endp.usernameForStorage(context.TODO(), username) + if err != nil { + if errors.Is(err, imapbackend.ErrInvalidCredentials) { + return nil, err + } + endp.log.Error("authentication failed due to an internal error", err, "username", username, "src_ip", connInfo.RemoteAddr) + return nil, fmt.Errorf("internal server error") + } -func (endp *Endpoint) EnableChildrenExt() bool { - return endp.Store.(children.Backend).EnableChildrenExt() + return endp.Store.GetOrCreateIMAPAcct(storageUsername) } func (endp *Endpoint) I18NLevel() int { @@ -253,14 +310,6 @@ func (endp *Endpoint) enableExtensions() error { exts := endp.Store.IMAPExtensions() for _, ext := range exts { switch ext { - case "APPENDLIMIT": - endp.serv.Enable(appendlimit.NewExtension()) - case "CHILDREN": - endp.serv.Enable(children.NewExtension()) - case "MOVE": - endp.serv.Enable(move.NewExtension()) - case "SPECIAL-USE": - endp.serv.Enable(specialuse.NewExtension()) case "I18NLEVEL=1", "I18NLEVEL=2": endp.serv.Enable(i18nlevel.NewExtension()) case "SORT": @@ -272,7 +321,6 @@ func (endp *Endpoint) enableExtensions() error { } endp.serv.Enable(compress.NewExtension()) - endp.serv.Enable(unselect.NewExtension()) endp.serv.Enable(namespace.NewExtension()) return nil @@ -288,7 +336,7 @@ func (endp *Endpoint) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm { } func init() { - module.RegisterEndpoint("imap", New) + modules.RegisterEndpoint("imap", New) imap.CharsetReader = message.CharsetReader } diff --git a/internal/endpoint/openmetrics/om.go b/internal/endpoint/openmetrics/om.go index 4e9452430..7251b85fe 100644 --- a/internal/endpoint/openmetrics/om.go +++ b/internal/endpoint/openmetrics/om.go @@ -21,35 +21,37 @@ package openmetrics import ( "errors" "fmt" - "net" "net/http" "sync" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" - "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/framework/resource/netresource" "github.com/prometheus/client_golang/prometheus/promhttp" ) const modName = "openmetrics" type Endpoint struct { - addrs []string - logger log.Logger + addrs []string + endpoints []config.Endpoint + logger *log.Logger listenersWg sync.WaitGroup serv http.Server mux *http.ServeMux } -func New(_ string, args []string) (module.Module, error) { +func New(c *container.C, _ string, args []string) (container.LifetimeModule, error) { return &Endpoint{ addrs: args, - logger: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + logger: c.DefaultLogger.Sublogger(modName), }, nil } -func (e *Endpoint) Init(cfg *config.Map) error { +func (e *Endpoint) Configure(inlineArgs []string, cfg *config.Map) error { cfg.Bool("debug", false, false, &e.logger.Debug) if _, err := cfg.Process(); err != nil { return err @@ -60,7 +62,6 @@ func (e *Endpoint) Init(cfg *config.Map) error { e.serv.Handler = e.mux for _, a := range e.addrs { - a := a endp, err := config.ParseEndpoint(a) if err != nil { return fmt.Errorf("%s: malformed endpoint: %v", modName, err) @@ -68,8 +69,27 @@ func (e *Endpoint) Init(cfg *config.Map) error { if endp.IsTLS() { return fmt.Errorf("%s: TLS is not supported yet", modName) } - l, err := net.Listen(endp.Network(), endp.Address()) + e.endpoints = append(e.endpoints, endp) + } + + return nil +} + +func (e *Endpoint) Name() string { + return modName +} + +func (e *Endpoint) InstanceName() string { + return "" +} + +func (e *Endpoint) Start() error { + for _, endp := range e.endpoints { + l, err := netresource.Listen(endp.Network(), endp.Address()) if err != nil { + if err := e.Stop(); err != nil { + e.logger.Error("failed to stop after failed listen", err) + } return fmt.Errorf("%s: %v", modName, err) } @@ -78,23 +98,15 @@ func (e *Endpoint) Init(cfg *config.Map) error { e.logger.Println("listening on", endp.String()) err := e.serv.Serve(l) if err != nil && !errors.Is(err, http.ErrServerClosed) { - e.logger.Error("serve failed", err, "endpoint", a) + e.logger.Error("serve failed", err, "endpoint", endp) } + e.listenersWg.Done() }() } - return nil } -func (e *Endpoint) Name() string { - return modName -} - -func (e *Endpoint) InstanceName() string { - return "" -} - -func (e *Endpoint) Close() error { +func (e *Endpoint) Stop() error { if err := e.serv.Close(); err != nil { return err } @@ -103,5 +115,5 @@ func (e *Endpoint) Close() error { } func init() { - module.RegisterEndpoint(modName, New) + modules.RegisterEndpoint(modName, New) } diff --git a/internal/endpoint/smtp/metrics.go b/internal/endpoint/smtp/metrics.go index 509241bc4..8c9a71881 100644 --- a/internal/endpoint/smtp/metrics.go +++ b/internal/endpoint/smtp/metrics.go @@ -26,7 +26,7 @@ var ( Namespace: "maddy", Subsystem: "smtp", Name: "started_transactions", - Help: "Amount of SMTP trasanactions started", + Help: "Amount of SMTP transactions started", }, []string{"module"}, ) @@ -35,7 +35,7 @@ var ( Namespace: "maddy", Subsystem: "smtp", Name: "smtp_completed_transactions", - Help: "Amount of SMTP trasanactions successfully completed", + Help: "Amount of SMTP transactions successfully completed", }, []string{"module"}, ) @@ -44,7 +44,7 @@ var ( Namespace: "maddy", Subsystem: "smtp", Name: "aborted_transactions", - Help: "Amount of SMTP trasanactions aborted", + Help: "Amount of SMTP transactions aborted", }, []string{"module"}, ) diff --git a/internal/endpoint/smtp/session.go b/internal/endpoint/smtp/session.go index e55d58b75..c6adfa0c3 100644 --- a/internal/endpoint/smtp/session.go +++ b/internal/endpoint/smtp/session.go @@ -31,6 +31,7 @@ import ( "sync" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/address" "github.com/foxcpp/maddy/framework/buffer" @@ -38,6 +39,7 @@ import ( "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/auth" ) func limitReader(r io.Reader, n int64, err error) *limitedReader { @@ -93,7 +95,19 @@ type Session struct { delivery module.Delivery deliveryErr error - log log.Logger + log *log.Logger +} + +func (s *Session) AuthMechanisms() []string { + return s.endp.saslAuth.SASLMechanisms() +} + +func (s *Session) Auth(mech string) (sasl.Server, error) { + return s.endp.saslAuth.CreateSASL(mech, s.connState.RemoteAddr, func(identity string, data auth.ContextData) error { + s.connState.AuthUser = identity + s.connState.AuthPassword = data.Password + return nil + }), nil } func (s *Session) Reset() { @@ -103,14 +117,19 @@ func (s *Session) Reset() { if s.delivery != nil { s.abort(s.msgCtx) } - s.endp.Log.DebugMsg("reset") + s.endp.log.DebugMsg("reset") } func (s *Session) releaseLimits() { - _, domain, err := address.Split(s.mailFrom) - if err != nil { - return + domain := "" + if s.mailFrom != "" { + var err error + _, domain, err = address.Split(s.mailFrom) + if err != nil { + return + } } + addr, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr) if !ok { addr = &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)} @@ -120,7 +139,7 @@ func (s *Session) releaseLimits() { func (s *Session) abort(ctx context.Context) { if err := s.delivery.Abort(ctx); err != nil { - s.endp.Log.Error("delivery abort failed", err) + s.endp.log.Error("delivery abort failed", err) } s.log.Msg("aborted", "msg_id", s.msgMeta.ID) abortedSMTPTransactions.WithLabelValues(s.endp.name).Inc() @@ -140,34 +159,19 @@ func (s *Session) cleanSession() { } func (s *Session) AuthPlain(username, password string) error { - if s.endp.serv.AuthDisabled { - return smtp.ErrAuthUnsupported - } - // Executed before authentication and session initialization. - if err := s.endp.pipeline.RunEarlyChecks(context.TODO(), &s.connState.ConnectionState); err != nil { + if err := s.endp.pipeline.RunEarlyChecks(context.TODO(), &s.connState); err != nil { return s.endp.wrapErr("", true, "AUTH", err) } + // saslAuth will handle AuthMap and AuthNormalize. err := s.endp.saslAuth.AuthPlain(username, password) if err != nil { - s.endp.Log.Error("authentication failed", err, "username", username, "src_ip", s.connState.RemoteAddr) + s.endp.log.Error("authentication failed", err, "username", username, "src_ip", s.connState.RemoteAddr) failedLogins.WithLabelValues(s.endp.name).Inc() - if exterrors.IsTemporary(err) { - return &smtp.SMTPError{ - Code: 454, - EnhancedCode: smtp.EnhancedCode{4, 7, 0}, - Message: "Temporary authentication failure", - } - } - - return &smtp.SMTPError{ - Code: 535, - EnhancedCode: smtp.EnhancedCode{5, 7, 8}, - Message: "Invalid credentials", - } + return s.endp.authErrorMap(err) } s.connState.AuthUser = username @@ -253,7 +257,7 @@ func (s *Session) startDelivery(ctx context.Context, from string, opts smtp.Mail mailCtx, mailTask := trace.NewTask(s.msgCtx, "MAIL FROM") defer mailTask.End() - delivery, err := s.endp.pipeline.Start(mailCtx, msgMeta, cleanFrom) + delivery, err := s.endp.pipeline.StartDelivery(mailCtx, msgMeta, cleanFrom) if err != nil { s.msgCtx = nil s.msgTask.End() @@ -313,13 +317,13 @@ func (s *Session) fetchRDNSName(ctx context.Context) { return } - reason, misc := exterrors.UnwrapDNSErr(err) - misc["reason"] = reason - if !strings.HasSuffix(reason, "canceled") { + if !errors.Is(err, context.Canceled) { // Often occurs when transaction completes before rDNS lookup and // rDNS name was not actually needed. So do not log cancelation // error if that's the case. + reason, misc := exterrors.UnwrapDNSErr(err) + misc["reason"] = reason s.log.Error("rDNS error", exterrors.WithFields(err, misc), "src_ip", s.connState.RemoteAddr) } s.connState.RDNSName.Set(nil, err) @@ -329,7 +333,7 @@ func (s *Session) fetchRDNSName(ctx context.Context) { s.connState.RDNSName.Set(name, nil) } -func (s *Session) Rcpt(to string) error { +func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { s.msgLock.Lock() defer s.msgLock.Unlock() @@ -357,7 +361,7 @@ func (s *Session) Rcpt(to string) error { rcptCtx, rcptTask := trace.NewTask(s.msgCtx, "RCPT TO") defer rcptTask.End() - if err := s.rcpt(rcptCtx, to); err != nil { + if err := s.rcpt(rcptCtx, to, opts); err != nil { if s.loggedRcptErrors < s.endp.maxLoggedRcptErrors { s.log.Error("RCPT error", err, "rcpt", to, "msg_id", s.msgMeta.ID) s.loggedRcptErrors++ @@ -367,11 +371,11 @@ func (s *Session) Rcpt(to string) error { } return s.endp.wrapErr(s.msgMeta.ID, !s.opts.UTF8, "RCPT", err) } - s.endp.Log.Msg("RCPT ok", "rcpt", to, "msg_id", s.msgMeta.ID) + s.endp.log.Msg("RCPT ok", "rcpt", to, "msg_id", s.msgMeta.ID) return nil } -func (s *Session) rcpt(ctx context.Context, to string) error { +func (s *Session) rcpt(ctx context.Context, to string, opts *smtp.RcptOptions) error { // INTERNATIONALIZATION: Do not permit non-ASCII addresses unless SMTPUTF8 is // used. if !address.IsASCII(to) && !s.opts.UTF8 { @@ -390,7 +394,7 @@ func (s *Session) rcpt(ctx context.Context, to string) error { } } - return s.delivery.AddRcpt(ctx, cleanTo) + return s.delivery.AddRcpt(ctx, cleanTo, *opts) } func (s *Session) Logout() error { @@ -407,11 +411,14 @@ func (s *Session) Logout() error { if s.cancelRDNS != nil { s.cancelRDNS() } + + s.endp.sessionCnt.Add(-1) + return nil } func (s *Session) prepareBody(r io.Reader) (textproto.Header, buffer.Buffer, error) { - limitr := limitReader(r, int64(s.endp.maxHeaderBytes), &exterrors.SMTPError{ + limitr := limitReader(r, s.endp.maxHeaderBytes, &exterrors.SMTPError{ Code: 552, EnhancedCode: exterrors.EnhancedCode{5, 3, 4}, Message: "Message header size exceeds limit", @@ -603,7 +610,7 @@ func (endp *Endpoint) wrapErr(msgId string, mangleUTF8 bool, command string, err } if smtpErr, ok := err.(*smtp.SMTPError); ok { - endp.Log.Printf("plain SMTP error returned, this is deprecated") + endp.log.Printf("plain SMTP error returned, this is deprecated") res.Code = smtpErr.Code res.EnhancedCode = smtpErr.EnhancedCode res.Message = smtpErr.Message diff --git a/internal/endpoint/smtp/smtp.go b/internal/endpoint/smtp/smtp.go index 6d058e474..acfcca3bc 100644 --- a/internal/endpoint/smtp/smtp.go +++ b/internal/endpoint/smtp/smtp.go @@ -24,39 +24,46 @@ import ( "crypto/tls" "fmt" "io" - "math/rand" "net" "os" "path/filepath" "strings" "sync" + "sync/atomic" "time" - "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" + "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/future" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/framework/resource/netresource" "github.com/foxcpp/maddy/internal/auth" + "github.com/foxcpp/maddy/internal/authz" "github.com/foxcpp/maddy/internal/limits" "github.com/foxcpp/maddy/internal/msgpipeline" + "github.com/foxcpp/maddy/internal/proxy_protocol" "golang.org/x/net/idna" ) type Endpoint struct { - saslAuth auth.SASLAuth - serv *smtp.Server - name string - addrs []string - listeners []net.Listener - pipeline *msgpipeline.MsgPipeline - resolver dns.Resolver - limits *limits.Group + saslAuth auth.SASLAuth + serv *smtp.Server + name string + addrs []string + endpoints []config.Endpoint + listeners []net.Listener + proxyProtocol *proxy_protocol.ProxyProtocol + pipeline *msgpipeline.MsgPipeline + resolver dns.Resolver + limits *limits.Group buffer func(r io.Reader) (buffer.Buffer, error) @@ -66,11 +73,14 @@ type Endpoint struct { deferServerReject bool maxLoggedRcptErrors int maxReceived int - maxHeaderBytes int + maxHeaderBytes int64 + + sessionCnt atomic.Int32 + shutdownTimeout time.Duration listenersWg sync.WaitGroup - Log log.Logger + log *log.Logger } func (endp *Endpoint) Name() string { @@ -81,7 +91,8 @@ func (endp *Endpoint) InstanceName() string { return endp.name } -func New(modName string, addrs []string) (module.Module, error) { +func New(c *container.C, modName string, addrs []string) (container.LifetimeModule, error) { + logger := c.DefaultLogger.Sublogger(modName) endp := &Endpoint{ name: modName, addrs: addrs, @@ -89,17 +100,17 @@ func New(modName string, addrs []string) (module.Module, error) { lmtp: modName == "lmtp", resolver: dns.DefaultResolver(), buffer: buffer.BufferInMemory, - Log: log.Logger{Name: modName}, + log: logger, saslAuth: auth.SASLAuth{ - Log: log.Logger{Name: modName + "/sasl"}, + Log: logger.Sublogger("sasl"), }, } return endp, nil } -func (endp *Endpoint) Init(cfg *config.Map) error { +func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { endp.serv = smtp.NewServer(endp) - endp.serv.ErrorLog = endp.Log + endp.serv.ErrorLog = endp.log endp.serv.LMTP = endp.lmtp endp.serv.EnableSMTPUTF8 = true endp.serv.EnableREQUIRETLS = true @@ -116,13 +127,7 @@ func (endp *Endpoint) Init(cfg *config.Map) error { addresses = append(addresses, saddr) } - - if err := endp.setupListeners(addresses); err != nil { - for _, l := range endp.listeners { - l.Close() - } - return err - } + endp.endpoints = addresses allLocal := true for _, addr := range addresses { @@ -132,11 +137,11 @@ func (endp *Endpoint) Init(cfg *config.Map) error { } if endp.serv.AllowInsecureAuth && !allLocal { - endp.Log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!") + endp.log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!") } if endp.serv.TLSConfig == nil { if !allLocal { - endp.Log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!") + endp.log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!") } endp.serv.AllowInsecureAuth = true @@ -241,9 +246,14 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error { cfg.Callback("auth", func(m *config.Map, node config.Node) error { return endp.saslAuth.AddProvider(m, node) }) + cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin) cfg.String("hostname", true, true, "", &hostname) + config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, + &endp.saslAuth.AuthNormalize) + modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap) cfg.Duration("write_timeout", false, false, 1*time.Minute, &endp.serv.WriteTimeout) cfg.Duration("read_timeout", false, false, 10*time.Minute, &endp.serv.ReadTimeout) + cfg.Duration("shutdown_timeout", false, false, 3*time.Minute, &endp.shutdownTimeout) cfg.DataSize("max_message_size", false, false, 32*1024*1024, &endp.serv.MaxMessageBytes) cfg.DataSize("max_header_size", false, false, 1*1024*1024, &endp.maxHeaderBytes) cfg.Int("max_recipients", false, false, 20000, &endp.serv.MaxRecipients) @@ -256,14 +266,15 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error { return autoBufferMode(1*1024*1024 /* 1 MiB */, path), nil }, bufferModeDirective, &endp.buffer) cfg.Custom("tls", true, endp.name != "lmtp", nil, tls2.TLSDirective, &endp.serv.TLSConfig) + cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol) cfg.Bool("insecure_auth", endp.name == "lmtp", false, &endp.serv.AllowInsecureAuth) cfg.Int("smtp_max_line_length", false, false, 4000, &endp.serv.MaxLineLength) cfg.Bool("io_debug", false, false, &ioDebug) - cfg.Bool("debug", true, false, &endp.Log.Debug) + cfg.Bool("debug", true, false, &endp.log.Debug) cfg.Bool("defer_sender_reject", false, true, &endp.deferServerReject) cfg.Int("max_logged_rcpt_errors", false, false, 5, &endp.maxLoggedRcptErrors) cfg.Custom("limits", false, false, func() (interface{}, error) { - return &limits.Group{}, nil + return limits.Empty(endp.log.Sublogger("limits")), nil }, func(cfg *config.Map, n config.Node) (interface{}, error) { var g *limits.Group if err := modconfig.GroupFromNode("limits", n.Args, n, cfg.Globals, &g); err != nil { @@ -277,6 +288,9 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error { return err } + endp.saslAuth.Log.Debug = endp.log.Debug + endp.saslAuth.ErrorMap = endp.authErrorMap + // INTERNATIONALIZATION: See RFC 6531 Section 3.3. endp.serv.Domain, err = idna.ToASCII(hostname) if err != nil { @@ -289,55 +303,59 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error { } endp.pipeline.Hostname = endp.serv.Domain endp.pipeline.Resolver = endp.resolver - endp.pipeline.Log = log.Logger{Name: "smtp/pipeline", Debug: endp.Log.Debug} + endp.pipeline.Log = endp.log.Sublogger("pipeline") endp.pipeline.FirstPipeline = true - endp.serv.AuthDisabled = len(endp.saslAuth.SASLMechanisms()) == 0 if endp.submission { endp.authAlwaysRequired = true if len(endp.saslAuth.SASLMechanisms()) == 0 { return fmt.Errorf("%s: auth. provider must be set for submission endpoint", endp.name) } } - for _, mech := range endp.saslAuth.SASLMechanisms() { - // The code below lacks handling to set AuthPassword. Don't - // override sasl.Plain handler so Login() will be called as usual. - if mech == sasl.Plain { - continue - } - mech := mech + if ioDebug { + endp.serv.Debug = endp.log.DebugWriter() + endp.log.Println("I/O debugging is on! It may leak passwords in logs, be careful!") + } - endp.serv.EnableAuth(mech, func(c *smtp.Conn) sasl.Server { - state := c.State() - if err := endp.pipeline.RunEarlyChecks(context.TODO(), &state); err != nil { - return auth.FailingSASLServ{Err: endp.wrapErr("", true, "AUTH", err)} - } + return nil +} - return endp.saslAuth.CreateSASL(mech, state.RemoteAddr, func(id string) error { - c.Session().(*Session).connState.AuthUser = id - return nil - }) - }) +func (endp *Endpoint) Start() error { + if err := endp.setupListeners(endp.endpoints); err != nil { + if err := endp.Stop(); err != nil { + endp.log.Error("failed to Stop after setupListeners fail", err) + } + return err } + return nil +} - if ioDebug { - endp.serv.Debug = endp.Log.DebugWriter() - endp.Log.Println("I/O debugging is on! It may leak passwords in logs, be careful!") +func (endp *Endpoint) authErrorMap(err error) error { + if exterrors.IsTemporary(err) { + return &smtp.SMTPError{ + Code: 454, + EnhancedCode: smtp.EnhancedCode{4, 7, 0}, + Message: "Temporary authentication failure", + } } - return nil + return &smtp.SMTPError{ + Code: 535, + EnhancedCode: smtp.EnhancedCode{5, 7, 8}, + Message: "Invalid credentials", + } } func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { for _, addr := range addresses { var l net.Listener var err error - l, err = net.Listen(addr.Network(), addr.Address()) + l, err = netresource.Listen(addr.Network(), addr.Address()) if err != nil { return fmt.Errorf("%s: %w", endp.name, err) } - endp.Log.Printf("listening on %v", addr) + endp.log.Printf("listening on %v", addr) if addr.IsTLS() { if endp.serv.TLSConfig == nil { @@ -346,13 +364,16 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { l = tls.NewListener(l, endp.serv.TLSConfig) } + if endp.proxyProtocol != nil { + l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.log.Sublogger("proxy")) + } + endp.listeners = append(endp.listeners, l) endp.listenersWg.Add(1) - addr := addr go func() { if err := endp.serv.Serve(l); err != nil { - endp.Log.Printf("failed to serve %s: %s", addr, err) + endp.log.Printf("failed to serve %s: %s", addr, err) } endp.listenersWg.Done() }() @@ -361,31 +382,49 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error { return nil } -func (endp *Endpoint) NewSession(state smtp.ConnectionState, _ string) (smtp.Session, error) { +func (endp *Endpoint) NewSession(conn *smtp.Conn) (smtp.Session, error) { + sess := endp.newSession(conn) + // Executed before authentication and session initialization. - if err := endp.pipeline.RunEarlyChecks(context.TODO(), &state); err != nil { + if err := endp.pipeline.RunEarlyChecks(context.TODO(), &sess.connState); err != nil { + if err := sess.Logout(); err != nil { + endp.log.Error("early checks logout failed", err) + } return nil, endp.wrapErr("", true, "EHLO", err) } - return endp.newSession(&state), nil + endp.sessionCnt.Add(1) + + return sess, nil } -func (endp *Endpoint) newSession(state *smtp.ConnectionState) smtp.Session { +func (endp *Endpoint) newSession(conn *smtp.Conn) *Session { s := &Session{ - endp: endp, - log: endp.Log, - connState: module.ConnState{ - ConnectionState: *state, - }, + endp: endp, + log: endp.log, sessionCtx: context.Background(), } + // Used in tests. + if conn == nil { + return s + } + + s.connState = module.ConnState{ + Hostname: conn.Hostname(), + LocalAddr: conn.Conn().LocalAddr(), + RemoteAddr: conn.Conn().RemoteAddr(), + } + if tlsState, ok := conn.TLSConnectionState(); ok { + s.connState.TLS = tlsState + } + if endp.serv.LMTP { s.connState.Proto = "LMTP" } else { - // Check if TLS connection state struct is poplated. + // Check if TLS connection conn struct is poplated. // If it is - we are ssing TLS. - if state.TLS.HandshakeComplete { + if s.connState.TLS.HandshakeComplete { s.connState.Proto = "ESMTPS" } else { s.connState.Proto = "ESMTP" @@ -402,16 +441,25 @@ func (endp *Endpoint) newSession(state *smtp.ConnectionState) smtp.Session { return s } -func (endp *Endpoint) Close() error { - endp.serv.Close() +func (endp *Endpoint) ConnectionCount() int { + return int(endp.sessionCnt.Load()) +} + +func (endp *Endpoint) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), endp.shutdownTimeout) + defer cancel() + + if err := endp.serv.Shutdown(ctx); err != nil { + return err + } + endp.listenersWg.Wait() + return nil } func init() { - module.RegisterEndpoint("smtp", New) - module.RegisterEndpoint("submission", New) - module.RegisterEndpoint("lmtp", New) - - rand.Seed(time.Now().UnixNano()) + modules.RegisterEndpoint("smtp", New) + modules.RegisterEndpoint("submission", New) + modules.RegisterEndpoint("lmtp", New) } diff --git a/internal/endpoint/smtp/smtp_test.go b/internal/endpoint/smtp/smtp_test.go index b825d155f..485c7e9ea 100644 --- a/internal/endpoint/smtp/smtp_test.go +++ b/internal/endpoint/smtp/smtp_test.go @@ -32,11 +32,15 @@ import ( "github.com/emersion/go-smtp" "github.com/foxcpp/go-mockdns" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/auth" "github.com/foxcpp/maddy/internal/msgpipeline" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var testPort string @@ -49,7 +53,7 @@ const testMsg = "From: \r\n" + func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt module.DeliveryTarget, checks []module.Check, cfg []config.Node) *Endpoint { t.Helper() - mod, err := New(modName, []string{"tcp://127.0.0.1:" + testPort}) + mod, err := New(container.New(), modName, []string{"tcp://127.0.0.1:" + testPort}) if err != nil { t.Fatal(err) } @@ -65,7 +69,7 @@ func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt mo }, }, } - endp.Log = testutils.Logger(t, "smtp") + endp.log = testutils.Logger(t, "smtp") cfg = append(cfg, config.Node{ @@ -89,7 +93,7 @@ func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt mo }) } - err = endp.Init(config.NewMap(nil, config.Node{ + err = endp.Configure(nil, config.NewMap(nil, config.Node{ Children: cfg, })) if err != nil { @@ -107,6 +111,10 @@ func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt mo endp.pipeline.FirstPipeline = true endp.pipeline.Log = testutils.Logger(t, "smtp/pipeline") + if err := endp.Start(); err != nil { + t.Fatal(err) + } + return endp } @@ -124,7 +132,7 @@ func submitMsgOpts(t *testing.T, cl *smtp.Client, from string, rcpts []string, o return err } for _, rcpt := range rcpts { - if err := cl.Rcpt(rcpt); err != nil { + if err := cl.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil { return err } } @@ -142,13 +150,17 @@ func submitMsgOpts(t *testing.T, cl *smtp.Client, from string, rcpts []string, o func TestSMTPDelivery(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg) if err != nil { @@ -180,7 +192,9 @@ func TestSMTPDelivery(t *testing.T) { func TestSMTPDelivery_rDNSError(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{ Err: &net.DNSError{ @@ -195,7 +209,9 @@ func TestSMTPDelivery_rDNSError(t *testing.T) { if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg) if err != nil { @@ -224,13 +240,17 @@ func TestSMTPDelivery_EarlyCheck_Fail(t *testing.T) { }, }, }, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = cl.Mail("sender@example.org", nil) if err == nil { @@ -264,13 +284,17 @@ func TestSMTPDeliver_CheckError(t *testing.T) { }, }, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = cl.Mail("sender@example.org", nil) if err == nil { @@ -303,13 +327,17 @@ func TestSMTPDeliver_CheckError_Deferred(t *testing.T) { }, }, nil) endp.deferServerReject = true - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = cl.Mail("sender@example.org", nil) if err != nil { @@ -334,21 +362,25 @@ func TestSMTPDeliver_CheckError_Deferred(t *testing.T) { } } - checkErr(cl.Rcpt("test1@example.org")) - checkErr(cl.Rcpt("test1@example.org")) - checkErr(cl.Rcpt("test2@example.org")) + checkErr(cl.Rcpt("test1@example.org", &smtp.RcptOptions{})) + checkErr(cl.Rcpt("test1@example.org", &smtp.RcptOptions{})) + checkErr(cl.Rcpt("test2@example.org", &smtp.RcptOptions{})) } func TestSMTPDelivery_Multi(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "sender1@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg) if err != nil { @@ -380,13 +412,17 @@ func TestSMTPDelivery_Multi(t *testing.T) { func TestSMTPDelivery_AbortData(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + _ = cl.Close() + }() if err := cl.Hello("mx.example.org"); err != nil { t.Fatal(err) @@ -394,7 +430,7 @@ func TestSMTPDelivery_AbortData(t *testing.T) { if err := cl.Mail("sender@example.org", nil); err != nil { t.Fatal(err) } - if err := cl.Rcpt("test@example.com"); err != nil { + if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil { t.Fatal(err) } data, err := cl.Data() @@ -406,7 +442,7 @@ func TestSMTPDelivery_AbortData(t *testing.T) { } // Then.. Suddenly, close the connection without sending the final dot. - cl.Close() + require.NoError(t, cl.Close()) time.Sleep(250 * time.Millisecond) @@ -418,13 +454,17 @@ func TestSMTPDelivery_AbortData(t *testing.T) { func TestSMTPDelivery_EmptyMessage(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Hello("mx.example.org"); err != nil { t.Fatal(err) @@ -432,7 +472,7 @@ func TestSMTPDelivery_EmptyMessage(t *testing.T) { if err := cl.Mail("sender@example.org", nil); err != nil { t.Fatal(err) } - if err := cl.Rcpt("test@example.com"); err != nil { + if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil { t.Fatal(err) } data, err := cl.Data() @@ -457,13 +497,17 @@ func TestSMTPDelivery_EmptyMessage(t *testing.T) { func TestSMTPDelivery_AbortLogout(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + _ = cl.Close() + }() if err := cl.Hello("mx.example.org"); err != nil { t.Fatal(err) @@ -471,12 +515,12 @@ func TestSMTPDelivery_AbortLogout(t *testing.T) { if err := cl.Mail("sender@example.org", nil); err != nil { t.Fatal(err) } - if err := cl.Rcpt("test@example.com"); err != nil { + if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil { t.Fatal(err) } // Then.. Suddenly, close the connection. - cl.Close() + require.NoError(t, cl.Close()) time.Sleep(250 * time.Millisecond) @@ -488,18 +532,22 @@ func TestSMTPDelivery_AbortLogout(t *testing.T) { func TestSMTPDelivery_Reset(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Mail("from-garbage@example.org", nil); err != nil { t.Fatal(err) } - if err := cl.Rcpt("to-garbage@example.org"); err != nil { + if err := cl.Rcpt("to-garbage@example.org", &smtp.RcptOptions{}); err != nil { t.Fatal(err) } if err := cl.Reset(); err != nil { @@ -522,14 +570,18 @@ func TestSMTPDelivery_Reset(t *testing.T) { func TestSMTPDelivery_SubmissionAuthRequire(t *testing.T) { tgt := testutils.Target{} - endp := testEndpoint(t, "submission", &module.Dummy{}, &tgt, nil, nil) - defer endp.Close() + endp := testEndpoint(t, "submission", &modules.Dummy{}, &tgt, nil, nil) + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Mail("from-garbage@example.org", nil); err == nil { t.Fatal("Expected an error, got none") @@ -538,14 +590,18 @@ func TestSMTPDelivery_SubmissionAuthRequire(t *testing.T) { func TestSMTPDelivery_SubmissionAuthOK(t *testing.T) { tgt := testutils.Target{} - endp := testEndpoint(t, "submission", &module.Dummy{}, &tgt, nil, nil) - defer endp.Close() + endp := testEndpoint(t, "submission", &modules.Dummy{}, &tgt, nil, nil) + defer func() { + assert.NoError(t, endp.Stop()) + }() cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Auth(sasl.NewPlainClient("", "user", "password")); err != nil { t.Fatal(err) @@ -583,7 +639,6 @@ func TestMain(m *testing.M) { flag.Parse() if *remoteSmtpPort == "random" { - rand.Seed(time.Now().UnixNano()) *remoteSmtpPort = strconv.Itoa(rand.Intn(65536-10000) + 10000) } diff --git a/internal/endpoint/smtp/smtputf8_test.go b/internal/endpoint/smtp/smtputf8_test.go index b3f570158..684f07c47 100644 --- a/internal/endpoint/smtp/smtputf8_test.go +++ b/internal/endpoint/smtp/smtputf8_test.go @@ -27,6 +27,8 @@ import ( "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSMTPUTF8_MangleStatusMessage(t *testing.T) { @@ -43,14 +45,18 @@ func TestSMTPUTF8_MangleStatusMessage(t *testing.T) { }, }, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = cl.Mail("sender@example.org", nil) if err == nil { @@ -73,14 +79,18 @@ func TestSMTP_RejectNonASCIIFrom(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + require.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "ѣ@example.org", []string{"rcpt@example.com"}, testMsg) @@ -100,14 +110,18 @@ func TestSMTPUTF8_NormalizeCaseFoldFrom(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsgOpts(t, cl, "foo@E\u0301.example.org", []string{"rcpt@example.com"}, &smtp.MailOptions{ UTF8: true, @@ -127,14 +141,18 @@ func TestSMTP_RejectNonASCIIRcpt(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "x@example.org", []string{"ѣ@example.org"}, testMsg) @@ -154,14 +172,18 @@ func TestSMTPUTF8_NormalizeCaseFoldRcpt(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsgOpts(t, cl, "x@example.org", []string{"foo@E\u0301.example.org"}, &smtp.MailOptions{ UTF8: true, @@ -191,14 +213,18 @@ func TestSMTPUTF8_NoMangleStatusMessage(t *testing.T) { }, }, nil) endp.deferServerReject = false - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = cl.Mail("sender@example.org", &smtp.MailOptions{ UTF8: true, @@ -222,14 +248,18 @@ func TestSMTPUTF8_NoMangleStatusMessage(t *testing.T) { func TestSMTPUTF8_Received_EHLO_ALabel(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Hello("凱凱.invalid"); err != nil { t.Fatal(err) @@ -256,7 +286,9 @@ func TestSMTPUTF8_Received_EHLO_ALabel(t *testing.T) { func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{ @@ -267,7 +299,9 @@ func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) { if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg) if err != nil { @@ -290,7 +324,9 @@ func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) { func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{ @@ -301,7 +337,9 @@ func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) { if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() err = submitMsgOpts(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, &smtp.MailOptions{ UTF8: true, @@ -326,14 +364,18 @@ func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) { func TestSMTPUTF8_Received_EHLO_ULabel(t *testing.T) { tgt := testutils.Target{} endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil) - defer endp.Close() + defer func() { + assert.NoError(t, endp.Stop()) + }() defer testutils.WaitForConnsClose(t, endp.serv) cl, err := smtp.Dial("127.0.0.1:" + testPort) if err != nil { t.Fatal(err) } - defer cl.Close() + defer func() { + assert.NoError(t, cl.Close()) + }() if err := cl.Hello("凱凱.invalid"); err != nil { t.Fatal(err) diff --git a/internal/endpoint/smtp/submission_test.go b/internal/endpoint/smtp/submission_test.go index 1c616b02d..911d4328a 100644 --- a/internal/endpoint/smtp/submission_test.go +++ b/internal/endpoint/smtp/submission_test.go @@ -26,6 +26,8 @@ import ( "github.com/emersion/go-message/textproto" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/stretchr/testify/assert" ) func init() { @@ -49,17 +51,17 @@ func TestSubmissionPrepare(t *testing.T) { } } - endp := testEndpoint(t, "submission", &module.Dummy{}, &module.Dummy{}, nil, nil) + endp := testEndpoint(t, "submission", &modules.Dummy{}, &modules.Dummy{}, nil, nil) defer func() { // Synchronize the endpoint initialization. // Otherwise Close will race with Serve called by setupListeners. cl, _ := smtp.Dial("127.0.0.1:" + testPort) - cl.Close() + assert.NoError(t, cl.Close()) - endp.Close() + assert.NoError(t, endp.Stop()) }() - session, err := endp.NewSession(smtp.ConnectionState{}, "") + session, err := endp.NewSession(nil) if err != nil { t.Fatal(err) } diff --git a/internal/imap_filter/command/command.go b/internal/imap_filter/command/command.go index 730a479b5..9961c4ea6 100644 --- a/internal/imap_filter/command/command.go +++ b/internal/imap_filter/command/command.go @@ -32,8 +32,10 @@ import ( "github.com/emersion/go-message/textproto" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) const modName = "imap.filter.command" @@ -42,14 +44,14 @@ var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`) type Check struct { instName string - log log.Logger + log *log.Logger cmd string cmdArgs []string } -func (c *Check) IMAPFilter(accountName string, msgMeta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) { - cmd, args := c.expandCommand(msgMeta, accountName) +func (c *Check) IMAPFilter(accountName string, rcptTo string, msgMeta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) { + cmd, args := c.expandCommand(msgMeta, accountName, rcptTo, hdr) var buf bytes.Buffer _ = textproto.WriteHeader(&buf, hdr) @@ -61,20 +63,13 @@ func (c *Check) IMAPFilter(accountName string, msgMeta *module.MsgMetadata, hdr return c.run(cmd, args, io.MultiReader(bytes.NewReader(buf.Bytes()), bR)) } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - c := &Check{ +func New(c *container.C, _, instName string) (module.Module, error) { + chk := &Check{ instName: instName, - log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), } - if len(inlineArgs) == 0 { - return nil, errors.New("command: at least one argument is required (command name)") - } - - c.cmd = inlineArgs[0] - c.cmdArgs = inlineArgs[1:] - - return c, nil + return chk, nil } func (c *Check) Name() string { @@ -85,7 +80,14 @@ func (c *Check) InstanceName() string { return c.instName } -func (c *Check) Init(cfg *config.Map) error { +func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) == 0 { + return errors.New("command: at least one argument is required (command name)") + } + + c.cmd = inlineArgs[0] + c.cmdArgs = inlineArgs[1:] + // Check whether the inline argument command is usable. if _, err := exec.LookPath(c.cmd); err != nil { return fmt.Errorf("command: %w", err) @@ -95,7 +97,7 @@ func (c *Check) Init(cfg *config.Map) error { return err } -func (c *Check) expandCommand(msgMeta *module.MsgMetadata, accountName string) (string, []string) { +func (c *Check) expandCommand(msgMeta *module.MsgMetadata, accountName string, rcptTo string, hdr textproto.Header) (string, []string) { expArgs := make([]string, len(c.cmdArgs)) for i, arg := range c.cmdArgs { @@ -136,6 +138,16 @@ func (c *Check) expandCommand(msgMeta *module.MsgMetadata, accountName string) ( return msgMeta.ID case "{sender}": return msgMeta.OriginalFrom + case "{rcpt_to}": + return rcptTo + case "{original_rcpt_to}": + oldestOriginalRcpt := rcptTo + for originalRcpt, ok := rcptTo, true; ok; originalRcpt, ok = msgMeta.OriginalRcpts[originalRcpt] { + oldestOriginalRcpt = originalRcpt + } + return oldestOriginalRcpt + case "{subject}": + return hdr.Get("Subject") case "{account_name}": return accountName } @@ -193,5 +205,5 @@ func (c *Check) run(cmdName string, args []string, stdin io.Reader) (string, []s } func init() { - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/imap_filter/group.go b/internal/imap_filter/group.go index 2b1857926..fd818f868 100644 --- a/internal/imap_filter/group.go +++ b/internal/imap_filter/group.go @@ -23,8 +23,10 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // Group wraps multiple modifiers and runs them serially. @@ -34,17 +36,17 @@ import ( type Group struct { instName string Filters []module.IMAPFilter - log log.Logger + log *log.Logger } -func NewGroup(_, instName string, _, _ []string) (module.Module, error) { +func NewGroup(c *container.C, modName, instName string) (module.Module, error) { return &Group{ instName: instName, - log: log.Logger{Name: "imap_filters", Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (g *Group) IMAPFilter(accountName string, meta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) { +func (g *Group) IMAPFilter(accountName string, rcptTo string, meta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) { if g == nil { return "", nil, nil } @@ -53,7 +55,7 @@ func (g *Group) IMAPFilter(accountName string, meta *module.MsgMetadata, hdr tex finalFlags = make([]string, 0, len(g.Filters)) ) for _, f := range g.Filters { - folder, flags, err := f.IMAPFilter(accountName, meta, hdr, body) + folder, flags, err := f.IMAPFilter(accountName, rcptTo, meta, hdr, body) if err != nil { g.log.Error("IMAP filter failed", err) continue @@ -66,7 +68,7 @@ func (g *Group) IMAPFilter(accountName string, meta *module.MsgMetadata, hdr tex return finalFolder, finalFlags, nil } -func (g *Group) Init(cfg *config.Map) error { +func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error { for _, node := range cfg.Block.Children { mod, err := modconfig.IMAPFilter(cfg.Globals, append([]string{node.Name}, node.Args...), node) if err != nil { @@ -88,5 +90,5 @@ func (g *Group) InstanceName() string { } func init() { - module.Register("imap_filters", NewGroup) + modules.Register("imap_filters", NewGroup) } diff --git a/internal/libdns/acmedns.go b/internal/libdns/acmedns.go new file mode 100644 index 000000000..ec829f885 --- /dev/null +++ b/internal/libdns/acmedns.go @@ -0,0 +1,30 @@ +//go:build libdns_acmedns || libdns_all +// +build libdns_acmedns libdns_all + +package libdns + +import ( + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/libdns/acmedns" +) + +func init() { + modules.Register("libdns.acmedns", func(c *container.C, modName, instName string) (module.Module, error) { + p := acmedns.Provider{} + return &ProviderModule{ + RecordDeleter: &p, + RecordAppender: &p, + setConfig: func(c *config.Map) { + c.String("username", false, true, "", &p.Username) + c.String("password", false, true, "", &p.Password) + c.String("subdomain", false, true, "", &p.Subdomain) + c.String("server_url", false, true, "", &p.ServerURL) + }, + instName: instName, + modName: modName, + }, nil + }) +} diff --git a/internal/libdns/alidns.go b/internal/libdns/alidns.go index 5acefe93e..27c890749 100644 --- a/internal/libdns/alidns.go +++ b/internal/libdns/alidns.go @@ -1,15 +1,18 @@ -//+build libdns_alidns libdns_all +//go:build libdns_alidns || libdns_all +// +build libdns_alidns libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/alidns" ) func init() { - module.Register("libdns.alidns", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.alidns", func(c *container.C, modName, instName string) (module.Module, error) { p := alidns.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/cloudflare.go b/internal/libdns/cloudflare.go index 10ae981ba..cc8cc7db5 100644 --- a/internal/libdns/cloudflare.go +++ b/internal/libdns/cloudflare.go @@ -1,15 +1,18 @@ -//+build libdns_cloudflare !libdns_separate +//go:build libdns_cloudflare || !libdns_separate +// +build libdns_cloudflare !libdns_separate package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/cloudflare" ) func init() { - module.Register("libdns.cloudflare", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.cloudflare", func(c *container.C, modName, instName string) (module.Module, error) { p := cloudflare.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/digitalocean.go b/internal/libdns/digitalocean.go index e18c38cf3..96cdc728b 100644 --- a/internal/libdns/digitalocean.go +++ b/internal/libdns/digitalocean.go @@ -1,15 +1,18 @@ -//+build libdns_digitalocean !libdns_separate +//go:build libdns_digitalocean || !libdns_separate +// +build libdns_digitalocean !libdns_separate package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/digitalocean" ) func init() { - module.Register("libdns.digitalocean", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.digitalocean", func(c *container.C, modName, instName string) (module.Module, error) { p := digitalocean.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/gandi.go b/internal/libdns/gandi.go index d90fa69eb..828a48cdd 100644 --- a/internal/libdns/gandi.go +++ b/internal/libdns/gandi.go @@ -1,21 +1,37 @@ -//+build libdns_gandi !libdns_separate +//go:build libdns_gandi || !libdns_separate +// +build libdns_gandi !libdns_separate package libdns import ( + "fmt" + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/gandi" ) func init() { - module.Register("libdns.gandi", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.gandi", func(c *container.C, modName, instName string) (module.Module, error) { p := gandi.Provider{} return &ProviderModule{ RecordDeleter: &p, RecordAppender: &p, setConfig: func(c *config.Map) { - c.String("api_token", false, true, "", &p.APIToken) + c.String("api_token", false, false, "", &p.APIToken) + c.String("personal_token", false, false, "", &p.BearerToken) + }, + afterConfig: func() error { + if p.APIToken != "" { + log.Println("libdns.gandi: api_token is deprecated, use personal_token instead (https://api.gandi.net/docs/authentication/)") + } + if p.APIToken == "" && p.BearerToken == "" { + return fmt.Errorf("libdns.gandi: either api_token or personal_token should be specified") + } + return nil }, instName: instName, modName: modName, diff --git a/internal/libdns/gcore.go b/internal/libdns/gcore.go new file mode 100644 index 000000000..98f71e7e4 --- /dev/null +++ b/internal/libdns/gcore.go @@ -0,0 +1,35 @@ +//go:build libdns_gcore || !libdns_separate + +package libdns + +import ( + "fmt" + + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/libdns/gcore" +) + +func init() { + modules.Register("libdns.gcore", func(c *container.C, modName, instName string) (module.Module, error) { + p := gcore.Provider{} + return &ProviderModule{ + RecordDeleter: &p, + RecordAppender: &p, + setConfig: func(c *config.Map) { + c.String("api_key", false, false, "", &p.APIKey) + }, + afterConfig: func() error { + if p.APIKey == "" { + return fmt.Errorf("libdns.gcore: api_key should be specified") + } + return nil + }, + + instName: instName, + modName: modName, + }, nil + }) +} diff --git a/internal/libdns/googleclouddns.go b/internal/libdns/googleclouddns.go index 90276df35..dc027ddc8 100644 --- a/internal/libdns/googleclouddns.go +++ b/internal/libdns/googleclouddns.go @@ -1,15 +1,18 @@ -//+build libdns_googleclouddns libdns_all +//go:build libdns_googleclouddns || libdns_all +// +build libdns_googleclouddns libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/googleclouddns" ) func init() { - module.Register("libdns.googleclouddns", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.googleclouddns", func(c *container.C, modName, instName string) (module.Module, error) { p := googleclouddns.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/hetzner.go b/internal/libdns/hetzner.go index c48b63b32..cd5c34580 100644 --- a/internal/libdns/hetzner.go +++ b/internal/libdns/hetzner.go @@ -1,20 +1,25 @@ -//+build libdns_hetzner !libdns_separate +//go:build libdns_hetzner || !libdns_separate +// +build libdns_hetzner !libdns_separate package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/hetzner" ) func init() { - module.Register("libdns.hetzner", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.hetzner", func(c *container.C, modName, instName string) (module.Module, error) { p := hetzner.Provider{} return &ProviderModule{ RecordDeleter: &p, RecordAppender: &p, setConfig: func(c *config.Map) { + log.DefaultLogger.Println("WARNING: maddy 0.10.0 will require new DNS API, see https://github.com/foxcpp/maddy/issues/807 for details") c.String("api_token", false, false, "", &p.AuthAPIToken) }, instName: instName, diff --git a/internal/libdns/leaseweb.go b/internal/libdns/leaseweb.go index d57fb0b1c..a76487a6d 100644 --- a/internal/libdns/leaseweb.go +++ b/internal/libdns/leaseweb.go @@ -1,20 +1,25 @@ -//+build libdns_leaseweb libdns_all +//go:build libdns_leaseweb || libdns_all +// +build libdns_leaseweb libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/leaseweb" ) func init() { - module.Register("libdns.leaseweb", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.leaseweb", func(c *container.C, modName, instName string) (module.Module, error) { p := leaseweb.Provider{} return &ProviderModule{ RecordDeleter: &p, RecordAppender: &p, setConfig: func(c *config.Map) { + log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.leaseweb, see https://github.com/foxcpp/maddy/issues/807 for details") c.String("api_key", false, false, "", &p.APIKey) }, instName: instName, diff --git a/internal/libdns/metaname.go b/internal/libdns/metaname.go index 503896318..0ffc61776 100644 --- a/internal/libdns/metaname.go +++ b/internal/libdns/metaname.go @@ -1,15 +1,18 @@ -//+build libdns_metaname libdns_all +//go:build libdns_metaname || libdns_all +// +build libdns_metaname libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/metaname" ) func init() { - module.Register("libdns.metaname", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.metaname", func(c *container.C, modName, instName string) (module.Module, error) { p := metaname.Provider{ Endpoint: "https://metaname.net/api/1.1", } diff --git a/internal/libdns/namecheap.go b/internal/libdns/namecheap.go index 77b942ca1..a8538c315 100644 --- a/internal/libdns/namecheap.go +++ b/internal/libdns/namecheap.go @@ -1,15 +1,18 @@ -//+build go1.16 +//go:build go1.16 +// +build go1.16 package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/namecheap" ) func init() { - module.Register("libdns.namecheap", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.namecheap", func(c *container.C, modName, instName string) (module.Module, error) { p := namecheap.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/namedotcom.go b/internal/libdns/namedotcom.go index 306154436..57c314811 100644 --- a/internal/libdns/namedotcom.go +++ b/internal/libdns/namedotcom.go @@ -1,15 +1,19 @@ -//+build libdns_namedotdom libdns_all +//go:build libdns_namedotdom || libdns_all +// +build libdns_namedotdom libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/namedotcom" ) func init() { - module.Register("libdns.namedotcom", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.namedotcom", func(c *container.C, modName, instName string) (module.Module, error) { p := namedotcom.Provider{ Server: "https://api.name.com", } @@ -17,6 +21,7 @@ func init() { RecordDeleter: &p, RecordAppender: &p, setConfig: func(c *config.Map) { + log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.namedotcom, see https://github.com/foxcpp/maddy/issues/807 for details") c.String("user", false, false, "", &p.User) c.String("token", false, false, "", &p.Token) }, diff --git a/internal/libdns/provider_module.go b/internal/libdns/provider_module.go index 74d201ef4..6df107ffa 100644 --- a/internal/libdns/provider_module.go +++ b/internal/libdns/provider_module.go @@ -8,15 +8,21 @@ import ( type ProviderModule struct { libdns.RecordDeleter libdns.RecordAppender - setConfig func(c *config.Map) + setConfig func(c *config.Map) + afterConfig func() error instName string modName string } -func (p *ProviderModule) Init(cfg *config.Map) error { +func (p *ProviderModule) Configure(inlineArgs []string, cfg *config.Map) error { p.setConfig(cfg) _, err := cfg.Process() + if p.afterConfig != nil { + if err := p.afterConfig(); err != nil { + return err + } + } return err } diff --git a/internal/libdns/rfc2136.go b/internal/libdns/rfc2136.go new file mode 100644 index 000000000..686bb324f --- /dev/null +++ b/internal/libdns/rfc2136.go @@ -0,0 +1,30 @@ +//go:build libdns_rfc2136 || libdns_all +// +build libdns_rfc2136 libdns_all + +package libdns + +import ( + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/libdns/rfc2136" +) + +func init() { + modules.Register("libdns.rfc2136", func(c *container.C, modName, instName string) (module.Module, error) { + p := rfc2136.Provider{} + return &ProviderModule{ + RecordDeleter: &p, + RecordAppender: &p, + setConfig: func(c *config.Map) { + c.String("key_name", false, true, "", &p.KeyName) + c.String("key", false, true, "", &p.Key) + c.String("key_alg", false, true, "", &p.KeyAlg) + c.String("server", false, true, "", &p.Server) + }, + instName: instName, + modName: modName, + }, nil + }) +} diff --git a/internal/libdns/route53.go b/internal/libdns/route53.go index 6b4664c29..be0a1dabe 100644 --- a/internal/libdns/route53.go +++ b/internal/libdns/route53.go @@ -1,15 +1,18 @@ -//+build libdns_route53 libdns_all +//go:build libdns_route53 || libdns_all +// +build libdns_route53 libdns_all package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/route53" ) func init() { - module.Register("libdns.route53", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.route53", func(c *container.C, modName, instName string) (module.Module, error) { p := route53.Provider{} return &ProviderModule{ RecordDeleter: &p, diff --git a/internal/libdns/vultr.go b/internal/libdns/vultr.go index 684a8cec1..097bc442d 100644 --- a/internal/libdns/vultr.go +++ b/internal/libdns/vultr.go @@ -1,20 +1,25 @@ -//+build libdns_vultr !libdns_separate +//go:build libdns_vultr || !libdns_separate +// +build libdns_vultr !libdns_separate package libdns import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/libdns/vultr" ) func init() { - module.Register("libdns.vultr", func(modName, instName string, _, _ []string) (module.Module, error) { + modules.Register("libdns.vultr", func(c *container.C, modName, instName string) (module.Module, error) { p := vultr.Provider{} return &ProviderModule{ RecordDeleter: &p, RecordAppender: &p, setConfig: func(c *config.Map) { + log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.vultr, see https://github.com/foxcpp/maddy/issues/807 for details") c.String("api_token", false, false, "", &p.APIToken) }, instName: instName, diff --git a/internal/limits/limits.go b/internal/limits/limits.go index 95d98a2f8..66086f7a2 100644 --- a/internal/limits/limits.go +++ b/internal/limits/limits.go @@ -28,16 +28,21 @@ package limits import ( "context" + "fmt" "net" "strconv" "time" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/limits/limiters" ) type Group struct { + log *log.Logger instName string global limiters.MultiLimit @@ -46,13 +51,20 @@ type Group struct { dest *limiters.BucketSet // BucketSet of MultiLimit } -func New(_, instName string, _, _ []string) (module.Module, error) { +func Empty(log *log.Logger) *Group { return &Group{ + log: log, + } +} + +func New(c *container.C, _, instName string) (module.Module, error) { + return &Group{ + log: c.DefaultLogger.Sublogger("limits"), instName: instName, }, nil } -func (g *Group) Init(cfg *config.Map) error { +func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error { var ( globalL []limiters.L ipL []func() limiters.L @@ -118,8 +130,8 @@ func (g *Group) Init(cfg *config.Map) error { } if len(destL) != 0 { g.dest = limiters.NewBucketSet(func() limiters.L { - l := make([]limiters.L, 0, len(sourceL)) - for _, ctor := range sourceL { + l := make([]limiters.L, 0, len(destL)) + for _, ctor := range destL { l = append(l, ctor()) } return &limiters.MultiLimit{Wrapped: l} @@ -175,22 +187,28 @@ func (g *Group) TakeMsg(ctx context.Context, addr net.IP, sourceDomain string) e ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() + g.log.DebugMsg("global TakeContext") if err := g.global.TakeContext(ctx); err != nil { - return err + return fmt.Errorf("TakeMsg: global: %w", err) } + g.log.DebugMsg("global TakeContext done") if g.ip != nil { + g.log.DebugMsg("ip TakeContext", "ip", addr.String()) if err := g.ip.TakeContext(ctx, addr.String()); err != nil { g.global.Release() - return err + return fmt.Errorf("TakeMsg: ip: %w", err) } + g.log.DebugMsg("ip TakeContext done", "ip", addr.String()) } if g.source != nil { + g.log.DebugMsg("source TakeContext", "domain", sourceDomain) if err := g.source.TakeContext(ctx, sourceDomain); err != nil { g.global.Release() g.ip.Release(addr.String()) - return err + return fmt.Errorf("TakeMSg: source: %w", err) } + g.log.DebugMsg("source TakeContext done", "domain", sourceDomain) } return nil } @@ -199,17 +217,27 @@ func (g *Group) TakeDest(ctx context.Context, domain string) error { if g.dest == nil { return nil } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - return g.dest.TakeContext(ctx, domain) + + g.log.DebugMsg("TakeDest", "domain", domain) + if err := g.dest.TakeContext(ctx, domain); err != nil { + return fmt.Errorf("TakeDest: dest: %w", err) + } + g.log.DebugMsg("TakeDest done", "domain", domain) + return nil } func (g *Group) ReleaseMsg(addr net.IP, sourceDomain string) { + g.log.DebugMsg("global ReleaseMsg") g.global.Release() if g.ip != nil { + g.log.DebugMsg("ip ReleaseMsg", "ip", addr.String()) g.ip.Release(addr.String()) } if g.source != nil { + g.log.DebugMsg("source ReleaseMsg", "domain", sourceDomain) g.source.Release(sourceDomain) } } @@ -218,7 +246,10 @@ func (g *Group) ReleaseDest(domain string) { if g.dest == nil { return } + + g.log.DebugMsg("ReleaseDest", "domain", domain) g.dest.Release(domain) + g.log.DebugMsg("ReleaseDest done", "domain", domain) } func (g *Group) Name() string { @@ -230,5 +261,5 @@ func (g *Group) InstanceName() string { } func init() { - module.Register("limits", New) + modules.Register("limits", New) } diff --git a/internal/modify/dkim/dkim.go b/internal/modify/dkim/dkim.go index edf1ad6af..bf4131211 100644 --- a/internal/modify/dkim/dkim.go +++ b/internal/modify/dkim/dkim.go @@ -34,10 +34,12 @@ import ( "github.com/foxcpp/maddy/framework/address" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" "golang.org/x/net/idna" ) @@ -105,30 +107,19 @@ type Modifier struct { bodyCanon dkim.Canonicalization sigExpiry time.Duration hash crypto.Hash - senderMatch map[string]struct{} multipleFromOk bool signSubdomains bool - log log.Logger + log *log.Logger } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { m := &Modifier{ instName: instName, signers: map[string]crypto.Signer{}, - log: log.Logger{Name: "modify.dkim"}, + log: c.DefaultLogger.Sublogger(modName), } - if len(inlineArgs) == 0 { - return m, nil - } - if len(inlineArgs) == 1 { - return nil, errors.New("modify.dkim: at least two arguments required") - } - - m.domains = inlineArgs[0 : len(inlineArgs)-1] - m.selector = inlineArgs[len(inlineArgs)-1] - return m, nil } @@ -140,12 +131,20 @@ func (m *Modifier) InstanceName() string { return m.instName } -func (m *Modifier) Init(cfg *config.Map) error { +func (m *Modifier) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + if len(inlineArgs) == 1 { + return errors.New("modify.dkim: at least two arguments required") + } + + m.domains = inlineArgs[0 : len(inlineArgs)-1] + m.selector = inlineArgs[len(inlineArgs)-1] + } + var ( hashName string keyPathTemplate string newKeyAlgo string - senderMatch []string ) cfg.Bool("debug", true, false, &m.log.Debug) @@ -165,8 +164,6 @@ func (m *Modifier) Init(cfg *config.Map) error { []string{"sha256"}, "sha256", &hashName) cfg.Enum("newkey_algo", false, false, []string{"rsa4096", "rsa2048", "ed25519"}, "rsa2048", &newKeyAlgo) - cfg.EnumList("require_sender_match", false, false, - []string{"envelope", "auth_domain", "auth_user", "off"}, []string{"envelope", "auth"}, &senderMatch) cfg.Bool("allow_multiple_from", false, false, &m.multipleFromOk) cfg.Bool("sign_subdomains", false, false, &m.signSubdomains) @@ -184,14 +181,6 @@ func (m *Modifier) Init(cfg *config.Map) error { return errors.New("sign_domain: only one domain is supported when sign_subdomains is enabled") } - m.senderMatch = make(map[string]struct{}, len(senderMatch)) - for _, method := range senderMatch { - m.senderMatch[method] = struct{}{} - } - if _, off := m.senderMatch["off"]; off && len(senderMatch) != 1 { - return errors.New("sign_domain: require_sender_match: 'off' should not be combined with other methods") - } - m.hash = hashFuncs[hashName] if m.hash == 0 { panic("modify.dkim.Init: Hash function allowed by config matcher but not present in hashFuncs") @@ -267,7 +256,7 @@ type state struct { m *Modifier meta *module.MsgMetadata from string - log log.Logger + log *log.Logger } func (m *Modifier) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) { @@ -283,8 +272,8 @@ func (s *state) RewriteSender(ctx context.Context, mailFrom string) (string, err return mailFrom, nil } -func (s state) RewriteRcpt(ctx context.Context, rcptTo string) (string, error) { - return rcptTo, nil +func (s *state) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) { + return []string{rcptTo}, nil } func (s *state) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error { @@ -354,16 +343,16 @@ func (s *state) RewriteBody(ctx context.Context, h *textproto.Header, body buffe return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"}) } if err := textproto.WriteHeader(signer, *h); err != nil { - signer.Close() + _ = signer.Close() return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"}) } r, err := body.Open() if err != nil { - signer.Close() + _ = signer.Close() return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"}) } if _, err := io.Copy(signer, r); err != nil { - signer.Close() + _ = signer.Close() return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"}) } @@ -378,10 +367,10 @@ func (s *state) RewriteBody(ctx context.Context, h *textproto.Header, body buffe return nil } -func (s state) Close() error { +func (s *state) Close() error { return nil } func init() { - module.Register("modify.dkim", New) + modules.Register("modify.dkim", New) } diff --git a/internal/modify/dkim/dkim_test.go b/internal/modify/dkim/dkim_test.go index 2fe9c3ede..7e189149f 100644 --- a/internal/modify/dkim/dkim_test.go +++ b/internal/modify/dkim/dkim_test.go @@ -21,7 +21,6 @@ package dkim import ( "bytes" "context" - "io/ioutil" "os" "path/filepath" "reflect" @@ -33,19 +32,20 @@ import ( "github.com/foxcpp/go-mockdns" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" ) func newTestModifier(t *testing.T, dir, keyAlgo string, domains []string) *Modifier { - mod, err := New("", "test", nil, nil) + mod, err := New(container.New(), "", "test") if err != nil { t.Fatal(err) } m := mod.(*Modifier) m.log = testutils.Logger(t, m.Name()) - err = m.Init(config.NewMap(nil, config.Node{ + err = m.Configure(nil, config.NewMap(nil, config.Node{ Children: []config.Node{ { Name: "domains", @@ -59,10 +59,6 @@ func newTestModifier(t *testing.T, dir, keyAlgo string, domains []string) *Modif Name: "key_path", Args: []string{filepath.Join(dir, "{domain}.key")}, }, - { - Name: "require_sender_match", - Args: []string{"off"}, - }, { Name: "newkey_algo", Args: []string{keyAlgo}, @@ -111,7 +107,7 @@ func verifyTestMsg(t *testing.T, keysPath string, expectedDomains []string, hdr domainsMap := make(map[string]bool) zones := map[string]mockdns.Zone{} for _, domain := range expectedDomains { - dnsRecord, err := ioutil.ReadFile(filepath.Join(keysPath, domain+".dns")) + dnsRecord, err := os.ReadFile(filepath.Join(keysPath, domain+".dns")) if err != nil { t.Fatal(err) } @@ -167,11 +163,7 @@ func TestGenerateSignVerify(t *testing.T) { test := func(domains []string, envelopeFrom string, expectDomain []string, keyAlgo string, headerCanon, bodyCanon dkim.Canonicalization, reload bool) { t.Helper() - dir, err := ioutil.TempDir("", "maddy-tests-dkim-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) + dir := t.TempDir() m := newTestModifier(t, dir, keyAlgo, domains) m.bodyCanon = bodyCanon diff --git a/internal/modify/dkim/keys.go b/internal/modify/dkim/keys.go index bbf8a2e9e..2a9b7514a 100644 --- a/internal/modify/dkim/keys.go +++ b/internal/modify/dkim/keys.go @@ -29,7 +29,6 @@ import ( "encoding/pem" "fmt" "io" - "io/ioutil" "os" "path/filepath" ) @@ -43,9 +42,13 @@ func (m *Modifier) loadOrGenerateKey(keyPath, newKeyAlgo string) (pkey crypto.Si } return nil, false, err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + m.log.Error("failed to close key file", err) + } + }() - pemBlob, err := ioutil.ReadAll(f) + pemBlob, err := io.ReadAll(f) if err != nil { return nil, false, err } diff --git a/internal/modify/dkim/keys_test.go b/internal/modify/dkim/keys_test.go index 148800cee..ebe13ba1c 100644 --- a/internal/modify/dkim/keys_test.go +++ b/internal/modify/dkim/keys_test.go @@ -22,7 +22,6 @@ import ( "crypto/ed25519" "crypto/rsa" "encoding/base64" - "io/ioutil" "os" "path/filepath" "strings" @@ -35,11 +34,7 @@ func TestKeyLoad_new(t *testing.T) { m := Modifier{} m.log = testutils.Logger(t, m.Name()) - dir, err := ioutil.TempDir("", "maddy-tests-dkim-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) + dir := t.TempDir() signer, newKey, err := m.loadOrGenerateKey(filepath.Join(dir, "testkey.key"), "ed25519") if err != nil { @@ -49,7 +44,7 @@ func TestKeyLoad_new(t *testing.T) { t.Fatal("newKey=false") } - recordBlob, err := ioutil.ReadFile(filepath.Join(dir, "testkey.dns")) + recordBlob, err := os.ReadFile(filepath.Join(dir, "testkey.dns")) if err != nil { t.Fatal(err) } @@ -85,13 +80,9 @@ func TestKeyLoad_existing_pkcs8(t *testing.T) { m := Modifier{} m.log = testutils.Logger(t, m.Name()) - dir, err := ioutil.TempDir("", "maddy-tests-dkim-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) + dir := t.TempDir() - if err := ioutil.WriteFile(filepath.Join(dir, "testkey.key"), []byte(pkeyEd25519), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "testkey.key"), []byte(pkeyEd25519), 0o600); err != nil { t.Fatal(err) } @@ -141,13 +132,9 @@ func TestKeyLoad_existing_pkcs1(t *testing.T) { m := Modifier{} m.log = testutils.Logger(t, m.Name()) - dir, err := ioutil.TempDir("", "maddy-tests-dkim-") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(dir) + dir := t.TempDir() - if err := ioutil.WriteFile(filepath.Join(dir, "testkey.key"), []byte(pkeyRSA), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "testkey.key"), []byte(pkeyRSA), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/modify/group.go b/internal/modify/group.go index 11439bcf5..116a32cfd 100644 --- a/internal/modify/group.go +++ b/internal/modify/group.go @@ -25,7 +25,10 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type ( @@ -43,7 +46,7 @@ type ( } ) -func (g *Group) Init(cfg *config.Map) error { +func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error { for _, node := range cfg.Block.Children { mod, err := modconfig.MsgModifier(cfg.Globals, append([]string{node.Name}, node.Args...), node) if err != nil { @@ -64,14 +67,16 @@ func (g *Group) InstanceName() string { return g.instName } -func (g Group) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) { +func (g *Group) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) { gs := groupState{} for _, modifier := range g.Modifiers { state, err := modifier.ModStateForMsg(ctx, msgMeta) if err != nil { // Free state objects we initialized already. for _, state := range gs.states { - state.Close() + if err := state.Close(); err != nil { + log.DefaultLogger.Error("failed to close modifier state", err) + } } return nil, err } @@ -91,15 +96,22 @@ func (gs groupState) RewriteSender(ctx context.Context, mailFrom string) (string return mailFrom, nil } -func (gs groupState) RewriteRcpt(ctx context.Context, rcptTo string) (string, error) { +func (gs groupState) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) { var err error + var result = []string{rcptTo} for _, state := range gs.states { - rcptTo, err = state.RewriteRcpt(ctx, rcptTo) - if err != nil { - return "", err + var intermediateResult = []string{} + for _, partResult := range result { + var partResult_multi []string + partResult_multi, err = state.RewriteRcpt(ctx, partResult) + if err != nil { + return []string{""}, err + } + intermediateResult = append(intermediateResult, partResult_multi...) } + result = intermediateResult } - return rcptTo, nil + return result, nil } func (gs groupState) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error { @@ -125,7 +137,7 @@ func (gs groupState) Close() error { } func init() { - module.Register("modifiers", func(_, instName string, _, _ []string) (module.Module, error) { + modules.Register("modifiers", func(c *container.C, _, instName string) (module.Module, error) { return &Group{ instName: instName, }, nil diff --git a/internal/modify/replace_addr.go b/internal/modify/replace_addr.go index 2f975ac89..7b3e98423 100644 --- a/internal/modify/replace_addr.go +++ b/internal/modify/replace_addr.go @@ -28,7 +28,9 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // replaceAddr is a simple module that replaces matching sender (or recipient) address @@ -37,20 +39,18 @@ import ( // If created with modName = "modify.replace_sender", it will change sender address. // If created with modName = "modify.replace_rcpt", it will change recipient addresses. type replaceAddr struct { - modName string - instName string - inlineArgs []string + modName string + instName string replaceSender bool replaceRcpt bool - table module.Table + table module.MultiTable } -func NewReplaceAddr(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func NewReplaceAddr(c *container.C, modName, instName string) (module.Module, error) { r := replaceAddr{ modName: modName, instName: instName, - inlineArgs: inlineArgs, replaceSender: modName == "modify.replace_sender", replaceRcpt: modName == "modify.replace_rcpt", } @@ -58,88 +58,99 @@ func NewReplaceAddr(modName, instName string, _, inlineArgs []string) (module.Mo return &r, nil } -func (r *replaceAddr) Init(cfg *config.Map) error { - return modconfig.ModuleFromNode("table", r.inlineArgs, cfg.Block, cfg.Globals, &r.table) +func (r *replaceAddr) Configure(inlineArgs []string, cfg *config.Map) error { + return modconfig.ModuleFromNode("table", inlineArgs, cfg.Block, cfg.Globals, &r.table) } -func (r replaceAddr) Name() string { +func (r *replaceAddr) Name() string { return r.modName } -func (r replaceAddr) InstanceName() string { +func (r *replaceAddr) InstanceName() string { return r.instName } -func (r replaceAddr) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) { +func (r *replaceAddr) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) { return r, nil } -func (r replaceAddr) RewriteSender(ctx context.Context, mailFrom string) (string, error) { +func (r *replaceAddr) RewriteSender(ctx context.Context, mailFrom string) (string, error) { if r.replaceSender { - return r.rewrite(ctx, mailFrom) + results, err := r.rewrite(ctx, mailFrom) + if err != nil { + return mailFrom, err + } + mailFrom = results[0] } return mailFrom, nil } -func (r replaceAddr) RewriteRcpt(ctx context.Context, rcptTo string) (string, error) { +func (r *replaceAddr) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) { if r.replaceRcpt { return r.rewrite(ctx, rcptTo) } - return rcptTo, nil + return []string{rcptTo}, nil } -func (r replaceAddr) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error { +func (r *replaceAddr) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error { return nil } -func (r replaceAddr) Close() error { +func (r *replaceAddr) Close() error { return nil } -func (r replaceAddr) rewrite(ctx context.Context, val string) (string, error) { +func (r *replaceAddr) rewrite(ctx context.Context, val string) ([]string, error) { normAddr, err := address.ForLookup(val) if err != nil { - return val, fmt.Errorf("malformed address: %v", err) + return []string{val}, fmt.Errorf("malformed address: %v", err) } - replacement, ok, err := r.table.Lookup(ctx, normAddr) + replacements, err := r.table.LookupMulti(ctx, normAddr) if err != nil { - return val, err + return []string{val}, err } - if ok { - if !address.Valid(replacement) { - return "", fmt.Errorf("refusing to replace recipient with the invalid address %s", replacement) + if len(replacements)> 0 { + for _, replacement := range replacements { + if !address.Valid(replacement) { + return []string{""}, fmt.Errorf("refusing to replace recipient with the invalid address %s", replacement) + } } - return replacement, nil + return replacements, nil } mbox, domain, err := address.Split(normAddr) if err != nil { // If we have malformed address here, something is really wrong, but let's // ignore it silently then anyway. - return val, nil + return []string{val}, nil } // mbox is already normalized, since it is a part of address.ForLookup // result. - replacement, ok, err = r.table.Lookup(ctx, mbox) + replacements, err = r.table.LookupMulti(ctx, mbox) if err != nil { - return val, err + return []string{val}, err } - if ok { - if strings.Contains(replacement, "@") && !strings.HasPrefix(replacement, `"`) && !strings.HasSuffix(replacement, `"`) { - if !address.Valid(replacement) { - return "", fmt.Errorf("refusing to replace recipient with invalid address %s", replacement) + if len(replacements)> 0 { + var results = make([]string, len(replacements)) + for i, replacement := range replacements { + if strings.Contains(replacement, "@") && !strings.HasPrefix(replacement, `"`) && !strings.HasSuffix(replacement, `"`) { + if !address.Valid(replacement) { + return []string{""}, fmt.Errorf("refusing to replace recipient with invalid address %s", replacement) + } + results[i] = replacement + } else { + results[i] = replacement + "@" + domain } - return replacement, nil } - return replacement + "@" + domain, nil + return results, nil } - return val, nil + return []string{val}, nil } func init() { - module.Register("modify.replace_sender", NewReplaceAddr) - module.Register("modify.replace_rcpt", NewReplaceAddr) + modules.Register("modify.replace_sender", NewReplaceAddr) + modules.Register("modify.replace_rcpt", NewReplaceAddr) } diff --git a/internal/modify/replace_addr_test.go b/internal/modify/replace_addr_test.go index 6114a57ac..35ebbb2e2 100644 --- a/internal/modify/replace_addr_test.go +++ b/internal/modify/replace_addr_test.go @@ -20,78 +20,90 @@ package modify import ( "context" + "reflect" "testing" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/internal/testutils" ) func testReplaceAddr(t *testing.T, modName string) { - test := func(addr, expected string, aliases map[string]string) { + test := func(addr string, expectedMulti []string, aliases map[string][]string) { t.Helper() - mod, err := NewReplaceAddr(modName, "", nil, []string{"dummy"}) + mod, err := NewReplaceAddr(container.New(), modName, "") if err != nil { t.Fatal(err) } m := mod.(*replaceAddr) - if err := m.Init(config.NewMap(nil, config.Node{})); err != nil { + if err := m.Configure([]string{"dummy"}, config.NewMap(nil, config.Node{})); err != nil { t.Fatal(err) } - m.table = testutils.Table{M: aliases} + m.table = testutils.MultiTable{M: aliases} - var actual string + var actualMulti []string if modName == "modify.replace_sender" { + var actual string actual, err = m.RewriteSender(context.Background(), addr) if err != nil { t.Fatal(err) } + actualMulti = []string{actual} } if modName == "modify.replace_rcpt" { - actual, err = m.RewriteRcpt(context.Background(), addr) + actualMulti, err = m.RewriteRcpt(context.Background(), addr) if err != nil { t.Fatal(err) } } - if actual != expected { - t.Errorf("want %s, got %s", expected, actual) + if !reflect.DeepEqual(actualMulti, expectedMulti) { + t.Errorf("want %s, got %s", expectedMulti, actualMulti) } } - test("test@example.org", "test@example.org", nil) - test("postmaster", "postmaster", nil) - test("test@example.com", "test@example.org", - map[string]string{"test@example.com": "test@example.org"}) - test(`"\"test @ test\""@example.com`, "test@example.org", - map[string]string{`"\"test @ test\""@example.com`: "test@example.org"}) - test(`test@example.com`, `"\"test @ test\""@example.org`, - map[string]string{`test@example.com`: `"\"test @ test\""@example.org`}) - test(`"\"test @ test\""@example.com`, `"\"b @ b\""@example.com`, - map[string]string{`"\"test @ test\""`: `"\"b @ b\""`}) - test("TeSt@eXAMple.com", "test@example.org", - map[string]string{"test@example.com": "test@example.org"}) - test("test@example.com", "test2@example.com", - map[string]string{"test": "test2"}) - test("test@example.com", "test2@example.org", - map[string]string{"test": "test2@example.org"}) - test("postmaster", "test2@example.org", - map[string]string{"postmaster": "test2@example.org"}) - test("TeSt@examPLE.com", "test2@example.com", - map[string]string{"test": "test2"}) - test("test@example.com", "test3@example.com", - map[string]string{ - "test@example.com": "test3@example.com", - "test": "test2", + test("test@example.org", []string{"test@example.org"}, nil) + test("postmaster", []string{"postmaster"}, nil) + test("test@example.com", []string{"test@example.org"}, + map[string][]string{"test@example.com": []string{"test@example.org"}}) + test(`"\"test @ test\""@example.com`, []string{"test@example.org"}, + map[string][]string{`"\"test @ test\""@example.com`: []string{"test@example.org"}}) + test(`test@example.com`, []string{`"\"test @ test\""@example.org`}, + map[string][]string{`test@example.com`: []string{`"\"test @ test\""@example.org`}}) + test(`"\"test @ test\""@example.com`, []string{`"\"b @ b\""@example.com`}, + map[string][]string{`"\"test @ test\""`: []string{`"\"b @ b\""`}}) + test("TeSt@eXAMple.com", []string{"test@example.org"}, + map[string][]string{"test@example.com": []string{"test@example.org"}}) + test("test@example.com", []string{"test2@example.com"}, + map[string][]string{"test": []string{"test2"}}) + test("test@example.com", []string{"test2@example.org"}, + map[string][]string{"test": []string{"test2@example.org"}}) + test("postmaster", []string{"test2@example.org"}, + map[string][]string{"postmaster": []string{"test2@example.org"}}) + test("TeSt@examPLE.com", []string{"test2@example.com"}, + map[string][]string{"test": []string{"test2"}}) + test("test@example.com", []string{"test3@example.com"}, + map[string][]string{ + "test@example.com": []string{"test3@example.com"}, + "test": []string{"test2"}, }) - test("rcpt@E\u0301.example.com", "rcpt@foo.example.com", - map[string]string{ - "rcpt@\u00E9.example.com": "rcpt@foo.example.com", + test("rcpt@E\u0301.example.com", []string{"rcpt@foo.example.com"}, + map[string][]string{ + "rcpt@\u00E9.example.com": []string{"rcpt@foo.example.com"}, }) - test("E\u0301@foo.example.com", "rcpt@foo.example.com", - map[string]string{ - "\u00E9@foo.example.com": "rcpt@foo.example.com", + test("E\u0301@foo.example.com", []string{"rcpt@foo.example.com"}, + map[string][]string{ + "\u00E9@foo.example.com": []string{"rcpt@foo.example.com"}, }) + + if modName == "modify.replace_rcpt" { + //multiple aliases + test("test@example.com", []string{"test@example.org", "test@example.net"}, + map[string][]string{"test@example.com": []string{"test@example.org", "test@example.net"}}) + test("test@example.com", []string{"1@example.com", "2@example.com", "3@example.com"}, + map[string][]string{"test@example.com": []string{"1@example.com", "2@example.com", "3@example.com"}}) + } } func TestReplaceAddr_RewriteSender(t *testing.T) { diff --git a/internal/msgpipeline/bodynonatomic_test.go b/internal/msgpipeline/bodynonatomic_test.go index 45da7b8ef..c44198022 100644 --- a/internal/msgpipeline/bodynonatomic_test.go +++ b/internal/msgpipeline/bodynonatomic_test.go @@ -79,8 +79,8 @@ func TestMsgPipeline_BodyNonAtomic_ModifiedRcpt(t *testing.T) { Modifiers: []module.Modifier{ testutils.Modifier{ InstName: "test_modifier", - RcptTo: map[string]string{ - "tester@example.org": "tester-alias@example.org", + RcptTo: map[string][]string{ + "tester@example.org": []string{"tester-alias@example.org"}, }, }, }, diff --git a/internal/msgpipeline/check_group.go b/internal/msgpipeline/check_group.go index 1fd6b245e..98b6f9c5f 100644 --- a/internal/msgpipeline/check_group.go +++ b/internal/msgpipeline/check_group.go @@ -21,7 +21,9 @@ package msgpipeline import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // CheckGroup is a module container for a group of Check implementations. @@ -37,7 +39,7 @@ type CheckGroup struct { L []module.Check } -func (cg *CheckGroup) Init(cfg *config.Map) error { +func (cg *CheckGroup) Configure(inlineArgs []string, cfg *config.Map) error { for _, node := range cfg.Block.Children { chk, err := modconfig.MessageCheck(cfg.Globals, append([]string{node.Name}, node.Args...), node) if err != nil { @@ -50,16 +52,16 @@ func (cg *CheckGroup) Init(cfg *config.Map) error { return nil } -func (CheckGroup) Name() string { +func (*CheckGroup) Name() string { return "checks" } -func (cg CheckGroup) InstanceName() string { +func (cg *CheckGroup) InstanceName() string { return cg.instName } func init() { - module.Register("checks", func(_, instName string, _, _ []string) (module.Module, error) { + modules.Register("checks", func(_ *container.C, _, instName string) (module.Module, error) { return &CheckGroup{ instName: instName, }, nil diff --git a/internal/msgpipeline/check_runner.go b/internal/msgpipeline/check_runner.go index ba9c78230..48ce00493 100644 --- a/internal/msgpipeline/check_runner.go +++ b/internal/msgpipeline/check_runner.go @@ -49,14 +49,14 @@ type checkRunner struct { didDMARCFetch bool dmarcVerify *dmarc.Verifier - log log.Logger + log *log.Logger states map[module.Check]module.CheckState mergedRes module.CheckResult } -func newCheckRunner(msgMeta *module.MsgMetadata, log log.Logger, r dns.Resolver) *checkRunner { +func newCheckRunner(msgMeta *module.MsgMetadata, log *log.Logger, r dns.Resolver) *checkRunner { return &checkRunner{ msgMeta: msgMeta, checkedRcptsPerCheck: map[module.CheckState]map[string]struct{}{}, @@ -73,7 +73,9 @@ func (cr *checkRunner) checkStates(ctx context.Context, checks []module.Check) ( newStatesMap := make(map[module.Check]module.CheckState, len(checks)) closeStates := func() { for _, state := range states { - state.Close() + if err := state.Close(); err != nil { + cr.log.Error("failed to close check state", err) + } } } @@ -125,7 +127,6 @@ func (cr *checkRunner) checkStates(ctx context.Context, checks []module.Check) ( if len(cr.checkedRcpts) != 0 { for _, rcpt := range cr.checkedRcpts { - rcpt := rcpt err := cr.runAndMergeResults(states, func(s module.CheckState) module.CheckResult { // Avoid calling CheckRcpt for the same recipient for the same check // multiple times, even if requested. @@ -176,7 +177,6 @@ func (cr *checkRunner) runAndMergeResults(states []module.CheckState, runner fun }{} for _, state := range states { - state := state data.wg.Add(1) go func() { defer func() { @@ -344,8 +344,12 @@ func (cr *checkRunner) applyResults(hostname string, header *textproto.Header) e } func (cr *checkRunner) close() { - cr.dmarcVerify.Close() + if err := cr.dmarcVerify.Close(); err != nil { + cr.log.Error("failed to close dmarc verify state", err) + } for _, state := range cr.states { - state.Close() + if err := state.Close(); err != nil { + cr.log.Error("failed to close check state", err) + } } } diff --git a/internal/msgpipeline/config_test.go b/internal/msgpipeline/config_test.go index b7ac666a9..24d7e51f8 100644 --- a/internal/msgpipeline/config_test.go +++ b/internal/msgpipeline/config_test.go @@ -224,7 +224,6 @@ func TestMsgPipelineCfg(t *testing.T) { } for _, case_ := range cases { - case_ := case_ t.Run(case_.name, func(t *testing.T) { cfg, _ := parser.Read(strings.NewReader(case_.str), "literal") parsed, err := parseMsgPipelineRootCfg(nil, cfg) diff --git a/internal/msgpipeline/dmarc_test.go b/internal/msgpipeline/dmarc_test.go index 8b7e1222c..e1c5656a3 100644 --- a/internal/msgpipeline/dmarc_test.go +++ b/internal/msgpipeline/dmarc_test.go @@ -30,6 +30,7 @@ import ( "github.com/emersion/go-message/textproto" "github.com/emersion/go-msgauth/authres" + "github.com/emersion/go-smtp" "github.com/foxcpp/go-mockdns" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/exterrors" @@ -54,12 +55,12 @@ func doTestDelivery(t *testing.T, tgt module.DeliveryTarget, from string, to []s panic(err) } - delivery, err := tgt.Start(context.Background(), &ctx, from) + delivery, err := tgt.StartDelivery(context.Background(), &ctx, from) if err != nil { return encodedID, err } for _, rcpt := range to { - if err := delivery.AddRcpt(context.Background(), rcpt); err != nil { + if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil { if err := delivery.Abort(context.Background()); err != nil { t.Log("delivery.Abort:", err) } diff --git a/internal/msgpipeline/modifier_test.go b/internal/msgpipeline/modifier_test.go index 02dc1e58e..ac3a0d7dc 100644 --- a/internal/msgpipeline/modifier_test.go +++ b/internal/msgpipeline/modifier_test.go @@ -234,9 +234,9 @@ func TestMsgPipeline_RcptModifier(t *testing.T) { target := testutils.Target{} mod := testutils.Modifier{ InstName: "test_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1-alias@example.com", - "rcpt2@example.com": "rcpt2-alias@example.com", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1-alias@example.com"}, + "rcpt2@example.com": []string{"rcpt2-alias@example.com"}, }, } d := MsgPipeline{ @@ -272,9 +272,9 @@ func TestMsgPipeline_RcptModifier_OriginalRcpt(t *testing.T) { target := testutils.Target{} mod := testutils.Modifier{ InstName: "test_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1-alias@example.com", - "rcpt2@example.com": "rcpt2-alias@example.com", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1-alias@example.com"}, + "rcpt2@example.com": []string{"rcpt2-alias@example.com"}, }, } d := MsgPipeline{ @@ -318,15 +318,15 @@ func TestMsgPipeline_RcptModifier_OriginalRcpt_Multiple(t *testing.T) { target := testutils.Target{} mod1, mod2 := testutils.Modifier{ InstName: "first_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1-alias@example.com", - "rcpt2@example.com": "rcpt2-alias@example.com", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1-alias@example.com"}, + "rcpt2@example.com": []string{"rcpt2-alias@example.com"}, }, }, testutils.Modifier{ InstName: "second_modifier", - RcptTo: map[string]string{ - "rcpt1-alias@example.com": "rcpt1-alias2@example.com", - "rcpt2@example.com": "wtf@example.com", + RcptTo: map[string][]string{ + "rcpt1-alias@example.com": []string{"rcpt1-alias2@example.com"}, + "rcpt2@example.com": []string{"wtf@example.com"}, }, } d := MsgPipeline{ @@ -373,15 +373,15 @@ func TestMsgPipeline_RcptModifier_Multiple(t *testing.T) { target := testutils.Target{} mod1, mod2 := testutils.Modifier{ InstName: "first_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1-alias@example.com", - "rcpt2@example.com": "rcpt2-alias@example.com", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1-alias@example.com"}, + "rcpt2@example.com": []string{"rcpt2-alias@example.com"}, }, }, testutils.Modifier{ InstName: "second_modifier", - RcptTo: map[string]string{ - "rcpt1-alias@example.com": "rcpt1-alias2@example.com", - "rcpt2@example.com": "wtf@example.com", + RcptTo: map[string][]string{ + "rcpt1-alias@example.com": []string{"rcpt1-alias2@example.com"}, + "rcpt2@example.com": []string{"wtf@example.com"}, }, } d := MsgPipeline{ @@ -417,15 +417,15 @@ func TestMsgPipeline_RcptModifier_PreDispatch(t *testing.T) { target := testutils.Target{} mod1, mod2 := testutils.Modifier{ InstName: "first_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1-alias@example.com", - "rcpt2@example.com": "rcpt2-alias@example.com", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1-alias@example.com"}, + "rcpt2@example.com": []string{"rcpt2-alias@example.com"}, }, }, testutils.Modifier{ InstName: "second_modifier", - RcptTo: map[string]string{ - "rcpt1-alias@example.com": "rcpt1-alias2@example.com", - "rcpt2@example.com": "wtf@example.com", + RcptTo: map[string][]string{ + "rcpt1-alias@example.com": []string{"rcpt1-alias2@example.com"}, + "rcpt2@example.com": []string{"wtf@example.com"}, }, } d := MsgPipeline{ @@ -469,9 +469,9 @@ func TestMsgPipeline_RcptModifier_PostDispatch(t *testing.T) { target := testutils.Target{} mod := testutils.Modifier{ InstName: "test_modifier", - RcptTo: map[string]string{ - "rcpt1@example.com": "rcpt1@example.org", - "rcpt2@example.com": "rcpt2@example.org", + RcptTo: map[string][]string{ + "rcpt1@example.com": []string{"rcpt1@example.org"}, + "rcpt2@example.com": []string{"rcpt2@example.org"}, }, } d := MsgPipeline{ diff --git a/internal/msgpipeline/module.go b/internal/msgpipeline/module.go index cf30d2214..f7c807922 100644 --- a/internal/msgpipeline/module.go +++ b/internal/msgpipeline/module.go @@ -20,24 +20,26 @@ package msgpipeline import ( "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Module struct { instName string - log log.Logger + log *log.Logger *MsgPipeline } -func NewModule(modName, instName string, aliases, inlineArgs []string) (module.Module, error) { +func NewModule(c *container.C, modName, instName string) (module.Module, error) { return &Module{ - log: log.Logger{Name: "msgpipeline"}, + log: c.DefaultLogger.Sublogger(modName), instName: instName, }, nil } -func (m *Module) Init(cfg *config.Map) error { +func (m *Module) Configure(inlineArgs []string, cfg *config.Map) error { var hostname string cfg.String("hostname", true, true, "", &hostname) cfg.Bool("debug", true, false, &m.log.Debug) @@ -52,7 +54,7 @@ func (m *Module) Init(cfg *config.Map) error { return err } m.MsgPipeline = p - m.MsgPipeline.Log = m.log + m.Log = m.log return nil } @@ -66,5 +68,5 @@ func (m *Module) InstanceName() string { } func init() { - module.Register("msgpipeline", NewModule) + modules.Register("msgpipeline", NewModule) } diff --git a/internal/msgpipeline/msgpipeline.go b/internal/msgpipeline/msgpipeline.go index 9c589abac..6025bf065 100644 --- a/internal/msgpipeline/msgpipeline.go +++ b/internal/msgpipeline/msgpipeline.go @@ -61,7 +61,7 @@ type MsgPipeline struct { // exactly in this place. FirstPipeline bool - Log log.Logger + Log *log.Logger } type rcptIn struct { @@ -90,10 +90,11 @@ func New(globals map[string]interface{}, cfg []config.Node) (*MsgPipeline, error return &MsgPipeline{ msgpipelineCfg: parsedCfg, Resolver: dns.DefaultResolver(), + Log: log.DefaultLogger.Sublogger("msgpipeline"), }, err } -func (d *MsgPipeline) RunEarlyChecks(ctx context.Context, state *smtp.ConnectionState) error { +func (d *MsgPipeline) RunEarlyChecks(ctx context.Context, state *module.ConnState) error { eg, checkCtx := errgroup.WithContext(ctx) // TODO: See if there is some point in parallelization of this @@ -111,13 +112,13 @@ func (d *MsgPipeline) RunEarlyChecks(ctx context.Context, state *smtp.Connection return eg.Wait() } -// Start starts new message delivery, runs connection and sender checks, sender modifiers +// StartDelivery starts new message delivery, runs connection and sender checks, sender modifiers // and selects source block from config to use for handling. // // Returned module.Delivery implements PartialDelivery. If underlying target doesn't // support it, msgpipeline will copy the returned error for all recipients handled // by target. -func (d *MsgPipeline) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { +func (d *MsgPipeline) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { dd := msgpipelineDelivery{ d: d, rcptModifiersState: make(map[*rcptBlock]module.ModifierState), @@ -186,7 +187,9 @@ func (dd *msgpipelineDelivery) initRunGlobalModifiers(ctx context.Context, msgMe } mailFrom, err = globalModifiersState.RewriteSender(ctx, mailFrom) if err != nil { - globalModifiersState.Close() + if err := globalModifiersState.Close(); err != nil { + dd.log.Error("failed to close global modifiers state", err) + } return "", err } dd.globalModifiersState = globalModifiersState @@ -267,7 +270,7 @@ type msgpipelineDelivery struct { sourceModifiersState module.ModifierState rcptModifiersState map[*rcptBlock]module.ModifierState - log log.Logger + log *log.Logger sourceAddr string sourceBlock sourceBlock @@ -277,7 +280,7 @@ type msgpipelineDelivery struct { checkRunner *checkRunner } -func (dd *msgpipelineDelivery) AddRcpt(ctx context.Context, to string) error { +func (dd *msgpipelineDelivery) AddRcpt(ctx context.Context, to string, opts smtp.RcptOptions) error { if err := dd.checkRunner.checkRcpt(ctx, dd.d.globalChecks, to); err != nil { return err } @@ -292,75 +295,86 @@ func (dd *msgpipelineDelivery) AddRcpt(ctx context.Context, to string) error { return err } dd.log.Debugln("global rcpt modifiers:", to, "=>", newTo) - to = newTo - newTo, err = dd.sourceModifiersState.RewriteRcpt(ctx, to) - if err != nil { - return err - } - dd.log.Debugln("per-source rcpt modifiers:", to, "=>", newTo) - to = newTo - - wrapErr := func(err error) error { - return exterrors.WithFields(err, map[string]interface{}{ - "effective_rcpt": to, - }) - } - - rcptBlock, err := dd.rcptBlockForAddr(ctx, to) - if err != nil { - return wrapErr(err) - } + resultTo := newTo + newTo = []string{} - if rcptBlock.rejectErr != nil { - return wrapErr(rcptBlock.rejectErr) - } - - if err := dd.checkRunner.checkRcpt(ctx, rcptBlock.checks, to); err != nil { - return wrapErr(err) - } - - rcptModifiersState, err := dd.getRcptModifiers(ctx, rcptBlock, to) - if err != nil { - return wrapErr(err) + for _, to = range resultTo { + var tempTo []string + tempTo, err = dd.sourceModifiersState.RewriteRcpt(ctx, to) + if err != nil { + return err + } + newTo = append(newTo, tempTo...) } + dd.log.Debugln("per-source rcpt modifiers:", to, "=>", newTo) + resultTo = newTo - newTo, err = rcptModifiersState.RewriteRcpt(ctx, to) - if err != nil { - rcptModifiersState.Close() - return wrapErr(err) - } - dd.log.Debugln("per-rcpt modifiers:", to, "=>", newTo) - to = newTo + for _, to = range resultTo { + wrapErr := func(err error) error { + return exterrors.WithFields(err, map[string]interface{}{ + "effective_rcpt": to, + }) + } - wrapErr = func(err error) error { - return exterrors.WithFields(err, map[string]interface{}{ - "effective_rcpt": to, - }) - } + rcptBlock, err := dd.rcptBlockForAddr(ctx, to) + if err != nil { + return wrapErr(err) + } - if originalTo != to { - dd.msgMeta.OriginalRcpts[to] = originalTo - } + if rcptBlock.rejectErr != nil { + return wrapErr(rcptBlock.rejectErr) + } - for _, tgt := range rcptBlock.targets { - // Do not wrap errors coming from nested pipeline target delivery since - // that pipeline itself will insert effective_rcpt field and could do - // its own rewriting - we do not want to hide it from the admin in - // error messages. - wrapErr := wrapErr - if _, ok := tgt.(*MsgPipeline); ok { - wrapErr = func(err error) error { return err } + if err := dd.checkRunner.checkRcpt(ctx, rcptBlock.checks, to); err != nil { + return wrapErr(err) } - delivery, err := dd.getDelivery(ctx, tgt) + rcptModifiersState, err := dd.getRcptModifiers(ctx, rcptBlock, to) if err != nil { return wrapErr(err) } - if err := delivery.AddRcpt(ctx, to); err != nil { + newTo, err = rcptModifiersState.RewriteRcpt(ctx, to) + if err != nil { + if err := rcptModifiersState.Close(); err != nil { + dd.log.Error("failed to close rcpt modifiers state", err) + } return wrapErr(err) } - delivery.recipients = append(delivery.recipients, originalTo) + dd.log.Debugln("per-rcpt modifiers:", to, "=>", newTo) + + for _, to = range newTo { + wrapErr = func(err error) error { + return exterrors.WithFields(err, map[string]interface{}{ + "effective_rcpt": to, + }) + } + + if originalTo != to { + dd.msgMeta.OriginalRcpts[to] = originalTo + } + + for _, tgt := range rcptBlock.targets { + // Do not wrap errors coming from nested pipeline target delivery since + // that pipeline itself will insert effective_rcpt field and could do + // its own rewriting - we do not want to hide it from the admin in + // error messages. + wrapErr := wrapErr + if _, ok := tgt.(*MsgPipeline); ok { + wrapErr = func(err error) error { return err } + } + + delivery, err := dd.getDelivery(ctx, tgt) + if err != nil { + return wrapErr(err) + } + + if err := delivery.AddRcpt(ctx, to, opts); err != nil { + return wrapErr(err) + } + delivery.recipients = append(delivery.recipients, originalTo) + } + } } return nil @@ -492,7 +506,7 @@ func (dd *msgpipelineDelivery) BodyNonAtomic(ctx context.Context, c module.Statu } } -func (dd msgpipelineDelivery) Commit(ctx context.Context) error { +func (dd *msgpipelineDelivery) Commit(ctx context.Context) error { dd.close() for _, delivery := range dd.deliveries { @@ -508,17 +522,23 @@ func (dd *msgpipelineDelivery) close() { dd.checkRunner.close() if dd.globalModifiersState != nil { - dd.globalModifiersState.Close() + if err := dd.globalModifiersState.Close(); err != nil { + dd.log.Error("failed to close global modifiers state", err) + } } if dd.sourceModifiersState != nil { - dd.sourceModifiersState.Close() + if err := dd.sourceModifiersState.Close(); err != nil { + dd.log.Error("failed to close source modifiers state", err) + } } for _, modifiers := range dd.rcptModifiersState { - modifiers.Close() + if err := modifiers.Close(); err != nil { + dd.log.Error("failed to close rcpt modifiers state", err) + } } } -func (dd msgpipelineDelivery) Abort(ctx context.Context) error { +func (dd *msgpipelineDelivery) Abort(ctx context.Context) error { dd.close() var lastErr error @@ -613,14 +633,14 @@ func (dd *msgpipelineDelivery) getDelivery(ctx context.Context, tgt module.Deliv return delivery_, nil } - deliveryObj, err := tgt.Start(ctx, dd.msgMeta, dd.sourceAddr) + deliveryObj, err := tgt.StartDelivery(ctx, dd.msgMeta, dd.sourceAddr) if err != nil { - dd.log.Debugf("tgt.Start(%s) failure, target = %s: %v", dd.sourceAddr, objectName(tgt), err) + dd.log.Debugf("tgt.StartDelivery(%s) failure, target = %s: %v", dd.sourceAddr, objectName(tgt), err) return nil, err } delivery_ = &delivery{Delivery: deliveryObj} - dd.log.Debugf("tgt.Start(%s) ok, target = %s", dd.sourceAddr, objectName(tgt)) + dd.log.Debugf("tgt.StartDelivery(%s) ok, target = %s", dd.sourceAddr, objectName(tgt)) dd.deliveries[tgt] = delivery_ return delivery_, nil diff --git a/internal/msgpipeline/msgpipeline_test.go b/internal/msgpipeline/msgpipeline_test.go index 212a7ef59..6d09da002 100644 --- a/internal/msgpipeline/msgpipeline_test.go +++ b/internal/msgpipeline/msgpipeline_test.go @@ -24,8 +24,10 @@ import ( "testing" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/internal/modify" "github.com/foxcpp/maddy/internal/testutils" ) @@ -378,14 +380,14 @@ func TestMsgPipeline_PerSourceReject(t *testing.T) { testutils.DoTestDelivery(t, &d, "sender1@example.com", []string{"rcpt@example.com"}) - _, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.com") + _, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.com") if err == nil { - t.Error("expected error for delivery.Start, got nil") + t.Error("expected error for delivery.StartDelivery, got nil") } - _, err = d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.org") + _, err = d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender2@example.org") if err == nil { - t.Error("expected error for delivery.Start, got nil") + t.Error("expected error for delivery.StartDelivery, got nil") } } @@ -411,9 +413,9 @@ func TestMsgPipeline_PerRcptReject(t *testing.T) { Log: testutils.Logger(t, "msgpipeline"), } - delivery, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender@example.com") + delivery, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, "sender@example.com") if err != nil { - t.Fatalf("unexpected Start err: %v", err) + t.Fatalf("unexpected StartDelivery err: %v", err) } defer func() { if err := delivery.Abort(context.Background()); err != nil { @@ -421,10 +423,10 @@ func TestMsgPipeline_PerRcptReject(t *testing.T) { } }() - if err := delivery.AddRcpt(context.Background(), "rcpt2@example.com"); err == nil { + if err := delivery.AddRcpt(context.Background(), "rcpt2@example.com", smtp.RcptOptions{}); err == nil { t.Fatalf("expected error for delivery.AddRcpt(rcpt2@example.com), got nil") } - if err := delivery.AddRcpt(context.Background(), "rcpt1@example.com"); err != nil { + if err := delivery.AddRcpt(context.Background(), "rcpt1@example.com", smtp.RcptOptions{}); err != nil { t.Fatalf("unexpected AddRcpt err for %s: %v", "rcpt1@example.com", err) } if err := delivery.Body(context.Background(), textproto.Header{}, buffer.MemoryBuffer{Slice: []byte("foobar")}); err != nil { @@ -626,7 +628,7 @@ func TestMsgPipeline_MalformedSource(t *testing.T) { // Simple checks for violations that can make msgpipeline misbehave. for _, addr := range []string{"not_postmaster_but_no_at_sign", "@no_mailbox", "no_domain@"} { - _, err := d.Start(context.Background(), &module.MsgMetadata{ID: "testing"}, addr) + _, err := d.StartDelivery(context.Background(), &module.MsgMetadata{ID: "testing"}, addr) if err == nil { t.Errorf("%s is accepted as valid address", addr) } @@ -659,3 +661,46 @@ func TestMsgPipeline_TwoRcptToOneTarget(t *testing.T) { } testutils.CheckTestMessage(t, &target, 0, "sender@example.com", []string{"recipient@example.com", "recipient@example.org"}) } + +func TestMsgPipeline_multi_alias(t *testing.T) { + target1, target2 := testutils.Target{InstName: "target1"}, testutils.Target{InstName: "target2"} + mod := testutils.Modifier{ + RcptTo: map[string][]string{ + "recipient@example.com": []string{ + "recipient-1@example.org", + "recipient-2@example.net", + }, + }, + } + d := MsgPipeline{ + msgpipelineCfg: msgpipelineCfg{ + perSource: map[string]sourceBlock{}, + defaultSource: sourceBlock{ + modifiers: modify.Group{ + Modifiers: []module.Modifier{mod}, + }, + perRcpt: map[string]*rcptBlock{ + "example.org": { + targets: []module.DeliveryTarget{&target1}, + }, + "example.net": { + targets: []module.DeliveryTarget{&target2}, + }, + }, + }, + }, + Log: testutils.Logger(t, "msgpipeline"), + } + + testutils.DoTestDelivery(t, &d, "sender@example.com", []string{"recipient@example.com"}) + + if len(target1.Messages) != 1 { + t.Errorf("wrong amount of messages received for target1, want %d, got %d", 1, len(target1.Messages)) + } + testutils.CheckTestMessage(t, &target1, 0, "sender@example.com", []string{"recipient-1@example.org"}) + + if len(target2.Messages) != 1 { + t.Errorf("wrong amount of messages received for target1, want %d, got %d", 1, len(target2.Messages)) + } + testutils.CheckTestMessage(t, &target2, 0, "sender@example.com", []string{"recipient-2@example.net"}) +} diff --git a/internal/proxy_protocol/proxy_protocol.go b/internal/proxy_protocol/proxy_protocol.go new file mode 100644 index 000000000..24fe20c0a --- /dev/null +++ b/internal/proxy_protocol/proxy_protocol.go @@ -0,0 +1,85 @@ +package proxy_protocol + +import ( + "crypto/tls" + "net" + "strings" + + "github.com/c0va23/go-proxyprotocol" + "github.com/foxcpp/maddy/framework/config" + tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/log" +) + +type ProxyProtocol struct { + trust []net.IPNet + tlsConfig *tls.Config +} + +func ProxyProtocolDirective(_ *config.Map, node config.Node) (interface{}, error) { + p := ProxyProtocol{} + + childM := config.NewMap(nil, node) + var trustList []string + + childM.StringList("trust", false, false, nil, &trustList) + childM.Custom("tls", true, false, nil, tls2.TLSDirective, &p.tlsConfig) + + if _, err := childM.Process(); err != nil { + return nil, err + } + + if len(node.Args)> 0 { + if trustList == nil { + trustList = make([]string, 0) + } + trustList = append(trustList, node.Args...) + } + + for _, trust := range trustList { + if !strings.Contains(trust, "/") { + trust += "/32" + } + _, ipNet, err := net.ParseCIDR(trust) + if err != nil { + return nil, err + } + p.trust = append(p.trust, *ipNet) + } + + return &p, nil +} + +func NewListener(inner net.Listener, p *ProxyProtocol, logger *log.Logger) net.Listener { + var listener net.Listener + + sourceChecker := func(upstream net.Addr) (bool, error) { + if tcpAddr, ok := upstream.(*net.TCPAddr); ok { + if len(p.trust) == 0 { + return true, nil + } + for _, trusted := range p.trust { + if trusted.Contains(tcpAddr.IP) { + return true, nil + } + } + } else if _, ok := upstream.(*net.UnixAddr); ok { + // UNIX local socket connection, always trusted + return true, nil + } + + logger.Printf("connection from untrusted source %s", upstream) + return false, nil + } + + proxyListener := proxyprotocol.NewDefaultListener(inner). + WithLogger(proxyprotocol.LoggerFunc(logger.Debugf)). + WithSourceChecker(sourceChecker) + listener = &proxyListener + + if p.tlsConfig != nil { + listener = tls.NewListener(listener, p.tlsConfig) + } + + return listener +} diff --git a/internal/smtpconn/pool/pool.go b/internal/smtpconn/pool/pool.go index bbe059a82..aee82fd70 100644 --- a/internal/smtpconn/pool/pool.go +++ b/internal/smtpconn/pool/pool.go @@ -22,10 +22,13 @@ import ( "context" "sync" "time" + + "github.com/foxcpp/maddy/framework/log" ) type Conn interface { Usable() bool + LastUseAt() time.Time Close() error } @@ -47,6 +50,8 @@ type P struct { cfg Config keys map[string]slot keysLock sync.Mutex + + cleanupStop chan struct{} } func New(cfg Config) *P { @@ -56,33 +61,81 @@ func New(cfg Config) *P { } } - return &P{ - cfg: cfg, - keys: make(map[string]slot, cfg.MaxKeys), + p := &P{ + cfg: cfg, + keys: make(map[string]slot, cfg.MaxKeys), + cleanupStop: make(chan struct{}), } + + go p.cleanUpTick(p.cleanupStop) + + return p } -func (p *P) Get(ctx context.Context, key string) (Conn, error) { - // TODO: See if it is possible to get rid of this lock. +func (p *P) cleanUpTick(stop chan struct{}) { + ctx := context.Background() + tick := time.NewTicker(time.Minute) + defer tick.Stop() + + for { + select { + case <-tick.c: + p.CleanUp(ctx) + case <-stop: + return + } + } +} + +func (p *P) CleanUp(ctx context.Context) { p.keysLock.Lock() defer p.keysLock.Unlock() + for k, v := range p.keys { + if v.lastUse+p.cfg.StaleKeyLifetimeSec> time.Now().Unix() { + continue + } + + close(v.c) + for conn := range v.c { + go p.close(conn) + } + delete(p.keys, k) + } +} + +func (p *P) close(c Conn) { + if err := c.Close(); err != nil { + log.DefaultLogger.Error("failed to close pooled connection", err) + } +} + +func (p *P) Get(ctx context.Context, key string) (Conn, error) { + p.keysLock.Lock() + bucket, ok := p.keys[key] if !ok { + p.keysLock.Unlock() return p.cfg.New(ctx, key) } if time.Now().Unix()-bucket.lastUse> p.cfg.MaxConnLifetimeSec { // Drop bucket. + delete(p.keys, key) close(bucket.c) + + // Close might take some time, unlock early. + p.keysLock.Unlock() + for conn := range bucket.c { - conn.Close() + p.close(conn) } - delete(p.keys, key) return p.cfg.New(ctx, key) } + p.keysLock.Unlock() + for { var conn Conn select { @@ -95,6 +148,16 @@ func (p *P) Get(ctx context.Context, key string) (Conn, error) { } if !conn.Usable() { + // Close might take some time, run in parallel. + go p.close(conn) + continue + } + if conn.LastUseAt().Add(time.Duration(p.cfg.MaxConnLifetimeSec) * time.Second).Before(time.Now()) { + go func() { + if err := conn.Close(); err != nil { + log.DefaultLogger.Error("failed to close pooled connection", err) + } + }() continue } @@ -118,12 +181,12 @@ func (p *P) Return(key string, c Conn) { if v.lastUse+p.cfg.StaleKeyLifetimeSec> time.Now().Unix() { continue } - + delete(p.keys, k) close(v.c) + for conn := range v.c { - conn.Close() + p.close(conn) } - delete(p.keys, k) } } @@ -139,18 +202,20 @@ func (p *P) Return(key string, c Conn) { bucket.lastUse = time.Now().Unix() default: // Let it go, let it go... - c.Close() + go p.close(c) } } func (p *P) Close() { + p.cleanupStop <- struct{}{} + p.keysLock.Lock() defer p.keysLock.Unlock() for k, v := range p.keys { close(v.c) for conn := range v.c { - conn.Close() + p.close(conn) } delete(p.keys, k) } diff --git a/internal/smtpconn/smtpconn.go b/internal/smtpconn/smtpconn.go index e6231f7c5..7ec93cac3 100644 --- a/internal/smtpconn/smtpconn.go +++ b/internal/smtpconn/smtpconn.go @@ -16,7 +16,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -// The package smtpconn contains the code shared between target.smtp and +// Package smtpconn contains the code shared between target.smtp and // remote modules. // // It implements the wrapper over the SMTP connection (go-smtp.Client) object @@ -73,12 +73,13 @@ type C struct { TLSConfig *tls.Config // Logger to use for debug log and certain errors. - Log log.Logger + Log *log.Logger // Include the remote server address in SMTP status messages in the form // "ADDRESS said: ..." AddrInSMTPMsg bool + conn net.Conn serverName string cl *smtp.Client rcpts []string @@ -163,26 +164,36 @@ func (c *C) wrapClientErr(err error, serverName string) error { // Connect actually estabilishes the network connection with the remote host, // executes HELO/EHLO and optionally STARTTLS command. func (c *C) Connect(ctx context.Context, endp config.Endpoint, starttls bool, tlsConfig *tls.Config) (didTLS bool, err error) { - didTLS, cl, err := c.attemptConnect(ctx, false, endp, starttls, tlsConfig) + didTLS, cl, conn, err := c.attemptConnect(ctx, false, endp, starttls, tlsConfig) if err != nil { return false, c.wrapClientErr(err, endp.Host) } c.serverName = endp.Host c.cl = cl + c.conn = conn + + c.Log.DebugMsg("connected", "remote_server", c.serverName, + "local_addr", c.LocalAddr(), "remote_addr", c.RemoteAddr()) + return didTLS, nil } // ConnectLMTP estabilishes the network connection with the remote host and // sends LHLO command, negotiating LMTP use. func (c *C) ConnectLMTP(ctx context.Context, endp config.Endpoint, starttls bool, tlsConfig *tls.Config) (didTLS bool, err error) { - didTLS, cl, err := c.attemptConnect(ctx, true, endp, starttls, tlsConfig) + didTLS, cl, conn, err := c.attemptConnect(ctx, true, endp, starttls, tlsConfig) if err != nil { return false, c.wrapClientErr(err, endp.Host) } c.serverName = endp.Host c.cl = cl + c.conn = conn + + c.Log.DebugMsg("connected", "remote_server", c.serverName, + "local_addr", c.LocalAddr(), "remote_addr", c.RemoteAddr()) + return didTLS, nil } @@ -203,14 +214,32 @@ func (err TLSError) Unwrap() error { return err.Err } -func (c *C) attemptConnect(ctx context.Context, lmtp bool, endp config.Endpoint, starttls bool, tlsConfig *tls.Config) (didTLS bool, cl *smtp.Client, err error) { - var conn net.Conn +func (c *C) LocalAddr() net.Addr { + if c.conn == nil { + return nil + } + return c.conn.LocalAddr() +} + +func (c *C) RemoteAddr() net.Addr { + if c.conn == nil { + return nil + } + return c.conn.RemoteAddr() +} + +func (c *C) closeClient(cl *smtp.Client) { + if err := cl.Close(); err != nil { + c.Log.Error("client connection close failed", err) + } +} +func (c *C) attemptConnect(ctx context.Context, lmtp bool, endp config.Endpoint, starttls bool, tlsConfig *tls.Config) (didTLS bool, cl *smtp.Client, conn net.Conn, err error) { dialCtx, cancel := context.WithTimeout(ctx, c.ConnectTimeout) conn, err = c.Dialer(dialCtx, endp.Network(), endp.Address()) cancel() if err != nil { - return false, nil, err + return false, nil, nil, fmt.Errorf("dialer: %w", err) } if endp.IsTLS() { @@ -222,13 +251,9 @@ func (c *C) attemptConnect(ctx context.Context, lmtp bool, endp config.Endpoint, c.lmtp = lmtp // This uses initial greeting timeout of 5 minutes (hardcoded). if lmtp { - cl, err = smtp.NewClientLMTP(conn, endp.Host) + cl = smtp.NewClientLMTP(conn) } else { - cl, err = smtp.NewClient(conn, endp.Host) - } - if err != nil { - conn.Close() - return false, nil, err + cl = smtp.NewClient(conn) } cl.CommandTimeout = c.CommandTimeout @@ -236,16 +261,19 @@ func (c *C) attemptConnect(ctx context.Context, lmtp bool, endp config.Endpoint, // i18n: hostname is already expected to be in A-labels form. if err := cl.Hello(c.Hostname); err != nil { - cl.Close() - return false, nil, err + c.closeClient(cl) + return false, nil, nil, err } - if endp.IsTLS() || !starttls { - return endp.IsTLS(), cl, nil + if !starttls { + return false, cl, conn, nil } if ok, _ := cl.Extension("STARTTLS"); !ok { - return false, cl, nil + if err := cl.Quit(); err != nil { + c.closeClient(cl) + } + return false, nil, nil, fmt.Errorf("TLS required but unsupported by downstream") } cfg := tlsConfig.Clone() @@ -256,13 +284,25 @@ func (c *C) attemptConnect(ctx context.Context, lmtp bool, endp config.Endpoint, // *after* the handshake (e.g. PKI verification fail), we don't log the error in // this case though. if err := cl.Quit(); err != nil { - cl.Close() + c.closeClient(cl) + } + + return false, nil, nil, TLSError{err} + } + + // Re-do HELO using our hostname instead of localhost. + if err := cl.Hello(c.Hostname); err != nil { + c.closeClient(cl) + + var tlsErr *tls.CertificateVerificationError + if errors.As(err, &tlsErr) { + return false, nil, nil, TLSError{Err: tlsErr} } - return false, nil, TLSError{err} + return false, nil, nil, err } - return true, cl, nil + return true, cl, conn, nil } // Mail sends the MAIL FROM command to the remote server. @@ -311,7 +351,6 @@ func (c *C) Mail(ctx context.Context, from string, opts smtp.MailOptions) error return c.wrapClientErr(err, c.serverName) } - c.Log.DebugMsg("connected", "remote_server", c.serverName) return nil } @@ -336,10 +375,14 @@ func (c *C) IsLMTP() bool { // // If the address is non-ASCII and cannot be converted to ASCII and the remote // server does not support SMTPUTF8, error will be returned. -func (c *C) Rcpt(ctx context.Context, to string) error { +func (c *C) Rcpt(ctx context.Context, to string, opts smtp.RcptOptions) error { defer trace.StartRegion(ctx, "smtpconn/RCPT TO").End() - // If necessary, the extension flag is enabled in Start. + outOpts := &smtp.RcptOptions{ + // TODO: DSN support + } + + // If necessary, the extension flag is enabled in StartDelivery. if ok, _ := c.cl.Extension("SMTPUTF8"); !address.IsASCII(to) && !ok { var err error to, err = address.ToASCII(to) @@ -356,7 +399,7 @@ func (c *C) Rcpt(ctx context.Context, to string) error { } } - if err := c.cl.Rcpt(to); err != nil { + if err := c.cl.Rcpt(to, outOpts); err != nil { return c.wrapClientErr(err, c.serverName) } @@ -482,11 +525,26 @@ func (c *C) Noop() error { return c.cl.Noop() } -// Close sends the QUIT command, if it fail - it directly closes the +// Close sends the QUIT command, if it fails - it directly closes the // connection. func (c *C) Close() error { + c.cl.CommandTimeout = 5 * time.Second + if err := c.cl.Quit(); err != nil { - c.Log.Error("QUIT error", c.wrapClientErr(err, c.serverName)) + var smtpErr *smtp.SMTPError + var netErr *net.OpError + if errors.As(err, &smtpErr) && smtpErr.Code == 421 { + // 421 "Service not available" is typically sent + // when idle timeout happens. + c.Log.DebugMsg("QUIT error", "reason", c.wrapClientErr(err, c.serverName)) + } else if errors.As(err, &netErr) && + (netErr.Timeout() || netErr.Err.Error() == "write: broken pipe" || netErr.Err.Error() == "read: connection reset") { + // The case for silently closed connections. + c.Log.DebugMsg("QUIT error", "reason", c.wrapClientErr(err, c.serverName)) + } else { + c.Log.Error("QUIT error", c.wrapClientErr(err, c.serverName)) + } + return c.cl.Close() } @@ -499,8 +557,8 @@ func (c *C) Close() error { // DirectClose closes the underlying connection without sending the QUIT // command. func (c *C) DirectClose() error { - c.cl.Close() + cl := c.cl c.cl = nil c.serverName = "" - return nil + return cl.Close() } diff --git a/internal/smtpconn/smtpconn_test.go b/internal/smtpconn/smtpconn_test.go index 6279add97..b8fd647ec 100644 --- a/internal/smtpconn/smtpconn_test.go +++ b/internal/smtpconn/smtpconn_test.go @@ -24,7 +24,6 @@ import ( "os" "strconv" "testing" - "time" ) var testPort string @@ -34,7 +33,6 @@ func TestMain(m *testing.M) { flag.Parse() if *remoteSmtpPort == "random" { - rand.Seed(time.Now().UnixNano()) *remoteSmtpPort = strconv.Itoa(rand.Intn(65536-10000) + 10000) } diff --git a/internal/smtpconn/smtputf8_test.go b/internal/smtpconn/smtputf8_test.go index 22acf4103..44efe4fdd 100644 --- a/internal/smtpconn/smtputf8_test.go +++ b/internal/smtpconn/smtputf8_test.go @@ -28,6 +28,7 @@ import ( "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) func doTestDelivery(t *testing.T, conn *C, from string, to []string, opts smtp.MailOptions) error { @@ -37,7 +38,7 @@ func doTestDelivery(t *testing.T, conn *C, from string, to []string, opts smtp.M return err } for _, rcpt := range to { - if err := conn.Rcpt(context.Background(), rcpt); err != nil { + if err := conn.Rcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil { return err } } @@ -65,7 +66,9 @@ func TestSMTPUTF8(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) srv.EnableSMTPUTF8 = case_.serverUTF8 - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) c := New() @@ -77,7 +80,9 @@ func TestSMTPUTF8(t *testing.T) { }, false, nil); err != nil { t.Fatal(err) } - defer c.Close() + defer func() { + require.NoError(t, c.Close()) + }() err := doTestDelivery(t, c, case_.clientSender, []string{case_.clientRcpt}, smtp.MailOptions{UTF8: true}) diff --git a/internal/storage/imapsql/sqlite3.go b/internal/sqlite/is.go similarity index 80% rename from internal/storage/imapsql/sqlite3.go rename to internal/sqlite/is.go index 84cc6c907..953179a15 100644 --- a/internal/storage/imapsql/sqlite3.go +++ b/internal/sqlite/is.go @@ -1,8 +1,6 @@ -//+build !nosqlite3,cgo - /* Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -18,6 +16,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package imapsql +package sqliteprovider -import _ "github.com/mattn/go-sqlite3" +func IsSqliteDriver(name string) bool { + return name == "sqlite" || name == "sqlite3" +} diff --git a/internal/sqlite/modernc_sqlite3.go b/internal/sqlite/modernc_sqlite3.go new file mode 100644 index 000000000..6e31cf734 --- /dev/null +++ b/internal/sqlite/modernc_sqlite3.go @@ -0,0 +1,35 @@ +//go:build (!nosqlite3 && !cgo) || modernc + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package sqliteprovider + +import _ "modernc.org/sqlite" + +const ( + IsAvailable = true + IsTranspiled = true +) + +func MapDriverName(n string) string { + if n == "sqlite3" { + return "sqlite" + } + return n +} diff --git a/internal/sqlite/no_sqlite3.go b/internal/sqlite/no_sqlite3.go new file mode 100644 index 000000000..17682ae89 --- /dev/null +++ b/internal/sqlite/no_sqlite3.go @@ -0,0 +1,30 @@ +//go:build nosqlite3 + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package sqliteprovider + +const ( + IsAvailable = false + IsTranspiled = false +) + +func MapDriverName(n string) string { + return n +} diff --git a/internal/sqlite/sqlite3.go b/internal/sqlite/sqlite3.go new file mode 100644 index 000000000..38fb1dee9 --- /dev/null +++ b/internal/sqlite/sqlite3.go @@ -0,0 +1,35 @@ +//go:build !nosqlite3 && cgo && !modernc + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package sqliteprovider + +import _ "github.com/mattn/go-sqlite3" + +const ( + IsAvailable = true + IsTranspiled = false +) + +func MapDriverName(n string) string { + if n == "sqlite" { + return "sqlite3" + } + return n +} diff --git a/internal/storage/blob/fs/fs.go b/internal/storage/blob/fs/fs.go index e8c9b389b..0ef45cd01 100644 --- a/internal/storage/blob/fs/fs.go +++ b/internal/storage/blob/fs/fs.go @@ -8,7 +8,9 @@ import ( "path/filepath" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // FSStore struct represents directory on FS used to store blobs. @@ -17,26 +19,27 @@ type FSStore struct { root string } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - switch len(inlineArgs) { - case 0: - return &FSStore{instName: instName}, nil - case 1: - return &FSStore{instName: instName, root: inlineArgs[0]}, nil - default: - return nil, fmt.Errorf("storage.blob.fs: 1 or 0 arguments expected") - } +func New(_ *container.C, _, instName string) (module.Module, error) { + return &FSStore{instName: instName}, nil } -func (s FSStore) Name() string { +func (s *FSStore) Name() string { return "storage.blob.fs" } -func (s FSStore) InstanceName() string { +func (s *FSStore) InstanceName() string { return s.instName } -func (s *FSStore) Init(cfg *config.Map) error { +func (s *FSStore) Configure(inlineArgs []string, cfg *config.Map) error { + switch len(inlineArgs) { + case 0: + case 1: + s.root = inlineArgs[0] + default: + return fmt.Errorf("storage.blob.fs: 1 or 0 arguments expected") + } + cfg.String("root", false, false, s.root, &s.root) if _, err := cfg.Process(); err != nil { return err @@ -91,5 +94,5 @@ func (s *FSStore) Delete(_ context.Context, keys []string) error { func init() { var _ module.BlobStore = &FSStore{} - module.Register(FSStore{}.Name(), New) + modules.Register((&FSStore{}).Name(), New) } diff --git a/internal/storage/blob/fs/fs_test.go b/internal/storage/blob/fs/fs_test.go index 2c8f7664d..ec4635e4a 100644 --- a/internal/storage/blob/fs/fs_test.go +++ b/internal/storage/blob/fs/fs_test.go @@ -7,6 +7,7 @@ import ( "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/storage/blob" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) func TestFS(t *testing.T) { @@ -14,6 +15,6 @@ func TestFS(t *testing.T) { dir := testutils.Dir(t) return &FSStore{instName: "test", root: dir} }, func(store module.BlobStore) { - os.RemoveAll(store.(*FSStore).root) + require.NoError(t, os.RemoveAll(store.(*FSStore).root)) }) } diff --git a/internal/storage/blob/s3/s3.go b/internal/storage/blob/s3/s3.go index 719ede6ee..4b2c0d630 100644 --- a/internal/storage/blob/s3/s3.go +++ b/internal/storage/blob/s3/s3.go @@ -7,17 +7,27 @@ import ( "net/http" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) const modName = "storage.blob.s3" +const ( + credsTypeFileMinio = "file_minio" + credsTypeFileAWS = "file_aws" + credsTypeAccessKey = "access_key" + credsTypeIAM = "iam" + credsTypeDefault = credsTypeAccessKey +) + type Store struct { instName string - log log.Logger + log *log.Logger endpoint string cl *minio.Client @@ -26,22 +36,23 @@ type Store struct { objectPrefix string } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, fmt.Errorf("%s: expected 0 arguments", modName) - } - +func New(c *container.C, modName, instName string) (module.Module, error) { return &Store{ instName: instName, - log: log.Logger{Name: modName}, + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (s *Store) Init(cfg *config.Map) error { +func (s *Store) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return fmt.Errorf("%s: expected 0 arguments", modName) + } + var ( secure bool accessKeyID string secretAccessKey string + credsType string location string ) cfg.String("endpoint", false, true, "", &s.endpoint) @@ -51,6 +62,7 @@ func (s *Store) Init(cfg *config.Map) error { cfg.String("bucket", false, true, "", &s.bucketName) cfg.String("region", false, false, "", &location) cfg.String("object_prefix", false, false, "", &s.objectPrefix) + cfg.String("creds", false, false, credsTypeDefault, &credsType) if _, err := cfg.Process(); err != nil { return err @@ -59,8 +71,23 @@ func (s *Store) Init(cfg *config.Map) error { return fmt.Errorf("%s: endpoint not set", modName) } + var creds *credentials.Credentials + + switch credsType { + case credsTypeFileMinio: + creds = credentials.NewFileMinioClient("", "") + case credsTypeFileAWS: + creds = credentials.NewFileAWSCredentials("", "") + case credsTypeIAM: + creds = credentials.NewIAM("") + case credsTypeAccessKey: + creds = credentials.NewStaticV4(accessKeyID, secretAccessKey, "") + default: + creds = credentials.NewStaticV4(accessKeyID, secretAccessKey, "") + } + cl, err := minio.New(s.endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Creds: creds, Secure: secure, Region: location, }) @@ -95,7 +122,9 @@ func (b *s3blob) Sync() error { panic("storage.blob.s3: Sync called twice for a blob object") } - b.pw.Close() + if err := b.pw.Close(); err != nil { + return err + } b.didSync = true return <-b.errch } @@ -167,5 +196,5 @@ func (s *Store) Delete(ctx context.Context, keys []string) error { func init() { var _ module.BlobStore = &Store{} - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/storage/blob/s3/s3_test.go b/internal/storage/blob/s3/s3_test.go index 98dd228b2..78ba97008 100644 --- a/internal/storage/blob/s3/s3_test.go +++ b/internal/storage/blob/s3/s3_test.go @@ -28,7 +28,7 @@ func TestFS(t *testing.T) { } st := &Store{instName: "test"} - err := st.Init(config.NewMap(map[string]interface{}{}, config.Node{ + err := st.Configure(nil, config.NewMap(map[string]interface{}{}, config.Node{ Children: []config.Node{ { Name: "endpoint", diff --git a/internal/storage/blob/test_blob.go b/internal/storage/blob/test_blob.go index eead14dc3..f0b2e2d9b 100644 --- a/internal/storage/blob/test_blob.go +++ b/internal/storage/blob/test_blob.go @@ -1,4 +1,5 @@ -//+build cgo,!no_sqlite3 +//go:build cgo && !no_sqlite3 +// +build cgo,!no_sqlite3 package blob @@ -38,11 +39,11 @@ func TestStore(t *testing.T, newStore func() module.BlobStore, cleanStore func(m prng := rand.New(randSrc) store := newStore() + l := testutils.Logger(t, "imapsql") b, err := imapsql.New("sqlite3", ":memory:", imapsql2.ExtBlobStore{Base: store}, imapsql.Opts{ - LazyUpdatesInit: true, - PRNG: prng, - Log: testutils.Logger(t, "imapsql"), + PRNG: prng, + Log: l, }, ) if err != nil { diff --git a/internal/storage/blob/test_blob_nosqlite.go b/internal/storage/blob/test_blob_nosqlite.go index 72a7a33c5..601f46782 100644 --- a/internal/storage/blob/test_blob_nosqlite.go +++ b/internal/storage/blob/test_blob_nosqlite.go @@ -1,4 +1,5 @@ -//+build !cgo no_sqlite3 +//go:build !cgo || no_sqlite3 +// +build !cgo no_sqlite3 package blob diff --git a/internal/storage/imapsql/bench_test.go b/internal/storage/imapsql/bench_test.go index f18e91569..04cfac939 100644 --- a/internal/storage/imapsql/bench_test.go +++ b/internal/storage/imapsql/bench_test.go @@ -46,8 +46,7 @@ func createTestDB(tb testing.TB, compAlgo string) *Storage { } db, err := imapsql.New(testDB, testDSN, &imapsql.FSStore{Root: testFsstore}, imapsql.Opts{ - LazyUpdatesInit: true, - CompressAlgo: compAlgo, + CompressAlgo: compAlgo, }) if err != nil { tb.Fatal(err) diff --git a/internal/storage/imapsql/delivery.go b/internal/storage/imapsql/delivery.go index d7655c0b6..a20c6c3be 100644 --- a/internal/storage/imapsql/delivery.go +++ b/internal/storage/imapsql/delivery.go @@ -20,11 +20,13 @@ package imapsql import ( "context" + "errors" "runtime/trace" - specialuse "github.com/emersion/go-imap-specialuse" + "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" imapsql "github.com/foxcpp/go-imap-sql" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/exterrors" @@ -32,13 +34,16 @@ import ( "github.com/foxcpp/maddy/internal/target" ) +type addedRcpt struct { + rcptTo string +} type delivery struct { store *Storage msgMeta *module.MsgMetadata d imapsql.Delivery mailFrom string - addedRcpts map[string]struct{} + addedRcpts map[string]addedRcpt } func (d *delivery) String() string { @@ -55,7 +60,7 @@ func userDoesNotExist(actual error) error { } } -func (d *delivery) AddRcpt(ctx context.Context, rcptTo string) error { +func (d *delivery) AddRcpt(ctx context.Context, rcptTo string, _ smtp.RcptOptions) error { defer trace.StartRegion(ctx, "sql/AddRcpt").End() accountName, err := d.store.deliveryNormalize(ctx, rcptTo) @@ -74,10 +79,11 @@ func (d *delivery) AddRcpt(ctx context.Context, rcptTo string) error { userHeader.Add("Delivered-To", accountName) if err := d.d.AddRcpt(accountName, userHeader); err != nil { - if err == imapsql.ErrUserDoesntExists || err == backend.ErrNoSuchMailbox { + if errors.Is(err, imapsql.ErrUserDoesntExists) || errors.Is(err, backend.ErrNoSuchMailbox) { return userDoesNotExist(err) } - if _, ok := err.(imapsql.SerializationError); ok { + var serializationError imapsql.SerializationError + if errors.As(err, &serializationError) { return &exterrors.SMTPError{ Code: 453, EnhancedCode: exterrors.EnhancedCode{4, 3, 2}, @@ -89,7 +95,9 @@ func (d *delivery) AddRcpt(ctx context.Context, rcptTo string) error { return err } - d.addedRcpts[accountName] = struct{}{} + d.addedRcpts[accountName] = addedRcpt{ + rcptTo: rcptTo, + } return nil } @@ -97,10 +105,10 @@ func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffe defer trace.StartRegion(ctx, "sql/Body").End() if !d.msgMeta.Quarantine && d.store.filters != nil { - for rcpt := range d.addedRcpts { - folder, flags, err := d.store.filters.IMAPFilter(rcpt, d.msgMeta, header, body) + for rcpt, rcptData := range d.addedRcpts { + folder, flags, err := d.store.filters.IMAPFilter(rcpt, rcptData.rcptTo, d.msgMeta, header, body) if err != nil { - d.store.Log.Error("IMAPFilter failed", err, "rcpt", rcpt) + d.store.log.Error("IMAPFilter failed", err, "rcpt", rcpt) continue } d.d.UserMailbox(rcpt, folder, flags) @@ -108,12 +116,13 @@ func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffe } if d.msgMeta.Quarantine { - if err := d.d.SpecialMailbox(specialuse.Junk, d.store.junkMbox); err != nil { - if _, ok := err.(imapsql.SerializationError); ok { + if err := d.d.SpecialMailbox(imap.JunkAttr, d.store.junkMbox); err != nil { + var serializationError imapsql.SerializationError + if errors.As(err, &serializationError) { return &exterrors.SMTPError{ Code: 453, EnhancedCode: exterrors.EnhancedCode{4, 3, 2}, - Message: "Storage access serialiation problem, try again later", + Message: "Internal server error, try again later", TargetName: "imapsql", Err: err, } @@ -125,11 +134,12 @@ func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffe header = header.Copy() header.Add("Return-Path", "<"+target.sanitizeforheader(d.mailfrom)+">") err := d.d.BodyParsed(header, body.Len(), body) - if _, ok := err.(imapsql.SerializationError); ok { + var serializationError imapsql.SerializationError + if errors.As(err, &serializationError) { return &exterrors.SMTPError{ Code: 453, EnhancedCode: exterrors.EnhancedCode{4, 3, 2}, - Message: "Storage access serialiation problem, try again later", + Message: "Internal server error, try again later", TargetName: "imapsql", Err: err, } @@ -149,14 +159,14 @@ func (d *delivery) Commit(ctx context.Context) error { return d.d.Commit() } -func (store *Storage) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { - defer trace.StartRegion(ctx, "sql/Start").End() +func (store *Storage) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { + defer trace.StartRegion(ctx, "sql/StartDelivery").End() return &delivery{ store: store, msgMeta: msgMeta, mailFrom: mailFrom, d: store.Back.NewDelivery(), - addedRcpts: map[string]struct{}{}, + addedRcpts: map[string]addedRcpt{}, }, nil } diff --git a/internal/storage/imapsql/imapsql.go b/internal/storage/imapsql/imapsql.go index d357be1e3..fe9103dea 100644 --- a/internal/storage/imapsql/imapsql.go +++ b/internal/storage/imapsql/imapsql.go @@ -28,6 +28,7 @@ package imapsql import ( "context" "crypto/sha1" + "database/sql" "encoding/hex" "errors" "fmt" @@ -39,34 +40,43 @@ import ( "github.com/emersion/go-imap" sortthread "github.com/emersion/go-imap-sortthread" "github.com/emersion/go-imap/backend" + mess "github.com/foxcpp/go-imap-mess" imapsql "github.com/foxcpp/go-imap-sql" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/authz" + sqliteprovider "github.com/foxcpp/maddy/internal/sqlite" "github.com/foxcpp/maddy/internal/updatepipe" + "github.com/foxcpp/maddy/internal/updatepipe/pubsub" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" ) +const modName = "storage.imapsql" + type Storage struct { Back *imapsql.Backend instName string - Log log.Logger + log *log.Logger junkMbox string - driver string - dsn []string + driver string + dsn []string + blobStore module.BlobStore + opts *imapsql.Opts resolver dns.Resolver - updates <-chan backend.Update - updPipe updatepipe.P - updPushStop chan struct{} + updPipe updatepipe.P + updPushStop chan struct{} + outboundUpds chan mess.Update filters module.IMAPFilter @@ -77,35 +87,36 @@ type Storage struct { } func (store *Storage) Name() string { - return "imapsql" + return modName } func (store *Storage) InstanceName() string { return store.instName } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { store := &Storage{ instName: instName, - Log: log.Logger{Name: "imapsql"}, + log: c.DefaultLogger.Sublogger(modName), resolver: dns.DefaultResolver(), } + return store, nil +} + +func (store *Storage) Configure(inlineArgs []string, cfg *config.Map) error { if len(inlineArgs) != 0 { if len(inlineArgs) == 1 { - return nil, errors.New("imapsql: expected at least 2 arguments") + return errors.New("imapsql: expected at least 2 arguments") } store.driver = inlineArgs[0] store.dsn = inlineArgs[1:] } - return store, nil -} -func (store *Storage) Init(cfg *config.Map) error { var ( driver string dsn []string - appendlimitVal = -1 + appendlimitVal int64 = -1 compression []string authNormalize string deliveryNormalize string @@ -113,15 +124,11 @@ func (store *Storage) Init(cfg *config.Map) error { blobStore module.BlobStore ) - opts := imapsql.Opts{ - // Prevent deadlock if nobody is listening for updates (e.g. no IMAP - // configured). - LazyUpdatesInit: true, - } + opts := &imapsql.Opts{} cfg.String("driver", false, false, store.driver, &driver) cfg.StringList("dsn", false, false, store.dsn, &dsn) cfg.Callback("fsstore", func(m *config.Map, node config.Node) error { - store.Log.Msg("'fsstore' directive is deprecated, use 'msg_store fs' instead") + store.log.Msg("'fsstore' directive is deprecated, use 'msg_store fs' instead") return modconfig.ModuleFromNode("storage.blob", append([]string{"fs"}, node.Args...), node, m.Globals, &blobStore) }) @@ -138,10 +145,10 @@ func (store *Storage) Init(cfg *config.Map) error { }, &blobStore) cfg.StringList("compression", false, false, []string{"off"}, &compression) cfg.DataSize("appendlimit", false, false, 32*1024*1024, &appendlimitVal) - cfg.Bool("debug", true, false, &store.Log.Debug) + cfg.Bool("debug", true, false, &store.log.Debug) cfg.Int("sqlite3_cache_size", false, false, 0, &opts.CacheSize) cfg.Int("sqlite3_busy_timeout", false, false, 5000, &opts.BusyTimeout) - cfg.Bool("sqlite3_exclusive_lock", false, false, &opts.ExclusiveLock) + cfg.Bool("disable_recent", false, true, &opts.DisableRecent) cfg.String("junk_mailbox", false, false, "Junk", &store.junkMbox) cfg.Custom("imap_filter", false, false, func() (interface{}, error) { return nil, nil @@ -153,7 +160,7 @@ func (store *Storage) Init(cfg *config.Map) error { cfg.Custom("auth_map", false, false, func() (interface{}, error) { return nil, nil }, modconfig.TableDirective, &store.authMap) - cfg.String("auth_normalize", false, false, "precis_casefold_email", &authNormalize) + cfg.String("auth_normalize", false, false, "auto", &authNormalize) cfg.Custom("delivery_map", false, false, func() (interface{}, error) { return nil, nil }, modconfig.TableDirective, &store.deliveryMap) @@ -170,6 +177,17 @@ func (store *Storage) Init(cfg *config.Map) error { return errors.New("imapsql: driver is required") } + if sqliteprovider.IsSqliteDriver(driver) { + if sqliteprovider.IsTranspiled { + store.log.Println("using transpiled SQLite (modernc.org/sqlite)") + } else if sqliteprovider.IsAvailable { + store.log.Debugln("using cgo SQLite") + } else { + return errors.New("imapsql: SQLite is not supported, recompile without no_sqlite3 tag set") + } + } + driver = sqliteprovider.MapDriverName(driver) + deliveryNormFunc, ok := authz.NormalizeFuncs[deliveryNormalize] if !ok { return errors.New("imapsql: unknown normalization function: " + deliveryNormalize) @@ -191,6 +209,9 @@ func (store *Storage) Init(cfg *config.Map) error { } } + if authNormalize != "auto" { + store.log.Msg("auth_normalize in storage.imapsql is deprecated and will be removed in the next release, use storage_map in imap config instead") + } authNormFunc, ok := authz.NormalizeFuncs[authNormalize] if !ok { return errors.New("imapsql: unknown normalization function: " + authNormalize) @@ -199,6 +220,7 @@ func (store *Storage) Init(cfg *config.Map) error { return authNormFunc(s) } if store.authMap != nil { + store.log.Msg("auth_map in storage.imapsql is deprecated and will be removed in the next release, use storage_map in imap config instead") store.authNormalize = func(ctx context.Context, username string) (string, error) { username, err := authNormFunc(username) if err != nil { @@ -212,22 +234,19 @@ func (store *Storage) Init(cfg *config.Map) error { } } - opts.Log = &store.Log + opts.Log = store.log if appendlimitVal == -1 { opts.MaxMsgBytes = nil } else { // int is 32-bit on some platforms, so cut off values we can't actually // use. - if int(uint32(appendlimitVal)) != appendlimitVal { + if int64(uint32(appendlimitVal)) != appendlimitVal { return errors.New("imapsql: appendlimit value is too big") } opts.MaxMsgBytes = new(uint32) *opts.MaxMsgBytes = uint32(appendlimitVal) } - var err error - - dsnStr := strings.Join(dsn, " ") if len(compression) != 0 { switch compression[0] { @@ -251,19 +270,33 @@ func (store *Storage) Init(cfg *config.Map) error { } } - store.Back, err = imapsql.New(driver, dsnStr, ExtBlobStore{Base: blobStore}, opts) - if err != nil { - return fmt.Errorf("imapsql: %s", err) + driverFound := false + for _, d := range sql.Drivers() { + if d == driver { + driverFound = true + break + } + } + if !driverFound { + return fmt.Errorf("imapsql: unknown driver %q", driver) } - - store.Log.Debugln("go-imap-sql version", imapsql.VersionStr) store.driver = driver store.dsn = dsn + store.blobStore = blobStore + store.opts = opts + store.log.Debugln("go-imap-sql version", imapsql.VersionStr) - store.Back.EnableChildrenExt() - store.Back.EnableSpecialUseExt() + return nil +} +func (store *Storage) Start() error { + dsnStr := strings.Join(store.dsn, " ") + var err error + store.Back, err = imapsql.New(store.driver, dsnStr, ExtBlobStore{Base: store.blobStore}, *store.opts) + if err != nil { + return fmt.Errorf("imapsql: %s", err) + } return nil } @@ -271,29 +304,42 @@ func (store *Storage) EnableUpdatePipe(mode updatepipe.BackendMode) error { if store.updPipe != nil { return nil } - if store.updates != nil { - panic("imapsql: EnableUpdatePipe called after Updates") - } - - upds := store.Back.Updates() switch store.driver { - case "sqlite3": + case "sqlite3", "sqlite": dbId := sha1.Sum([]byte(strings.Join(store.dsn, " "))) + sockPath := filepath.Join( + config.RuntimeDirectory, + fmt.Sprintf("sql-%s.sock", hex.EncodeToString(dbId[:]))) + store.log.DebugMsg("using unix socket for external updates", "path", sockPath) store.updPipe = &updatepipe.UnixSockPipe{ - SockPath: filepath.Join( - config.RuntimeDirectory, - fmt.Sprintf("sql-%s.sock", hex.EncodeToString(dbId[:]))), - Log: log.Logger{Name: "sql/updpipe", Debug: store.Log.Debug}, + SockPath: sockPath, + Log: store.log.Sublogger("updpipe"), + } + case "postgres": + store.log.DebugMsg("using PostgreSQL broker for external updates") + ps, err := pubsub.NewPQ(strings.Join(store.dsn, " ")) + if err != nil { + return fmt.Errorf("enable_update_pipe: %w", err) } + ps.Log = store.log.Sublogger("updpipe/pubsub") + pipe := &updatepipe.PubSubPipe{ + PubSub: ps, + Log: store.log.Sublogger("updpipe"), + } + store.Back.UpdateManager().ExternalUnsubscribe = pipe.Unsubscribe + store.Back.UpdateManager().ExternalSubscribe = pipe.Subscribe + store.updPipe = pipe default: return errors.New("imapsql: driver does not have an update pipe implementation") } - wrapped := make(chan backend.Update, cap(upds)*2) + inbound := make(chan mess.Update, 32) + outbound := make(chan mess.Update, 10) + store.outboundUpds = outbound if mode == updatepipe.ModeReplicate { - if err := store.updPipe.Listen(wrapped); err != nil { + if err := store.updPipe.Listen(inbound); err != nil { store.updPipe = nil return err } @@ -304,11 +350,18 @@ func (store *Storage) EnableUpdatePipe(mode updatepipe.BackendMode) error { return err } - store.updPushStop = make(chan struct{}) + store.Back.UpdateManager().SetExternalSink(outbound) + + store.updPushStop = make(chan struct{}, 1) go func() { defer func() { + // Ensure we sent all outbound updates. + for upd := range outbound { + if err := store.updPipe.Push(upd); err != nil { + store.log.Error("IMAP update pipe push failed", err) + } + } store.updPushStop <- struct{}{} - close(wrapped) if err := recover(); err != nil { stack := debug.Stack() @@ -318,27 +371,21 @@ func (store *Storage) EnableUpdatePipe(mode updatepipe.BackendMode) error { for { select { - case <-store.updpushstop: - return - case u := <-upds: - if u == nil { - // The channel is closed. We must be stopping now. - <-store.updpushstop + case u := <-inbound: + store.log.DebugMsg("external update received", "type", u.Type, "key", u.Key) + store.Back.UpdateManager().ExternalUpdate(u) + case u, ok := <-outbound: + if !ok { return } - + store.log.DebugMsg("sending external update", "type", u.Type, "key", u.Key) if err := store.updPipe.Push(u); err != nil { - store.Log.Error("IMAP update pipe push failed", err) - } - - if mode != updatepipe.ModePush { - wrapped <- u + store.log.Error("IMAP update pipe push failed", err) } } } }() - store.updates = wrapped return nil } @@ -354,19 +401,6 @@ func (store *Storage) CreateMessageLimit() *uint32 { return store.Back.CreateMessageLimit() } -func (store *Storage) Updates() <-chan backend.Update { - if store.updates != nil { - return store.updates - } - - store.updates = store.Back.Updates() - return store.updates -} - -func (store *Storage) EnableChildrenExt() bool { - return store.Back.EnableChildrenExt() -} - func (store *Storage) GetOrCreateIMAPAcct(username string) (backend.User, error) { accountName, err := store.authNormalize(context.TODO(), username) if err != nil { @@ -390,24 +424,28 @@ func (store *Storage) Lookup(ctx context.Context, key string) (string, bool, err return "", false, err } if err := usr.Logout(); err != nil { - store.Log.Error("logout failed", err, "username", accountName) + store.log.Error("logout failed", err, "username", accountName) } return "", true, nil } -func (store *Storage) Close() error { +func (store *Storage) Stop() error { // Stop backend from generating new updates. - store.Back.Close() + if err := store.Back.Close(); err != nil { + store.log.Error("close backend failed", err) + } // Wait for 'updates replicate' goroutine to actually stop so we will send - // all updates before shuting down (this is especially important for - // maddyctl). + // all updates before shutting down (this is especially important for + // maddy subcommands). if store.updPipe != nil { - store.updPushStop <- struct{}{} + close(store.outboundUpds) <-store.updpushstop - store.updPipe.Close() + if err := store.updPipe.Close(); err != nil { + store.log.Error("updatepipe close failed", err) + } } return nil @@ -422,6 +460,6 @@ func (store *Storage) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm { } func init() { - module.Register("storage.imapsql", New) - module.Register("target.imapsql", New) + modules.Register("storage.imapsql", New) + modules.Register("target.imapsql", New) } diff --git a/internal/table/chain.go b/internal/table/chain.go index 371aab9e5..819002a9b 100644 --- a/internal/table/chain.go +++ b/internal/table/chain.go @@ -23,7 +23,9 @@ import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Chain struct { @@ -34,14 +36,14 @@ type Chain struct { optional []bool } -func NewChain(modName, instName string, _, _ []string) (module.Module, error) { +func NewChain(_ *container.C, modName, instName string) (module.Module, error) { return &Chain{ modName: modName, instName: instName, }, nil } -func (s *Chain) Init(cfg *config.Map) error { +func (s *Chain) Configure(inlineArgs []string, cfg *config.Map) error { cfg.Callback("step", func(m *config.Map, node config.Node) error { var tbl module.Table err := modconfig.ModuleFromNode("table", node.Args, node, m.Globals, &tbl) @@ -78,22 +80,54 @@ func (s *Chain) InstanceName() string { } func (s *Chain) Lookup(ctx context.Context, key string) (string, bool, error) { + newVal, err := s.LookupMulti(ctx, key) + if err != nil { + return "", false, err + } + if len(newVal) == 0 { + return "", false, nil + } + + return newVal[0], true, nil +} + +func (s *Chain) LookupMulti(ctx context.Context, key string) ([]string, error) { + result := []string{key} +STEP: for i, step := range s.chain { - val, ok, err := step.Lookup(ctx, key) - if err != nil { - return "", false, err - } - if !ok { - if s.optional[i] { - continue + newResult := []string{} + for _, key = range result { + if step_multi, ok := step.(module.MultiTable); ok { + val, err := step_multi.LookupMulti(ctx, key) + if err != nil { + return []string{}, err + } + if len(val) == 0 { + if s.optional[i] { + continue STEP + } + return []string{}, nil + } + newResult = append(newResult, val...) + } else { + val, ok, err := step.Lookup(ctx, key) + if err != nil { + return []string{}, err + } + if !ok { + if s.optional[i] { + continue STEP + } + return []string{}, nil + } + newResult = append(newResult, val) } - return "", false, nil } - key = val + result = newResult } - return key, true, nil + return result, nil } func init() { - module.Register("table.chain", NewChain) + modules.Register("table.chain", NewChain) } diff --git a/internal/table/email_localpart.go b/internal/table/email_localpart.go index fb9c32567..b500b31cf 100644 --- a/internal/table/email_localpart.go +++ b/internal/table/email_localpart.go @@ -23,22 +23,26 @@ import ( "github.com/foxcpp/maddy/framework/address" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type EmailLocalpart struct { - modName string - instName string + modName string + instName string + allowNonEmail bool } -func NewEmailLocalpart(modName, instName string, _, _ []string) (module.Module, error) { +func NewEmailLocalpart(_ *container.C, modName, instName string) (module.Module, error) { return &EmailLocalpart{ - modName: modName, - instName: instName, + modName: modName, + instName: instName, + allowNonEmail: modName == "table.email_localpart_optional", }, nil } -func (s *EmailLocalpart) Init(cfg *config.Map) error { +func (s *EmailLocalpart) Configure(inlineArgs []string, cfg *config.Map) error { return nil } @@ -53,6 +57,9 @@ func (s *EmailLocalpart) InstanceName() string { func (s *EmailLocalpart) Lookup(ctx context.Context, key string) (string, bool, error) { mbox, _, err := address.Split(key) if err != nil { + if s.allowNonEmail { + return key, true, nil + } // Invalid email, no local part mapping. return "", false, nil } @@ -60,5 +67,6 @@ func (s *EmailLocalpart) Lookup(ctx context.Context, key string) (string, bool, } func init() { - module.Register("table.email_localpart", NewEmailLocalpart) + modules.Register("table.email_localpart", NewEmailLocalpart) + modules.Register("table.email_localpart_optional", NewEmailLocalpart) } diff --git a/internal/table/email_with_domain.go b/internal/table/email_with_domain.go new file mode 100644 index 000000000..6ebb706ba --- /dev/null +++ b/internal/table/email_with_domain.go @@ -0,0 +1,92 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package table + +import ( + "context" + "fmt" + + "github.com/foxcpp/maddy/framework/address" + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" +) + +type EmailWithDomain struct { + modName string + instName string + domains []string + log *log.Logger +} + +func NewEmailWithDomain(c *container.C, modName, instName string) (module.Module, error) { + return &EmailWithDomain{ + modName: modName, + instName: instName, + log: c.DefaultLogger.Sublogger(modName), + }, nil +} + +func (s *EmailWithDomain) Configure(inlineArgs []string, cfg *config.Map) error { + s.domains = inlineArgs + + for _, d := range s.domains { + if !address.ValidDomain(d) { + return fmt.Errorf("%s: invalid domain: %s", s.modName, d) + } + } + if len(s.domains) == 0 { + return fmt.Errorf("%s: at least one domain is required", s.modName) + } + + return nil +} + +func (s *EmailWithDomain) Name() string { + return s.modName +} + +func (s *EmailWithDomain) InstanceName() string { + return s.modName +} + +func (s *EmailWithDomain) Lookup(ctx context.Context, key string) (string, bool, error) { + quotedMbox := address.QuoteMbox(key) + + if len(s.domains) == 0 { + s.log.Msg("only first domain is used when expanding key", "key", key, "domain", s.domains[0]) + } + + return quotedMbox + "@" + s.domains[0], true, nil +} + +func (s *EmailWithDomain) LookupMulti(ctx context.Context, key string) ([]string, error) { + quotedMbox := address.QuoteMbox(key) + emails := make([]string, len(s.domains)) + for i, domain := range s.domains { + emails[i] = quotedMbox + "@" + domain + } + return emails, nil +} + +func init() { + modules.Register("table.email_with_domain", NewEmailWithDomain) +} diff --git a/internal/table/file.go b/internal/table/file.go index aec893089..47d8f372b 100644 --- a/internal/table/file.go +++ b/internal/table/file.go @@ -29,9 +29,10 @@ import ( "time" "github.com/foxcpp/maddy/framework/config" - "github.com/foxcpp/maddy/framework/hooks" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) const FileModName = "table.file" @@ -47,24 +48,16 @@ type File struct { stopReloader chan struct{} forceReload chan struct{} - log log.Logger + log *log.Logger } -func NewFile(_, instName string, _, inlineArgs []string) (module.Module, error) { +func NewFile(c *container.C, modName, instName string) (module.Module, error) { m := &File{ instName: instName, m: make(map[string][]string), stopReloader: make(chan struct{}), forceReload: make(chan struct{}), - log: log.Logger{Name: FileModName}, - } - - switch len(inlineArgs) { - case 1: - m.file = inlineArgs[0] - case 0: - default: - return nil, fmt.Errorf("%s: cannot use multiple files with single %s, use %s multiple times to do so", FileModName, FileModName, FileModName) + log: c.DefaultLogger.Sublogger(modName), } return m, nil @@ -78,7 +71,15 @@ func (f *File) InstanceName() string { return f.instName } -func (f *File) Init(cfg *config.Map) error { +func (f *File) Configure(inlineArgs []string, cfg *config.Map) error { + switch len(inlineArgs) { + case 1: + f.file = inlineArgs[0] + case 0: + default: + return fmt.Errorf("%s: cannot use multiple files with single %s, use %s multiple times to do so", FileModName, FileModName, FileModName) + } + var file string cfg.Bool("debug", true, false, &f.log.Debug) cfg.String("file", false, false, "", &file) @@ -100,11 +101,22 @@ func (f *File) Init(cfg *config.Map) error { f.log.Printf("ignoring non-existent file: %s", f.file) } + return nil +} + +func (f *File) Start() error { go f.reloader() - hooks.AddHook(hooks.EventReload, func() { - f.forceReload <- struct{}{} - }) + return nil +} +func (f *File) Reload() error { + f.forceReload <- struct{}{} + return nil +} + +func (f *File) Stop() error { + f.stopReloader <- struct{}{} + <-f.stopreloader return nil } @@ -119,55 +131,66 @@ func (f *File) reloader() { }() t := time.NewTicker(reloadInterval) + defer t.Stop() for { select { case <-t.c: - var latestStamp time.Time - info, err := os.Stat(f.file) - if err != nil { - if os.IsNotExist(err) { - f.mLck.Lock() - f.m = map[string][]string{} - f.mStamp = time.Now() - f.mLck.Unlock() - continue - } - f.log.Printf("%v", err) - } - if info.ModTime().After(latestStamp) { - latestStamp = info.ModTime() - } + f.reload() + case <-f.forcereload: + f.reload() + case <-f.stopreloader: f.stopReloader <- struct{}{} return } + } +} - f.log.Debugf("reloading") +func (f *File) reload() { + info, err := os.Stat(f.file) + if err != nil { + if os.IsNotExist(err) { + f.mLck.Lock() + f.m = map[string][]string{} + f.mLck.Unlock() + return + } + f.log.Error("os stat", err) + } + if info.ModTime().Before(f.mStamp) || time.Since(info.ModTime()) < (reloadInterval/2) { + return // reload not necessary + } - newm := make(map[string][]string, len(f.m)+5) - if err := readFile(f.file, newm); err != nil { - if os.IsNotExist(err) { - f.log.Printf("ignoring non-existent file: %s", f.file) - continue - } + f.log.Debugf("reloading") - f.log.Println(err) - continue + newm := make(map[string][]string, len(f.m)+5) + if err := readFile(f.file, newm); err != nil { + if os.IsNotExist(err) { + f.log.Printf("ignoring non-existent file: %s", f.file) + return } - f.mLck.Lock() - f.m = newm - f.mStamp = time.Now() - f.mLck.Unlock() + f.log.Println(err) + return + } + // after reading we need to check whether file has changed in between + info2, err := os.Stat(f.file) + if err != nil { + f.log.Println(err) + return } -} -func (f *File) Close() error { - f.stopReloader <- struct{}{} - <-f.stopreloader - return nil + if !info2.ModTime().Equal(info.ModTime()) { + // file has changed in the meantime + return + } + + f.mLck.Lock() + f.m = newm + f.mStamp = info.ModTime() + f.mLck.Unlock() } func readFile(path string, out map[string][]string) error { @@ -203,9 +226,11 @@ func readFile(path string, out map[string][]string) error { if len(from) == 0 { return parseErr("empty address before colon") } - to := strings.TrimSpace(parts[1]) - out[from] = append(out[from], to) + for _, to := range strings.Split(parts[1], ",") { + to := strings.TrimSpace(to) + out[from] = append(out[from], to) + } } return scnr.Err() } @@ -235,5 +260,5 @@ func (f *File) LookupMulti(_ context.Context, val string) ([]string, error) { } func init() { - module.Register(FileModName, NewFile) + modules.Register(FileModName, NewFile) } diff --git a/internal/table/file_test.go b/internal/table/file_test.go index 6d22c8554..20335eb1b 100644 --- a/internal/table/file_test.go +++ b/internal/table/file_test.go @@ -19,26 +19,38 @@ along with this program. If not, see . package table import ( - "io/ioutil" "os" "reflect" "testing" "time" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestReadFile(t *testing.T) { test := func(file string, expected map[string][]string) { t.Helper() - f, err := ioutil.TempFile("", "maddy-tests-") + f, err := os.CreateTemp("", "maddy-tests-") if err != nil { t.Fatal(err) } - defer os.Remove(f.Name()) - defer f.Close() + defer func(name string) { + err := os.Remove(name) + if err != nil { + t.Log(err) + } + }(f.Name()) + defer func(f *os.File) { + err := f.Close() + if err != nil { + t.Log(err) + } + }(f) if _, err := f.WriteString(file); err != nil { t.Fatal(err) } @@ -66,7 +78,8 @@ func TestReadFile(t *testing.T) { test(`"a @ a"@example.org: b@example.com`, map[string][]string{`"a @ a"@example.org`: {"b@example.com"}}) test(`a@example.org: "b @ b"@example.com`, map[string][]string{`a@example.org`: {`"b @ b"@example.com`}}) test(`"a @ a": "b @ b"`, map[string][]string{`"a @ a"`: {`"b @ b"`}}) - test("a: b, c", map[string][]string{"a": {"b, c"}}) + test("a: b, c", map[string][]string{"a": {"b", "c"}}) + test("a: b\na: c", map[string][]string{"a": {"b", "c"}}) test(": b", nil) test(":", nil) test("aaa", map[string][]string{"aaa": {""}}) @@ -87,51 +100,63 @@ func TestFileReload(t *testing.T) { const file = `cat: dog` - f, err := ioutil.TempFile("", "maddy-tests-") + f, err := os.CreateTemp("", "maddy-tests-") if err != nil { t.Fatal(err) } - defer os.Remove(f.Name()) + defer func(name string) { + err := os.Remove(name) + if err != nil { + t.Log(err) + } + }(f.Name()) if _, err := f.WriteString(file); err != nil { - f.Close() + _ = f.Close() + t.Fatal(err) + } + err = f.Close() + if err != nil { t.Fatal(err) } - f.Close() - mod, err := NewFile("", "", nil, []string{f.Name()}) + mod, err := NewFile(container.New(), "", "") if err != nil { t.Fatal(err) } m := mod.(*File) + if err := m.Start(); err != nil { + t.Fatal(err) + } m.log = testutils.Logger(t, "file_map") - defer m.Close() + defer func() { + assert.NoError(t, m.Stop()) + }() - if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil { + if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil { t.Fatal(err) } - // This delay is somehow important. Not sure why. - time.Sleep(250 * time.Millisecond) - - if err := ioutil.WriteFile(f.Name(), []byte("dog: cat"), os.ModePerm); err != nil { - t.Fatal(err) + // ensure it is correctly loaded at first time. + m.mLck.RLock() + if m.m["cat"] == nil { + t.Fatalf("wrong content loaded, new m were not loaded, %v", m.m) } + m.mLck.RUnlock() - for i := 0; i < 10; i++ { - time.Sleep(reloadInterval + 50*time.Millisecond) + for i := 0; i < 100; i++ { + // try to provoke race condition on file writing + if i%2 == 0 { + if err := os.WriteFile(f.Name(), []byte("dog: cat"), os.ModePerm); err != nil { + t.Fatal(err) + } + } + time.Sleep(reloadInterval + 5*time.Millisecond) m.mLck.RLock() - if m.m["dog"] != nil { - m.mLck.RUnlock() - break + if m.m["dog"] == nil { + t.Fatalf("wrong content loaded, new m were not loaded, %v", m.m) } m.mLck.RUnlock() } - - m.mLck.RLock() - defer m.mLck.RUnlock() - if m.m["dog"] == nil { - t.Fatal("New m were not loaded") - } } func TestFileReload_Broken(t *testing.T) { @@ -139,26 +164,39 @@ func TestFileReload_Broken(t *testing.T) { const file = `cat: dog` - f, err := ioutil.TempFile("", "maddy-tests-") + f, err := os.CreateTemp("", "maddy-tests-") if err != nil { t.Fatal(err) } - defer os.Remove(f.Name()) + defer func(name string) { + err := os.Remove(name) + if err != nil { + t.Fatal(err) + } + }(f.Name()) if _, err := f.WriteString(file); err != nil { - f.Close() + require.NoError(t, f.Close()) t.Fatal(err) } - f.Close() + require.NoError(t, f.Close()) - mod, err := NewFile("", "", nil, []string{f.Name()}) + mod, err := NewFile(container.New(), "", "") if err != nil { t.Fatal(err) } m := mod.(*File) + if err := m.Start(); err != nil { + t.Fatal(err) + } m.log = testutils.Logger(t, FileModName) - defer m.Close() + defer func(m *File) { + err := m.Stop() + if err != nil { + t.Fatal(err) + } + }(m) - if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil { + if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil { t.Fatal(err) } @@ -169,7 +207,12 @@ func TestFileReload_Broken(t *testing.T) { if _, err := f2.WriteString(":"); err != nil { t.Fatal(err) } - defer f2.Close() + defer func(f2 *os.File) { + err := f2.Close() + if err != nil { + t.Fatal(err) + } + }(f2) time.Sleep(3 * reloadInterval) @@ -185,32 +228,43 @@ func TestFileReload_Removed(t *testing.T) { const file = `cat: dog` - f, err := ioutil.TempFile("", "maddy-tests-") + f, err := os.CreateTemp("", "maddy-tests-") if err != nil { t.Fatal(err) } if _, err := f.WriteString(file); err != nil { - f.Close() + _ = f.Close() + t.Fatal(err) + } + err = f.Close() + if err != nil { t.Fatal(err) } - f.Close() - mod, err := NewFile("", "", nil, []string{f.Name()}) + mod, err := NewFile(container.New(), "", "") if err != nil { t.Fatal(err) } m := mod.(*File) + if err := m.Start(); err != nil { + t.Fatal(err) + } m.log = testutils.Logger(t, FileModName) - defer m.Close() + defer func(m *File) { + err := m.Stop() + if err != nil { + t.Fatal(err) + } + }(m) - if err := mod.Init(&config.Map{Block: config.Node{}}); err != nil { + if err := mod.Configure([]string{f.Name()}, &config.Map{Block: config.Node{}}); err != nil { t.Fatal(err) } - // This delay is somehow important. Not sure why. - time.Sleep(250 * time.Millisecond) - - os.Remove(f.Name()) + err = os.Remove(f.Name()) + if err != nil { + t.Fatal(err) + } time.Sleep(3 * reloadInterval) @@ -222,5 +276,5 @@ func TestFileReload_Removed(t *testing.T) { } func init() { - reloadInterval = 250 * time.Millisecond + reloadInterval = 10 * time.Millisecond } diff --git a/internal/table/identity.go b/internal/table/identity.go index c405d3dcb..2a179b274 100644 --- a/internal/table/identity.go +++ b/internal/table/identity.go @@ -22,7 +22,9 @@ import ( "context" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Identity struct { @@ -30,14 +32,14 @@ type Identity struct { instName string } -func NewIdentity(modName, instName string, _, _ []string) (module.Module, error) { +func NewIdentity(_ *container.C, modName, instName string) (module.Module, error) { return &Identity{ modName: modName, instName: instName, }, nil } -func (s *Identity) Init(cfg *config.Map) error { +func (s *Identity) Configure(inlineArgs []string, cfg *config.Map) error { return nil } @@ -54,5 +56,5 @@ func (s *Identity) Lookup(_ context.Context, key string) (string, bool, error) { } func init() { - module.Register("table.identity", NewIdentity) + modules.Register("table.identity", NewIdentity) } diff --git a/internal/table/regexp.go b/internal/table/regexp.go index 5136c7d4c..1cf214a94 100644 --- a/internal/table/regexp.go +++ b/internal/table/regexp.go @@ -25,29 +25,29 @@ import ( "strings" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Regexp struct { - modName string - instName string - inlineArgs []string + modName string + instName string - re *regexp.Regexp - replacement string + re *regexp.Regexp + replacements []string expandPlaceholders bool } -func NewRegexp(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func NewRegexp(_ *container.C, modName, instName string) (module.Module, error) { return &Regexp{ - modName: modName, - instName: instName, - inlineArgs: inlineArgs, + modName: modName, + instName: instName, }, nil } -func (r *Regexp) Init(cfg *config.Map) error { +func (r *Regexp) Configure(inlineArgs []string, cfg *config.Map) error { var ( fullMatch bool caseInsensitive bool @@ -59,12 +59,9 @@ func (r *Regexp) Init(cfg *config.Map) error { return err } - if len(r.inlineArgs)> 2 { - return fmt.Errorf("%s: at most two arguments accepted", r.modName) - } - regex := r.inlineArgs[0] - if len(r.inlineArgs) == 2 { - r.replacement = r.inlineArgs[1] + regex := inlineArgs[0] + if len(inlineArgs)> 1 { + r.replacements = inlineArgs[1:] } if fullMatch { @@ -96,19 +93,35 @@ func (r *Regexp) InstanceName() string { return r.modName } -func (r *Regexp) Lookup(_ context.Context, key string) (string, bool, error) { +func (r *Regexp) LookupMulti(_ context.Context, key string) ([]string, error) { matches := r.re.FindStringSubmatchIndex(key) if matches == nil { - return "", false, nil + return []string{}, nil } - if !r.expandPlaceholders { - return r.replacement, true, nil + result := []string{} + for _, replacement := range r.replacements { + if !r.expandPlaceholders { + result = append(result, replacement) + } else { + result = append(result, string(r.re.ExpandString([]byte{}, replacement, key, matches))) + } + } + return result, nil +} + +func (r *Regexp) Lookup(ctx context.Context, key string) (string, bool, error) { + newVal, err := r.LookupMulti(ctx, key) + if err != nil { + return "", false, err + } + if len(newVal) == 0 { + return "", false, nil } - return string(r.re.ExpandString([]byte{}, r.replacement, key, matches)), true, nil + return newVal[0], true, nil } func init() { - module.Register("table.regexp", NewRegexp) + modules.Register("table.regexp", NewRegexp) } diff --git a/internal/table/sql_query.go b/internal/table/sql_query.go index c15f710aa..248dc8746 100644 --- a/internal/table/sql_query.go +++ b/internal/table/sql_query.go @@ -21,17 +21,23 @@ package table import ( "context" "database/sql" + "errors" "fmt" "strings" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + sqliteprovider "github.com/foxcpp/maddy/internal/sqlite" _ "github.com/lib/pq" ) type SQL struct { modName string instName string + prepare func() error namedArgs bool @@ -43,7 +49,7 @@ type SQL struct { del *sql.Stmt } -func NewSQL(modName, instName string, _, _ []string) (module.Module, error) { +func NewSQL(_ *container.C, modName, instName string) (module.Module, error) { return &SQL{ modName: modName, instName: instName, @@ -58,7 +64,7 @@ func (s *SQL) InstanceName() string { return s.instName } -func (s *SQL) Init(cfg *config.Map) error { +func (s *SQL) Configure(inlineArgs []string, cfg *config.Map) error { var ( driver string initQueries []string @@ -88,53 +94,62 @@ func (s *SQL) Init(cfg *config.Map) error { if driver == "postgres" && s.namedArgs { return config.NodeErr(cfg.Block, "PostgreSQL driver does not support named_args") } + driver = sqliteprovider.MapDriverName(driver) db, err := sql.Open(driver, strings.Join(dsnParts, " ")) if err != nil { return config.NodeErr(cfg.Block, "failed to open db: %v", err) } s.db = db - - for _, init := range initQueries { - if _, err := db.Exec(init); err != nil { - return config.NodeErr(cfg.Block, "init query failed: %v", err) + s.prepare = func() error { + for _, init := range initQueries { + if _, err := db.Exec(init); err != nil { + return config.NodeErr(cfg.Block, "init query failed: %v", err) + } } - } - s.lookup, err = db.Prepare(lookupQuery) - if err != nil { - return config.NodeErr(cfg.Block, "failed to prepare lookup query: %v", err) - } - if addQuery != "" { - s.add, err = db.Prepare(addQuery) + s.lookup, err = db.Prepare(lookupQuery) if err != nil { - return config.NodeErr(cfg.Block, "failed to prepare add query: %v", err) + return fmt.Errorf("failed to prepare lookup query: %v", err) } - } - if listQuery != "" { - s.list, err = db.Prepare(listQuery) - if err != nil { - return config.NodeErr(cfg.Block, "failed to prepare list query: %v", err) + if addQuery != "" { + s.add, err = db.Prepare(addQuery) + if err != nil { + return fmt.Errorf("failed to prepare add query: %v", err) + } } - } - if setQuery != "" { - s.set, err = db.Prepare(setQuery) - if err != nil { - return config.NodeErr(cfg.Block, "failed to prepare set query: %v", err) + if listQuery != "" { + s.list, err = db.Prepare(listQuery) + if err != nil { + return config.NodeErr(cfg.Block, "failed to prepare list query: %v", err) + } } - } - if removeQuery != "" { - s.del, err = db.Prepare(removeQuery) - if err != nil { - return config.NodeErr(cfg.Block, "failed to prepare del query: %v", err) + if setQuery != "" { + s.set, err = db.Prepare(setQuery) + if err != nil { + return config.NodeErr(cfg.Block, "failed to prepare set query: %v", err) + } + } + if removeQuery != "" { + s.del, err = db.Prepare(removeQuery) + if err != nil { + return config.NodeErr(cfg.Block, "failed to prepare del query: %v", err) + } } + return nil } + return s.prepare() +} + +func (s *SQL) Start() error { return nil } -func (s *SQL) Close() error { - s.lookup.Close() +func (s *SQL) Stop() error { + if err := s.lookup.Close(); err != nil { + log.DefaultLogger.Error("lookup query close failed", err) + } return s.db.Close() } @@ -149,7 +164,7 @@ func (s *SQL) Lookup(ctx context.Context, val string) (string, bool, error) { row = s.lookup.QueryRowContext(ctx, val) } if err := row.Scan(&repl); err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return "", false, nil } return "", false, fmt.Errorf("%s: lookup %s: %w", s.modName, val, err) @@ -193,7 +208,9 @@ func (s *SQL) Keys() ([]string, error) { if err != nil { return nil, fmt.Errorf("%s: list: %w", s.modName, err) } - defer rows.Close() + defer func() { + _ = rows.Close() + }() var list []string for rows.Next() { var key string @@ -202,6 +219,9 @@ func (s *SQL) Keys() ([]string, error) { } list = append(list, key) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%s: list: %w", s.modName, err) + } return list, nil } @@ -247,5 +267,5 @@ func (s *SQL) SetKey(k, v string) error { } func init() { - module.Register("table.sql_query", NewSQL) + modules.Register("table.sql_query", NewSQL) } diff --git a/internal/table/sql_query_test.go b/internal/table/sql_query_test.go index 1a454d336..a81f1bfeb 100644 --- a/internal/table/sql_query_test.go +++ b/internal/table/sql_query_test.go @@ -1,4 +1,5 @@ -//+build !nosqlite3,cgo +//go:build !nosqlite3 && cgo +// +build !nosqlite3,cgo /* Maddy Mail Server - Composable all-in-one email server. @@ -27,17 +28,18 @@ import ( "testing" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/internal/testutils" ) func TestSQL(t *testing.T) { path := testutils.Dir(t) - mod, err := NewSQL("sql_table", "", nil, nil) + mod, err := NewSQL(container.New(), "sql_table", "") if err != nil { t.Fatal("Module create failed:", err) } tbl := mod.(*SQL) - err = tbl.Init(config.NewMap(nil, config.Node{ + err = tbl.Configure(nil, config.NewMap(nil, config.Node{ Children: []config.Node{ { Name: "driver", @@ -65,6 +67,9 @@ func TestSQL(t *testing.T) { if err != nil { t.Fatal("Init failed:", err) } + if err := tbl.Start(); err != nil { + t.Fatal(err) + } check := func(key, res string, ok, fail bool) { t.Helper() diff --git a/internal/table/sql_table.go b/internal/table/sql_table.go index e793dc480..c8f802168 100644 --- a/internal/table/sql_table.go +++ b/internal/table/sql_table.go @@ -23,7 +23,9 @@ import ( "fmt" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" _ "github.com/lib/pq" ) @@ -34,7 +36,7 @@ type SQLTable struct { wrapped *SQL } -func NewSQLTable(modName, instName string, _, _ []string) (module.Module, error) { +func NewSQLTable(_ *container.C, modName, instName string) (module.Module, error) { return &SQLTable{ modName: modName, instName: instName, @@ -54,7 +56,7 @@ func (s *SQLTable) InstanceName() string { return s.instName } -func (s *SQLTable) Init(cfg *config.Map) error { +func (s *SQLTable) Configure(inlineArgs []string, cfg *config.Map) error { var ( driver string dsnParts []string @@ -99,7 +101,7 @@ func (s *SQLTable) Init(cfg *config.Map) error { delQuery = fmt.Sprintf("DELETE FROM %s WHERE %s = 1ドル", tableName, keyColumn) } - return s.wrapped.Init(config.NewMap(cfg.Globals, config.Node{ + return s.wrapped.Configure(nil, config.NewMap(cfg.Globals, config.Node{ Children: []config.Node{ { Name: "driver", @@ -144,8 +146,10 @@ func (s *SQLTable) Init(cfg *config.Map) error { })) } -func (s *SQLTable) Close() error { - return s.wrapped.Close() +func (s *SQLTable) Start() error { return s.wrapped.Start() } + +func (s *SQLTable) Stop() error { + return s.wrapped.Stop() } func (s *SQLTable) Lookup(ctx context.Context, val string) (string, bool, error) { @@ -169,5 +173,5 @@ func (s *SQLTable) SetKey(k, v string) error { } func init() { - module.Register("table.sql_table", NewSQLTable) + modules.Register("table.sql_table", NewSQLTable) } diff --git a/internal/table/static.go b/internal/table/static.go index d3b293d5d..21b09c1f9 100644 --- a/internal/table/static.go +++ b/internal/table/static.go @@ -22,7 +22,9 @@ import ( "context" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Static struct { @@ -32,7 +34,7 @@ type Static struct { m map[string][]string } -func NewStatic(modName, instName string, _, _ []string) (module.Module, error) { +func NewStatic(_ *container.C, modName, instName string) (module.Module, error) { return &Static{ modName: modName, instName: instName, @@ -40,7 +42,7 @@ func NewStatic(modName, instName string, _, _ []string) (module.Module, error) { }, nil } -func (s *Static) Init(cfg *config.Map) error { +func (s *Static) Configure(inlineArgs []string, cfg *config.Map) error { cfg.Callback("entry", func(_ *config.Map, node config.Node) error { if len(node.Args) < 2 { return config.NodeErr(node, "expected at least one value") @@ -68,6 +70,10 @@ func (s *Static) Lookup(ctx context.Context, key string) (string, bool, error) { return val[0], true, nil } +func (s *Static) LookupMulti(ctx context.Context, key string) ([]string, error) { + return s.m[key], nil +} + func init() { - module.Register("table.static", NewStatic) + modules.Register("table.static", NewStatic) } diff --git a/internal/target/delivery.go b/internal/target/delivery.go index 1c3450fa2..759591363 100644 --- a/internal/target/delivery.go +++ b/internal/target/delivery.go @@ -23,7 +23,8 @@ import ( "github.com/foxcpp/maddy/framework/module" ) -func DeliveryLogger(l log.Logger, msgMeta *module.MsgMetadata) log.Logger { +func DeliveryLogger(parent *log.Logger, msgMeta *module.MsgMetadata) *log.Logger { + l := parent.Sublogger("") fields := make(map[string]interface{}, len(l.Fields)+1) for k, v := range l.Fields { fields[k] = v diff --git a/internal/target/queue/queue.go b/internal/target/queue/queue.go index 436df4b3c..1aec8213e 100644 --- a/internal/target/queue/queue.go +++ b/internal/target/queue/queue.go @@ -30,12 +30,12 @@ All scheduled deliveries are attempted to the configured DeliveryTarget. All metadata is preserved on disk. Failure status is determined on per-recipient basis: -- Delivery.Start fail handled as a failure for all recipients. -- Delivery.AddRcpt fail handled as a failure for the corresponding recipient. -- Delivery.Body fail handled as a failure for all recipients. -- If Delivery implements PartialDelivery, then - PartialDelivery.BodyNonAtomic is used instead. Failures are determined based - on StatusCollector.SetStatus calls done by target in this case. + - Delivery.StartDelivery fail handled as a failure for all recipients. + - Delivery.AddRcpt fail handled as a failure for the corresponding recipient. + - Delivery.Body fail handled as a failure for all recipients. + - If Delivery implements PartialDelivery, then + PartialDelivery.BodyNonAtomic is used instead. Failures are determined based + on StatusCollector.SetStatus calls done by target in this case. For each failure check is done to see if it is a permanent failure or a temporary one. This is done using exterrors.IsTemporaryOrUnspec. @@ -63,7 +63,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "math" "os" "path/filepath" @@ -80,9 +79,11 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/dsn" "github.com/foxcpp/maddy/internal/msgpipeline" "github.com/foxcpp/maddy/internal/target" @@ -90,7 +91,6 @@ import ( // partialError describes state of partially successful message delivery. type partialError struct { - // Underlying error objects for each recipient. Errs map[string]error @@ -111,7 +111,7 @@ func (pe *partialError) SetStatus(rcptTo string, err error) { pe.Errs[rcptTo] = err } -func (pe partialError) Error() string { +func (pe *partialError) Error() string { return fmt.Sprintf("delivery failed for some recipients: %v", pe.Errs) } @@ -124,16 +124,16 @@ type Queue struct { location string hostname string autogenMsgDomain string - wheel *TimeWheel + wheel *TimeWheel[queueSlot] dsnPipeline module.DeliveryTarget // Retry delay is calculated using the following formula: // initialRetryTime * retryTimeScale ^ (TriesCount - 1) - initialRetryTime time.Duration retryTimeScale float64 maxTries int + maxParallelism int // If any delivery is scheduled in less than postInitDelay // after Init, its delay will be increased by postInitDelay. @@ -147,7 +147,7 @@ type Queue struct { // after start-up for whatever reason it will not affect the queue. postInitDelay time.Duration - Log log.Logger + log *log.Logger Target module.DeliveryTarget deliveryWg sync.WaitGroup @@ -189,30 +189,33 @@ type queueSlot struct { Body buffer.Buffer } -func NewQueue(_, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { q := &Queue{ name: instName, initialRetryTime: 15 * time.Minute, retryTimeScale: 1.25, postInitDelay: 10 * time.Second, - Log: log.Logger{Name: "queue"}, + log: c.DefaultLogger.Sublogger(modName), } + return q, nil +} + +func (q *Queue) Configure(inlineArgs []string, cfg *config.Map) error { switch len(inlineArgs) { case 0: // Not inline definition. case 1: q.location = inlineArgs[0] default: - return nil, errors.New("queue: wrong amount of inline arguments") + return errors.New("queue: wrong amount of inline arguments") } - return q, nil -} -func (q *Queue) Init(cfg *config.Map) error { - var maxParallelism int - cfg.Bool("debug", true, false, &q.Log.Debug) + cfg.Bool("debug", true, false, &q.log.Debug) cfg.Int("max_tries", false, false, 20, &q.maxTries) - cfg.Int("max_parallelism", false, false, 16, &maxParallelism) + cfg.Int("max_parallelism", false, false, 16, &q.maxParallelism) + cfg.Duration("post_init_delay", false, false, q.postInitDelay, &q.postInitDelay) + cfg.Duration("initial_retry_time", false, false, q.initialRetryTime, &q.initialRetryTime) + cfg.Float("retry_time_scale", false, false, q.retryTimeScale, &q.retryTimeScale) cfg.String("location", false, false, q.location, &q.location) cfg.Custom("target", false, true, nil, modconfig.DeliveryDirective, &q.Target) cfg.String("hostname", true, true, "", &q.hostname) @@ -230,7 +233,7 @@ func (q *Queue) Init(cfg *config.Map) error { } q.dsnPipeline.(*msgpipeline.MsgPipeline).Hostname = q.hostname - q.dsnPipeline.(*msgpipeline.MsgPipeline).Log = log.Logger{Name: "queue/pipeline", Debug: q.Log.Debug} + q.dsnPipeline.(*msgpipeline.MsgPipeline).Log = q.log.Sublogger("pipeline") } if q.location == "" && q.name == "" { return errors.New("queue: need explicit location directive or inline argument if defined inline") @@ -243,30 +246,38 @@ func (q *Queue) Init(cfg *config.Map) error { if err := os.MkdirAll(q.location, os.ModePerm); err != nil { return err } + return nil +} - return q.start(maxParallelism) +func (q *Queue) Start() error { + return q.start(q.maxParallelism) } func (q *Queue) start(maxParallelism int) error { - q.wheel = NewTimeWheel(q.dispatch) + q.wheel = NewTimeWheel[queueSlot](q.dispatch) q.deliverySemaphore = make(chan struct{}, maxParallelism) if err := q.readDiskQueue(); err != nil { return err } - q.Log.Debugf("delivery target: %T", q.Target) + q.log.Debugf("delivery target: %T", q.Target) return nil } -func (q *Queue) Close() error { +func (q *Queue) EarlyStop() error { + // We must ensure queue state is consistent on disk before we proceed + // with configuration reload. q.wheel.Close() q.deliveryWg.Wait() - return nil } +func (q *Queue) Stop() error { + return q.EarlyStop() +} + // discardBroken changes the name of metadata file to have .meta_broken // extension. // @@ -282,14 +293,14 @@ func (q *Queue) discardBroken(id string) { } } -func (q *Queue) dispatch(value TimeSlot) { - slot := value.Value.(queueSlot) +func (q *Queue) dispatch(ctx context.Context, value TimeSlot[queueSlot]) { + slot := value.Value - q.Log.Debugln("starting delivery for", slot.ID) + q.log.Debugln("starting delivery for", slot.ID) q.deliveryWg.Add(1) go func() { - q.Log.Debugln("waiting on delivery semaphore for", slot.ID) + q.log.Debugln("waiting on delivery semaphore for", slot.ID) q.deliverySemaphore <- struct{}{} defer func() { <-q.deliverysemaphore @@ -306,7 +317,7 @@ func (q *Queue) dispatch(value TimeSlot) { } }() - q.Log.Debugln("delivery semaphore acquired for", slot.ID) + q.log.Debugln("delivery semaphore acquired for", slot.ID) var ( meta *QueueMetadata hdr textproto.Header @@ -316,7 +327,7 @@ func (q *Queue) dispatch(value TimeSlot) { var err error meta, hdr, body, err = q.openMessage(slot.ID) if err != nil { - q.Log.Error("read message", err, slot.ID) + q.log.Error("read message", err, slot.ID) return } if meta == nil { @@ -328,7 +339,7 @@ func (q *Queue) dispatch(value TimeSlot) { body = slot.Body } - q.tryDelivery(meta, hdr, body) + q.tryDelivery(ctx, meta, hdr, body) }() } @@ -372,10 +383,10 @@ func toSMTPErr(err error) *smtp.SMTPError { return res } -func (q *Queue) tryDelivery(meta *QueueMetadata, header textproto.Header, body buffer.Buffer) { - dl := target.DeliveryLogger(q.Log, meta.MsgMeta) +func (q *Queue) tryDelivery(ctx context.Context, meta *QueueMetadata, header textproto.Header, body buffer.Buffer) { + dl := target.DeliveryLogger(q.log, meta.MsgMeta) - partialErr := q.deliver(meta, header, body) + partialErr := q.deliver(ctx, meta, header, body) dl.Debugf("errors: %v", partialErr.Errs) // While iterating the list of recipients we also pick the smallest tries count @@ -403,7 +414,7 @@ func (q *Queue) tryDelivery(meta *QueueMetadata, header textproto.Header, body b meta.RcptErrs[rcpt] = toSMTPErr(rcptErr) temporary := exterrors.IsTemporaryOrUnspec(rcptErr) - if !temporary || meta.TriesCount[rcpt]+1 == q.maxTries { + if !temporary || meta.TriesCount[rcpt]+1>= q.maxTries { delete(meta.TriesCount, rcpt) dl.Msg("not delivered, permanent error", "rcpt", rcpt) failedRcpts = append(failedRcpts, rcpt) @@ -459,8 +470,8 @@ func (q *Queue) tryDelivery(meta *QueueMetadata, header textproto.Header, body b }) } -func (q *Queue) deliver(meta *QueueMetadata, header textproto.Header, body buffer.Buffer) partialError { - dl := target.DeliveryLogger(q.Log, meta.MsgMeta) +func (q *Queue) deliver(ctx context.Context, meta *QueueMetadata, header textproto.Header, body buffer.Buffer) partialError { + dl := target.DeliveryLogger(q.log, meta.MsgMeta) perr := partialError{ Errs: map[string]error{}, statusLock: new(sync.Mutex), @@ -470,25 +481,34 @@ func (q *Queue) deliver(meta *QueueMetadata, header textproto.Header, body buffe msgMeta.ID = msgMeta.ID + "-" + strconv.FormatInt(time.Now().Unix(), 16) dl.Debugf("using message ID = %s", msgMeta.ID) - msgCtx, msgTask := trace.NewTask(context.Background(), "Queue delivery") + msgCtx, msgTask := trace.NewTask(ctx, "Queue delivery") defer msgTask.End() mailCtx, mailTask := trace.NewTask(msgCtx, "MAIL FROM") - delivery, err := q.Target.Start(mailCtx, msgMeta, meta.From) + delivery, err := q.Target.StartDelivery(mailCtx, msgMeta, meta.From) mailTask.End() if err != nil { - dl.Debugf("target.Start failed: %v", err) + dl.Debugf("target.StartDelivery failed: %v", err) + for _, rcpt := range meta.To { + perr.Errs[rcpt] = err + } + return perr + } + dl.Debugf("target.StartDelivery OK") + + // Check in case delivery implementation is actually + // context-unaware. + if err := mailCtx.Err(); err != nil { for _, rcpt := range meta.To { perr.Errs[rcpt] = err } return perr } - dl.Debugf("target.Start OK") var acceptedRcpts []string for _, rcpt := range meta.To { rcptCtx, rcptTask := trace.NewTask(msgCtx, "RCPT TO") - if err := delivery.AddRcpt(rcptCtx, rcpt); err != nil { + if err := delivery.AddRcpt(rcptCtx, rcpt, smtp.RcptOptions{} /* TODO: DSN support */); err != nil { dl.Debugf("delivery.AddRcpt %s failed: %v", rcpt, err) perr.Errs[rcpt] = err } else { @@ -496,10 +516,19 @@ func (q *Queue) deliver(meta *QueueMetadata, header textproto.Header, body buffe acceptedRcpts = append(acceptedRcpts, rcpt) } rcptTask.End() + + // Check in case delivery implementation is actually + // context-unaware. + if err := mailCtx.Err(); err != nil { + for _, rcpt := range meta.To { + perr.Errs[rcpt] = err + } + return perr + } } if len(acceptedRcpts) == 0 { - dl.Debugf("delivery.Abort (no accepted receipients)") + dl.Debugf("delivery.Abort (no accepted recipients)") if err := delivery.Abort(msgCtx); err != nil { dl.Error("delivery.Abort failed", err) } @@ -512,6 +541,10 @@ func (q *Queue) deliver(meta *QueueMetadata, header textproto.Header, body buffe } } + // At this point, it is too late to abort delivery. We should complete + // it or fail it consistently. + msgCtx = context.WithoutCancel(msgCtx) + bodyCtx, bodyTask := trace.NewTask(msgCtx, "DATA") defer bodyTask.End() @@ -559,7 +592,7 @@ type queueDelivery struct { body buffer.Buffer } -func (qd *queueDelivery) AddRcpt(ctx context.Context, rcptTo string) error { +func (qd *queueDelivery) AddRcpt(ctx context.Context, rcptTo string, _ smtp.RcptOptions) error { qd.meta.To = append(qd.meta.To, rcptTo) return nil } @@ -606,7 +639,7 @@ func (qd *queueDelivery) Commit(ctx context.Context) error { return nil } -func (q *Queue) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { +func (q *Queue) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { meta := &QueueMetadata{ MsgMeta: msgMeta, From: mailFrom, @@ -619,7 +652,7 @@ func (q *Queue) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom func (q *Queue) removeFromDisk(msgMeta *module.MsgMetadata) { id := msgMeta.ID - dl := target.DeliveryLogger(q.Log, msgMeta) + dl := target.DeliveryLogger(q.log, msgMeta) // Order is important. // If we remove header and body but can't remove meta now - readDiskQueue @@ -636,11 +669,14 @@ func (q *Queue) removeFromDisk(msgMeta *module.MsgMetadata) { if err := os.Remove(metaPath); err != nil { dl.Error("failed to remove meta-data from disk", err) } + + queuedMsgs.WithLabelValues(q.name, q.location).Dec() + dl.Debugf("removed message from disk") } func (q *Queue) readDiskQueue() error { - dirInfo, err := ioutil.ReadDir(q.location) + dirInfo, err := os.ReadDir(q.location) if err != nil { return err } @@ -658,18 +694,18 @@ func (q *Queue) readDiskQueue() error { meta, err := q.readMessageMeta(id) if err != nil { - q.Log.Printf("failed to read meta-data, skipping: %v (msg ID = %s)", err, id) + q.log.Printf("failed to read meta-data, skipping: %v (msg ID = %s)", err, id) continue } // Check header file existence. if _, err := os.Stat(filepath.Join(q.location, id+".header")); err != nil { if os.IsNotExist(err) { - q.Log.Printf("header file doesn't exist for msg ID = %s", id) + q.log.Printf("header file doesn't exist for msg ID = %s", id) q.tryRemoveDanglingFile(id + ".meta") q.tryRemoveDanglingFile(id + ".body") } else { - q.Log.Printf("skipping nonstat'able header file: %v (msg ID = %s)", err, id) + q.log.Printf("skipping nonstat'able header file: %v (msg ID = %s)", err, id) } continue } @@ -677,11 +713,11 @@ func (q *Queue) readDiskQueue() error { // Check body file existence. if _, err := os.Stat(filepath.Join(q.location, id+".body")); err != nil { if os.IsNotExist(err) { - q.Log.Printf("body file doesn't exist for msg ID = %s", id) + q.log.Printf("body file doesn't exist for msg ID = %s", id) q.tryRemoveDanglingFile(id + ".meta") q.tryRemoveDanglingFile(id + ".header") } else { - q.Log.Printf("skipping nonstat'able body file: %v (msg ID = %s)", err, id) + q.log.Printf("skipping nonstat'able body file: %v (msg ID = %s)", err, id) } continue } @@ -700,15 +736,17 @@ func (q *Queue) readDiskQueue() error { nextTryTime = time.Now().Add(q.postInitDelay) } - q.Log.Debugf("will try to deliver (msg ID = %s) in %v (%v)", id, time.Until(nextTryTime), nextTryTime) + q.log.Debugf("will try to deliver (msg ID = %s) in %v (%v)", id, time.Until(nextTryTime), nextTryTime) q.wheel.Add(nextTryTime, queueSlot{ ID: id, }) loadedCount++ + + queuedMsgs.WithLabelValues(q.name, q.location).Inc() } if loadedCount != 0 { - q.Log.Printf("loaded %d saved queue entries", loadedCount) + q.log.Printf("loaded %d saved queue entries", loadedCount) } return nil @@ -722,7 +760,11 @@ func (q *Queue) storeNewMessage(meta *QueueMetadata, header textproto.Header, bo if err != nil { return nil, err } - defer headerFile.Close() + defer func() { + if err := headerFile.Close(); err != nil { + q.log.Error("header file close failed", err) + } + }() if err := textproto.WriteHeader(headerFile, header); err != nil { q.tryRemoveDanglingFile(id + ".header") @@ -734,14 +776,22 @@ func (q *Queue) storeNewMessage(meta *QueueMetadata, header textproto.Header, bo q.tryRemoveDanglingFile(id + ".header") return nil, err } - defer bodyReader.Close() + defer func() { + if err := bodyReader.Close(); err != nil { + q.log.Error("bodyReader close failed", err) + } + }() bodyPath := filepath.Join(q.location, id+".body") bodyFile, err := os.Create(bodyPath) if err != nil { return nil, err } - defer bodyFile.Close() + defer func() { + if err := bodyFile.Close(); err != nil { + q.log.Error("body file close failed", err) + } + }() if _, err := io.Copy(bodyFile, bodyReader); err != nil { q.tryRemoveDanglingFile(id + ".body") @@ -763,6 +813,8 @@ func (q *Queue) storeNewMessage(meta *QueueMetadata, header textproto.Header, bo return nil, err } + queuedMsgs.WithLabelValues(q.name, q.location).Inc() + return buffer.FileBuffer{Path: bodyPath, LenHint: body.Len()}, nil } @@ -782,7 +834,11 @@ func (q *Queue) updateMetadataOnDisk(meta *QueueMetadata) error { return err } } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + q.log.Error("metadata file close failed", err) + } + }() metaCopy := *meta metaCopy.MsgMeta = meta.MsgMeta.DeepCopy() @@ -811,7 +867,11 @@ func (q *Queue) readMessageMeta(id string) (*QueueMetadata, error) { if err != nil { return nil, err } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + q.log.Error("metadata file close failed", err) + } + }() meta := &QueueMetadata{} @@ -836,10 +896,10 @@ type BufferedReadCloser struct { func (q *Queue) tryRemoveDanglingFile(name string) { if err := os.Remove(filepath.Join(q.location, name)); err != nil { - q.Log.Error("dangling file remove failed", err) + q.log.Error("dangling file remove failed", err) return } - q.Log.Printf("removed dangling file %s", name) + q.log.Printf("removed dangling file %s", name) } func (q *Queue) openMessage(id string) (*QueueMetadata, textproto.Header, buffer.Buffer, error) { @@ -898,7 +958,7 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt dsnID, err := module.GenerateMsgID() if err != nil { - q.Log.Error("rand.Rand error", err) + q.log.Error("rand.Rand error", err) return } @@ -938,7 +998,7 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt } var dsnBodyBlob bytes.Buffer - dl := target.DeliveryLogger(q.Log, meta.MsgMeta) + dl := target.DeliveryLogger(q.log, meta.MsgMeta) dsnHeader, err := dsn.GenerateDSN(meta.MsgMeta.SMTPOpts.UTF8, dsnEnvelope, mtaInfo, rcptInfo, header, &dsnBodyBlob) if err != nil { dl.Error("failed to generate fail DSN", err) @@ -959,7 +1019,7 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt defer msgTask.End() mailCtx, mailTask := trace.NewTask(msgCtx, "MAIL FROM") - dsnDelivery, err := q.dsnPipeline.Start(mailCtx, dsnMeta, "") + dsnDelivery, err := q.dsnPipeline.StartDelivery(mailCtx, dsnMeta, "") mailTask.End() if err != nil { dl.Error("failed to enqueue DSN", err, "dsn_id", dsnID) @@ -976,7 +1036,7 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt }() rcptCtx, rcptTask := trace.NewTask(msgCtx, "RCPT TO") - if err = dsnDelivery.AddRcpt(rcptCtx, meta.From); err != nil { + if err = dsnDelivery.AddRcpt(rcptCtx, meta.From, smtp.RcptOptions{}); err != nil { rcptTask.End() return } @@ -995,5 +1055,5 @@ func (q *Queue) emitDSN(meta *QueueMetadata, header textproto.Header, failedRcpt } func init() { - module.Register("target.queue", NewQueue) + modules.Register("target.queue", New) } diff --git a/internal/target/queue/queue_test.go b/internal/target/queue/queue_test.go index 622ddc80e..2ad9e821c 100644 --- a/internal/target/queue/queue_test.go +++ b/internal/target/queue/queue_test.go @@ -24,7 +24,7 @@ import ( "crypto/sha1" "encoding/hex" "errors" - "io/ioutil" + "io" "os" "path/filepath" "reflect" @@ -33,11 +33,14 @@ import ( "time" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) // newTestQueue returns properly initialized Queue object usable for testing. @@ -45,25 +48,18 @@ import ( // See newTestQueueDir to create testing queue from an existing directory. // It is called responsibility to remove queue directory created by this function. func newTestQueue(t *testing.T, target module.DeliveryTarget) *Queue { - dir, err := ioutil.TempDir("", "maddy-tests-queue") - if err != nil { - t.Fatal("failed to create temporary directory for queue:", err) - } - return newTestQueueDir(t, target, dir) + return newTestQueueDir(t, target, t.TempDir()) } func cleanQueue(t *testing.T, q *Queue) { t.Log("--- queue.Close") - if err := q.Close(); err != nil { + if err := q.Stop(); err != nil { t.Fatal("queue.Close:", err) } - if err := os.RemoveAll(q.location); err != nil { - t.Fatal("os.RemoveAll", err) - } } func newTestQueueDir(t *testing.T, target module.DeliveryTarget, dir string) *Queue { - mod, _ := NewQueue("", "queue", nil, nil) + mod, _ := New(container.New(), "", "queue") q := mod.(*Queue) q.initialRetryTime = 0 q.retryTimeScale = 1 @@ -73,9 +69,9 @@ func newTestQueueDir(t *testing.T, target module.DeliveryTarget, dir string) *Qu q.Target = target if testing.Verbose() { - q.Log = testutils.Logger(t, "queue") + q.log = testutils.Logger(t, "queue") } else { - q.Log = log.Logger{Out: log.NopOutput{}} + q.log = &log.NopLogger } if err := q.start(1); err != nil { @@ -111,7 +107,7 @@ type unreliableTargetDeliveryPartial struct { *unreliableTargetDelivery } -func (utd *unreliableTargetDelivery) AddRcpt(ctx context.Context, rcptTo string) error { +func (utd *unreliableTargetDelivery) AddRcpt(ctx context.Context, rcptTo string, _ smtp.RcptOptions) error { if len(utd.ut.rcptFailures)> utd.ut.passedMessages { rcptErrs := utd.ut.rcptFailures[utd.ut.passedMessages] if err := rcptErrs[rcptTo]; err != nil { @@ -129,7 +125,7 @@ func (utd *unreliableTargetDelivery) Body(ctx context.Context, header textproto. } r, _ := body.Open() - utd.msg.Body, _ = ioutil.ReadAll(r) + utd.msg.Body, _ = io.ReadAll(r) if len(utd.ut.bodyFailures)> utd.ut.passedMessages { return utd.ut.bodyFailures[utd.ut.passedMessages] @@ -140,7 +136,7 @@ func (utd *unreliableTargetDelivery) Body(ctx context.Context, header textproto. func (utd *unreliableTargetDeliveryPartial) BodyNonAtomic(ctx context.Context, c module.StatusCollector, header textproto.Header, body buffer.Buffer) { r, _ := body.Open() - utd.msg.Body, _ = ioutil.ReadAll(r) + utd.msg.Body, _ = io.ReadAll(r) if len(utd.ut.bodyFailuresPartial)> utd.ut.passedMessages { for rcpt, err := range utd.ut.bodyFailuresPartial[utd.ut.passedMessages] { @@ -165,7 +161,7 @@ func (utd *unreliableTargetDelivery) Commit(ctx context.Context) error { return nil } -func (ut *unreliableTarget) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { +func (ut *unreliableTarget) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { if ut.bodyFailuresPartial != nil { return &unreliableTargetDeliveryPartial{ &unreliableTargetDelivery{ @@ -207,7 +203,7 @@ func checkQueueDir(t *testing.T, q *Queue, expectedIDs []string) { expectedMap[id] = false } - dir, err := ioutil.ReadDir(q.location) + dir, err := os.ReadDir(q.location) if err != nil { t.Fatalf("failed to read queue directory: %v", err) } @@ -251,7 +247,7 @@ func TestQueueDelivery(t *testing.T) { // Wait for the delivery to complete and stop processing. msg := readMsgChanTimeout(t, dt.committed, 5*time.Second) - q.Close() + require.NoError(t, q.Stop()) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org", "tester2@example.org"}, "") @@ -275,7 +271,7 @@ func TestQueueDelivery_PermanentFail_NonPartial(t *testing.T) { // Queue will abort a delivery if it fails for all recipients. readMsgChanTimeout(t, dt.aborted, 5*time.Second) - q.Close() + require.NoError(t, q.Stop()) // Delivery is failed permanently, hence no retry should be rescheduled. checkQueueDir(t, q, []string{}) @@ -302,7 +298,7 @@ func TestQueueDelivery_PermanentFail_Partial(t *testing.T) { // Here delivery fails for recipients too, but this is reported using PartialDelivery. readMsgChanTimeout(t, dt.aborted, 5*time.Second) - q.Close() + require.NoError(t, q.Stop()) checkQueueDir(t, q, []string{}) } @@ -328,7 +324,7 @@ func TestQueueDelivery_TemporaryFail(t *testing.T) { msg := readMsgChanTimeout(t, dt.committed, 5*time.Second) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org", "tester2@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) // No more retries scheduled, queue storage is clear. defer checkQueueDir(t, q, []string{}) } @@ -361,7 +357,7 @@ func TestQueueDelivery_TemporaryFail_Partial(t *testing.T) { msg = readMsgChanTimeout(t, dt.committed, 5000*time.Second) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) // No more retries scheduled, queue storage is clear. checkQueueDir(t, q, []string{}) } @@ -401,7 +397,7 @@ func TestQueueDelivery_MultipleAttempts(t *testing.T) { msg = readMsgChanTimeout(t, dt.committed, 5*time.Second) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) // No more retries should be scheduled. checkQueueDir(t, q, []string{}) } @@ -426,7 +422,7 @@ func TestQueueDelivery_PermanentRcptReject(t *testing.T) { msg := readMsgChanTimeout(t, dt.committed, 5*time.Second) testutils.CheckMsgID(t, msg, "tester@example.org", []string{"tester2@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) // No more retries should be scheduled. checkQueueDir(t, q, []string{}) } @@ -460,7 +456,7 @@ func TestQueueDelivery_TemporaryRcptReject(t *testing.T) { msg = readMsgChanTimeout(t, dt.committed, 5*time.Second) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) // No more retries should be scheduled. checkQueueDir(t, q, []string{}) } @@ -494,7 +490,7 @@ func TestQueueDelivery_SerializationRoundtrip(t *testing.T) { testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "") // Then stop it. - q.Close() + require.NoError(t, q.Stop()) // Make sure it is saved. checkQueueDir(t, q, []string{deliveryID}) @@ -507,7 +503,7 @@ func TestQueueDelivery_SerializationRoundtrip(t *testing.T) { testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester1@example.org"}, "") // Close it again. - q.Close() + require.NoError(t, q.Stop()) // No more retries should be scheduled. checkQueueDir(t, q, []string{}) } @@ -541,7 +537,7 @@ func TestQueueDelivery_DeserlizationCleanUp(t *testing.T) { msg := readMsgChanTimeout(t, dt.committed, 5*time.Second) testutils.CheckMsgID(t, msg, "tester@example.com", []string{"tester2@example.org"}, "") - q.Close() + require.NoError(t, q.Stop()) if err := os.Remove(filepath.Join(q.location, deliveryID+fileSuffix)); err != nil { t.Fatal(err) @@ -549,7 +545,7 @@ func TestQueueDelivery_DeserlizationCleanUp(t *testing.T) { // Dangling files should be removed during load. q = newTestQueueDir(t, &dt, q.location) - q.Close() + require.NoError(t, q.Stop()) // Nothing should be left. checkQueueDir(t, q, []string{}) @@ -612,12 +608,12 @@ func TestQueueDelivery_AbortNoDangling(t *testing.T) { DontTraceSender: true, ID: encodedID, } - delivery, err := q.Start(context.Background(), &ctx, "test3@example.org") + delivery, err := q.StartDelivery(context.Background(), &ctx, "test3@example.org") if err != nil { - t.Fatalf("unexpected Start err: %v", err) + t.Fatalf("unexpected StartDelivery err: %v", err) } for _, rcpt := range [...]string{"test@example.org", "test2@example.org"} { - if err := delivery.AddRcpt(context.Background(), rcpt); err != nil { + if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil { t.Fatalf("unexpected AddRcpt err for %s: %v", rcpt, err) } } @@ -792,12 +788,12 @@ func TestQueueDSN_RcptRewrite(t *testing.T) { }, ID: encodedID, } - delivery, err := q.Start(context.Background(), &ctx, "test3@example.org") + delivery, err := q.StartDelivery(context.Background(), &ctx, "test3@example.org") if err != nil { - t.Fatalf("unexpected Start err: %v", err) + t.Fatalf("unexpected StartDelivery err: %v", err) } for _, rcpt := range [...]string{"test@example.org", "test2@example.org"} { - if err := delivery.AddRcpt(context.Background(), rcpt); err != nil { + if err := delivery.AddRcpt(context.Background(), rcpt, smtp.RcptOptions{}); err != nil { t.Fatalf("unexpected AddRcpt err for %s: %v", rcpt, err) } } diff --git a/internal/target/queue/timewheel.go b/internal/target/queue/timewheel.go index 060804b27..cc385e3c5 100644 --- a/internal/target/queue/timewheel.go +++ b/internal/target/queue/timewheel.go @@ -20,17 +20,18 @@ package queue import ( "container/list" + "context" "sync" "sync/atomic" "time" ) -type TimeSlot struct { +type TimeSlot[Value any] struct { Time time.Time - Value interface{} + Value Value } -type TimeWheel struct { +type TimeWheel[Value any] struct { stopped uint32 slots *list.List @@ -38,39 +39,41 @@ type TimeWheel struct { updateNotify chan time.Time stopNotify chan struct{} + tickerCtx context.Context + tickerCancel context.CancelFunc - dispatch func(TimeSlot) + dispatch func(context.Context, TimeSlot[Value]) } -func NewTimeWheel(dispatch func(TimeSlot)) *TimeWheel { - tw := &TimeWheel{ +func NewTimeWheel[Value any](dispatch func(context.Context, TimeSlot[Value])) *TimeWheel[Value] { + ctx, cancel := context.WithCancel(context.Background()) + + tw := &TimeWheel[Value]{ slots: list.New(), stopNotify: make(chan struct{}), + tickerCtx: ctx, + tickerCancel: cancel, updateNotify: make(chan time.Time), dispatch: dispatch, } - go tw.tick() + go tw.tick(context.Background()) return tw } -func (tw *TimeWheel) Add(target time.Time, value interface{}) { +func (tw *TimeWheel[Value]) Add(target time.Time, value Value) { if atomic.LoadUint32(&tw.stopped) == 1 { // Already stopped, ignore. return } - if value == nil { - panic("can't insert nil objects into TimeWheel queue") - } - tw.slotsLock.Lock() - tw.slots.PushBack(TimeSlot{Time: target, Value: value}) + tw.slots.PushBack(TimeSlot[Value]{Time: target, Value: value}) tw.slotsLock.Unlock() tw.updateNotify <- target } -func (tw *TimeWheel) Close() { +func (tw *TimeWheel[Value]) Close() { atomic.StoreUint32(&tw.stopped, 1) // Idempotent Close is convenient sometimes. @@ -78,6 +81,8 @@ func (tw *TimeWheel) Close() { return } + tw.tickerCancel() + tw.stopNotify <- struct{}{} <-tw.stopnotify @@ -86,16 +91,16 @@ func (tw *TimeWheel) Close() { close(tw.updateNotify) } -func (tw *TimeWheel) tick() { +func (tw *TimeWheel[Value]) tick(ctx context.Context) { for { now := time.Now() // Look for list element closest to now. tw.slotsLock.Lock() - var closestSlot TimeSlot + var closestSlot TimeSlot[Value] var closestEl *list.Element for e := tw.slots.Front(); e != nil; e = e.Next() { - slot := e.Value.(TimeSlot) - if slot.Time.Sub(now) < closestSlot.Time.Sub(now) || closestSlot.Value == nil { + slot := e.Value.(TimeSlot[Value]) + if slot.Time.Sub(now) < closestSlot.Time.Sub(now) || closestEl == nil { closestSlot = slot closestEl = e } @@ -124,7 +129,7 @@ func (tw *TimeWheel) tick() { tw.slots.Remove(closestEl) tw.slotsLock.Unlock() - tw.dispatch(closestSlot) + tw.dispatch(ctx, closestSlot) break selectloop case newTarget := <-tw.updatenotify: diff --git a/internal/target/queue/timewheel_test.go b/internal/target/queue/timewheel_test.go index d758b6030..9beb171e6 100644 --- a/internal/target/queue/timewheel_test.go +++ b/internal/target/queue/timewheel_test.go @@ -19,6 +19,7 @@ along with this program. If not, see . package queue import ( + "context" "testing" "time" ) @@ -26,9 +27,9 @@ import ( func TestTimeWheelAdd(t *testing.T) { t.Parallel() - called := make(chan TimeSlot) + called := make(chan TimeSlot[int]) - w := NewTimeWheel(func(slot TimeSlot) { + w := NewTimeWheel[int](func(ctx context.Context, slot TimeSlot[int]) { called <- slot }) defer w.Close() @@ -36,7 +37,7 @@ func TestTimeWheelAdd(t *testing.T) { w.Add(time.Now().Add(1*time.Second), 1) slot := <-called - if val, _ := slot.Value.(int); val != 1 { + if slot.Value != 1 { t.Errorf("Wrong slot value: %v", slot.Value) } } @@ -44,9 +45,9 @@ func TestTimeWheelAdd(t *testing.T) { func TestTimeWheelAdd_Ordering(t *testing.T) { t.Parallel() - called := make(chan TimeSlot) + called := make(chan TimeSlot[int]) - w := NewTimeWheel(func(slot TimeSlot) { + w := NewTimeWheel[int](func(ctx context.Context, slot TimeSlot[int]) { called <- slot }) defer w.Close() @@ -55,11 +56,11 @@ func TestTimeWheelAdd_Ordering(t *testing.T) { w.Add(time.Now().Add(1250*time.Millisecond), 2) slot := <-called - if val, _ := slot.Value.(int); val != 1 { + if slot.Value != 1 { t.Errorf("Wrong first slot value: %v", slot.Value) } slot = <-called - if val, _ := slot.Value.(int); val != 2 { + if slot.Value != 2 { t.Errorf("Wrong second slot value: %v", slot.Value) } } @@ -67,9 +68,9 @@ func TestTimeWheelAdd_Ordering(t *testing.T) { func TestTimeWheelAdd_Restart(t *testing.T) { t.Parallel() - called := make(chan TimeSlot) + called := make(chan TimeSlot[int]) - w := NewTimeWheel(func(slot TimeSlot) { + w := NewTimeWheel[int](func(ctx context.Context, slot TimeSlot[int]) { called <- slot }) defer w.Close() @@ -78,11 +79,11 @@ func TestTimeWheelAdd_Restart(t *testing.T) { w.Add(time.Now().Add(500*time.Millisecond), 2) slot := <-called - if val, _ := slot.Value.(int); val != 2 { + if slot.Value != 2 { t.Errorf("Wrong first slot value: %v", slot.Value) } slot = <-called - if val, _ := slot.Value.(int); val != 1 { + if slot.Value != 1 { t.Errorf("Wrong second slot value: %v", slot.Value) } } @@ -90,9 +91,9 @@ func TestTimeWheelAdd_Restart(t *testing.T) { func TestTimeWheelAdd_MissingGotoBug(t *testing.T) { t.Parallel() - called := make(chan TimeSlot) + called := make(chan TimeSlot[int]) - w := NewTimeWheel(func(slot TimeSlot) { + w := NewTimeWheel[int](func(ctx context.Context, slot TimeSlot[int]) { called <- slot }) defer w.Close() @@ -101,7 +102,7 @@ func TestTimeWheelAdd_MissingGotoBug(t *testing.T) { w.Add(time.Now().Add(500*time.Millisecond), 2) // should correctly restart slot := <-called - if val, _ := slot.Value.(int); val != 2 { + if slot.Value != 2 { t.Errorf("Wrong first slot value: %v", slot.Value) } } @@ -109,9 +110,9 @@ func TestTimeWheelAdd_MissingGotoBug(t *testing.T) { func TestTimeWheelAdd_EmptyUpdWait(t *testing.T) { t.Parallel() - called := make(chan TimeSlot) + called := make(chan TimeSlot[int]) - w := NewTimeWheel(func(slot TimeSlot) { + w := NewTimeWheel[int](func(ctx context.Context, slot TimeSlot[int]) { called <- slot }) defer w.Close() @@ -121,7 +122,7 @@ func TestTimeWheelAdd_EmptyUpdWait(t *testing.T) { w.Add(time.Now().Add(1*time.Second), 1) slot := <-called - if val, _ := slot.Value.(int); val != 1 { + if slot.Value != 1 { t.Errorf("Wrong slot value: %v", slot.Value) } } diff --git a/internal/target/received.go b/internal/target/received.go index 051e5a6da..b60bbd14b 100644 --- a/internal/target/received.go +++ b/internal/target/received.go @@ -31,7 +31,7 @@ import ( ) func SanitizeForHeader(raw string) string { - return strings.Replace(raw, "\n", "", -1) + return strings.ReplaceAll(raw, "\n", "") } func GenerateReceived(ctx context.Context, msgMeta *module.MsgMetadata, ourHostname, mailFrom string) (string, error) { diff --git a/internal/target/remote/connect.go b/internal/target/remote/connect.go index 3e6c5e78f..3b9692d08 100644 --- a/internal/target/remote/connect.go +++ b/internal/target/remote/connect.go @@ -21,10 +21,11 @@ package remote import ( "context" "crypto/tls" - "crypto/x509" + "errors" "net" "runtime/trace" "sort" + "time" "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/dns" @@ -44,9 +45,11 @@ type mxConn struct { errored bool reuseLimit int + takeDest bool // Amount of times connection was used for an SMTP transaction. transactions int + lastUseAt time.Time // MX/TLS security level established for this connection. mxLevel module.MXLevel @@ -54,31 +57,23 @@ type mxConn struct { } func (c *mxConn) Usable() bool { - if c.C == nil || c.transactions> c.reuseLimit || c.C.Client() == nil { + if c.C == nil || c.transactions> c.reuseLimit || c.Client() == nil || c.errored { return false } return c.C.Client().Reset() == nil } +func (c *mxConn) LastUseAt() time.Time { + return c.lastUseAt +} + func (c *mxConn) Close() error { return c.C.Close() } func isVerifyError(err error) bool { - _, ok := err.(x509.UnknownAuthorityError) - if ok { - return true - } - _, ok = err.(x509.HostnameError) - if ok { - return true - } - _, ok = err.(x509.ConstraintViolationError) - if ok { - return true - } - _, ok = err.(x509.CertificateInvalidError) - return ok + var e *tls.CertificateVerificationError + return errors.As(err, &e) } // connect attempts to connect to the MX, first trying STARTTLS with X.509 @@ -95,7 +90,7 @@ func (rd *remoteDelivery) connect(ctx context.Context, conn mxConn, host string, tlsCfg.ServerName = host } - rd.Log.DebugMsg("trying", "remote_server", host, "domain", conn.domain) + rd.log.DebugMsg("trying", "remote_server", host, "domain", conn.domain) retry: // smtpconn.C default TLS behavior is not useful for us, we want to handle @@ -111,6 +106,18 @@ retry: starttlsOk, _ := conn.Client().Extension("STARTTLS") if starttlsOk && tlsCfg != nil { if err := conn.Client().StartTLS(tlsCfg); err != nil { + // Here we just issue STARTTLS command. If it fails for some + // reason - this is either a connection problem or server actively + // rejecting STARTTLS (despite advertising STARTTLS). + // We err on the caution side here and do not perform any fallbacks. + if err := conn.DirectClose(); err != nil { + rd.log.Error("conn.DirectClose failed", err) + } + return module.TLSNone, nil, err + } + + // TLS handshake is deferred to here, this is where we check errors and allow fallback. + if err := conn.Client().Hello(rd.rt.hostname); err != nil { tlsErr = err // Attempt TLS without authentication. It is still better than @@ -121,21 +128,25 @@ retry: // error happens with InsecureSkipVerify too (e.g. certificate is // *too* broken). if isVerifyError(err) && tlsLevel == module.TLSAuthenticated { - rd.Log.Error("TLS verify error, trying without authentication", err, "remote_server", host, "domain", conn.domain) + rd.log.Error("TLS verify error, trying without authentication", err, "remote_server", host, "domain", conn.domain) tlsCfg.InsecureSkipVerify = true tlsLevel = module.TLSEncrypted // TODO: Check go-smtp code to make TLS verification errors // non-sticky so we can properly send QUIT in this case. - conn.DirectClose() + if err := conn.DirectClose(); err != nil { + rd.log.Error("conn.DirectClose failed", err) + } goto retry } - rd.Log.Error("TLS error, trying plaintext", err, "remote_server", host, "domain", conn.domain) + rd.log.Error("TLS error, trying plaintext", err, "remote_server", host, "domain", conn.domain) tlsCfg = nil tlsLevel = module.TLSNone - conn.DirectClose() + if err := conn.DirectClose(); err != nil { + rd.log.Error("conn.DirectClose failed", err) + } goto retry } @@ -179,7 +190,7 @@ func (rd *remoteDelivery) attemptMX(ctx context.Context, conn *mxConn, record *n for _, p := range rd.policies { policyLevel, err := p.CheckConn(connCtx, mxLevel, tlsLevel, conn.domain, record.Host, tlsState) if err != nil { - conn.Close() + rd.closeConn(conn) return exterrors.WithFields(err, map[string]interface{}{"tls_err": tlsErr}) } if policyLevel> tlsLevel { @@ -196,9 +207,19 @@ func (rd *remoteDelivery) attemptMX(ctx context.Context, conn *mxConn, record *n return nil } -func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string) (*smtpconn.C, error) { +func (rd *remoteDelivery) closeConn(c *mxConn) { + if c.takeDest { + rd.rt.limits.ReleaseDest(c.domain) + } + + if err := c.Close(); err != nil { + rd.log.Error("client connection close failed", err) + } +} + +func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string) (*mxConn, error) { if c, ok := rd.connections[domain]; ok { - return c.C, nil + return c, nil } pooledConn, err := rd.rt.pool.Get(ctx, domain) @@ -212,9 +233,10 @@ func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string // connection with weaker security. if pooledConn != nil && !rd.msgMeta.SMTPOpts.RequireTLS { conn = pooledConn.(*mxConn) - rd.Log.Msg("reusing cached connection", "domain", domain, "transactions_counter", conn.transactions) + rd.log.Msg("reusing cached connection", "domain", domain, "transactions_counter", conn.transactions, + "local_addr", conn.LocalAddr(), "remote_addr", conn.RemoteAddr()) } else { - rd.Log.DebugMsg("opening new connection", "domain", domain, "cache_ignored", pooledConn != nil) + rd.log.DebugMsg("opening new connection", "domain", domain, "cache_ignored", pooledConn != nil) conn, err = rd.newConn(ctx, domain) if err != nil { return nil, err @@ -223,7 +245,7 @@ func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string if rd.msgMeta.SMTPOpts.RequireTLS { if conn.tlsLevel < module.TLSAuthenticated { - conn.Close() + rd.closeConn(conn) return nil, &exterrors.SMTPError{ Code: 550, EnhancedCode: exterrors.EnhancedCode{5, 7, 30}, @@ -234,11 +256,11 @@ func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string } } if conn.mxLevel < module.MX_MTASTS { - conn.Close() + rd.closeConn(conn) return nil, &exterrors.SMTPError{ Code: 550, EnhancedCode: exterrors.EnhancedCode{5, 7, 30}, - Message: "Failed to estabilish the MX record authenticity (REQUIRETLS)", + Message: "Failed to establish the MX record authenticity (REQUIRETLS)", Misc: map[string]interface{}{ "mx_level": conn.mxLevel, }, @@ -249,9 +271,11 @@ func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string region := trace.StartRegion(ctx, "remote/limits.TakeDest") if err := rd.rt.limits.TakeDest(ctx, domain); err != nil { region.End() + rd.closeConn(conn) return nil, err } region.End() + conn.takeDest = true // Relaxed REQUIRETLS mode is not conforming to the specification strictly // but allows to start deploying client support for REQUIRETLS without the @@ -266,12 +290,13 @@ func (rd *remoteDelivery) connectionForDomain(ctx context.Context, domain string } if err := conn.Mail(ctx, rd.mailFrom, rd.msgMeta.SMTPOpts); err != nil { - conn.Close() + rd.closeConn(conn) return nil, err } + conn.lastUseAt = time.Now() rd.connections[domain] = conn - return conn.C, nil + return conn, nil } func (rd *remoteDelivery) newConn(ctx context.Context, domain string) (*mxConn, error) { @@ -279,10 +304,11 @@ func (rd *remoteDelivery) newConn(ctx context.Context, domain string) (*mxConn, reuseLimit: rd.rt.connReuseLimit, C: smtpconn.New(), domain: domain, + lastUseAt: time.Now(), } conn.Dialer = rd.rt.dialer - conn.Log = rd.Log + conn.Log = rd.log conn.Hostname = rd.rt.hostname conn.AddrInSMTPMsg = true if rd.rt.connectTimeout != 0 { @@ -320,7 +346,7 @@ func (rd *remoteDelivery) newConn(ctx context.Context, domain string) (*mxConn, if err := rd.attemptMX(ctx, &conn, record); err != nil { if len(records) != 0 { - rd.Log.Error("cannot use MX", err, "remote_server", record.Host, "domain", domain) + rd.log.Error("cannot use MX", err, "remote_server", record.Host, "domain", domain) } lastErr = err continue @@ -329,7 +355,7 @@ func (rd *remoteDelivery) newConn(ctx context.Context, domain string) (*mxConn, } region.End() - // Stil not connected? Bail out. + // Still not connected? Bail out. if conn.Client() == nil { return nil, &exterrors.SMTPError{ Code: exterrors.SMTPCode(lastErr, 451, 550), diff --git a/internal/target/remote/dane_delivery_test.go b/internal/target/remote/dane_delivery_test.go index 2b921c7dc..cb441acaa 100644 --- a/internal/target/remote/dane_delivery_test.go +++ b/internal/target/remote/dane_delivery_test.go @@ -29,10 +29,13 @@ import ( "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" miekgdns "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func targetWithExtResolver(t *testing.T, zones map[string]mockdns.Zone) (*mockdns.Server, *Target) { - dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false) + l := testutils.Logger(t, "mockdns") + dnsSrv, err := mockdns.NewServerWithLogger(zones, l, false) if err != nil { t.Fatal(err) } @@ -76,7 +79,9 @@ func tlsaRecord(name string, usage, matchType, selector uint8, cert string) map[ func TestRemoteDelivery_DANE_Ok(t *testing.T) { _, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Non-CNAME" case. @@ -97,7 +102,9 @@ func TestRemoteDelivery_DANE_Ok(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + assert.NoError(t, dnsSrv.Close()) + }() tgt.policies = append(tgt.policies, &localPolicy{ minTLSLevel: module.TLSAuthenticated, // Established via DANE instead of PKIX. @@ -110,7 +117,9 @@ func TestRemoteDelivery_DANE_Ok(t *testing.T) { func TestRemoteDelivery_DANE_CNAMEd_1(t *testing.T) { _, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Secure CNAME" case - TLSA at CNAME matches. @@ -134,7 +143,9 @@ func TestRemoteDelivery_DANE_CNAMEd_1(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + assert.NoError(t, dnsSrv.Close()) + }() tgt.policies = append(tgt.policies, &localPolicy{ minTLSLevel: module.TLSAuthenticated, // Established via DANE instead of PKIX. @@ -147,7 +158,9 @@ func TestRemoteDelivery_DANE_CNAMEd_1(t *testing.T) { func TestRemoteDelivery_DANE_CNAMEd_2(t *testing.T) { _, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Secure CNAME" case - TLSA at initial name matches. @@ -172,7 +185,9 @@ func TestRemoteDelivery_DANE_CNAMEd_2(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + assert.NoError(t, dnsSrv.Close()) + }() tgt.policies = append(tgt.policies, &localPolicy{ minTLSLevel: module.TLSAuthenticated, // Established via DANE instead of PKIX. @@ -185,7 +200,9 @@ func TestRemoteDelivery_DANE_CNAMEd_2(t *testing.T) { func TestRemoteDelivery_DANE_InsecureCNAMEDest(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Insecure CNAME" case - initial name is secure. @@ -216,7 +233,9 @@ func TestRemoteDelivery_DANE_InsecureCNAMEDest(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() tgt.tlsConfig = clientCfg _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) @@ -230,7 +249,9 @@ func TestRemoteDelivery_DANE_InsecureCNAMEDest(t *testing.T) { func TestRemoteDelivery_DANE_NonAD_TLSA_Ignore(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Non-CNAME" case - initial name is insecure. @@ -249,7 +270,9 @@ func TestRemoteDelivery_DANE_NonAD_TLSA_Ignore(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -257,7 +280,9 @@ func TestRemoteDelivery_DANE_NonAD_TLSA_Ignore(t *testing.T) { func TestRemoteDelivery_DANE_NonADIgnore_CNAME(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) // RFC 7672, Section 2.2.2. "Insecure CNAME" case - initial name is insecure. @@ -280,7 +305,9 @@ func TestRemoteDelivery_DANE_NonADIgnore_CNAME(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -288,7 +315,9 @@ func TestRemoteDelivery_DANE_NonADIgnore_CNAME(t *testing.T) { func TestRemoteDelivery_DANE_SkipAUnauth(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -307,7 +336,9 @@ func TestRemoteDelivery_DANE_SkipAUnauth(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() tgt.tlsConfig = clientCfg testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) @@ -316,7 +347,9 @@ func TestRemoteDelivery_DANE_SkipAUnauth(t *testing.T) { func TestRemoteDelivery_DANE_Mismatch(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -336,7 +369,9 @@ func TestRemoteDelivery_DANE_Mismatch(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() tgt.tlsConfig = clientCfg _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) @@ -350,7 +385,9 @@ func TestRemoteDelivery_DANE_Mismatch(t *testing.T) { func TestRemoteDelivery_DANE_NoRecord(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -364,7 +401,9 @@ func TestRemoteDelivery_DANE_NoRecord(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() tgt.tlsConfig = clientCfg testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) @@ -373,7 +412,9 @@ func TestRemoteDelivery_DANE_NoRecord(t *testing.T) { func TestRemoteDelivery_DANE_LookupErr(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -390,7 +431,9 @@ func TestRemoteDelivery_DANE_LookupErr(t *testing.T) { } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() tgt.tlsConfig = clientCfg _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) @@ -404,7 +447,9 @@ func TestRemoteDelivery_DANE_LookupErr(t *testing.T) { func TestRemoteDelivery_DANE_NoTLS(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -423,7 +468,9 @@ func TestRemoteDelivery_DANE_NoTLS(t *testing.T) { }, } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -436,7 +483,9 @@ func TestRemoteDelivery_DANE_NoTLS(t *testing.T) { func TestRemoteDelivery_DANE_TLSError(t *testing.T) { _, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -456,7 +505,9 @@ func TestRemoteDelivery_DANE_TLSError(t *testing.T) { }, } dnsSrv, tgt := targetWithExtResolver(t, zones) - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() // Cause failure through version incompatibility. tgt.tlsConfig = &tls.Config{ diff --git a/internal/target/remote/dane_test.go b/internal/target/remote/dane_test.go index d8b6d5d2c..470fbbbce 100644 --- a/internal/target/remote/dane_test.go +++ b/internal/target/remote/dane_test.go @@ -31,8 +31,9 @@ import ( ) // These certificates are related like this: -// Root A -> Intermediate A -> Leaf A -// Root B -> LeafB +// +// Root A -> Intermediate A -> Leaf A +// Root B -> LeafB var ( rootA = `-----BEGIN CERTIFICATE----- MIIBMDCB46ADAgECAhRDwag3n5CG90BEO87zEMAPejn6YTAFBgMrZXAwFjEUMBIG diff --git a/internal/target/remote/debugflags.go b/internal/target/remote/debugflags.go index c79d9317b..0a71a10a7 100644 --- a/internal/target/remote/debugflags.go +++ b/internal/target/remote/debugflags.go @@ -1,4 +1,5 @@ -//+build debugflags +//go:build debugflags +// +build debugflags /* Maddy Mail Server - Composable all-in-one email server. @@ -20,8 +21,15 @@ along with this program. If not, see . package remote -import "flag" +import ( + maddycli "github.com/foxcpp/maddy/internal/cli" + "github.com/urfave/cli/v2" +) func init() { - flag.StringVar(&smtpPort, "debug.smtpport", "25", "SMTP port to use for connections in tests") + maddycli.AddGlobalFlag(&cli.StringFlag{ + Name: "debug.smtpport", + Usage: "SMTP port to use for connections in tests", + Destination: &smtpPort, + }) } diff --git a/internal/target/remote/mxauth_test.go b/internal/target/remote/mxauth_test.go index 2bd8d2d49..8a9e7e378 100644 --- a/internal/target/remote/mxauth_test.go +++ b/internal/target/remote/mxauth_test.go @@ -32,11 +32,15 @@ import ( "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRemoteDelivery_AuthMX_MTASTS(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -63,7 +67,9 @@ func TestRemoteDelivery_AuthMX_MTASTS(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -71,11 +77,15 @@ func TestRemoteDelivery_AuthMX_MTASTS(t *testing.T) { func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) { _, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + require.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.2:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -108,7 +118,9 @@ func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) { &localPolicy{minMXLevel: module.MX_MTASTS}, }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -120,7 +132,9 @@ func TestRemoteDelivery_MTASTS_SkipNonMatching(t *testing.T) { func TestRemoteDelivery_AuthMX_MTASTS_Fail(t *testing.T) { clientCfg, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + assert.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) zones := map[string]mockdns.Zone{ @@ -148,7 +162,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_Fail(t *testing.T) { &localPolicy{minMXLevel: module.MX_MTASTS}, }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -162,7 +178,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_Fail(t *testing.T) { func TestRemoteDelivery_AuthMX_MTASTS_NoTLS(t *testing.T) { be1, srv1 := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + assert.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) zones := map[string]mockdns.Zone{ @@ -189,7 +207,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoTLS(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), &localPolicy{minMXLevel: module.MX_MTASTS}, }) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -203,7 +223,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoTLS(t *testing.T) { func TestRemoteDelivery_AuthMX_MTASTS_RequirePKIX(t *testing.T) { _, be1, srv1 := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + require.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) zones := map[string]mockdns.Zone{ @@ -230,7 +252,12 @@ func TestRemoteDelivery_AuthMX_MTASTS_RequirePKIX(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), &localPolicy{minMXLevel: module.MX_MTASTS}, }) - defer tgt.Close() + defer func(tgt *Target) { + err := tgt.Stop() + if err != nil { + t.Fatal(err) + } + }(tgt) _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -257,7 +284,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoPolicy(t *testing.T) { // // https://builds.sr.ht/~emersion/job/147975 tarpit := testutils.FailOnConn(t, "127.0.0.1:"+smtpPort) - defer tarpit.Close() + defer func() { + require.NoError(t, tarpit.Close()) + }() zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -280,7 +309,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoPolicy(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), &localPolicy{minMXLevel: module.MX_MTASTS}, }) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -290,7 +321,9 @@ func TestRemoteDelivery_AuthMX_MTASTS_NoPolicy(t *testing.T) { func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -303,11 +336,14 @@ func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) { }, } - dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false) + l := testutils.Logger(t, "mockdns") + dnsSrv, err := mockdns.NewServerWithLogger(zones, l, false) if err != nil { t.Fatal(err) } - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() dialer := net.Dialer{} dialer.Resolver = &net.Resolver{} @@ -322,7 +358,9 @@ func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) { extResolver.Cfg.Port = strconv.Itoa(addr.Port) tgt := testTarget(t, zones, extResolver, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -330,7 +368,9 @@ func TestRemoteDelivery_AuthMX_DNSSEC(t *testing.T) { func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -342,11 +382,14 @@ func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) { }, } - dnsSrv, err := mockdns.NewServerWithLogger(zones, testutils.Logger(t, "mockdns"), false) + l := testutils.Logger(t, "mockdns") + dnsSrv, err := mockdns.NewServerWithLogger(zones, l, false) if err != nil { t.Fatal(err) } - defer dnsSrv.Close() + defer func() { + require.NoError(t, dnsSrv.Close()) + }() dialer := net.Dialer{} dialer.Resolver = &net.Resolver{} @@ -363,7 +406,9 @@ func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) { tgt := testTarget(t, zones, extResolver, []module.MXAuthPolicy{ &localPolicy{minMXLevel: module.MX_DNSSEC}, }) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err = testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -378,7 +423,9 @@ func TestRemoteDelivery_AuthMX_DNSSEC_Fail(t *testing.T) { func TestRemoteDelivery_REQUIRETLS(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = true - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -405,7 +452,9 @@ func TestRemoteDelivery_REQUIRETLS(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDeliveryMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", @@ -419,7 +468,9 @@ func TestRemoteDelivery_REQUIRETLS(t *testing.T) { func TestRemoteDelivery_REQUIRETLS_Fail(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = false /* no REQUIRETLS */ - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -446,7 +497,9 @@ func TestRemoteDelivery_REQUIRETLS_Fail(t *testing.T) { testSTSPolicy(t, zones, mtastsGet), }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", @@ -464,7 +517,9 @@ func TestRemoteDelivery_REQUIRETLS_Fail(t *testing.T) { func TestRemoteDelivery_REQUIRETLS_Relaxed(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = false /* no REQUIRETLS */ - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -492,7 +547,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed(t *testing.T) { }) tgt.relaxedREQUIRETLS = true tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDeliveryMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", @@ -506,7 +563,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed(t *testing.T) { func TestRemoteDelivery_REQUIRETLS_Relaxed_NoMXAuth(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = false /* no REQUIRETLS */ - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -529,7 +588,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoMXAuth(t *testing.T) { }) tgt.relaxedREQUIRETLS = true tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", @@ -547,7 +608,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoMXAuth(t *testing.T) { func TestRemoteDelivery_REQUIRETLS_Relaxed_NoTLS(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = false /* no REQUIRETLS */ - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -575,7 +638,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoTLS(t *testing.T) { }) tgt.relaxedREQUIRETLS = true tgt.tlsConfig = nil - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", @@ -593,7 +658,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_NoTLS(t *testing.T) { func TestRemoteDelivery_REQUIRETLS_Relaxed_TLSFail(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) srv.EnableREQUIRETLS = false /* no REQUIRETLS */ - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -626,7 +693,9 @@ func TestRemoteDelivery_REQUIRETLS_Relaxed_TLSFail(t *testing.T) { srv.TLSConfig.MinVersion = tls.VersionTLS11 srv.TLSConfig.MaxVersion = tls.VersionTLS11 tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() if _, err := testutils.DoTestDeliveryErrMeta(t, tgt, "test@example.com", []string{"test@example.invalid"}, &module.MsgMetadata{ OriginalFrom: "test@example.com", diff --git a/internal/target/remote/policy_group.go b/internal/target/remote/policy_group.go index 68a2202b2..a7d949b6b 100644 --- a/internal/target/remote/policy_group.go +++ b/internal/target/remote/policy_group.go @@ -21,7 +21,9 @@ package remote import ( "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) // PolicyGroup is a module container for a group of Policy implementations. @@ -39,7 +41,7 @@ type PolicyGroup struct { pols map[string]module.MXAuthPolicy } -func (pg *PolicyGroup) Init(cfg *config.Map) error { +func (pg *PolicyGroup) Configure(inlineArgs []string, cfg *config.Map) error { var debugLog bool cfg.Bool("debug", true, false, &debugLog) cfg.AllowUnknown() @@ -87,16 +89,16 @@ func (pg *PolicyGroup) Init(cfg *config.Map) error { return nil } -func (PolicyGroup) Name() string { +func (*PolicyGroup) Name() string { return "mx_auth" } -func (pg PolicyGroup) InstanceName() string { +func (pg *PolicyGroup) InstanceName() string { return pg.instName } func init() { - module.Register("mx_auth", func(_, instName string, _, _ []string) (module.Module, error) { + modules.Register("mx_auth", func(_ *container.C, _, instName string) (module.Module, error) { return &PolicyGroup{ instName: instName, pols: map[string]module.MXAuthPolicy{}, diff --git a/internal/target/remote/remote.go b/internal/target/remote/remote.go index 03b6f33a3..daf5a874b 100644 --- a/internal/target/remote/remote.go +++ b/internal/target/remote/remote.go @@ -35,15 +35,18 @@ import ( "time" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/address" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/limits" "github.com/foxcpp/maddy/internal/smtpconn/pool" "github.com/foxcpp/maddy/internal/target" @@ -77,7 +80,7 @@ type Target struct { pool *pool.P connReuseLimit int - Log log.Logger + log *log.Logger connectTimeout time.Duration commandTimeout time.Duration @@ -86,30 +89,31 @@ type Target struct { var _ module.DeliveryTarget = &Target{} -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, errors.New("remote: inline arguments are not used") - } +func New(c *container.C, modName, instName string) (module.Module, error) { // Keep this synchronized with testTarget. return &Target{ name: instName, resolver: dns.DefaultResolver(), dialer: (&net.Dialer{}).DialContext, - Log: log.Logger{Name: "remote"}, + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (rt *Target) Init(cfg *config.Map) error { +func (rt *Target) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return errors.New("remote: inline arguments are not used") + } + var err error rt.extResolver, err = dns.NewExtResolver() if err != nil { - rt.Log.Error("cannot initialize DNSSEC-aware resolver, DNSSEC and DANE are not available", err) + rt.log.Error("cannot initialize DNSSEC-aware resolver, DNSSEC and DANE are not available", err) } cfg.String("hostname", true, true, "", &rt.hostname) cfg.String("local_ip", false, false, "", &rt.localIP) cfg.Bool("force_ipv4", false, false, &rt.ipv4) - cfg.Bool("debug", true, false, &rt.Log.Debug) + cfg.Bool("debug", true, false, &rt.log.Debug) cfg.Custom("tls_client", true, false, func() (interface{}, error) { return &tls.Config{}, nil }, tls2.TLSClientBlock, &rt.tlsConfig) @@ -126,7 +130,7 @@ func (rt *Target) Init(cfg *config.Map) error { return p.L, nil }, &rt.policies) cfg.Custom("limits", false, false, func() (interface{}, error) { - return &limits.Group{}, nil + return limits.Empty(rt.log.Sublogger("limits")), nil }, func(cfg *config.Map, n config.Node) (interface{}, error) { var g *limits.Group if err := modconfig.GroupFromNode("limits", n.Args, n, cfg.Globals, &g); err != nil { @@ -142,12 +146,12 @@ func (rt *Target) Init(cfg *config.Map) error { cfg.Duration("submission_timeout", false, false, 5*time.Minute, &rt.submissionTimeout) poolCfg := pool.Config{ - MaxKeys: 20000, - MaxConnsPerKey: 10, // basically, max. amount of idle connections in cache + MaxKeys: 5000, + MaxConnsPerKey: 5, // basically, max. amount of idle connections in cache MaxConnLifetimeSec: 150, // 2.5 mins, half of recommended idle time from RFC 5321 - StaleKeyLifetimeSec: 60 * 5, // should be bigger than MaxConnLifetimeSec + StaleKeyLifetimeSec: 60 * 4, // make sure that cleanup runs before recommended idle time from RFC 5321 } - cfg.Int("conn_max_idle_count", false, false, 10, &poolCfg.MaxConnsPerKey) + cfg.Int("conn_max_idle_count", false, false, 5, &poolCfg.MaxConnsPerKey) cfg.Int64("conn_max_idle_time", false, false, 150, &poolCfg.MaxConnLifetimeSec) if _, err := cfg.Process(); err != nil { @@ -183,7 +187,11 @@ func (rt *Target) Init(cfg *config.Map) error { return nil } -func (rt *Target) Close() error { +func (rt *Target) Start() error { + return nil +} + +func (rt *Target) Stop() error { rt.pool.Close() return nil @@ -201,7 +209,7 @@ type remoteDelivery struct { rt *Target mailFrom string msgMeta *module.MsgMetadata - Log log.Logger + log *log.Logger recipients []string connections map[string]*mxConn @@ -209,11 +217,11 @@ type remoteDelivery struct { policies []module.DeliveryMXAuthPolicy } -func (rt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { +func (rt *Target) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { policies := make([]module.DeliveryMXAuthPolicy, 0, len(rt.policies)) - if !(msgMeta.TLSRequireOverride && rt.allowSecOverride) { + if !msgMeta.TLSRequireOverride || !rt.allowSecOverride { for _, p := range rt.policies { - policies = append(policies, p.Start(msgMeta)) + policies = append(policies, p.StartDelivery(msgMeta)) } } @@ -263,13 +271,13 @@ func (rt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFr rt: rt, mailFrom: mailFrom, msgMeta: msgMeta, - Log: target.DeliveryLogger(rt.Log, msgMeta), + log: target.DeliveryLogger(rt.log, msgMeta), connections: map[string]*mxConn{}, policies: policies, }, nil } -func (rd *remoteDelivery) AddRcpt(ctx context.Context, to string) error { +func (rd *remoteDelivery) AddRcpt(ctx context.Context, to string, opts smtp.RcptOptions) error { defer trace.StartRegion(ctx, "remote/AddRcpt").End() if rd.msgMeta.Quarantine { @@ -311,9 +319,10 @@ func (rd *remoteDelivery) AddRcpt(ctx context.Context, to string) error { return err } - if err := conn.Rcpt(ctx, to); err != nil { + if err := conn.Rcpt(ctx, to, opts); err != nil { return moduleError(err) } + conn.lastUseAt = time.Now() rd.recipients = append(rd.recipients, to) return nil @@ -404,8 +413,6 @@ func (rd *remoteDelivery) BodyNonAtomic(ctx context.Context, c module.StatusColl var wg sync.WaitGroup for i, conn := range rd.connections { - i := i - conn := conn wg.Add(1) go func() { defer wg.Done() @@ -417,13 +424,18 @@ func (rd *remoteDelivery) BodyNonAtomic(ctx context.Context, c module.StatusColl } return } - defer bodyR.Close() + defer func() { + if err := bodyR.Close(); err != nil { + rd.log.Error("failed to close message buffer", err) + } + }() err = conn.Data(ctx, header, bodyR) for _, rcpt := range conn.Rcpts() { c.SetStatus(rcpt, err) } rd.connections[i].errored = err != nil + conn.lastUseAt = time.Now() }() } @@ -443,14 +455,15 @@ func (rd *remoteDelivery) Commit(ctx context.Context) error { func (rd *remoteDelivery) Close() error { for _, conn := range rd.connections { rd.rt.limits.ReleaseDest(conn.domain) + conn.takeDest = false conn.transactions++ - if conn.C == nil || conn.transactions> rd.rt.connReuseLimit || conn.C.Client() == nil || conn.errored { - rd.Log.Debugf("disconnected from %s (errored=%v,transactions=%v,disconnected before=%v)", - conn.ServerName(), conn.errored, conn.transactions, conn.C.Client() == nil) - conn.Close() + if !conn.Usable() { + rd.log.Debugf("disconnected %v from %s (errored=%v,transactions=%v,disconnected before=%v)", + conn.LocalAddr(), conn.ServerName(), conn.errored, conn.transactions, conn.Client() == nil) + rd.closeConn(conn) } else { - rd.Log.Debugf("returning connection for %s to pool", conn.ServerName()) + rd.log.Debugf("returning connection %v for %s to pool", conn.LocalAddr(), conn.ServerName()) rd.rt.pool.Return(conn.domain, conn) } } @@ -479,5 +492,5 @@ func (rd *remoteDelivery) Close() error { } func init() { - module.Register("target.remote", New) + modules.Register("target.remote", New) } diff --git a/internal/target/remote/remote_test.go b/internal/target/remote/remote_test.go index 833c6ff68..06f0cb804 100644 --- a/internal/target/remote/remote_test.go +++ b/internal/target/remote/remote_test.go @@ -27,7 +27,6 @@ import ( "os" "strconv" "testing" - "time" "github.com/emersion/go-message/textproto" "github.com/emersion/go-smtp" @@ -35,12 +34,15 @@ import ( "github.com/foxcpp/go-mtasts" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/limits" "github.com/foxcpp/maddy/internal/smtpconn/pool" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // .invalid TLD is used here to make sure if there is something wrong about @@ -58,14 +60,14 @@ func testTarget(t *testing.T, zones map[string]mockdns.Zone, extResolver *dns.Ex dialer: resolver.DialContext, extResolver: extResolver, tlsConfig: &tls.Config{}, - Log: testutils.Logger(t, "remote"), + log: testutils.Logger(t, "remote"), policies: extraPolicies, - limits: &limits.Group{}, + limits: limits.Empty(testutils.Logger(t, "limits")), pool: pool.New(pool.Config{ - MaxKeys: 20000, - MaxConnsPerKey: 10, // basically, max. amount of idle connections in cache + MaxKeys: 5000, + MaxConnsPerKey: 5, // basically, max. amount of idle connections in cache MaxConnLifetimeSec: 150, // 2.5 mins, half of recommended idle time from RFC 5321 - StaleKeyLifetimeSec: 60 * 5, // should be bigger than MaxConnLifetimeSec + StaleKeyLifetimeSec: 60 * 4, // make sure that cleanup runs before recommended idle time from RFC 5321 }), } @@ -73,12 +75,12 @@ func testTarget(t *testing.T, zones map[string]mockdns.Zone, extResolver *dns.Ex } func testSTSPolicy(t *testing.T, zones map[string]mockdns.Zone, mtastsGet func(context.Context, string) (*mtasts.Policy, error)) *mtastsPolicy { - m, err := NewMTASTSPolicy("mx_auth.mtasts", "test", nil, nil) + m, err := NewMTASTSPolicy(container.New(), "mx_auth.mtasts", "test") if err != nil { t.Fatal(err) } p := m.(*mtastsPolicy) - err = p.Init(config.NewMap(nil, config.Node{ + err = p.Configure(nil, config.NewMap(nil, config.Node{ Children: []config.Node{ { Name: "cache", @@ -99,12 +101,12 @@ func testSTSPolicy(t *testing.T, zones map[string]mockdns.Zone, mtastsGet func(c } func testDANEPolicy(t *testing.T, extR *dns.ExtResolver) *danePolicy { - m, err := NewDANEPolicy("mx_auth.dane", "test", nil, nil) + m, err := NewDANEPolicy(container.New(), "mx_auth.dane", "test") if err != nil { t.Fatal(err) } p := m.(*danePolicy) - err = p.Init(config.NewMap(nil, config.Node{ + err = p.Configure(nil, config.NewMap(nil, config.Node{ Children: nil, })) if err != nil { @@ -118,7 +120,9 @@ func testDANEPolicy(t *testing.T, extR *dns.ExtResolver) *danePolicy { func TestRemoteDelivery(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -130,7 +134,9 @@ func TestRemoteDelivery(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -138,7 +144,9 @@ func TestRemoteDelivery(t *testing.T) { func TestRemoteDelivery_NoMXFallback(t *testing.T) { tarpit := testutils.FailOnConn(t, "127.0.0.1:"+smtpPort) - defer tarpit.Close() + defer func() { + assert.NoError(t, tarpit.Close()) + }() zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -147,14 +155,16 @@ func TestRemoteDelivery_NoMXFallback(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err == nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err == nil { t.Fatal("Expected an error, got none") } @@ -165,7 +175,9 @@ func TestRemoteDelivery_NoMXFallback(t *testing.T) { func TestRemoteDelivery_EmptySender(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -177,7 +189,9 @@ func TestRemoteDelivery_EmptySender(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "", []string{"test@example.invalid"}) @@ -187,7 +201,9 @@ func TestRemoteDelivery_IPLiteral(t *testing.T) { t.Skip("Support disabled") be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ @@ -203,7 +219,9 @@ func TestRemoteDelivery_IPLiteral(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@[127.0.0.1]"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@[127.0.0.1]"}) @@ -211,7 +229,9 @@ func TestRemoteDelivery_IPLiteral(t *testing.T) { func TestRemoteDelivery_FallbackMX(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -220,7 +240,9 @@ func TestRemoteDelivery_FallbackMX(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -228,7 +250,9 @@ func TestRemoteDelivery_FallbackMX(t *testing.T) { func TestRemoteDelivery_BodyNonAtomic(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -240,7 +264,9 @@ func TestRemoteDelivery_BodyNonAtomic(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() c := multipleErrs{ errs: map[string]error{}, @@ -256,7 +282,9 @@ func TestRemoteDelivery_BodyNonAtomic(t *testing.T) { func TestRemoteDelivery_Abort(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -268,14 +296,16 @@ func TestRemoteDelivery_Abort(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -286,7 +316,9 @@ func TestRemoteDelivery_Abort(t *testing.T) { func TestRemoteDelivery_CommitWithoutBody(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -298,14 +330,16 @@ func TestRemoteDelivery_CommitWithoutBody(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -317,7 +351,9 @@ func TestRemoteDelivery_CommitWithoutBody(t *testing.T) { func TestRemoteDelivery_MAILFROMErr(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -335,14 +371,16 @@ func TestRemoteDelivery_MAILFROMErr(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) testutils.CheckSMTPErr(t, err, 550, exterrors.EnhancedCode{5, 1, 2}, "mx.example.invalid. said: Hey") if err := delivery.Abort(context.Background()); err != nil { @@ -352,7 +390,9 @@ func TestRemoteDelivery_MAILFROMErr(t *testing.T) { func TestRemoteDelivery_NoMX(t *testing.T) { tarpit := testutils.FailOnConn(t, "127.0.0.1:"+smtpPort) - defer tarpit.Close() + defer func() { + require.NoError(t, tarpit.Close()) + }() zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -361,14 +401,16 @@ func TestRemoteDelivery_NoMX(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err == nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err == nil { t.Fatal("Expected an error, got none") } @@ -382,7 +424,9 @@ func TestRemoteDelivery_NullMX(t *testing.T) { // deliver the message. Use of testutils.SMTPServer here // causes weird race conditions. tarpit := testutils.FailOnConn(t, "127.0.0.1:"+smtpPort) - defer tarpit.Close() + defer func() { + require.NoError(t, tarpit.Close()) + }() zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -391,14 +435,16 @@ func TestRemoteDelivery_NullMX(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) testutils.CheckSMTPErr(t, err, 556, exterrors.EnhancedCode{5, 1, 10}, "Domain does not accept email (null MX)") if err := delivery.Abort(context.Background()); err != nil { @@ -408,7 +454,9 @@ func TestRemoteDelivery_NullMX(t *testing.T) { func TestRemoteDelivery_Quarantined(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -420,16 +468,18 @@ func TestRemoteDelivery_Quarantined(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() meta := module.MsgMetadata{ID: "test..."} - delivery, err := tgt.Start(context.Background(), &meta, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &meta, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -450,7 +500,9 @@ func TestRemoteDelivery_Quarantined(t *testing.T) { func TestRemoteDelivery_MAILFROMErr_Repeated(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -468,17 +520,19 @@ func TestRemoteDelivery_MAILFROMErr_Repeated(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) testutils.CheckSMTPErr(t, err, 550, exterrors.EnhancedCode{5, 1, 2}, "mx.example.invalid. said: Hey") - err = delivery.AddRcpt(context.Background(), "test2@example.invalid") + err = delivery.AddRcpt(context.Background(), "test2@example.invalid", smtp.RcptOptions{}) testutils.CheckSMTPErr(t, err, 550, exterrors.EnhancedCode{5, 1, 2}, "mx.example.invalid. said: Hey") if err := delivery.Abort(context.Background()); err != nil { @@ -488,7 +542,9 @@ func TestRemoteDelivery_MAILFROMErr_Repeated(t *testing.T) { func TestRemoteDelivery_RcptErr(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -508,19 +564,21 @@ func TestRemoteDelivery_RcptErr(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) testutils.CheckSMTPErr(t, err, 550, exterrors.EnhancedCode{5, 1, 2}, "mx.example.invalid. said: Hey") // It should be possible to, however, add another recipient and continue // delivery as if nothing happened. - if err := delivery.AddRcpt(context.Background(), "test2@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test2@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -541,7 +599,9 @@ func TestRemoteDelivery_RcptErr(t *testing.T) { func TestRemoteDelivery_DownMX(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -559,7 +619,9 @@ func TestRemoteDelivery_DownMX(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -582,7 +644,9 @@ func TestRemoteDelivery_AllMXDown(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -592,10 +656,14 @@ func TestRemoteDelivery_AllMXDown(t *testing.T) { func TestRemoteDelivery_Split(t *testing.T) { be1, srv1 := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + assert.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) be2, srv2 := testutils.SMTPServer(t, "127.0.0.2:"+smtpPort) - defer srv2.Close() + defer func() { + assert.NoError(t, srv2.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv2) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -613,7 +681,9 @@ func TestRemoteDelivery_Split(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid", "test@example2.invalid"}) @@ -623,10 +693,14 @@ func TestRemoteDelivery_Split(t *testing.T) { func TestRemoteDelivery_Split_Fail(t *testing.T) { be1, srv1 := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + require.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) be2, srv2 := testutils.SMTPServer(t, "127.0.0.2:"+smtpPort) - defer srv2.Close() + defer func() { + require.NoError(t, srv2.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv2) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -652,21 +726,23 @@ func TestRemoteDelivery_Split_Fail(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) if err == nil { t.Fatal("Expected an error, got none") } // It should be possible to, however, add another recipient and continue // delivery as if nothing happened. - if err := delivery.AddRcpt(context.Background(), "test@example2.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example2.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -687,7 +763,9 @@ func TestRemoteDelivery_Split_Fail(t *testing.T) { func TestRemoteDelivery_BodyErr(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -705,14 +783,16 @@ func TestRemoteDelivery_BodyErr(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - err = delivery.AddRcpt(context.Background(), "test@example.invalid") + err = delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}) if err != nil { t.Fatal(err) } @@ -732,10 +812,14 @@ func TestRemoteDelivery_BodyErr(t *testing.T) { func TestRemoteDelivery_Split_BodyErr(t *testing.T) { be1, srv1 := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + require.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) _, srv2 := testutils.SMTPServer(t, "127.0.0.2:"+smtpPort) - defer srv2.Close() + defer func() { + require.NoError(t, srv2.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv2) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -759,17 +843,19 @@ func TestRemoteDelivery_Split_BodyErr(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example2.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example2.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -788,10 +874,14 @@ func TestRemoteDelivery_Split_BodyErr(t *testing.T) { func TestRemoteDelivery_Split_BodyErr_NonAtomic(t *testing.T) { be1, srv1 := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv1.Close() + defer func() { + require.NoError(t, srv1.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv1) _, srv2 := testutils.SMTPServer(t, "127.0.0.2:"+smtpPort) - defer srv2.Close() + defer func() { + require.NoError(t, srv2.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv2) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -815,20 +905,22 @@ func TestRemoteDelivery_Split_BodyErr_NonAtomic(t *testing.T) { } tgt := testTarget(t, zones, nil, nil) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() - delivery, err := tgt.Start(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") + delivery, err := tgt.StartDelivery(context.Background(), &module.MsgMetadata{ID: "test..."}, "test@example.com") if err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test2@example.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test2@example.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } - if err := delivery.AddRcpt(context.Background(), "test@example2.invalid"); err != nil { + if err := delivery.AddRcpt(context.Background(), "test@example2.invalid", smtp.RcptOptions{}); err != nil { t.Fatal(err) } @@ -856,7 +948,9 @@ func TestRemoteDelivery_Split_BodyErr_NonAtomic(t *testing.T) { func TestRemoteDelivery_TLSErrFallback(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -875,7 +969,9 @@ func TestRemoteDelivery_TLSErrFallback(t *testing.T) { tgt := testTarget(t, zones, nil, nil) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -883,7 +979,9 @@ func TestRemoteDelivery_TLSErrFallback(t *testing.T) { func TestRemoteDelivery_RequireTLS_Missing(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -897,7 +995,9 @@ func TestRemoteDelivery_RequireTLS_Missing(t *testing.T) { tgt := testTarget(t, zones, nil, []module.MXAuthPolicy{ &localPolicy{minTLSLevel: module.TLSEncrypted}, }) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -907,7 +1007,9 @@ func TestRemoteDelivery_RequireTLS_Missing(t *testing.T) { func TestRemoteDelivery_RequireTLS_Present(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -922,7 +1024,9 @@ func TestRemoteDelivery_RequireTLS_Present(t *testing.T) { &localPolicy{minTLSLevel: module.TLSEncrypted}, }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -930,7 +1034,9 @@ func TestRemoteDelivery_RequireTLS_Present(t *testing.T) { func TestRemoteDelivery_RequireTLS_NoErrFallback(t *testing.T) { clientCfg, _, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -951,7 +1057,9 @@ func TestRemoteDelivery_RequireTLS_NoErrFallback(t *testing.T) { &localPolicy{minTLSLevel: module.TLSEncrypted}, }) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() _, err := testutils.DoTestDeliveryErr(t, tgt, "test@example.com", []string{"test@example.invalid"}) if err == nil { @@ -961,7 +1069,9 @@ func TestRemoteDelivery_RequireTLS_NoErrFallback(t *testing.T) { func TestRemoteDelivery_TLS_FallbackNoVerify(t *testing.T) { _, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -976,20 +1086,25 @@ func TestRemoteDelivery_TLS_FallbackNoVerify(t *testing.T) { tgt := testTarget(t, zones, nil, []module.MXAuthPolicy{ &localPolicy{minTLSLevel: module.TLSEncrypted}, }) - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) // But it should still be delivered over TLS. - if !be.Messages[0].State.TLS.HandshakeComplete { + tlsState, ok := be.Messages[0].Conn.TLSConnectionState() + if !ok || !tlsState.HandshakeComplete { t.Fatal("Message was not delivered over TLS") } } func TestRemoteDelivery_TLS_FallbackPlaintext(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -1008,7 +1123,9 @@ func TestRemoteDelivery_TLS_FallbackPlaintext(t *testing.T) { tgt := testTarget(t, zones, nil, nil) tgt.tlsConfig = clientCfg - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) @@ -1019,7 +1136,6 @@ func TestMain(m *testing.M) { flag.Parse() if *remoteSmtpPort == "random" { - rand.Seed(time.Now().UnixNano()) *remoteSmtpPort = strconv.Itoa(rand.Intn(65536-10000) + 10000) } @@ -1029,7 +1145,9 @@ func TestMain(m *testing.M) { func TestRemoteDelivery_ConnReuse(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+smtpPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) zones := map[string]mockdns.Zone{ "example.invalid.": { @@ -1042,7 +1160,9 @@ func TestRemoteDelivery_ConnReuse(t *testing.T) { tgt := testTarget(t, zones, nil, nil) tgt.connReuseLimit = 5 - defer tgt.Close() + defer func() { + assert.NoError(t, tgt.Stop()) + }() testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) diff --git a/internal/target/remote/security.go b/internal/target/remote/security.go index 08a837774..c40c5fc11 100644 --- a/internal/target/remote/security.go +++ b/internal/target/remote/security.go @@ -28,11 +28,13 @@ import ( "github.com/foxcpp/go-mtasts" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/dns" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/future" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/target" ) @@ -41,21 +43,21 @@ type ( cache *mtasts.Cache mtastsGet func(context.Context, string) (*mtasts.Policy, error) updaterStop chan struct{} - log log.Logger + log *log.Logger instName string } mtastsDelivery struct { c *mtastsPolicy domain string policyFut *future.Future - log log.Logger + log *log.Logger } ) -func NewMTASTSPolicy(_, instName string, _, _ []string) (module.Module, error) { +func NewMTASTSPolicy(c *container.C, modName, instName string) (module.Module, error) { return &mtastsPolicy{ instName: instName, - log: log.Logger{Name: "mx_auth.mtasts", Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -71,7 +73,7 @@ func (c *mtastsPolicy) Weight() int { return 10 } -func (c *mtastsPolicy) Init(cfg *config.Map) error { +func (c *mtastsPolicy) Configure(inlineArgs []string, cfg *config.Map) error { var ( storeType string storeDir string @@ -99,6 +101,11 @@ func (c *mtastsPolicy) Init(cfg *config.Map) error { return nil } +func (c *mtastsPolicy) Start() error { + c.StartUpdater() + return nil +} + // StartUpdater starts a goroutine to update MTA-STS cache periodically until // Close is called. // @@ -108,6 +115,15 @@ func (c *mtastsPolicy) StartUpdater() { go c.updater() } +func (c *mtastsPolicy) Stop() error { + if c.updaterStop != nil { + c.updaterStop <- struct{}{} + <-c.updaterstop + c.updaterStop = nil + } + return nil +} + func (c *mtastsPolicy) updater() { defer func() { if err := recover(); err != nil { @@ -142,22 +158,13 @@ func (c *mtastsPolicy) updater() { } } -func (c *mtastsPolicy) Start(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy { +func (c *mtastsPolicy) StartDelivery(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy { return &mtastsDelivery{ c: c, log: target.DeliveryLogger(c.log, msgMeta), } } -func (c *mtastsPolicy) Close() error { - if c.updaterStop != nil { - c.updaterStop <- struct{}{} - <-c.updaterstop - c.updaterStop = nil - } - return nil -} - func (c *mtastsDelivery) PrepareDomain(ctx context.Context, domain string) { c.policyFut = future.New() go func() { @@ -180,7 +187,7 @@ func (c *mtastsDelivery) CheckMX(ctx context.Context, mxLevel module.MXLevel, do return module.MXNone, &exterrors.SMTPError{ Code: 550, EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "Failed to estabilish the module.MX record authenticity (MTA-STS)", + Message: "Failed to establish the MX record authenticity (MTA-STS)", } } c.log.Msg("MX does not match published non-enforced MTA-STS policy", "mx", mx, "domain", c.domain) @@ -213,7 +220,7 @@ func (c *mtastsDelivery) CheckConn(ctx context.Context, mxLevel module.MXLevel, return module.TLSNone, &exterrors.SMTPError{ Code: 451, EnhancedCode: exterrors.EnhancedCode{4, 7, 1}, - Message: "Recipient server module.TLS certificate is not trusted but " + + Message: "Recipient server TLS certificate is not trusted but " + "authentication is required by MTA-STS", Misc: map[string]interface{}{ "tls_level": tlsLevel, @@ -233,14 +240,14 @@ func (c *mtastsDelivery) Reset(msgMeta *module.MsgMetadata) { // Stub that will be removed in 0.5. type stsPreloadPolicy struct { - log log.Logger + log *log.Logger instName string } -func NewSTSPreload(_, instName string, _, _ []string) (module.Module, error) { +func NewSTSPreload(c *container.C, modName, instName string) (module.Module, error) { return &stsPreloadPolicy{ instName: instName, - log: log.Logger{Name: "mx_auth.sts_preload", Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -256,7 +263,7 @@ func (c *stsPreloadPolicy) Weight() int { return 30 // after MTA-STS } -func (c *stsPreloadPolicy) Init(cfg *config.Map) error { +func (c *stsPreloadPolicy) Configure(inlineArgs []string, cfg *config.Map) error { c.log.Println("sts_preload module is deprecated and is no-op as the list is expired and unmaintained") var ( @@ -276,7 +283,7 @@ type preloadDelivery struct { *stsPreloadPolicy } -func (p *stsPreloadPolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy { +func (p *stsPreloadPolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy { return &preloadDelivery{stsPreloadPolicy: p} } @@ -291,45 +298,37 @@ func (p *preloadDelivery) CheckConn(ctx context.Context, mxLevel module.MXLevel, return tlsLevel, nil } -func (p *stsPreloadPolicy) Close() error { - return nil -} - type dnssecPolicy struct { instName string } -func NewDNSSECPolicy(_, instName string, _, _ []string) (module.Module, error) { +func NewDNSSECPolicy(_ *container.C, _, instName string) (module.Module, error) { return &dnssecPolicy{ instName: instName, }, nil } -func (c *dnssecPolicy) Name() string { +func (dnssecPolicy) Name() string { return "mx_auth.dnssec" } -func (c *dnssecPolicy) InstanceName() string { +func (c dnssecPolicy) InstanceName() string { return c.instName } -func (c *dnssecPolicy) Weight() int { +func (dnssecPolicy) Weight() int { return 1 } -func (c *dnssecPolicy) Init(cfg *config.Map) error { +func (dnssecPolicy) Configure(inlineArgs []string, cfg *config.Map) error { _, err := cfg.Process() // will fail if there is any directive return err } -func (dnssecPolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy { +func (dnssecPolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy { return dnssecPolicy{} } -func (dnssecPolicy) Close() error { - return nil -} - func (dnssecPolicy) Reset(*module.MsgMetadata) {} func (dnssecPolicy) PrepareDomain(ctx context.Context, domain string) {} func (dnssecPolicy) PrepareConn(ctx context.Context, mx string) {} @@ -348,7 +347,7 @@ func (dnssecPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsLe type ( danePolicy struct { extResolver *dns.ExtResolver - log log.Logger + log *log.Logger instName string } daneDelivery struct { @@ -357,10 +356,10 @@ type ( } ) -func NewDANEPolicy(_, instName string, _, _ []string) (module.Module, error) { +func NewDANEPolicy(c *container.C, modName, instName string) (module.Module, error) { return &danePolicy{ instName: instName, - log: log.Logger{Name: "remote/dane", Debug: log.DefaultLogger.Debug}, + log: c.DefaultLogger.Sublogger(modName), }, nil } @@ -376,7 +375,7 @@ func (c *danePolicy) Weight() int { return 10 } -func (c *danePolicy) Init(cfg *config.Map) error { +func (c *danePolicy) Configure(inlineArgs []string, cfg *config.Map) error { var err error c.extResolver, err = dns.NewExtResolver() if err != nil { @@ -389,14 +388,10 @@ func (c *danePolicy) Init(cfg *config.Map) error { return err } -func (c *danePolicy) Start(*module.MsgMetadata) module.DeliveryMXAuthPolicy { +func (c *danePolicy) StartDelivery(*module.MsgMetadata) module.DeliveryMXAuthPolicy { return &daneDelivery{c: c} } -func (c *danePolicy) Close() error { - return nil -} - func (c *daneDelivery) PrepareDomain(ctx context.Context, domain string) {} func (c *daneDelivery) discoverTLSA(ctx context.Context, mx string) ([]dns.TLSA, error) { @@ -536,7 +531,7 @@ type ( } ) -func NewLocalPolicy(_, instName string, _, _ []string) (module.Module, error) { +func NewLocalPolicy(_ *container.C, _, instName string) (module.Module, error) { return &localPolicy{ instName: instName, }, nil @@ -554,7 +549,7 @@ func (c *localPolicy) Weight() int { return 1000 } -func (c *localPolicy) Init(cfg *config.Map) error { +func (c *localPolicy) Configure(inlineArgs []string, cfg *config.Map) error { var ( minTLSLevel string minMXLevel string @@ -589,42 +584,40 @@ func (c *localPolicy) Init(cfg *config.Map) error { return nil } -func (l localPolicy) Start(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy { +func (l *localPolicy) StartDelivery(msgMeta *module.MsgMetadata) module.DeliveryMXAuthPolicy { return l } -func (l localPolicy) Close() error { - return nil -} +func (l *localPolicy) Reset(*module.MsgMetadata) {} +func (l *localPolicy) PrepareDomain(ctx context.Context, domain string) {} +func (l *localPolicy) PrepareConn(ctx context.Context, mx string) {} -func (l localPolicy) Reset(*module.MsgMetadata) {} -func (l localPolicy) PrepareDomain(ctx context.Context, domain string) {} -func (l localPolicy) PrepareConn(ctx context.Context, mx string) {} - -func (l localPolicy) CheckMX(ctx context.Context, mxLevel module.MXLevel, domain, mx string, dnssec bool) (module.MXLevel, error) { +func (l *localPolicy) CheckMX(ctx context.Context, mxLevel module.MXLevel, domain, mx string, dnssec bool) (module.MXLevel, error) { if mxLevel < l.minMXLevel { return module.MXNone, &exterrors.SMTPError{ // Err on the side of caution if policy evaluation was messed up by // a temporary error (we can't know with the current design). Code: 451, EnhancedCode: exterrors.EnhancedCode{4, 7, 0}, - Message: "Failed to estabilish the module.MX record authenticity", + Message: "Failed to establish the MX record authenticity", Misc: map[string]interface{}{ - "mx_level": mxLevel, + "mx_level": mxLevel, + "required_mx_level": l.minMXLevel, }, } } return module.MXNone, nil } -func (l localPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsLevel module.TLSLevel, domain, mx string, tlsState tls.ConnectionState) (module.TLSLevel, error) { +func (l *localPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsLevel module.TLSLevel, domain, mx string, tlsState tls.ConnectionState) (module.TLSLevel, error) { if tlsLevel < l.minTLSLevel { return module.TLSNone, &exterrors.SMTPError{ Code: 451, EnhancedCode: exterrors.EnhancedCode{4, 7, 1}, Message: "TLS it not available or unauthenticated but required", Misc: map[string]interface{}{ - "tls_level": tlsLevel, + "tls_level": tlsLevel, + "required_tls_level": l.minTLSLevel, }, } } @@ -632,9 +625,9 @@ func (l localPolicy) CheckConn(ctx context.Context, mxLevel module.MXLevel, tlsL } func init() { - module.Register("mx_auth.mtasts", NewMTASTSPolicy) - module.Register("mx_auth.sts_preload", NewSTSPreload) - module.Register("mx_auth.dnssec", NewDNSSECPolicy) - module.Register("mx_auth.dane", NewDANEPolicy) - module.Register("mx_auth.local_policy", NewLocalPolicy) + modules.Register("mx_auth.mtasts", NewMTASTSPolicy) + modules.Register("mx_auth.sts_preload", NewSTSPreload) + modules.Register("mx_auth.dnssec", NewDNSSECPolicy) + modules.Register("mx_auth.dane", NewDANEPolicy) + modules.Register("mx_auth.local_policy", NewLocalPolicy) } diff --git a/internal/target/skeleton.go b/internal/target/skeleton.go new file mode 100644 index 000000000..488ce8137 --- /dev/null +++ b/internal/target/skeleton.go @@ -0,0 +1,131 @@ +//go:build ignore +// +build ignore + +// Copy that file into target/ subdirectory. + +package target_name + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2021 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import ( + "context" + + "github.com/emersion/go-message/textproto" + "github.com/foxcpp/maddy/framework/buffer" + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" +) + +const modName = "target.target_name" + +type Target struct { + instName string + log log.Logger +} + +func New(_, instName string, _, inlineArgs []string) (module.Module, error) { + // If wanted, extract any values from inlineArgs (these values: + // deliver_to target_name ARG1 ARG2 { ... } + + return &Target{ + instName: instName, + log: log.Logger{Name: instName}, + }, nil +} + +func (t *Target) Init(cfg *config.Map) error { + cfg.Bool("debug", true, false, &t.log.Debug) + + // Read any config directives into Target variables here. + + if _, err := cfg.Process(); err != nil { + return err + } + + // Finish setup using obtained values. + + return nil +} + +func (t *Target) Name() string { + return modName +} + +func (t *Target) InstanceName() string { + return t.instName +} + +// If it necessary to have any server shutdown cleanup - implement Close. + +func (t *Target) Close() error { + return nil +} + +type delivery struct { + t *Target + mailFrom string + log log.Logger + msgMeta *module.MsgMetadata +} + +/* +See module.DeliveryTarget and module.Delivery docs for details on each method. +*/ + +func (t *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { + return &delivery{ + t: t, + mailFrom: mailFrom, + log: DeliveryLogger(t.log, msgMeta), + msgMeta: msgMeta, + }, nil +} + +func (d *delivery) AddRcpt(ctx context.Context, rcptTo string) error { + // Corresponds to SMTP RCPT command. + panic("implement me") +} + +func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffer.Buffer) error { + // Corresponds to SMTP DATA command. + panic("implement me") +} + +/* +If Body call can fail partially (either success or fail for each recipient passed to AddRcpt) +- implement BodyNonAtomic and signal status for each recipient using StatusCollector callback. + +func (d *delivery) BodyNonAtomic(ctx context.Context, sc module.StatusCollector, header textproto.Header, body buffer.Buffer) { + +} +*/ + +func (d *delivery) Abort(ctx context.Context) error { + panic("implement me") +} + +func (d *delivery) Commit(ctx context.Context) error { + panic("implement me") +} + +func init() { + modules.Register(modName, New) +} diff --git a/internal/target/smtp/sasl.go b/internal/target/smtp/sasl.go index 75f5d4e57..968617213 100644 --- a/internal/target/smtp/sasl.go +++ b/internal/target/smtp/sasl.go @@ -57,12 +57,18 @@ func saslAuthDirective(_ *config.Map, node config.Node) (interface{}, error) { } return sasl.NewPlainClient("", msgMeta.Conn.AuthUser, msgMeta.Conn.AuthPassword), nil }, nil - case "plain": + case "plain", "login": if len(node.Args) != 3 { return nil, config.NodeErr(node, "two additional arguments are required (username, password)") } return func(*module.MsgMetadata) (sasl.Client, error) { - return sasl.NewPlainClient("", node.Args[1], node.Args[2]), nil + if node.Args[0] == "plain" { + return sasl.NewPlainClient("", node.Args[1], node.Args[2]), nil + } + if node.Args[0] == "login" { + return sasl.NewLoginClient(node.Args[1], node.Args[2]), nil + } + return nil, config.NodeErr(node, "unknown authentication mechanism: %s", node.Args[0]) }, nil case "external": if len(node.Args)> 1 { diff --git a/internal/target/smtp/sasl_test.go b/internal/target/smtp/sasl_test.go index 63b52a5ec..c28ec7e44 100644 --- a/internal/target/smtp/sasl_test.go +++ b/internal/target/smtp/sasl_test.go @@ -25,6 +25,7 @@ import ( "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) func testSaslFactory(t *testing.T, args ...string) saslClientFactory { @@ -40,7 +41,9 @@ func testSaslFactory(t *testing.T, args ...string) saslClientFactory { func TestSASL_Plain(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -68,7 +71,9 @@ func TestSASL_Plain(t *testing.T) { func TestSASL_Plain_AuthFail(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) be.AuthErr = &smtp.SMTPError{ @@ -96,9 +101,28 @@ func TestSASL_Plain_AuthFail(t *testing.T) { } } +func TestSASL_Login_Directive(t *testing.T) { + factory := testSaslFactory(t, "login", "test", "testpass") + client, err := factory(nil) + if err != nil { + t.Fatal(err) + } + + mech, _, err := client.Start() + if err != nil { + t.Fatal(err) + } + + if mech != "LOGIN" { + t.Fatalf("expected LOGIN mechanism, got %q", mech) + } +} + func TestSASL_Forward(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -131,7 +155,9 @@ func TestSASL_Forward(t *testing.T) { func TestSASL_Forward_NoCreds(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ diff --git a/internal/target/smtp/smtp_downstream.go b/internal/target/smtp/smtp_downstream.go index 1e631c196..5fbbd7513 100644 --- a/internal/target/smtp/smtp_downstream.go +++ b/internal/target/smtp/smtp_downstream.go @@ -29,7 +29,6 @@ package smtp_downstream import ( "context" "crypto/tls" - "errors" "fmt" "net" "runtime/trace" @@ -40,32 +39,32 @@ import ( "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" tls2 "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/internal/smtpconn" "github.com/foxcpp/maddy/internal/target" "golang.org/x/net/idna" ) type Downstream struct { - modName string - instName string - lmtp bool - targetsArg []string - - requireTLS bool - attemptStartTLS bool - hostname string - endpoints []config.Endpoint - saslFactory saslClientFactory - tlsConfig tls.Config + modName string + instName string + lmtp bool + + starttls bool + hostname string + endpoints []config.Endpoint + saslFactory saslClientFactory + tlsConfig *tls.Config connectTimeout time.Duration commandTimeout time.Duration submissionTimeout time.Duration - log log.Logger + log *log.Logger } func (u *Downstream) moduleError(err error) error { @@ -78,28 +77,51 @@ func (u *Downstream) moduleError(err error) error { }) } -func NewDownstream(modName, instName string, _, inlineArgs []string) (module.Module, error) { +func New(c *container.C, modName, instName string) (module.Module, error) { return &Downstream{ - modName: modName, - instName: instName, - lmtp: modName == "target.lmtp" || modName == "lmtp_downstream", /* compatibility with 0.3 configs */ - targetsArg: inlineArgs, - log: log.Logger{Name: modName}, + modName: modName, + instName: instName, + lmtp: modName == "target.lmtp", + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (u *Downstream) Init(cfg *config.Map) error { - var targetsArg []string +func (u *Downstream) Configure(inlineArgs []string, cfg *config.Map) error { + var attemptTLS *bool + + targetsArg := make([]string, 0, len(inlineArgs)) cfg.Bool("debug", true, false, &u.log.Debug) - cfg.Bool("require_tls", false, false, &u.requireTLS) - cfg.Bool("attempt_starttls", false, !u.lmtp, &u.attemptStartTLS) + cfg.Callback("require_tls", func(m *config.Map, node config.Node) error { + u.log.Msg("require_tls directive is deprecated and ignored") + return nil + }) + cfg.Callback("attempt_starttls", func(m *config.Map, node config.Node) error { + u.log.Msg("attempt_starttls directive is deprecated and equivalent to starttls") + + if len(node.Args) == 0 { + trueVal := true + attemptTLS = &trueVal + return nil + } + if len(node.Args) != 1 { + return config.NodeErr(node, "expected exactly 1 argument") + } + + b, err := config.ParseBool(node.Args[0]) + if err != nil { + return err + } + attemptTLS = &b + return nil + }) + cfg.Bool("starttls", false, !u.lmtp, &u.starttls) cfg.String("hostname", true, true, "", &u.hostname) cfg.StringList("targets", false, false, nil, &targetsArg) cfg.Custom("auth", false, false, func() (interface{}, error) { return nil, nil }, saslAuthDirective, &u.saslFactory) cfg.Custom("tls_client", true, false, func() (interface{}, error) { - return tls.Config{}, nil + return &tls.Config{}, nil }, tls2.TLSClientBlock, &u.tlsConfig) cfg.Duration("connect_timeout", false, false, 5*time.Minute, &u.connectTimeout) cfg.Duration("command_timeout", false, false, 5*time.Minute, &u.commandTimeout) @@ -109,6 +131,10 @@ func (u *Downstream) Init(cfg *config.Map) error { return err } + if attemptTLS != nil { + u.starttls = *attemptTLS + } + // INTERNATIONALIZATION: See RFC 6531 Section 3.7.1. var err error u.hostname, err = idna.ToASCII(u.hostname) @@ -116,8 +142,8 @@ func (u *Downstream) Init(cfg *config.Map) error { return fmt.Errorf("%s: cannot represent the hostname as an A-label name: %w", u.modName, err) } - u.targetsArg = append(u.targetsArg, targetsArg...) - for _, tgt := range u.targetsArg { + targetsArg = append(targetsArg, inlineArgs...) + for _, tgt := range targetsArg { endp, err := config.ParseEndpoint(tgt) if err != nil { return err @@ -143,7 +169,7 @@ func (u *Downstream) InstanceName() string { type delivery struct { u *Downstream - log log.Logger + log *log.Logger msgMeta *module.MsgMetadata mailFrom string @@ -157,8 +183,8 @@ type lmtpDelivery struct { *delivery } -func (u *Downstream) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { - defer trace.StartRegion(ctx, "target.smtp/Start").End() +func (u *Downstream) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { + defer trace.StartRegion(ctx, "target.smtp/StartDelivery").End() d := &delivery{ u: u, @@ -171,7 +197,9 @@ func (u *Downstream) Start(ctx context.Context, msgMeta *module.MsgMetadata, mai } if err := d.conn.Mail(ctx, mailFrom, msgMeta.SMTPOpts); err != nil { - d.conn.Close() + if err := d.conn.Close(); err != nil { + u.log.Error("failed to close smtp connection", err) + } return nil, err } @@ -182,6 +210,12 @@ func (u *Downstream) Start(ctx context.Context, msgMeta *module.MsgMetadata, mai return d, nil } +func (d *delivery) closeConn(c *smtpconn.C) { + if err := c.Close(); err != nil { + d.log.Error("failed to close SMTP connection", err) + } +} + func (d *delivery) connect(ctx context.Context) error { // TODO: Review possibility of connection pooling here. var lastErr error @@ -201,14 +235,11 @@ func (d *delivery) connect(ctx context.Context) error { } for _, endp := range d.u.endpoints { - var ( - didTLS bool - err error - ) + var err error if d.u.lmtp { - didTLS, err = conn.ConnectLMTP(ctx, endp, d.u.attemptStartTLS, &d.u.tlsConfig) + _, err = conn.ConnectLMTP(ctx, endp, d.u.starttls, d.u.tlsConfig) } else { - didTLS, err = conn.Connect(ctx, endp, d.u.attemptStartTLS, &d.u.tlsConfig) + _, err = conn.Connect(ctx, endp, d.u.starttls, d.u.tlsConfig) } if err != nil { if len(d.u.endpoints) != 1 { @@ -220,12 +251,6 @@ func (d *delivery) connect(ctx context.Context) error { d.log.DebugMsg("connected", "downstream_server", conn.ServerName()) - if !didTLS && d.u.requireTLS { - conn.Close() - lastErr = errors.New("TLS is required, but unsupported by downstream") - continue - } - lastErr = nil break } @@ -236,12 +261,12 @@ func (d *delivery) connect(ctx context.Context) error { if d.u.saslFactory != nil { saslClient, err := d.u.saslFactory(d.msgMeta) if err != nil { - conn.Close() + d.closeConn(conn) return err } if err := conn.Client().Auth(saslClient); err != nil { - conn.Close() + d.closeConn(conn) return err } } @@ -251,8 +276,8 @@ func (d *delivery) connect(ctx context.Context) error { return nil } -func (d *delivery) AddRcpt(ctx context.Context, rcptTo string) error { - err := d.conn.Rcpt(ctx, rcptTo) +func (d *delivery) AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error { + err := d.conn.Rcpt(ctx, rcptTo, opts) if err != nil { return d.u.moduleError(err) } @@ -267,7 +292,11 @@ func (d *delivery) Body(ctx context.Context, header textproto.Header, body buffe return exterrors.WithFields(err, map[string]interface{}{"target": d.u.modName}) } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + d.log.Msg("failed to close body buffer", err) + } + }() return d.u.moduleError(d.conn.Data(ctx, header, r)) } @@ -279,7 +308,11 @@ func (d *lmtpDelivery) BodyNonAtomic(ctx context.Context, sc module.StatusCollec sc.SetStatus(rcpt, modErr) } } - defer r.Close() + defer func() { + if err := r.Close(); err != nil { + d.log.Msg("failed to close body buffer", err) + } + }() rcptIndx := 0 err = d.conn.LMTPData(ctx, header, r, func(rcpt string, err *smtp.SMTPError) { @@ -305,8 +338,7 @@ func (d *lmtpDelivery) BodyNonAtomic(ctx context.Context, sc module.StatusCollec } func (d *delivery) Abort(ctx context.Context) error { - d.conn.Close() - return nil + return d.conn.Close() } func (d *delivery) Commit(ctx context.Context) error { @@ -314,6 +346,6 @@ func (d *delivery) Commit(ctx context.Context) error { } func init() { - module.Register("target.smtp", NewDownstream) - module.Register("target.lmtp", NewDownstream) + modules.Register("target.smtp", New) + modules.Register("target.lmtp", New) } diff --git a/internal/target/smtp/smtp_downstream_test.go b/internal/target/smtp/smtp_downstream_test.go index 8a1afc17c..e292f5e39 100644 --- a/internal/target/smtp/smtp_downstream_test.go +++ b/internal/target/smtp/smtp_downstream_test.go @@ -25,23 +25,27 @@ import ( "os" "strconv" "testing" - "time" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/exterrors" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) var testPort string func TestDownstreamDelivery(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) tarpit := testutils.FailOnConn(t, "127.0.0.2:"+testPort) - defer tarpit.Close() + defer func() { + require.NoError(t, tarpit.Close()) + }() mod := &Downstream{ hostname: "mx.example.invalid", @@ -75,7 +79,9 @@ func TestDownstreamDelivery_LMTP(t *testing.T) { Message: "nop", }, } - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -126,7 +132,9 @@ func TestDownstreamDelivery_LMTP_ErrorCoerce(t *testing.T) { Message: "nop", }, } - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -157,7 +165,9 @@ func (sc *statusCollector) SetStatus(rcptTo string, err error) { func TestDownstreamDelivery_Fallback(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.2:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -183,7 +193,9 @@ func TestDownstreamDelivery_Fallback(t *testing.T) { func TestDownstreamDelivery_MAILErr(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) be.MailErr = &smtp.SMTPError{ @@ -208,9 +220,11 @@ func TestDownstreamDelivery_MAILErr(t *testing.T) { testutils.CheckSMTPErr(t, err, 550, exterrors.EnhancedCode{5, 1, 2}, "Hey") } -func TestDownstreamDelivery_AttemptTLS(t *testing.T) { +func TestDownstreamDelivery_StartTLS(t *testing.T) { clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -222,97 +236,25 @@ func TestDownstreamDelivery_AttemptTLS(t *testing.T) { Port: testPort, }, }, - tlsConfig: *clientCfg.Clone(), - attemptStartTLS: true, - log: testutils.Logger(t, "target.smtp"), + tlsConfig: clientCfg.Clone(), + starttls: true, + log: testutils.Logger(t, "target.smtp"), } testutils.DoTestDelivery(t, mod, "test@example.invalid", []string{"rcpt@example.invalid"}) be.CheckMsg(t, 0, "test@example.invalid", []string{"rcpt@example.invalid"}) - if !be.Messages[0].State.TLS.HandshakeComplete { - t.Error("Expected TLS to be used, but it was not") - } -} -func TestDownstreamDelivery_AttemptTLS_Fallback(t *testing.T) { - be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() - defer testutils.CheckSMTPConnLeak(t, srv) - - mod := &Downstream{ - hostname: "mx.example.invalid", - endpoints: []config.Endpoint{ - { - Scheme: "tcp", - Host: "127.0.0.1", - Port: testPort, - }, - }, - attemptStartTLS: true, - log: testutils.Logger(t, "target.smtp"), - } - - testutils.DoTestDelivery(t, mod, "test@example.invalid", []string{"rcpt@example.invalid"}) - be.CheckMsg(t, 0, "test@example.invalid", []string{"rcpt@example.invalid"}) -} - -func TestDownstreamDelivery_RequireTLS(t *testing.T) { - clientCfg, be, srv := testutils.SMTPServerSTARTTLS(t, "127.0.0.1:"+testPort) - defer srv.Close() - defer testutils.CheckSMTPConnLeak(t, srv) - - mod := &Downstream{ - hostname: "mx.example.invalid", - endpoints: []config.Endpoint{ - { - Scheme: "tcp", - Host: "127.0.0.1", - Port: testPort, - }, - }, - tlsConfig: *clientCfg.Clone(), - attemptStartTLS: true, - requireTLS: true, - log: testutils.Logger(t, "target.smtp"), - } - - testutils.DoTestDelivery(t, mod, "test@example.invalid", []string{"rcpt@example.invalid"}) - be.CheckMsg(t, 0, "test@example.invalid", []string{"rcpt@example.invalid"}) - if !be.Messages[0].State.TLS.HandshakeComplete { - t.Error("Expected TLS to be used, but it was not") - } -} - -func TestDownstreamDelivery_RequireTLS_Implicit(t *testing.T) { - clientCfg, be, srv := testutils.SMTPServerTLS(t, "127.0.0.1:"+testPort) - defer srv.Close() - defer testutils.CheckSMTPConnLeak(t, srv) - - mod := &Downstream{ - hostname: "mx.example.invalid", - endpoints: []config.Endpoint{ - { - Scheme: "tls", - Host: "127.0.0.1", - Port: testPort, - }, - }, - tlsConfig: *clientCfg.Clone(), - attemptStartTLS: true, - requireTLS: true, - log: testutils.Logger(t, "target.smtp"), - } - - testutils.DoTestDelivery(t, mod, "test@example.invalid", []string{"rcpt@example.invalid"}) - be.CheckMsg(t, 0, "test@example.invalid", []string{"rcpt@example.invalid"}) - if !be.Messages[0].State.TLS.HandshakeComplete { - t.Error("Expected TLS to be used, but it was not") + tlsState, ok := be.Messages[0].Conn.TLSConnectionState() + if !ok || !tlsState.HandshakeComplete { + t.Fatal("Message was not delivered over TLS") } } -func TestDownstreamDelivery_RequireTLS_Fail(t *testing.T) { +func TestDownstreamDelivery_StartTLS_NoFallback(t *testing.T) { _, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) mod := &Downstream{ @@ -324,9 +266,8 @@ func TestDownstreamDelivery_RequireTLS_Fail(t *testing.T) { Port: testPort, }, }, - attemptStartTLS: true, - requireTLS: true, - log: testutils.Logger(t, "target.smtp"), + starttls: true, + log: testutils.Logger(t, "target.smtp"), } _, err := testutils.DoTestDeliveryErr(t, mod, "test@example.invalid", []string{"rcpt@example.invalid"}) @@ -340,7 +281,6 @@ func TestMain(m *testing.M) { flag.Parse() if *remoteSmtpPort == "random" { - rand.Seed(time.Now().UnixNano()) *remoteSmtpPort = strconv.Itoa(rand.Intn(65536-10000) + 10000) } diff --git a/internal/target/smtp/smtputf8_test.go b/internal/target/smtp/smtputf8_test.go index e3d10587e..7c8962e08 100644 --- a/internal/target/smtp/smtputf8_test.go +++ b/internal/target/smtp/smtputf8_test.go @@ -22,24 +22,32 @@ import ( "testing" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/internal/testutils" + "github.com/stretchr/testify/require" ) func TestDownstreamDelivery_EHLO_ALabel(t *testing.T) { be, srv := testutils.SMTPServer(t, "127.0.0.1:"+testPort) - defer srv.Close() + defer func() { + require.NoError(t, srv.Close()) + }() defer testutils.CheckSMTPConnLeak(t, srv) - mod, err := NewDownstream("", "", nil, []string{"tcp://127.0.0.1:" + testPort}) + mod, err := New(container.New(), "", "") if err != nil { t.Fatal(err) } - if err := mod.Init(config.NewMap(nil, config.Node{ + if err := mod.Configure([]string{"tcp://127.0.0.1:" + testPort}, config.NewMap(nil, config.Node{ Children: []config.Node{ { Name: "hostname", Args: []string{"тест.invalid"}, }, + { + Name: "starttls", + Args: []string{"no"}, + }, }, })); err != nil { t.Fatal(err) @@ -51,7 +59,7 @@ func TestDownstreamDelivery_EHLO_ALabel(t *testing.T) { testutils.DoTestDelivery(t, tgt, "test@example.com", []string{"test@example.invalid"}) be.CheckMsg(t, 0, "test@example.com", []string{"test@example.invalid"}) - if be.Messages[0].State.Hostname != "xn--e1aybc.invalid" { + if be.Messages[0].Conn.Hostname() != "xn--e1aybc.invalid" { t.Error("target/remote should use use Punycode in EHLO") } } diff --git a/internal/testutils/bench_delivery.go b/internal/testutils/bench_delivery.go index 33d474312..85fb80f00 100644 --- a/internal/testutils/bench_delivery.go +++ b/internal/testutils/bench_delivery.go @@ -23,12 +23,13 @@ import ( "context" "crypto/sha1" "encoding/hex" - "io/ioutil" + "io" "strconv" "strings" "testing" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/module" ) @@ -100,7 +101,7 @@ func RandomMsg(b *testing.B) (module.MsgMetadata, textproto.Header, buffer.Buffe for i := 0; i < ExtraMessageHeaderFields; i++ { hdr.Add("AAAAAAAAAAAA-"+strconv.Itoa(i), strings.Repeat("A", ExtraMessageHeaderFieldSize)) } - bodyBlob, _ := ioutil.ReadAll(body) + bodyBlob, _ := io.ReadAll(body) return module.MsgMetadata{ DontTraceSender: true, @@ -116,15 +117,15 @@ func BenchDelivery(b *testing.B, target module.DeliveryTarget, sender string, re b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - delivery, err := target.Start(benchCtx, &meta, sender) + delivery, err := target.StartDelivery(benchCtx, &meta, sender) if err != nil { b.Fatal(err) } for i, rcptTemplate := range recipientTemplates { - rcpt := strings.Replace(rcptTemplate, "X", strconv.Itoa(i), -1) + rcpt := strings.ReplaceAll(rcptTemplate, "X", strconv.Itoa(i)) - if err := delivery.AddRcpt(benchCtx, rcpt); err != nil { + if err := delivery.AddRcpt(benchCtx, rcpt, smtp.RcptOptions{}); err != nil { b.Fatal(err) } } diff --git a/internal/testutils/buffer.go b/internal/testutils/buffer.go index 9e0bcd531..259eea2d9 100644 --- a/internal/testutils/buffer.go +++ b/internal/testutils/buffer.go @@ -22,7 +22,6 @@ import ( "bufio" "bytes" "io" - "io/ioutil" "strings" "testing" @@ -38,7 +37,7 @@ func BodyFromStr(t *testing.T, literal string) (textproto.Header, buffer.MemoryB if err != nil { t.Fatal(err) } - body, err := ioutil.ReadAll(bufr) + body, err := io.ReadAll(bufr) if err != nil { t.Fatal(err) } @@ -67,10 +66,10 @@ type FailingBuffer struct { } func (fb FailingBuffer) Open() (io.ReadCloser, error) { - r := ioutil.NopCloser(bytes.NewReader(fb.Blob)) + r := io.NopCloser(bytes.NewReader(fb.Blob)) if fb.IOError != nil { - return ioutil.NopCloser(&errorReader{r, fb.IOError}), fb.OpenError + return io.NopCloser(&errorReader{r, fb.IOError}), fb.OpenError } return r, fb.OpenError diff --git a/internal/testutils/check.go b/internal/testutils/check.go index 5e8401a9c..7c829cd5f 100644 --- a/internal/testutils/check.go +++ b/internal/testutils/check.go @@ -22,10 +22,11 @@ import ( "context" "github.com/emersion/go-message/textproto" - "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Check struct { @@ -55,7 +56,7 @@ func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadat return &checkState{msgMeta, c}, nil } -func (c *Check) Init(*config.Map) error { +func (c *Check) Configure([]string, *config.Map) error { return nil } @@ -70,7 +71,7 @@ func (c *Check) InstanceName() string { return "test_check" } -func (c *Check) CheckConnection(ctx context.Context, state *smtp.ConnectionState) error { +func (c *Check) CheckConnection(ctx context.Context, state *module.ConnState) error { return c.EarlyErr } @@ -105,8 +106,7 @@ func (cs *checkState) Close() error { } func init() { - module.Register("test_check", func(_, _ string, _, _ []string) (module.Module, error) { + modules.Register("test_check", func(_ *container.C, _, _ string) (module.Module, error) { return &Check{}, nil }) - module.RegisterInstance(&Check{}, nil) } diff --git a/internal/testutils/filesystem.go b/internal/testutils/filesystem.go index e6b6abf2c..cdac01746 100644 --- a/internal/testutils/filesystem.go +++ b/internal/testutils/filesystem.go @@ -19,14 +19,14 @@ along with this program. If not, see . package testutils import ( - "io/ioutil" + "os" "testing" ) -// Dir is a wrapper for ioutil.TempDir that +// Dir is a wrapper for os.MkdirTemp that // fails the test on errors. func Dir(t *testing.T) string { - dir, err := ioutil.TempDir("", "maddy-tests-") + dir, err := os.MkdirTemp("", "maddy-tests-") if err != nil { t.Fatalf("can't create test dir: %v", err) } diff --git a/internal/testutils/logger.go b/internal/testutils/logger.go index 9fd55061d..712d53bb2 100644 --- a/internal/testutils/logger.go +++ b/internal/testutils/logger.go @@ -33,16 +33,18 @@ var ( directLog = flag.Bool("test.directlog", false, "(maddy) Log to stderr instead of test log") ) -func Logger(t *testing.T, name string) log.Logger { +func Logger(t *testing.T, name string) *log.Logger { if *directLog { - return log.Logger{ - Out: log.WriterOutput(os.Stderr, true), - Name: name, - Debug: *debugLog, + return &log.Logger{ + Parent: &log.DefaultLogger, // silence "no parent" warning + Out: log.WriterOutput(os.Stderr, true), + Name: name, + Debug: *debugLog, } } - return log.Logger{ + return &log.Logger{ + Parent: &log.DefaultLogger, Out: log.FuncOutput(func(_ time.Time, debug bool, str string) { t.Helper() str = strings.TrimSuffix(str, "\n") diff --git a/internal/testutils/modifier.go b/internal/testutils/modifier.go index bd139220e..c276b9250 100644 --- a/internal/testutils/modifier.go +++ b/internal/testutils/modifier.go @@ -24,7 +24,9 @@ import ( "github.com/emersion/go-message/textproto" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type Modifier struct { @@ -36,13 +38,13 @@ type Modifier struct { BodyErr error MailFrom map[string]string - RcptTo map[string]string + RcptTo map[string][]string AddHdr textproto.Header UnclosedStates int } -func (m Modifier) Init(*config.Map) error { +func (m Modifier) Configure([]string, *config.Map) error { return nil } @@ -82,20 +84,20 @@ func (ms modifierState) RewriteSender(ctx context.Context, mailFrom string) (str return mailFrom, nil } -func (ms modifierState) RewriteRcpt(ctx context.Context, rcptTo string) (string, error) { +func (ms modifierState) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) { if ms.m.RcptToErr != nil { - return "", ms.m.RcptToErr + return []string{""}, ms.m.RcptToErr } if ms.m.RcptTo == nil { - return rcptTo, nil + return []string{rcptTo}, nil } newRcptTo, ok := ms.m.RcptTo[rcptTo] if ok { return newRcptTo, nil } - return rcptTo, nil + return []string{rcptTo}, nil } func (ms modifierState) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error { @@ -115,8 +117,7 @@ func (ms modifierState) Close() error { } func init() { - module.Register("test_modifier", func(_, _ string, _, _ []string) (module.Module, error) { + modules.Register("test_modifier", func(_ *container.C, _, _ string) (module.Module, error) { return &Modifier{}, nil }) - module.RegisterInstance(&Modifier{}, nil) } diff --git a/internal/testutils/multitable.go b/internal/testutils/multitable.go new file mode 100644 index 000000000..9b84abec2 --- /dev/null +++ b/internal/testutils/multitable.go @@ -0,0 +1,35 @@ +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package testutils + +import "context" + +type MultiTable struct { + M map[string][]string + Err error +} + +func (m MultiTable) LookupMulti(_ context.Context, a string) ([]string, error) { + b, ok := m.M[a] + if ok { + return b, m.Err + } else { + return []string{}, m.Err + } +} diff --git a/internal/testutils/smtp_server.go b/internal/testutils/smtp_server.go index 814bf684a..214b72dba 100644 --- a/internal/testutils/smtp_server.go +++ b/internal/testutils/smtp_server.go @@ -21,16 +21,19 @@ package testutils import ( "crypto/tls" "crypto/x509" + "fmt" "io" - "io/ioutil" "net" "reflect" "sort" + "sync/atomic" "testing" "time" + "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/exterrors" + "github.com/stretchr/testify/require" ) type SMTPMessage struct { @@ -38,7 +41,7 @@ type SMTPMessage struct { Opts smtp.MailOptions To []string Data []byte - State *smtp.ConnectionState + Conn *smtp.Conn AuthUser string AuthPass string } @@ -54,20 +57,27 @@ type SMTPBackend struct { RcptErr map[string]error DataErr error LMTPDataErr []error + + ActiveSessionsCounter atomic.Int32 } -func (be *SMTPBackend) NewSession(state smtp.ConnectionState, _ string) (smtp.Session, error) { +func (be *SMTPBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) { be.SessionCounter++ + be.ActiveSessionsCounter.Add(1) if be.SourceEndpoints == nil { be.SourceEndpoints = make(map[string]struct{}) } - be.SourceEndpoints[state.RemoteAddr.String()] = struct{}{} + be.SourceEndpoints[conn.Conn().RemoteAddr().String()] = struct{}{} return &session{ backend: be, - state: &state, + conn: conn, }, nil } +func (be *SMTPBackend) ConnectionCount() int { + return int(be.ActiveSessionsCounter.Load()) +} + func (be *SMTPBackend) CheckMsg(t *testing.T, indx int, from string, rcptTo []string) { t.Helper() @@ -96,24 +106,34 @@ type session struct { backend *SMTPBackend user string password string - state *smtp.ConnectionState + conn *smtp.Conn msg *SMTPMessage } +func (s *session) AuthMechanisms() []string { + return []string{sasl.Plain} +} + +func (s *session) Auth(mech string) (sasl.Server, error) { + if mech != sasl.Plain { + return nil, fmt.Errorf("mechanisms other than plain are unsupported") + } + return sasl.NewPlainServer(func(identity, username, password string) error { + if s.backend.AuthErr != nil { + return s.backend.AuthErr + } + s.user = username + s.password = password + return nil + }), nil +} + func (s *session) Reset() { s.msg = &SMTPMessage{} } func (s *session) Logout() error { - return nil -} - -func (s *session) AuthPlain(username, password string) error { - if s.backend.AuthErr != nil { - return s.backend.AuthErr - } - s.user = username - s.password = password + s.backend.ActiveSessionsCounter.Add(-1) return nil } @@ -130,7 +150,7 @@ func (s *session) Mail(from string, opts *smtp.MailOptions) error { return nil } -func (s *session) Rcpt(to string) error { +func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error { if err := s.backend.RcptErr[to]; err != nil { return err } @@ -144,12 +164,12 @@ func (s *session) Data(r io.Reader) error { return s.backend.DataErr } - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) if err != nil { return err } s.msg.Data = b - s.msg.State = s.state + s.msg.Conn = s.conn s.msg.AuthUser = s.user s.msg.AuthPass = s.password s.backend.Messages = append(s.backend.Messages, s.msg) @@ -161,12 +181,12 @@ func (s *session) LMTPData(r io.Reader, status smtp.StatusCollector) error { return s.backend.DataErr } - b, err := ioutil.ReadAll(r) + b, err := io.ReadAll(r) if err != nil { return err } s.msg.Data = b - s.msg.State = s.state + s.msg.Conn = s.conn s.msg.AuthUser = s.user s.msg.AuthPass = s.password s.backend.Messages = append(s.backend.Messages, s.msg) @@ -180,10 +200,6 @@ func (s *session) LMTPData(r io.Reader, status smtp.StatusCollector) error { type SMTPServerConfigureFunc func(*smtp.Server) -var AuthDisabled = func(s *smtp.Server) { - s.AuthDisabled = true -} - func SMTPServer(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*SMTPBackend, *smtp.Server) { t.Helper() @@ -212,10 +228,8 @@ func SMTPServer(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*SMTP // nil Server.listener (Serve sets it to a non-nil value, so it is racy and // happens only sometimes). testConn, err := net.Dial("tcp", addr) - if err != nil { - t.Fatal(err) - } - testConn.Close() + require.NoError(t, err) + require.NoError(t, testConn.Close()) return be, s } @@ -306,10 +320,8 @@ func SMTPServerSTARTTLS(t *testing.T, addr string, fn ...SMTPServerConfigureFunc // nil Server.listener (Serve sets it to a non-nil value, so it is racy and // happens only sometimes). testConn, err := net.Dial("tcp", addr) - if err != nil { - t.Fatal(err) - } - testConn.Close() + require.NoError(t, err) + require.NoError(t, testConn.Close()) return clientCfg, be, s } @@ -364,22 +376,28 @@ func SMTPServerTLS(t *testing.T, addr string, fn ...SMTPServerConfigureFunc) (*t if err != nil { t.Fatal(err) } - testConn.Close() + require.NoError(t, testConn.Close()) return clientCfg, be, s } +type smtpBackendConnCounter interface { + ConnectionCount() int +} + func CheckSMTPConnLeak(t *testing.T, srv *smtp.Server) { t.Helper() + ccb, ok := srv.Backend.(smtpBackendConnCounter) + if !ok { + t.Error("CheckSMTPConnLeak used for smtp.Server with backend without ConnectionCount method") + return + } + // Connection closure is handled asynchronously, so before failing // wait a bit for handleQuit in go-smtp to do its work. for i := 0; i < 10; i++ { - found := false - srv.ForEachConn(func(_ *smtp.Conn) { - found = true - }) - if !found { + if ccb.ConnectionCount() == 0 { return } time.Sleep(100 * time.Millisecond) diff --git a/internal/testutils/target.go b/internal/testutils/target.go index 3a55a44bc..221834bac 100644 --- a/internal/testutils/target.go +++ b/internal/testutils/target.go @@ -24,12 +24,12 @@ import ( "encoding/hex" "errors" "io" - "io/ioutil" "reflect" "sort" "testing" "github.com/emersion/go-message/textproto" + "github.com/emersion/go-smtp" "github.com/foxcpp/maddy/framework/buffer" "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/exterrors" @@ -62,18 +62,18 @@ type Target struct { module.Module is implemented with dummy functions for logging done by MsgPipeline code. */ -func (dt Target) Init(*config.Map) error { +func (dt *Target) Configure([]string, *config.Map) error { return nil } -func (dt Target) InstanceName() string { +func (dt *Target) InstanceName() string { if dt.InstName != "" { return dt.InstName } return "test_instance" } -func (dt Target) Name() string { +func (dt *Target) Name() string { return "test_target" } @@ -86,7 +86,7 @@ type testTargetDeliveryPartial struct { testTargetDelivery } -func (dt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { +func (dt *Target) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) { if dt.PartialBodyErr != nil { return &testTargetDeliveryPartial{ testTargetDelivery: testTargetDelivery{ @@ -101,7 +101,7 @@ func (dt *Target) Start(ctx context.Context, msgMeta *module.MsgMetadata, mailFr }, dt.StartErr } -func (dtd *testTargetDelivery) AddRcpt(ctx context.Context, to string) error { +func (dtd *testTargetDelivery) AddRcpt(ctx context.Context, to string, _ smtp.RcptOptions) error { if dtd.tgt.RcptErr != nil { if err := dtd.tgt.RcptErr[to]; err != nil { return err @@ -129,9 +129,13 @@ func (dtd *testTargetDeliveryPartial) BodyNonAtomic(ctx context.Context, c modul } return } - defer body.Close() + defer func() { + if err := body.Close(); err != nil { + panic(err) + } + }() - dtd.msg.Body, err = ioutil.ReadAll(body) + dtd.msg.Body, err = io.ReadAll(body) if err != nil { for rcpt, err := range dtd.tgt.PartialBodyErr { c.SetStatus(rcpt, err) @@ -153,15 +157,19 @@ func (dtd *testTargetDelivery) Body(ctx context.Context, header textproto.Header if err != nil { return err } - defer body.Close() + defer func() { + if err := body.Close(); err != nil { + panic(err) + } + }() if dtd.tgt.DiscardMessages { // Don't bother. - _, err = io.Copy(ioutil.Discard, body) + _, err = io.Copy(io.Discard, body) return err } - dtd.msg.Body, err = ioutil.ReadAll(body) + dtd.msg.Body, err = io.ReadAll(body) return err } @@ -211,16 +219,16 @@ func DoTestDeliveryNonAtomic(t *testing.T, c module.StatusCollector, tgt module. ID: encodedID, OriginalFrom: from, } - t.Log("-- tgt.Start", from) - delivery, err := tgt.Start(testCtx, &msgMeta, from) + t.Log("-- tgt.StartDelivery", from) + delivery, err := tgt.StartDelivery(testCtx, &msgMeta, from) if err != nil { - t.Log("-- ... tgt.Start", from, err, exterrors.Fields(err)) + t.Log("-- ... tgt.StartDelivery", from, err, exterrors.Fields(err)) t.Fatalf("Unexpected err: %v %+v", err, exterrors.Fields(err)) return encodedID } for _, rcpt := range to { t.Log("-- delivery.AddRcpt", rcpt) - if err := delivery.AddRcpt(testCtx, rcpt); err != nil { + if err := delivery.AddRcpt(testCtx, rcpt, smtp.RcptOptions{}); err != nil { t.Log("-- ... delivery.AddRcpt", rcpt, err, exterrors.Fields(err)) t.Log("-- delivery.Abort") if err := delivery.Abort(testCtx); err != nil { @@ -262,15 +270,15 @@ func DoTestDeliveryErrMeta(t *testing.T, tgt module.DeliveryTarget, from string, body := buffer.MemoryBuffer{Slice: []byte("foobar\r\n")} msgMeta.DontTraceSender = true msgMeta.ID = encodedID - t.Log("-- tgt.Start", from) - delivery, err := tgt.Start(testCtx, msgMeta, from) + t.Log("-- tgt.StartDelivery", from) + delivery, err := tgt.StartDelivery(testCtx, msgMeta, from) if err != nil { - t.Log("-- ... tgt.Start", from, err, exterrors.Fields(err)) + t.Log("-- ... tgt.StartDelivery", from, err, exterrors.Fields(err)) return encodedID, err } for _, rcpt := range to { t.Log("-- delivery.AddRcpt", rcpt) - if err := delivery.AddRcpt(testCtx, rcpt); err != nil { + if err := delivery.AddRcpt(testCtx, rcpt, smtp.RcptOptions{}); err != nil { t.Log("-- ... delivery.AddRcpt", rcpt, err, exterrors.Fields(err)) t.Log("-- delivery.Abort") if err := delivery.Abort(testCtx); err != nil { diff --git a/internal/tls/acme/acme.go b/internal/tls/acme/acme.go index 65f9e7c33..ca1716168 100644 --- a/internal/tls/acme/acme.go +++ b/internal/tls/acme/acme.go @@ -9,9 +9,11 @@ import ( "github.com/caddyserver/certmagic" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/hooks" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) const modName = "tls.loader.acme" @@ -19,35 +21,38 @@ const modName = "tls.loader.acme" type Loader struct { instName string + names []string store certmagic.Storage cache *certmagic.Cache cfg *certmagic.Config cancelManage context.CancelFunc - log log.Logger + log *log.Logger } -func New(_, instName string, _, inlineArgs []string) (module.Module, error) { - if len(inlineArgs) != 0 { - return nil, fmt.Errorf("%s: no inline args expected", modName) - } +func New(c *container.C, _, instName string) (module.Module, error) { return &Loader{ instName: instName, - log: log.Logger{Name: modName}, + log: c.DefaultLogger.Sublogger(modName), }, nil } -func (l *Loader) Init(cfg *config.Map) error { +func (l *Loader) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs) != 0 { + return fmt.Errorf("%s: no inline args expected", modName) + } + var ( - hostname string - extraNames []string - storePath string - caPath string - testCAPath string - email string - agreed bool - challenge string - provider certmagic.ACMEDNSProvider + hostname string + extraNames []string + storePath string + caPath string + testCAPath string + email string + agreed bool + challenge string + overrideDomain string + provider certmagic.DNSProvider ) cfg.Bool("debug", true, false, &l.log.Debug) cfg.String("hostname", true, true, "", &hostname) @@ -60,13 +65,15 @@ func (l *Loader) Init(cfg *config.Map) error { certmagic.LetsEncryptStagingCA, &testCAPath) cfg.String("email", false, false, "", &email) + cfg.String("override_domain", false, false, + "", &overrideDomain) cfg.Bool("agreed", false, false, &agreed) cfg.Enum("challenge", false, true, []string{"dns-01"}, "dns-01", &challenge) cfg.Custom("dns", false, false, func() (interface{}, error) { return nil, nil }, func(m *config.Map, node config.Node) (interface{}, error) { - var p certmagic.ACMEDNSProvider + var p certmagic.DNSProvider err := modconfig.ModuleFromNode("libdns", node.Args, node, m.Globals, &p) return p, err }, &provider) @@ -80,61 +87,63 @@ func (l *Loader) Init(cfg *config.Map) error { l.cache = certmagic.NewCache(certmagic.CacheOptions{ Logger: cmLog, GetConfigForCert: func(c certmagic.Certificate) (*certmagic.Config, error) { - return &certmagic.Config{ - Storage: l.store, - Logger: cmLog, - }, nil + return l.cfg, nil }, }) l.cfg = certmagic.New(l.cache, certmagic.Config{ - Storage: l.store, // not sure if it is necessary to set these twice - Logger: cmLog, + Storage: l.store, // not sure if it is necessary to set these twice + Logger: cmLog, DefaultServerName: hostname, }) - mngr := certmagic.NewACMEManager(l.cfg, certmagic.ACMEManager{ + issuer := certmagic.NewACMEIssuer(l.cfg, certmagic.ACMEIssuer{ Logger: cmLog, CA: caPath, + TestCA: testCAPath, Email: email, Agreed: agreed, }) switch challenge { case "dns-01": - mngr.DisableTLSALPNChallenge = true - mngr.DisableHTTPChallenge = true + issuer.DisableTLSALPNChallenge = true + issuer.DisableHTTPChallenge = true if provider == nil { return fmt.Errorf("tls.loader.acme: dns-01 challenge requires a configured DNS provider") } - mngr.DNS01Solver = &certmagic.DNS01Solver{ - DNSProvider: provider, + issuer.DNS01Solver = &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: provider, + OverrideDomain: overrideDomain, + }, } default: return fmt.Errorf("tls.loader.acme: challenge not supported") } - l.cfg.Issuers = []certmagic.Issuer{mngr} + l.cfg.Issuers = []certmagic.Issuer{issuer} - if module.NoRun { - return nil - } + l.names = append([]string{hostname}, extraNames...) + + return nil +} + +func (l *Loader) ConfigureTLS(c *tls.Config) error { + c.GetCertificate = l.cfg.GetCertificate + return nil +} +func (l *Loader) Start() error { manageCtx, cancelManage := context.WithCancel(context.Background()) - err := l.cfg.ManageAsync(manageCtx, append([]string{hostname}, extraNames...)) + err := l.cfg.ManageAsync(manageCtx, l.names) if err != nil { cancelManage() return err } l.cancelManage = cancelManage - - return nil -} - -func (l *Loader) ConfigureTLS(c *tls.Config) error { - c.GetCertificate = l.cfg.GetCertificate return nil } -func (l *Loader) Close() error { +func (l *Loader) Stop() error { l.cancelManage() l.cache.Stop() return nil @@ -150,11 +159,11 @@ func (l *Loader) InstanceName() string { func init() { hooks.AddHook(hooks.EventShutdown, func() { - certmagic.CleanUpOwnLocks(nil) + certmagic.CleanUpOwnLocks(context.TODO(), log.DefaultLogger.Zap()) }) } func init() { var _ module.TLSLoader = &Loader{} - module.Register(modName, New) + modules.Register(modName, New) } diff --git a/internal/tls/file.go b/internal/tls/file.go index 943a59edf..ab7b90ed0 100644 --- a/internal/tls/file.go +++ b/internal/tls/file.go @@ -27,17 +27,17 @@ import ( "time" "github.com/foxcpp/maddy/framework/config" - "github.com/foxcpp/maddy/framework/hooks" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type FileLoader struct { - instName string - inlineArgs []string - certPaths []string - keyPaths []string - log log.Logger + instName string + certPaths []string + keyPaths []string + log *log.Logger certs []tls.Certificate certsLock sync.RWMutex @@ -46,16 +46,15 @@ type FileLoader struct { stopTick chan struct{} } -func NewFileLoader(_, instName string, _, inlineArgs []string) (module.Module, error) { +func NewFileLoader(c *container.C, modName, instName string) (module.Module, error) { return &FileLoader{ - instName: instName, - inlineArgs: inlineArgs, - log: log.Logger{Name: "tls.loader.file", Debug: log.DefaultLogger.Debug}, - stopTick: make(chan struct{}), + instName: instName, + log: c.DefaultLogger.Sublogger(modName), + stopTick: make(chan struct{}), }, nil } -func (f *FileLoader) Init(cfg *config.Map) error { +func (f *FileLoader) Configure(inlineArgs []string, cfg *config.Map) error { cfg.StringList("certs", false, false, nil, &f.certPaths) cfg.StringList("keys", false, false, nil, &f.keyPaths) if _, err := cfg.Process(); err != nil { @@ -66,12 +65,12 @@ func (f *FileLoader) Init(cfg *config.Map) error { return errors.New("tls.loader.file: mismatch in certs and keys count") } - if len(f.inlineArgs)%2 != 0 { + if len(inlineArgs)%2 != 0 { return errors.New("tls.loader.file: odd amount of arguments") } - for i := 0; i < len(f.inlineArgs); i += 2 { - f.certPaths = append(f.certPaths, f.inlineArgs[i]) - f.keyPaths = append(f.keyPaths, f.inlineArgs[i+1]) + for i := 0; i < len(inlineArgs); i += 2 { + f.certPaths = append(f.certPaths, inlineArgs[i]) + f.keyPaths = append(f.keyPaths, inlineArgs[i+1]) } for _, certPath := range f.certPaths { @@ -84,19 +83,21 @@ func (f *FileLoader) Init(cfg *config.Map) error { return err } - hooks.AddHook(hooks.EventReload, func() { - f.log.Println("reloading certificates") - if err := f.loadCerts(); err != nil { - f.log.Error("reload failed", err) - } - }) + return nil +} +func (f *FileLoader) Start() error { f.reloadTick = time.NewTicker(time.Minute) go f.reloadTicker() return nil } -func (f *FileLoader) Close() error { +func (f *FileLoader) Reload() error { + f.log.Println("reloading certificates") + return f.loadCerts() +} + +func (f *FileLoader) Stop() error { f.reloadTick.Stop() f.stopTick <- struct{}{} return nil @@ -164,5 +165,5 @@ func (f *FileLoader) ConfigureTLS(c *tls.Config) error { func init() { var _ module.TLSLoader = &FileLoader{} - module.Register("tls.loader.file", NewFileLoader) + modules.Register("tls.loader.file", NewFileLoader) } diff --git a/internal/tls/self_signed.go b/internal/tls/self_signed.go index d5e174f30..1d6c5134d 100644 --- a/internal/tls/self_signed.go +++ b/internal/tls/self_signed.go @@ -30,7 +30,9 @@ import ( "time" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" ) type SelfSignedLoader struct { @@ -40,14 +42,14 @@ type SelfSignedLoader struct { cert tls.Certificate } -func NewSelfSignedLoader(_, instName string, _, inlineArgs []string) (module.Module, error) { +func NewSelfSignedLoader(_ *container.C, _, instName string) (module.Module, error) { return &SelfSignedLoader{ - instName: instName, - serverNames: inlineArgs, + instName: instName, }, nil } -func (f *SelfSignedLoader) Init(cfg *config.Map) error { +func (f *SelfSignedLoader) Configure(inlineArgs []string, cfg *config.Map) error { + f.serverNames = inlineArgs if _, err := cfg.Process(); err != nil { return err } @@ -108,5 +110,5 @@ func (f *SelfSignedLoader) ConfigureTLS(c *tls.Config) error { func init() { var _ module.TLSLoader = &SelfSignedLoader{} - module.Register("tls.loader.self_signed", NewSelfSignedLoader) + modules.Register("tls.loader.self_signed", NewSelfSignedLoader) } diff --git a/internal/updatepipe/pubsub/pq.go b/internal/updatepipe/pubsub/pq.go new file mode 100644 index 000000000..bee749227 --- /dev/null +++ b/internal/updatepipe/pubsub/pq.go @@ -0,0 +1,90 @@ +package pubsub + +import ( + "context" + "database/sql" + "time" + + "github.com/foxcpp/maddy/framework/log" + "github.com/lib/pq" +) + +type Msg struct { + Key string + Payload string +} + +type PqPubSub struct { + Notify chan Msg + + L *pq.Listener + sender *sql.DB + + Log *log.Logger +} + +func NewPQ(dsn string) (*PqPubSub, error) { + l := &PqPubSub{ + Log: log.DefaultLogger.Sublogger("pgpubsub"), + Notify: make(chan Msg), + } + l.L = pq.NewListener(dsn, 10*time.Second, time.Minute, l.eventHandler) + var err error + l.sender, err = sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + + go func() { + defer close(l.Notify) + for n := range l.L.Notify { + if n == nil { + continue + } + + l.Notify <- Msg{Key: n.Channel, Payload: n.Extra} + } + }() + + return l, nil +} + +func (l *PqPubSub) Close() error { + if err := l.sender.Close(); err != nil { + l.Log.Error("failed to close sender socket", err) + } + if err := l.L.Close(); err != nil { + l.Log.Error("failed to close listener", err) + } + return nil +} + +func (l *PqPubSub) eventHandler(ev pq.ListenerEventType, err error) { + switch ev { + case pq.ListenerEventConnected: + l.Log.DebugMsg("connected") + case pq.ListenerEventReconnected: + l.Log.Msg("connection reestablished") + case pq.ListenerEventConnectionAttemptFailed: + l.Log.Error("connection attempt failed", err) + case pq.ListenerEventDisconnected: + l.Log.Msg("connection closed", "err", err) + } +} + +func (l *PqPubSub) Subscribe(_ context.Context, key string) error { + return l.L.Listen(key) +} + +func (l *PqPubSub) Unsubscribe(_ context.Context, key string) error { + return l.L.Unlisten(key) +} + +func (l *PqPubSub) Publish(key, payload string) error { + _, err := l.sender.Exec(`SELECT pg_notify(1,ドル 2ドル)`, key, payload) + return err +} + +func (l *PqPubSub) Listener() chan Msg { + return l.Notify +} diff --git a/internal/updatepipe/pubsub/pubsub.go b/internal/updatepipe/pubsub/pubsub.go new file mode 100644 index 000000000..64480ab15 --- /dev/null +++ b/internal/updatepipe/pubsub/pubsub.go @@ -0,0 +1,11 @@ +package pubsub + +import "context" + +type PubSub interface { + Subscribe(ctx context.Context, key string) error + Unsubscribe(ctx context.Context, key string) error + Publish(key, payload string) error + Listener() chan Msg + Close() error +} diff --git a/internal/updatepipe/pubsub_pipe.go b/internal/updatepipe/pubsub_pipe.go new file mode 100644 index 000000000..4341f4d35 --- /dev/null +++ b/internal/updatepipe/pubsub_pipe.go @@ -0,0 +1,101 @@ +package updatepipe + +import ( + "context" + "fmt" + "os" + "strconv" + + mess "github.com/foxcpp/go-imap-mess" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/internal/updatepipe/pubsub" +) + +type PubSubPipe struct { + PubSub pubsub.PubSub + Log *log.Logger +} + +func (p *PubSubPipe) Listen(upds chan<- mess.Update) error { + go func() { + for m := range p.PubSub.Listener() { + id, upd, err := parseUpdate(m.Payload) + if err != nil { + p.Log.Error("failed to parse update", err) + continue + } + if id == p.myID() { + continue + } + upds <- *upd + } + }() + return nil +} + +func (p *PubSubPipe) InitPush() error { + return nil +} + +func (p *PubSubPipe) myID() string { + return fmt.Sprintf("%d-%p", os.Getpid(), p) +} + +func (p *PubSubPipe) channel(key interface{}) (string, error) { + var psKey string + switch k := key.(type) { + case string: + psKey = k + case uint64: + psKey = "__uint64_" + strconv.FormatUint(k, 10) + default: + return "", fmt.Errorf("updatepipe: key type must be either string or uint64") + } + return psKey, nil +} + +func (p *PubSubPipe) Subscribe(key interface{}) { + psKey, err := p.channel(key) + if err != nil { + p.Log.Error("invalid key passed to Subscribe", err) + return + } + + if err := p.PubSub.Subscribe(context.TODO(), psKey); err != nil { + p.Log.Error("pubsub subscribe failed", err) + } else { + p.Log.DebugMsg("subscribed to pubsub", "channel", psKey) + } +} + +func (p *PubSubPipe) Unsubscribe(key interface{}) { + psKey, err := p.channel(key) + if err != nil { + p.Log.Error("invalid key passed to Unsubscribe", err) + return + } + + if err := p.PubSub.Unsubscribe(context.TODO(), psKey); err != nil { + p.Log.Error("pubsub unsubscribe failed", err) + } else { + p.Log.DebugMsg("unsubscribed from pubsub", "channel", psKey) + } +} + +func (p *PubSubPipe) Push(upd mess.Update) error { + psKey, err := p.channel(upd.Key) + if err != nil { + return err + } + + updBlob, err := formatUpdate(p.myID(), upd) + if err != nil { + return err + } + + return p.PubSub.Publish(psKey, updBlob) +} + +func (p *PubSubPipe) Close() error { + return p.PubSub.Close() +} diff --git a/internal/updatepipe/serialize.go b/internal/updatepipe/serialize.go index 8e7b63b05..d5a941ff2 100644 --- a/internal/updatepipe/serialize.go +++ b/internal/updatepipe/serialize.go @@ -22,10 +22,10 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/backend" + mess "github.com/foxcpp/go-imap-mess" ) func unescapeName(s string) string { @@ -36,95 +36,34 @@ func escapeName(s string) string { return strings.ReplaceAll(s, ";", "\x10") } -type message struct { - SeqNum uint32 - Flags []string -} - -func parseUpdate(s string) (id string, upd backend.Update, err error) { - parts := strings.SplitN(s, ";", 5) - if len(parts) != 5 { +func parseUpdate(s string) (id string, upd *mess.Update, err error) { + parts := strings.SplitN(s, ";", 2) + if len(parts) != 2 { return "", nil, errors.New("updatepipe: mismatched parts count") } - updBase := backend.NewUpdate(unescapeName(parts[2]), unescapeName(parts[3])) - switch parts[1] { - case "ExpungeUpdate": - exUpd := &backend.ExpungeUpdate{Update: updBase} - if err := json.Unmarshal([]byte(parts[4]), &exUpd.SeqNum); err != nil { - return "", nil, err - } - upd = exUpd - case "MailboxUpdate": - mboxUpd := &backend.MailboxUpdate{Update: updBase} - if err := json.Unmarshal([]byte(parts[4]), &mboxUpd.MailboxStatus); err != nil { - return "", nil, err - } - upd = mboxUpd - case "MessageUpdate": - // imap.Message is not JSON-serializable because it contains maps with - // complex keys. - // In practice, however, MessageUpdate is used only for FLAGS, so we - // serialize them only with a SeqNum. - - msg := message{} - if err := json.Unmarshal([]byte(parts[4]), &msg); err != nil { - return "", nil, err - } + upd = &mess.Update{} + dec := json.NewDecoder(strings.NewReader(unescapeName(parts[1]))) + dec.UseNumber() + err = dec.Decode(upd) + if err != nil { + return "", nil, fmt.Errorf("parseUpdate: %w", err) + } - msgUpd := &backend.MessageUpdate{ - Update: updBase, - Message: imap.NewMessage(msg.SeqNum, []imap.FetchItem{imap.FetchFlags}), - } - msgUpd.Message.Flags = msg.Flags - upd = msgUpd + if val, ok := upd.Key.(json.Number); ok { + upd.Key, _ = strconv.ParseUint(val.String(), 10, 64) } return parts[0], upd, nil } -func formatUpdate(myID string, upd backend.Update) (string, error) { - var ( - objType string - objStr []byte - err error - ) - switch v := upd.(type) { - case *backend.ExpungeUpdate: - objType = "ExpungeUpdate" - objStr, err = json.Marshal(v.SeqNum) - if err != nil { - return "", err - } - case *backend.MessageUpdate: - // imap.Message is not JSON-serializable because it contains maps with - // complex keys. - // In practice, however, MessageUpdate is used only for FLAGS, so we - // serialize them only with a seqnum. - - objType = "MessageUpdate" - objStr, err = json.Marshal(message{ - SeqNum: v.Message.SeqNum, - Flags: v.Message.Flags, - }) - if err != nil { - return "", err - } - case *backend.MailboxUpdate: - objType = "MailboxUpdate" - objStr, err = json.Marshal(v.MailboxStatus) - if err != nil { - return "", err - } - default: - return "", fmt.Errorf("updatepipe: unknown update type: %T", upd) +func formatUpdate(myID string, upd mess.Update) (string, error) { + updBlob, err := json.Marshal(upd) + if err != nil { + return "", fmt.Errorf("formatUpdate: %w", err) } - return strings.Join([]string{ myID, - objType, - escapeName(upd.Username()), - escapeName(upd.Mailbox()), - string(objStr), + escapeName(string(updBlob)), }, ";") + "\n", nil } diff --git a/internal/updatepipe/unix_pipe.go b/internal/updatepipe/unix_pipe.go index 9e6ff66b8..8cd124d86 100644 --- a/internal/updatepipe/unix_pipe.go +++ b/internal/updatepipe/unix_pipe.go @@ -25,8 +25,9 @@ import ( "net" "os" - "github.com/emersion/go-imap/backend" + mess "github.com/foxcpp/go-imap-mess" "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/resource/netresource" ) // UnixSockPipe implements the UpdatePipe interface by serializating updates @@ -34,18 +35,17 @@ import ( // Listen goroutine can be running. // // The socket is stream-oriented and consists of the following messages: -// OBJ_ID;TYPE_NAME;USER;MAILBOX;JSON_SERIALIZED_INTERNAL_OBJECT\n // -// Where TYPE_NAME is one of the folow: ExpungeUpdate, MailboxUpdate, -// MessageUpdate. -// And OBJ_ID is Process ID and UnixSockPipe address concated as a string. +// SENDER_ID;JSON_SERIALIZED_INTERNAL_OBJECT\n +// +// And SENDER_ID is Process ID and UnixSockPipe address concated as a string. // It is used to deduplicate updates sent to Push and recevied via Listen. // // The SockPath field specifies the socket path to use. The actual socket // is initialized on the first call to Listen or (Init)Push. type UnixSockPipe struct { SockPath string - Log log.Logger + Log *log.Logger listener net.Listener sender net.Conn @@ -57,7 +57,7 @@ func (usp *UnixSockPipe) myID() string { return fmt.Sprintf("%d-%p", os.Getpid(), usp) } -func (usp *UnixSockPipe) readUpdates(conn net.Conn, updCh chan<- backend.Update) { +func (usp *UnixSockPipe) readUpdates(conn net.Conn, updCh chan<- mess.Update) { scnr := bufio.NewScanner(conn) for scnr.Scan() { id, upd, err := parseUpdate(scnr.Text()) @@ -70,18 +70,12 @@ func (usp *UnixSockPipe) readUpdates(conn net.Conn, updCh chan<- backend.Update) continue } - updCh <- upd + updCh <- *upd } } -func (usp *UnixSockPipe) Wrap(upd <-chan backend.Update) chan backend.Update { - ourUpds := make(chan backend.Update, cap(upd)) - - return ourUpds -} - -func (usp *UnixSockPipe) Listen(upd chan<- backend.Update) error { - l, err := net.Listen("unix", usp.SockPath) +func (usp *UnixSockPipe) Listen(upd chan<- mess.Update) error { + l, err := netresource.Listen("unix", usp.SockPath) if err != nil { return err } @@ -108,7 +102,7 @@ func (usp *UnixSockPipe) InitPush() error { return nil } -func (usp *UnixSockPipe) Push(upd backend.Update) error { +func (usp *UnixSockPipe) Push(upd mess.Update) error { if usp.sender == nil { if err := usp.InitPush(); err != nil { return err @@ -126,11 +120,17 @@ func (usp *UnixSockPipe) Push(upd backend.Update) error { func (usp *UnixSockPipe) Close() error { if usp.sender != nil { - usp.sender.Close() + if err := usp.sender.Close(); err != nil { + usp.Log.Error("failed to close sender socket", err) + } } if usp.listener != nil { - usp.listener.Close() - os.Remove(usp.SockPath) + if err := usp.listener.Close(); err != nil { + usp.Log.Error("failed to close listener", err) + } + if err := os.Remove(usp.SockPath); err != nil { + usp.Log.Error("failed to remove socket", err) + } } return nil } diff --git a/internal/updatepipe/update_pipe.go b/internal/updatepipe/update_pipe.go index 57735edad..1427c22f2 100644 --- a/internal/updatepipe/update_pipe.go +++ b/internal/updatepipe/update_pipe.go @@ -19,17 +19,17 @@ along with this program. If not, see . // Package updatepipe implements utilities for serialization and transport of // IMAP update objects between processes and machines. // -// Its main goal is provide maddyctl with ability to properly notify the server -// about changes without relying on it to coordinate access in the first place -// (so maddyctl can work without a running server or with a broken server -// instance). +// Its main goal is provide maddy command with ability to properly notify the +// server about changes without relying on it to coordinate access in the +// first place (so maddy command can work without a running server or with a +// broken server instance). // // Additionally, it can be used to transfer IMAP updates between replicated // nodes. package updatepipe import ( - "github.com/emersion/go-imap/backend" + mess "github.com/foxcpp/go-imap-mess" ) // The P interface represents the handle for a transport medium used for IMAP @@ -43,7 +43,7 @@ type P interface { // // Updates sent using the same UpdatePipe object using Push are not // duplicates to the channel passed to Listen. - Listen(upds chan<- backend.Update) error + Listen(upds chan<- mess.Update) error // InitPush prepares the UpdatePipe to be used as updates source (Push // method). @@ -56,7 +56,7 @@ type P interface { // // The update will not be duplicated if the UpdatePipe is also listening // for updates. - Push(upd backend.Update) error + Push(upd mess.Update) error Close() error } diff --git a/maddy.conf b/maddy.conf index 788c9d6c6..5f02fb353 100644 --- a/maddy.conf +++ b/maddy.conf @@ -1,12 +1,9 @@ -## Maddy Mail Server - default configuration file (2021-08-16) +## Maddy Mail Server - default configuration file (2022-06-18) # Suitable for small-scale deployments. Uses its own format for local users DB, -# should be managed via maddyctl utility. +# should be managed via maddy subcommands. # # See tutorials at https://maddy.email for guidance on typical # configuration changes. -# -# See manual pages (also available at https://maddy.email) for reference -# documentation. # ---------------------------------------------------------------------------- # Base variables @@ -28,7 +25,7 @@ tls file /etc/maddy/certs/$(hostname)/fullchain.pem /etc/maddy/certs/$(hostname) # PAM, /etc/shadow file). # # If table module supports it (sql_table does) - credentials can be managed -# using 'maddyctl creds' command. +# using 'maddy creds' command. auth.pass_table local_authdb { table sql_table { @@ -43,7 +40,7 @@ auth.pass_table local_authdb { # also by SMTP & Submission endpoints for delivery of local messages. # # IMAP accounts, mailboxes and all message metadata can be inspected using -# imap-* subcommands of maddyctl utility. +# imap-* subcommands of maddy. storage.imapsql local_mailboxes { driver sqlite3 diff --git a/maddy.conf.docker b/maddy.conf.docker new file mode 100644 index 000000000..39c3bbb18 --- /dev/null +++ b/maddy.conf.docker @@ -0,0 +1,182 @@ +## Maddy Mail Server - default configuration file (2022-06-18) +## This is the copy of maddy.conf with changes necessary to run it in Docker. +# Suitable for small-scale deployments. Uses its own format for local users DB, +# should be managed via maddy subcommands. +# +# See tutorials at https://maddy.email for guidance on typical +# configuration changes. + +# ---------------------------------------------------------------------------- +# Base variables + +$(hostname) = {env:MADDY_HOSTNAME} +$(primary_domain) = {env:MADDY_DOMAIN} +$(local_domains) = $(primary_domain) + +tls file /data/tls/fullchain.pem /data/tls/privkey.pem + +# ---------------------------------------------------------------------------- +# Local storage & authentication + +# pass_table provides local hashed passwords storage for authentication of +# users. It can be configured to use any "table" module, in default +# configuration a table in SQLite DB is used. +# Table can be replaced to use e.g. a file for passwords. Or pass_table module +# can be replaced altogether to use some external source of credentials (e.g. +# PAM, /etc/shadow file). +# +# If table module supports it (sql_table does) - credentials can be managed +# using 'maddy creds' command. + +auth.pass_table local_authdb { + table sql_table { + driver sqlite3 + dsn credentials.db + table_name passwords + } +} + +# imapsql module stores all indexes and metadata necessary for IMAP using a +# relational database. It is used by IMAP endpoint for mailbox access and +# also by SMTP & Submission endpoints for delivery of local messages. +# +# IMAP accounts, mailboxes and all message metadata can be inspected using +# imap-* subcommands of maddy. + +storage.imapsql local_mailboxes { + driver sqlite3 + dsn imapsql.db +} + +# ---------------------------------------------------------------------------- +# SMTP endpoints + message routing + +hostname $(hostname) + +table.chain local_rewrites { + optional_step regexp "(.+)\+(.+)@(.+)" "1ドル@3ドル" + optional_step static { + entry postmaster postmaster@$(primary_domain) + } + optional_step file /etc/maddy/aliases +} + +msgpipeline local_routing { + # Insert handling for special-purpose local domains here. + # e.g. + # destination lists.example.org { + # deliver_to lmtp tcp://127.0.0.1:8024 + # } + + destination postmaster $(local_domains) { + modify { + replace_rcpt &local_rewrites + } + + deliver_to &local_mailboxes + } + + default_destination { + reject 550 5.1.1 "User doesn't exist" + } +} + +smtp tcp://0.0.0.0:25 { + limits { + # Up to 20 msgs/sec across max. 10 SMTP connections. + all rate 20 1s + all concurrency 10 + } + + dmarc yes + check { + require_mx_record + dkim + spf + } + + source $(local_domains) { + reject 501 5.1.8 "Use Submission for outgoing SMTP" + } + default_source { + destination postmaster $(local_domains) { + deliver_to &local_routing + } + default_destination { + reject 550 5.1.1 "User doesn't exist" + } + } +} + +submission tls://0.0.0.0:465 tcp://0.0.0.0:587 { + limits { + # Up to 50 msgs/sec across any amount of SMTP connections. + all rate 50 1s + } + + auth &local_authdb + + source $(local_domains) { + check { + authorize_sender { + prepare_email &local_rewrites + user_to_email identity + } + } + + destination postmaster $(local_domains) { + deliver_to &local_routing + } + default_destination { + modify { + dkim $(primary_domain) $(local_domains) default + } + deliver_to &remote_queue + } + } + default_source { + reject 501 5.1.8 "Non-local sender domain" + } +} + +target.remote outbound_delivery { + limits { + # Up to 20 msgs/sec across max. 10 SMTP connections + # for each recipient domain. + destination rate 20 1s + destination concurrency 10 + } + mx_auth { + dane + mtasts { + cache fs + fs_dir mtasts_cache/ + } + local_policy { + min_tls_level encrypted + min_mx_level none + } + } +} + +target.queue remote_queue { + target &outbound_delivery + + autogenerated_msg_domain $(primary_domain) + bounce { + destination postmaster $(local_domains) { + deliver_to &local_routing + } + default_destination { + reject 550 5.0.0 "Refusing to send DSNs to non-local addresses" + } + } +} + +# ---------------------------------------------------------------------------- +# IMAP endpoints + +imap tls://0.0.0.0:993 tcp://0.0.0.0:143 { + auth &local_authdb + storage &local_mailboxes +} diff --git a/maddy.go b/maddy.go index 43df3db9a..ba1156495 100644 --- a/maddy.go +++ b/maddy.go @@ -20,28 +20,33 @@ package maddy import ( "errors" - "flag" "fmt" - "io" "net/http" "os" "path/filepath" "runtime" "runtime/debug" - "strings" + "sync" "github.com/caddyserver/certmagic" parser "github.com/foxcpp/maddy/framework/cfgparser" "github.com/foxcpp/maddy/framework/config" + modconfig "github.com/foxcpp/maddy/framework/config/module" "github.com/foxcpp/maddy/framework/config/tls" + "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/hooks" "github.com/foxcpp/maddy/framework/log" - "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/framework/resource/netresource" + "github.com/foxcpp/maddy/internal/authz" + maddycli "github.com/foxcpp/maddy/internal/cli" + "github.com/urfave/cli/v2" // Import packages for side-effect of module registration. _ "github.com/foxcpp/maddy/internal/auth/dovecot_sasl" _ "github.com/foxcpp/maddy/internal/auth/external" _ "github.com/foxcpp/maddy/internal/auth/ldap" + _ "github.com/foxcpp/maddy/internal/auth/netauth" _ "github.com/foxcpp/maddy/internal/auth/pam" _ "github.com/foxcpp/maddy/internal/auth/pass_table" _ "github.com/foxcpp/maddy/internal/auth/plain_separate" @@ -78,10 +83,7 @@ import ( var ( Version = "go-build" - enableDebugFlags = false - profileEndpoint *string - blockProfileRate *int - mutexProfileFract *int + enableDebugFlags = false ) func BuildInfo() string { @@ -101,133 +103,210 @@ default runtime_dir: %s`, DefaultRuntimeDirectory) } -// Run is the entry point for all maddy code. It takes care of command line arguments parsing, -// logging initialization, directives setup, configuration reading. After all that, it -// calls moduleMain to initialize and run modules. -func Run() int { - certmagic.UserAgent = "maddy/" + Version - - flag.StringVar(&config.LibexecDirectory, "libexec", DefaultLibexecDirectory, "path to the libexec directory") - flag.BoolVar(&log.DefaultLogger.Debug, "debug", false, "enable debug logging early") - - var ( - configPath = flag.String("config", filepath.Join(ConfigDirectory, "maddy.conf"), "path to configuration file") - logTargets = flag.String("log", "stderr", "default logging target(s)") - printVersion = flag.Bool("v", false, "print version and build metadata, then exit") +func init() { + maddycli.AddGlobalFlag( + &cli.PathFlag{ + Name: "config", + Usage: "Configuration file to use", + EnvVars: []string{"MADDY_CONFIG"}, + Value: filepath.Join(ConfigDirectory, "maddy.conf"), + }, ) + maddycli.AddGlobalFlag(&cli.BoolFlag{ + Name: "debug", + Usage: "enable debug logging early", + Destination: &log.DefaultLogger.Debug, + }) + maddycli.AddSubcommand(&cli.Command{ + Name: "verify-config", + Usage: "Check configuration file for errors", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "debug", + Usage: "enable debug logging early", + Destination: &log.DefaultLogger.Debug, + }, + }, + Action: VerifyConfig, + }) + maddycli.AddSubcommand(&cli.Command{ + Name: "run", + Usage: "Start the server", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "libexec", + Value: DefaultLibexecDirectory, + Usage: "path to the libexec directory", + Destination: &config.LibexecDirectory, + }, + &cli.StringSliceFlag{ + Name: "log", + Usage: "default logging target(s)", + Value: cli.NewStringSlice("stderr"), + }, + &cli.BoolFlag{ + Name: "v", + Usage: "print version and build metadata, then exit", + Hidden: true, + }, + }, + Action: Run, + }) + maddycli.AddSubcommand(&cli.Command{ + Name: "version", + Usage: "Print version and build metadata, then exit", + Action: func(c *cli.Context) error { + fmt.Println(BuildInfo()) + return nil + }, + }) if enableDebugFlags { - profileEndpoint = flag.String("debug.pprof", "", "enable live profiler HTTP endpoint and listen on the specified address") - blockProfileRate = flag.Int("debug.blockprofrate", 0, "set blocking profile rate") - mutexProfileFract = flag.Int("debug.mutexproffract", 0, "set mutex profile fraction") + maddycli.AddGlobalFlag(&cli.StringFlag{ + Name: "debug.pprof", + Usage: "enable live profiler HTTP endpoint and listen on the specified address", + }) + maddycli.AddGlobalFlag(&cli.IntFlag{ + Name: "debug.blockprofrate", + Usage: "set blocking profile rate", + }) + maddycli.AddGlobalFlag(&cli.IntFlag{ + Name: "debug.mutexproffract", + Usage: "set mutex profile fraction", + }) } +} - flag.Parse() +// Run is the entry point for all server-running code. It takes care of command line arguments processing, +// logging initialization, directives setup, configuration reading. After all that, it +// calls moduleMain to initialize and run modules. +func Run(c *cli.Context) error { + certmagic.UserAgent = "maddy/" + Version - if len(flag.Args()) != 0 { - fmt.Println("usage:", os.Args[0], "[options]") - return 2 + if c.NArg() != 0 { + return cli.Exit(fmt.Sprintln("usage:", os.Args[0], "[options]"), 2) } - if *printVersion { + if c.Bool("v") { fmt.Println("maddy", BuildInfo()) - return 0 + return nil } var err error - log.DefaultLogger.Out, err = LogOutputOption(strings.Split(*logTargets, ",")) + log.DefaultLogger.Out, err = LogOutputOption(c.StringSlice("log")) if err != nil { systemdStatusErr(err) - log.Println(err) - return 2 + return cli.Exit(err.Error(), 2) } - initDebug() + initDebug(c) - os.Setenv("PATH", config.LibexecDirectory+string(filepath.ListSeparator)+os.Getenv("PATH")) - - f, err := os.Open(*configPath) + err = os.Setenv("PATH", config.LibexecDirectory+string(filepath.ListSeparator)+os.Getenv("PATH")) if err != nil { systemdStatusErr(err) - log.Println(err) - return 2 + return cli.Exit(err.Error(), 1) } - defer f.Close() - cfg, err := parser.Read(f, *configPath) - if err != nil { + hooks.AddHook(hooks.EventLogRotate, reinitLogging) + defer func(out log.Output) { + if err := out.Close(); err != nil { + log.Println("failed to close default logger output:", err) + } + }(log.DefaultLogger.Out) + defer hooks.RunHooks(hooks.EventShutdown) + + defer func() { + if err := netresource.CloseAllListeners(); err != nil { + log.DefaultLogger.Error("CloseAllListeners failed", err) + } + }() + + if err := moduleMain(c.Path("config")); err != nil { systemdStatusErr(err) - log.Println(err) - return 2 + return cli.Exit(err.Error(), 1) } - if err := moduleMain(cfg); err != nil { - systemdStatusErr(err) - log.Println(err) - return 2 + return nil +} + +func VerifyConfig(c *cli.Context) error { + err := os.Setenv("PATH", config.LibexecDirectory+string(filepath.ListSeparator)+os.Getenv("PATH")) + if err != nil { + return cli.Exit(err.Error(), 1) } - return 0 + if _, err := moduleConfigure(c.Path("config")); err != nil { + return cli.Exit(err.Error(), 2) + } + + _, _ = fmt.Fprintln(os.Stderr, "No errors detected") + + return nil } -func initDebug() { +func initDebug(c *cli.Context) { if !enableDebugFlags { return } - if *profileEndpoint != "" { + if c.IsSet("debug.pprof") { + profileEndpoint := c.String("debug.pprof") go func() { - log.Println("listening on", "http://"+*profileEndpoint, "for profiler requests") - log.Println("failed to listen on profiler endpoint:", http.ListenAndServe(*profileEndpoint, nil)) + log.Println("listening on", "http://"+profileEndpoint, "for profiler requests") + log.Println("failed to listen on profiler endpoint:", http.ListenAndServe(profileEndpoint, nil)) }() } // These values can also be affected by environment so set them // only if argument is specified. - if *mutexProfileFract != 0 { - runtime.SetMutexProfileFraction(*mutexProfileFract) + if c.IsSet("debug.mutexproffract") { + runtime.SetMutexProfileFraction(c.Int("debug.mutexproffract")) } - if *blockProfileRate != 0 { - runtime.SetBlockProfileRate(*blockProfileRate) + if c.IsSet("debug.blockprofrate") { + runtime.SetBlockProfileRate(c.Int("debug.blockprofrate")) } } -func InitDirs() error { - if config.StateDirectory == "" { - config.StateDirectory = DefaultStateDirectory +func InitDirs(c *container.C) error { + if c.Config.StateDirectory == "" { + c.Config.StateDirectory = DefaultStateDirectory } - if config.RuntimeDirectory == "" { - config.RuntimeDirectory = DefaultRuntimeDirectory + if c.Config.RuntimeDirectory == "" { + c.Config.RuntimeDirectory = DefaultRuntimeDirectory } - if config.LibexecDirectory == "" { - config.LibexecDirectory = DefaultLibexecDirectory + if c.Config.LibexecDirectory == "" { + c.Config.LibexecDirectory = DefaultLibexecDirectory } - if err := ensureDirectoryWritable(config.StateDirectory); err != nil { + if err := ensureDirectoryWritable(c.Config.StateDirectory); err != nil { return err } - if err := ensureDirectoryWritable(config.RuntimeDirectory); err != nil { + if err := ensureDirectoryWritable(c.Config.RuntimeDirectory); err != nil { return err } // Make sure all paths we are going to use are absolute // before we change the working directory. - if !filepath.IsAbs(config.StateDirectory) { + if !filepath.IsAbs(c.Config.StateDirectory) { return errors.New("statedir should be absolute") } - if !filepath.IsAbs(config.RuntimeDirectory) { + if !filepath.IsAbs(c.Config.RuntimeDirectory) { return errors.New("runtimedir should be absolute") } - if !filepath.IsAbs(config.LibexecDirectory) { + if !filepath.IsAbs(c.Config.LibexecDirectory) { return errors.New("-libexec should be absolute") } // Change the working directory to make all relative paths // in configuration relative to state directory. - if err := os.Chdir(config.StateDirectory); err != nil { + if err := os.Chdir(c.Config.StateDirectory); err != nil { log.Println(err) } + config.StateDirectory = c.Config.StateDirectory + config.RuntimeDirectory = c.Config.RuntimeDirectory + config.LibexecDirectory = c.Config.LibexecDirectory + return nil } @@ -240,69 +319,219 @@ func ensureDirectoryWritable(path string) error { if err != nil { return err } - testFile.Close() - return os.Remove(testFile.Name()) + if err := testFile.Close(); err != nil { + log.Println("failed to close writeable-test file:", err) + } + return os.RemoveAll(testFile.Name()) } -func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, error) { +func ReadGlobals(c *container.C, cfg []config.Node) (map[string]interface{}, []config.Node, error) { globals := config.NewMap(nil, config.Node{Children: cfg}) - globals.String("state_dir", false, false, DefaultStateDirectory, &config.StateDirectory) - globals.String("runtime_dir", false, false, DefaultRuntimeDirectory, &config.RuntimeDirectory) + globals.String("state_dir", false, false, DefaultStateDirectory, &c.Config.StateDirectory) + globals.String("runtime_dir", false, false, DefaultRuntimeDirectory, &c.Config.RuntimeDirectory) globals.String("hostname", false, false, "", nil) globals.String("autogenerated_msg_domain", false, false, "", nil) globals.Custom("tls", false, false, nil, tls.TLSDirective, nil) + globals.Custom("tls_client", false, false, nil, tls.TLSClientBlock, nil) globals.Bool("storage_perdomain", false, false, nil) globals.Bool("auth_perdomain", false, false, nil) globals.StringList("auth_domains", false, false, nil, nil) - globals.Custom("log", false, false, defaultLogOutput, logOutput, &log.DefaultLogger.Out) - globals.Bool("debug", false, log.DefaultLogger.Debug, &log.DefaultLogger.Debug) + globals.Custom("log", false, false, defaultLogOutput, logOutput, &c.DefaultLogger.Out) + globals.Bool("debug", false, false, &c.DefaultLogger.Debug) + config.EnumMapped(globals, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, nil) + modconfig.Table(globals, "auth_map", true, false, nil, nil) globals.AllowUnknown() unknown, err := globals.Process() return globals.Values, unknown, err } -func moduleMain(cfg []config.Node) error { - globals, modBlocks, err := ReadGlobals(cfg) +func ReadConfig(path string) ([]config.Node, error) { + f, err := os.Open(path) if err != nil { - return err + return nil, err } + defer func() { + if err := f.Close(); err != nil { + log.Println("failed to close config file:", err) + } + }() - if err := InitDirs(); err != nil { - return err + return parser.Read(f, path) +} + +func moduleConfigure(configPath string) (*container.C, error) { + c := container.New() + container.Global = c + + cfg, err := ReadConfig(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config %s: %w", configPath, err) } - defer log.DefaultLogger.Out.Close() + globals, modBlocks, err := ReadGlobals(c, cfg) + if err != nil { + return nil, err + } - hooks.AddHook(hooks.EventLogRotate, reinitLogging) + // ReadGlobals will configure c.DefaultLogger. + if c.DefaultLogger.Out != nil { + log.DefaultLogger.Out = c.DefaultLogger.Out + } + + if err := InitDirs(c); err != nil { + return nil, err + } - endpoints, mods, err := RegisterModules(globals, modBlocks) + err = RegisterModules(c, globals, modBlocks) + if err != nil { + return nil, err + } + + for _, inst := range c.Modules.NotInitialized() { + return nil, fmt.Errorf("unused configuration block %s (%s)", + inst.InstanceName(), inst.Name()) + } + + return c, nil +} + +func moduleStart(c *container.C) error { + return c.Lifetime.StartAll() +} + +func moduleStop(c *container.C, earlyStop bool) error { + if earlyStop { + if err := c.Lifetime.EarlyStopAll(); err != nil { + c.DefaultLogger.Error("early stop failed", err) + } + } + + return c.Lifetime.StopAll() +} + +func moduleMain(configPath string) error { + log.DefaultLogger.Msg("loading configuration...") + + // Make path absolute to make sure we can still read it if current directory changes (in moduleConfigure). + configPath, err := filepath.Abs(configPath) if err != nil { return err } - err = initModules(globals, endpoints, mods) + c, err := moduleConfigure(configPath) if err != nil { return err } - systemdStatus(SDReady, "Listening for incoming connections...") + c.DefaultLogger.Msg("configuration loaded") + + if err := moduleStart(c); err != nil { + return err + } + c.DefaultLogger.Msg("server started", "version", Version) + + systemdStatus(SDReady, "Configuration running.") + asyncStopWg := sync.WaitGroup{} // Some containers might still be waiting on moduleStop + for handleSignals() { + hooks.RunHooks(hooks.EventReload) + + c = moduleReload(c, configPath, &asyncStopWg) + } + + c.DefaultLogger.Msg("server stopping...") - handleSignals() + systemdStatus(SDStopping, "Waiting for old configuration to stop...") + asyncStopWg.Wait() - systemdStatus(SDStopping, "Waiting for running transactions to complete...") + systemdStatus(SDStopping, "Waiting for current configuration to stop...") + if err := moduleStop(c, true); err != nil { + c.DefaultLogger.Msg("moduleStop failed", err) + } + c.DefaultLogger.Msg("server stopped") - hooks.RunHooks(hooks.EventShutdown) + if c.DefaultLogger.Out != nil { + if err := c.DefaultLogger.Close(); err != nil { + log.DefaultLogger.Error("failed to close output logger", err) + } + } return nil } -type ModInfo struct { - Instance module.Module - Cfg config.Node +func moduleReload(oldContainer *container.C, configPath string, asyncStopWg *sync.WaitGroup) *container.C { + oldContainer.DefaultLogger.Msg("reloading server...") + systemdStatus(SDReloading, "Reloading server...") + + rollbackReload := func() { + // Restore DefaultLogger config that might be set by moduleConfig + log.DefaultLogger.Out = oldContainer.DefaultLogger.Out + } + + oldContainer.DefaultLogger.Msg("loading new configuration...") + newContainer, err := moduleConfigure(configPath) + if err != nil { + rollbackReload() + oldContainer.DefaultLogger.Error("failed to load new configuration", err) + + return oldContainer + } + + oldContainer.DefaultLogger.Msg("configuration loaded") + rollbackReload = func() { + // Restore DefaultLogger config that might be set by moduleConfig + log.DefaultLogger.Out = oldContainer.DefaultLogger.Out + container.Global = oldContainer + } + + if err := oldContainer.Lifetime.EarlyStopAll(); err != nil { + rollbackReload() + oldContainer.DefaultLogger.Error("failed to early-stop old server", err) + + return oldContainer + } + + netresource.ResetListenersUsage() + oldContainer.DefaultLogger.Msg("starting new server") + if err := moduleStart(newContainer); err != nil { + rollbackReload() + oldContainer.DefaultLogger.Error("failed to start new server", err) + + return oldContainer + } + + newContainer.DefaultLogger.Msg("new server started", "version", Version) + + systemdStatus(SDReloading, "New configuration running. Waiting for old connections and transactions to finish...") + + asyncStopWg.Add(1) + go func() { + defer asyncStopWg.Done() + defer func() { + if err := netresource.CloseUnusedListeners(); err != nil { + oldContainer.DefaultLogger.Error("CloseUnusedListeners failed", err) + } + }() + + oldContainer.DefaultLogger.Msg("stopping old server") + if err := moduleStop(oldContainer, false); err != nil { + oldContainer.DefaultLogger.Error("moduleStop failed", err) + } + oldContainer.DefaultLogger.Msg("old server stopped") + if err := oldContainer.DefaultLogger.Close(); err != nil { + newContainer.DefaultLogger.Error("failed to close old server log", err) + } + + systemdStatus(SDReady, "Configuration running.") + }() + + return newContainer } -func RegisterModules(globals map[string]interface{}, nodes []config.Node) (endpoints, mods []ModInfo, err error) { - mods = make([]ModInfo, 0, len(nodes)) +func RegisterModules(c *container.C, globals map[string]interface{}, nodes []config.Node) (err error) { + var endpoints []struct { + Endpoint container.LifetimeModule + Cfg *config.Map + } for _, block := range nodes { var instName string @@ -316,75 +545,70 @@ func RegisterModules(globals map[string]interface{}, nodes []config.Node) (endpo modName := block.Name - endpFactory := module.GetEndpoint(modName) + endpFactory := modules.GetEndpoint(modName) if endpFactory != nil { - inst, err := endpFactory(modName, block.Args) + inst, err := endpFactory(c, modName, block.Args) if err != nil { - return nil, nil, err + return err } - endpoints = append(endpoints, ModInfo{Instance: inst, Cfg: block}) + endpoints = append(endpoints, struct { + Endpoint container.LifetimeModule + Cfg *config.Map + }{Endpoint: inst, Cfg: config.NewMap(globals, block)}) continue } - factory := module.Get(modName) + factory := modules.Get(modName) if factory == nil { - return nil, nil, config.NodeErr(block, "unknown module or global directive: %s", modName) + return config.NodeErr(block, "unknown module or global directive: %s", modName) } - if module.HasInstance(instName) { - return nil, nil, config.NodeErr(block, "config block named %s already exists", instName) + inst, err := factory(c, modName, instName) + if err != nil { + return err } - inst, err := factory(modName, instName, modAliases, nil) + err = c.Modules.Register(inst, func() error { + err := inst.Configure(nil, config.NewMap(globals, block)) + if err != nil { + return err + } + + if lt, ok := inst.(container.LifetimeModule); ok { + c.Lifetime.Add(lt) + } + return nil + }) if err != nil { - return nil, nil, err + if errors.Is(err, container.ErrInstanceNameDuplicate) { + return config.NodeErr(block, "config block named %s already exists", inst.InstanceName()) + } + return err } - block := block - module.RegisterInstance(inst, config.NewMap(globals, block)) for _, alias := range modAliases { - if module.HasInstance(alias) { - return nil, nil, config.NodeErr(block, "config block named %s already exists", alias) + if err := c.Modules.AddAlias(instName, alias); err != nil { + if errors.Is(err, container.ErrInstanceNameDuplicate) { + return config.NodeErr(block, "config block named %s already exists", alias) + } + return err } - module.RegisterAlias(alias, instName) } log.Debugf("%v:%v: register config block %v %v", block.File, block.Line, instName, modAliases) - mods = append(mods, ModInfo{Instance: inst, Cfg: block}) } if len(endpoints) == 0 { - return nil, nil, fmt.Errorf("at least one endpoint should be configured") + return fmt.Errorf("at least one endpoint should be configured") } - return endpoints, mods, nil -} - -func initModules(globals map[string]interface{}, endpoints, mods []ModInfo) error { + // Endpoints are configured directly after registration. for _, endp := range endpoints { - if err := endp.Instance.Init(config.NewMap(globals, endp.Cfg)); err != nil { + if err := endp.Endpoint.Configure(nil, endp.Cfg); err != nil { return err } - - if closer, ok := endp.Instance.(io.Closer); ok { - endp := endp - hooks.AddHook(hooks.EventShutdown, func() { - log.Debugf("close %s (%s)", endp.Instance.Name(), endp.Instance.InstanceName()) - if err := closer.Close(); err != nil { - log.Printf("module %s (%s) close failed: %v", endp.Instance.Name(), endp.Instance.InstanceName(), err) - } - }) - } - } - - for _, inst := range mods { - if module.Initialized[inst.Instance.InstanceName()] { - continue - } - - return fmt.Errorf("Unused configuration block at %s:%d - %s (%s)", - inst.Cfg.File, inst.Cfg.Line, inst.Instance.InstanceName(), inst.Instance.Name()) + c.Lifetime.Add(endp.Endpoint) } return nil diff --git a/maddy_debug.go b/maddy_debug.go index fa40aa65a..dc0316868 100644 --- a/maddy_debug.go +++ b/maddy_debug.go @@ -1,4 +1,5 @@ -//+build debugflags +//go:build debugflags +// +build debugflags /* Maddy Mail Server - Composable all-in-one email server. diff --git a/multiarch/README.md b/multiarch/README.md deleted file mode 100644 index 5accdf36f..000000000 --- a/multiarch/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Mutliarch builds - -## Requirements - -An ARM64 server with docker daemon exposed (for example, a raspberry pi 4 with Raspberry Pi OS 64bits) - -## Build - -At repository root, launch : - -``` -./docker-build-multiarch.sh --tag=TAG --push -``` - -It will build and push multi-arch docker images as TAG. diff --git a/multiarch/buildkitd.toml b/multiarch/buildkitd.toml deleted file mode 100644 index becff42b1..000000000 --- a/multiarch/buildkitd.toml +++ /dev/null @@ -1,7 +0,0 @@ -################### -## https://github.com/moby/buildkit/blob/master/docs/buildkitd.toml.md - -debug = true - -# insecure-entitlements allows insecure entitlements, disabled by default. -insecure-entitlements = [ "network.host", "security.insecure" ] diff --git a/signal.go b/signal.go index 332a4c63f..4925b3db1 100644 --- a/signal.go +++ b/signal.go @@ -1,4 +1,5 @@ -//+build darwin dragonfly freebsd linux netbsd openbsd solaris +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris +// +build darwin dragonfly freebsd linux netbsd openbsd solaris /* Maddy Mail Server - Composable all-in-one email server. @@ -35,9 +36,10 @@ import ( // (SIGTERM, SIGHUP, SIGINT) will cause this function to return. // // SIGUSR1 will call reinitLogging without returning. -func handleSignals() os.Signal { +func handleSignals() (reload bool) { sig := make(chan os.Signal, 5) signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, syscall.SIGUSR1, syscall.SIGUSR2) + defer signal.Stop(sig) for { switch s := <-sig; s { @@ -47,10 +49,8 @@ func handleSignals() os.Signal { hooks.RunHooks(hooks.EventLogRotate) systemdStatus(SDReady, "Listening for incoming connections...") case syscall.SIGUSR2: - log.Printf("signal received (%s), reloading state", s.String()) - systemdStatus(SDReloading, "Reloading state...") - hooks.RunHooks(hooks.EventReload) - systemdStatus(SDReady, "Listening for incoming connections...") + log.Printf("signal received (%s), reloading configuration", s.String()) + return true default: go func() { s := handleSignals() @@ -59,7 +59,7 @@ func handleSignals() os.Signal { }() log.Printf("signal received (%v), next signal will force immediate shutdown.", s) - return s + return false } } } diff --git a/signal_nonposix.go b/signal_nonposix.go index 3eaf09643..f5750a61b 100644 --- a/signal_nonposix.go +++ b/signal_nonposix.go @@ -1,4 +1,5 @@ -//+build windows plan9 +//go:build windows || plan9 +// +build windows plan9 /* Maddy Mail Server - Composable all-in-one email server. @@ -39,6 +40,6 @@ func handleSignals() os.Signal { os.Exit(1) }() - log.Printf("signal received (%v), next signal will force immediate shutdown.", s) + log.Printf("signal received (%v)", s) return s } diff --git a/systemd.go b/systemd.go index fa076010b..2eea54e07 100644 --- a/systemd.go +++ b/systemd.go @@ -1,4 +1,5 @@ -//+build linux +//go:build linux +// +build linux /* Maddy Mail Server - Composable all-in-one email server. @@ -83,7 +84,11 @@ func systemdStatus(status SDStatus, desc string) { } return } - defer sock.Close() + defer func() { + if err := sock.Close(); err != nil { + log.Println("systemd: failed to close systemd socket:", err) + } + }() if err := setScmPassCred(sock); err != nil { log.Println("systemd: failed to set SCM_PASSCRED on the socket:", err) @@ -110,7 +115,11 @@ func systemdStatusErr(reportedErr error) { } return } - defer sock.Close() + defer func() { + if err := sock.Close(); err != nil { + log.Println("systemd: failed to close systemd socket:", err) + } + }() if err := setScmPassCred(sock); err != nil { log.Println("systemd: failed to set SCM_PASSCRED on the socket:", err) diff --git a/systemd_nonlinux.go b/systemd_nonlinux.go index 7c601b6ae..e31cd15fe 100644 --- a/systemd_nonlinux.go +++ b/systemd_nonlinux.go @@ -1,4 +1,5 @@ -//+build !linux +//go:build !linux +// +build !linux /* Maddy Mail Server - Composable all-in-one email server. diff --git a/tests/basic_test.go b/tests/basic_test.go index c92010a55..80cf1aef3 100644 --- a/tests/basic_test.go +++ b/tests/basic_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -55,7 +56,8 @@ func TestBasic(tt *testing.T) { conn.ExpectPattern("250-ENHANCEDSTATUSCODES") conn.ExpectPattern("250-CHUNKING") conn.ExpectPattern("250-SMTPUTF8") - conn.ExpectPattern("250 SIZE *") + conn.ExpectPattern("250-SIZE *") + conn.ExpectPattern("250 LIMITS RCPTMAX=20000") conn.Writeln("QUIT") conn.ExpectPattern("221 *") } diff --git a/tests/build_cover.sh b/tests/build_cover.sh index 929511c26..b724fd5a2 100755 --- a/tests/build_cover.sh +++ b/tests/build_cover.sh @@ -2,4 +2,4 @@ if [ -z "$GO" ]; then GO=go fi -exec $GO test -tags 'cover_main debugflags' -coverpkg 'github.com/foxcpp/maddy,github.com/foxcpp/maddy/pkg/...,github.com/foxcpp/maddy/internal/...' -cover -covermode atomic -c cover_test.go -o maddy.cover +exec $GO test -race -tags 'cover_main debugflags' -coverpkg 'github.com/foxcpp/maddy,github.com/foxcpp/maddy/pkg/...,github.com/foxcpp/maddy/internal/...' -cover -covermode atomic -c cover_test.go -o maddy.cover diff --git a/tests/conn.go b/tests/conn.go index 9ff86e8bc..2d597e86e 100644 --- a/tests/conn.go +++ b/tests/conn.go @@ -211,7 +211,7 @@ func (c *Conn) SMTPPlainAuth(username, password string, expectOk bool) { if expectOk { c.ExpectPattern("235 *") } else { - c.ExpectPattern("*") + c.ExpectPattern("5*") } } @@ -282,6 +282,13 @@ func (c *Conn) Close() error { return c.Conn.Close() } +func (c *Conn) MustClose() { + c.T.Helper() + if err := c.Close(); err != nil { + c.fatal("Close: %v", err) + } +} + func (c *Conn) Rebind(subtest *T) *Conn { cpy := *c cpy.T = subtest diff --git a/tests/cover_test.go b/tests/cover_test.go index 1c9b5a7b3..298e3e9fd 100644 --- a/tests/cover_test.go +++ b/tests/cover_test.go @@ -1,4 +1,5 @@ -//+build cover_main +//go:build cover_main +// +build cover_main /* Maddy Mail Server - Composable all-in-one email server. @@ -36,15 +37,19 @@ https://github.com/albertito/chasquid/blob/master/coverage_test.go */ import ( + "flag" + "io" "os" "testing" - "github.com/foxcpp/maddy" + _ "github.com/foxcpp/maddy" // To register run command + _ "github.com/foxcpp/maddy/internal/cli/ctl" // To register other CLI commands. + + maddycli "github.com/foxcpp/maddy/internal/cli" ) func TestMain(m *testing.M) { - // -test.* flags are registered somewhere in init() in "testing" (?) - // so calling flag.Parse() in maddy.Run() catches them up. + // -test.* flags are registered somewhere in init() in "testing" (?). // maddy.Run changes the working directory, we need to change it back so // -test.coverprofile writes out profile in the right location. @@ -53,18 +58,28 @@ func TestMain(m *testing.M) { panic(err) } - code := maddy.Run() + // Skip flag parsing and make flag.Parse no-op so when + // m.Run calls it it will not error out on maddy flags. + args := os.Args + os.Args = []string{"command"} + flag.Parse() + os.Args = args + + code := maddycli.RunWithoutExit() if err := os.Chdir(wd); err != nil { panic(err) } // Silence output produced by "testing" runtime. - _, w, err := os.Pipe() + r, w, err := os.Pipe() if err == nil { os.Stderr = w os.Stdout = w } + go func() { + _, _ = io.ReadAll(r) + }() // Even though we do not have any tests to run, we need to call out into // "testing" to make it process flags and produce the coverage report. diff --git a/tests/dovecot_sasl_test.go b/tests/dovecot_sasl_test.go index 143434558..140b1820c 100644 --- a/tests/dovecot_sasl_test.go +++ b/tests/dovecot_sasl_test.go @@ -1,9 +1,8 @@ -//+build integration -//+build darwin dragonfly freebsd linux netbsd openbsd solaris +//go:build integration && (darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris) /* Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -25,9 +24,9 @@ package tests_test import ( "bufio" + "bytes" "errors" "flag" - "io/ioutil" "os" "os/exec" "os/user" @@ -35,6 +34,7 @@ import ( "strings" "syscall" "testing" + "time" "github.com/foxcpp/maddy/tests" ) @@ -45,7 +45,9 @@ func init() { flag.StringVar(&DovecotExecutable, "integration.dovecot", "dovecot", "path to dovecot executable for interop tests") } -const dovecotConf = `base_dir = $ROOT/run/ +const dovecotConf = ` +base_dir = $ROOT/run/ +state_dir = $ROOT/lib/ log_path = /dev/stderr ssl = no @@ -53,12 +55,14 @@ default_internal_user = $USER default_internal_group = $GROUP default_login_user = $USER +auth_failure_delay = 0 + passdb { driver = passwd-file args = $ROOT/passwd } -userdb { +userdb file { driver = passwd-file args = $ROOT/passwd } @@ -75,7 +79,7 @@ protocols = imap service imap-login { chroot = inet_listener imap { - address = 127.0.0.1 + listen = 127.0.0.1 port = 0 } } @@ -92,8 +96,64 @@ auth_verbose_passwords = yes mail_debug = yes ` +const dovecotConf24 = `dovecot_config_version = 2.4.0 +dovecot_storage_version = 2.4.0 + +base_dir = $ROOT/run/ +state_dir = $ROOT/lib/ +mail_plugin_dir = $ROOT/lib/ +login_plugin_dir = $ROOT/lib/ +log_path = /dev/stderr +ssl = no + +default_internal_user = $USER +default_internal_group = $GROUP +default_login_user = $USER + +auth_failure_delay = 0 + +passdb file { + driver = passwd-file + passwd_file_path = $ROOT/passwd +} + +userdb file { + driver = passwd-file + passwd_file_path = $ROOT/passwd +} + +service auth { + unix_listener auth { + mode = 0666 + } +} + +# Turn on debugging information, to help troubleshooting issues. +auth_verbose = yes +auth_debug = yes +auth_debug_passwords = yes +auth_verbose_passwords = yes +mail_debug = yes +` + const dovecotPasswd = `tester:{plain}123456:1000:1000::/home/user` +func isDovecot24(t *testing.T, dovecotExec string) bool { + cmd := exec.Command(dovecotExec, "--version") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + version, _, _ := strings.Cut(stdout.String(), "-") + t.Log("Dovecot version:", stdout.String()) + + parts := strings.SplitN(version, ".", 3) + + return len(parts)>= 2 && parts[0] == "2" && parts[1]>= "4" +} + func runDovecot(t *testing.T) (string, *exec.Cmd) { dovecotExec, err := exec.LookPath(DovecotExecutable) if err != nil { @@ -103,10 +163,7 @@ func runDovecot(t *testing.T) (string, *exec.Cmd) { t.Fatal(err) } - tempDir, err := ioutil.TempDir("", "maddy-dovecot-interop-") - if err != nil { - t.Fatal(err) - } + tempDir := t.TempDir() curUser, err := user.Current() if err != nil { @@ -117,15 +174,20 @@ func runDovecot(t *testing.T) (string, *exec.Cmd) { t.Fatal(err) } + dovecotConfTemplate := dovecotConf + if isDovecot24(t, dovecotExec) { + dovecotConfTemplate = dovecotConf24 + } + dovecotConf := strings.NewReplacer( "$ROOT", tempDir, "$USER", curUser.Username, - "$GROUP", curGroup.Name).Replace(dovecotConf) - err = ioutil.WriteFile(filepath.Join(tempDir, "dovecot.conf"), []byte(dovecotConf), os.ModePerm) + "$GROUP", curGroup.Name).Replace(dovecotConfTemplate) + err = os.WriteFile(filepath.Join(tempDir, "dovecot.conf"), []byte(dovecotConf), os.ModePerm) if err != nil { t.Fatal(err) } - err = ioutil.WriteFile(filepath.Join(tempDir, "passwd"), []byte(dovecotPasswd), os.ModePerm) + err = os.WriteFile(filepath.Join(tempDir, "passwd"), []byte(dovecotPasswd), os.ModePerm) if err != nil { t.Fatal(err) } @@ -147,8 +209,14 @@ func runDovecot(t *testing.T) (string, *exec.Cmd) { for scnr.Scan() { line := scnr.Text() - // One of messages printed near completing initialization. - if strings.Contains(line, "master: Error: file_dotlock_open(/var/lib/dovecot/instances) failed: Permission denied") { + // One of messages printed near completing initialization (Dovecot 2.3 or older) + if strings.Contains(line, "starting up for imap") { + time.Sleep(500 * time.Millisecond) + ready <- struct{}{} + } + // Dovecot 2.4+ + if strings.Contains(line, "starting up without any protocols") { + time.Sleep(500 * time.Millisecond) ready <- struct{}{} } diff --git a/tests/dovecot_sasld_test.go b/tests/dovecot_sasld_test.go index b2aad8d8f..8fa2fd6fe 100644 --- a/tests/dovecot_sasld_test.go +++ b/tests/dovecot_sasld_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -88,16 +89,13 @@ Xi3olS9rB0J+Rvjz -----END PRIVATE KEY-----` func runChasquid(t *testing.T, authClientPath string) (string, *exec.Cmd) { - tempDir, err := ioutil.TempDir("", "maddy-chasquid-interop-") - if err != nil { - t.Fatal(err) - } + tempDir := t.TempDir() t.Log("Using", tempDir) chasquidConf := strings.NewReplacer( "$ROOT", tempDir, "$AUTH_CLIENT", authClientPath).Replace(chasquidConf) - err = ioutil.WriteFile(filepath.Join(tempDir, "chasquid.conf"), []byte(chasquidConf), os.ModePerm) + err := ioutil.WriteFile(filepath.Join(tempDir, "chasquid.conf"), []byte(chasquidConf), os.ModePerm) if err != nil { t.Fatal(err) } diff --git a/tests/ghsa_5835_4gvc_32pc_test.go b/tests/ghsa_5835_4gvc_32pc_test.go new file mode 100644 index 000000000..b7e0108fb --- /dev/null +++ b/tests/ghsa_5835_4gvc_32pc_test.go @@ -0,0 +1,178 @@ +//go:build integration + +package tests_test + +import ( + "strconv" + "testing" + "time" + + "github.com/foxcpp/maddy/tests" + "github.com/jimlambrt/gldap" + "github.com/stretchr/testify/require" +) + +type searchEntry struct { + dn string + options []gldap.Option +} + +type MockLDAP struct { + T *testing.T + SearchEntries map[string][]searchEntry + AllowedBinds map[string]string +} + +func (ml *MockLDAP) HandleBind(w *gldap.ResponseWriter, r *gldap.Request) { + resp := r.NewBindResponse( + gldap.WithResponseCode(gldap.ResultInvalidCredentials), + ) + + m, err := r.GetSimpleBindMessage() + if err != nil { + require.NoError(ml.T, w.Write(resp)) + return + } + + pass, ok := ml.AllowedBinds[m.UserName] + if ok && pass == string(m.Password) { + resp.SetResultCode(gldap.ResultSuccess) + require.NoError(ml.T, w.Write(resp)) + } + + require.NoError(ml.T, w.Write(resp)) +} + +func (ml *MockLDAP) HandleSearch(w *gldap.ResponseWriter, r *gldap.Request) { + resp := r.NewSearchDoneResponse() + m, err := r.GetSearchMessage() + if err != nil { + ml.T.Logf("not a search message: %s", err) + require.NoError(ml.T, w.Write(resp)) + return + } + ml.T.Logf("search base dn: %s", m.BaseDN) + ml.T.Logf("search scope: %d", m.Scope) + ml.T.Logf("search filter: %s", m.Filter) + + entries := ml.SearchEntries[m.Filter] + for _, entry := range entries { + ldapEntry := r.NewSearchResponseEntry(entry.dn, entry.options...) + require.NoError(ml.T, w.Write(ldapEntry)) + } + + resp.SetResultCode(gldap.ResultSuccess) + require.NoError(ml.T, w.Write(resp)) +} + +func (ml *MockLDAP) Run(address string) { + s, err := gldap.NewServer() + if err != nil { + ml.T.Fatalf("unable to create server: %s", err.Error()) + } + + // create a router and add a bind handler + r, err := gldap.NewMux() + if err != nil { + ml.T.Fatalf("unable to create router: %s", err.Error()) + } + require.NoError(ml.T, r.Bind(ml.HandleBind)) + require.NoError(ml.T, r.Search(ml.HandleSearch)) + require.NoError(ml.T, s.Router(r)) + go func() { + require.NoError(ml.T, s.Run(address)) + }() + ml.T.Cleanup(func() { + require.NoError(ml.T, s.Stop()) + }) + + for !s.Ready() { + ml.T.Log("Waiting for server to start") + time.Sleep(100 * time.Millisecond) + } +} + +func TestLDAPInjectionFilter(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + + ldapPort := t.Port("ldap") + + ldapSrv := &MockLDAP{ + T: tt, + AllowedBinds: map[string]string{ + "DC=com,CN=bob": "bob_pass", + "DC=com,CN=alice": "alice_pass", + }, + SearchEntries: map[string][]searchEntry{ + "(&(objectClass=inetOrgPerson)(uid=alice))": { + { + dn: "DC=com,CN=alice", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"alice"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + "(&(objectClass=inetOrgPerson)(uid=bob))": { + { + dn: "DC=com,CN=bob", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"bob"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + "(&(objectClass=inetOrgPerson)(uid=bob)(description=prefix*))": { + { + dn: "DC=com,CN=bob", + options: []gldap.Option{ + gldap.WithAttributes(map[string][]string{ + "objectClass": {"inetOrgPerson"}, + "uid": {"bob"}, + "description": {"prefix_test"}, + }), + }, + }, + }, + }, + } + ldapSrv.Run(":" + strconv.Itoa(int(ldapPort))) + + t.Port("smtp") + t.DNS(nil) + t.Config(` + hostname mx.maddy.test + tls off + + auth.ldap ldap_auth { + urls ldap://127.0.0.1:{env:TEST_PORT_ldap} + bind plain "DC=com,CN=bob" "bob_pass" + base_dn "DC=com" + filter "(&(objectClass=inetOrgPerson)(uid={username}))" + } + + submission tcp://0.0.0.0:{env:TEST_PORT_smtp} { + auth &ldap_auth + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + smtpConn := t.Conn("smtp") + defer smtpConn.MustClose() + smtpConn.SMTPNegotation("clieht.maddy.test", nil, nil) + smtpConn.SMTPPlainAuth("alice", "alice_pass", true) + + smtpConn2 := t.Conn("smtp") + defer smtpConn2.MustClose() + smtpConn2.SMTPNegotation("clieht.maddy.test", nil, nil) + smtpConn2.SMTPPlainAuth("bob)(description=prefix*", "bob_pass", false) +} diff --git a/tests/gocovcat.go b/tests/gocovcat.go index 5b3e759c4..4018912d0 100644 --- a/tests/gocovcat.go +++ b/tests/gocovcat.go @@ -2,6 +2,7 @@ // // From: https://git.lukeshu.com/go/cmd/gocovcat/ // +//go:build ignore // +build ignore // Copyright 2017 Luke Shumaker diff --git a/tests/imap_test.go b/tests/imap_test.go new file mode 100644 index 000000000..ba3fabcdd --- /dev/null +++ b/tests/imap_test.go @@ -0,0 +1,96 @@ +//go:build integration && cgo && !nosqlite3 +// +build integration,cgo,!nosqlite3 + +package tests_test + +import ( + "testing" + + "github.com/foxcpp/maddy/tests" +) + +func TestIMAPEndpointAuthMap(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + + t.DNS(nil) + t.Port("imap") + t.Config(` + storage.imapsql test_store { + driver sqlite3 + dsn imapsql.db + } + + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + tls off + + auth_map email_localpart + auth pass_table static { + entry "user" "bcrypt:2ドルa10ドル$E.AuCH3oYbaRrETXfXwc0.4jRAQBbanpZiCfudsJz9bHzLr/qj6ti" # password: 123 + } + storage &test_store + } + `) + t.Run(1) + defer t.Close() + + imapConn := t.Conn("imap") + defer imapConn.Close() + imapConn.ExpectPattern(`\* OK *`) + imapConn.Writeln(". LOGIN user@example.org 123") + imapConn.ExpectPattern(". OK *") + imapConn.Writeln(". SELECT INBOX") + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`. OK *`) +} + +func TestIMAPEndpointStorageMap(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + + t.DNS(nil) + t.Port("imap") + t.Config(` + storage.imapsql test_store { + driver sqlite3 + dsn imapsql.db + } + + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + tls off + + storage_map email_localpart + + auth_map email_localpart + auth pass_table static { + entry "user" "bcrypt:2ドルa10ドル$z9SvUwUjkY8wKOWd9IbISeEmbJua2cXRPqw7s2BnLXJuc6pIMPncK" # password: 123 + } + storage &test_store + } + `) + t.Run(1) + defer t.Close() + + imapConn := t.Conn("imap") + defer imapConn.Close() + imapConn.ExpectPattern(`\* OK *`) + imapConn.Writeln(". LOGIN user@example.org 123") + imapConn.ExpectPattern(". OK *") + imapConn.Writeln(". CREATE testbox") + imapConn.ExpectPattern(". OK *") + + imapConn2 := t.Conn("imap") + defer imapConn2.Close() + imapConn2.ExpectPattern(`\* OK *`) + imapConn2.Writeln(". LOGIN user@example.com 123") + imapConn2.ExpectPattern(". OK *") + imapConn2.Writeln(`. LIST "" "*"`) + imapConn2.Expect(`* LIST (\HasNoChildren) "." INBOX`) + imapConn2.Expect(`* LIST (\HasNoChildren) "." "testbox"`) + imapConn2.ExpectPattern(". OK *") +} diff --git a/tests/imapsql_test.go b/tests/imapsql_test.go index cdd83adbb..776d74239 100644 --- a/tests/imapsql_test.go +++ b/tests/imapsql_test.go @@ -1,4 +1,4 @@ -//+build integration,cgo,!nosqlite3 +//go:build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -94,6 +94,7 @@ func TestImapsqlDelivery(tt *testing.T) { imapConn.Writeln(". NOOP") imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) imapConn.ExpectPattern(". OK *") imapConn.Writeln(". FETCH 1 (BODY.PEEK[])") @@ -180,6 +181,7 @@ func TestImapsqlDeliveryMap(tt *testing.T) { imapConn.Writeln(". NOOP") imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) imapConn.ExpectPattern(". OK *") } @@ -251,5 +253,6 @@ func TestImapsqlAuthMap(tt *testing.T) { imapConn.Writeln(". NOOP") imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) imapConn.ExpectPattern(". OK *") } diff --git a/tests/issue327_test.go b/tests/issue327_test.go index e6ce3b226..b76642435 100644 --- a/tests/issue327_test.go +++ b/tests/issue327_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. diff --git a/tests/limits_test.go b/tests/limits_test.go index bc20c7070..e219add72 100644 --- a/tests/limits_test.go +++ b/tests/limits_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -50,14 +51,14 @@ func TestConcurrencyLimit(tt *testing.T) { c1 := t.Conn("smtp") defer c1.Close() c1.SMTPNegotation("localhost", nil, nil) - c1.Writeln("MAIL FROM:") c1.ExpectPattern("250 *") // Down on semaphore. c2 := t.Conn("smtp") defer c2.Close() c2.SMTPNegotation("localhost", nil, nil) - c1.Writeln("MAIL FROM:") // Temporary error due to lock timeout. c1.ExpectPattern("451 *") } @@ -86,21 +87,21 @@ func TestPerIPConcurrency(tt *testing.T) { c1 := t.Conn("smtp") defer c1.Close() c1.SMTPNegotation("localhost", nil, nil) - c1.Writeln("MAIL FROM:") c1.ExpectPattern("250 *") // Down on semaphore. c3 := t.Conn4("127.0.0.2", "smtp") defer c3.Close() c3.SMTPNegotation("localhost", nil, nil) - c3.Writeln("MAIL FROM:") c3.ExpectPattern("250 *") // Down on semaphore (different IP). c2 := t.Conn("smtp") defer c2.Close() c2.SMTPNegotation("localhost", nil, nil) - c1.Writeln("MAIL FROM:") // Temporary error due to lock timeout. c1.ExpectPattern("451 *") } diff --git a/tests/lmtp_test.go b/tests/lmtp_test.go index 5e86c3100..8e29a934d 100644 --- a/tests/lmtp_test.go +++ b/tests/lmtp_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. diff --git a/tests/modules_test.go b/tests/modules_test.go new file mode 100644 index 000000000..39046d830 --- /dev/null +++ b/tests/modules_test.go @@ -0,0 +1,63 @@ +//go:build integration + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package tests_test + +import ( + "testing" + + "github.com/foxcpp/maddy/tests" +) + +func TestConfigCycle(tt *testing.T) { + tt.Parallel() + + t := tests.NewT(tt) + t.DNS(nil) + t.Config(` + hostname mx.maddy.test + + msgpipeline local_routing { + destination maddy.test { + deliver_to dummy + } + default_destination { + deliver_to &outbound_queue + } + } + + target.queue outbound_queue { + target dummy + autogenerated_msg_domain maddy.test + bounce { + deliver_to &local_routing + } + } + + smtp tcp://127.0.0.1:1443 { + tls off + + deliver_to &local_routing + } + `) + t.Run(1) + + t.Close() +} diff --git a/tests/mta_test.go b/tests/mta_test.go index bf62177d0..21ac001b9 100644 --- a/tests/mta_test.go +++ b/tests/mta_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. diff --git a/tests/multiple_domains_test.go b/tests/multiple_domains_test.go new file mode 100644 index 000000000..6b29c3fa9 --- /dev/null +++ b/tests/multiple_domains_test.go @@ -0,0 +1,340 @@ +//go:build integration + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2025 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package tests_test + +import ( + "testing" + + "github.com/foxcpp/maddy/tests" +) + +// Test cases based on https://maddy.email/multiple-domains/ + +func TestMultipleDomains_SeparateNamespace(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(nil) + t.Port("submission") + t.Port("imap") + t.Config(` + tls off + hostname test.maddy.email + + auth.pass_table local_authdb { + table sql_table { + driver sqlite3 + dsn credentials.db + table_name passwords + } + } + storage.imapsql local_mailboxes { + driver sqlite3 + dsn imapsql.db + } + + submission tcp://0.0.0.0:{env:TEST_PORT_submission} { + auth &local_authdb + reject + } + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + auth &local_authdb + storage &local_mailboxes + } + `) + + t.MustRunCLIGroup( + []string{"creds", "create", "-p", "user1", "user1@test1.maddy.email"}, + []string{"creds", "create", "-p", "user2", "user2@test1.maddy.email"}, + []string{"creds", "create", "-p", "user3", "user1@test2.maddy.email"}, + []string{"imap-acct", "create", "--no-specialuse", "user1@test1.maddy.email"}, + []string{"imap-acct", "create", "--no-specialuse", "user2@test1.maddy.email"}, + []string{"imap-acct", "create", "--no-specialuse", "user1@test2.maddy.email"}, + ) + t.Run(2) + + user1 := t.Conn("imap") + defer user1.Close() + user1.ExpectPattern(`\* OK *`) + user1.Writeln(`. LOGIN user1@test1.maddy.email user1`) + user1.ExpectPattern(`. OK *`) + user1.Writeln(`. CREATE user1`) + user1.ExpectPattern(`. OK *`) + + user1SMTP := t.Conn("submission") + defer user1SMTP.Close() + user1SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user1SMTP.SMTPPlainAuth("user1@test1.maddy.email", "user1", true) + + user2 := t.Conn("imap") + defer user2.Close() + user2.ExpectPattern(`\* OK *`) + user2.Writeln(`. LOGIN user2@test1.maddy.email user2`) + user2.ExpectPattern(`. OK *`) + user2.Writeln(`. CREATE user2`) + user2.ExpectPattern(`. OK *`) + + user2SMTP := t.Conn("submission") + defer user2SMTP.Close() + user2SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user2SMTP.SMTPPlainAuth("user2@test1.maddy.email", "user2", true) + + user3 := t.Conn("imap") + defer user3.Close() + user3.ExpectPattern(`\* OK *`) + user3.Writeln(`. LOGIN user1@test2.maddy.email user3`) + user3.ExpectPattern(`. OK *`) + user3.Writeln(`. CREATE user3`) + user3.ExpectPattern(`. OK *`) + + user3SMTP := t.Conn("submission") + defer user3SMTP.Close() + user3SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user3SMTP.SMTPPlainAuth("user1@test2.maddy.email", "user3", true) + + user1.Writeln(`. LIST "" "*"`) + user1.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user1.Expect(`* LIST (\HasNoChildren) "." "user1"`) + user1.ExpectPattern(". OK *") + + user2.Writeln(`. LIST "" "*"`) + user2.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user2.Expect(`* LIST (\HasNoChildren) "." "user2"`) + user2.ExpectPattern(". OK *") + + user3.Writeln(`. LIST "" "*"`) + user3.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user3.Expect(`* LIST (\HasNoChildren) "." "user3"`) + user3.ExpectPattern(". OK *") +} + +func TestMultipleDomains_SharedCredentials_DistinctMailboxes(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(nil) + t.Port("submission") + t.Port("imap") + t.Config(` + tls off + hostname test.maddy.email + auth_map email_localpart + + auth.pass_table local_authdb { + table sql_table { + driver sqlite3 + dsn credentials.db + table_name passwords + } + } + storage.imapsql local_mailboxes { + driver sqlite3 + dsn imapsql.db + } + + submission tcp://0.0.0.0:{env:TEST_PORT_submission} { + auth &local_authdb + reject + } + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + auth &local_authdb + storage &local_mailboxes + } + `) + + t.MustRunCLIGroup( + []string{"creds", "create", "-p", "user1", "user1"}, + []string{"creds", "create", "-p", "user2", "user2"}, + []string{"imap-acct", "create", "--no-specialuse", "user1@test1.maddy.email"}, + []string{"imap-acct", "create", "--no-specialuse", "user2@test1.maddy.email"}, + []string{"imap-acct", "create", "--no-specialuse", "user1@test2.maddy.email"}, + ) + t.Run(2) + + user1 := t.Conn("imap") + defer user1.Close() + user1.ExpectPattern(`\* OK *`) + user1.Writeln(`. LOGIN user1@test1.maddy.email user1`) + user1.ExpectPattern(`. OK *`) + user1.Writeln(`. CREATE user1`) + user1.ExpectPattern(`. OK *`) + + user1SMTP := t.Conn("submission") + defer user1SMTP.Close() + user1SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user1SMTP.SMTPPlainAuth("user1@test1.maddy.email", "user1", true) + + user2 := t.Conn("imap") + defer user2.Close() + user2.ExpectPattern(`\* OK *`) + user2.Writeln(`. LOGIN user2@test1.maddy.email user2`) + user2.ExpectPattern(`. OK *`) + user2.Writeln(`. CREATE user2`) + user2.ExpectPattern(`. OK *`) + + user2SMTP := t.Conn("submission") + defer user2SMTP.Close() + user2SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user2SMTP.SMTPPlainAuth("user2@test1.maddy.email", "user2", true) + + user3 := t.Conn("imap") + defer user3.Close() + user3.ExpectPattern(`\* OK *`) + user3.Writeln(`. LOGIN user1@test2.maddy.email user1`) + user3.ExpectPattern(`. OK *`) + user3.Writeln(`. CREATE user3`) + user3.ExpectPattern(`. OK *`) + + user3SMTP := t.Conn("submission") + defer user3SMTP.Close() + user3SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user3SMTP.SMTPPlainAuth("user1@test2.maddy.email", "user1", true) + + user1.Writeln(`. LIST "" "*"`) + user1.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user1.Expect(`* LIST (\HasNoChildren) "." "user1"`) + user1.ExpectPattern(". OK *") + + user2.Writeln(`. LIST "" "*"`) + user2.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user2.Expect(`* LIST (\HasNoChildren) "." "user2"`) + user2.ExpectPattern(". OK *") + + user3.Writeln(`. LIST "" "*"`) + user3.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user3.Expect(`* LIST (\HasNoChildren) "." "user3"`) + user3.ExpectPattern(". OK *") +} + +func TestMultipleDomains_SharedCredentials_SharedMailboxes(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(nil) + t.Port("submission") + t.Port("imap") + t.Config(` + tls off + hostname test.maddy.email + auth_map email_localpart_optional + + auth.pass_table local_authdb { + table sql_table { + driver sqlite3 + dsn credentials.db + table_name passwords + } + } + storage.imapsql local_mailboxes { + driver sqlite3 + dsn imapsql.db + + delivery_map email_localpart_optional + } + + submission tcp://0.0.0.0:{env:TEST_PORT_submission} { + auth &local_authdb + reject + } + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + auth &local_authdb + storage &local_mailboxes + + storage_map email_localpart_optional + } + `) + + t.MustRunCLIGroup( + []string{"creds", "create", "-p", "user1", "user1"}, + []string{"creds", "create", "-p", "user2", "user2"}, + []string{"imap-acct", "create", "--no-specialuse", "user1"}, + []string{"imap-acct", "create", "--no-specialuse", "user2"}, + ) + t.Run(2) + + user1 := t.Conn("imap") + defer user1.Close() + user1.ExpectPattern(`\* OK *`) + user1.Writeln(`. LOGIN user1 user1`) + user1.ExpectPattern(`. OK *`) + user1.Writeln(`. CREATE user1`) + user1.ExpectPattern(`. OK *`) + + user1SMTP := t.Conn("submission") + defer user1SMTP.Close() + user1SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user1SMTP.SMTPPlainAuth("user1", "user1", true) + + user2 := t.Conn("imap") + defer user2.Close() + user2.ExpectPattern(`\* OK *`) + user2.Writeln(`. LOGIN user2@test1.maddy.email user2`) + user2.ExpectPattern(`. OK *`) + user2.Writeln(`. CREATE user2`) + user2.ExpectPattern(`. OK *`) + + user2SMTP := t.Conn("submission") + defer user2SMTP.Close() + user2SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user2SMTP.SMTPPlainAuth("user2", "user2", true) + + user12 := t.Conn("imap") + defer user12.Close() + user12.ExpectPattern(`\* OK *`) + user12.Writeln(`. LOGIN user1@test2.maddy.email user1`) + user12.ExpectPattern(`. OK *`) + user12.Writeln(`. CREATE user12`) + user12.ExpectPattern(`. OK *`) + + user13 := t.Conn("imap") + defer user13.Close() + user13.ExpectPattern(`\* OK *`) + user13.Writeln(`. LOGIN user1@test.maddy.email user1`) + user13.ExpectPattern(`. OK *`) + user13.Writeln(`. CREATE user13`) + user13.ExpectPattern(`. OK *`) + + user12SMTP := t.Conn("submission") + defer user12SMTP.Close() + user12SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user12SMTP.SMTPPlainAuth("user1", "user1", true) + + user13SMTP := t.Conn("submission") + defer user13SMTP.Close() + user13SMTP.SMTPNegotation("localhost", []string{"AUTH PLAIN"}, nil) + user13SMTP.SMTPPlainAuth("user1@test.maddy.email", "user1", true) + + user1.Writeln(`. LIST "" "*"`) + user1.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user1.Expect(`* LIST (\HasNoChildren) "." "user1"`) + user1.Expect(`* LIST (\HasNoChildren) "." "user12"`) + user1.Expect(`* LIST (\HasNoChildren) "." "user13"`) + user1.ExpectPattern(". OK *") + + user2.Writeln(`. LIST "" "*"`) + user2.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user2.Expect(`* LIST (\HasNoChildren) "." "user2"`) + user2.ExpectPattern(". OK *") + + user12.Writeln(`. LIST "" "*"`) + user12.Expect(`* LIST (\HasNoChildren) "." INBOX`) + user12.Expect(`* LIST (\HasNoChildren) "." "user1"`) + user12.Expect(`* LIST (\HasNoChildren) "." "user12"`) + user12.Expect(`* LIST (\HasNoChildren) "." "user13"`) + user12.ExpectPattern(". OK *") +} diff --git a/internal/table/sqlite3.go b/tests/reload_non_unix.go similarity index 79% rename from internal/table/sqlite3.go rename to tests/reload_non_unix.go index f0c19ea41..a75116340 100644 --- a/internal/table/sqlite3.go +++ b/tests/reload_non_unix.go @@ -1,8 +1,8 @@ -//+build !nosqlite3,cgo +//go:build !unix /* Maddy Mail Server - Composable all-in-one email server. -Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -18,6 +18,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -package table +package tests -import _ "github.com/mattn/go-sqlite3" +func (t *T) reloadConfig() { + t.Skip("Tests for config reload are not available") +} diff --git a/tests/reload_test.go b/tests/reload_test.go new file mode 100644 index 000000000..be3a479b6 --- /dev/null +++ b/tests/reload_test.go @@ -0,0 +1,255 @@ +//go:build unix && integration + +// Can't reload on Windows, yet + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package tests_test + +import ( + "testing" + "time" + + sqliteprovider "github.com/foxcpp/maddy/internal/sqlite" + "github.com/foxcpp/maddy/tests" +) + +func TestSmtpPipelineSwitch(tt *testing.T) { + if !sqliteprovider.IsTranspiled { + tt.Skip("Test is unstable with original SQLite") + } + + tt.Parallel() + t := tests.NewT(tt) + + t.DNS(nil) + t.Port("smtp") + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname maddy.test + tls off + + reject + } + `) + t.Run(1) + defer t.Close() + + conn1 := t.Conn("smtp") + defer conn1.Close() + conn1.SMTPNegotation("localhost", nil, nil) + conn1.Writeln("MAIL FROM:") + conn1.ExpectPattern("2*") + conn1.Writeln("RCPT TO:") + conn1.ExpectPattern("5*") // REJECTED + conn1.Writeln("RSET") + conn1.ExpectPattern("2*") + + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname maddy.test + tls off + + deliver_to dummy + } + `) + + conn2 := t.Conn("smtp") + defer conn2.Close() + conn2.SMTPNegotation("localhost", nil, nil) + conn2.Writeln("MAIL FROM:") + conn2.ExpectPattern("2*") + conn2.Writeln("RCPT TO:") + conn2.ExpectPattern("2*") + conn2.Writeln("DATA") + conn2.ExpectPattern("354 *") + conn2.Writeln("From: ") + conn2.Writeln("To: ") + conn2.Writeln("Subject: Hi!") + conn2.Writeln("") + conn2.Writeln("Hi!") + conn2.Writeln(".") + conn2.ExpectPattern("2*") // DISCARDED + + conn1.Writeln("MAIL FROM:") + conn1.ExpectPattern("2*") + conn1.Writeln("RCPT TO:") + conn1.ExpectPattern("5*") // Still REJECTED (running on old server). + conn1.Writeln("RSET") + conn1.ExpectPattern("2*") +} + +func TestImapStorageSwitch(tt *testing.T) { + if !sqliteprovider.IsTranspiled { + tt.Skip("Test is unstable with original SQLite") + } + + tt.Parallel() + t := tests.NewT(tt) + + t.DNS(nil) + t.Port("smtp") + t.Port("imap") + t.Config(` + storage.imapsql test_store { + driver sqlite3 + dsn imapsql.db + } + + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + tls off + + auth dummy + storage &test_store + } + + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname maddy.test + tls off + + deliver_to &test_store + } + `) + t.Run(1) + defer t.Close() + + imapConn := t.Conn("imap") + defer imapConn.Close() + imapConn.ExpectPattern(`\* OK *`) + imapConn.Writeln(". LOGIN testusr@maddy.test 1234") + imapConn.ExpectPattern(". OK *") + imapConn.Writeln(". SELECT INBOX") + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`\* *`) + imapConn.ExpectPattern(`. OK *`) + + conn1 := t.Conn("smtp") + defer conn1.Close() + conn1.SMTPNegotation("localhost", nil, nil) + conn1.Writeln("MAIL FROM:") + conn1.ExpectPattern("2*") + conn1.Writeln("RCPT TO:") + conn1.ExpectPattern("2*") + conn1.Writeln("DATA") + conn1.ExpectPattern("354 *") + conn1.Writeln("From: ") + conn1.Writeln("To: ") + conn1.Writeln("Subject: Store 1") + conn1.Writeln("") + conn1.Writeln("Hi!") + conn1.Writeln(".") + conn1.ExpectPattern("2*") // Goes to storage 1 + + t.Config(` + storage.imapsql test_store { + driver sqlite3 + dsn imapsql2.db + } + + imap tcp://127.0.0.1:{env:TEST_PORT_imap} { + tls off + + auth dummy + storage &test_store + } + + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname maddy.test + tls off + + deliver_to &test_store + } + `) + + imapConn2 := t.Conn("imap") + defer imapConn2.Close() + imapConn2.ExpectPattern(`\* OK *`) + imapConn2.Writeln(". LOGIN testusr2@maddy.test 1234") + imapConn2.ExpectPattern(". OK *") + + time.Sleep(500 * time.Millisecond) + + conn2 := t.Conn("smtp") + defer conn2.Close() + conn2.SMTPNegotation("localhost", nil, nil) + conn2.Writeln("MAIL FROM:") + conn2.ExpectPattern("2*") + conn2.Writeln("RCPT TO:") + conn2.ExpectPattern("2*") + conn2.Writeln("DATA") + conn2.ExpectPattern("354 *") + conn2.Writeln("From: ") + conn2.Writeln("To: ") + conn2.Writeln("Subject: Store 2") + conn2.Writeln("") + conn2.Writeln("Hi!") + conn2.Writeln(".") + conn2.ExpectPattern("2*") // Goes to storage 2 + + imapConn.Writeln(". NOOP") + imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) + imapConn.ExpectPattern(". OK *") + + // Old connection sees message in store 1. + imapConn.Writeln(". FETCH 1 (BODY.PEEK[])") + imapConn.ExpectPattern(`\* 1 FETCH (BODY\[\] {*}*`) + imapConn.Expect(`Delivered-To: testusr@maddy.test`) + imapConn.Expect(`Return-Path: `) + imapConn.ExpectPattern(`Received: from localhost (client.maddy.test \[` + tests.DefaultSourceIP.String() + `\]) by maddy.test`) + imapConn.ExpectPattern(` (envelope-sender ) with ESMTP id *; *`) + imapConn.ExpectPattern(` *`) + imapConn.Expect("From: ") + imapConn.Expect("To: ") + imapConn.Expect("Subject: Store 1") + imapConn.Expect("") + imapConn.Expect("Hi!") + imapConn.Expect(")") + imapConn.ExpectPattern(`. OK *`) + + // New connection sees message in store 2. + imapConn2.Writeln(". SELECT INBOX") + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`\* *`) + imapConn2.ExpectPattern(`. OK *`) + imapConn2.Writeln(". FETCH 1 (BODY.PEEK[])") + imapConn2.ExpectPattern(`\* 1 FETCH (BODY\[\] {*}*`) + imapConn2.Expect(`Delivered-To: testusr2@maddy.test`) + imapConn2.Expect(`Return-Path: `) + imapConn2.ExpectPattern(`Received: from localhost (client.maddy.test \[` + tests.DefaultSourceIP.String() + `\]) by maddy.test`) + imapConn2.ExpectPattern(` (envelope-sender ) with ESMTP id *; *`) + imapConn2.ExpectPattern(` *`) + imapConn2.Expect("From: ") + imapConn2.Expect("To: ") + imapConn2.Expect("Subject: Store 2") + imapConn2.Expect("") + imapConn2.Expect("Hi!") + imapConn2.Expect(")") + imapConn2.ExpectPattern(`. OK *`) + +} diff --git a/tests/reload_unix.go b/tests/reload_unix.go new file mode 100644 index 000000000..303f4e48b --- /dev/null +++ b/tests/reload_unix.go @@ -0,0 +1,42 @@ +//go:build unix + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package tests + +import ( + "syscall" + "time" +) + +func (t *T) reloadConfig() { + err := t.servProc.Process.Signal(syscall.SIGUSR2) + if err != nil { + t.Fatal("Failed to send SIGUSR2:", err) + } + + t.Log("waiting for server to reload...") + + select { + case <-t.reloadedchan: + case <-time.after(5 * time.Second): + t.killServer() + t.Fatal("Server reload is taking too long, killed") + } +} diff --git a/tests/replace_addr_test.go b/tests/replace_addr_test.go index fb1d871e6..c8980714d 100644 --- a/tests/replace_addr_test.go +++ b/tests/replace_addr_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. diff --git a/tests/smtp_autobuffer_test.go b/tests/smtp_autobuffer_test.go index 1b00399fb..58ef341e5 100644 --- a/tests/smtp_autobuffer_test.go +++ b/tests/smtp_autobuffer_test.go @@ -1,4 +1,5 @@ -//+build integration,cgo,!nosqlite3 +//go:build integration && cgo && !nosqlite3 +// +build integration,cgo,!nosqlite3 /* Maddy Mail Server - Composable all-in-one email server. @@ -98,6 +99,7 @@ func TestSMTPEndpoint_LargeMessage(tt *testing.T) { imapConn.Writeln(". NOOP") imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) imapConn.ExpectPattern(". OK *") imapConn.Writeln(". FETCH 1 (BODY.PEEK[])") @@ -185,6 +187,7 @@ func TestSMTPEndpoint_FileBuffer(tt *testing.T) { imapConn.Writeln(". NOOP") imapConn.ExpectPattern(`\* 1 EXISTS`) + imapConn.ExpectPattern(`\* 1 RECENT`) imapConn.ExpectPattern(". OK *") imapConn.Writeln(". FETCH 1 (BODY.PEEK[])") @@ -300,9 +303,8 @@ func TestSMTPEndpoint_Autobuffer(tt *testing.T) { imapConn.Writeln(". NOOP") // This will break with go-imap v2 upgrade merging updates. - imapConn.ExpectPattern(`\* 1 EXISTS`) - imapConn.ExpectPattern(`\* 2 EXISTS`) imapConn.ExpectPattern(`\* 3 EXISTS`) + imapConn.ExpectPattern(`\* 3 RECENT`) imapConn.ExpectPattern(". OK *") imapConn.Writeln(". FETCH 1:3 (BODY.PEEK[])") diff --git a/tests/smtp_test.go b/tests/smtp_test.go index ed1bee424..d89722725 100644 --- a/tests/smtp_test.go +++ b/tests/smtp_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -22,6 +23,7 @@ package tests_test import ( "errors" + "fmt" "io/ioutil" "path/filepath" "strings" @@ -67,6 +69,94 @@ func TestCheckRequireTLS(tt *testing.T) { conn.ExpectPattern("221 *") } +func TestProxyProtocolTrustedSource(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(map[string]mockdns.Zone{ + "one.maddy.test.": { + TXT: []string{"v=spf1 ip4:127.0.0.17 -all"}, + }, + }) + t.Port("smtp") + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname mx.maddy.test + tls off + + proxy_protocol { + trust ` + tests.DefaultSourceIP.String() + ` ::1/128 + tls off + } + + defer_sender_reject no + + check { + spf { + enforce_early yes + fail_action reject + } + } + + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + conn := t.Conn("smtp") + defer conn.Close() + conn.Writeln(fmt.Sprintf("PROXY TCP4 127.0.0.17 %s 12345 %d", tests.DefaultSourceIP.String(), t.Port("smtp"))) + conn.SMTPNegotation("localhost", nil, nil) + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("250 *") + conn.Writeln("QUIT") + conn.ExpectPattern("221 *") +} + +func TestProxyProtocolUntrustedSource(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + t.DNS(map[string]mockdns.Zone{ + "one.maddy.test.": { + TXT: []string{"v=spf1 ip4:127.0.0.17 -all"}, + }, + }) + t.Port("smtp") + t.Config(` + smtp tcp://127.0.0.1:{env:TEST_PORT_smtp} { + hostname mx.maddy.test + tls off + + proxy_protocol { + trust fe80::bad/128 + tls off + } + + defer_sender_reject no + + check { + spf { + enforce_early yes + fail_action reject + } + } + + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + conn := t.Conn("smtp") + defer conn.Close() + conn.Writeln(fmt.Sprintf("PROXY TCP4 127.0.0.17 %s 12345 %d", tests.DefaultSourceIP.String(), t.Port("smtp"))) + conn.SMTPNegotation("localhost", nil, nil) + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("550 *") + conn.Writeln("QUIT") + conn.ExpectPattern("221 *") +} + func TestCheckSPF(tt *testing.T) { tt.Parallel() t := tests.NewT(tt) @@ -121,13 +211,22 @@ func TestCheckSPF(tt *testing.T) { conn := t.Conn("smtp") defer conn.Close() - conn.SMTPNegotation("localhost", nil, nil) + conn.SMTPNegotation("fail.maddy.test", nil, nil) conn.Writeln("MAIL FROM:") conn.ExpectPattern("250 *") conn.Writeln("RSET") conn.ExpectPattern("250 *") + // Actually checks fail.maddy.test. + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("552 5.7.0 *") + + conn.SMTPNegotation("pass.maddy.test", nil, nil) + + conn.Writeln("MAIL FROM:") + conn.ExpectPattern("250 *") + conn.Writeln("MAIL FROM:") conn.ExpectPattern("551 5.7.0 *") @@ -363,7 +462,7 @@ func TestCheckAuthorizeSender(tt *testing.T) { auth_normalize precis_casefold user_to_email static { entry "test-user1" "test@example1.org" - entry "test-user2" "é@example1.org" + entry "test-user2" "é@example1.org" } } } diff --git a/tests/stress_test.go b/tests/stress_test.go index 93be9a0ec..74ed268ba 100644 --- a/tests/stress_test.go +++ b/tests/stress_test.go @@ -1,4 +1,5 @@ -//+build integration +//go:build integration +// +build integration /* Maddy Mail Server - Composable all-in-one email server. @@ -59,7 +60,7 @@ func TestSMTPFlood_FullMsg_NoLimits_1Conn(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "DATA", "From: ", @@ -102,7 +103,7 @@ func TestSMTPFlood_FullMsg_NoLimits_10Conns(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "DATA", "From: ", @@ -150,7 +151,7 @@ func TestSMTPFlood_EnvelopeAbort_NoLimits_10Conns(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "RSET", }, []string{ @@ -201,7 +202,7 @@ func TestSMTPFlood_EnvelopeAbort_Ratelimited(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "RSET", }, []string{ @@ -264,7 +265,7 @@ func TestSMTPFlood_FullMsg_Ratelimited_PerSource(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "DATA", "From: ", @@ -291,7 +292,7 @@ func TestSMTPFlood_FullMsg_Ratelimited_PerSource(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "DATA", "From: ", @@ -363,7 +364,7 @@ func TestSMTPFlood_EnvelopeAbort_Ratelimited_PerIP(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "RSET", }, []string{ @@ -382,7 +383,7 @@ func TestSMTPFlood_EnvelopeAbort_Ratelimited_PerIP(tt *testing.T) { defer c.Close() c.SMTPNegotation("helo.maddy.test", nil, nil) floodSmtp(&c, []string{ - "MAIL FROM:", "RCPT TO:", "RSET", }, []string{ diff --git a/tests/t.go b/tests/t.go index 4b08a476d..a2795e7b5 100644 --- a/tests/t.go +++ b/tests/t.go @@ -25,9 +25,9 @@ package tests import ( "bufio" + "bytes" "flag" "fmt" - "io/ioutil" "math/rand" "net" "os" @@ -35,10 +35,13 @@ import ( "path/filepath" "strconv" "strings" + "sync" "testing" "time" "github.com/foxcpp/go-mockdns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var ( @@ -59,13 +62,16 @@ type T struct { portsRev map[uint16]string servProc *exec.Cmd + + reloadedChan chan struct{} } func NewT(t *testing.T) *T { return &T{ - T: t, - ports: map[string]uint16{}, - portsRev: map[uint16]string{}, + T: t, + ports: map[string]uint16{}, + portsRev: map[uint16]string{}, + reloadedChan: make(chan struct{}, 1), } } @@ -74,11 +80,21 @@ func NewT(t *testing.T) *T { func (t *T) Config(cfg string) { t.Helper() + t.cfg = cfg + if t.servProc != nil { - panic("tests: Config called after Run") - } + t.Log("Reloading configuration for running server...") - t.cfg = cfg + configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" + + "runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n" + + err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm) + if err != nil { + t.Fatal("Test configuration failed:", err) + } + + t.reloadConfig() + } } // DNS sets the DNS zones to emulate for the tested server instance. @@ -97,15 +113,28 @@ func (t *T) DNS(zones map[string]mockdns.Zone) { if t.dnsServ != nil { t.Log("NOTE: Multiple DNS calls, replacing the server instance...") - t.dnsServ.Close() + require.NoError(t, t.dnsServ.Close()) } - dnsServ, err := mockdns.NewServer(zones, false) + dnsServ, err := mockdns.NewServerWithLogger(zones, t, false) if err != nil { t.Fatal("Test configuration failed:", err) } dnsServ.Log = t t.dnsServ = dnsServ + + t.Cleanup(func() { + if t.dnsServ == nil { + return + } + + // Shutdown the DNS server after maddy to make sure it will not spend time + // timing out queries. + if err := t.dnsServ.Close(); err != nil { + t.Log("Unable to stop the DNS server:", err) + } + t.dnsServ = nil + }) } // Port allocates the random TCP port for use by test. It will made accessible @@ -130,14 +159,7 @@ func (t *T) Env(kv string) { t.env = append(t.env, kv) } -// Run completes the configuration of test environment and starts the test server. -// -// T.Close should be called by the end of test to release any resources and -// shutdown the server. -// -// The parameter waitListeners specifies the amount of listeners the server is -// supposed to configure. Run() will block before all of them are up. -func (t *T) Run(waitListeners int) { +func (t *T) ensureCanRun() { if t.cfg == "" { panic("tests: Run called without configuration set") } @@ -150,63 +172,63 @@ func (t *T) Run(waitListeners int) { } // Setup file system, create statedir, runtimedir, write out config. - testDir, err := ioutil.TempDir("", "maddy-tests-") - if err != nil { - t.Fatal("Test configuration failed:", err) - } - t.testDir = testDir - - t.Log("Using", t.testDir) - - defer func() { - if !t.Failed() { - return + if t.testDir == "" { + testDir, err := os.MkdirTemp("", "maddy-tests-") + if err != nil { + t.Fatal("Test configuration failed:", err) } + t.testDir = testDir + t.Log("using", t.testDir) - // Clean-up on test failure (if Run failed somewhere) - - t.dnsServ.Close() - t.dnsServ = nil + if err := os.MkdirAll(filepath.Join(t.testDir, "statedir"), os.ModePerm); err != nil { + t.Fatal("Test configuration failed:", err) + } + if err := os.MkdirAll(filepath.Join(t.testDir, "runtimedir"), os.ModePerm); err != nil { + t.Fatal("Test configuration failed:", err) + } - os.RemoveAll(t.testDir) - t.testDir = "" - }() + t.Cleanup(func() { + if !t.Failed() { + return + } - if err := os.MkdirAll(filepath.Join(t.testDir, "statedir"), os.ModePerm); err != nil { - t.Fatal("Test configuration failed:", err) - } - if err := os.MkdirAll(filepath.Join(t.testDir, "runtimedir"), os.ModePerm); err != nil { - t.Fatal("Test configuration failed:", err) + t.Log("removing", t.testDir) + assert.NoError(t, os.RemoveAll(t.testDir)) + t.testDir = "" + }) } configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" + - "runtime_dir " + filepath.Join(t.testDir, "runtime") + "\n\n" + "runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n" - err = ioutil.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm) + err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm) if err != nil { t.Fatal("Test configuration failed:", err) } +} +func (t *T) buildCmd(additionalArgs ...string) *exec.Cmd { // Assigning 0 by default will make outbound SMTP unusable. remoteSmtp := "0" if port := t.ports["remote_smtp"]; port != 0 { remoteSmtp = strconv.Itoa(int(port)) } - cmd := exec.Command(TestBinary, - "-config", filepath.Join(t.testDir, "maddy.conf"), + args := []string{"-config", filepath.Join(t.testDir, "maddy.conf"), "-debug.smtpport", remoteSmtp, "-debug.dnsoverride", t.dnsServ.LocalAddr().String(), - "-log", "stderr") + } if CoverageOut != "" { - cmd.Args = append(cmd.Args, "-test.coverprofile", CoverageOut+"."+strconv.FormatInt(time.Now().UnixNano(), 16)) + args = append(args, "-test.coverprofile", CoverageOut+"."+strconv.FormatInt(time.Now().UnixNano(), 16)) } if DebugLog { - cmd.Args = append(cmd.Args, "-debug") + args = append(args, "-debug") } - t.Logf("launching %v", cmd.Args) + args = append(args, additionalArgs...) + + cmd := exec.Command(TestBinary, args...) pwd, err := os.Getwd() if err != nil { @@ -218,53 +240,120 @@ func (t *T) Run(waitListeners int) { cmd.Env = append(cmd.Env, "TEST_PWD="+pwd, "TEST_STATE_DIR="+filepath.Join(t.testDir, "statedir"), - "TEST_RUNTIME_DIR="+filepath.Join(t.testDir, "statedir"), + "TEST_RUNTIME_DIR="+filepath.Join(t.testDir, "runtimedir"), ) for name, port := range t.ports { cmd.Env = append(cmd.Env, fmt.Sprintf("TEST_PORT_%s=%d", name, port)) } cmd.Env = append(cmd.Env, t.env...) + return cmd +} + +func (t *T) MustRunCLIGroup(args ...[]string) { + t.ensureCanRun() + + wg := sync.WaitGroup{} + for _, arg := range args { + wg.Add(1) + go func() { + defer wg.Done() + + _, err := t.RunCLI(arg...) + if err != nil { + t.Printf("maddy %v: %v", arg, err) + t.Fail() + } + }() + } + wg.Wait() +} + +func (t *T) MustRunCLI(args ...string) string { + s, err := t.RunCLI(args...) + if err != nil { + t.Fatalf("maddy %v: %v", args, err) + } + return s +} + +func (t *T) RunCLI(args ...string) (string, error) { + t.ensureCanRun() + cmd := t.buildCmd(args...) + + var stderr, stdout bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = &stdout + + t.Log("launching maddy", cmd.Args) + if err := cmd.Run(); err != nil { + t.Log("Stderr:", stderr.String()) + t.Fatal("Test configuration failed:", err) + } + + t.Log("Stderr:", stderr.String()) + + return stdout.String(), nil +} + +// Run completes the configuration of test environment and starts the test server. +// +// T.Close should be called by the end of test to release any resources and +// shutdown the server. +// +// The parameter waitListeners specifies the amount of listeners the server is +// supposed to configure. Run() will block before all of them are up. +func (t *T) Run(waitListeners int) { + t.ensureCanRun() + cmd := t.buildCmd("run") + // Capture maddy log and redirect it. logOut, err := cmd.StderrPipe() if err != nil { t.Fatal("Test configuration failed:", err) } + t.Log("launching maddy", cmd.Args) if err := cmd.Start(); err != nil { t.Fatal("Test configuration failed:", err) } - // Log scanning goroutine checks for the "listening" messages and sends 'true' - // on the channel each time. - listeningMsg := make(chan bool) + serverStarted := make(chan bool) go func() { - defer logOut.Close() - defer close(listeningMsg) + defer close(serverStarted) scnr := bufio.NewScanner(logOut) for scnr.Scan() { line := scnr.Text() - if strings.Contains(line, "listening on") { - listeningMsg <- true - line += " (test runner>listener wait trigger<)" + t.Log("maddy:", line) + + if strings.HasPrefix(line, "server started") { + serverStarted <- true } - t.Log("maddy:", line) + if strings.HasPrefix(line, "new server started") { + select { + case t.reloadedChan <- struct{}{}: + t.Log("server reload confirmed, continuing test") + default: + t.Log("unexpected reloads detected") + t.Fail() + } + } } if err := scnr.Err(); err != nil { t.Log("stderr I/O error:", err) } }() - for i := 0; i < waitListeners; i++ { - if !<-listeningmsg { - t.Fatal("Log ended before all expected listeners are up. Start-up error?") - } + if !<-serverstarted { + t.Fatal("Log ended before all expected listeners are up. Start-up error?") } t.servProc = cmd + + t.Cleanup(t.killServer) } func (t *T) StateDir() string { @@ -275,10 +364,10 @@ func (t *T) RuntimeDir() string { return filepath.Join(t.testDir, "statedir") } -func (t *T) Close() { +func (t *T) killServer() { if err := t.servProc.Process.Signal(os.Interrupt); err != nil { t.Log("Unable to kill the server process:", err) - os.RemoveAll(t.testDir) + assert.NoError(t, os.RemoveAll(t.testDir)) return // Return, as now it is pointless to wait for it. } @@ -300,13 +389,10 @@ func (t *T) Close() { t.Log("Failed to remove test directory:", err) } t.testDir = "" +} - // Shutdown the DNS server after maddy to make sure it will not spend time - // timing out queries. - if err := t.dnsServ.Close(); err != nil { - t.Log("Unable to stop the DNS server:", err) - } - t.dnsServ = nil +func (t *T) Close() { + t.Log("close is no-op") } // Printf implements Logger interfaces used by some libraries.

AltStyle によって変換されたページ (->オリジナル) /