Merge branch 'master' into gnous
This commit is contained in:
commit
0817b2b716
10
.github/ISSUE_TEMPLATE/Bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/Bug_report.yml
vendored
@ -62,6 +62,16 @@ body:
|
||||
- nightly
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: "Which architecture are you using?"
|
||||
description: "#### `uname -m`"
|
||||
multiple: false
|
||||
options:
|
||||
- x86
|
||||
- ARM64 (aarch64)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Operating System:"
|
||||
|
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -1,8 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ❓ Community-driven support
|
||||
- name: ❓ Community-driven support (Free)
|
||||
url: https://docs.mailcow.email/#get-support
|
||||
about: Please use the community forum for questions or assistance
|
||||
- name: 🔥 Premium Support (Paid)
|
||||
url: https://www.servercow.de/mailcow?lang=en#support
|
||||
about: Buy a support subscription for any critical issues and get assisted by the mailcow Team. See conditions!
|
||||
- name: 🚨 Report a security vulnerability
|
||||
url: https://www.servercow.de/anfrage?lang=en
|
||||
url: "mailto:info@servercow.de?subject=mailcow: dockerized Security Vulnerability"
|
||||
about: Please give us appropriate time to verify, respond and fix before disclosure.
|
||||
|
37
.github/workflows/check_if_support_labeled.yml
vendored
Normal file
37
.github/workflows/check_if_support_labeled.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Check if labeled support, if so send message and close issue
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
jobs:
|
||||
add-comment:
|
||||
if: github.event.label.name == 'support'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add comment
|
||||
run: gh issue comment "$NUMBER" --body "$BODY"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SUPPORTISSUES_ACTION_PAT }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
BODY: |
|
||||
**THIS IS A AUTOMATED MESSAGE!**
|
||||
|
||||
It seems your issue is not a bug.
|
||||
Therefore we highly advise you to get support!
|
||||
|
||||
You can get support either by:
|
||||
- ordering a paid [support contract at Servercow](https://www.servercow.de/mailcow?lang=en#support/) (Directly from the developers) or
|
||||
- using the [community forum](https://community.mailcow.email) (**Based on volunteers! NO guaranteed answer**) or
|
||||
- using the [Telegram support channel](https://t.me/mailcow) (**Based on volunteers! NO guaranteed answer**)
|
||||
|
||||
This issue will be closed. If you think your reported issue is not a support case feel free to comment above and if so the issue will reopened.
|
||||
|
||||
- name: Close issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SUPPORTISSUES_ACTION_PAT }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: gh issue close "$NUMBER" -r "not planned"
|
@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||
uses: actions/stale@v8.0.0
|
||||
uses: actions/stale@v9.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||
days-before-stale: 60
|
||||
|
@ -22,7 +22,7 @@ jobs:
|
||||
bash helper-scripts/update_postscreen_whitelist.sh
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
|
||||
commit-message: update postscreen_access.cidr
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,6 +13,7 @@ data/conf/dovecot/acl_anyone
|
||||
data/conf/dovecot/dovecot-master.passwd
|
||||
data/conf/dovecot/dovecot-master.userdb
|
||||
data/conf/dovecot/extra.conf
|
||||
data/conf/dovecot/mail_replica.conf
|
||||
data/conf/dovecot/global_sieve_*
|
||||
data/conf/dovecot/last_login
|
||||
data/conf/dovecot/lua
|
||||
|
@ -1,5 +1,33 @@
|
||||
When a problem occurs, then always for a reason! What you want to do in such a case is:
|
||||
# Contribution Guidelines (Last modified on 18th December 2023)
|
||||
|
||||
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
|
||||
|
||||
## Pull Requests (Last modified on 18th December 2023)
|
||||
|
||||
However, please note the following regarding pull requests:
|
||||
|
||||
1. **ALWAYS** create your PR using the staging branch of your locally cloned mailcow instance, as the pull request will end up in said staging branch of mailcow once approved. Ideally, you should simply create a new branch for your pull request that is named after the type of your PR (e.g. `feat/` for function updates or `fix/` for bug fixes) and the actual content (e.g. `sogo-6.0.0` for an update from SOGo to version 6 or `html-escape` for a fix that includes escaping HTML in mailcow).
|
||||
2. Please **keep** this pull request branch **clean** and free of commits that have nothing to do with the changes you have made (e.g. commits from other users from other branches). *If you make changes to the `update.sh` script or other scripts that trigger a commit, there is usually a developer mode for clean working in this case.
|
||||
3. **Test your changes before you commit them as a pull request.** <ins>If possible</ins>, write a small **test log** or demonstrate the functionality with a **screenshot or GIF**. *We will of course also test your pull request ourselves, but proof from you will save us the question of whether you have tested your own changes yourself.*
|
||||
4. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
|
||||
5. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
|
||||
6. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
|
||||
|
||||
---
|
||||
|
||||
## Issue Reporting (Last modified on 18th December 2023)
|
||||
|
||||
If you plan to report a issue within mailcow please read and understand the following rules:
|
||||
|
||||
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).
|
||||
2. **ONLY** report an error if you have the **necessary know-how (at least the basics)** for the administration of an e-mail server and the usage of Docker. mailcow is a complex and fully-fledged e-mail server including groupware components on a Docker basement and it requires a bit of technical know-how for debugging and operating.
|
||||
3. **ONLY** report bugs that are contained in the latest mailcow release series. *The definition of the latest release series includes the last major patch (e.g. 2023-12) and all minor patches (revisions) below it (e.g. 2023-12a, b, c etc.).* New issue reports published starting from January 1, 2024 must meet this criterion, as versions below the latest releases are no longer supported by us.
|
||||
4. When reporting a problem, please be as detailed as possible and include even the smallest changes to your mailcow installation. Simply fill out the corresponding bug report form in detail and accurately to minimize possible questions.
|
||||
5. **Before you open an issue/feature request**, please first check whether a similar request already exists in the mailcow tracker on GitHub. If so, please include yourself in this request.
|
||||
6. When you create a issue/feature request: Please note that the creation does <ins>**not guarantee an instant implementation or fix by the mailcow team or the community**</ins>.
|
||||
7. Please **ALWAYS** anonymize any sensitive information in your bug report or feature request before submitting it.
|
||||
|
||||
### Quick guide to reporting problems:
|
||||
1. Read your logs; follow them to see what the reason for your problem is.
|
||||
2. Follow the leads given to you in your logfiles and start investigating.
|
||||
3. Restarting the troubled service or the whole stack to see if the problem persists.
|
||||
@ -7,3 +35,5 @@ When a problem occurs, then always for a reason! What you want to do in such a c
|
||||
5. Search our [issues](https://github.com/mailcow/mailcow-dockerized/issues) for your problem.
|
||||
6. [Create an issue](https://github.com/mailcow/mailcow-dockerized/issues/new/choose) over at our GitHub repository if you think your problem might be a bug or a missing feature you badly need. But please make sure, that you include **all the logs** and a full description to your problem.
|
||||
7. Ask your questions in our community-driven [support channels](https://docs.mailcow.email/#community-support-and-chat).
|
||||
|
||||
## When creating an issue/feature request or a pull request, you will be asked to confirm these guidelines.
|
@ -2,6 +2,8 @@
|
||||
|
||||
[](https://translate.mailcow.email/engage/mailcow-dockerized/)
|
||||
[](https://twitter.com/mailcow_email)
|
||||

|
||||
|
||||
|
||||
## Want to support mailcow?
|
||||
|
||||
@ -27,6 +29,8 @@ Please see [the official documentation](https://docs.mailcow.email/) for install
|
||||
|
||||
[Official 𝕏 (Twitter) Account](https://twitter.com/mailcow_email)
|
||||
|
||||
[Official Mastodon Account](https://mailcow.social/@doncow)
|
||||
|
||||
Telegram desktop clients are available for [multiple platforms](https://desktop.telegram.org). You can search the groups history for keywords.
|
||||
|
||||
## Misc
|
||||
|
@ -1,7 +1,8 @@
|
||||
FROM alpine:3.17
|
||||
FROM alpine:3.18
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
bash \
|
||||
|
@ -1,12 +1,14 @@
|
||||
FROM clamav/clamav:1.0.3_base
|
||||
FROM alpine:3.19
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
rsync \
|
||||
clamav \
|
||||
bind-tools \
|
||||
bash
|
||||
bash \
|
||||
tini
|
||||
|
||||
# init
|
||||
COPY clamd.sh /clamd.sh
|
||||
@ -14,7 +16,9 @@ RUN chmod +x /sbin/tini
|
||||
|
||||
# healthcheck
|
||||
COPY healthcheck.sh /healthcheck.sh
|
||||
COPY clamdcheck.sh /usr/local/bin
|
||||
RUN chmod +x /healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
||||
|
||||
ENTRYPOINT []
|
||||
|
14
data/Dockerfiles/clamd/clamdcheck.sh
Normal file
14
data/Dockerfiles/clamd/clamdcheck.sh
Normal file
@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
if [ "${CLAMAV_NO_CLAMD:-}" != "false" ]; then
|
||||
if [ "$(echo "PING" | nc localhost 3310)" != "PONG" ]; then
|
||||
echo "ERROR: Unable to contact server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Clamd is up"
|
||||
fi
|
||||
|
||||
exit 0
|
@ -1,7 +1,8 @@
|
||||
FROM alpine:3.17
|
||||
FROM alpine:3.19
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --update --no-cache python3 \
|
||||
@ -9,12 +10,13 @@ RUN apk add --update --no-cache python3 \
|
||||
openssl \
|
||||
tzdata \
|
||||
py3-psutil \
|
||||
py3-redis \
|
||||
py3-async-timeout \
|
||||
&& pip3 install --upgrade pip \
|
||||
fastapi \
|
||||
uvicorn \
|
||||
aiodocker \
|
||||
docker \
|
||||
aioredis
|
||||
docker
|
||||
RUN mkdir /app/modules
|
||||
|
||||
COPY docker-entrypoint.sh /app/
|
||||
|
@ -5,16 +5,63 @@ import json
|
||||
import uuid
|
||||
import async_timeout
|
||||
import asyncio
|
||||
import aioredis
|
||||
import aiodocker
|
||||
import docker
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from fastapi import FastAPI, Response, Request
|
||||
from modules.DockerApi import DockerApi
|
||||
from redis import asyncio as aioredis
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
dockerapi = None
|
||||
app = FastAPI()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global dockerapi
|
||||
|
||||
# Initialize a custom logger
|
||||
logger = logging.getLogger("dockerapi")
|
||||
logger.setLevel(logging.INFO)
|
||||
# Configure the logger to output logs to the terminal
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("Init APP")
|
||||
|
||||
# Init redis client
|
||||
if os.environ['REDIS_SLAVEOF_IP'] != "":
|
||||
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0")
|
||||
else:
|
||||
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0")
|
||||
|
||||
# Init docker clients
|
||||
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
|
||||
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
|
||||
|
||||
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
|
||||
|
||||
logger.info("Subscribe to redis channel")
|
||||
# Subscribe to redis channel
|
||||
dockerapi.pubsub = redis.pubsub()
|
||||
await dockerapi.pubsub.subscribe("MC_CHANNEL")
|
||||
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
|
||||
|
||||
|
||||
yield
|
||||
|
||||
# Close docker connections
|
||||
dockerapi.sync_docker_client.close()
|
||||
await dockerapi.async_docker_client.close()
|
||||
|
||||
# Close redis
|
||||
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
|
||||
await dockerapi.redis_client.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Define Routes
|
||||
@app.get("/host/stats")
|
||||
@ -145,52 +192,6 @@ async def post_container_update_stats(container_id : str):
|
||||
stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
|
||||
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||
|
||||
# Events
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
global dockerapi
|
||||
|
||||
# Initialize a custom logger
|
||||
logger = logging.getLogger("dockerapi")
|
||||
logger.setLevel(logging.INFO)
|
||||
# Configure the logger to output logs to the terminal
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("Init APP")
|
||||
|
||||
# Init redis client
|
||||
if os.environ['REDIS_SLAVEOF_IP'] != "":
|
||||
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0")
|
||||
else:
|
||||
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0")
|
||||
|
||||
# Init docker clients
|
||||
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
|
||||
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
|
||||
|
||||
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
|
||||
|
||||
logger.info("Subscribe to redis channel")
|
||||
# Subscribe to redis channel
|
||||
dockerapi.pubsub = redis.pubsub()
|
||||
await dockerapi.pubsub.subscribe("MC_CHANNEL")
|
||||
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
global dockerapi
|
||||
|
||||
# Close docker connections
|
||||
dockerapi.sync_docker_client.close()
|
||||
await dockerapi.async_docker_client.close()
|
||||
|
||||
# Close redis
|
||||
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
|
||||
await dockerapi.redis_client.close()
|
||||
|
||||
# PubSub Handler
|
||||
async def handle_pubsub_messages(channel: aioredis.client.PubSub):
|
||||
|
@ -1,119 +1,115 @@
|
||||
FROM debian:bullseye-slim
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
# renovate: datasource=github-tags depName=dovecot/core versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG DOVECOT=2.3.21
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.16
|
||||
ENV LC_ALL C
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV LC_ALL C.UTF-8
|
||||
|
||||
# Add groups and users before installing Dovecot to not break compatibility
|
||||
RUN groupadd -g 5000 vmail \
|
||||
&& groupadd -g 401 dovecot \
|
||||
&& groupadd -g 402 dovenull \
|
||||
&& groupadd -g 999 sogo \
|
||||
&& usermod -a -G sogo nobody \
|
||||
&& useradd -g vmail -u 5000 vmail -d /var/vmail \
|
||||
&& useradd -c "Dovecot unprivileged user" -d /dev/null -u 401 -g dovecot -s /bin/false dovecot \
|
||||
&& useradd -c "Dovecot login user" -d /dev/null -u 402 -g dovenull -s /bin/false dovenull \
|
||||
&& touch /etc/default/locale \
|
||||
&& apt-get update \
|
||||
&& apt-get -y --no-install-recommends install \
|
||||
build-essential \
|
||||
apt-transport-https \
|
||||
RUN addgroup -g 5000 vmail \
|
||||
&& addgroup -g 401 dovecot \
|
||||
&& addgroup -g 402 dovenull \
|
||||
&& sed -i "s/999/99/" /etc/group \
|
||||
&& addgroup -g 999 sogo \
|
||||
&& addgroup nobody sogo \
|
||||
&& adduser -D -u 5000 -G vmail -h /var/vmail vmail \
|
||||
&& adduser -D -G dovecot -u 401 -h /dev/null -s /sbin/nologin dovecot \
|
||||
&& adduser -D -G dovenull -u 402 -h /dev/null -s /sbin/nologin dovenull \
|
||||
&& apk add --no-cache --update \
|
||||
bash \
|
||||
bind-tools \
|
||||
findutils \
|
||||
envsubst \
|
||||
ca-certificates \
|
||||
cpanminus \
|
||||
curl \
|
||||
dnsutils \
|
||||
dirmngr \
|
||||
gettext \
|
||||
gnupg2 \
|
||||
jq \
|
||||
libauthen-ntlm-perl \
|
||||
libcgi-pm-perl \
|
||||
libcrypt-openssl-rsa-perl \
|
||||
libcrypt-ssleay-perl \
|
||||
libdata-uniqid-perl \
|
||||
libdbd-mysql-perl \
|
||||
libdbi-perl \
|
||||
libdigest-hmac-perl \
|
||||
libdist-checkconflicts-perl \
|
||||
libencode-imaputf7-perl \
|
||||
libfile-copy-recursive-perl \
|
||||
libfile-tail-perl \
|
||||
libhtml-parser-perl \
|
||||
libio-compress-perl \
|
||||
libio-socket-inet6-perl \
|
||||
libio-socket-ssl-perl \
|
||||
libio-tee-perl \
|
||||
libipc-run-perl \
|
||||
libjson-webtoken-perl \
|
||||
liblockfile-simple-perl \
|
||||
libmail-imapclient-perl \
|
||||
libmodule-implementation-perl \
|
||||
libmodule-scandeps-perl \
|
||||
libnet-ssleay-perl \
|
||||
libpackage-stash-perl \
|
||||
libpackage-stash-xs-perl \
|
||||
libpar-packer-perl \
|
||||
libparse-recdescent-perl \
|
||||
libproc-processtable-perl \
|
||||
libreadonly-perl \
|
||||
libregexp-common-perl \
|
||||
libssl-dev \
|
||||
libsys-meminfo-perl \
|
||||
libterm-readkey-perl \
|
||||
libtest-deep-perl \
|
||||
libtest-fatal-perl \
|
||||
libtest-mock-guard-perl \
|
||||
libtest-mockobject-perl \
|
||||
libtest-nowarnings-perl \
|
||||
libtest-pod-perl \
|
||||
libtest-requires-perl \
|
||||
libtest-simple-perl \
|
||||
libtest-warn-perl \
|
||||
libtry-tiny-perl \
|
||||
libunicode-string-perl \
|
||||
liburi-perl \
|
||||
libwww-perl \
|
||||
lua-sql-mysql \
|
||||
lua \
|
||||
lua-cjson \
|
||||
lua-socket \
|
||||
lua-sql-mysql \
|
||||
lua5.3-sql-mysql \
|
||||
icu-data-full \
|
||||
mariadb-connector-c \
|
||||
gcompat \
|
||||
mariadb-client \
|
||||
perl \
|
||||
perl-ntlm \
|
||||
perl-cgi \
|
||||
perl-crypt-openssl-rsa \
|
||||
perl-utils \
|
||||
perl-crypt-ssleay \
|
||||
perl-data-uniqid \
|
||||
perl-dbd-mysql \
|
||||
perl-dbi \
|
||||
perl-digest-hmac \
|
||||
perl-dist-checkconflicts \
|
||||
perl-encode-imaputf7 \
|
||||
perl-file-copy-recursive \
|
||||
perl-file-tail \
|
||||
perl-io-socket-inet6 \
|
||||
perl-io-gzip \
|
||||
perl-io-socket-ssl \
|
||||
perl-io-tee \
|
||||
perl-ipc-run \
|
||||
perl-json-webtoken \
|
||||
perl-mail-imapclient \
|
||||
perl-module-implementation \
|
||||
perl-module-scandeps \
|
||||
perl-net-ssleay \
|
||||
perl-package-stash \
|
||||
perl-package-stash-xs \
|
||||
perl-par-packer \
|
||||
perl-parse-recdescent \
|
||||
perl-lockfile-simple --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ \
|
||||
libproc \
|
||||
perl-readonly \
|
||||
perl-regexp-common \
|
||||
perl-sys-meminfo \
|
||||
perl-term-readkey \
|
||||
perl-test-deep \
|
||||
perl-test-fatal \
|
||||
perl-test-mockobject \
|
||||
perl-test-mock-guard \
|
||||
perl-test-pod \
|
||||
perl-test-requires \
|
||||
perl-test-simple \
|
||||
perl-test-warn \
|
||||
perl-try-tiny \
|
||||
perl-unicode-string \
|
||||
perl-proc-processtable \
|
||||
perl-app-cpanminus \
|
||||
procps \
|
||||
python3-pip \
|
||||
redis-server \
|
||||
supervisor \
|
||||
python3 \
|
||||
py3-mysqlclient \
|
||||
py3-html2text \
|
||||
py3-jinja2 \
|
||||
py3-redis \
|
||||
redis \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
syslog-ng-redis \
|
||||
syslog-ng-json \
|
||||
supervisor \
|
||||
tzdata \
|
||||
wget \
|
||||
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true \
|
||||
&& apt-key adv --fetch-keys https://repo.dovecot.org/DOVECOT-REPO-GPG \
|
||||
&& echo "deb https://repo.dovecot.org/ce-${DOVECOT}/debian/bullseye bullseye main" > /etc/apt/sources.list.d/dovecot.list \
|
||||
&& apt-get update \
|
||||
&& apt-get -y --no-install-recommends install \
|
||||
dovecot-lua \
|
||||
dovecot-managesieved \
|
||||
dovecot-sieve \
|
||||
dovecot \
|
||||
dovecot-dev \
|
||||
dovecot-lmtpd \
|
||||
dovecot-lua \
|
||||
dovecot-ldap \
|
||||
dovecot-mysql \
|
||||
dovecot-core \
|
||||
dovecot-sql \
|
||||
dovecot-submissiond \
|
||||
dovecot-pigeonhole-plugin \
|
||||
dovecot-pop3d \
|
||||
dovecot-imapd \
|
||||
dovecot-solr \
|
||||
&& pip3 install mysql-connector-python html2text jinja2 redis \
|
||||
&& apt-get autoremove --purge -y \
|
||||
&& apt-get autoclean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /tmp/* /var/tmp/* /root/.cache/
|
||||
# imapsync dependencies
|
||||
RUN cpan Crypt::OpenSSL::PKCS12
|
||||
dovecot-fts-solr \
|
||||
&& arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$arch" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true
|
||||
|
||||
# RUN cpan LockFile::Simple
|
||||
|
||||
COPY trim_logs.sh /usr/local/bin/trim_logs.sh
|
||||
COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
|
||||
|
@ -335,6 +335,15 @@ sys.exit()
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Set mail_replica for HA setups
|
||||
if [[ -n ${MAILCOW_REPLICA_IP} && -n ${DOVEADM_REPLICA_PORT} ]]; then
|
||||
cat <<EOF > /etc/dovecot/mail_replica.conf
|
||||
# Autogenerated by mailcow
|
||||
mail_replica = tcp:${MAILCOW_REPLICA_IP}:${DOVEADM_REPLICA_PORT}
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
# 401 is user dovecot
|
||||
if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
|
||||
openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem
|
||||
@ -432,4 +441,8 @@ done
|
||||
# May be related to something inside Docker, I seriously don't know
|
||||
touch /etc/dovecot/lua/passwd-verify.lua
|
||||
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
@ -3,11 +3,10 @@
|
||||
import smtplib
|
||||
import os
|
||||
import sys
|
||||
import mysql.connector
|
||||
import MySQLdb
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import COMMASPACE, formatdate
|
||||
import cgi
|
||||
import jinja2
|
||||
from jinja2 import Template
|
||||
import json
|
||||
@ -50,7 +49,7 @@ try:
|
||||
def query_mysql(query, headers = True, update = False):
|
||||
while True:
|
||||
try:
|
||||
cnx = mysql.connector.connect(unix_socket = '/var/run/mysqld/mysqld.sock', user=os.environ.get('DBUSER'), passwd=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||
except Exception as ex:
|
||||
print('%s - trying again...' % (ex))
|
||||
time.sleep(3)
|
||||
|
@ -55,7 +55,7 @@ try:
|
||||
msg.attach(text_part)
|
||||
msg.attach(html_part)
|
||||
msg['To'] = username
|
||||
p = Popen(['/usr/lib/dovecot/dovecot-lda', '-d', username, '-o', '"plugin/quota=maildir:User quota:noenforcing"'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)
|
||||
p = Popen(['/usr/libexec/dovecot/dovecot-lda', '-d', username, '-o', '"plugin/quota=maildir:User quota:noenforcing"'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)
|
||||
p.communicate(input=bytes(msg.as_string(), 'utf-8'))
|
||||
|
||||
domain = username.split("@")[-1]
|
||||
|
@ -11,7 +11,7 @@ fi
|
||||
|
||||
# Is replication active?
|
||||
# grep on file is less expensive than doveconf
|
||||
if ! grep -qi mail_replica /etc/dovecot/dovecot.conf; then
|
||||
if [ -n ${MAILCOW_REPLICA_IP} ]; then
|
||||
${REDIS_CMDLINE} SET DOVECOT_REPL_HEALTH 1 > /dev/null
|
||||
exit
|
||||
fi
|
||||
|
@ -13,6 +13,10 @@ autostart=true
|
||||
|
||||
[program:dovecot]
|
||||
command=/usr/sbin/dovecot -F
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
|
||||
[eventlistener:processes]
|
||||
|
@ -1,4 +1,4 @@
|
||||
@version: 3.28
|
||||
@version: 4.5
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@ -6,11 +6,12 @@ options {
|
||||
use_dns(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
keep_timestamp(no);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
unix-stream("/dev/log");
|
||||
source s_dgram {
|
||||
unix-dgram("/dev/log");
|
||||
internal();
|
||||
};
|
||||
destination d_stdout { pipe("/dev/stdout"); };
|
||||
@ -36,7 +37,7 @@ filter f_replica {
|
||||
not match("Error: sync: Unknown user in remote" value("MESSAGE"));
|
||||
};
|
||||
log {
|
||||
source(s_src);
|
||||
source(s_dgram);
|
||||
filter(f_replica);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
|
@ -1,4 +1,4 @@
|
||||
@version: 3.28
|
||||
@version: 4.5
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@ -6,11 +6,12 @@ options {
|
||||
use_dns(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
keep_timestamp(no);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
unix-stream("/dev/log");
|
||||
source s_dgram {
|
||||
unix-dgram("/dev/log");
|
||||
internal();
|
||||
};
|
||||
destination d_stdout { pipe("/dev/stdout"); };
|
||||
@ -36,7 +37,7 @@ filter f_replica {
|
||||
not match("Error: sync: Unknown user in remote" value("MESSAGE"));
|
||||
};
|
||||
log {
|
||||
source(s_src);
|
||||
source(s_dgram);
|
||||
filter(f_replica);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
|
@ -1,6 +1,9 @@
|
||||
FROM alpine:3.17
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
ENV XTABLES_LIBDIR /usr/lib/xtables
|
||||
ENV PYTHON_IPTABLES_XTABLES_VERSION 12
|
||||
ENV IPTABLES_LIBDIR /usr/lib
|
||||
@ -12,12 +15,16 @@ RUN apk add --virtual .build-deps \
|
||||
openssl-dev \
|
||||
&& apk add -U python3 \
|
||||
iptables \
|
||||
iptables-dev \
|
||||
ip6tables \
|
||||
xtables-addons \
|
||||
nftables \
|
||||
tzdata \
|
||||
py3-pip \
|
||||
py3-nftables \
|
||||
musl-dev \
|
||||
&& pip3 install --ignore-installed --upgrade pip \
|
||||
jsonschema \
|
||||
python-iptables \
|
||||
redis \
|
||||
ipaddress \
|
||||
@ -26,5 +33,10 @@ RUN apk add --virtual .build-deps \
|
||||
|
||||
# && pip3 install --upgrade pip python-iptables==0.13.0 redis ipaddress dnspython \
|
||||
|
||||
COPY server.py /
|
||||
CMD ["python3", "-u", "/server.py"]
|
||||
COPY modules /app/modules
|
||||
COPY main.py /app/
|
||||
COPY ./docker-entrypoint.sh /app/
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
|
29
data/Dockerfiles/netfilter/docker-entrypoint.sh
Executable file
29
data/Dockerfiles/netfilter/docker-entrypoint.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
backend=iptables
|
||||
|
||||
nft list table ip filter &>/dev/null
|
||||
nftables_found=$?
|
||||
|
||||
iptables -L -n &>/dev/null
|
||||
iptables_found=$?
|
||||
|
||||
if [ $nftables_found -lt $iptables_found ]; then
|
||||
backend=nftables
|
||||
fi
|
||||
|
||||
if [ $nftables_found -gt $iptables_found ]; then
|
||||
backend=iptables
|
||||
fi
|
||||
|
||||
if [ $nftables_found -eq 0 ] && [ $nftables_found -eq $iptables_found ]; then
|
||||
nftables_lines=$(nft list ruleset | wc -l)
|
||||
iptables_lines=$(iptables-save | wc -l)
|
||||
if [ $nftables_lines -gt $iptables_lines ]; then
|
||||
backend=nftables
|
||||
else
|
||||
backend=iptables
|
||||
fi
|
||||
fi
|
||||
|
||||
exec python -u /app/main.py $backend
|
496
data/Dockerfiles/netfilter/main.py
Normal file
496
data/Dockerfiles/netfilter/main.py
Normal file
@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import signal
|
||||
import ipaddress
|
||||
from collections import Counter
|
||||
from random import randint
|
||||
from threading import Thread
|
||||
from threading import Lock
|
||||
import redis
|
||||
import json
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
import uuid
|
||||
from modules.Logger import Logger
|
||||
from modules.IPTables import IPTables
|
||||
from modules.NFTables import NFTables
|
||||
|
||||
|
||||
# globals
|
||||
WHITELIST = []
|
||||
BLACKLIST= []
|
||||
bans = {}
|
||||
quit_now = False
|
||||
exit_code = 0
|
||||
lock = Lock()
|
||||
chain_name = "MAILCOW"
|
||||
r = None
|
||||
pubsub = None
|
||||
clear_before_quit = False
|
||||
|
||||
|
||||
def refreshF2boptions():
|
||||
global f2boptions
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
f2boptions = {}
|
||||
|
||||
if not r.get('F2B_OPTIONS'):
|
||||
f2boptions['ban_time'] = r.get('F2B_BAN_TIME')
|
||||
f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME')
|
||||
f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT')
|
||||
f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS')
|
||||
f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW')
|
||||
f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4')
|
||||
f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6')
|
||||
else:
|
||||
try:
|
||||
f2boptions = json.loads(r.get('F2B_OPTIONS'))
|
||||
except ValueError:
|
||||
logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json')
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
verifyF2boptions(f2boptions)
|
||||
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
|
||||
|
||||
def verifyF2boptions(f2boptions):
|
||||
verifyF2boption(f2boptions,'ban_time', 1800)
|
||||
verifyF2boption(f2boptions,'max_ban_time', 10000)
|
||||
verifyF2boption(f2boptions,'ban_time_increment', True)
|
||||
verifyF2boption(f2boptions,'max_attempts', 10)
|
||||
verifyF2boption(f2boptions,'retry_window', 600)
|
||||
verifyF2boption(f2boptions,'netban_ipv4', 32)
|
||||
verifyF2boption(f2boptions,'netban_ipv6', 128)
|
||||
verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
|
||||
verifyF2boption(f2boptions,'manage_external', 0)
|
||||
|
||||
def verifyF2boption(f2boptions, f2boption, f2bdefault):
|
||||
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
|
||||
|
||||
def refreshF2bregex():
|
||||
global f2bregex
|
||||
global quit_now
|
||||
global exit_code
|
||||
if not r.get('F2B_REGEX'):
|
||||
f2bregex = {}
|
||||
f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
|
||||
f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
|
||||
f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
|
||||
f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
|
||||
f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
|
||||
f2bregex[6] = '-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
|
||||
f2bregex[7] = '-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[8] = '-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
|
||||
f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
|
||||
r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
|
||||
else:
|
||||
try:
|
||||
f2bregex = {}
|
||||
f2bregex = json.loads(r.get('F2B_REGEX'))
|
||||
except ValueError:
|
||||
logger.logCrit('Error loading F2B options: F2B_REGEX is not json')
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def get_ip(address):
|
||||
ip = ipaddress.ip_address(address)
|
||||
if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_private or ip.is_loopback:
|
||||
return False
|
||||
|
||||
return ip
|
||||
|
||||
def ban(address):
|
||||
global f2boptions
|
||||
global lock
|
||||
|
||||
refreshF2boptions()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
RETRY_WINDOW = int(f2boptions['retry_window'])
|
||||
NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
|
||||
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
|
||||
|
||||
ip = get_ip(address)
|
||||
if not ip: return
|
||||
address = str(ip)
|
||||
self_network = ipaddress.ip_network(address)
|
||||
|
||||
with lock:
|
||||
temp_whitelist = set(WHITELIST)
|
||||
if temp_whitelist:
|
||||
for wl_key in temp_whitelist:
|
||||
wl_net = ipaddress.ip_network(wl_key, False)
|
||||
if wl_net.overlaps(self_network):
|
||||
logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
|
||||
return
|
||||
|
||||
net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
|
||||
net = str(net)
|
||||
|
||||
if not net in bans:
|
||||
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
|
||||
|
||||
current_attempt = time.time()
|
||||
if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
|
||||
bans[net]['attempts'] = 0
|
||||
|
||||
bans[net]['attempts'] += 1
|
||||
bans[net]['last_attempt'] = current_attempt
|
||||
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
cur_time = int(round(time.time()))
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
|
||||
if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
|
||||
with lock:
|
||||
tables.banIPv4(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
with lock:
|
||||
tables.banIPv6(net)
|
||||
|
||||
r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
|
||||
else:
|
||||
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
|
||||
|
||||
def unban(net):
|
||||
global lock
|
||||
|
||||
if not net in bans:
|
||||
logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
return
|
||||
|
||||
logger.logInfo('Unbanning %s' % net)
|
||||
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
tables.unbanIPv4(net)
|
||||
else:
|
||||
with lock:
|
||||
tables.unbanIPv6(net)
|
||||
|
||||
r.hdel('F2B_ACTIVE_BANS', '%s' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
if net in bans:
|
||||
bans[net]['attempts'] = 0
|
||||
bans[net]['ban_counter'] += 1
|
||||
|
||||
def permBan(net, unban=False):
|
||||
global f2boptions
|
||||
global lock
|
||||
|
||||
is_unbanned = False
|
||||
is_banned = False
|
||||
if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
if unban:
|
||||
is_unbanned = tables.unbanIPv4(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
is_banned = tables.banIPv4(net)
|
||||
else:
|
||||
with lock:
|
||||
if unban:
|
||||
is_unbanned = tables.unbanIPv6(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
is_banned = tables.banIPv6(net)
|
||||
|
||||
|
||||
if is_unbanned:
|
||||
r.hdel('F2B_PERM_BANS', '%s' % net)
|
||||
logger.logCrit('Removed host/network %s from blacklist' % net)
|
||||
elif is_banned:
|
||||
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
|
||||
logger.logCrit('Added host/network %s to blacklist' % net)
|
||||
|
||||
def clear():
|
||||
global lock
|
||||
logger.logInfo('Clearing all bans')
|
||||
for net in bans.copy():
|
||||
unban(net)
|
||||
with lock:
|
||||
tables.clearIPv4Table()
|
||||
tables.clearIPv6Table()
|
||||
try:
|
||||
if r is not None:
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
except Exception as ex:
|
||||
logger.logWarn('Error clearing redis keys F2B_ACTIVE_BANS and F2B_PERM_BANS: %s' % ex)
|
||||
|
||||
def watch():
|
||||
global pubsub
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
logger.logInfo('Watching Redis channel F2B_CHANNEL')
|
||||
pubsub.subscribe('F2B_CHANNEL')
|
||||
|
||||
while not quit_now:
|
||||
try:
|
||||
for item in pubsub.listen():
|
||||
refreshF2bregex()
|
||||
for rule_id, rule_regex in f2bregex.items():
|
||||
if item['data'] and item['type'] == 'message':
|
||||
try:
|
||||
result = re.search(rule_regex, item['data'])
|
||||
except re.error:
|
||||
result = False
|
||||
if result:
|
||||
addr = result.group(1)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback:
|
||||
continue
|
||||
logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
|
||||
ban(addr)
|
||||
except Exception as ex:
|
||||
logger.logWarn('Error reading log line from pubsub: %s' % ex)
|
||||
pubsub = None
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def snat4(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24')
|
||||
|
||||
def snat6(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
|
||||
|
||||
def autopurge():
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
refreshF2boptions()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
MAX_BAN_TIME = int(f2boptions['max_ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
|
||||
if QUEUE_UNBAN:
|
||||
for net in QUEUE_UNBAN:
|
||||
unban(str(net))
|
||||
for net in bans.copy():
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
|
||||
if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME:
|
||||
unban(net)
|
||||
|
||||
def mailcowChainOrder():
|
||||
global lock
|
||||
global quit_now
|
||||
global exit_code
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
quit_now, exit_code = tables.checkIPv4ChainOrder()
|
||||
if quit_now: return
|
||||
quit_now, exit_code = tables.checkIPv6ChainOrder()
|
||||
|
||||
def isIpNetwork(address):
|
||||
try:
|
||||
ipaddress.ip_network(address, False)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def genNetworkList(list):
|
||||
resolver = dns.resolver.Resolver()
|
||||
hostnames = []
|
||||
networks = []
|
||||
for key in list:
|
||||
if isIpNetwork(key):
|
||||
networks.append(key)
|
||||
else:
|
||||
hostnames.append(key)
|
||||
for hostname in hostnames:
|
||||
hostname_ips = []
|
||||
for rdtype in ['A', 'AAAA']:
|
||||
try:
|
||||
answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
|
||||
except dns.exception.Timeout:
|
||||
logger.logInfo('Hostname %s timedout on resolve' % hostname)
|
||||
break
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
continue
|
||||
except dns.exception.DNSException as dnsexception:
|
||||
logger.logInfo('%s' % dnsexception)
|
||||
continue
|
||||
for rdata in answer:
|
||||
hostname_ips.append(rdata.to_text())
|
||||
networks.extend(hostname_ips)
|
||||
return set(networks)
|
||||
|
||||
def whitelistUpdate():
|
||||
global lock
|
||||
global quit_now
|
||||
global WHITELIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_WHITELIST')
|
||||
new_whitelist = []
|
||||
if list:
|
||||
new_whitelist = genNetworkList(list)
|
||||
with lock:
|
||||
if Counter(new_whitelist) != Counter(WHITELIST):
|
||||
WHITELIST = new_whitelist
|
||||
logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def blacklistUpdate():
|
||||
global quit_now
|
||||
global BLACKLIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_BLACKLIST')
|
||||
new_blacklist = []
|
||||
if list:
|
||||
new_blacklist = genNetworkList(list)
|
||||
if Counter(new_blacklist) != Counter(BLACKLIST):
|
||||
addban = set(new_blacklist).difference(BLACKLIST)
|
||||
delban = set(BLACKLIST).difference(new_blacklist)
|
||||
BLACKLIST = new_blacklist
|
||||
logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
|
||||
if addban:
|
||||
for net in addban:
|
||||
permBan(net=net)
|
||||
if delban:
|
||||
for net in delban:
|
||||
permBan(net=net, unban=True)
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def sigterm_quit(signum, frame):
|
||||
global clear_before_quit
|
||||
clear_before_quit = True
|
||||
sys.exit(exit_code)
|
||||
|
||||
def berfore_quit():
|
||||
if clear_before_quit:
|
||||
clear()
|
||||
if pubsub is not None:
|
||||
pubsub.unsubscribe()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
atexit.register(berfore_quit)
|
||||
signal.signal(signal.SIGTERM, sigterm_quit)
|
||||
|
||||
# init Logger
|
||||
logger = Logger()
|
||||
|
||||
# init backend
|
||||
backend = sys.argv[1]
|
||||
if backend == "nftables":
|
||||
logger.logInfo('Using NFTables backend')
|
||||
tables = NFTables(chain_name, logger)
|
||||
else:
|
||||
logger.logInfo('Using IPTables backend')
|
||||
tables = IPTables(chain_name, logger)
|
||||
|
||||
# In case a previous session was killed without cleanup
|
||||
clear()
|
||||
|
||||
# Reinit MAILCOW chain
|
||||
# Is called before threads start, no locking
|
||||
logger.logInfo("Initializing mailcow netfilter chain")
|
||||
tables.initChainIPv4()
|
||||
tables.initChainIPv6()
|
||||
|
||||
if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE").lower() in ("y", "yes"):
|
||||
logger.logInfo(f"Skipping {chain_name} isolation")
|
||||
else:
|
||||
logger.logInfo(f"Setting {chain_name} isolation")
|
||||
tables.create_mailcow_isolation_rule("br-mailcow", [3306, 6379, 8983, 12345], os.getenv("MAILCOW_REPLICA_IP"))
|
||||
|
||||
# connect to redis
|
||||
while True:
|
||||
try:
|
||||
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
|
||||
redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
|
||||
if "".__eq__(redis_slaveof_ip):
|
||||
r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
|
||||
else:
|
||||
r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
|
||||
r.ping()
|
||||
pubsub = r.pubsub()
|
||||
except Exception as ex:
|
||||
print('%s - trying again in 3 seconds' % (ex))
|
||||
time.sleep(3)
|
||||
else:
|
||||
break
|
||||
logger.set_redis(r)
|
||||
|
||||
# rename fail2ban to netfilter
|
||||
if r.exists('F2B_LOG'):
|
||||
r.rename('F2B_LOG', 'NETFILTER_LOG')
|
||||
# clear bans in redis
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
|
||||
refreshF2boptions()
|
||||
|
||||
watch_thread = Thread(target=watch)
|
||||
watch_thread.daemon = True
|
||||
watch_thread.start()
|
||||
|
||||
if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv4Address:
|
||||
snat4_thread = Thread(target=snat4,args=(snat_ip,))
|
||||
snat4_thread.daemon = True
|
||||
snat4_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
|
||||
|
||||
if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT6_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv6Address:
|
||||
snat6_thread = Thread(target=snat6,args=(snat_ip,))
|
||||
snat6_thread.daemon = True
|
||||
snat6_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address')
|
||||
|
||||
autopurge_thread = Thread(target=autopurge)
|
||||
autopurge_thread.daemon = True
|
||||
autopurge_thread.start()
|
||||
|
||||
mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
|
||||
mailcowchainwatch_thread.daemon = True
|
||||
mailcowchainwatch_thread.start()
|
||||
|
||||
blacklistupdate_thread = Thread(target=blacklistUpdate)
|
||||
blacklistupdate_thread.daemon = True
|
||||
blacklistupdate_thread.start()
|
||||
|
||||
whitelistupdate_thread = Thread(target=whitelistUpdate)
|
||||
whitelistupdate_thread.daemon = True
|
||||
whitelistupdate_thread.start()
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(0.5)
|
||||
|
||||
sys.exit(exit_code)
|
252
data/Dockerfiles/netfilter/modules/IPTables.py
Normal file
252
data/Dockerfiles/netfilter/modules/IPTables.py
Normal file
@ -0,0 +1,252 @@
|
||||
import iptc
|
||||
import time
|
||||
import os
|
||||
|
||||
class IPTables:
|
||||
def __init__(self, chain_name, logger):
|
||||
self.chain_name = chain_name
|
||||
self.logger = logger
|
||||
|
||||
def initChainIPv4(self):
|
||||
if not iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) in iptc.Table(iptc.Table.FILTER).chains:
|
||||
iptc.Table(iptc.Table.FILTER).create_chain(self.chain_name)
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
|
||||
rule = iptc.Rule()
|
||||
rule.src = '0.0.0.0/0'
|
||||
rule.dst = '0.0.0.0/0'
|
||||
target = iptc.Target(rule, self.chain_name)
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
|
||||
def initChainIPv6(self):
|
||||
if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) in iptc.Table6(iptc.Table6.FILTER).chains:
|
||||
iptc.Table6(iptc.Table6.FILTER).create_chain(self.chain_name)
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = '::/0'
|
||||
rule.dst = '::/0'
|
||||
target = iptc.Target(rule, self.chain_name)
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
|
||||
def checkIPv4ChainOrder(self):
|
||||
filter_table = iptc.Table(iptc.Table.FILTER)
|
||||
filter_table.refresh()
|
||||
return self.checkChainOrder(filter_table)
|
||||
|
||||
def checkIPv6ChainOrder(self):
|
||||
filter_table = iptc.Table6(iptc.Table6.FILTER)
|
||||
filter_table.refresh()
|
||||
return self.checkChainOrder(filter_table)
|
||||
|
||||
def checkChainOrder(self, filter_table):
|
||||
err = False
|
||||
exit_code = None
|
||||
|
||||
forward_chain = iptc.Chain(filter_table, 'FORWARD')
|
||||
input_chain = iptc.Chain(filter_table, 'INPUT')
|
||||
for chain in [forward_chain, input_chain]:
|
||||
target_found = False
|
||||
for position, item in enumerate(chain.rules):
|
||||
if item.target.name == self.chain_name:
|
||||
target_found = True
|
||||
if position > 2:
|
||||
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
|
||||
err = True
|
||||
exit_code = 2
|
||||
if not target_found:
|
||||
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
return err, exit_code
|
||||
|
||||
def clearIPv4Table(self):
|
||||
self.clearTable(iptc.Table(iptc.Table.FILTER))
|
||||
|
||||
def clearIPv6Table(self):
|
||||
self.clearTable(iptc.Table6(iptc.Table6.FILTER))
|
||||
|
||||
def clearTable(self, filter_table):
|
||||
filter_table.autocommit = False
|
||||
forward_chain = iptc.Chain(filter_table, "FORWARD")
|
||||
input_chain = iptc.Chain(filter_table, "INPUT")
|
||||
mailcow_chain = iptc.Chain(filter_table, self.chain_name)
|
||||
if mailcow_chain in filter_table.chains:
|
||||
for rule in mailcow_chain.rules:
|
||||
mailcow_chain.delete_rule(rule)
|
||||
for rule in forward_chain.rules:
|
||||
if rule.target.name == self.chain_name:
|
||||
forward_chain.delete_rule(rule)
|
||||
for rule in input_chain.rules:
|
||||
if rule.target.name == self.chain_name:
|
||||
input_chain.delete_rule(rule)
|
||||
filter_table.delete_chain(self.chain_name)
|
||||
filter_table.commit()
|
||||
filter_table.refresh()
|
||||
filter_table.autocommit = True
|
||||
|
||||
def banIPv4(self, source):
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
rule = iptc.Rule()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
return False
|
||||
chain.insert_rule(rule)
|
||||
return True
|
||||
|
||||
def banIPv6(self, source):
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
return False
|
||||
chain.insert_rule(rule)
|
||||
return True
|
||||
|
||||
def unbanIPv4(self, source):
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
rule = iptc.Rule()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
return False
|
||||
chain.delete_rule(rule)
|
||||
return True
|
||||
|
||||
def unbanIPv6(self, source):
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
return False
|
||||
chain.delete_rule(rule)
|
||||
return True
|
||||
|
||||
def snat4(self, snat_target, source):
|
||||
try:
|
||||
table = iptc.Table('nat')
|
||||
table.refresh()
|
||||
chain = iptc.Chain(table, 'POSTROUTING')
|
||||
table.autocommit = False
|
||||
new_rule = self.getSnat4Rule(snat_target, source)
|
||||
|
||||
if not chain.rules:
|
||||
# if there are no rules in the chain, insert the new rule directly
|
||||
self.logger.logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
|
||||
chain.insert_rule(new_rule)
|
||||
else:
|
||||
for position, rule in enumerate(chain.rules):
|
||||
if not hasattr(rule.target, 'parameter'):
|
||||
continue
|
||||
match = all((
|
||||
new_rule.get_src() == rule.get_src(),
|
||||
new_rule.get_dst() == rule.get_dst(),
|
||||
new_rule.target.parameters == rule.target.parameters,
|
||||
new_rule.target.name == rule.target.name
|
||||
))
|
||||
if position == 0:
|
||||
if not match:
|
||||
self.logger.logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
|
||||
chain.insert_rule(new_rule)
|
||||
else:
|
||||
if match:
|
||||
self.logger.logInfo(f'Remove rule for source network {new_rule.src} to SNAT target {snat_target} from POSTROUTING chain at position {position}')
|
||||
chain.delete_rule(rule)
|
||||
|
||||
table.commit()
|
||||
table.autocommit = True
|
||||
return True
|
||||
except:
|
||||
self.logger.logCrit('Error running SNAT4, retrying...')
|
||||
return False
|
||||
|
||||
def snat6(self, snat_target, source):
|
||||
try:
|
||||
table = iptc.Table6('nat')
|
||||
table.refresh()
|
||||
chain = iptc.Chain(table, 'POSTROUTING')
|
||||
table.autocommit = False
|
||||
new_rule = self.getSnat6Rule(snat_target, source)
|
||||
|
||||
if new_rule not in chain.rules:
|
||||
self.logger.logInfo('Added POSTROUTING rule for source network %s to SNAT target %s' % (new_rule.src, snat_target))
|
||||
chain.insert_rule(new_rule)
|
||||
else:
|
||||
for position, item in enumerate(chain.rules):
|
||||
if item == new_rule:
|
||||
if position != 0:
|
||||
chain.delete_rule(new_rule)
|
||||
|
||||
table.commit()
|
||||
table.autocommit = True
|
||||
except:
|
||||
self.logger.logCrit('Error running SNAT6, retrying...')
|
||||
|
||||
|
||||
def getSnat4Rule(self, snat_target, source):
|
||||
rule = iptc.Rule()
|
||||
rule.src = source
|
||||
rule.dst = '!' + rule.src
|
||||
target = rule.create_target("SNAT")
|
||||
target.to_source = snat_target
|
||||
match = rule.create_match("comment")
|
||||
match.comment = f'{int(round(time.time()))}'
|
||||
return rule
|
||||
|
||||
def getSnat6Rule(self, snat_target, source):
|
||||
rule = iptc.Rule6()
|
||||
rule.src = source
|
||||
rule.dst = '!' + rule.src
|
||||
target = rule.create_target("SNAT")
|
||||
target.to_source = snat_target
|
||||
return rule
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
try:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
|
||||
# insert mailcow isolation rule
|
||||
rule = iptc.Rule()
|
||||
rule.in_interface = f'!{_interface}'
|
||||
rule.out_interface = _interface
|
||||
rule.protocol = 'tcp'
|
||||
rule.create_target("DROP")
|
||||
match = rule.create_match("multiport")
|
||||
match.dports = ','.join(map(str, _dports))
|
||||
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
chain.insert_rule(rule, position=0)
|
||||
|
||||
# insert mailcow isolation exception rule
|
||||
if _allow != "":
|
||||
rule = iptc.Rule()
|
||||
rule.src = _allow
|
||||
rule.in_interface = f'!{_interface}'
|
||||
rule.out_interface = _interface
|
||||
rule.protocol = 'tcp'
|
||||
rule.create_target("ACCEPT")
|
||||
match = rule.create_match("multiport")
|
||||
match.dports = ','.join(map(str, _dports))
|
||||
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
chain.insert_rule(rule, position=0)
|
||||
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.logCrit(f"Error adding {self.chain_name} isolation: {e}")
|
||||
return False
|
27
data/Dockerfiles/netfilter/modules/Logger.py
Normal file
27
data/Dockerfiles/netfilter/modules/Logger.py
Normal file
@ -0,0 +1,27 @@
|
||||
import time
|
||||
import json
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.r = None
|
||||
|
||||
def set_redis(self, redis):
|
||||
self.r = redis
|
||||
|
||||
def log(self, priority, message):
|
||||
tolog = {}
|
||||
tolog['time'] = int(round(time.time()))
|
||||
tolog['priority'] = priority
|
||||
tolog['message'] = message
|
||||
if self.r is not None:
|
||||
self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
|
||||
print(message)
|
||||
|
||||
def logWarn(self, message):
|
||||
self.log('warn', message)
|
||||
|
||||
def logCrit(self, message):
|
||||
self.log('crit', message)
|
||||
|
||||
def logInfo(self, message):
|
||||
self.log('info', message)
|
657
data/Dockerfiles/netfilter/modules/NFTables.py
Normal file
657
data/Dockerfiles/netfilter/modules/NFTables.py
Normal file
@ -0,0 +1,657 @@
|
||||
import nftables
|
||||
import ipaddress
|
||||
import os
|
||||
|
||||
class NFTables:
|
||||
def __init__(self, chain_name, logger):
|
||||
self.chain_name = chain_name
|
||||
self.logger = logger
|
||||
|
||||
self.nft = nftables.Nftables()
|
||||
self.nft.set_json_output(True)
|
||||
self.nft.set_handle_output(True)
|
||||
self.nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
|
||||
'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
|
||||
|
||||
self.search_current_chains()
|
||||
|
||||
def initChainIPv4(self):
|
||||
self.insert_mailcow_chains("ip")
|
||||
|
||||
def initChainIPv6(self):
|
||||
self.insert_mailcow_chains("ip6")
|
||||
|
||||
def checkIPv4ChainOrder(self):
|
||||
return self.checkChainOrder("ip")
|
||||
|
||||
def checkIPv6ChainOrder(self):
|
||||
return self.checkChainOrder("ip6")
|
||||
|
||||
def checkChainOrder(self, filter_table):
|
||||
err = False
|
||||
exit_code = None
|
||||
|
||||
for chain in ['input', 'forward']:
|
||||
chain_position = self.check_mailcow_chains(filter_table, chain)
|
||||
if chain_position is None: continue
|
||||
|
||||
if chain_position is False:
|
||||
self.logger.logCrit(f'MAILCOW target not found in {filter_table} {chain} table, restarting container to fix it...')
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
if chain_position > 0:
|
||||
chain_position += 1
|
||||
self.logger.logCrit(f'MAILCOW target is in position {chain_position} in the {filter_table} {chain} table, restarting container to fix it...')
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
return err, exit_code
|
||||
|
||||
def clearIPv4Table(self):
|
||||
self.clearTable("ip")
|
||||
|
||||
def clearIPv6Table(self):
|
||||
self.clearTable("ip6")
|
||||
|
||||
def clearTable(self, _family):
|
||||
is_empty_dict = True
|
||||
json_command = self.get_base_dict()
|
||||
chain_handle = self.get_chain_handle(_family, "filter", self.chain_name)
|
||||
# if no handle, the chain doesn't exists
|
||||
if chain_handle is not None:
|
||||
is_empty_dict = False
|
||||
# flush chain
|
||||
mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
|
||||
flush_chain = {'flush': {'chain': mailcow_chain}}
|
||||
json_command["nftables"].append(flush_chain)
|
||||
|
||||
# remove rule in forward chain
|
||||
# remove rule in input chain
|
||||
chains_family = [self.nft_chain_names[_family]['filter']['input'],
|
||||
self.nft_chain_names[_family]['filter']['forward'] ]
|
||||
|
||||
for chain_base in chains_family:
|
||||
if not chain_base: continue
|
||||
|
||||
rules_handle = self.get_rules_handle(_family, "filter", chain_base)
|
||||
if rules_handle is not None:
|
||||
for r_handle in rules_handle:
|
||||
is_empty_dict = False
|
||||
mailcow_rule = {'family':_family,
|
||||
'table': 'filter',
|
||||
'chain': chain_base,
|
||||
'handle': r_handle }
|
||||
delete_rules = {'delete': {'rule': mailcow_rule} }
|
||||
json_command["nftables"].append(delete_rules)
|
||||
|
||||
# remove chain
|
||||
# after delete all rules referencing this chain
|
||||
if chain_handle is not None:
|
||||
mc_chain_handle = {'family':_family,
|
||||
'table': 'filter',
|
||||
'name': self.chain_name,
|
||||
'handle': chain_handle }
|
||||
delete_chain = {'delete': {'chain': mc_chain_handle} }
|
||||
json_command["nftables"].append(delete_chain)
|
||||
|
||||
if is_empty_dict == False:
|
||||
if self.nft_exec_dict(json_command):
|
||||
self.logger.logInfo(f"Clear completed: {_family}")
|
||||
|
||||
def banIPv4(self, source):
|
||||
ban_dict = self.get_ban_ip_dict(source, "ip")
|
||||
return self.nft_exec_dict(ban_dict)
|
||||
|
||||
def banIPv6(self, source):
|
||||
ban_dict = self.get_ban_ip_dict(source, "ip6")
|
||||
return self.nft_exec_dict(ban_dict)
|
||||
|
||||
def unbanIPv4(self, source):
|
||||
unban_dict = self.get_unban_ip_dict(source, "ip")
|
||||
if not unban_dict:
|
||||
return False
|
||||
return self.nft_exec_dict(unban_dict)
|
||||
|
||||
def unbanIPv6(self, source):
|
||||
unban_dict = self.get_unban_ip_dict(source, "ip6")
|
||||
if not unban_dict:
|
||||
return False
|
||||
return self.nft_exec_dict(unban_dict)
|
||||
|
||||
def snat4(self, snat_target, source):
|
||||
self.snat_rule("ip", snat_target, source)
|
||||
|
||||
def snat6(self, snat_target, source):
|
||||
self.snat_rule("ip6", snat_target, source)
|
||||
|
||||
|
||||
def nft_exec_dict(self, query: dict):
|
||||
if not query: return False
|
||||
|
||||
rc, output, error = self.nft.json_cmd(query)
|
||||
if rc != 0:
|
||||
#self.logger.logCrit(f"Nftables Error: {error}")
|
||||
return False
|
||||
|
||||
# Prevent returning False or empty string on commands that do not produce output
|
||||
if rc == 0 and len(output) == 0:
|
||||
return True
|
||||
|
||||
return output
|
||||
|
||||
def get_base_dict(self):
|
||||
return {'nftables': [{ 'metainfo': { 'json_schema_version': 1} } ] }
|
||||
|
||||
def search_current_chains(self):
|
||||
nft_chain_priority = {'ip': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} },
|
||||
'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } }
|
||||
|
||||
# Command: 'nft list chains'
|
||||
_list = {'list' : {'chains': 'null'} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset['nftables']:
|
||||
chain = _object.get("chain")
|
||||
if not chain: continue
|
||||
|
||||
_family = chain['family']
|
||||
_table = chain['table']
|
||||
_hook = chain.get("hook")
|
||||
_priority = chain.get("prio")
|
||||
_name = chain['name']
|
||||
|
||||
if _family not in self.nft_chain_names: continue
|
||||
if _table not in self.nft_chain_names[_family]: continue
|
||||
if _hook not in self.nft_chain_names[_family][_table]: continue
|
||||
if _priority is None: continue
|
||||
|
||||
_saved_priority = nft_chain_priority[_family][_table][_hook]
|
||||
if _saved_priority is None or _priority < _saved_priority:
|
||||
# at this point, we know the chain has:
|
||||
# hook and priority set
|
||||
# and it has the lowest priority
|
||||
nft_chain_priority[_family][_table][_hook] = _priority
|
||||
self.nft_chain_names[_family][_table][_hook] = _name
|
||||
|
||||
def search_for_chain(self, kernel_ruleset: dict, chain_name: str):
|
||||
found = False
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
chain = _object.get("chain")
|
||||
if not chain:
|
||||
continue
|
||||
ch_name = chain.get("name")
|
||||
if ch_name == chain_name:
|
||||
found = True
|
||||
break
|
||||
return found
|
||||
|
||||
def get_chain_dict(self, _family: str, _name: str):
|
||||
# nft (add | create) chain [<family>] <table> <name>
|
||||
_chain_opts = {'family': _family, 'table': 'filter', 'name': _name }
|
||||
_add = {'add': {'chain': _chain_opts} }
|
||||
final_chain = self.get_base_dict()
|
||||
final_chain["nftables"].append(_add)
|
||||
return final_chain
|
||||
|
||||
def get_mailcow_jump_rule_dict(self, _family: str, _chain: str):
|
||||
_jump_rule = self.get_base_dict()
|
||||
_expr_opt=[]
|
||||
_expr_counter = {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}
|
||||
_counter_dict = {'counter': _expr_counter}
|
||||
_expr_opt.append(_counter_dict)
|
||||
|
||||
_jump_opts = {'jump': {'target': self.chain_name} }
|
||||
|
||||
_expr_opt.append(_jump_opts)
|
||||
|
||||
_rule_params = {'family': _family,
|
||||
'table': 'filter',
|
||||
'chain': _chain,
|
||||
'expr': _expr_opt,
|
||||
'comment': "mailcow" }
|
||||
|
||||
_add_rule = {'insert': {'rule': _rule_params} }
|
||||
|
||||
_jump_rule["nftables"].append(_add_rule)
|
||||
|
||||
return _jump_rule
|
||||
|
||||
def insert_mailcow_chains(self, _family: str):
|
||||
nft_input_chain = self.nft_chain_names[_family]['filter']['input']
|
||||
nft_forward_chain = self.nft_chain_names[_family]['filter']['forward']
|
||||
# Command: 'nft list table <family> filter'
|
||||
_table_opts = {'family': _family, 'name': 'filter'}
|
||||
_list = {'list': {'table': _table_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
# chain
|
||||
if not self.search_for_chain(kernel_ruleset, self.chain_name):
|
||||
cadena = self.get_chain_dict(_family, self.chain_name)
|
||||
if self.nft_exec_dict(cadena):
|
||||
self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
|
||||
|
||||
input_jump_found, forward_jump_found = False, False
|
||||
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
if nft_input_chain and rule["chain"] == nft_input_chain:
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
input_jump_found = True
|
||||
if nft_forward_chain and rule["chain"] == nft_forward_chain:
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
forward_jump_found = True
|
||||
|
||||
if not input_jump_found:
|
||||
command = self.get_mailcow_jump_rule_dict(_family, nft_input_chain)
|
||||
self.nft_exec_dict(command)
|
||||
|
||||
if not forward_jump_found:
|
||||
command = self.get_mailcow_jump_rule_dict(_family, nft_forward_chain)
|
||||
self.nft_exec_dict(command)
|
||||
|
||||
def delete_nat_rule(self, _family:str, _chain: str, _handle:str):
|
||||
delete_command = self.get_base_dict()
|
||||
_rule_opts = {'family': _family,
|
||||
'table': 'nat',
|
||||
'chain': _chain,
|
||||
'handle': _handle }
|
||||
_delete = {'delete': {'rule': _rule_opts} }
|
||||
delete_command["nftables"].append(_delete)
|
||||
|
||||
return self.nft_exec_dict(delete_command)
|
||||
|
||||
def delete_filter_rule(self, _family:str, _chain: str, _handle:str):
|
||||
delete_command = self.get_base_dict()
|
||||
_rule_opts = {'family': _family,
|
||||
'table': 'filter',
|
||||
'chain': _chain,
|
||||
'handle': _handle }
|
||||
_delete = {'delete': {'rule': _rule_opts} }
|
||||
delete_command["nftables"].append(_delete)
|
||||
|
||||
return self.nft_exec_dict(delete_command)
|
||||
|
||||
def snat_rule(self, _family: str, snat_target: str, source_address: str):
|
||||
chain_name = self.nft_chain_names[_family]['nat']['postrouting']
|
||||
|
||||
# no postrouting chain, may occur if docker has ipv6 disabled.
|
||||
if not chain_name: return
|
||||
|
||||
# Command: nft list chain <family> nat <chain_name>
|
||||
_chain_opts = {'family': _family, 'table': 'nat', 'name': chain_name}
|
||||
_list = {'list':{'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if not kernel_ruleset:
|
||||
return
|
||||
|
||||
rule_position = 0
|
||||
rule_handle = None
|
||||
rule_found = False
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
if not rule.get("comment") or not rule["comment"] == "mailcow":
|
||||
rule_position +=1
|
||||
continue
|
||||
|
||||
rule_found = True
|
||||
rule_handle = rule["handle"]
|
||||
break
|
||||
|
||||
dest_net = ipaddress.ip_network(source_address, strict=False)
|
||||
target_net = ipaddress.ip_network(snat_target, strict=False)
|
||||
|
||||
if rule_found:
|
||||
saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
|
||||
saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"])
|
||||
|
||||
daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"]
|
||||
daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"])
|
||||
|
||||
target_ip = rule["expr"][3]["snat"]["addr"]
|
||||
|
||||
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len), strict=False)
|
||||
daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len), strict=False)
|
||||
current_target_net = ipaddress.ip_network(target_ip, strict=False)
|
||||
|
||||
match = all((
|
||||
dest_net == saddr_net,
|
||||
dest_net == daddr_net,
|
||||
target_net == current_target_net
|
||||
))
|
||||
try:
|
||||
if rule_position == 0:
|
||||
if not match:
|
||||
# Position 0 , it is a mailcow rule , but it does not have the same parameters
|
||||
if self.delete_nat_rule(_family, chain_name, rule_handle):
|
||||
self.logger.logInfo(f'Remove rule for source network {saddr_net} to SNAT target {target_net} from {_family} nat {chain_name} chain, rule does not match configured parameters')
|
||||
else:
|
||||
# Position > 0 and is mailcow rule
|
||||
if self.delete_nat_rule(_family, chain_name, rule_handle):
|
||||
self.logger.logInfo(f'Remove rule for source network {saddr_net} to SNAT target {target_net} from {_family} nat {chain_name} chain, rule is at position {rule_position}')
|
||||
except:
|
||||
self.logger.logCrit(f"Error running SNAT on {_family}, retrying..." )
|
||||
else:
|
||||
# rule not found
|
||||
json_command = self.get_base_dict()
|
||||
try:
|
||||
snat_dict = {'snat': {'addr': str(target_net.network_address)} }
|
||||
|
||||
expr_counter = {'family': _family, 'table': 'nat', 'packets': 0, 'bytes': 0}
|
||||
counter_dict = {'counter': expr_counter}
|
||||
|
||||
prefix_dict = {'prefix': {'addr': str(dest_net.network_address), 'len': int(dest_net.prefixlen)} }
|
||||
payload_dict = {'payload': {'protocol': _family, 'field': "saddr"} }
|
||||
match_dict1 = {'match': {'op': '==', 'left': payload_dict, 'right': prefix_dict} }
|
||||
|
||||
payload_dict2 = {'payload': {'protocol': _family, 'field': "daddr"} }
|
||||
match_dict2 = {'match': {'op': '!=', 'left': payload_dict2, 'right': prefix_dict } }
|
||||
expr_list = [
|
||||
match_dict1,
|
||||
match_dict2,
|
||||
counter_dict,
|
||||
snat_dict
|
||||
]
|
||||
rule_fields = {'family': _family,
|
||||
'table': 'nat',
|
||||
'chain': chain_name,
|
||||
'comment': "mailcow",
|
||||
'expr': expr_list }
|
||||
|
||||
insert_dict = {'insert': {'rule': rule_fields} }
|
||||
json_command["nftables"].append(insert_dict)
|
||||
if self.nft_exec_dict(json_command):
|
||||
self.logger.logInfo(f'Added {_family} nat {chain_name} rule for source network {dest_net} to {target_net}')
|
||||
except:
|
||||
self.logger.logCrit(f"Error running SNAT on {_family}, retrying...")
|
||||
|
||||
def get_chain_handle(self, _family: str, _table: str, chain_name: str):
|
||||
chain_handle = None
|
||||
# Command: 'nft list chains {family}'
|
||||
_list = {'list': {'chains': {'family': _family} } }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("chain"):
|
||||
continue
|
||||
chain = _object["chain"]
|
||||
if chain["family"] == _family and chain["table"] == _table and chain["name"] == chain_name:
|
||||
chain_handle = chain["handle"]
|
||||
break
|
||||
return chain_handle
|
||||
|
||||
def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"):
|
||||
rule_handle = []
|
||||
# Command: 'nft list chain {family} {table} {chain_name}'
|
||||
_chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
|
||||
_list = {'list': {'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
|
||||
if rule.get("comment") and rule["comment"] == _comment_filter:
|
||||
rule_handle.append(rule["handle"])
|
||||
return rule_handle
|
||||
|
||||
def get_ban_ip_dict(self, ipaddr: str, _family: str):
|
||||
json_command = self.get_base_dict()
|
||||
|
||||
expr_opt = []
|
||||
ipaddr_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
right_dict = {'prefix': {'addr': str(ipaddr_net.network_address), 'len': int(ipaddr_net.prefixlen) } }
|
||||
|
||||
left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} }
|
||||
match_dict = {'op': '==', 'left': left_dict, 'right': right_dict }
|
||||
expr_opt.append({'match': match_dict})
|
||||
|
||||
counter_dict = {'counter': {'family': _family, 'table': "filter", 'packets': 0, 'bytes': 0} }
|
||||
expr_opt.append(counter_dict)
|
||||
|
||||
expr_opt.append({'drop': "null"})
|
||||
|
||||
rule_dict = {'family': _family, 'table': "filter", 'chain': self.chain_name, 'expr': expr_opt}
|
||||
|
||||
base_dict = {'insert': {'rule': rule_dict} }
|
||||
json_command["nftables"].append(base_dict)
|
||||
|
||||
return json_command
|
||||
|
||||
def get_unban_ip_dict(self, ipaddr:str, _family: str):
|
||||
json_command = self.get_base_dict()
|
||||
# Command: 'nft list chain {s_family} filter MAILCOW'
|
||||
_chain_opts = {'family': _family, 'table': 'filter', 'name': self.chain_name}
|
||||
_list = {'list': {'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
rule_handle = None
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]["expr"][0]["match"]
|
||||
left_opt = rule["left"]["payload"]
|
||||
if not left_opt["protocol"] == _family:
|
||||
continue
|
||||
if not left_opt["field"] =="saddr":
|
||||
continue
|
||||
|
||||
# ip currently banned
|
||||
rule_right = rule["right"]
|
||||
if isinstance(rule_right, dict):
|
||||
current_rule_ip = rule_right["prefix"]["addr"] + '/' + str(rule_right["prefix"]["len"])
|
||||
else:
|
||||
current_rule_ip = rule_right
|
||||
current_rule_net = ipaddress.ip_network(current_rule_ip)
|
||||
|
||||
# ip to ban
|
||||
candidate_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
|
||||
if current_rule_net == candidate_net:
|
||||
rule_handle = _object["rule"]["handle"]
|
||||
break
|
||||
|
||||
if rule_handle is not None:
|
||||
mailcow_rule = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule_handle}
|
||||
delete_rule = {'delete': {'rule': mailcow_rule} }
|
||||
json_command["nftables"].append(delete_rule)
|
||||
else:
|
||||
return False
|
||||
|
||||
return json_command
|
||||
|
||||
def check_mailcow_chains(self, family: str, chain: str):
|
||||
position = 0
|
||||
rule_found = False
|
||||
chain_name = self.nft_chain_names[family]['filter'][chain]
|
||||
|
||||
if not chain_name: return None
|
||||
|
||||
_chain_opts = {'family': family, 'table': 'filter', 'name': chain_name}
|
||||
_list = {'list': {'chain': _chain_opts}}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
rule = _object["rule"]
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
rule_found = True
|
||||
break
|
||||
|
||||
position+=1
|
||||
|
||||
return position if rule_found else False
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
family = "ip"
|
||||
table = "filter"
|
||||
comment_filter_drop = "mailcow isolation"
|
||||
comment_filter_allow = "mailcow isolation allow"
|
||||
json_command = self.get_base_dict()
|
||||
|
||||
# Delete old mailcow isolation rules
|
||||
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_drop)
|
||||
for handle in handles:
|
||||
self.delete_filter_rule(family, self.chain_name, handle)
|
||||
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_allow)
|
||||
for handle in handles:
|
||||
self.delete_filter_rule(family, self.chain_name, handle)
|
||||
|
||||
# insert mailcow isolation rule
|
||||
_match_dict_drop = [
|
||||
{
|
||||
"match": {
|
||||
"op": "!=",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "iifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "oifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "tcp",
|
||||
"field": "dport"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"set": _dports
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"counter": {
|
||||
"packets": 0,
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"drop": None
|
||||
}
|
||||
]
|
||||
rule_drop = { "insert": { "rule": {
|
||||
"family": family,
|
||||
"table": table,
|
||||
"chain": self.chain_name,
|
||||
"comment": comment_filter_drop,
|
||||
"expr": _match_dict_drop
|
||||
}}}
|
||||
json_command["nftables"].append(rule_drop)
|
||||
|
||||
# insert mailcow isolation allow rule
|
||||
if _allow != "":
|
||||
_match_dict_allow = [
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "ip",
|
||||
"field": "saddr"
|
||||
}
|
||||
},
|
||||
"right": _allow
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "!=",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "iifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "oifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "tcp",
|
||||
"field": "dport"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"set": _dports
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"counter": {
|
||||
"packets": 0,
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"accept": None
|
||||
}
|
||||
]
|
||||
rule_allow = { "insert": { "rule": {
|
||||
"family": family,
|
||||
"table": table,
|
||||
"chain": self.chain_name,
|
||||
"comment": comment_filter_allow,
|
||||
"expr": _match_dict_allow
|
||||
}}}
|
||||
json_command["nftables"].append(rule_allow)
|
||||
|
||||
success = self.nft_exec_dict(json_command)
|
||||
if success == False:
|
||||
self.logger.logCrit(f"Error adding {self.chain_name} isolation")
|
||||
return False
|
||||
|
||||
return True
|
0
data/Dockerfiles/netfilter/modules/__init__.py
Normal file
0
data/Dockerfiles/netfilter/modules/__init__.py
Normal file
@ -1,610 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import signal
|
||||
import ipaddress
|
||||
from collections import Counter
|
||||
from random import randint
|
||||
from threading import Thread
|
||||
from threading import Lock
|
||||
import redis
|
||||
import json
|
||||
import iptc
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
|
||||
while True:
|
||||
try:
|
||||
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
|
||||
redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
|
||||
if "".__eq__(redis_slaveof_ip):
|
||||
r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
|
||||
else:
|
||||
r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
|
||||
r.ping()
|
||||
except Exception as ex:
|
||||
print('%s - trying again in 3 seconds' % (ex))
|
||||
time.sleep(3)
|
||||
else:
|
||||
break
|
||||
|
||||
pubsub = r.pubsub()
|
||||
|
||||
WHITELIST = []
|
||||
BLACKLIST= []
|
||||
|
||||
bans = {}
|
||||
|
||||
quit_now = False
|
||||
exit_code = 0
|
||||
lock = Lock()
|
||||
|
||||
def log(priority, message):
|
||||
tolog = {}
|
||||
tolog['time'] = int(round(time.time()))
|
||||
tolog['priority'] = priority
|
||||
tolog['message'] = message
|
||||
r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
|
||||
print(message)
|
||||
|
||||
def logWarn(message):
|
||||
log('warn', message)
|
||||
|
||||
def logCrit(message):
|
||||
log('crit', message)
|
||||
|
||||
def logInfo(message):
|
||||
log('info', message)
|
||||
|
||||
def refreshF2boptions():
|
||||
global f2boptions
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
f2boptions = {}
|
||||
|
||||
if not r.get('F2B_OPTIONS'):
|
||||
f2boptions['ban_time'] = r.get('F2B_BAN_TIME')
|
||||
f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME')
|
||||
f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT')
|
||||
f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS')
|
||||
f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW')
|
||||
f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4')
|
||||
f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6')
|
||||
else:
|
||||
try:
|
||||
f2boptions = json.loads(r.get('F2B_OPTIONS'))
|
||||
except ValueError:
|
||||
print('Error loading F2B options: F2B_OPTIONS is not json')
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
verifyF2boptions(f2boptions)
|
||||
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
|
||||
|
||||
def verifyF2boptions(f2boptions):
|
||||
verifyF2boption(f2boptions,'ban_time', 1800)
|
||||
verifyF2boption(f2boptions,'max_ban_time', 10000)
|
||||
verifyF2boption(f2boptions,'ban_time_increment', True)
|
||||
verifyF2boption(f2boptions,'max_attempts', 10)
|
||||
verifyF2boption(f2boptions,'retry_window', 600)
|
||||
verifyF2boption(f2boptions,'netban_ipv4', 32)
|
||||
verifyF2boption(f2boptions,'netban_ipv6', 128)
|
||||
|
||||
def verifyF2boption(f2boptions, f2boption, f2bdefault):
|
||||
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
|
||||
|
||||
def refreshF2bregex():
|
||||
global f2bregex
|
||||
global quit_now
|
||||
global exit_code
|
||||
if not r.get('F2B_REGEX'):
|
||||
f2bregex = {}
|
||||
f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
|
||||
f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
|
||||
f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
|
||||
f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
|
||||
f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
|
||||
f2bregex[6] = '-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
|
||||
f2bregex[7] = '-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[8] = '-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
|
||||
f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
|
||||
r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
|
||||
else:
|
||||
try:
|
||||
f2bregex = {}
|
||||
f2bregex = json.loads(r.get('F2B_REGEX'))
|
||||
except ValueError:
|
||||
print('Error loading F2B options: F2B_REGEX is not json')
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
if r.exists('F2B_LOG'):
|
||||
r.rename('F2B_LOG', 'NETFILTER_LOG')
|
||||
|
||||
def mailcowChainOrder():
|
||||
global lock
|
||||
global quit_now
|
||||
global exit_code
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
filter4_table = iptc.Table(iptc.Table.FILTER)
|
||||
filter6_table = iptc.Table6(iptc.Table6.FILTER)
|
||||
filter4_table.refresh()
|
||||
filter6_table.refresh()
|
||||
for f in [filter4_table, filter6_table]:
|
||||
forward_chain = iptc.Chain(f, 'FORWARD')
|
||||
input_chain = iptc.Chain(f, 'INPUT')
|
||||
for chain in [forward_chain, input_chain]:
|
||||
target_found = False
|
||||
for position, item in enumerate(chain.rules):
|
||||
if item.target.name == 'MAILCOW':
|
||||
target_found = True
|
||||
if position > 2:
|
||||
logCrit('Error in %s chain order: MAILCOW on position %d, restarting container' % (chain.name, position))
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
if not target_found:
|
||||
logCrit('Error in %s chain: MAILCOW target not found, restarting container' % (chain.name))
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def ban(address):
|
||||
global lock
|
||||
refreshF2boptions()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
RETRY_WINDOW = int(f2boptions['retry_window'])
|
||||
NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
|
||||
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
|
||||
|
||||
ip = ipaddress.ip_address(address)
|
||||
if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
address = str(ip)
|
||||
if ip.is_private or ip.is_loopback:
|
||||
return
|
||||
|
||||
self_network = ipaddress.ip_network(address)
|
||||
|
||||
with lock:
|
||||
temp_whitelist = set(WHITELIST)
|
||||
|
||||
if temp_whitelist:
|
||||
for wl_key in temp_whitelist:
|
||||
wl_net = ipaddress.ip_network(wl_key, False)
|
||||
if wl_net.overlaps(self_network):
|
||||
logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
|
||||
return
|
||||
|
||||
net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
|
||||
net = str(net)
|
||||
|
||||
if not net in bans:
|
||||
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
|
||||
|
||||
bans[net]['attempts'] += 1
|
||||
bans[net]['last_attempt'] = time.time()
|
||||
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
cur_time = int(round(time.time()))
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
|
||||
if type(ip) is ipaddress.IPv4Address:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
else:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule6()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
|
||||
else:
|
||||
logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
|
||||
|
||||
def unban(net):
|
||||
global lock
|
||||
if not net in bans:
|
||||
logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
return
|
||||
logInfo('Unbanning %s' % net)
|
||||
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
else:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule6()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
r.hdel('F2B_ACTIVE_BANS', '%s' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
if net in bans:
|
||||
bans[net]['attempts'] = 0
|
||||
bans[net]['ban_counter'] += 1
|
||||
|
||||
def permBan(net, unban=False):
|
||||
global lock
|
||||
if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules and not unban:
|
||||
logCrit('Add host/network %s to blacklist' % net)
|
||||
chain.insert_rule(rule)
|
||||
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
|
||||
elif rule in chain.rules and unban:
|
||||
logCrit('Remove host/network %s from blacklist' % net)
|
||||
chain.delete_rule(rule)
|
||||
r.hdel('F2B_PERM_BANS', '%s' % net)
|
||||
else:
|
||||
with lock:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW')
|
||||
rule = iptc.Rule6()
|
||||
rule.src = net
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules and not unban:
|
||||
logCrit('Add host/network %s to blacklist' % net)
|
||||
chain.insert_rule(rule)
|
||||
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
|
||||
elif rule in chain.rules and unban:
|
||||
logCrit('Remove host/network %s from blacklist' % net)
|
||||
chain.delete_rule(rule)
|
||||
r.hdel('F2B_PERM_BANS', '%s' % net)
|
||||
|
||||
def quit(signum, frame):
|
||||
global quit_now
|
||||
quit_now = True
|
||||
|
||||
def clear():
|
||||
global lock
|
||||
logInfo('Clearing all bans')
|
||||
for net in bans.copy():
|
||||
unban(net)
|
||||
with lock:
|
||||
filter4_table = iptc.Table(iptc.Table.FILTER)
|
||||
filter6_table = iptc.Table6(iptc.Table6.FILTER)
|
||||
for filter_table in [filter4_table, filter6_table]:
|
||||
filter_table.autocommit = False
|
||||
forward_chain = iptc.Chain(filter_table, "FORWARD")
|
||||
input_chain = iptc.Chain(filter_table, "INPUT")
|
||||
mailcow_chain = iptc.Chain(filter_table, "MAILCOW")
|
||||
if mailcow_chain in filter_table.chains:
|
||||
for rule in mailcow_chain.rules:
|
||||
mailcow_chain.delete_rule(rule)
|
||||
for rule in forward_chain.rules:
|
||||
if rule.target.name == 'MAILCOW':
|
||||
forward_chain.delete_rule(rule)
|
||||
for rule in input_chain.rules:
|
||||
if rule.target.name == 'MAILCOW':
|
||||
input_chain.delete_rule(rule)
|
||||
filter_table.delete_chain("MAILCOW")
|
||||
filter_table.commit()
|
||||
filter_table.refresh()
|
||||
filter_table.autocommit = True
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
pubsub.unsubscribe()
|
||||
|
||||
def watch():
|
||||
logInfo('Watching Redis channel F2B_CHANNEL')
|
||||
pubsub.subscribe('F2B_CHANNEL')
|
||||
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
while not quit_now:
|
||||
try:
|
||||
for item in pubsub.listen():
|
||||
refreshF2bregex()
|
||||
for rule_id, rule_regex in f2bregex.items():
|
||||
if item['data'] and item['type'] == 'message':
|
||||
try:
|
||||
result = re.search(rule_regex, item['data'])
|
||||
except re.error:
|
||||
result = False
|
||||
if result:
|
||||
addr = result.group(1)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback:
|
||||
continue
|
||||
logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
|
||||
ban(addr)
|
||||
except Exception as ex:
|
||||
logWarn('Error reading log line from pubsub: %s' % ex)
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def snat4(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
def get_snat4_rule():
|
||||
rule = iptc.Rule()
|
||||
rule.src = os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24'
|
||||
rule.dst = '!' + rule.src
|
||||
target = rule.create_target("SNAT")
|
||||
target.to_source = snat_target
|
||||
match = rule.create_match("comment")
|
||||
match.comment = f'{int(round(time.time()))}'
|
||||
return rule
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
try:
|
||||
table = iptc.Table('nat')
|
||||
table.refresh()
|
||||
chain = iptc.Chain(table, 'POSTROUTING')
|
||||
table.autocommit = False
|
||||
new_rule = get_snat4_rule()
|
||||
|
||||
if not chain.rules:
|
||||
# if there are no rules in the chain, insert the new rule directly
|
||||
logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
|
||||
chain.insert_rule(new_rule)
|
||||
else:
|
||||
for position, rule in enumerate(chain.rules):
|
||||
if not hasattr(rule.target, 'parameter'):
|
||||
continue
|
||||
match = all((
|
||||
new_rule.get_src() == rule.get_src(),
|
||||
new_rule.get_dst() == rule.get_dst(),
|
||||
new_rule.target.parameters == rule.target.parameters,
|
||||
new_rule.target.name == rule.target.name
|
||||
))
|
||||
if position == 0:
|
||||
if not match:
|
||||
logInfo(f'Added POSTROUTING rule for source network {new_rule.src} to SNAT target {snat_target}')
|
||||
chain.insert_rule(new_rule)
|
||||
else:
|
||||
if match:
|
||||
logInfo(f'Remove rule for source network {new_rule.src} to SNAT target {snat_target} from POSTROUTING chain at position {position}')
|
||||
chain.delete_rule(rule)
|
||||
|
||||
table.commit()
|
||||
table.autocommit = True
|
||||
except:
|
||||
print('Error running SNAT4, retrying...')
|
||||
|
||||
def snat6(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
def get_snat6_rule():
|
||||
rule = iptc.Rule6()
|
||||
rule.src = os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64')
|
||||
rule.dst = '!' + rule.src
|
||||
target = rule.create_target("SNAT")
|
||||
target.to_source = snat_target
|
||||
return rule
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
try:
|
||||
table = iptc.Table6('nat')
|
||||
table.refresh()
|
||||
chain = iptc.Chain(table, 'POSTROUTING')
|
||||
table.autocommit = False
|
||||
if get_snat6_rule() not in chain.rules:
|
||||
logInfo('Added POSTROUTING rule for source network %s to SNAT target %s' % (get_snat6_rule().src, snat_target))
|
||||
chain.insert_rule(get_snat6_rule())
|
||||
table.commit()
|
||||
else:
|
||||
for position, item in enumerate(chain.rules):
|
||||
if item == get_snat6_rule():
|
||||
if position != 0:
|
||||
chain.delete_rule(get_snat6_rule())
|
||||
table.commit()
|
||||
table.autocommit = True
|
||||
except:
|
||||
print('Error running SNAT6, retrying...')
|
||||
|
||||
def autopurge():
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
refreshF2boptions()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
MAX_BAN_TIME = int(f2boptions['max_ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
|
||||
if QUEUE_UNBAN:
|
||||
for net in QUEUE_UNBAN:
|
||||
unban(str(net))
|
||||
for net in bans.copy():
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
|
||||
if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME:
|
||||
unban(net)
|
||||
|
||||
def isIpNetwork(address):
|
||||
try:
|
||||
ipaddress.ip_network(address, False)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def genNetworkList(list):
|
||||
resolver = dns.resolver.Resolver()
|
||||
hostnames = []
|
||||
networks = []
|
||||
for key in list:
|
||||
if isIpNetwork(key):
|
||||
networks.append(key)
|
||||
else:
|
||||
hostnames.append(key)
|
||||
for hostname in hostnames:
|
||||
hostname_ips = []
|
||||
for rdtype in ['A', 'AAAA']:
|
||||
try:
|
||||
answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
|
||||
except dns.exception.Timeout:
|
||||
logInfo('Hostname %s timedout on resolve' % hostname)
|
||||
break
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
continue
|
||||
except dns.exception.DNSException as dnsexception:
|
||||
logInfo('%s' % dnsexception)
|
||||
continue
|
||||
for rdata in answer:
|
||||
hostname_ips.append(rdata.to_text())
|
||||
networks.extend(hostname_ips)
|
||||
return set(networks)
|
||||
|
||||
def whitelistUpdate():
|
||||
global lock
|
||||
global quit_now
|
||||
global WHITELIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_WHITELIST')
|
||||
new_whitelist = []
|
||||
if list:
|
||||
new_whitelist = genNetworkList(list)
|
||||
with lock:
|
||||
if Counter(new_whitelist) != Counter(WHITELIST):
|
||||
WHITELIST = new_whitelist
|
||||
logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def blacklistUpdate():
|
||||
global quit_now
|
||||
global BLACKLIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_BLACKLIST')
|
||||
new_blacklist = []
|
||||
if list:
|
||||
new_blacklist = genNetworkList(list)
|
||||
if Counter(new_blacklist) != Counter(BLACKLIST):
|
||||
addban = set(new_blacklist).difference(BLACKLIST)
|
||||
delban = set(BLACKLIST).difference(new_blacklist)
|
||||
BLACKLIST = new_blacklist
|
||||
logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
|
||||
if addban:
|
||||
for net in addban:
|
||||
permBan(net=net)
|
||||
if delban:
|
||||
for net in delban:
|
||||
permBan(net=net, unban=True)
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def initChain():
|
||||
# Is called before threads start, no locking
|
||||
print("Initializing mailcow netfilter chain")
|
||||
# IPv4
|
||||
if not iptc.Chain(iptc.Table(iptc.Table.FILTER), "MAILCOW") in iptc.Table(iptc.Table.FILTER).chains:
|
||||
iptc.Table(iptc.Table.FILTER).create_chain("MAILCOW")
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
|
||||
rule = iptc.Rule()
|
||||
rule.src = '0.0.0.0/0'
|
||||
rule.dst = '0.0.0.0/0'
|
||||
target = iptc.Target(rule, "MAILCOW")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
# IPv6
|
||||
if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), "MAILCOW") in iptc.Table6(iptc.Table6.FILTER).chains:
|
||||
iptc.Table6(iptc.Table6.FILTER).create_chain("MAILCOW")
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = '::/0'
|
||||
rule.dst = '::/0'
|
||||
target = iptc.Target(rule, "MAILCOW")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
# In case a previous session was killed without cleanup
|
||||
clear()
|
||||
# Reinit MAILCOW chain
|
||||
initChain()
|
||||
|
||||
watch_thread = Thread(target=watch)
|
||||
watch_thread.daemon = True
|
||||
watch_thread.start()
|
||||
|
||||
if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv4Address:
|
||||
snat4_thread = Thread(target=snat4,args=(snat_ip,))
|
||||
snat4_thread.daemon = True
|
||||
snat4_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
|
||||
|
||||
if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT6_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv6Address:
|
||||
snat6_thread = Thread(target=snat6,args=(snat_ip,))
|
||||
snat6_thread.daemon = True
|
||||
snat6_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address')
|
||||
|
||||
autopurge_thread = Thread(target=autopurge)
|
||||
autopurge_thread.daemon = True
|
||||
autopurge_thread.start()
|
||||
|
||||
mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
|
||||
mailcowchainwatch_thread.daemon = True
|
||||
mailcowchainwatch_thread.start()
|
||||
|
||||
blacklistupdate_thread = Thread(target=blacklistUpdate)
|
||||
blacklistupdate_thread.daemon = True
|
||||
blacklistupdate_thread.start()
|
||||
|
||||
whitelistupdate_thread = Thread(target=whitelistUpdate)
|
||||
whitelistupdate_thread.daemon = True
|
||||
whitelistupdate_thread.start()
|
||||
|
||||
signal.signal(signal.SIGTERM, quit)
|
||||
atexit.register(clear)
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(0.5)
|
||||
|
||||
sys.exit(exit_code)
|
@ -1,6 +1,7 @@
|
||||
FROM alpine:3.17
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
WORKDIR /app
|
||||
|
||||
#RUN addgroup -S olefy && adduser -S olefy -G olefy \
|
||||
|
@ -1,8 +1,8 @@
|
||||
FROM php:8.2-fpm-alpine3.17
|
||||
FROM php:8.2-fpm-alpine3.18
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG APCU_PECL_VERSION=5.1.22
|
||||
ARG APCU_PECL_VERSION=5.1.23
|
||||
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG IMAGICK_PECL_VERSION=3.7.0
|
||||
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
@ -10,9 +10,9 @@ ARG MAILPARSE_PECL_VERSION=3.1.6
|
||||
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MEMCACHED_PECL_VERSION=3.2.0
|
||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG REDIS_PECL_VERSION=6.0.1
|
||||
ARG REDIS_PECL_VERSION=6.0.2
|
||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG COMPOSER_VERSION=2.6.5
|
||||
ARG COMPOSER_VERSION=2.6.6
|
||||
|
||||
RUN apk add -U --no-cache autoconf \
|
||||
aspell-dev \
|
||||
|
@ -1,5 +1,5 @@
|
||||
FROM debian:bullseye-slim
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV LC_ALL C
|
||||
|
@ -1,5 +1,5 @@
|
||||
FROM debian:bullseye-slim
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG CODENAME=bullseye
|
||||
@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y \
|
||||
dnsutils \
|
||||
netcat \
|
||||
&& apt-key adv --fetch-keys https://rspamd.com/apt-stable/gpg.key \
|
||||
&& echo "deb [arch=amd64] https://rspamd.com/apt-stable/ $CODENAME main" > /etc/apt/sources.list.d/rspamd.list \
|
||||
&& echo "deb https://rspamd.com/apt-stable/ $CODENAME main" > /etc/apt/sources.list.d/rspamd.list \
|
||||
&& apt-get update \
|
||||
&& apt-get --no-install-recommends -y install rspamd redis-tools procps nano \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|
@ -79,6 +79,9 @@ EOF
|
||||
redis-cli -h redis-mailcow SLAVEOF NO ONE
|
||||
fi
|
||||
|
||||
# Provide additional lua modules
|
||||
ln -s /usr/lib/$(uname -m)-linux-gnu/liblua5.1-cjson.so.0.0.0 /usr/lib/rspamd/cjson.so
|
||||
|
||||
chown -R _rspamd:_rspamd /var/lib/rspamd \
|
||||
/etc/rspamd/local.d \
|
||||
/etc/rspamd/override.d \
|
||||
|
@ -1,10 +1,11 @@
|
||||
FROM debian:bullseye-slim
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG SOGO_DEBIAN_REPOSITORY=http://packages.sogo.nu/nightly/5/debian/
|
||||
ARG DEBIAN_VERSION=bullseye
|
||||
ARG SOGO_DEBIAN_REPOSITORY=http://www.axis.cz/linux/debian
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.16
|
||||
ARG GOSU_VERSION=1.17
|
||||
ENV LC_ALL C
|
||||
|
||||
# Prerequisites
|
||||
@ -21,7 +22,7 @@ RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
dirmngr \
|
||||
netcat \
|
||||
netcat-traditional \
|
||||
psmisc \
|
||||
wget \
|
||||
patch \
|
||||
@ -32,7 +33,7 @@ RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
|
||||
&& mkdir /usr/share/doc/sogo \
|
||||
&& touch /usr/share/doc/sogo/empty.sh \
|
||||
&& apt-key adv --keyserver keys.openpgp.org --recv-key 74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 \
|
||||
&& echo "deb ${SOGO_DEBIAN_REPOSITORY} bullseye bullseye" > /etc/apt/sources.list.d/sogo.list \
|
||||
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} sogo-v5" > /etc/apt/sources.list.d/sogo.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
sogo \
|
||||
sogo-activesync \
|
||||
|
@ -3,7 +3,7 @@ FROM solr:7.7-slim
|
||||
USER root
|
||||
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.16
|
||||
ARG GOSU_VERSION=1.17
|
||||
|
||||
COPY solr.sh /
|
||||
COPY solr-config-7.7.0.xml /
|
||||
|
@ -1,9 +1,10 @@
|
||||
FROM alpine:3.17
|
||||
FROM alpine:3.18
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
curl \
|
||||
bind-tools \
|
||||
unbound \
|
||||
bash \
|
||||
openssl \
|
||||
@ -18,10 +19,10 @@ EXPOSE 53/udp 53/tcp
|
||||
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
# healthcheck (nslookup)
|
||||
# healthcheck (dig, ping)
|
||||
COPY healthcheck.sh /healthcheck.sh
|
||||
RUN chmod +x /healthcheck.sh
|
||||
HEALTHCHECK --interval=30s --timeout=10s CMD [ "/healthcheck.sh" ]
|
||||
HEALTHCHECK --interval=30s --timeout=30s CMD [ "/healthcheck.sh" ]
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
|
@ -1,12 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
nslookup mailcow.email 127.0.0.1 1> /dev/null
|
||||
# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!)
|
||||
if [[ "${SKIP_UNBOUND_HEALTHCHECK}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
SKIP_UNBOUND_HEALTHCHECK=y
|
||||
fi
|
||||
|
||||
if [ $? == 0 ]; then
|
||||
echo "DNS resolution is working!"
|
||||
# Declare log function for logfile inside container
|
||||
function log_to_file() {
|
||||
echo "$(date +"%Y-%m-%d %H:%M:%S"): $1" > /var/log/healthcheck.log
|
||||
}
|
||||
|
||||
# General Ping function to check general pingability
|
||||
function check_ping() {
|
||||
declare -a ipstoping=("1.1.1.1" "8.8.8.8" "9.9.9.9")
|
||||
|
||||
for ip in "${ipstoping[@]}" ; do
|
||||
ping -q -c 3 -w 5 "$ip"
|
||||
if [ $? -ne 0 ]; then
|
||||
log_to_file "Healthcheck: Couldn't ping $ip for 5 seconds... Gave up!"
|
||||
log_to_file "Please check your internet connection or firewall rules to fix this error, because a simple ping test should always go through from the unbound container!"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
log_to_file "Healthcheck: Ping Checks WORKING properly!"
|
||||
return 0
|
||||
}
|
||||
|
||||
# General DNS Resolve Check against Unbound Resolver himself
|
||||
function check_dns() {
|
||||
declare -a domains=("mailcow.email" "github.com" "hub.docker.com")
|
||||
|
||||
for domain in "${domains[@]}" ; do
|
||||
for ((i=1; i<=3; i++)); do
|
||||
dig +short +timeout=2 +tries=1 "$domain" @127.0.0.1 > /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
log_to_file "Healthcheck: DNS Resolution Failed on $i attempt! Trying again..."
|
||||
if [ $i -eq 3 ]; then
|
||||
log_to_file "Healthcheck: DNS Resolution not possible after $i attempts... Gave up!"
|
||||
log_to_file "Maybe check your outbound firewall, as it needs to resolve DNS over TCP AND UDP!"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
log_to_file "Healthcheck: DNS Resolver WORKING properly!"
|
||||
return 0
|
||||
|
||||
}
|
||||
|
||||
if [[ ${SKIP_UNBOUND_HEALTHCHECK} == "y" ]]; then
|
||||
log_to_file "Healthcheck: ALL CHECKS WERE SKIPPED! Unbound is healthy!"
|
||||
exit 0
|
||||
else
|
||||
echo "DNS resolution is not working correctly..."
|
||||
echo "Maybe check your outbound firewall, as it needs to resolve DNS over TCP AND UDP!"
|
||||
fi
|
||||
|
||||
# run checks, if check is not returning 0 (return value if check is ok), healthcheck will exit with 1 (marked in docker as unhealthy)
|
||||
check_ping
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
check_dns
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_to_file "Healthcheck: ALL CHECKS WERE SUCCESSFUL! Unbound is healthy!"
|
||||
exit 0
|
@ -1,5 +1,5 @@
|
||||
FROM alpine:3.17
|
||||
LABEL maintainer "André Peters <andre.peters@servercow.de>"
|
||||
FROM alpine:3.18
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# Installation
|
||||
RUN apk add --update \
|
||||
|
@ -19,9 +19,11 @@ fi
|
||||
|
||||
if [[ "${WATCHDOG_VERBOSE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
SMTP_VERBOSE="--verbose"
|
||||
CURL_VERBOSE="--verbose"
|
||||
set -xv
|
||||
else
|
||||
SMTP_VERBOSE=""
|
||||
CURL_VERBOSE=""
|
||||
exec 2>/dev/null
|
||||
fi
|
||||
|
||||
@ -97,7 +99,9 @@ log_msg() {
|
||||
echo $(date) $(printf '%s\n' "${1}")
|
||||
}
|
||||
|
||||
function mail_error() {
|
||||
function notify_error() {
|
||||
# Check if one of the notification options is enabled
|
||||
[[ -z ${WATCHDOG_NOTIFY_EMAIL} ]] && [[ -z ${WATCHDOG_NOTIFY_WEBHOOK} ]] && return 0
|
||||
THROTTLE=
|
||||
[[ -z ${1} ]] && return 1
|
||||
# If exists, body will be the content of "/tmp/${1}", even if ${2} is set
|
||||
@ -122,6 +126,9 @@ function mail_error() {
|
||||
else
|
||||
SUBJECT="${WATCHDOG_SUBJECT}: ${1}"
|
||||
fi
|
||||
|
||||
# Send mail notification if enabled
|
||||
if [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]]; then
|
||||
IFS=',' read -r -a MAIL_RCPTS <<< "${WATCHDOG_NOTIFY_EMAIL}"
|
||||
for rcpt in "${MAIL_RCPTS[@]}"; do
|
||||
RCPT_DOMAIN=
|
||||
@ -153,6 +160,23 @@ function mail_error() {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Send webhook notification if enabled
|
||||
if [[ ! -z ${WATCHDOG_NOTIFY_WEBHOOK} ]]; then
|
||||
if [[ -z ${WATCHDOG_NOTIFY_WEBHOOK_BODY} ]]; then
|
||||
log_msg "No webhook body set, skipping webhook notification..."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Replace subject and body placeholders
|
||||
WEBHOOK_BODY=$(echo ${WATCHDOG_NOTIFY_WEBHOOK_BODY} | sed "s/\$SUBJECT\|\${SUBJECT}/$SUBJECT/g" | sed "s/\$BODY\|\${BODY}/$BODY/g")
|
||||
|
||||
# POST to webhook
|
||||
curl -X POST -H "Content-Type: application/json" ${CURL_VERBOSE} -d "${WEBHOOK_BODY}" ${WATCHDOG_NOTIFY_WEBHOOK}
|
||||
|
||||
log_msg "Sent notification using webhook"
|
||||
fi
|
||||
}
|
||||
|
||||
get_container_ip() {
|
||||
@ -197,7 +221,7 @@ get_container_ip() {
|
||||
# One-time check
|
||||
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
|
||||
if [[ -z "$(get_ipv6)" ]]; then
|
||||
mail_error "ipv6-config" "enable_ipv6 is true in docker-compose.yml, but an IPv6 link could not be established. Please verify your IPv6 connection."
|
||||
notify_error "ipv6-config" "enable_ipv6 is true in docker-compose.yml, but an IPv6 link could not be established. Please verify your IPv6 connection."
|
||||
fi
|
||||
fi
|
||||
|
||||
@ -692,8 +716,8 @@ rspamd_checks() {
|
||||
From: watchdog@localhost
|
||||
|
||||
Empty
|
||||
' | usr/bin/curl --max-time 10 -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/scan | jq -rc .default.required_score)
|
||||
if [[ ${SCORE} != "9999" ]]; then
|
||||
' | usr/bin/curl --max-time 10 -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/scan | jq -rc .default.required_score | sed 's/\..*//' )
|
||||
if [[ ${SCORE} -ne 9999 ]]; then
|
||||
echo "Rspamd settings check failed, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
|
||||
err_count=$(( ${err_count} + 1))
|
||||
else
|
||||
@ -746,8 +770,8 @@ olefy_checks() {
|
||||
}
|
||||
|
||||
# Notify about start
|
||||
if [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]]; then
|
||||
mail_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
|
||||
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
|
||||
fi
|
||||
|
||||
# Create watchdog agents
|
||||
@ -1029,33 +1053,33 @@ while true; do
|
||||
fi
|
||||
if [[ ${com_pipe_answer} == "ratelimit" ]]; then
|
||||
log_msg "At least one ratelimit was applied"
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
|
||||
notify_error "${com_pipe_answer}"
|
||||
elif [[ ${com_pipe_answer} == "mail_queue_status" ]]; then
|
||||
log_msg "Mail queue status is critical"
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
|
||||
notify_error "${com_pipe_answer}"
|
||||
elif [[ ${com_pipe_answer} == "external_checks" ]]; then
|
||||
log_msg "Your mailcow is an open relay!"
|
||||
# Define $2 to override message text, else print service was restarted at ...
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please stop mailcow now and check your network configuration!"
|
||||
notify_error "${com_pipe_answer}" "Please stop mailcow now and check your network configuration!"
|
||||
elif [[ ${com_pipe_answer} == "mysql_repl_checks" ]]; then
|
||||
log_msg "MySQL replication is not working properly"
|
||||
# Define $2 to override message text, else print service was restarted at ...
|
||||
# Once mail per 10 minutes
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check the SQL replication status" 600
|
||||
notify_error "${com_pipe_answer}" "Please check the SQL replication status" 600
|
||||
elif [[ ${com_pipe_answer} == "dovecot_repl_checks" ]]; then
|
||||
log_msg "Dovecot replication is not working properly"
|
||||
# Define $2 to override message text, else print service was restarted at ...
|
||||
# Once mail per 10 minutes
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check the Dovecot replicator status" 600
|
||||
notify_error "${com_pipe_answer}" "Please check the Dovecot replicator status" 600
|
||||
elif [[ ${com_pipe_answer} == "certcheck" ]]; then
|
||||
log_msg "Certificates are about to expire"
|
||||
# Define $2 to override message text, else print service was restarted at ...
|
||||
# Only mail once a day
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please renew your certificate" 86400
|
||||
notify_error "${com_pipe_answer}" "Please renew your certificate" 86400
|
||||
elif [[ ${com_pipe_answer} == "acme-mailcow" ]]; then
|
||||
log_msg "acme-mailcow did not complete successfully"
|
||||
# Define $2 to override message text, else print service was restarted at ...
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}" "Please check acme-mailcow for further information."
|
||||
notify_error "${com_pipe_answer}" "Please check acme-mailcow for further information."
|
||||
elif [[ ${com_pipe_answer} == "fail2ban" ]]; then
|
||||
F2B_RES=($(timeout 4s ${REDIS_CMDLINE} --raw GET F2B_RES 2> /dev/null))
|
||||
if [[ ! -z "${F2B_RES}" ]]; then
|
||||
@ -1065,7 +1089,7 @@ while true; do
|
||||
log_msg "Banned ${host}"
|
||||
rm /tmp/fail2ban 2> /dev/null
|
||||
timeout 2s whois "${host}" > /tmp/fail2ban
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && [[ ${WATCHDOG_NOTIFY_BAN} =~ ^([yY][eE][sS]|[yY])+$ ]] && mail_error "${com_pipe_answer}" "IP ban: ${host}"
|
||||
[[ ${WATCHDOG_NOTIFY_BAN} =~ ^([yY][eE][sS]|[yY])+$ ]] && notify_error "${com_pipe_answer}" "IP ban: ${host}"
|
||||
done
|
||||
fi
|
||||
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
|
||||
@ -1085,7 +1109,7 @@ while true; do
|
||||
else
|
||||
log_msg "Sending restart command to ${CONTAINER_ID}..."
|
||||
curl --silent --insecure -XPOST https://dockerapi/containers/${CONTAINER_ID}/restart
|
||||
[[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${com_pipe_answer}"
|
||||
notify_error "${com_pipe_answer}"
|
||||
log_msg "Wait for restarted container to settle and continue watching..."
|
||||
sleep 35
|
||||
fi
|
||||
@ -1095,3 +1119,4 @@ while true; do
|
||||
kill -USR1 ${BACKGROUND_TASKS[*]}
|
||||
fi
|
||||
done
|
||||
|
||||
|
@ -247,6 +247,9 @@ plugin {
|
||||
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
|
||||
mail_log_fields = uid box msgid size
|
||||
mail_log_cached_only = yes
|
||||
|
||||
# Try set mail_replica
|
||||
!include_try /etc/dovecot/mail_replica.conf
|
||||
}
|
||||
service quota-warning {
|
||||
executable = script /usr/local/bin/quota_notify.py
|
||||
|
@ -12,7 +12,8 @@ if /^\s*Received: from.* \(.*rspamd-mailcow.*mailcow-network.*\).*\(Postcow\)/
|
||||
REPLACE Received: from rspamd (rspamd $3) by $4 (Postcow) with $5
|
||||
endif
|
||||
/^\s*X-Enigmail/ IGNORE
|
||||
/^\s*X-Mailer/ IGNORE
|
||||
# Not removing Mailer by default, might be signed
|
||||
#/^\s*X-Mailer/ IGNORE
|
||||
/^\s*X-Originating-IP/ IGNORE
|
||||
/^\s*X-Forward/ IGNORE
|
||||
# Not removing UA by default, might be signed
|
||||
|
@ -11,6 +11,7 @@ smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
||||
smtpd_relay_restrictions = permit_mynetworks,
|
||||
permit_sasl_authenticated,
|
||||
defer_unauth_destination
|
||||
smtpd_forbid_bare_newline = yes
|
||||
# alias maps are auto-generated in postfix.sh on startup
|
||||
alias_maps = hash:/etc/aliases
|
||||
alias_database = hash:/etc/aliases
|
||||
@ -84,6 +85,7 @@ smtp_tls_security_level = dane
|
||||
smtpd_data_restrictions = reject_unauth_pipelining, permit
|
||||
smtpd_delay_reject = yes
|
||||
smtpd_error_sleep_time = 10s
|
||||
smtpd_forbid_bare_newline = yes
|
||||
smtpd_hard_error_limit = ${stress?1}${stress:5}
|
||||
smtpd_helo_required = yes
|
||||
smtpd_proxy_timeout = 600s
|
||||
@ -160,7 +162,8 @@ transport_maps = pcre:/opt/postfix/conf/custom_transport.pcre,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_relay_ne.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf
|
||||
smtp_sasl_auth_soft_bounce = no
|
||||
postscreen_discard_ehlo_keywords = silent-discard, dsn
|
||||
postscreen_discard_ehlo_keywords = silent-discard, dsn, chunking
|
||||
smtpd_discard_ehlo_keywords = chunking
|
||||
compatibility_level = 2
|
||||
smtputf8_enable = no
|
||||
# Define protocols for SMTPS and submission service
|
||||
|
@ -1,19 +1,20 @@
|
||||
# Whitelist generated by Postwhite v3.4 on Wed Nov 1 00:14:24 UTC 2023
|
||||
# Whitelist generated by Postwhite v3.4 on Thu Feb 1 00:13:50 UTC 2024
|
||||
# https://github.com/stevejenkins/postwhite/
|
||||
# 2030 total rules
|
||||
# 2089 total rules
|
||||
2a00:1450:4000::/36 permit
|
||||
2a01:111:f400::/48 permit
|
||||
2a01:111:f403:8000::/50 permit
|
||||
2a01:111:f403:8000::/51 permit
|
||||
2a01:111:f403::/49 permit
|
||||
2a01:111:f403:c000::/51 permit
|
||||
2a01:111:f403:f000::/52 permit
|
||||
2a02:a60:0:5::/64 permit
|
||||
2c0f:fb50:4000::/36 permit
|
||||
2.207.151.53 permit
|
||||
3.14.230.16 permit
|
||||
3.70.123.177 permit
|
||||
3.93.157.0/24 permit
|
||||
3.129.120.190 permit
|
||||
3.137.16.58 permit
|
||||
3.210.190.0/24 permit
|
||||
8.20.114.31 permit
|
||||
8.25.194.0/23 permit
|
||||
@ -116,7 +117,6 @@
|
||||
40.92.0.0/16 permit
|
||||
40.107.0.0/16 permit
|
||||
40.112.65.63 permit
|
||||
40.117.80.0/24 permit
|
||||
43.228.184.0/22 permit
|
||||
44.206.138.57 permit
|
||||
44.209.42.157 permit
|
||||
@ -183,8 +183,8 @@
|
||||
50.18.125.237 permit
|
||||
50.18.126.162 permit
|
||||
50.31.32.0/19 permit
|
||||
50.31.156.96/27 permit
|
||||
50.31.205.0/24 permit
|
||||
50.56.130.220 permit
|
||||
50.56.130.221 permit
|
||||
51.137.58.21 permit
|
||||
51.140.75.55 permit
|
||||
51.144.100.179 permit
|
||||
@ -206,7 +206,6 @@
|
||||
52.95.49.88/29 permit
|
||||
52.96.91.34 permit
|
||||
52.96.111.82 permit
|
||||
52.96.172.98 permit
|
||||
52.96.214.50 permit
|
||||
52.96.222.194 permit
|
||||
52.96.222.226 permit
|
||||
@ -304,22 +303,31 @@
|
||||
64.147.123.27 permit
|
||||
64.147.123.28 permit
|
||||
64.147.123.29 permit
|
||||
64.147.123.128/27 permit
|
||||
64.207.219.7 permit
|
||||
64.207.219.8 permit
|
||||
64.207.219.9 permit
|
||||
64.207.219.10 permit
|
||||
64.207.219.11 permit
|
||||
64.207.219.12 permit
|
||||
64.207.219.13 permit
|
||||
64.207.219.14 permit
|
||||
64.207.219.15 permit
|
||||
64.207.219.71 permit
|
||||
64.207.219.72 permit
|
||||
64.207.219.73 permit
|
||||
64.207.219.74 permit
|
||||
64.207.219.75 permit
|
||||
64.207.219.76 permit
|
||||
64.207.219.77 permit
|
||||
64.207.219.78 permit
|
||||
64.207.219.79 permit
|
||||
64.207.219.135 permit
|
||||
64.207.219.136 permit
|
||||
64.207.219.137 permit
|
||||
64.207.219.138 permit
|
||||
64.207.219.139 permit
|
||||
64.207.219.140 permit
|
||||
64.207.219.141 permit
|
||||
64.207.219.142 permit
|
||||
64.207.219.143 permit
|
||||
@ -396,8 +404,6 @@
|
||||
66.196.81.228/30 permit
|
||||
66.196.81.232/31 permit
|
||||
66.196.81.234 permit
|
||||
66.211.168.230/31 permit
|
||||
66.211.170.86/31 permit
|
||||
66.211.170.88/29 permit
|
||||
66.211.184.0/23 permit
|
||||
66.218.74.64/30 permit
|
||||
@ -586,10 +592,12 @@
|
||||
74.112.67.243 permit
|
||||
74.125.0.0/16 permit
|
||||
74.202.227.40 permit
|
||||
74.208.4.192/26 permit
|
||||
74.208.5.64/26 permit
|
||||
74.208.122.0/26 permit
|
||||
74.208.4.200 permit
|
||||
74.208.4.201 permit
|
||||
74.208.4.220 permit
|
||||
74.208.4.221 permit
|
||||
74.209.250.0/24 permit
|
||||
75.2.70.75 permit
|
||||
76.223.128.0/19 permit
|
||||
76.223.176.0/20 permit
|
||||
77.238.176.0/22 permit
|
||||
@ -615,13 +623,23 @@
|
||||
77.238.189.148/30 permit
|
||||
81.7.169.128/25 permit
|
||||
81.223.46.0/27 permit
|
||||
82.165.159.0/24 permit
|
||||
82.165.159.0/26 permit
|
||||
82.165.229.31 permit
|
||||
82.165.229.130 permit
|
||||
82.165.230.21 permit
|
||||
82.165.230.22 permit
|
||||
82.165.159.2 permit
|
||||
82.165.159.3 permit
|
||||
82.165.159.4 permit
|
||||
82.165.159.12 permit
|
||||
82.165.159.13 permit
|
||||
82.165.159.14 permit
|
||||
82.165.159.34 permit
|
||||
82.165.159.35 permit
|
||||
82.165.159.40 permit
|
||||
82.165.159.41 permit
|
||||
82.165.159.42 permit
|
||||
82.165.159.45 permit
|
||||
82.165.159.130 permit
|
||||
82.165.159.131 permit
|
||||
84.116.6.0/23 permit
|
||||
84.116.36.0/24 permit
|
||||
84.116.50.0/23 permit
|
||||
85.158.136.0/21 permit
|
||||
86.61.88.25 permit
|
||||
87.198.219.130 permit
|
||||
@ -1178,6 +1196,7 @@
|
||||
98.139.245.208/30 permit
|
||||
98.139.245.212/31 permit
|
||||
99.78.197.208/28 permit
|
||||
99.83.190.102 permit
|
||||
103.2.140.0/22 permit
|
||||
103.9.96.0/22 permit
|
||||
103.28.42.0/24 permit
|
||||
@ -1193,7 +1212,6 @@
|
||||
104.130.96.0/28 permit
|
||||
104.130.122.0/23 permit
|
||||
104.214.25.77 permit
|
||||
104.245.209.192/26 permit
|
||||
106.10.144.64/27 permit
|
||||
106.10.144.100/31 permit
|
||||
106.10.144.103 permit
|
||||
@ -1419,9 +1437,11 @@
|
||||
135.84.216.0/22 permit
|
||||
136.143.160.0/24 permit
|
||||
136.143.161.0/24 permit
|
||||
136.143.178.49 permit
|
||||
136.143.182.0/23 permit
|
||||
136.143.184.0/24 permit
|
||||
136.143.188.0/24 permit
|
||||
136.143.190.0/23 permit
|
||||
136.147.128.0/20 permit
|
||||
136.147.135.0/24 permit
|
||||
136.147.176.0/20 permit
|
||||
@ -1452,13 +1472,14 @@
|
||||
144.178.38.0/24 permit
|
||||
145.253.228.160/29 permit
|
||||
145.253.239.128/29 permit
|
||||
146.20.14.105 permit
|
||||
146.20.14.107 permit
|
||||
146.20.112.0/26 permit
|
||||
146.20.113.0/24 permit
|
||||
146.20.191.0/24 permit
|
||||
146.20.215.0/24 permit
|
||||
146.20.215.182 permit
|
||||
146.88.28.0/24 permit
|
||||
147.160.158.0/24 permit
|
||||
147.243.1.47 permit
|
||||
147.243.1.48 permit
|
||||
147.243.1.153 permit
|
||||
@ -1527,6 +1548,10 @@
|
||||
163.47.180.0/23 permit
|
||||
163.114.130.16 permit
|
||||
163.114.132.120 permit
|
||||
164.177.132.168 permit
|
||||
164.177.132.169 permit
|
||||
164.177.132.170 permit
|
||||
164.177.132.171 permit
|
||||
165.173.128.0/24 permit
|
||||
166.78.68.0/22 permit
|
||||
166.78.68.221 permit
|
||||
@ -1558,6 +1583,7 @@
|
||||
168.245.127.231 permit
|
||||
169.148.129.0/24 permit
|
||||
169.148.131.0/24 permit
|
||||
169.148.142.10 permit
|
||||
169.148.144.0/25 permit
|
||||
170.10.68.0/22 permit
|
||||
170.10.128.0/24 permit
|
||||
@ -1710,7 +1736,6 @@
|
||||
198.244.60.0/22 permit
|
||||
198.245.80.0/20 permit
|
||||
198.245.81.0/24 permit
|
||||
199.15.176.173 permit
|
||||
199.15.213.187 permit
|
||||
199.15.226.37 permit
|
||||
199.16.156.0/22 permit
|
||||
@ -1718,6 +1743,8 @@
|
||||
199.33.145.32 permit
|
||||
199.34.22.36 permit
|
||||
199.59.148.0/22 permit
|
||||
199.67.80.2 permit
|
||||
199.67.82.2 permit
|
||||
199.67.84.0/24 permit
|
||||
199.67.86.0/24 permit
|
||||
199.67.88.0/24 permit
|
||||
@ -1781,6 +1808,7 @@
|
||||
204.92.114.187 permit
|
||||
204.92.114.203 permit
|
||||
204.92.114.204/31 permit
|
||||
204.132.224.66 permit
|
||||
204.141.32.0/23 permit
|
||||
204.141.42.0/23 permit
|
||||
204.220.160.0/20 permit
|
||||
@ -1824,6 +1852,8 @@
|
||||
207.67.98.192/27 permit
|
||||
207.68.176.0/26 permit
|
||||
207.68.176.96/27 permit
|
||||
207.97.204.96 permit
|
||||
207.97.204.97 permit
|
||||
207.126.144.0/20 permit
|
||||
207.171.160.0/19 permit
|
||||
207.211.30.64/26 permit
|
||||
@ -1930,12 +1960,41 @@
|
||||
212.82.111.228/31 permit
|
||||
212.82.111.230 permit
|
||||
212.123.28.40 permit
|
||||
212.227.15.0/24 permit
|
||||
212.227.15.0/25 permit
|
||||
212.227.17.0/27 permit
|
||||
212.227.126.128/25 permit
|
||||
212.227.15.3 permit
|
||||
212.227.15.4 permit
|
||||
212.227.15.5 permit
|
||||
212.227.15.6 permit
|
||||
212.227.15.14 permit
|
||||
212.227.15.15 permit
|
||||
212.227.15.18 permit
|
||||
212.227.15.19 permit
|
||||
212.227.15.25 permit
|
||||
212.227.15.26 permit
|
||||
212.227.15.29 permit
|
||||
212.227.15.44 permit
|
||||
212.227.15.45 permit
|
||||
212.227.15.46 permit
|
||||
212.227.15.47 permit
|
||||
212.227.15.50 permit
|
||||
212.227.15.52 permit
|
||||
212.227.15.53 permit
|
||||
212.227.15.54 permit
|
||||
212.227.15.55 permit
|
||||
212.227.17.11 permit
|
||||
212.227.17.12 permit
|
||||
212.227.17.18 permit
|
||||
212.227.17.19 permit
|
||||
212.227.17.20 permit
|
||||
212.227.17.21 permit
|
||||
212.227.17.22 permit
|
||||
212.227.17.26 permit
|
||||
212.227.17.28 permit
|
||||
212.227.17.29 permit
|
||||
212.227.126.224 permit
|
||||
212.227.126.225 permit
|
||||
212.227.126.226 permit
|
||||
212.227.126.227 permit
|
||||
213.46.255.0/24 permit
|
||||
213.165.64.0/23 permit
|
||||
213.199.128.139 permit
|
||||
213.199.128.145 permit
|
||||
213.199.138.181 permit
|
||||
@ -1999,10 +2058,10 @@
|
||||
216.203.30.55 permit
|
||||
216.203.33.178/31 permit
|
||||
216.205.24.0/24 permit
|
||||
216.221.160.0/19 permit
|
||||
216.239.32.0/19 permit
|
||||
217.72.192.64/26 permit
|
||||
217.72.192.248/29 permit
|
||||
217.72.207.0/27 permit
|
||||
217.72.192.77 permit
|
||||
217.72.192.78 permit
|
||||
217.77.141.52 permit
|
||||
217.77.141.59 permit
|
||||
217.175.194.0/24 permit
|
||||
|
92
data/conf/rspamd/dynmaps/footer.php
Normal file
92
data/conf/rspamd/dynmaps/footer.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
// File size is limited by Nginx site to 10M
|
||||
// To speed things up, we do not include prerequisites
|
||||
header('Content-Type: text/plain');
|
||||
require_once "vars.inc.php";
|
||||
// Do not show errors, we log to using error_log
|
||||
ini_set('error_reporting', 0);
|
||||
// Init database
|
||||
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("FOOTER: " . $e . PHP_EOL);
|
||||
http_response_code(501);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders() {
|
||||
if (!is_array($_SERVER)) {
|
||||
return array();
|
||||
}
|
||||
$headers = array();
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
if (substr($name, 0, 5) == 'HTTP_') {
|
||||
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
|
||||
// Read headers
|
||||
$headers = getallheaders();
|
||||
// Get Domain
|
||||
$domain = $headers['Domain'];
|
||||
// Get Username
|
||||
$username = $headers['Username'];
|
||||
// Get From
|
||||
$from = $headers['From'];
|
||||
// define empty footer
|
||||
$empty_footer = json_encode(array(
|
||||
'html' => '',
|
||||
'plain' => '',
|
||||
'skip_replies' => 0,
|
||||
'vars' => array()
|
||||
));
|
||||
|
||||
error_log("FOOTER: checking for domain " . $domain . ", user " . $username . " and address " . $from . PHP_EOL);
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT `plain`, `html`, `mbox_exclude`, `skip_replies` FROM `domain_wide_footer`
|
||||
WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain
|
||||
));
|
||||
$footer = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (in_array($from, json_decode($footer['mbox_exclude']))){
|
||||
$footer = false;
|
||||
}
|
||||
if (empty($footer)){
|
||||
echo $empty_footer;
|
||||
exit;
|
||||
}
|
||||
error_log("FOOTER: " . json_encode($footer) . PHP_EOL);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT `custom_attributes` FROM `mailbox` WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
));
|
||||
$custom_attributes = $stmt->fetch(PDO::FETCH_ASSOC)['custom_attributes'];
|
||||
if (empty($custom_attributes)){
|
||||
$custom_attributes = (object)array();
|
||||
}
|
||||
}
|
||||
catch (Exception $e) {
|
||||
error_log("FOOTER: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(502);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
// return footer
|
||||
$footer["vars"] = $custom_attributes;
|
||||
echo json_encode($footer);
|
9
data/conf/rspamd/local.d/ratelimit.conf
Normal file
9
data/conf/rspamd/local.d/ratelimit.conf
Normal file
@ -0,0 +1,9 @@
|
||||
# Uncomment below to apply the ratelimits globally. Use Ratelimits inside mailcow UI to overwrite them for a specific domain/mailbox.
|
||||
# rates {
|
||||
# # Format: "1 / 1h" or "20 / 1m" etc.
|
||||
# to = "100 / 1s";
|
||||
# to_ip = "100 / 1s";
|
||||
# to_ip_from = "100 / 1s";
|
||||
# bounce_to = "100 / 1h";
|
||||
# bounce_to_ip = "7 / 1m";
|
||||
# }
|
@ -527,20 +527,21 @@ rspamd_config:register_symbol({
|
||||
name = 'MOO_FOOTER',
|
||||
type = 'prefilter',
|
||||
callback = function(task)
|
||||
local cjson = require "cjson"
|
||||
local lua_mime = require "lua_mime"
|
||||
local lua_util = require "lua_util"
|
||||
local rspamd_logger = require "rspamd_logger"
|
||||
local rspamd_redis = require "rspamd_redis"
|
||||
local ucl = require "ucl"
|
||||
local redis_params = rspamd_parse_redis_server('footer')
|
||||
local rspamd_http = require "rspamd_http"
|
||||
local envfrom = task:get_from(1)
|
||||
local uname = task:get_user()
|
||||
if not envfrom or not uname then
|
||||
return false
|
||||
end
|
||||
local uname = uname:lower()
|
||||
local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case
|
||||
local env_from_domain = envfrom[1].domain:lower()
|
||||
local env_from_addr = envfrom[1].addr:lower()
|
||||
|
||||
-- determine newline type
|
||||
local function newline(task)
|
||||
local t = task:get_newlines_type()
|
||||
|
||||
@ -552,20 +553,27 @@ rspamd_config:register_symbol({
|
||||
|
||||
return '\r\n'
|
||||
end
|
||||
local function redis_cb_footer(err, data)
|
||||
-- retrieve footer
|
||||
local function footer_cb(err_message, code, data, headers)
|
||||
if err or type(data) ~= 'string' then
|
||||
rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
|
||||
else
|
||||
|
||||
-- parse json string
|
||||
local parser = ucl.parser()
|
||||
local res,err = parser:parse_string(data)
|
||||
if not res then
|
||||
local footer = cjson.decode(data)
|
||||
if not footer then
|
||||
rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
|
||||
else
|
||||
local footer = parser:get_object()
|
||||
|
||||
if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "") then
|
||||
rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s", uname, footer.html, footer.plain)
|
||||
rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s, vars=%s", uname, footer.html, footer.plain, footer.vars)
|
||||
|
||||
if footer.skip_replies ~= 0 then
|
||||
in_reply_to = task:get_header_raw('in-reply-to')
|
||||
if in_reply_to then
|
||||
rspamd_logger.infox(rspamd_config, "mail is a reply - skip footer")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local envfrom_mime = task:get_from(2)
|
||||
local from_name = ""
|
||||
@ -575,6 +583,7 @@ rspamd_config:register_symbol({
|
||||
from_name = envfrom[1].name
|
||||
end
|
||||
|
||||
-- default replacements
|
||||
local replacements = {
|
||||
auth_user = uname,
|
||||
from_user = envfrom[1].user,
|
||||
@ -582,10 +591,20 @@ rspamd_config:register_symbol({
|
||||
from_addr = envfrom[1].addr,
|
||||
from_domain = envfrom[1].domain:lower()
|
||||
}
|
||||
if footer.html then
|
||||
-- add custom mailbox attributes
|
||||
if footer.vars and type(footer.vars) == "string" then
|
||||
local footer_vars = cjson.decode(footer.vars)
|
||||
|
||||
if type(footer_vars) == "table" then
|
||||
for key, value in pairs(footer_vars) do
|
||||
replacements[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
if footer.html and footer.html ~= "" then
|
||||
footer.html = lua_util.jinja_template(footer.html, replacements, true)
|
||||
end
|
||||
if footer.plain then
|
||||
if footer.plain and footer.plain ~= "" then
|
||||
footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
|
||||
end
|
||||
|
||||
@ -653,17 +672,14 @@ rspamd_config:register_symbol({
|
||||
end
|
||||
end
|
||||
|
||||
local redis_ret_footer = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
env_from_domain, -- hash key
|
||||
false, -- is write
|
||||
redis_cb_footer, --callback
|
||||
'HGET', -- command
|
||||
{"DOMAIN_WIDE_FOOTER", env_from_domain} -- arguments
|
||||
)
|
||||
if not redis_ret_footer then
|
||||
rspamd_logger.infox(rspamd_config, "cannot make request to load footer for domain")
|
||||
end
|
||||
-- fetch footer
|
||||
rspamd_http.request({
|
||||
task=task,
|
||||
url='http://nginx:8081/footer.php',
|
||||
body='',
|
||||
callback=footer_cb,
|
||||
headers={Domain=env_from_domain,Username=uname,From=env_from_addr},
|
||||
})
|
||||
|
||||
return true
|
||||
end,
|
||||
|
@ -1,11 +1,3 @@
|
||||
rates {
|
||||
# Format: "1 / 1h" or "20 / 1m" etc. - global ratelimits are disabled by default
|
||||
to = "100 / 1s";
|
||||
to_ip = "100 / 1s";
|
||||
to_ip_from = "100 / 1s";
|
||||
bounce_to = "100 / 1h";
|
||||
bounce_to_ip = "7 / 1m";
|
||||
}
|
||||
whitelisted_rcpts = "postmaster,mailer-daemon";
|
||||
max_rcpt = 25;
|
||||
custom_keywords = "/etc/rspamd/lua/ratelimit.lua";
|
||||
|
@ -12,6 +12,7 @@
|
||||
SOGoJunkFolderName= "Junk";
|
||||
SOGoMailDomain = "sogo.local";
|
||||
SOGoEnableEMailAlarms = YES;
|
||||
SOGoMailHideInlineAttachments = YES;
|
||||
SOGoFoldersSendEMailNotifications = YES;
|
||||
SOGoForwardEnabled = YES;
|
||||
|
||||
|
@ -85,6 +85,8 @@ $cors_settings = cors('get');
|
||||
$cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']);
|
||||
$cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']);
|
||||
|
||||
$f2b_data = fail2ban('get');
|
||||
|
||||
$template = 'admin.twig';
|
||||
$template_data = [
|
||||
'tfa_data' => $tfa_data,
|
||||
@ -101,7 +103,8 @@ $template_data = [
|
||||
'domains' => $domains,
|
||||
'all_domains' => $all_domains,
|
||||
'mailboxes' => $mailboxes,
|
||||
'f2b_data' => fail2ban('get'),
|
||||
'f2b_data' => $f2b_data,
|
||||
'f2b_banlist_url' => getBaseUrl() . "/api/v1/get/fail2ban/banlist/" . $f2b_data['banlist_id'],
|
||||
'q_data' => quarantine('settings'),
|
||||
'qn_data' => quota_notification('get'),
|
||||
'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'),
|
||||
@ -113,6 +116,7 @@ $template_data = [
|
||||
'password_complexity' => password_complexity('get'),
|
||||
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||
'cors_settings' => $cors_settings,
|
||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||
'lang_admin' => json_encode($lang['admin']),
|
||||
'lang_datatables' => json_encode($lang['datatables'])
|
||||
];
|
||||
|
@ -3137,6 +3137,86 @@ paths:
|
||||
type: string
|
||||
type: object
|
||||
summary: Update domain
|
||||
/api/v1/edit/domain/footer:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- mailbox
|
||||
- edit
|
||||
- domain_wide_footer
|
||||
- domains:
|
||||
- mailcow.tld
|
||||
html: "<br>foo {= foo =}"
|
||||
plain: "<foo {= foo =}"
|
||||
mbox_exclude:
|
||||
- moo@mailcow.tld
|
||||
- null
|
||||
msg:
|
||||
- domain_footer_modified
|
||||
- mailcow.tld
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Domains
|
||||
description: >-
|
||||
You can update the footer of one or more domains per request.
|
||||
operationId: Update domain wide footer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
attr:
|
||||
html: "<br>foo {= foo =}"
|
||||
plain: "foo {= foo =}"
|
||||
mbox_exclude:
|
||||
- moo@mailcow.tld
|
||||
items: mailcow.tld
|
||||
properties:
|
||||
attr:
|
||||
properties:
|
||||
html:
|
||||
description: Footer text in HTML format
|
||||
type: string
|
||||
plain:
|
||||
description: Footer text in PLAIN text format
|
||||
type: string
|
||||
mbox_exclude:
|
||||
description: Array of mailboxes to exclude from domain wide footer
|
||||
type: object
|
||||
type: object
|
||||
items:
|
||||
description: contains a list of domain names where you want to update the footer
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
summary: Update domain wide footer
|
||||
/api/v1/edit/fail2ban:
|
||||
post:
|
||||
responses:
|
||||
@ -3336,6 +3416,86 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
summary: Update mailbox
|
||||
/api/v1/edit/mailbox/custom-attribute:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- mailbox
|
||||
- edit
|
||||
- mailbox_custom_attribute
|
||||
- mailboxes:
|
||||
- moo@mailcow.tld
|
||||
attribute:
|
||||
- role
|
||||
- foo
|
||||
value:
|
||||
- cow
|
||||
- bar
|
||||
- null
|
||||
msg:
|
||||
- mailbox_modified
|
||||
- moo@mailcow.tld
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mailboxes
|
||||
description: >-
|
||||
You can update custom attributes of one or more mailboxes per request.
|
||||
operationId: Update mailbox custom attributes
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
attr:
|
||||
attribute:
|
||||
- role
|
||||
- foo
|
||||
value:
|
||||
- cow
|
||||
- bar
|
||||
items:
|
||||
- moo@mailcow.tld
|
||||
properties:
|
||||
attr:
|
||||
properties:
|
||||
attribute:
|
||||
description: Array of attribute keys
|
||||
type: object
|
||||
value:
|
||||
description: Array of attribute values
|
||||
type: object
|
||||
type: object
|
||||
items:
|
||||
description: contains list of mailboxes you want update
|
||||
type: object
|
||||
type: object
|
||||
summary: Update mailbox custom attributes
|
||||
/api/v1/edit/mailq:
|
||||
post:
|
||||
responses:
|
||||
@ -5581,6 +5741,7 @@ paths:
|
||||
sogo_access: "1"
|
||||
tls_enforce_in: "0"
|
||||
tls_enforce_out: "0"
|
||||
custom_attributes: {}
|
||||
domain: domain3.tld
|
||||
is_relayed: 0
|
||||
local_part: info
|
||||
@ -5646,6 +5807,40 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
summary: Edit Cross-Origin Resource Sharing (CORS) settings
|
||||
"/api/v1/get/spam-score/{mailbox}":
|
||||
get:
|
||||
parameters:
|
||||
- description: name of mailbox or empty for current user - admin user will retrieve the global spam filter score
|
||||
in: path
|
||||
name: mailbox
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- description: e.g. api-key-string
|
||||
example: api-key-string
|
||||
in: header
|
||||
name: X-API-Key
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
spam_score: "8,15"
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mailboxes
|
||||
description: >-
|
||||
Using this endpoint you can get the global spam filter score or the spam filter score of a certain mailbox.
|
||||
operationId: Get mailbox or global spam filter score
|
||||
summary: Get mailbox or global spam filter score
|
||||
|
||||
tags:
|
||||
- name: Domains
|
||||
|
@ -228,8 +228,8 @@ legend {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.slave-info {
|
||||
padding: 15px 0px 15px 15px;
|
||||
font-weight: bold;
|
||||
color: orange;
|
||||
}
|
||||
.alert-hr {
|
||||
margin:3px 0px;
|
||||
|
@ -175,6 +175,9 @@ pre {
|
||||
background-color: #282828;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
.form-control {
|
||||
background-color: transparent;
|
||||
}
|
||||
input.form-control, textarea.form-control {
|
||||
color: #e2e2e2 !important;
|
||||
background-color: #424242 !important;
|
||||
|
@ -58,6 +58,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
'dkim' => dkim('details', $domain),
|
||||
'domain_details' => $result,
|
||||
'domain_footer' => $domain_footer,
|
||||
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
||||
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address')
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -218,6 +220,7 @@ $js_minifier->add('/web/js/site/pwgen.js');
|
||||
$template_data['result'] = $result;
|
||||
$template_data['return_to'] = $_SESSION['return_to'];
|
||||
$template_data['lang_user'] = json_encode($lang['user']);
|
||||
$template_data['lang_admin'] = json_encode($lang['admin']);
|
||||
$template_data['lang_datatables'] = json_encode($lang['datatables']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
|
@ -2,6 +2,7 @@
|
||||
function customize($_action, $_item, $_data = null) {
|
||||
global $redis;
|
||||
global $lang;
|
||||
global $LOGO_LIMITS;
|
||||
|
||||
switch ($_action) {
|
||||
case 'add':
|
||||
@ -35,6 +36,23 @@ function customize($_action, $_item, $_data = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($_data[$_item]['size'] > $LOGO_LIMITS['max_size']) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => 'img_size_exceeded'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
list($width, $height) = getimagesize($_data[$_item]['tmp_name']);
|
||||
if ($width > $LOGO_LIMITS['max_width'] || $height > $LOGO_LIMITS['max_height']) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => 'img_dimensions_exceeded'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$image = new Imagick($_data[$_item]['tmp_name']);
|
||||
if ($image->valid() !== true) {
|
||||
$_SESSION['return'][] = array(
|
||||
|
@ -1,5 +1,5 @@
|
||||
<?php
|
||||
function fail2ban($_action, $_data = null) {
|
||||
function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
global $redis;
|
||||
$_data_log = $_data;
|
||||
switch ($_action) {
|
||||
@ -247,6 +247,7 @@ function fail2ban($_action, $_data = null) {
|
||||
$netban_ipv6 = intval((isset($_data['netban_ipv6'])) ? $_data['netban_ipv6'] : $is_now['netban_ipv6']);
|
||||
$wl = (isset($_data['whitelist'])) ? $_data['whitelist'] : $is_now['whitelist'];
|
||||
$bl = (isset($_data['blacklist'])) ? $_data['blacklist'] : $is_now['blacklist'];
|
||||
$manage_external = (isset($_data['manage_external'])) ? intval($_data['manage_external']) : 0;
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
@ -266,6 +267,8 @@ function fail2ban($_action, $_data = null) {
|
||||
$f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6;
|
||||
$f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts;
|
||||
$f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window;
|
||||
$f2b_options['banlist_id'] = $is_now['banlist_id'];
|
||||
$f2b_options['manage_external'] = ($manage_external > 0) ? 1 : 0;
|
||||
try {
|
||||
$redis->Set('F2B_OPTIONS', json_encode($f2b_options));
|
||||
$redis->Del('F2B_WHITELIST');
|
||||
@ -329,5 +332,71 @@ function fail2ban($_action, $_data = null) {
|
||||
'msg' => 'f2b_modified'
|
||||
);
|
||||
break;
|
||||
case 'banlist':
|
||||
try {
|
||||
$f2b_options = json_decode($redis->Get('F2B_OPTIONS'), true);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
|
||||
'msg' => array('redis_error', $e)
|
||||
);
|
||||
http_response_code(500);
|
||||
return false;
|
||||
}
|
||||
if (is_array($_extra)) {
|
||||
$_extra = $_extra[0];
|
||||
}
|
||||
if ($_extra != $f2b_options['banlist_id']){
|
||||
http_response_code(404);
|
||||
return false;
|
||||
}
|
||||
|
||||
switch ($_data) {
|
||||
case 'get':
|
||||
try {
|
||||
$bl = $redis->hKeys('F2B_BLACKLIST');
|
||||
$active_bans = $redis->hKeys('F2B_ACTIVE_BANS');
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
|
||||
'msg' => array('redis_error', $e)
|
||||
);
|
||||
http_response_code(500);
|
||||
return false;
|
||||
}
|
||||
$banlist = implode("\n", array_merge($bl, $active_bans));
|
||||
return $banlist;
|
||||
break;
|
||||
case 'refresh':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
return false;
|
||||
}
|
||||
|
||||
$f2b_options['banlist_id'] = uuid4();
|
||||
try {
|
||||
$redis->Set('F2B_OPTIONS', json_encode($f2b_options));
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
|
||||
'msg' => array('redis_error', $e)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
|
||||
'msg' => 'f2b_banlist_refreshed'
|
||||
);
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -2246,6 +2246,21 @@ function cors($action, $data = null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
function getBaseURL() {
|
||||
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'];
|
||||
$base_url = $protocol . '://' . $host;
|
||||
|
||||
return $base_url;
|
||||
}
|
||||
function uuid4() {
|
||||
$data = openssl_random_pseudo_bytes(16);
|
||||
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
function get_logs($application, $lines = false) {
|
||||
if ($lines === false) {
|
||||
|
@ -478,16 +478,24 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$DOMAIN_DEFAULT_ATTRIBUTES = null;
|
||||
if ($_data['template']){
|
||||
$DOMAIN_DEFAULT_ATTRIBUTES = mailbox('get', 'domain_templates', $_data['template'])['attributes'];
|
||||
}
|
||||
if (empty($DOMAIN_DEFAULT_ATTRIBUTES)) {
|
||||
$DOMAIN_DEFAULT_ATTRIBUTES = mailbox('get', 'domain_templates')[0]['attributes'];
|
||||
}
|
||||
|
||||
$domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
$description = $_data['description'];
|
||||
if (empty($description)) $description = $domain;
|
||||
$tags = (array)$_data['tags'];
|
||||
$aliases = (int)$_data['aliases'];
|
||||
$mailboxes = (int)$_data['mailboxes'];
|
||||
$defquota = (int)$_data['defquota'];
|
||||
$maxquota = (int)$_data['maxquota'];
|
||||
$tags = (isset($_data['tags'])) ? (array)$_data['tags'] : $DOMAIN_DEFAULT_ATTRIBUTES['tags'];
|
||||
$aliases = (isset($_data['aliases'])) ? (int)$_data['aliases'] : $DOMAIN_DEFAULT_ATTRIBUTES['max_num_aliases_for_domain'];
|
||||
$mailboxes = (isset($_data['mailboxes'])) ? (int)$_data['mailboxes'] : $DOMAIN_DEFAULT_ATTRIBUTES['max_num_mboxes_for_domain'];
|
||||
$defquota = (isset($_data['defquota'])) ? (int)$_data['defquota'] : $DOMAIN_DEFAULT_ATTRIBUTES['def_quota_for_mbox'] / 1024 ** 2;
|
||||
$maxquota = (isset($_data['maxquota'])) ? (int)$_data['maxquota'] : $DOMAIN_DEFAULT_ATTRIBUTES['max_quota_for_mbox'] / 1024 ** 2;
|
||||
$restart_sogo = (int)$_data['restart_sogo'];
|
||||
$quota = (int)$_data['quota'];
|
||||
$quota = (isset($_data['quota'])) ? (int)$_data['quota'] : $DOMAIN_DEFAULT_ATTRIBUTES['max_quota_for_domain'] / 1024 ** 2;
|
||||
if ($defquota > $maxquota) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
@ -520,11 +528,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$active = intval($_data['active']);
|
||||
$relay_all_recipients = intval($_data['relay_all_recipients']);
|
||||
$relay_unknown_only = intval($_data['relay_unknown_only']);
|
||||
$backupmx = intval($_data['backupmx']);
|
||||
$gal = intval($_data['gal']);
|
||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $DOMAIN_DEFAULT_ATTRIBUTES['active'];
|
||||
$relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_all_recipients'];
|
||||
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only'];
|
||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
|
||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
|
||||
if ($relay_all_recipients == 1) {
|
||||
$backupmx = '1';
|
||||
}
|
||||
@ -625,9 +633,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!empty(intval($_data['rl_value']))) {
|
||||
$_data['rl_value'] = (isset($_data['rl_value'])) ? intval($_data['rl_value']) : $DOMAIN_DEFAULT_ATTRIBUTES['rl_value'];
|
||||
$_data['rl_frame'] = (isset($_data['rl_frame'])) ? $_data['rl_frame'] : $DOMAIN_DEFAULT_ATTRIBUTES['rl_frame'];
|
||||
if (!empty($_data['rl_value']) && !empty($_data['rl_frame'])){
|
||||
ratelimit('edit', 'domain', array('rl_value' => $_data['rl_value'], 'rl_frame' => $_data['rl_frame'], 'object' => $domain));
|
||||
}
|
||||
$_data['key_size'] = (isset($_data['key_size'])) ? intval($_data['key_size']) : $DOMAIN_DEFAULT_ATTRIBUTES['key_size'];
|
||||
$_data['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : $DOMAIN_DEFAULT_ATTRIBUTES['dkim_selector'];
|
||||
if (!empty($_data['key_size']) && !empty($_data['dkim_selector'])) {
|
||||
if (!empty($redis->hGet('DKIM_SELECTORS', $domain))) {
|
||||
$_SESSION['return'][] = array(
|
||||
@ -1006,11 +1018,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (empty($name)) {
|
||||
$name = $local_part;
|
||||
}
|
||||
$template_attr = null;
|
||||
if ($_data['template']){
|
||||
$template_attr = mailbox('get', 'mailbox_templates', $_data['template'])['attributes'];
|
||||
}
|
||||
if (empty($template_attr)) {
|
||||
$template_attr = mailbox('get', 'mailbox_templates')[0]['attributes'];
|
||||
}
|
||||
$MAILBOX_DEFAULT_ATTRIBUTES = array_merge($MAILBOX_DEFAULT_ATTRIBUTES, $template_attr);
|
||||
|
||||
$password = $_data['password'];
|
||||
$password2 = $_data['password2'];
|
||||
$name = ltrim(rtrim($_data['name'], '>'), '<');
|
||||
$tags = $_data['tags'];
|
||||
$quota_m = intval($_data['quota']);
|
||||
$tags = (isset($_data['tags'])) ? $_data['tags'] : $MAILBOX_DEFAULT_ATTRIBUTES['tags'];
|
||||
$quota_m = (isset($_data['quota'])) ? intval($_data['quota']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['quota']) / 1024 ** 2;
|
||||
if ((!isset($_SESSION['acl']['unlimited_quota']) || $_SESSION['acl']['unlimited_quota'] != "1") && $quota_m === 0) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
@ -1019,9 +1043,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (empty($name)) {
|
||||
$name = $local_part;
|
||||
}
|
||||
|
||||
if (isset($_data['protocol_access'])) {
|
||||
$_data['protocol_access'] = (array)$_data['protocol_access'];
|
||||
$_data['imap_access'] = (in_array('imap', $_data['protocol_access'])) ? 1 : 0;
|
||||
@ -1029,7 +1051,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$_data['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0;
|
||||
$_data['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0;
|
||||
}
|
||||
$active = intval($_data['active']);
|
||||
$active = (isset($_data['active'])) ? intval($_data['active']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['active']);
|
||||
$force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
|
||||
$tls_enforce_in = (isset($_data['tls_enforce_in'])) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
|
||||
$tls_enforce_out = (isset($_data['tls_enforce_out'])) ? intval($_data['tls_enforce_out']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_out']);
|
||||
@ -1227,7 +1249,24 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$_data['quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
|
||||
$_data['quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
|
||||
$_data['app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
|
||||
} else {
|
||||
$_data['spam_alias'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_spam_alias']);
|
||||
$_data['tls_policy'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_tls_policy']);
|
||||
$_data['spam_score'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_spam_score']);
|
||||
$_data['spam_policy'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_spam_policy']);
|
||||
$_data['delimiter_action'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_delimiter_action']);
|
||||
$_data['syncjobs'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_syncjobs']);
|
||||
$_data['eas_reset'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_eas_reset']);
|
||||
$_data['sogo_profile_reset'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_sogo_profile_reset']);
|
||||
$_data['pushover'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_pushover']);
|
||||
$_data['quarantine'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine']);
|
||||
$_data['quarantine_attachments'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_attachments']);
|
||||
$_data['quarantine_notification'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_notification']);
|
||||
$_data['quarantine_category'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_category']);
|
||||
$_data['app_passwds'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_app_passwds']);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("INSERT INTO `user_acl`
|
||||
(`username`, `spam_alias`, `tls_policy`, `spam_score`, `spam_policy`, `delimiter_action`, `syncjobs`, `eas_reset`, `sogo_profile_reset`,
|
||||
`pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`)
|
||||
@ -1251,31 +1290,17 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':app_passwds' => $_data['app_passwds']
|
||||
));
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("INSERT INTO `user_acl`
|
||||
(`username`, `spam_alias`, `tls_policy`, `spam_score`, `spam_policy`, `delimiter_action`, `syncjobs`, `eas_reset`, `sogo_profile_reset`,
|
||||
`pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`)
|
||||
VALUES (:username, :spam_alias, :tls_policy, :spam_score, :spam_policy, :delimiter_action, :syncjobs, :eas_reset, :sogo_profile_reset,
|
||||
:pushover, :quarantine, :quarantine_attachments, :quarantine_notification, :quarantine_category, :app_passwds) ");
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
':spam_alias' => 0,
|
||||
':tls_policy' => 0,
|
||||
':spam_score' => 0,
|
||||
':spam_policy' => 0,
|
||||
':delimiter_action' => 0,
|
||||
':syncjobs' => 0,
|
||||
':eas_reset' => 0,
|
||||
':sogo_profile_reset' => 0,
|
||||
':pushover' => 0,
|
||||
':quarantine' => 0,
|
||||
':quarantine_attachments' => 0,
|
||||
':quarantine_notification' => 0,
|
||||
':quarantine_category' => 0,
|
||||
':app_passwds' => 0
|
||||
));
|
||||
catch (PDOException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => $e->getMessage()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$_data['rl_frame'] = (isset($_data['rl_frame'])) ? $_data['rl_frame'] : $MAILBOX_DEFAULT_ATTRIBUTES['rl_frame'];
|
||||
$_data['rl_value'] = (isset($_data['rl_value'])) ? $_data['rl_value'] : $MAILBOX_DEFAULT_ATTRIBUTES['rl_value'];
|
||||
if (isset($_data['rl_frame']) && isset($_data['rl_value'])){
|
||||
ratelimit('edit', 'mailbox', array(
|
||||
'object' => $username,
|
||||
@ -1524,10 +1549,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr["tls_enforce_out"] = isset($_data['tls_enforce_out']) ? intval($_data['tls_enforce_out']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_out']);
|
||||
if (isset($_data['protocol_access'])) {
|
||||
$_data['protocol_access'] = (array)$_data['protocol_access'];
|
||||
$attr['imap_access'] = (in_array('imap', $_data['protocol_access'])) ? 1 : intval($MAILBOX_DEFAULT_ATTRIBUTES['imap_access']);
|
||||
$attr['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : intval($MAILBOX_DEFAULT_ATTRIBUTES['pop3_access']);
|
||||
$attr['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : intval($MAILBOX_DEFAULT_ATTRIBUTES['smtp_access']);
|
||||
$attr['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : intval($MAILBOX_DEFAULT_ATTRIBUTES['sieve_access']);
|
||||
$attr['imap_access'] = (in_array('imap', $_data['protocol_access'])) ? 1 : 0;
|
||||
$attr['pop3_access'] = (in_array('pop3', $_data['protocol_access'])) ? 1 : 0;
|
||||
$attr['smtp_access'] = (in_array('smtp', $_data['protocol_access'])) ? 1 : 0;
|
||||
$attr['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0;
|
||||
}
|
||||
else {
|
||||
$attr['imap_access'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['imap_access']);
|
||||
@ -3264,6 +3289,62 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
return true;
|
||||
break;
|
||||
case 'mailbox_custom_attribute':
|
||||
$_data['attribute'] = isset($_data['attribute']) ? $_data['attribute'] : array();
|
||||
$_data['attribute'] = is_array($_data['attribute']) ? $_data['attribute'] : array($_data['attribute']);
|
||||
$_data['attribute'] = array_map(function($value) { return str_replace(' ', '', $value); }, $_data['attribute']);
|
||||
$_data['value'] = isset($_data['value']) ? $_data['value'] : array();
|
||||
$_data['value'] = is_array($_data['value']) ? $_data['value'] : array($_data['value']);
|
||||
$attributes = (object)array_combine($_data['attribute'], $_data['value']);
|
||||
$mailboxes = is_array($_data['mailboxes']) ? $_data['mailboxes'] : array($_data['mailboxes']);
|
||||
|
||||
foreach ($mailboxes as $mailbox) {
|
||||
if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('username_invalid', $mailbox)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$is_now = mailbox('get', 'mailbox_details', $mailbox);
|
||||
if(!empty($is_now)){
|
||||
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $is_now['domain'])) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE `mailbox`
|
||||
SET `custom_attributes` = :custom_attributes
|
||||
WHERE username = :username");
|
||||
$stmt->execute(array(
|
||||
":username" => $mailbox,
|
||||
":custom_attributes" => json_encode($attributes)
|
||||
));
|
||||
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('mailbox_modified', $mailbox)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
break;
|
||||
case 'resource':
|
||||
if (!is_array($_data['name'])) {
|
||||
$names = array();
|
||||
@ -3344,7 +3425,47 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
break;
|
||||
case 'domain_wide_footer':
|
||||
$domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
if (!is_array($_data['domains'])) {
|
||||
$domains = array();
|
||||
$domains[] = $_data['domains'];
|
||||
}
|
||||
else {
|
||||
$domains = $_data['domains'];
|
||||
}
|
||||
|
||||
$footers = array();
|
||||
$footers['html'] = isset($_data['html']) ? $_data['html'] : '';
|
||||
$footers['plain'] = isset($_data['plain']) ? $_data['plain'] : '';
|
||||
$footers['skip_replies'] = isset($_data['skip_replies']) ? (int)$_data['skip_replies'] : 0;
|
||||
$footers['mbox_exclude'] = array();
|
||||
if (isset($_data["mbox_exclude"])){
|
||||
if (!is_array($_data["mbox_exclude"])) {
|
||||
$_data["mbox_exclude"] = array($_data["mbox_exclude"]);
|
||||
}
|
||||
foreach ($_data["mbox_exclude"] as $mailbox) {
|
||||
if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('username_invalid', $mailbox)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$is_now = mailbox('get', 'mailbox_details', $mailbox);
|
||||
if(empty($is_now)){
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('username_invalid', $mailbox)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
array_push($footers['mbox_exclude'], $mailbox);
|
||||
}
|
||||
}
|
||||
foreach ($domains as $domain) {
|
||||
$domain = idn_to_ascii(strtolower(trim($domain)), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
if (!is_valid_domain_name($domain)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
@ -3362,17 +3483,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$footers = array();
|
||||
$footers['html'] = isset($_data['footer_html']) ? $_data['footer_html'] : '';
|
||||
$footers['plain'] = isset($_data['footer_plain']) ? $_data['footer_plain'] : '';
|
||||
try {
|
||||
$redis->hSet('DOMAIN_WIDE_FOOTER', $domain, json_encode($footers));
|
||||
$stmt = $pdo->prepare("DELETE FROM `domain_wide_footer` WHERE `domain`= :domain");
|
||||
$stmt->execute(array(':domain' => $domain));
|
||||
$stmt = $pdo->prepare("INSERT INTO `domain_wide_footer` (`domain`, `html`, `plain`, `mbox_exclude`, `skip_replies`) VALUES (:domain, :html, :plain, :mbox_exclude, :skip_replies)");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
':html' => $footers['html'],
|
||||
':plain' => $footers['plain'],
|
||||
':mbox_exclude' => json_encode($footers['mbox_exclude']),
|
||||
':skip_replies' => $footers['skip_replies'],
|
||||
));
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
catch (PDOException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('redis_error', $e)
|
||||
'msg' => $e->getMessage()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@ -3381,6 +3508,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('domain_footer_modified', htmlspecialchars($domain))
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
@ -3934,14 +4062,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `id` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain");
|
||||
$stmt = $pdo->prepare("SELECT `id`, `address` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $_data,
|
||||
));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($row = array_shift($rows)) {
|
||||
if ($_extra == "address"){
|
||||
$aliases[] = $row['address'];
|
||||
} else {
|
||||
$aliases[] = $row['id'];
|
||||
}
|
||||
}
|
||||
return $aliases;
|
||||
break;
|
||||
case 'alias_details':
|
||||
@ -4292,6 +4424,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
`mailbox`.`modified`,
|
||||
`quota2`.`bytes`,
|
||||
`attributes`,
|
||||
`custom_attributes`,
|
||||
`quota2`.`messages`
|
||||
FROM `mailbox`, `quota2`, `domain`
|
||||
WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
|
||||
@ -4312,6 +4445,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
`mailbox`.`modified`,
|
||||
`quota2replica`.`bytes`,
|
||||
`attributes`,
|
||||
`custom_attributes`,
|
||||
`quota2replica`.`messages`
|
||||
FROM `mailbox`, `quota2replica`, `domain`
|
||||
WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
|
||||
@ -4328,12 +4462,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$mailboxdata['active'] = $row['active'];
|
||||
$mailboxdata['active_int'] = $row['active'];
|
||||
$mailboxdata['domain'] = $row['domain'];
|
||||
$mailboxdata['relayhost'] = $row['relayhost'];
|
||||
$mailboxdata['name'] = $row['name'];
|
||||
$mailboxdata['local_part'] = $row['local_part'];
|
||||
$mailboxdata['quota'] = $row['quota'];
|
||||
$mailboxdata['messages'] = $row['messages'];
|
||||
$mailboxdata['attributes'] = json_decode($row['attributes'], true);
|
||||
$mailboxdata['custom_attributes'] = json_decode($row['custom_attributes'], true);
|
||||
$mailboxdata['quota_used'] = intval($row['bytes']);
|
||||
$mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100);
|
||||
$mailboxdata['created'] = $row['created'];
|
||||
@ -4514,19 +4648,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
|
||||
try {
|
||||
$footers = $redis->hGet('DOMAIN_WIDE_FOOTER', $domain);
|
||||
$footers = json_decode($footers, true);
|
||||
$stmt = $pdo->prepare("SELECT `html`, `plain`, `mbox_exclude`, `skip_replies` FROM `domain_wide_footer`
|
||||
WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain
|
||||
));
|
||||
$footer = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
catch (PDOException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('redis_error', $e)
|
||||
'msg' => $e->getMessage()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return $footers;
|
||||
return $footer;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
@ -3,7 +3,7 @@ function init_db_schema() {
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "15112023_1536";
|
||||
$db_version = "08012024_1442";
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@ -267,6 +267,21 @@ function init_db_schema() {
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"domain_wide_footer" => array(
|
||||
"cols" => array(
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"html" => "LONGTEXT",
|
||||
"plain" => "LONGTEXT",
|
||||
"mbox_exclude" => "JSON NOT NULL DEFAULT ('[]')",
|
||||
"skip_replies" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("domain")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"tags_domain" => array(
|
||||
"cols" => array(
|
||||
"tag_name" => "VARCHAR(255) NOT NULL",
|
||||
@ -344,6 +359,7 @@ function init_db_schema() {
|
||||
"local_part" => "VARCHAR(255) NOT NULL",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"attributes" => "JSON",
|
||||
"custom_attributes" => "JSON NOT NULL DEFAULT ('{}')",
|
||||
"kind" => "VARCHAR(100) NOT NULL DEFAULT ''",
|
||||
"multiple_bookings" => "INT NOT NULL DEFAULT -1",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
|
622
data/web/inc/lib/ssp.class.php
Normal file
622
data/web/inc/lib/ssp.class.php
Normal file
@ -0,0 +1,622 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Helper functions for building a DataTables server-side processing SQL query
|
||||
*
|
||||
* The static functions in this class are just helper functions to help build
|
||||
* the SQL used in the DataTables demo server-side processing scripts. These
|
||||
* functions obviously do not represent all that can be done with server-side
|
||||
* processing, they are intentionally simple to show how it works. More complex
|
||||
* server-side processing operations will likely require a custom script.
|
||||
*
|
||||
* See https://datatables.net/usage/server-side for full details on the server-
|
||||
* side processing requirements of DataTables.
|
||||
*
|
||||
* @license MIT - https://datatables.net/license_mit
|
||||
*/
|
||||
|
||||
class SSP {
|
||||
/**
|
||||
* Create the data output array for the DataTables rows
|
||||
*
|
||||
* @param array $columns Column information array
|
||||
* @param array $data Data from the SQL get
|
||||
* @return array Formatted data in a row based format
|
||||
*/
|
||||
static function data_output ( $columns, $data )
|
||||
{
|
||||
$out = array();
|
||||
|
||||
for ( $i=0, $ien=count($data) ; $i<$ien ; $i++ ) {
|
||||
$row = array();
|
||||
|
||||
for ( $j=0, $jen=count($columns) ; $j<$jen ; $j++ ) {
|
||||
$column = $columns[$j];
|
||||
|
||||
// Is there a formatter?
|
||||
if ( isset( $column['formatter'] ) ) {
|
||||
if(empty($column['db'])){
|
||||
$row[ $column['dt'] ] = $column['formatter']( $data[$i] );
|
||||
}
|
||||
else{
|
||||
$row[ $column['dt'] ] = $column['formatter']( $data[$i][ $column['db'] ], $data[$i] );
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(!empty($column['db']) && (!isset($column['dummy']) || $column['dummy'] !== true)){
|
||||
$row[ $column['dt'] ] = $data[$i][ $columns[$j]['db'] ];
|
||||
}
|
||||
else{
|
||||
$row[ $column['dt'] ] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Database connection
|
||||
*
|
||||
* Obtain an PHP PDO connection from a connection details array
|
||||
*
|
||||
* @param array $conn SQL connection details. The array should have
|
||||
* the following properties
|
||||
* * host - host name
|
||||
* * db - database name
|
||||
* * user - user name
|
||||
* * pass - user password
|
||||
* * Optional: `'charset' => 'utf8'` - you might need this depending on your PHP / MySQL config
|
||||
* @return resource PDO connection
|
||||
*/
|
||||
static function db ( $conn )
|
||||
{
|
||||
if ( is_array( $conn ) ) {
|
||||
return self::sql_connect( $conn );
|
||||
}
|
||||
|
||||
return $conn;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Paging
|
||||
*
|
||||
* Construct the LIMIT clause for server-side processing SQL query
|
||||
*
|
||||
* @param array $request Data sent to server by DataTables
|
||||
* @param array $columns Column information array
|
||||
* @return string SQL limit clause
|
||||
*/
|
||||
static function limit ( $request, $columns )
|
||||
{
|
||||
$limit = '';
|
||||
|
||||
if ( isset($request['start']) && $request['length'] != -1 ) {
|
||||
$limit = "LIMIT ".intval($request['start']).", ".intval($request['length']);
|
||||
}
|
||||
|
||||
return $limit;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ordering
|
||||
*
|
||||
* Construct the ORDER BY clause for server-side processing SQL query
|
||||
*
|
||||
* @param array $request Data sent to server by DataTables
|
||||
* @param array $columns Column information array
|
||||
* @return string SQL order by clause
|
||||
*/
|
||||
static function order ( $tableAS, $request, $columns )
|
||||
{
|
||||
$select = '';
|
||||
$order = '';
|
||||
|
||||
if ( isset($request['order']) && count($request['order']) ) {
|
||||
$selects = [];
|
||||
$orderBy = [];
|
||||
$dtColumns = self::pluck( $columns, 'dt' );
|
||||
|
||||
for ( $i=0, $ien=count($request['order']) ; $i<$ien ; $i++ ) {
|
||||
// Convert the column index into the column data property
|
||||
$columnIdx = intval($request['order'][$i]['column']);
|
||||
$requestColumn = $request['columns'][$columnIdx];
|
||||
|
||||
$columnIdx = array_search( $columnIdx, $dtColumns );
|
||||
$column = $columns[ $columnIdx ];
|
||||
|
||||
if ( $requestColumn['orderable'] == 'true' ) {
|
||||
$dir = $request['order'][$i]['dir'] === 'asc' ?
|
||||
'ASC' :
|
||||
'DESC';
|
||||
|
||||
if(isset($column['order_subquery'])) {
|
||||
$selects[] = '('.$column['order_subquery'].') AS `'.$column['db'].'_count`';
|
||||
$orderBy[] = '`'.$column['db'].'_count` '.$dir;
|
||||
} else {
|
||||
$orderBy[] = '`'.$tableAS.'`.`'.$column['db'].'` '.$dir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $selects ) ) {
|
||||
$select = ', '.implode(', ', $selects);
|
||||
}
|
||||
|
||||
if ( count( $orderBy ) ) {
|
||||
$order = 'ORDER BY '.implode(', ', $orderBy);
|
||||
}
|
||||
}
|
||||
|
||||
return [$select, $order];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Searching / Filtering
|
||||
*
|
||||
* Construct the WHERE clause for server-side processing SQL query.
|
||||
*
|
||||
* NOTE this does not match the built-in DataTables filtering which does it
|
||||
* word by word on any field. It's possible to do here performance on large
|
||||
* databases would be very poor
|
||||
*
|
||||
* @param array $request Data sent to server by DataTables
|
||||
* @param array $columns Column information array
|
||||
* @param array $bindings Array of values for PDO bindings, used in the
|
||||
* sql_exec() function
|
||||
* @return string SQL where clause
|
||||
*/
|
||||
static function filter ( $tablesAS, $request, $columns, &$bindings )
|
||||
{
|
||||
$globalSearch = array();
|
||||
$columnSearch = array();
|
||||
$joins = array();
|
||||
$dtColumns = self::pluck( $columns, 'dt' );
|
||||
|
||||
if ( isset($request['search']) && $request['search']['value'] != '' ) {
|
||||
$str = $request['search']['value'];
|
||||
|
||||
for ( $i=0, $ien=count($request['columns']) ; $i<$ien ; $i++ ) {
|
||||
$requestColumn = $request['columns'][$i];
|
||||
$columnIdx = array_search( $i, $dtColumns );
|
||||
$column = $columns[ $columnIdx ];
|
||||
|
||||
if ( $requestColumn['searchable'] == 'true' ) {
|
||||
if(!empty($column['db'])){
|
||||
$binding = self::bind( $bindings, '%'.$str.'%', PDO::PARAM_STR );
|
||||
|
||||
if(isset($column['search']['join'])) {
|
||||
$joins[] = $column['search']['join'];
|
||||
$globalSearch[] = $column['search']['where_column'].' LIKE '.$binding;
|
||||
} else {
|
||||
$globalSearch[] = "`".$tablesAS."`.`".$column['db']."` LIKE ".$binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Individual column filtering
|
||||
if ( isset( $request['columns'] ) ) {
|
||||
for ( $i=0, $ien=count($request['columns']) ; $i<$ien ; $i++ ) {
|
||||
$requestColumn = $request['columns'][$i];
|
||||
$columnIdx = array_search( $requestColumn['data'], $dtColumns );
|
||||
$column = $columns[ $columnIdx ];
|
||||
|
||||
$str = $requestColumn['search']['value'];
|
||||
|
||||
if ( $requestColumn['searchable'] == 'true' &&
|
||||
$str != '' ) {
|
||||
if(!empty($column['db'])){
|
||||
$binding = self::bind( $bindings, '%'.$str.'%', PDO::PARAM_STR );
|
||||
$columnSearch[] = "`".$tablesAS."`.`".$column['db']."` LIKE ".$binding;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine the filters into a single string
|
||||
$where = '';
|
||||
|
||||
if ( count( $globalSearch ) ) {
|
||||
$where = '('.implode(' OR ', $globalSearch).')';
|
||||
}
|
||||
|
||||
if ( count( $columnSearch ) ) {
|
||||
$where = $where === '' ?
|
||||
implode(' AND ', $columnSearch) :
|
||||
$where .' AND '. implode(' AND ', $columnSearch);
|
||||
}
|
||||
|
||||
$join = '';
|
||||
if( count($joins) ) {
|
||||
$join = implode(' ', $joins);
|
||||
}
|
||||
|
||||
if ( $where !== '' ) {
|
||||
$where = 'WHERE '.$where;
|
||||
}
|
||||
|
||||
return [$join, $where];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Perform the SQL queries needed for an server-side processing requested,
|
||||
* utilising the helper functions of this class, limit(), order() and
|
||||
* filter() among others. The returned array is ready to be encoded as JSON
|
||||
* in response to an SSP request, or can be modified if needed before
|
||||
* sending back to the client.
|
||||
*
|
||||
* @param array $request Data sent to server by DataTables
|
||||
* @param array|PDO $conn PDO connection resource or connection parameters array
|
||||
* @param string $table SQL table to query
|
||||
* @param string $primaryKey Primary key of the table
|
||||
* @param array $columns Column information array
|
||||
* @return array Server-side processing response array
|
||||
*/
|
||||
static function simple ( $request, $conn, $table, $primaryKey, $columns )
|
||||
{
|
||||
$bindings = array();
|
||||
$db = self::db( $conn );
|
||||
|
||||
// Allow for a JSON string to be passed in
|
||||
if (isset($request['json'])) {
|
||||
$request = json_decode($request['json'], true);
|
||||
}
|
||||
|
||||
// table AS
|
||||
$tablesAS = null;
|
||||
if(is_array($table)) {
|
||||
$tablesAS = $table[1];
|
||||
$table = $table[0];
|
||||
}
|
||||
|
||||
// Build the SQL query string from the request
|
||||
list($select, $order) = self::order( $tablesAS, $request, $columns );
|
||||
$limit = self::limit( $request, $columns );
|
||||
list($join, $where) = self::filter( $tablesAS, $request, $columns, $bindings );
|
||||
|
||||
// Main query to actually get the data
|
||||
$data = self::sql_exec( $db, $bindings,
|
||||
"SELECT `$tablesAS`.`".implode("`, `$tablesAS`.`", self::pluck($columns, 'db'))."`
|
||||
$select
|
||||
FROM `$table` AS `$tablesAS`
|
||||
$join
|
||||
$where
|
||||
GROUP BY `{$tablesAS}`.`{$primaryKey}`
|
||||
$order
|
||||
$limit"
|
||||
);
|
||||
|
||||
// Data set length after filtering
|
||||
$resFilterLength = self::sql_exec( $db, $bindings,
|
||||
"SELECT COUNT(DISTINCT `{$tablesAS}`.`{$primaryKey}`)
|
||||
FROM `$table` AS `$tablesAS`
|
||||
$join
|
||||
$where"
|
||||
);
|
||||
$recordsFiltered = $resFilterLength[0][0];
|
||||
|
||||
// Total data set length
|
||||
$resTotalLength = self::sql_exec( $db,
|
||||
"SELECT COUNT(`{$tablesAS}`.`{$primaryKey}`)
|
||||
FROM `$table` AS `$tablesAS`"
|
||||
);
|
||||
$recordsTotal = $resTotalLength[0][0];
|
||||
|
||||
/*
|
||||
* Output
|
||||
*/
|
||||
return array(
|
||||
"draw" => isset ( $request['draw'] ) ?
|
||||
intval( $request['draw'] ) :
|
||||
0,
|
||||
"recordsTotal" => intval( $recordsTotal ),
|
||||
"recordsFiltered" => intval( $recordsFiltered ),
|
||||
"data" => self::data_output( $columns, $data )
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The difference between this method and the `simple` one, is that you can
|
||||
* apply additional `where` conditions to the SQL queries. These can be in
|
||||
* one of two forms:
|
||||
*
|
||||
* * 'Result condition' - This is applied to the result set, but not the
|
||||
* overall paging information query - i.e. it will not effect the number
|
||||
* of records that a user sees they can have access to. This should be
|
||||
* used when you want apply a filtering condition that the user has sent.
|
||||
* * 'All condition' - This is applied to all queries that are made and
|
||||
* reduces the number of records that the user can access. This should be
|
||||
* used in conditions where you don't want the user to ever have access to
|
||||
* particular records (for example, restricting by a login id).
|
||||
*
|
||||
* In both cases the extra condition can be added as a simple string, or if
|
||||
* you are using external values, as an assoc. array with `condition` and
|
||||
* `bindings` parameters. The `condition` is a string with the SQL WHERE
|
||||
* condition and `bindings` is an assoc. array of the binding names and
|
||||
* values.
|
||||
*
|
||||
* @param array $request Data sent to server by DataTables
|
||||
* @param array|PDO $conn PDO connection resource or connection parameters array
|
||||
* @param string|array $table SQL table to query, if array second key is AS
|
||||
* @param string $primaryKey Primary key of the table
|
||||
* @param array $columns Column information array
|
||||
* @param string $join JOIN sql string
|
||||
* @param string|array $whereResult WHERE condition to apply to the result set
|
||||
* @return array Server-side processing response array
|
||||
*/
|
||||
static function complex (
|
||||
$request,
|
||||
$conn,
|
||||
$table,
|
||||
$primaryKey,
|
||||
$columns,
|
||||
$join=null,
|
||||
$whereResult=null
|
||||
) {
|
||||
$bindings = array();
|
||||
$db = self::db( $conn );
|
||||
|
||||
// table AS
|
||||
$tablesAS = null;
|
||||
if(is_array($table)) {
|
||||
$tablesAS = $table[1];
|
||||
$table = $table[0];
|
||||
}
|
||||
|
||||
// Build the SQL query string from the request
|
||||
list($select, $order) = self::order( $tablesAS, $request, $columns );
|
||||
$limit = self::limit( $request, $columns );
|
||||
list($join_filter, $where) = self::filter( $tablesAS, $request, $columns, $bindings );
|
||||
|
||||
// whereResult can be a simple string, or an assoc. array with a
|
||||
// condition and bindings
|
||||
if ( $whereResult ) {
|
||||
$str = $whereResult;
|
||||
|
||||
if ( is_array($whereResult) ) {
|
||||
$str = $whereResult['condition'];
|
||||
|
||||
if ( isset($whereResult['bindings']) ) {
|
||||
self::add_bindings($bindings, $whereResult);
|
||||
}
|
||||
}
|
||||
|
||||
$where = $where ?
|
||||
$where .' AND '.$str :
|
||||
'WHERE '.$str;
|
||||
}
|
||||
|
||||
// Main query to actually get the data
|
||||
$data = self::sql_exec( $db, $bindings,
|
||||
"SELECT `$tablesAS`.`".implode("`, `$tablesAS`.`", self::pluck($columns, 'db'))."`
|
||||
$select
|
||||
FROM `$table` AS `$tablesAS`
|
||||
$join
|
||||
$join_filter
|
||||
$where
|
||||
GROUP BY `{$tablesAS}`.`{$primaryKey}`
|
||||
$order
|
||||
$limit"
|
||||
);
|
||||
|
||||
// Data set length after filtering
|
||||
$resFilterLength = self::sql_exec( $db, $bindings,
|
||||
"SELECT COUNT(DISTINCT `{$tablesAS}`.`{$primaryKey}`)
|
||||
FROM `$table` AS `$tablesAS`
|
||||
$join
|
||||
$join_filter
|
||||
$where"
|
||||
);
|
||||
$recordsFiltered = (isset($resFilterLength[0])) ? $resFilterLength[0][0] : 0;
|
||||
|
||||
// Total data set length
|
||||
$resTotalLength = self::sql_exec( $db, $bindings,
|
||||
"SELECT COUNT(`{$tablesAS}`.`{$primaryKey}`)
|
||||
FROM `$table` AS `$tablesAS`
|
||||
$join
|
||||
$join_filter
|
||||
$where"
|
||||
);
|
||||
$recordsTotal = (isset($resTotalLength[0])) ? $resTotalLength[0][0] : 0;
|
||||
|
||||
/*
|
||||
* Output
|
||||
*/
|
||||
return array(
|
||||
"draw" => isset ( $request['draw'] ) ?
|
||||
intval( $request['draw'] ) :
|
||||
0,
|
||||
"recordsTotal" => intval( $recordsTotal ),
|
||||
"recordsFiltered" => intval( $recordsFiltered ),
|
||||
"data" => self::data_output( $columns, $data )
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Connect to the database
|
||||
*
|
||||
* @param array $sql_details SQL server connection details array, with the
|
||||
* properties:
|
||||
* * host - host name
|
||||
* * db - database name
|
||||
* * user - user name
|
||||
* * pass - user password
|
||||
* @return resource Database connection handle
|
||||
*/
|
||||
static function sql_connect ( $sql_details )
|
||||
{
|
||||
try {
|
||||
$db = @new PDO(
|
||||
"mysql:host={$sql_details['host']};dbname={$sql_details['db']}",
|
||||
$sql_details['user'],
|
||||
$sql_details['pass'],
|
||||
array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION )
|
||||
);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
self::fatal(
|
||||
"An error occurred while connecting to the database. ".
|
||||
"The error reported by the server was: ".$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
return $db;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Execute an SQL query on the database
|
||||
*
|
||||
* @param resource $db Database handler
|
||||
* @param array $bindings Array of PDO binding values from bind() to be
|
||||
* used for safely escaping strings. Note that this can be given as the
|
||||
* SQL query string if no bindings are required.
|
||||
* @param string $sql SQL query to execute.
|
||||
* @return array Result from the query (all rows)
|
||||
*/
|
||||
static function sql_exec ( $db, $bindings, $sql=null )
|
||||
{
|
||||
// Argument shifting
|
||||
if ( $sql === null ) {
|
||||
$sql = $bindings;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare( $sql );
|
||||
|
||||
// Bind parameters
|
||||
if ( is_array( $bindings ) ) {
|
||||
for ( $i=0, $ien=count($bindings) ; $i<$ien ; $i++ ) {
|
||||
$binding = $bindings[$i];
|
||||
$stmt->bindValue( $binding['key'], $binding['val'], $binding['type'] );
|
||||
}
|
||||
}
|
||||
|
||||
// Execute
|
||||
try {
|
||||
$stmt->execute();
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
self::fatal( "An SQL error occurred: ".$e->getMessage() );
|
||||
}
|
||||
|
||||
// Return all
|
||||
return $stmt->fetchAll( PDO::FETCH_BOTH );
|
||||
}
|
||||
|
||||
|
||||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
* Internal methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throw a fatal error.
|
||||
*
|
||||
* This writes out an error message in a JSON string which DataTables will
|
||||
* see and show to the user in the browser.
|
||||
*
|
||||
* @param string $msg Message to send to the client
|
||||
*/
|
||||
static function fatal ( $msg )
|
||||
{
|
||||
echo json_encode( array(
|
||||
"error" => $msg
|
||||
) );
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDO binding key which can be used for escaping variables safely
|
||||
* when executing a query with sql_exec()
|
||||
*
|
||||
* @param array &$a Array of bindings
|
||||
* @param * $val Value to bind
|
||||
* @param int $type PDO field type
|
||||
* @return string Bound key to be used in the SQL where this parameter
|
||||
* would be used.
|
||||
*/
|
||||
static function bind ( &$a, $val, $type )
|
||||
{
|
||||
$key = ':binding_'.count( $a );
|
||||
|
||||
$a[] = array(
|
||||
'key' => $key,
|
||||
'val' => $val,
|
||||
'type' => $type
|
||||
);
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
static function add_bindings(&$bindings, $vals)
|
||||
{
|
||||
foreach($vals['bindings'] as $key => $value) {
|
||||
$bindings[] = array(
|
||||
'key' => $key,
|
||||
'val' => $value,
|
||||
'type' => PDO::PARAM_STR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Pull a particular property from each assoc. array in a numeric array,
|
||||
* returning and array of the property values from each item.
|
||||
*
|
||||
* @param array $a Array to get data from
|
||||
* @param string $prop Property to read
|
||||
* @return array Array of property values
|
||||
*/
|
||||
static function pluck ( $a, $prop )
|
||||
{
|
||||
$out = array();
|
||||
|
||||
for ( $i=0, $len=count($a) ; $i<$len ; $i++ ) {
|
||||
if ( empty($a[$i][$prop]) && $a[$i][$prop] !== 0 ) {
|
||||
continue;
|
||||
}
|
||||
if ( $prop == 'db' && isset($a[$i]['dummy']) && $a[$i]['dummy'] === true ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//removing the $out array index confuses the filter method in doing proper binding,
|
||||
//adding it ensures that the array data are mapped correctly
|
||||
$out[$i] = $a[$i][$prop];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return a string from an array or a string
|
||||
*
|
||||
* @param array|string $a Array to join
|
||||
* @param string $join Glue for the concatenation
|
||||
* @return string Joined string
|
||||
*/
|
||||
static function _flatten ( $a, $join = ' AND ' )
|
||||
{
|
||||
if ( ! $a ) {
|
||||
return '';
|
||||
}
|
||||
else if ( $a && is_array($a) ) {
|
||||
return implode( $join, $a );
|
||||
}
|
||||
return $a;
|
||||
}
|
||||
}
|
||||
|
@ -70,6 +70,8 @@ try {
|
||||
}
|
||||
}
|
||||
catch (Exception $e) {
|
||||
// Stop when redis is not available
|
||||
http_response_code(500);
|
||||
?>
|
||||
<center style='font-family:sans-serif;'>Connection to Redis failed.<br /><br />The following error was reported:<br/><?=$e->getMessage();?></center>
|
||||
<?php
|
||||
@ -98,6 +100,7 @@ try {
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
// Stop when SQL connection fails
|
||||
http_response_code(500);
|
||||
?>
|
||||
<center style='font-family:sans-serif;'>Connection to database failed.<br /><br />The following error was reported:<br/> <?=$e->getMessage();?></center>
|
||||
<?php
|
||||
@ -105,6 +108,7 @@ exit;
|
||||
}
|
||||
// Stop when dockerapi is not available
|
||||
if (fsockopen("tcp://dockerapi", 443, $errno, $errstr) === false) {
|
||||
http_response_code(500);
|
||||
?>
|
||||
<center style='font-family:sans-serif;'>Connection to dockerapi container failed.<br /><br />The following error was reported:<br/><?=$errno;?> - <?=$errstr;?></center>
|
||||
<?php
|
||||
|
@ -126,6 +126,15 @@ $MAILCOW_APPS = array(
|
||||
)
|
||||
);
|
||||
|
||||
// Logo max file size in bytes
|
||||
$LOGO_LIMITS['max_size'] = 15 * 1024 * 1024; // 15MB
|
||||
|
||||
// Logo max width in pixels
|
||||
$LOGO_LIMITS['max_width'] = 1920;
|
||||
|
||||
// Logo max height in pixels
|
||||
$LOGO_LIMITS['max_height'] = 1920;
|
||||
|
||||
// Rows until pagination begins
|
||||
$PAGINATION_SIZE = 25;
|
||||
|
||||
|
@ -391,3 +391,11 @@ function addTag(tagAddElem, tag = null){
|
||||
$(tagValuesElem).val(JSON.stringify(value_tags));
|
||||
$(tagInputElem).val('');
|
||||
}
|
||||
function copyToClipboard(id) {
|
||||
var copyText = document.getElementById(id);
|
||||
copyText.select();
|
||||
copyText.setSelectionRange(0, 99999);
|
||||
// only works with https connections
|
||||
navigator.clipboard.writeText(copyText.value);
|
||||
mailcow_alert_box(lang.copy_to_clipboard, "success");
|
||||
}
|
@ -199,6 +199,23 @@ jQuery(function($){
|
||||
});
|
||||
}
|
||||
|
||||
function add_table_row(table_id, type) {
|
||||
var row = $('<tr />');
|
||||
if (type == "mbox_attr") {
|
||||
cols = '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required></td>';
|
||||
cols += '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required></td>';
|
||||
cols += '<td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">' + lang_admin.remove_row + '</a></td>';
|
||||
}
|
||||
row.append(cols);
|
||||
table_id.append(row);
|
||||
}
|
||||
$('#mbox_attr_table').on('click', 'tr a', function (e) {
|
||||
e.preventDefault();
|
||||
$(this).parents('tr').remove();
|
||||
});
|
||||
$('#add_mbox_attr_row').click(function() {
|
||||
add_table_row($('#mbox_attr_table'), "mbox_attr");
|
||||
});
|
||||
|
||||
// detect element visibility changes
|
||||
function onVisible(element, callback) {
|
||||
|
@ -435,7 +435,7 @@ jQuery(function($){
|
||||
var table = $('#domain_table').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
pageLength: pagination_size,
|
||||
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" +
|
||||
@ -447,9 +447,9 @@ jQuery(function($){
|
||||
},
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/domain/all",
|
||||
url: "/api/v1/get/domain/datatables",
|
||||
dataSrc: function(json){
|
||||
$.each(json, function(i, item) {
|
||||
$.each(json.data, function(i, item) {
|
||||
item.domain_name = escapeHtml(item.domain_name);
|
||||
|
||||
item.aliases = item.aliases_in_domain + " / " + item.max_num_aliases_for_domain;
|
||||
@ -498,7 +498,7 @@ jQuery(function($){
|
||||
}
|
||||
});
|
||||
|
||||
return json;
|
||||
return json.data;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@ -528,17 +528,20 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.aliases,
|
||||
data: 'aliases',
|
||||
searchable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.mailboxes,
|
||||
data: 'mailboxes',
|
||||
searchable: false,
|
||||
responsivePriority: 4,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.domain_quota,
|
||||
data: 'quota',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
render: function (data, type) {
|
||||
data = data.split("/");
|
||||
@ -548,6 +551,7 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.stats,
|
||||
data: 'stats',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
render: function (data, type) {
|
||||
data = data.split("/");
|
||||
@ -557,53 +561,67 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.mailbox_defquota,
|
||||
data: 'def_quota_for_mbox',
|
||||
searchable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.mailbox_quota,
|
||||
data: 'max_quota_for_mbox',
|
||||
searchable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: 'RL',
|
||||
data: 'rl',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.backup_mx,
|
||||
data: 'backupmx',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
redner: function (data, type){
|
||||
return 1==value ? '<i class="bi bi-check-lg"></i>' : 0==value && '<i class="bi bi-x-lg"></i>';
|
||||
render: function (data, type){
|
||||
return 1==data ? '<i class="bi bi-check-lg"></i>' : 0==data && '<i class="bi bi-x-lg"></i>';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.domain_admins,
|
||||
data: 'domain_admins',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
className: 'none'
|
||||
},
|
||||
{
|
||||
title: lang.created_on,
|
||||
data: 'created',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
className: 'none'
|
||||
},
|
||||
{
|
||||
title: lang.last_modified,
|
||||
data: 'modified',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
className: 'none'
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
data: 'tags',
|
||||
searchable: true,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
className: 'none'
|
||||
},
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 6,
|
||||
render: function (data, type) {
|
||||
@ -613,6 +631,8 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right',
|
||||
responsivePriority: 5,
|
||||
defaultContent: ''
|
||||
@ -844,7 +864,7 @@ jQuery(function($){
|
||||
var table = $('#mailbox_table').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
pageLength: pagination_size,
|
||||
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" +
|
||||
@ -853,13 +873,12 @@ jQuery(function($){
|
||||
language: lang_datatables,
|
||||
initComplete: function(settings, json){
|
||||
hideTableExpandCollapseBtn('#tab-mailboxes', '#mailbox_table');
|
||||
filterByDomain(json, 8, table);
|
||||
},
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/mailbox/reduced",
|
||||
url: "/api/v1/get/mailbox/datatables",
|
||||
dataSrc: function(json){
|
||||
$.each(json, function (i, item) {
|
||||
$.each(json.data, function (i, item) {
|
||||
item.quota = {
|
||||
sortBy: item.quota_used,
|
||||
value: item.quota
|
||||
@ -945,7 +964,7 @@ jQuery(function($){
|
||||
}
|
||||
});
|
||||
|
||||
return json;
|
||||
return json.data;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
@ -975,13 +994,14 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.domain_quota,
|
||||
data: 'quota.value',
|
||||
searchable: false,
|
||||
responsivePriority: 8,
|
||||
defaultContent: '',
|
||||
orderData: 23
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.last_mail_login,
|
||||
data: 'last_mail_login',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 7,
|
||||
render: function (data, type) {
|
||||
@ -994,15 +1014,16 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.last_pw_change,
|
||||
data: 'last_pw_change',
|
||||
searchable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.in_use,
|
||||
data: 'in_use.value',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 9,
|
||||
className: 'dt-data-w100',
|
||||
orderData: 24
|
||||
className: 'dt-data-w100'
|
||||
},
|
||||
{
|
||||
title: lang.fname,
|
||||
@ -1067,6 +1088,7 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.msg_num,
|
||||
data: 'messages',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 5
|
||||
},
|
||||
@ -1085,12 +1107,14 @@ jQuery(function($){
|
||||
{
|
||||
title: 'Tags',
|
||||
data: 'tags',
|
||||
searchable: true,
|
||||
defaultContent: '',
|
||||
className: 'none'
|
||||
},
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 4,
|
||||
render: function (data, type) {
|
||||
@ -1100,22 +1124,12 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right',
|
||||
responsivePriority: 6,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
data: 'quota.sortBy',
|
||||
defaultContent: '',
|
||||
className: "d-none"
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
data: 'in_use.sortBy',
|
||||
defaultContent: '',
|
||||
className: "d-none"
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
@ -40,7 +40,7 @@ jQuery(function($){
|
||||
if (value.score > 0) highlightClass = 'negative';
|
||||
else if (value.score < 0) highlightClass = 'positive';
|
||||
else highlightClass = 'neutral';
|
||||
$('#qid_detail_symbols').append('<span data-bs-toggle="tooltip" class="rspamd-symbol ' + highlightClass + '" title="' + (value.options ? value.options.join(', ') : '') + '">' + value.name + ' (<span class="score">' + value.score + '</span>)</span>');
|
||||
$('#qid_detail_symbols').append('<span data-bs-toggle="tooltip" class="rspamd-symbol ' + highlightClass + '" title="' + (value.options ? escapeHtml(value.options.join(', ')) : '') + '">' + value.name + ' (<span class="score">' + value.score + '</span>)</span>');
|
||||
});
|
||||
$('[data-bs-toggle="tooltip"]').tooltip();
|
||||
}
|
||||
|
@ -504,6 +504,16 @@ if (isset($_GET['query'])) {
|
||||
$_SESSION['challenge'] = $WebAuthn->getChallenge();
|
||||
return;
|
||||
break;
|
||||
case "fail2ban":
|
||||
if (!isset($_SESSION['mailcow_cc_role'])){
|
||||
switch ($object) {
|
||||
case 'banlist':
|
||||
header('Content-Type: text/plain');
|
||||
echo fail2ban('banlist', 'get', $extra);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
switch ($category) {
|
||||
@ -523,6 +533,47 @@ if (isset($_GET['query'])) {
|
||||
|
||||
case "domain":
|
||||
switch ($object) {
|
||||
case "datatables":
|
||||
$table = ['domain', 'd'];
|
||||
$primaryKey = 'domain';
|
||||
$columns = [
|
||||
['db' => 'domain', 'dt' => 2],
|
||||
['db' => 'aliases', 'dt' => 3, 'order_subquery' => "SELECT COUNT(*) FROM `alias` WHERE (`domain`= `d`.`domain` OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = `d`.`domain`)) AND `address` NOT IN (SELECT `username` FROM `mailbox`)"],
|
||||
['db' => 'mailboxes', 'dt' => 4, 'order_subquery' => "SELECT COUNT(*) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],
|
||||
['db' => 'quota', 'dt' => 5, 'order_subquery' => "SELECT COALESCE(SUM(`mailbox`.`quota`), 0) FROM `mailbox` WHERE `mailbox`.`domain` = `d`.`domain` AND (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)"],
|
||||
['db' => 'stats', 'dt' => 6, 'dummy' => true, 'order_subquery' => "SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` IN (SELECT `username` FROM `mailbox` WHERE `domain` = `d`.`domain`)"],
|
||||
['db' => 'defquota', 'dt' => 7],
|
||||
['db' => 'maxquota', 'dt' => 8],
|
||||
['db' => 'backupmx', 'dt' => 10],
|
||||
['db' => 'tags', 'dt' => 14, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_domain` AS `td` ON `td`.`domain` = `d`.`domain`', 'where_column' => '`td`.`tag_name`']],
|
||||
['db' => 'active', 'dt' => 15],
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/ssp.class.php';
|
||||
global $pdo;
|
||||
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||
$data = SSP::simple($_GET, $pdo, $table, $primaryKey, $columns);
|
||||
} elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {
|
||||
$data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns,
|
||||
'INNER JOIN domain_admins as da ON da.domain = d.domain',
|
||||
[
|
||||
'condition' => 'da.active = 1 and da.username = :username',
|
||||
'bindings' => ['username' => $_SESSION['mailcow_cc_username']]
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($data['data'])) {
|
||||
$domainsData = [];
|
||||
foreach ($data['data'] as $domain) {
|
||||
if ($details = mailbox('get', 'domain_details', $domain[2])) {
|
||||
$domainsData[] = $details;
|
||||
}
|
||||
}
|
||||
$data['data'] = $domainsData;
|
||||
}
|
||||
|
||||
process_get_return($data);
|
||||
break;
|
||||
case "all":
|
||||
$tags = null;
|
||||
if (isset($_GET['tags']) && $_GET['tags'] != '')
|
||||
@ -1011,6 +1062,45 @@ if (isset($_GET['query'])) {
|
||||
break;
|
||||
case "mailbox":
|
||||
switch ($object) {
|
||||
case "datatables":
|
||||
$table = ['mailbox', 'm'];
|
||||
$primaryKey = 'username';
|
||||
$columns = [
|
||||
['db' => 'username', 'dt' => 2],
|
||||
['db' => 'quota', 'dt' => 3],
|
||||
['db' => 'last_mail_login', 'dt' => 4, 'dummy' => true, 'order_subquery' => "SELECT MAX(`datetime`) FROM `sasl_log` WHERE `service` != 'SSO' AND `username` = `m`.`username`"],
|
||||
['db' => 'last_pw_change', 'dt' => 5, 'dummy' => true, 'order_subquery' => "JSON_EXTRACT(attributes, '$.passwd_update')"],
|
||||
['db' => 'in_use', 'dt' => 6, 'dummy' => true, 'order_subquery' => "(SELECT SUM(bytes) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`) / `m`.`quota`"],
|
||||
['db' => 'messages', 'dt' => 17, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],
|
||||
['db' => 'tags', 'dt' => 20, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],
|
||||
['db' => 'active', 'dt' => 21]
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/lib/ssp.class.php';
|
||||
global $pdo;
|
||||
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||
$data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns, null, "(`m`.`kind` = '' OR `m`.`kind` = NULL)");
|
||||
} elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {
|
||||
$data = SSP::complex($_GET, $pdo, $table, $primaryKey, $columns,
|
||||
'INNER JOIN domain_admins as da ON da.domain = m.domain',
|
||||
[
|
||||
'condition' => "(`m`.`kind` = '' OR `m`.`kind` = NULL) AND `da`.`active` = 1 AND `da`.`username` = :username",
|
||||
'bindings' => ['username' => $_SESSION['mailcow_cc_username']]
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($data['data'])) {
|
||||
$mailboxData = [];
|
||||
foreach ($data['data'] as $mailbox) {
|
||||
if ($details = mailbox('get', 'mailbox_details', $mailbox[2])) {
|
||||
$mailboxData[] = $details;
|
||||
}
|
||||
}
|
||||
$data['data'] = $mailboxData;
|
||||
}
|
||||
|
||||
process_get_return($data);
|
||||
break;
|
||||
case "all":
|
||||
case "reduced":
|
||||
$tags = null;
|
||||
@ -1324,6 +1414,10 @@ if (isset($_GET['query'])) {
|
||||
break;
|
||||
case "fail2ban":
|
||||
switch ($object) {
|
||||
case 'banlist':
|
||||
header('Content-Type: text/plain');
|
||||
echo fail2ban('banlist', 'get', $extra);
|
||||
break;
|
||||
default:
|
||||
$data = fail2ban('get');
|
||||
process_get_return($data);
|
||||
@ -1591,6 +1685,12 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "spam-score":
|
||||
$score = mailbox('get', 'spam_score', $object);
|
||||
if ($score)
|
||||
$score = array("score" => preg_replace("/\s+/", "", $score));
|
||||
process_get_return($score);
|
||||
break;
|
||||
break;
|
||||
// return no route found if no case is matched
|
||||
default:
|
||||
@ -1867,8 +1967,6 @@ if (isset($_GET['query'])) {
|
||||
case "quota_notification_bcc":
|
||||
process_edit_return(quota_notification_bcc('edit', $attr));
|
||||
break;
|
||||
case "domain-wide-footer":
|
||||
process_edit_return(mailbox('edit', 'domain_wide_footer', $attr));
|
||||
break;
|
||||
case "mailq":
|
||||
process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
|
||||
@ -1881,6 +1979,9 @@ if (isset($_GET['query'])) {
|
||||
case "template":
|
||||
process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr)));
|
||||
break;
|
||||
case "custom-attribute":
|
||||
process_edit_return(mailbox('edit', 'mailbox_custom_attribute', array_merge(array('mailboxes' => $items), $attr)));
|
||||
break;
|
||||
default:
|
||||
process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr)));
|
||||
break;
|
||||
@ -1900,6 +2001,9 @@ if (isset($_GET['query'])) {
|
||||
case "template":
|
||||
process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr)));
|
||||
break;
|
||||
case "footer":
|
||||
process_edit_return(mailbox('edit', 'domain_wide_footer', array_merge(array('domains' => $items), $attr)));
|
||||
break;
|
||||
default:
|
||||
process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr)));
|
||||
break;
|
||||
@ -1933,8 +2037,15 @@ if (isset($_GET['query'])) {
|
||||
process_edit_return(fwdhost('edit', array_merge(array('fwdhost' => $items), $attr)));
|
||||
break;
|
||||
case "fail2ban":
|
||||
switch ($object) {
|
||||
case 'banlist':
|
||||
process_edit_return(fail2ban('banlist', 'refresh', $items));
|
||||
break;
|
||||
default:
|
||||
process_edit_return(fail2ban('edit', array_merge(array('network' => $items), $attr)));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "ui_texts":
|
||||
process_edit_return(customize('edit', 'ui_texts', $attr));
|
||||
break;
|
||||
@ -1972,7 +2083,7 @@ if (isset($_GET['query'])) {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
if ($_SESSION['mailcow_cc_api'] === true) {
|
||||
if (array_key_exists('mailcow_cc_api', $_SESSION) && $_SESSION['mailcow_cc_api'] === true) {
|
||||
if (isset($_SESSION['mailcow_cc_api']) && $_SESSION['mailcow_cc_api'] === true) {
|
||||
unset($_SESSION['return']);
|
||||
}
|
||||
|
@ -107,7 +107,8 @@
|
||||
"username": "Uživatelské jméno",
|
||||
"validate": "Ověřit",
|
||||
"validation_success": "Úspěšně ověřeno",
|
||||
"tags": "Štítky"
|
||||
"tags": "Štítky",
|
||||
"dry": "Simulovat synchronizaci"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Přístupy",
|
||||
@ -209,7 +210,7 @@
|
||||
"include_exclude_info": "Ve výchozím nastavení (bez výběru), jsou adresovány <b>všechny mailové schránky</b>",
|
||||
"includes": "Zahrnout tyto přijemce",
|
||||
"ip_check": "Kontrola IP",
|
||||
"ip_check_disabled": "Kontrola IP je vypnuta. Můžete ji zapnout v <br> <strong>System > Nastavení > Options > Přizpůsobení</strong>",
|
||||
"ip_check_disabled": "Kontrola IP je zakázána. Můžete ji povolit v nabídce<br> <strong>Systém > Nastavení > Možnosti > Přizpůsobení</strong>",
|
||||
"ip_check_opt_in": "Přihlásit se k používání služby třetí strany <strong>ipv4.mailcow.email</strong> a <strong>ipv6.mailcow.email</strong> pro zjištění externích IP adres.",
|
||||
"is_mx_based": "Na základě MX",
|
||||
"last_applied": "Naposledy použité",
|
||||
@ -218,7 +219,7 @@
|
||||
"loading": "Prosím čekejte...",
|
||||
"login_time": "Čas přihlášení",
|
||||
"logo_info": "Obrázek bude zmenšen na výšku 40 pixelů pro horní navigační lištu a na max. šířku 250 pixelů pro úvodní stránku.",
|
||||
"lookup_mx": "Ověřit cíl proti MX záznamu (.outlook.com bude směrovat všechnu poštu pro MX *.outlook.com přes tento uzel)",
|
||||
"lookup_mx": "Cíl je regulární výraz, který se porovná s názvem MX (<code>.*\\.google\\.com</code> pro směrování veškeré pošty cílené na MX, který končí na google.com přes tento skok)",
|
||||
"main_name": "Název webu (\"mailcow UI\")",
|
||||
"merged_vars_hint": "Šedé řádky byly přidány z <code>vars.(local.)inc.php</code> a zde je nelze upravit.",
|
||||
"message": "Zpráva",
|
||||
@ -343,7 +344,9 @@
|
||||
"verify": "Ověřit",
|
||||
"yes": "✓",
|
||||
"f2b_ban_time_increment": "Délka banu je prodlužována s každým dalším banem",
|
||||
"f2b_max_ban_time": "Maximální délka banu (s)"
|
||||
"f2b_max_ban_time": "Maximální délka banu (s)",
|
||||
"cors_settings": "Nastavení CORS",
|
||||
"queue_unban": "zrušit ban"
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Přístup odepřen nebo jsou neplatná data ve formuláři",
|
||||
@ -465,7 +468,14 @@
|
||||
"username_invalid": "Uživatelské jméno %s nelze použít",
|
||||
"validity_missing": "Zdejte dobu platnosti",
|
||||
"value_missing": "Prosím, uveďte všechny hodnoty",
|
||||
"yotp_verification_failed": "Yubico OTP ověření selhalo: %s"
|
||||
"yotp_verification_failed": "Yubico OTP ověření selhalo: %s",
|
||||
"webauthn_authenticator_failed": "Zvolený ověřovací prostředek nebyl nalezen",
|
||||
"cors_invalid_method": "Zadaná neplatná metoda Allow-Method",
|
||||
"cors_invalid_origin": "Zadán neplatný Allow-Origin",
|
||||
"webauthn_publickey_failed": "Pro vybraný ověřovací prostředek nebyl uložen žádný veřejný klíč",
|
||||
"webauthn_username_failed": "Zvolený ověřovací prostředek patří k jinému účtu",
|
||||
"extended_sender_acl_denied": "chybějící ACL pro nastavení externích adres odesílatele",
|
||||
"demo_mode_enabled": "Demo režim je zapnutý"
|
||||
},
|
||||
"datatables": {
|
||||
"emptyTable": "Tabulka neobsahuje žádná data",
|
||||
@ -488,7 +498,9 @@
|
||||
"processing": "Zpracovávání...",
|
||||
"search": "Vyhledávání:",
|
||||
"decimal": ",",
|
||||
"thousands": " "
|
||||
"thousands": " ",
|
||||
"collapse_all": "Sbalit vše",
|
||||
"expand_all": "Rozbalit vše"
|
||||
},
|
||||
"debug": {
|
||||
"chart_this_server": "Graf (tento server)",
|
||||
@ -515,7 +527,20 @@
|
||||
"success": "Úspěch",
|
||||
"system_containers": "Systém a kontejnery",
|
||||
"uptime": "Doba běhu",
|
||||
"username": "Uživatelské meno"
|
||||
"username": "Uživatelské meno",
|
||||
"architecture": "Architektura",
|
||||
"error_show_ip": "Nepodařilo se přeložit veřejné IP adresy",
|
||||
"show_ip": "Zobrazit veřejné IP adresy",
|
||||
"container_running": "Běží",
|
||||
"container_stopped": "Zastaven",
|
||||
"current_time": "Systémový čas",
|
||||
"timezone": "Časové pásmo",
|
||||
"update_available": "K dispozici je aktualizace",
|
||||
"no_update_available": "Systém je na nejnovější verzi",
|
||||
"update_failed": "Nepodařilo se zkontrolovat aktualizace",
|
||||
"wip": "Nedokončená vývojová verze",
|
||||
"memory": "Paměť",
|
||||
"container_disabled": "Kontejner je zastaven nebo zakázán"
|
||||
},
|
||||
"diagnostics": {
|
||||
"cname_from_a": "Hodnota odvozena z A/AAAA záznamu. Lze použít, pokud záznam ukazuje na správný zdroj.",
|
||||
@ -640,7 +665,19 @@
|
||||
"title": "Úprava objektu",
|
||||
"unchanged_if_empty": "Pokud se nemění, ponechte prázdné",
|
||||
"username": "Uživatelské jméno",
|
||||
"validate_save": "Ověřit a uložit"
|
||||
"validate_save": "Ověřit a uložit",
|
||||
"domain_footer_info": "Patičky pro celou doménu se přidávají ke všem odchozím e-mailům spojeným s adresou v rámci této domény. <br> Pro patičku lze použít následující proměnné:",
|
||||
"domain_footer_info_vars": {
|
||||
"from_name": "{= from_name =} - Jméno odesílatele, např. pro \"Mailcow <moo@mailcow.tld>\" vrátí \"Mailcow\"",
|
||||
"auth_user": "{= auth_user =} - Ověřené uživatelské jméno zadané MTA",
|
||||
"from_user": "{= from_user =} - uživatelská část odesílatele, např. pro \"moo@mailcow.tld\" vrátí \"moo\"",
|
||||
"from_domain": "{= from_domain =} - Doména odesílatele",
|
||||
"from_addr": "{= from_addr =} - E-mailová adresa odesílatele"
|
||||
},
|
||||
"domain_footer": "Patička pro celou doménu",
|
||||
"domain_footer_html": "HTML text",
|
||||
"domain_footer_plain": "Prostý text",
|
||||
"pushover_sound": "Zvukové upozornění"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "Potvrdit",
|
||||
@ -870,7 +907,8 @@
|
||||
"username": "Uživatelské jméno",
|
||||
"waiting": "Čekání",
|
||||
"weekly": "Každý týden",
|
||||
"yes": "✓"
|
||||
"yes": "✓",
|
||||
"relay_unknown": "Předávání neexistujících schránek"
|
||||
},
|
||||
"oauth2": {
|
||||
"access_denied": "K udělení přístupu se přihlašte jako vlastník mailové schránky.",
|
||||
@ -935,7 +973,19 @@
|
||||
"type": "Typ"
|
||||
},
|
||||
"queue": {
|
||||
"queue_manager": "Správce fronty"
|
||||
"queue_manager": "Správce fronty",
|
||||
"delete": "Vymazat vše",
|
||||
"info": "Poštovní fronta obsahuje všechny e-maily, které čekají na doručení. Pokud e-mail uvízne v poštovní frontě na delší dobu, systém jej automaticky odstraní.<br>Chybové hlášení příslušného e-mailu poskytuje informace o tom, proč se e-mail nepodařilo doručit.",
|
||||
"flush": "Vyprázdnit frontu",
|
||||
"legend": "Funkce operací poštovní fronty:",
|
||||
"ays": "Potvrďte, že chcete opravdu odstranit všechny položky z aktuální fronty.",
|
||||
"deliver_mail": "Doručit",
|
||||
"deliver_mail_legend": "Opětovný pokus o doručení vybraných e-mailů.",
|
||||
"hold_mail": "Podržet",
|
||||
"hold_mail_legend": "Podrží vybrané e-maily. (Zabrání dalším pokusům o doručení)",
|
||||
"show_message": "Zobrazit zprávu",
|
||||
"unhold_mail": "Uvolnit",
|
||||
"unhold_mail_legend": "Uvolnit vybrané e-maily k doručení. (Pouze v případě předchozího podržení)"
|
||||
},
|
||||
"ratelimit": {
|
||||
"disabled": "Vypnuto",
|
||||
@ -1029,7 +1079,9 @@
|
||||
"verified_fido2_login": "Ověřené FIDO2 přihlášení",
|
||||
"verified_totp_login": "TOTP přihlášení ověřeno",
|
||||
"verified_webauthn_login": "WebAuthn přihlášení ověřeno",
|
||||
"verified_yotp_login": "Yubico OTP přihlášení ověřeno"
|
||||
"verified_yotp_login": "Yubico OTP přihlášení ověřeno",
|
||||
"cors_headers_edited": "Nastavení CORS byla uložena",
|
||||
"domain_footer_modified": "Změny patičky domény %s byly uloženy"
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s používá Yubico Cloud API. Prosím získejte API klíč pro své Yubico <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">ZDE</a>",
|
||||
@ -1215,7 +1267,8 @@
|
||||
"weeks": "týdny",
|
||||
"with_app_password": "s heslem aplikace",
|
||||
"year": "rok",
|
||||
"years": "let"
|
||||
"years": "let",
|
||||
"pushover_sound": "Zvukové upozornění"
|
||||
},
|
||||
"warning": {
|
||||
"cannot_delete_self": "Nelze smazat právě přihlášeného uživatele",
|
||||
|
@ -148,6 +148,7 @@
|
||||
"change_logo": "Logo ändern",
|
||||
"configuration": "Konfiguration",
|
||||
"convert_html_to_text": "Konvertiere HTML zu reinem Text",
|
||||
"copy_to_clipboard": "Text wurde in die Zwischenablage kopiert!",
|
||||
"cors_settings": "CORS Einstellungen",
|
||||
"credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
|
||||
"customer_id": "Kunde",
|
||||
@ -181,6 +182,8 @@
|
||||
"f2b_blacklist": "Blacklist für Netzwerke und Hosts",
|
||||
"f2b_filter": "Regex-Filter",
|
||||
"f2b_list_info": "Ein Host oder Netzwerk auf der Blacklist wird immer eine Whitelist-Einheit überwiegen. <b>Die Aktualisierung der Liste dauert einige Sekunden.</b>",
|
||||
"f2b_manage_external": "Fail2Ban extern verwalten",
|
||||
"f2b_manage_external_info": "Fail2ban wird die Banlist weiterhin pflegen, jedoch werden keine aktiven Regeln zum blockieren gesetzt. Die unten generierte Banlist, kann verwendet werden, um den Datenverkehr extern zu blockieren.",
|
||||
"f2b_max_attempts": "Max. Versuche",
|
||||
"f2b_max_ban_time": "Maximale Bannzeit in Sekunden",
|
||||
"f2b_netban_ipv4": "Netzbereich für IPv4-Banns (8-32)",
|
||||
@ -346,7 +349,9 @@
|
||||
"oauth2_apps": "OAuth2 Apps",
|
||||
"queue_unban": "entsperren",
|
||||
"allowed_methods": "Access-Control-Allow-Methods",
|
||||
"allowed_origins": "Access-Control-Allow-Origin"
|
||||
"allowed_origins": "Access-Control-Allow-Origin",
|
||||
"logo_dark_label": "Invertiert für den Darkmode",
|
||||
"logo_normal_label": "Normal"
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
|
||||
@ -389,7 +394,9 @@
|
||||
"goto_invalid": "Ziel-Adresse %s ist ungültig",
|
||||
"ham_learn_error": "Ham Lernfehler: %s",
|
||||
"imagick_exception": "Fataler Bildverarbeitungsfehler",
|
||||
"img_dimensions_exceeded": "Grafik überschreitet die maximale Bildgröße",
|
||||
"img_invalid": "Grafik konnte nicht validiert werden",
|
||||
"img_size_exceeded": "Grafik überschreitet die maximale Dateigröße",
|
||||
"img_tmp_missing": "Grafik konnte nicht validiert werden: Erstellung temporärer Datei fehlgeschlagen.",
|
||||
"invalid_bcc_map_type": "Ungültiger BCC-Map-Typ",
|
||||
"invalid_destination": "Ziel-Format \"%s\" ist ungültig",
|
||||
@ -574,6 +581,7 @@
|
||||
"client_secret": "Client-Secret",
|
||||
"comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.",
|
||||
"created_on": "Erstellt am",
|
||||
"custom_attributes": "benutzerdefinierte Attribute",
|
||||
"delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
|
||||
"delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
|
||||
"delete2duplicates": "Lösche Duplikate im Ziel",
|
||||
@ -582,10 +590,19 @@
|
||||
"disable_login": "Login verbieten (Mails werden weiterhin angenommen)",
|
||||
"domain": "Domain bearbeiten",
|
||||
"domain_admin": "Domain-Administrator bearbeiten",
|
||||
"domain_footer": "Domain wide footer",
|
||||
"domain_footer_html": "HTML footer",
|
||||
"domain_footer_info": "Domain wide footer werden allen ausgehenden E-Mails hinzugefügt, die einer Adresse innerhalb dieser Domain gehört.<br>Die folgenden Variablen können für den Footer benutzt werden:",
|
||||
"domain_footer_plain": "PLAIN footer",
|
||||
"domain_footer": "Domänenweite Fußzeile",
|
||||
"domain_footer_html": "Fußzeile im HTML Format",
|
||||
"domain_footer_info": "Domänenweite Footer (Domain wide footer) werden allen ausgehenden E-Mails hinzugefügt, die einer Adresse innerhalb dieser Domain gehört.<br>Die folgenden Variablen können für die Fußzeile benutzt werden:",
|
||||
"domain_footer_info_vars": {
|
||||
"auth_user": "{= auth_user =} - Angemeldeter Benutzername vom MTA",
|
||||
"from_user": "{= from_user =} - Absender Teil der E-Mail z.B. für \"moo@mailcow.tld\" wird \"moo\" zurückgeben.",
|
||||
"from_name": "{= from_name =} - Namen des Absenders z.B. für \"Mailcow <moo@mailcow.tld>\", wird \"Mailcow\" zurückgegeben.",
|
||||
"from_addr": "{= from_addr =} - Adresse des Absenders.",
|
||||
"from_domain": "{= from_domain =} - Domain des Absenders",
|
||||
"custom": "{= foo =} - Wenn die Mailbox das benutzerdefinierte Attribut \"foo\" mit dem Wert \"bar\" hat, wird \"bar\" zurückgegeben."
|
||||
},
|
||||
"domain_footer_plain": "Fußzeile im PLAIN Format",
|
||||
"domain_footer_skip_replies": "Ignoriere Footer bei Antwort E-Mails",
|
||||
"domain_quota": "Domain Speicherplatz gesamt (MiB)",
|
||||
"domains": "Domains",
|
||||
"dont_check_sender_acl": "Absender für Domain %s u. Alias-Domain nicht prüfen",
|
||||
@ -614,6 +631,7 @@
|
||||
"max_quota": "Max. Größe per Mailbox (MiB)",
|
||||
"maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>",
|
||||
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
|
||||
"mbox_exclude": "Mailboxen ausschließen",
|
||||
"mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.",
|
||||
"mins_interval": "Intervall (min)",
|
||||
"multiple_bookings": "Mehrfaches Buchen",
|
||||
@ -1027,6 +1045,7 @@
|
||||
"domain_removed": "Domain %s wurde entfernt",
|
||||
"dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet",
|
||||
"eas_reset": "ActiveSync Gerät des Benutzers %s wurde zurückgesetzt",
|
||||
"f2b_banlist_refreshed": "Banlist ID wurde erfolgreich erneuert.",
|
||||
"f2b_modified": "Änderungen an Fail2ban-Parametern wurden gespeichert",
|
||||
"forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt",
|
||||
"forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt",
|
||||
@ -1076,6 +1095,7 @@
|
||||
"verified_yotp_login": "Yubico-OTP-Anmeldung verifiziert"
|
||||
},
|
||||
"tfa": {
|
||||
"authenticators": "Authentikatoren",
|
||||
"api_register": "%s verwendet die Yubico-Cloud-API. Ein API-Key für den Yubico-Stick kann <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">hier</a> bezogen werden.",
|
||||
"confirm": "Bestätigen",
|
||||
"confirm_totp_token": "Bitte bestätigen Sie die Änderung durch Eingabe eines generierten Tokens",
|
||||
@ -1125,6 +1145,7 @@
|
||||
"apple_connection_profile_complete": "Dieses Verbindungsprofil beinhaltet neben IMAP- und SMTP-Konfigurationen auch Pfade für die Konfiguration von CalDAV (Kalender) und CardDAV (Adressbücher) für ein Apple-Gerät.",
|
||||
"apple_connection_profile_mailonly": "Dieses Verbindungsprofil beinhaltet IMAP- und SMTP-Konfigurationen für ein Apple-Gerät.",
|
||||
"apple_connection_profile_with_app_password": "Es wird ein neues App-Passwort erzeugt und in das Profil eingefügt, damit bei der Einrichtung kein Passwort eingegeben werden muss. Geben Sie das Profil nicht weiter, da es einen vollständigen Zugriff auf Ihr Postfach ermöglicht.",
|
||||
"attribute": "Attribut",
|
||||
"change_password": "Passwort ändern",
|
||||
"change_password_hint_app_passwords": "Ihre Mailbox hat %d App-Passwörter, die nicht geändert werden. Um diese zu verwalten, gehen Sie bitte zum App-Passwörter-Tab.",
|
||||
"clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen",
|
||||
@ -1244,6 +1265,7 @@
|
||||
"tls_policy_warning": "<strong>Vorsicht:</strong> Entscheiden Sie sich unverschlüsselte Verbindungen abzulehnen, kann dies dazu führen, dass Kontakte Sie nicht mehr erreichen.<br>Nachrichten, die die Richtlinie nicht erfüllen, werden durch einen Hard-Fail im Mailsystem abgewiesen.<br>Diese Einstellung ist aktiv für die primäre Mailbox, für alle Alias-Adressen, die dieser Mailbox <b>direkt zugeordnet</b> sind (lediglich eine einzige Ziel-Adresse) und der Adressen, die sich aus Alias-Domains ergeben. Ausgeschlossen sind temporäre Aliasse (\"Spam-Alias-Adressen\"), Catch-All Alias-Adressen sowie Alias-Adressen mit mehreren Zielen.",
|
||||
"user_settings": "Benutzereinstellungen",
|
||||
"username": "Benutzername",
|
||||
"value": "Wert",
|
||||
"verify": "Verifizieren",
|
||||
"waiting": "Warte auf Ausführung",
|
||||
"week": "Woche",
|
||||
|
@ -154,6 +154,7 @@
|
||||
"logo_dark_label": "Inverted for dark mode",
|
||||
"configuration": "Configuration",
|
||||
"convert_html_to_text": "Convert HTML to plain text",
|
||||
"copy_to_clipboard": "Text copied to clipboard!",
|
||||
"cors_settings": "CORS Settings",
|
||||
"credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.",
|
||||
"customer_id": "Customer ID",
|
||||
@ -187,6 +188,8 @@
|
||||
"f2b_blacklist": "Blacklisted networks/hosts",
|
||||
"f2b_filter": "Regex filters",
|
||||
"f2b_list_info": "A blacklisted host or network will always outweigh a whitelist entity. <b>List updates will take a few seconds to be applied.</b>",
|
||||
"f2b_manage_external": "Manage Fail2Ban externally",
|
||||
"f2b_manage_external_info": "Fail2ban will still maintain the banlist, but it will not actively set rules to block traffic. Use the generated banlist below to externally block the traffic.",
|
||||
"f2b_max_attempts": "Max. attempts",
|
||||
"f2b_max_ban_time": "Max. ban time (s)",
|
||||
"f2b_netban_ipv4": "IPv4 subnet size to apply ban on (8-32)",
|
||||
@ -391,7 +394,9 @@
|
||||
"goto_invalid": "Goto address %s is invalid",
|
||||
"ham_learn_error": "Ham learn error: %s",
|
||||
"imagick_exception": "Error: Imagick exception while reading image",
|
||||
"img_dimensions_exceeded": "Image exceeds the maximum image size",
|
||||
"img_invalid": "Cannot validate image file",
|
||||
"img_size_exceeded": "Image exceeds the maximum file size",
|
||||
"img_tmp_missing": "Cannot validate image file: Temporary file not found",
|
||||
"invalid_bcc_map_type": "Invalid BCC map type",
|
||||
"invalid_destination": "Destination format \"%s\" is invalid",
|
||||
@ -576,6 +581,7 @@
|
||||
"client_secret": "Client secret",
|
||||
"comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview",
|
||||
"created_on": "Created on",
|
||||
"custom_attributes": "Custom attributes",
|
||||
"delete1": "Delete from source when completed",
|
||||
"delete2": "Delete messages on destination that are not on source",
|
||||
"delete2duplicates": "Delete duplicates on destination",
|
||||
@ -592,9 +598,11 @@
|
||||
"from_user": "{= from_user =} - From user part of envelope, e.g for \"moo@mailcow.tld\" it returns \"moo\"",
|
||||
"from_name": "{= from_name =} - From name of envelope, e.g for \"Mailcow <moo@mailcow.tld>\" it returns \"Mailcow\"",
|
||||
"from_addr": "{= from_addr =} - From address part of envelope",
|
||||
"from_domain": "{= from_domain =} - From domain part of envelope"
|
||||
"from_domain": "{= from_domain =} - From domain part of envelope",
|
||||
"custom": "{= foo =} - If mailbox has the custom attribute \"foo\" with value \"bar\" it returns \"bar\""
|
||||
},
|
||||
"domain_footer_plain": "PLAIN footer",
|
||||
"domain_footer_skip_replies": "Ignore footer on reply e-mails",
|
||||
"domain_quota": "Domain quota",
|
||||
"domains": "Domains",
|
||||
"dont_check_sender_acl": "Disable sender check for domain %s (+ alias domains)",
|
||||
@ -623,6 +631,7 @@
|
||||
"max_quota": "Max. quota per mailbox (MiB)",
|
||||
"maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
|
||||
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
|
||||
"mbox_exclude": "Exclude mailboxes",
|
||||
"mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.",
|
||||
"mins_interval": "Interval (min)",
|
||||
"multiple_bookings": "Multiple bookings",
|
||||
@ -1043,6 +1052,7 @@
|
||||
"domain_removed": "Domain %s has been removed",
|
||||
"dovecot_restart_success": "Dovecot was restarted successfully",
|
||||
"eas_reset": "ActiveSync devices for user %s were reset",
|
||||
"f2b_banlist_refreshed": "Banlist ID has been successfully refreshed.",
|
||||
"f2b_modified": "Changes to Fail2ban parameters have been saved",
|
||||
"forwarding_host_added": "Forwarding host %s has been added",
|
||||
"forwarding_host_removed": "Forwarding host %s has been removed",
|
||||
@ -1092,6 +1102,7 @@
|
||||
"verified_yotp_login": "Verified Yubico OTP login"
|
||||
},
|
||||
"tfa": {
|
||||
"authenticators": "Authenticators",
|
||||
"api_register": "%s uses the Yubico Cloud API. Please get an API key for your key <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">here</a>",
|
||||
"confirm": "Confirm",
|
||||
"confirm_totp_token": "Please confirm your changes by entering the generated token",
|
||||
@ -1141,6 +1152,7 @@
|
||||
"apple_connection_profile_complete": "This connection profile includes IMAP and SMTP parameters as well as CalDAV (calendars) and CardDAV (contacts) paths for an Apple device.",
|
||||
"apple_connection_profile_mailonly": "This connection profile includes IMAP and SMTP configuration parameters for an Apple device.",
|
||||
"apple_connection_profile_with_app_password": "A new app password is generated and added to the profile so that no password needs to be entered when setting up your device. Please do not share the file as it grants full access to your mailbox.",
|
||||
"attribute": "Attribute",
|
||||
"change_password": "Change password",
|
||||
"change_password_hint_app_passwords": "Your account has %d app passwords that will not be changed. To manage these, go to the App passwords tab.",
|
||||
"clear_recent_successful_connections": "Clear seen successful connections",
|
||||
@ -1271,6 +1283,7 @@
|
||||
"tls_policy_warning": "<strong>Warning:</strong> If you decide to enforce encrypted mail transfer, you may lose emails.<br>Messages to not satisfy the policy will be bounced with a hard fail by the mail system.<br>This option applies to your primary email address (login name), all addresses derived from alias domains as well as alias addresses <b>with only this single mailbox</b> as target.",
|
||||
"user_settings": "User settings",
|
||||
"username": "Username",
|
||||
"value": "Value",
|
||||
"verify": "Verify",
|
||||
"waiting": "Waiting",
|
||||
"week": "week",
|
||||
|
@ -891,5 +891,17 @@
|
||||
"no_active_admin": "Viimeistä aktiivista järjestelmänvalvojaa ei voi poistaa käytöstä",
|
||||
"session_token": "Lomakkeen tunnus sanoma ei kelpaa: tunnus sanoman risti riita",
|
||||
"session_ua": "Lomakkeen tunnus sanoma ei kelpaa: käyttäjä agentin tarkistus virhe"
|
||||
},
|
||||
"datatables": {
|
||||
"emptyTable": "Tietoja ei ole saatavilla taulukossa",
|
||||
"expand_all": "Laajenna kaikki",
|
||||
"lengthMenu": "Näytä menu merkinnät",
|
||||
"loadingRecords": "Ladataan...",
|
||||
"processing": "Ole hyvä ja odota...",
|
||||
"search": "Etsi:",
|
||||
"paginate": {
|
||||
"first": "Ensimmäinen",
|
||||
"last": "Edellinen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"acl": {
|
||||
"alias_domains": "Adicionar domínios de alias",
|
||||
"alias_domains": "Adicionar domínios alternativos",
|
||||
"app_passwds": "Gerenciar senhas de aplicativos",
|
||||
"bcc_maps": "Mapas BCC",
|
||||
"delimiter_action": "Ação delimitadora",
|
||||
@ -9,8 +9,8 @@
|
||||
"eas_reset": "Redefinir dispositivos EAS",
|
||||
"extend_sender_acl": "Permitir estender a ACL do remetente por endereços externos",
|
||||
"filters": "Filtros",
|
||||
"login_as": "Faça login como usuário da caixa de correio",
|
||||
"mailbox_relayhost": "Alterar relayhost para uma caixa de correio",
|
||||
"login_as": "Faça login como usuário da mailbox",
|
||||
"mailbox_relayhost": "Alterar relayhost para uma mailbox",
|
||||
"prohibited": "Proibido pela ACL",
|
||||
"protocol_access": "Alterar o acesso ao protocolo",
|
||||
"pushover": "Pushover",
|
||||
@ -28,7 +28,7 @@
|
||||
"spam_score": "Pontuação de spam",
|
||||
"syncjobs": "Trabalhos de sincronização",
|
||||
"tls_policy": "Política de TLS",
|
||||
"unlimited_quota": "Cota ilimitada para caixas de correio"
|
||||
"unlimited_quota": "Cota ilimitada para mailbox"
|
||||
},
|
||||
"add": {
|
||||
"activate_filter_warn": "Todos os outros filtros serão desativados quando a opção ativa estiver marcada.",
|
||||
@ -37,7 +37,7 @@
|
||||
"add_domain_only": "Adicionar somente domínio",
|
||||
"add_domain_restart": "Adicionar domínio e reiniciar o SoGo",
|
||||
"alias_address": "Endereço (s) de alias",
|
||||
"alias_address_info": "<small>Endereço de e-mail completo/es ou @example .com, para capturar todas as mensagens de um domínio (separadas por vírgula). somente <b>domínios mailcow</b></small>.",
|
||||
"alias_address_info": "<small>Endereço/s de e-mail completo ou @example .com, para capturar todas as mensagens de um domínio (separadas por vírgula). <b> somente domínios mailcow</b>.</small>",
|
||||
"alias_domain": "Domínio de alias",
|
||||
"alias_domain_info": "<small>Somente nomes de domínio válidos (separados por vírgula).</small>",
|
||||
"app_name": "Nome do aplicativo",
|
||||
@ -70,11 +70,11 @@
|
||||
"hostname": "Anfitrião",
|
||||
"inactive": "Inativo",
|
||||
"kind": "Gentil",
|
||||
"mailbox_quota_def": "Cota de caixa de correio padrão",
|
||||
"mailbox_quota_m": "Cota máxima por caixa de correio (MiB)",
|
||||
"mailbox_quota_def": "Cota de caixa de mailbox",
|
||||
"mailbox_quota_m": "Cota máxima por mailbox (MiB)",
|
||||
"mailbox_username": "Nome de usuário (parte esquerda de um endereço de e-mail)",
|
||||
"max_aliases": "Máximo de aliases possíveis",
|
||||
"max_mailboxes": "Número máximo de caixas de correio possíveis",
|
||||
"max_mailboxes": "Número máximo de mailboxes possíveis",
|
||||
"mins_interval": "Intervalo de votação (minutos)",
|
||||
"multiple_bookings": "Várias reservas",
|
||||
"nexthop": "Próximo salto",
|
||||
@ -86,10 +86,10 @@
|
||||
"public_comment": "Comentário público",
|
||||
"quota_mb": "Cota (MiB)",
|
||||
"relay_all": "Retransmita todos os destinatários",
|
||||
"relay_all_info": "↪ Se você optar por <b>não</b> retransmitir todos os destinatários, precisará adicionar uma caixa de correio (“cega”) para cada destinatário que deve ser retransmitido.",
|
||||
"relay_all_info": "↪ Se você optar por <b>não</b> retransmitir todos os destinatários, precisará adicionar uma mailbox (“cega”) para cada destinatário que deve ser retransmitido.",
|
||||
"relay_domain": "Retransmitir este domínio",
|
||||
"relay_transport_info": "<div class=\"badge fs-6 bg-info\">Informações</div> Você pode definir mapas de transporte para um destino personalizado para esse domínio. Se não for definido, uma pesquisa MX será feita.",
|
||||
"relay_unknown_only": "Retransmita somente caixas de correio não existentes. As caixas de correio existentes serão entregues localmente.",
|
||||
"relay_unknown_only": "Retransmita somente mailboxes não existentes. As mailboxes existentes serão entregues localmente.",
|
||||
"relayhost_wrapped_tls_info": "Por favor, <b>não</b> use portas com cobertura TLS (usadas principalmente na porta 465). <br>\r\nUse qualquer porta não encapsulada e emita STARTTLS. Uma política de TLS para impor o TLS pode ser criada em “mapas de políticas de TLS”.",
|
||||
"select": "Selecione...",
|
||||
"select_domain": "Selecione primeiro um domínio",
|
||||
@ -107,7 +107,8 @@
|
||||
"timeout2": "Tempo limite para conexão com o host local",
|
||||
"username": "Nome de usuário",
|
||||
"validate": "Validar",
|
||||
"validation_success": "Validado com sucesso"
|
||||
"validation_success": "Validado com sucesso",
|
||||
"dry": "Simular sincronização"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Acesso",
|
||||
@ -211,14 +212,14 @@
|
||||
"in_use_by": "Em uso por",
|
||||
"inactive": "Inativo",
|
||||
"include_exclude": "Incluir/Excluir",
|
||||
"include_exclude_info": "Por padrão - sem seleção - <b>todas as caixas de correio são endereçadas</b>",
|
||||
"include_exclude_info": "Por padrão - sem seleção - <b>todas as mailboxes são endereçadas</b>",
|
||||
"includes": "Inclua esses destinatários",
|
||||
"ip_check": "Verificação de IP",
|
||||
"ip_check_disabled": "A verificação de IP está desativada. Você pode ativá-lo em <br><strong>Sistema > Configuração > Opções > Personalizar</strong>",
|
||||
"ip_check_opt_in": "<strong>Opte por usar o serviço de terceiros <strong>ipv4.mailcow.email e ipv6.mailcow.email</strong> para resolver endereços IP externos.</strong>",
|
||||
"ip_check_opt_in": "Opte por usar o serviço de terceiros <strong>ipv4.mailcow.email.</strong> e <strong>ipv6.mailcow.email</strong> para resolver endereços IP externos.",
|
||||
"is_mx_based": "Baseado em MX",
|
||||
"last_applied": "Aplicado pela última vez",
|
||||
"license_info": "Uma licença não é necessária, mas ajuda no desenvolvimento futuro. <br><a href=\"https://www.servercow.de/mailcow?lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Registre seu GUID aqui</a> ou <a href=\"https://www.servercow.de/mailcow?lang=en#support\" target=\"_blank\" alt=\"Support order\">compre suporte para a instalação do mailcow</a>.",
|
||||
"license_info": "Uma licença não é necessária, mas ajuda no desenvolvimento.<br><a href=\"https://www.servercow.de/mailcow? Lang=en#sal\" target=\"_blank\" alt=\"SAL order\">Registre seu GUID aqui</a> ou <a href=\"https://www.servercow.de/mailcow? Lang=en#support\" target=\"_blank\" alt=\"Support order\">comprar suporte para sua instalação de mailcow.</a>",
|
||||
"link": "Link",
|
||||
"loading": "Por favor, espere...",
|
||||
"login_time": "Hora do login",
|
||||
@ -237,7 +238,7 @@
|
||||
"oauth2_add_client": "Adicionar cliente OAuth2",
|
||||
"oauth2_client_id": "ID do cliente",
|
||||
"oauth2_client_secret": "Segredo do cliente",
|
||||
"oauth2_info": "A implementação do OAuth2 suporta o tipo de concessão “Código de Autorização” e emite tokens de atualização. <br>\r\nO servidor também emite automaticamente novos tokens de atualização, após o uso de um token de atualização. <br><br>\r\n• O escopo padrão é <i>perfil</i>. Somente usuários de caixas de correio podem ser autenticados no OAuth2. <i>Se o parâmetro do escopo for omitido, ele retornará ao perfil.</i> <br>\r\n• O parâmetro <i>state</i> deve ser enviado pelo cliente como parte da solicitação de autorização. <br><br>\r\nCaminhos para solicitações para a API OAuth2: <br>\r\n<ul>\r\n <li><code>Ponto final de autorização: /oauth/authorize</code></li>\r\n <li><code>Ponto final do token: /oauth/token</code></li>\r\n <li>Página de recursos: <code>/oauth/profile</code></li></ul>\r\nA regeneração do segredo do cliente não expirará os códigos de autorização existentes, mas eles falharão na renovação do token. <br><br>\r\nA revogação dos tokens do cliente causará o encerramento imediato de todas as sessões ativas. Todos os clientes precisam se autenticar novamente.",
|
||||
"oauth2_info": "A implementação OAuth2 suporta o tipo de concessão \"Código de Autorização\" e emite tokens de atualização.<br>\nO servidor também emite automaticamente novos tokens de atualização, depois que um token de atualização foi usado.<br><br>\n• O escopo padrão é <i>perfil</i>. Somente usuários com caixa de e-mail podem ser autenticados contra o OAuth2. Se o parâmetro de escopo for omitido, ele voltará para <i>perfil</i>.<br>\nCaminhos para solicitações OAuth2 API: <br>\n<ul>\n<li>Endpoint de autorização: <code>/oauth/authorize</code></li>\n<li>Endpoint token: <code>/oauth/token</code></li>\n<li>Página de recursos: <code>/oauth/profile</code></li>\n</ul>\nRegenerar o segredo do cliente não expirará os códigos de autorização existentes, mas eles não renovarão seu token.<br><br>\nA revogação dos tokens do cliente causará o término imediato de todas as sessões ativas. Todos os clientes precisam se autenticar novamente.",
|
||||
"oauth2_redirect_uri": "URI de redirecionamento",
|
||||
"oauth2_renew_secret": "Gere um novo segredo de cliente",
|
||||
"oauth2_revoke_tokens": "Revogar todos os tokens do cliente",
|
||||
@ -255,25 +256,25 @@
|
||||
"priority": "Prioridade",
|
||||
"private_key": "Chave privada",
|
||||
"quarantine": "Quarentena",
|
||||
"quarantine_bcc": "Envie uma cópia de todas as notificações (BCC) para esse destinatário: <br><small>deixe em branco para desativar. <b>Correio não assinado e não verificado. Deve ser entregue somente internamente</b></small>.",
|
||||
"quarantine_bcc": "Envie uma cópia de todas as notificações (BCC) para este destinatário:<br><small>Deixe em branco para desativar. <b>E-mail não assinado e não verificado. Deve ser entregue apenas internamente.</b></small>",
|
||||
"quarantine_exclude_domains": "Excluir domínios e domínios de alias",
|
||||
"quarantine_max_age": "Idade máxima em dias <br><small>O valor deve ser igual ou superior a 1 dia.</small>",
|
||||
"quarantine_max_score": "Descarte a notificação se a pontuação de spam de um e-mail for maior que esse valor: O <br><small>padrão</small> é 9999,0",
|
||||
"quarantine_max_size": "Tamanho máximo em MiB (elementos maiores são descartados): <br><small>0 <b>não</b> indica ilimitado</small>.",
|
||||
"quarantine_max_size": "Tamanho máximo em MiB (elementos maiores são descartados): <br><small>0 <b>não</b> indica ilimitado.</small>",
|
||||
"quarantine_notification_html": "Modelo de e-mail de notificação: <br><small>deixe em branco para restaurar o modelo padrão.</small>",
|
||||
"quarantine_notification_sender": "Remetente do e-mail de notificação",
|
||||
"quarantine_notification_subject": "Assunto do e-mail de notificação",
|
||||
"quarantine_redirect": "<b>Redirecione todas as notificações</b> para esse destinatário: <br><small>deixe em branco para desativar. <b>Correio não assinado e não verificado. Deve ser entregue somente internamente</b></small>.",
|
||||
"quarantine_redirect": "<b>Redirecione todas as notificações</b> para esse destinatário: <br><small>deixe em branco para desativar. <b>E-mail não assinado e não verificado. Deve ser entregue somente internamente.</b></small>",
|
||||
"quarantine_release_format": "Formato dos itens lançados",
|
||||
"quarantine_release_format_att": "Como anexo",
|
||||
"quarantine_release_format_raw": "Original não modificado",
|
||||
"quarantine_retention_size": "<b>Retenções por caixa de correio: <small>0</small> indica inativo.</b> <br>",
|
||||
"quarantine_retention_size": "Retenções por mailbox: <br><small>0 indica <b>inativo</b>.</small>",
|
||||
"quota_notification_html": "Modelo de e-mail de notificação: <br><small>deixe em branco para restaurar o modelo padrão.</small>",
|
||||
"quota_notification_sender": "Remetente do e-mail de notificação",
|
||||
"quota_notification_subject": "Assunto do e-mail de notificação",
|
||||
"quota_notifications": "Notificações de cotas",
|
||||
"quota_notifications_info": "As notificações de cota são enviadas aos usuários uma vez ao ultrapassar 80% e uma vez ao ultrapassar 95% de uso.",
|
||||
"quota_notifications_vars": "{{percent}} é igual à cota atual do usuário <br>{{username}} é o nome da caixa de correio",
|
||||
"quota_notifications_vars": "{{percent}} é igual à cota atual do usuário <br>{{username}} é o nome da mailbox",
|
||||
"queue_unban": "não banido",
|
||||
"r_active": "Restrições ativas",
|
||||
"r_inactive": "Restrições inativas",
|
||||
@ -301,7 +302,7 @@
|
||||
"rsettings_insert_preset": "Inserir exemplo de predefinição “%s”",
|
||||
"rsettings_preset_1": "Desative tudo, exceto o DKIM e o limite de taxa para usuários autenticados",
|
||||
"rsettings_preset_2": "Postmasters querem spam",
|
||||
"rsettings_preset_3": "Permitir somente remetentes específicos para uma caixa de correio (ou seja, uso somente como caixa de correio interna)",
|
||||
"rsettings_preset_3": "Permitir somente remetentes específicos para uma mailbox (ou seja, uso somente como mailbox interna)",
|
||||
"rsettings_preset_4": "Desativar Rspamd para um domínio",
|
||||
"rspamd_com_settings": "Um nome de configuração será gerado automaticamente, veja os exemplos de predefinições abaixo. Para obter mais detalhes, consulte a documentação <a href=\"https://rspamd.com/doc/configuration/settings.html#settings-structure\" target=\"_blank\">do Rspamd</a>",
|
||||
"rspamd_global_filters": "Mapas de filtro globais",
|
||||
@ -347,7 +348,10 @@
|
||||
"username": "Nome de usuário",
|
||||
"validate_license_now": "Valide o GUID em relação ao servidor de licenças",
|
||||
"verify": "Verificar",
|
||||
"yes": "✓"
|
||||
"yes": "✓",
|
||||
"copy_to_clipboard": "Texto copiado para a área de transferência!",
|
||||
"f2b_manage_external": "Gerenciar Fail2Ban externamente",
|
||||
"f2b_manage_external_info": "O Fail2ban ainda manterá a lista de banimentos, mas não definirá ativamente regras para bloquear o tráfego. Use a lista de banimento gerada abaixo para bloquear externamente o tráfego."
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Acesso negado ou dados de formulário inválidos",
|
||||
@ -365,7 +369,7 @@
|
||||
"comment_too_long": "Comentário muito longo, máximo de 160 caracteres permitidos",
|
||||
"cors_invalid_method": "Método de permissão inválido especificado",
|
||||
"cors_invalid_origin": "Origem de permissão inválida especificada",
|
||||
"defquota_empty": "A cota padrão por caixa de correio não deve ser 0.",
|
||||
"defquota_empty": "A cota padrão por mailbox não deve ser 0.",
|
||||
"demo_mode_enabled": "O modo de demonstração está ativado",
|
||||
"description_invalid": "A descrição do recurso para %s é inválida",
|
||||
"dkim_domain_or_sel_exists": "Existe uma chave DKIM para “%s” e não será substituída",
|
||||
@ -403,22 +407,22 @@
|
||||
"invalid_recipient_map_old": "Destinatário original inválido especificado: %s",
|
||||
"ip_list_empty": "A lista de IPs permitidos não pode estar vazia",
|
||||
"is_alias": "%s já é conhecido como endereço de alias",
|
||||
"is_alias_or_mailbox": "%s já é conhecido como alias, caixa de correio ou endereço de alias expandido a partir de um domínio de alias.",
|
||||
"is_alias_or_mailbox": "%s já é conhecido como alias, mailbox ou alias de endereço expandido a partir de um domínio de alias.",
|
||||
"is_spam_alias": "%s já é conhecido como endereço de alias temporário (endereço de alias de spam)",
|
||||
"last_key": "A última chave não pode ser excluída. Em vez disso, desative o TFA.",
|
||||
"login_failed": "Falha no login",
|
||||
"mailbox_defquota_exceeds_mailbox_maxquota": "A cota padrão excede o limite máximo da cota",
|
||||
"mailbox_invalid": "O nome da caixa de correio é inválido",
|
||||
"mailbox_invalid": "O nome da mailbox é inválido",
|
||||
"mailbox_quota_exceeded": "A cota excede o limite do domínio (máx. %d MiB)",
|
||||
"mailbox_quota_exceeds_domain_quota": "A cota máxima excede o limite da cota do domínio",
|
||||
"mailbox_quota_left_exceeded": "Não há espaço restante (espaço restante: %d MiB)",
|
||||
"mailboxes_in_use": "O máximo de caixas de correio deve ser maior ou igual a %d",
|
||||
"mailboxes_in_use": "O máximo de mailboxes deve ser maior ou igual a %d",
|
||||
"malformed_username": "Nome de usuário malformado",
|
||||
"map_content_empty": "O conteúdo do mapa não pode estar vazio",
|
||||
"max_alias_exceeded": "Número máximo de aliases excedido",
|
||||
"max_mailbox_exceeded": "Número máximo de caixas de correio excedido (%d de %d)",
|
||||
"max_quota_in_use": "A cota da caixa de correio deve ser maior ou igual a %d MiB",
|
||||
"maxquota_empty": "A cota máxima por caixa de correio não deve ser 0.",
|
||||
"max_mailbox_exceeded": "Número máximo de mailboxes excedido (%d de %d)",
|
||||
"max_quota_in_use": "A cota da mailbox deve ser maior ou igual a %d MiB",
|
||||
"maxquota_empty": "A cota máxima por mailbox não deve ser 0.",
|
||||
"mysql_error": "Erro do MySQL: %s",
|
||||
"network_host_invalid": "Rede ou host inválidos: %s",
|
||||
"next_hop_interferes": "%s interfere com o nexthop %s",
|
||||
@ -488,27 +492,27 @@
|
||||
"infoFiltered": "(filtrado do total de entradas _MAX_)",
|
||||
"infoPostFix": "",
|
||||
"thousands": ",",
|
||||
"lengthMenu": "Show _MENU_ entries",
|
||||
"loadingRecords": "Loading...",
|
||||
"processing": "Please wait...",
|
||||
"search": "Search:",
|
||||
"zeroRecords": "No matching records found",
|
||||
"lengthMenu": "Mostrar _ MENU_ entradas",
|
||||
"loadingRecords": "Carregando...",
|
||||
"processing": "Por favor, aguarde...",
|
||||
"search": "Pesquisa:",
|
||||
"zeroRecords": "Nenhum registro correspondente encontrado",
|
||||
"paginate": {
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"first": "Primeiro",
|
||||
"last": "Última",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
"previous": "Anterior"
|
||||
},
|
||||
"aria": {
|
||||
"sortAscending": ": activate to sort column ascending",
|
||||
"sortDescending": ": activate to sort column descending"
|
||||
"sortAscending": ": Ative para classificar a coluna ascendente",
|
||||
"sortDescending": ": Ative para classificar a coluna decrescente"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"architecture": "Arquitetura",
|
||||
"chart_this_server": "Gráfico (este servidor)",
|
||||
"containers_info": "Informações do contêiner",
|
||||
"container_running": "Correndo",
|
||||
"container_running": "Executando",
|
||||
"container_disabled": "Contêiner parado ou desativado",
|
||||
"container_stopped": "Parado",
|
||||
"cores": "Núcleos",
|
||||
@ -571,7 +575,7 @@
|
||||
"automap": "Tente mapear pastas automaticamente (“Itens enviados”, “Enviados” => “Enviados” etc.)",
|
||||
"backup_mx_options": "Opções de relé",
|
||||
"bcc_dest_format": "O destino do BCC deve ser um único endereço de e-mail válido. <br>Se precisar enviar uma cópia para vários endereços, crie um alias e use-o aqui.",
|
||||
"client_id": "ID do cliente",
|
||||
"client_id": "ID Cliente",
|
||||
"client_secret": "Segredo do cliente",
|
||||
"comment_info": "Um comentário privado não é visível para o usuário, enquanto um comentário público é mostrado como dica de ferramenta ao passar o mouse sobre ele na visão geral do usuário",
|
||||
"created_on": "Criado em",
|
||||
@ -591,7 +595,8 @@
|
||||
"from_user": "{= from_user =} - Da parte do envelope do usuário, por exemplo, para \"moo@mailcow.tld\", ele retorna “moo”",
|
||||
"from_name": "{= from_name =} - Do nome do envelope, por exemplo, para “Mailcow < moo@mailcow.tld >”, ele retorna “Mailcow”",
|
||||
"from_addr": "{= from_addr =} - Do endereço, parte do envelope",
|
||||
"from_domain": "{= from_domain =} - Da parte do domínio do envelope"
|
||||
"from_domain": "{= from_domain =} - Da parte do domínio do envelope",
|
||||
"custom": "{= foo =} - Se o mailbox tiver o atributo personalizado \"foo\" com valor \"bar\", retornará \"bar\""
|
||||
},
|
||||
"domain_footer_plain": "Rodapé simples",
|
||||
"domain_quota": "Cota de domínio",
|
||||
@ -614,11 +619,11 @@
|
||||
"kind": "Gentil",
|
||||
"last_modified": "Última modificação",
|
||||
"lookup_mx": "Destination é uma expressão regular que corresponde ao nome MX (<code>.*\\ .google\\ .com</code> para rotear todos os e-mails direcionados a um MX que termina em google.com nesse salto)",
|
||||
"mailbox": "Editar caixa de correio",
|
||||
"mailbox_quota_def": "Cota de caixa de correio padrão",
|
||||
"mailbox": "Editar mailbox",
|
||||
"mailbox_quota_def": "Cota mailbox padrão",
|
||||
"mailbox_relayhost_info": "Aplicado somente à caixa de correio e aos aliases diretos, substitui um host de retransmissão de domínio.",
|
||||
"max_aliases": "Máximo de aliases",
|
||||
"max_mailboxes": "Número máximo de caixas de correio possíveis",
|
||||
"max_mailboxes": "Número máximo de mailboxes possíveis",
|
||||
"max_quota": "Cota máxima por caixa de correio (MiB)",
|
||||
"maxage": "Duração máxima das mensagens em dias que serão pesquisadas remotamente <br><small>(0 = ignorar a idade</small>)",
|
||||
"maxbytespersecond": "Máximo de bytes por segundo <br><small>(0 = ilimitado</small>)",
|
||||
@ -652,7 +657,7 @@
|
||||
"relay_all_info": "↪ Se você optar por <b>não</b> retransmitir todos os destinatários, precisará adicionar uma caixa de correio (“cega”) para cada destinatário que deve ser retransmitido.",
|
||||
"relay_domain": "Retransmitir este domínio",
|
||||
"relay_transport_info": "<div class=\"badge fs-6 bg-info\">Informações</div> Você pode definir mapas de transporte para um destino personalizado para esse domínio. Se não for definido, uma pesquisa MX será feita.",
|
||||
"relay_unknown_only": "Retransmita somente caixas de correio não existentes. As caixas de correio existentes serão entregues localmente.",
|
||||
"relay_unknown_only": "Retransmita somente mailboxes não existentes. As caixas de mailboxes serão entregues localmente.",
|
||||
"relayhost": "Transportes dependentes do remetente",
|
||||
"remove": "Remover",
|
||||
"resource": "Recurso",
|
||||
@ -681,7 +686,9 @@
|
||||
"title": "Editar objeto",
|
||||
"unchanged_if_empty": "Se inalterado, deixe em branco",
|
||||
"username": "Nome de usuário",
|
||||
"validate_save": "Valide e salve"
|
||||
"validate_save": "Valide e salve",
|
||||
"custom_attributes": "Atributos personalizados",
|
||||
"mbox_exclude": "Excluir mailboxes"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "Confirme",
|
||||
@ -824,13 +831,13 @@
|
||||
"last_run_reset": "Programe a seguir",
|
||||
"mailbox": "Caixa de correio",
|
||||
"mailbox_defaults": "Configurações padrão",
|
||||
"mailbox_defaults_info": "Defina as configurações padrão para novas caixas de correio.",
|
||||
"mailbox_defaults_info": "Defina as configurações padrão para novas mailboxes.",
|
||||
"mailbox_defquota": "Tamanho padrão da caixa de correio",
|
||||
"mailbox_templates": "Modelos de caixa de correio",
|
||||
"mailbox_quota": "Tamanho máximo de uma caixa de correio",
|
||||
"mailboxes": "Caixas de correio",
|
||||
"mailboxes": "mailboxes",
|
||||
"max_aliases": "Máximo de aliases",
|
||||
"max_mailboxes": "Número máximo de caixas de correio possíveis",
|
||||
"max_mailboxes": "Número máximo de mailboxes possíveis",
|
||||
"max_quota": "Cota máxima por caixa de correio",
|
||||
"mins_interval": "Intervalo (min)",
|
||||
"msg_num": "Mensagem #",
|
||||
@ -858,10 +865,10 @@
|
||||
"recipient_map_old_info": "O destino original do mapa de um destinatário deve ser um endereço de e-mail válido ou um nome de domínio.",
|
||||
"recipient_maps": "Mapas de destinatários",
|
||||
"relay_all": "Retransmita todos os destinatários",
|
||||
"relay_unknown": "Retransmitir caixas de correio desconhecidas",
|
||||
"relay_unknown": "Retransmitir mailboxes desconhecidas",
|
||||
"remove": "Remover",
|
||||
"resources": "Recursos",
|
||||
"running": "Correndo",
|
||||
"running": "Executando",
|
||||
"sender": "Remetente",
|
||||
"set_postfilter": "Marcar como postfilter",
|
||||
"set_prefilter": "Marcar como pré-filtro",
|
||||
@ -873,7 +880,7 @@
|
||||
"sieve_preset_5": "Resposta automática (férias)",
|
||||
"sieve_preset_6": "Rejeitar e-mail com resposta",
|
||||
"sieve_preset_7": "Redirecionar e manter/soltar",
|
||||
"sieve_preset_8": "Descartar mensagem enviada para um endereço de alias do qual o remetente faz parte",
|
||||
"sieve_preset_8": "Redirecionar e-mail de um remetente específico, marcar como lido e classificar em subpasta",
|
||||
"sieve_preset_header": "Veja os exemplos de predefinições abaixo. Para obter mais detalhes, consulte a <a href=\"https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)\" target=\"_blank\">Wikipedia</a>.",
|
||||
"sogo_visible": "O alias é visível no SoGo",
|
||||
"sogo_visible_n": "Ocultar alias no SoGo",
|
||||
@ -905,7 +912,7 @@
|
||||
"tls_map_parameters_info": "Vazio ou parâmetros, por exemplo: protocols=! Cifras SSLv2 = média, exclusão = 3DES",
|
||||
"tls_map_policy": "Política",
|
||||
"tls_policy_maps": "Mapas de políticas de TLS",
|
||||
"tls_policy_maps_enforced_tls": "Essas políticas também substituirão o comportamento dos usuários de caixas de correio que impõem conexões TLS de saída. <code>Se nenhuma política existir abaixo, esses usuários aplicarão os valores padrão especificados como <code>smtp_tls_mandatory_protocols e smtp_tls_mandatory_ciphers</code>.</code>",
|
||||
"tls_policy_maps_enforced_tls": "Essas políticas também substituirão o comportamento das caixas de e-mail dos usuários, que impõem conexões TLS de saída. Se não houver nenhuma política abaixo, esses usuários aplicarão os valores padrão especificados como <code>smtp_tls_mandatory_protocols</code> e <code>smtp_tls_mandatory_ciphers</code>.",
|
||||
"tls_policy_maps_info": "Esse mapa de políticas substitui as regras de transporte TLS de saída, independentemente das configurações de política de TLS do usuário. <br>\r\n Consulte <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">a documentação do “smtp_tls_policy_maps” para obter mais informações</a>.",
|
||||
"tls_policy_maps_long": "Substituições do mapa de políticas de TLS de saída",
|
||||
"toggle_all": "Alternar tudo",
|
||||
@ -1003,7 +1010,7 @@
|
||||
"help": "Mostrar/ocultar painel de ajuda",
|
||||
"imap_smtp_server_auth_info": "Use seu endereço de e-mail completo e o mecanismo de autenticação PLAIN. <br>\r\nSeus dados de login serão criptografados pela criptografia obrigatória do lado do servidor.",
|
||||
"mailcow_apps_detail": "Use um aplicativo mailcow para acessar seus e-mails, calendário, contatos e muito mais.",
|
||||
"mailcow_panel_detail": "<b>Os administradores de domínio</b> criam, modificam ou excluem caixas de correio e aliases, alteram domínios e leem mais informações sobre seus domínios atribuídos. <br>\r\n<b>Os usuários de caixas de correio</b> podem criar aliases com limite de tempo (aliases de spam), alterar suas configurações de senha e filtro de spam."
|
||||
"mailcow_panel_detail": "<b>Os administradores de domínio</b> criam, modificam ou excluem mailboxes e aliases, alteram domínios e leem mais informações sobre seus domínios atribuídos. <br>\n<b>Os usuários de caixas de correio</b> podem criar aliases com limite de tempo (aliases de spam), alterar suas configurações de senha e filtro de spam."
|
||||
},
|
||||
"success": {
|
||||
"acl_saved": "ACL para o objeto %s salvo",
|
||||
@ -1088,7 +1095,8 @@
|
||||
"verified_fido2_login": "Login FIDO2 verificado",
|
||||
"verified_totp_login": "Login TOTP verificado",
|
||||
"verified_webauthn_login": "Login verificado do WebAuthn",
|
||||
"verified_yotp_login": "Login OTP verificado do Yubico"
|
||||
"verified_yotp_login": "Login OTP verificado do Yubico",
|
||||
"f2b_banlist_refreshed": "O Banlist ID foi atualizado com sucesso."
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s usa a API Yubico Cloud. Obtenha uma chave de API para sua chave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aqui</a>",
|
||||
@ -1216,7 +1224,7 @@
|
||||
"quarantine_notification_info": "Depois que uma notificação for enviada, os itens serão marcados como “notificados” e nenhuma outra notificação será enviada para esse item específico.",
|
||||
"recent_successful_connections": "Conexões bem-sucedidas vistas",
|
||||
"remove": "Remover",
|
||||
"running": "Correndo",
|
||||
"running": "Executando",
|
||||
"save": "Salvar alterações",
|
||||
"save_changes": "Salvar alterações",
|
||||
"sender_acl_disabled": "<span class=\"badge fs-6 bg-danger\">A verificação do remetente está desativada</span>",
|
||||
@ -1277,7 +1285,9 @@
|
||||
"weeks": "semanas",
|
||||
"with_app_password": "com senha do aplicativo",
|
||||
"year": "ano",
|
||||
"years": "anos"
|
||||
"years": "anos",
|
||||
"attribute": "Atributo",
|
||||
"value": "Valor"
|
||||
},
|
||||
"warning": {
|
||||
"cannot_delete_self": "Não é possível excluir o usuário conectado",
|
||||
@ -1288,7 +1298,7 @@
|
||||
"ip_invalid": "IP inválido ignorado: %s",
|
||||
"is_not_primary_alias": "Alias não primário ignorado %s",
|
||||
"no_active_admin": "Não é possível desativar o último administrador ativo",
|
||||
"quota_exceeded_scope": "Cota de domínio excedida: somente caixas de correio ilimitadas podem ser criadas nesse escopo de domínio.",
|
||||
"quota_exceeded_scope": "Cota de domínio excedida: somente mailboxes ilimitadas podem ser criadas nesse escopo de domínio.",
|
||||
"session_token": "Token de formulário inválido: incompatibilidade de token",
|
||||
"session_ua": "Token de formulário inválido: erro de validação do agente de usuário"
|
||||
}
|
||||
|
@ -107,7 +107,8 @@
|
||||
"validate": "Проверить",
|
||||
"validation_success": "Проверка прошла успешно",
|
||||
"tags": "Теги",
|
||||
"app_passwd_protocols": "Разрешенные протоколы для пароля приложения"
|
||||
"app_passwd_protocols": "Разрешенные протоколы для пароля приложения",
|
||||
"dry": "Имитировать синхронизацию"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Настройки доступа",
|
||||
@ -344,7 +345,10 @@
|
||||
"allowed_methods": "Access-Control-Allow-Methods",
|
||||
"ip_check": "Проверить IP",
|
||||
"ip_check_disabled": "Проверка IP отключена. Вы можете включить его в разделе <br> <strong>Система > Конфигурация > Параметры > Настроить</strong>.",
|
||||
"ip_check_opt_in": "Согласие на использование сторонних служб <strong>ipv4.mailcow.email</strong> и <strong>ipv6.mailcow.email</strong> для разрешения внешних IP-адресов."
|
||||
"ip_check_opt_in": "Согласие на использование сторонних служб <strong>ipv4.mailcow.email</strong> и <strong>ipv6.mailcow.email</strong> для разрешения внешних IP-адресов.",
|
||||
"f2b_manage_external": "Внешнее управление Fail2Ban",
|
||||
"f2b_manage_external_info": "Fail2ban по-прежнему будет вести банлист, но не будет активно устанавливать правила для блокировки трафика. Используйте сгенерированный ниже банлист для внешнего блокирования трафика.",
|
||||
"copy_to_clipboard": "Текст скопирован в буфер обмена!"
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Доступ запрещён, или указаны неверные данные",
|
||||
@ -625,11 +629,14 @@
|
||||
"auth_user": "{= auth_user =} - Аутентифицированное имя пользователя, указанное MTA",
|
||||
"from_user": "{= from_user =} - Из пользовательской части envelope, например, для \"moo@mailcow.tld\" возвращается \"moo\"",
|
||||
"from_addr": "{= from_addr =} - Из адресной части envelope",
|
||||
"from_domain": "{= from_domain =} - из доменной части envelope"
|
||||
"from_domain": "{= from_domain =} - из доменной части envelope",
|
||||
"custom": "{= foo =} - Если почтовый ящик имеет пользовательский атрибут \"foo\" со значением \"bar\", он возвращает \"bar\"."
|
||||
},
|
||||
"domain_footer": "Нижний колонтитул домена",
|
||||
"domain_footer_html": "HTML нижний колонтитул",
|
||||
"domain_footer_plain": "ПРОСТОЙ нижний колонтитул"
|
||||
"domain_footer_plain": "ПРОСТОЙ нижний колонтитул",
|
||||
"mbox_exclude": "Исключить почтовые ящики",
|
||||
"custom_attributes": "Пользовательские атрибуты"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "Подтвердить",
|
||||
@ -1011,7 +1018,8 @@
|
||||
"verified_webauthn_login": "Авторизация WebAuthn пройдена",
|
||||
"verified_yotp_login": "Авторизация Yubico OTP пройдена",
|
||||
"cors_headers_edited": "Настройки CORS сохранены",
|
||||
"domain_footer_modified": "Изменения в нижнем колонтитуле домена %s сохранены"
|
||||
"domain_footer_modified": "Изменения в нижнем колонтитуле домена %s сохранены",
|
||||
"f2b_banlist_refreshed": "Идентификатор банлиста был успешно обновлен."
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s использует Yubico Cloud API. Пожалуйста, получите ключ API для вашего ключа <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">здесь</a>",
|
||||
@ -1198,7 +1206,9 @@
|
||||
"apple_connection_profile_with_app_password": "Новый пароль приложения генерируется и добавляется в профиль, поэтому при настройке устройства не требуется вводить пароль. Не предоставляйте доступ к файлу, поскольку он предоставляет полный доступ к вашему почтовому ящику.",
|
||||
"direct_protocol_access": "Этот пользователь почтового ящика имеет <b>прямой, внешний доступ</b> к следующим протоколам и приложениям. Эта настройка контролируется вашим администратором. Для предоставления доступа к отдельным протоколам и приложениям могут быть созданы пароли приложений.<br> Кнопка \"Вход в веб-почту\" обеспечивает единый вход в SOGo и всегда доступна.",
|
||||
"with_app_password": "с паролем приложения",
|
||||
"change_password_hint_app_passwords": "В вашей учетной записи есть {{number_of_app_passwords}} паролей приложений, которые не будут изменены. Чтобы управлять ими, перейдите на вкладку \"Пароли приложений\"."
|
||||
"change_password_hint_app_passwords": "В вашей учетной записи есть {{number_of_app_passwords}} паролей приложений, которые не будут изменены. Чтобы управлять ими, перейдите на вкладку \"Пароли приложений\".",
|
||||
"attribute": "Атрибут",
|
||||
"value": "Значение"
|
||||
},
|
||||
"warning": {
|
||||
"cannot_delete_self": "Вы не можете удалить сами себя",
|
||||
|
@ -107,7 +107,8 @@
|
||||
"username": "Používateľské meno",
|
||||
"validate": "Overiť",
|
||||
"validation_success": "Úspešne overené",
|
||||
"tags": "Štítky"
|
||||
"tags": "Štítky",
|
||||
"dry": "Simulovať synchronizáciu"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Prístup",
|
||||
@ -486,7 +487,9 @@
|
||||
},
|
||||
"emptyTable": "Nie sú k dispozícii žiadne dáta.",
|
||||
"decimal": ",",
|
||||
"thousands": " "
|
||||
"thousands": " ",
|
||||
"collapse_all": "Zbaliť všetko",
|
||||
"expand_all": "Rozbaliť všetko"
|
||||
},
|
||||
"debug": {
|
||||
"chart_this_server": "Graf (tento server)",
|
||||
@ -639,7 +642,18 @@
|
||||
"title": "Upraviť objekt",
|
||||
"unchanged_if_empty": "Ak nemeníte, nechajte prázdne",
|
||||
"username": "Používateľské meno",
|
||||
"validate_save": "Validovať a uložiť"
|
||||
"validate_save": "Validovať a uložiť",
|
||||
"domain_footer_info_vars": {
|
||||
"from_addr": "{= from_addr =} - E-mailová adresa odosielateľa",
|
||||
"from_domain": "{= from_domain =} - Doména odosielateľa",
|
||||
"auth_user": "{= auth_user =} - Prihlasovacie meno odosielateľa",
|
||||
"from_user": "{= from_user =} - Používateľská časť e-mailovej adresy odosielateľa, napr. pre \"moo@mailcow.tld\" vráti \"moo\"",
|
||||
"from_name": "{= from_name =} - Meno odosielateľa, napr. pre \"Mailcow <moo@mailcow.tld>\" vráti \"Mailcow\""
|
||||
},
|
||||
"domain_footer": "Pätička pre celú doménu",
|
||||
"domain_footer_html": "HTML text",
|
||||
"domain_footer_info": "Pätička pre celú doménu sa pridáva do všetkých odchádzajúcich e-mailov spojených s adresou v rámci tejto domény. <br> Pre pätičku je možné použiť nasledujúce premenné:",
|
||||
"domain_footer_plain": "Obyčajný text"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "Potvrdiť",
|
||||
@ -934,7 +948,19 @@
|
||||
"type": "Typ"
|
||||
},
|
||||
"queue": {
|
||||
"queue_manager": "Správca fronty"
|
||||
"queue_manager": "Správca fronty",
|
||||
"delete": "Vymazať všetko",
|
||||
"flush": "Vyprázdnit frontu",
|
||||
"info": "Poštová fronta obsahuje všetky e-maily, ktoré čakajú na doručenie. Ak e-mail uviazne v poštovej fronte na dlhší čas, systém ho automaticky vymaže.<br>Chybové hlásenie príslušného e-mailu poskytuje informácie o tom, prečo sa e-mail nepodarilo doručiť.",
|
||||
"legend": "Možnosti akcií nad poštovou frontou:",
|
||||
"ays": "Potvrďte, že chcete naozaj odstrániť všetky položky z aktuálnej fronty.",
|
||||
"deliver_mail": "Doručiť",
|
||||
"deliver_mail_legend": "Pokus o opätovné doručenie vybraných e-mailov.",
|
||||
"show_message": "Zobraziť správu",
|
||||
"unhold_mail": "Uvoľniť",
|
||||
"unhold_mail_legend": "Uvoľniť vybrané e-maily na doručenie. (Len v prípade predchádzajúceho podržania)",
|
||||
"hold_mail": "Podržať",
|
||||
"hold_mail_legend": "Podržať vybrané e-maily. (Zabráni ďalším pokusom o doručenie)"
|
||||
},
|
||||
"ratelimit": {
|
||||
"disabled": "Vypnuté",
|
||||
@ -1028,7 +1054,8 @@
|
||||
"verified_fido2_login": "Overené FIDO2 prihlásenie",
|
||||
"verified_totp_login": "Overené TOTP prihlásenie",
|
||||
"verified_webauthn_login": "Overené WebAuthn prihlásenie",
|
||||
"verified_yotp_login": "Overené Yubico OTP prihlásenie"
|
||||
"verified_yotp_login": "Overené Yubico OTP prihlásenie",
|
||||
"domain_footer_modified": "Zmeny v pätičke domény %s boli uložené"
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s využíva Yubico Cloud API. Prosím, zaobstarajte si API kľúč pre váš kľúč <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">tu</a>",
|
||||
|
@ -107,7 +107,8 @@
|
||||
"kind": "Вид",
|
||||
"delete1": "Видалити з джерела після завершення",
|
||||
"delete2duplicates": "Видалити дублікати на місці призначення",
|
||||
"domain_quota_m": "Загальна квота домену (МіБ)"
|
||||
"domain_quota_m": "Загальна квота домену (МіБ)",
|
||||
"dry": "Імітувати синхронізацію"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Налаштування доступу",
|
||||
@ -345,7 +346,10 @@
|
||||
"ip_check_disabled": "Перевірка IP вимкнена. Ви можете ввімкнути його в меню<br> <strong>Система > Конфігурація > Параметри > Налаштувати</strong>",
|
||||
"ip_check_opt_in": "Згода на використання сторонніх служб <strong>ipv4.mailcow.email</strong> і <strong>ipv6.mailcow.email</strong> для визначення зовнішніх IP-адрес.",
|
||||
"options": "Параметри",
|
||||
"queue_unban": "розблокувати"
|
||||
"queue_unban": "розблокувати",
|
||||
"f2b_manage_external": "Керування Fail2Ban ззовні",
|
||||
"f2b_manage_external_info": "Fail2ban буде підтримувати список заборонених, але не буде активно встановлювати правила для блокування трафіку. Використовуйте згенерований список заборон нижче для зовнішнього блокування трафіку.",
|
||||
"copy_to_clipboard": "Текст скопійовано в буфер обміну!"
|
||||
},
|
||||
"danger": {
|
||||
"alias_domain_invalid": "Неприпустимий псевдонім домену: %s",
|
||||
@ -650,10 +654,13 @@
|
||||
"auth_user": "{= auth_user =} - Аутентифіковане ім'я користувача, вказане MTA",
|
||||
"from_user": "{= from_user =} - З користувацької частини envelope, наприклад, для \"moo@mailcow.tld\" повертає \"moo\"",
|
||||
"from_addr": "{= from_addr =} - З адресної частини envelope",
|
||||
"from_domain": "{= from_domain =} - З доменної частини envelope"
|
||||
"from_domain": "{= from_domain =} - З доменної частини envelope",
|
||||
"custom": "{= foo =} - Якщо поштова скринька має кастомний атрибут \"foo\" зі значенням \"bar\", то повертається \"bar\""
|
||||
},
|
||||
"domain_footer_html": "Нижній колонтитул HTML",
|
||||
"domain_footer_plain": "ЗВИЧАЙНИЙ нижній колонтитул"
|
||||
"domain_footer_plain": "ЗВИЧАЙНИЙ нижній колонтитул",
|
||||
"custom_attributes": "Користувацькі атрибути",
|
||||
"mbox_exclude": "Виключити поштові скриньки"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "Підтвердити",
|
||||
@ -1059,7 +1066,8 @@
|
||||
"template_modified": "Зміни до шаблону %s збережено",
|
||||
"cors_headers_edited": "Налаштування CORS збережено",
|
||||
"ip_check_opt_in_modified": "Перевірка IP-адреси успішно збережено",
|
||||
"template_removed": "Шаблону із ID %s видалено"
|
||||
"template_removed": "Шаблону із ID %s видалено",
|
||||
"f2b_banlist_refreshed": "Ідентифікатор списку заборонених успішно оновлено."
|
||||
},
|
||||
"tfa": {
|
||||
"confirm": "Підтвердьте",
|
||||
@ -1248,7 +1256,9 @@
|
||||
"tls_policy_warning": "<strong>Попередження:</strong> якщо ви увімкнете примусове шифрування пошти, ви можете зіткнутися з втратою листів.<br>Повідомлення, які не відповідають політиці, будуть відкидатися з повідомленням поштовим сервером про серйозний збій.<br>Цей параметр застосовується до вашої основної адреси електронної пошти (логіну), усім особистим псевдонімам та псевдонімам доменів. Маються на увазі лише псевдоніми <b>з однією поштовою скринькою</b>, як одержувач.",
|
||||
"year": "рік",
|
||||
"years": "років",
|
||||
"pushover_sound": "Звук"
|
||||
"pushover_sound": "Звук",
|
||||
"value": "Значення",
|
||||
"attribute": "Атрибут"
|
||||
},
|
||||
"warning": {
|
||||
"domain_added_sogo_failed": "Домен був доданий, але перезавантажити SOGo не вдалося, будь ласка, перевірте журнали сервера.",
|
||||
|
@ -107,7 +107,8 @@
|
||||
"timeout2": "本地主機連線逾時時間",
|
||||
"username": "使用者名稱",
|
||||
"validate": "驗證",
|
||||
"validation_success": "驗證成功"
|
||||
"validation_success": "驗證成功",
|
||||
"dry": "模擬同步"
|
||||
},
|
||||
"admin": {
|
||||
"access": "存取",
|
||||
@ -335,7 +336,22 @@
|
||||
"username": "使用者名稱",
|
||||
"validate_license_now": "與證書伺服器驗證 GUID",
|
||||
"verify": "驗證",
|
||||
"yes": "✓"
|
||||
"yes": "✓",
|
||||
"f2b_manage_external_info": "Fail2ban仍會維護禁令列表,但不會主動設定規則來阻止流量。 使用下面產生的禁止清單從外部阻止流量。",
|
||||
"allowed_origins": "存取控制允許來源",
|
||||
"logo_dark_label": "深色模式",
|
||||
"logo_normal_label": "標準",
|
||||
"f2b_ban_time_increment": "禁令時間會隨著每次禁令增加",
|
||||
"copy_to_clipboard": "文字已複製到剪貼簿!",
|
||||
"cors_settings": "CORS 設定",
|
||||
"f2b_manage_external": "外部管理 Fail2Ban",
|
||||
"f2b_max_ban_time": "最大限度。 禁止時間(s)",
|
||||
"allowed_methods": "存取控制允許方法",
|
||||
"ip_check": "IP檢查",
|
||||
"ip_check_opt_in": "選擇使用第三方服務 <strong>ipv4.mailcow.email</strong> 和 <strong>ipv6.mailcow.email</strong> 來解析外部 IP 位址。",
|
||||
"ip_check_disabled": "IP 檢查已停用。 您可以在<br> <strong>系統 > 配置 > 選項 > 自訂</strong>下啟用它",
|
||||
"options": "選項",
|
||||
"queue_unban": "解除禁令"
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "存取拒絕或表單資料有誤",
|
||||
@ -454,7 +470,17 @@
|
||||
"username_invalid": "使用者名稱 %s 無法使用",
|
||||
"validity_missing": "請設定有效期",
|
||||
"value_missing": "請填入所有欄位",
|
||||
"yotp_verification_failed": "Yubico OTP 認證失敗: %s"
|
||||
"yotp_verification_failed": "Yubico OTP 認證失敗: %s",
|
||||
"webauthn_authenticator_failed": "找不到所選的驗證器",
|
||||
"webauthn_publickey_failed": "沒有為選定的身份驗證器儲存公鑰",
|
||||
"webauthn_username_failed": "所選驗證器屬於另一個帳戶",
|
||||
"cors_invalid_method": "指定的允許方法無效",
|
||||
"cors_invalid_origin": "指定的允許來源無效",
|
||||
"demo_mode_enabled": "演示模式已啟用",
|
||||
"extended_sender_acl_denied": "缺少設定外部寄件者地址的 ACL",
|
||||
"template_exists": "模板 %s 已存在",
|
||||
"template_id_invalid": "範本 ID %s 無效",
|
||||
"template_name_invalid": "模板名稱無效"
|
||||
},
|
||||
"debug": {
|
||||
"chart_this_server": "圖表 (此伺服器)",
|
||||
@ -473,7 +499,7 @@
|
||||
"restart_container": "重新啟動",
|
||||
"service": "服務",
|
||||
"size": "大小",
|
||||
"solr_dead": "Solr 正在啟動、停用或已停止運行",
|
||||
"solr_dead": "Solr 正在啟動,停用或已停止運行.",
|
||||
"solr_status": "Solr 狀態",
|
||||
"started_at": "啟動於",
|
||||
"started_on": "啟動於",
|
||||
@ -481,7 +507,19 @@
|
||||
"success": "成功",
|
||||
"system_containers": "系統和容器",
|
||||
"uptime": "運行時間",
|
||||
"username": "使用者名稱"
|
||||
"username": "使用者名稱",
|
||||
"architecture": "結構",
|
||||
"current_time": "系統時間",
|
||||
"container_running": "正在執行",
|
||||
"memory": "記憶",
|
||||
"container_disabled": "容器停止或停用",
|
||||
"container_stopped": "已停止",
|
||||
"cores": "核心",
|
||||
"error_show_ip": "無法解析公用IP位址",
|
||||
"show_ip": "顯示公網IP",
|
||||
"update_available": "有可用更新",
|
||||
"no_update_available": "系統已經是最新版本",
|
||||
"update_failed": "無法檢查更新"
|
||||
},
|
||||
"diagnostics": {
|
||||
"cname_from_a": "由 A/AAAA 紀錄獲取。只要紀錄指向正確的資源,此功能就會持續運作。",
|
||||
@ -607,7 +645,11 @@
|
||||
"title": "編輯物件",
|
||||
"unchanged_if_empty": "如果不更改則留空",
|
||||
"username": "使用者名稱",
|
||||
"validate_save": "驗證並儲存"
|
||||
"validate_save": "驗證並儲存",
|
||||
"domain_footer_info": "網域範圍的頁尾將會新增至與該網域內的位址關聯的所有外發電子郵件。 <br> 以下變數可用於頁尾:",
|
||||
"custom_attributes": "自訂屬性",
|
||||
"mbox_exclude": "排除信箱",
|
||||
"pushover_sound": "聲音"
|
||||
},
|
||||
"fido2": {
|
||||
"confirm": "確認",
|
||||
@ -646,7 +688,10 @@
|
||||
"quarantine": "隔離",
|
||||
"restart_netfilter": "重新啟動 netfilter",
|
||||
"restart_sogo": "重新啟動 SOGo",
|
||||
"user_settings": "使用者設定"
|
||||
"user_settings": "使用者設定",
|
||||
"email": "電子郵件",
|
||||
"mailcow_system": "系統",
|
||||
"mailcow_config": "配置"
|
||||
},
|
||||
"info": {
|
||||
"awaiting_tfa_confirmation": "等待 TFA 確認",
|
||||
@ -829,7 +874,13 @@
|
||||
"username": "使用者名稱",
|
||||
"waiting": "等待中",
|
||||
"weekly": "每週",
|
||||
"yes": "✓"
|
||||
"yes": "✓",
|
||||
"templates": "範本",
|
||||
"domain_templates": "域模板",
|
||||
"add_template": "新增模板",
|
||||
"mailbox_templates": "信箱模板",
|
||||
"relay_unknown": "轉發未知信箱",
|
||||
"template": "範本"
|
||||
},
|
||||
"oauth2": {
|
||||
"access_denied": "請使用信箱使用者登入來進行 OAuth2 授權",
|
||||
@ -929,7 +980,7 @@
|
||||
"delete_filters": "過濾器 %s 已刪除",
|
||||
"deleted_syncjob": "同步任務 ID %s 已刪除",
|
||||
"deleted_syncjobs": "同步任務 %s 已刪除",
|
||||
"dkim_added": " DKIM 金鑰 %s 已儲存",
|
||||
"dkim_added": "DKIM 金鑰 %s 已儲存",
|
||||
"domain_add_dkim_available": "DKIM 金鑰已存在",
|
||||
"dkim_duplicated": "域名的 DKIM 金鑰 %s 已複製到 %s",
|
||||
"dkim_removed": " DKIM 金鑰 %s 已刪除",
|
||||
@ -976,14 +1027,21 @@
|
||||
"settings_map_added": "設定規則已新增",
|
||||
"settings_map_removed": "設定規則 ID %s 已刪除",
|
||||
"sogo_profile_reset": "使用者 %s 的 SOGo 個人頁面已重設",
|
||||
"tls_policy_map_entry_deleted": " TLS 規則 ID %s 已刪除",
|
||||
"tls_policy_map_entry_deleted": "TLS 規則 ID %s 已刪除",
|
||||
"tls_policy_map_entry_saved": "TLS 規則 \"%s\" 已儲存",
|
||||
"ui_texts": "UI 內文更改已儲存",
|
||||
"upload_success": "檔案已成功上傳",
|
||||
"verified_fido2_login": "FIDO2 登入驗證成功",
|
||||
"verified_totp_login": "TOTP 登入驗證成功",
|
||||
"verified_webauthn_login": "WebAuthn 登入驗證成功",
|
||||
"verified_yotp_login": "Yubico OTP 登入驗證成功"
|
||||
"verified_yotp_login": "Yubico OTP 登入驗證成功",
|
||||
"template_removed": "模板 ID %s 已刪除",
|
||||
"template_added": "新增了模板 %s",
|
||||
"template_modified": "模板 %s 的變更已儲存",
|
||||
"cors_headers_edited": "CORS 設定已儲存",
|
||||
"domain_footer_modified": "網域頁尾 %s 的變更已儲存",
|
||||
"f2b_banlist_refreshed": "禁止清單 ID 已成功刷新。",
|
||||
"ip_check_opt_in_modified": "IP檢查已成功儲存"
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s 使用 Yubico Cloud API,請在<a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">這裡</a>為這把金鑰獲取 API 金鑰",
|
||||
@ -1171,7 +1229,9 @@
|
||||
"weeks": "週",
|
||||
"with_app_password": "使用應用程式密碼",
|
||||
"year": "年",
|
||||
"years": "年"
|
||||
"years": "年",
|
||||
"attribute": "屬性",
|
||||
"pushover_sound": "聲音"
|
||||
},
|
||||
"warning": {
|
||||
"cannot_delete_self": "不能刪除已登入的使用者",
|
||||
@ -1185,5 +1245,43 @@
|
||||
"quota_exceeded_scope": "域名容量配額已滿: 此域名現在只能創建無限容量的信箱。",
|
||||
"session_token": "表單驗證失敗: 驗證碼錯誤",
|
||||
"session_ua": "表單驗證失敗: User-Agent 校驗錯誤"
|
||||
},
|
||||
"datatables": {
|
||||
"infoEmpty": "顯示 0 到 0 個條目,共 0 個條目",
|
||||
"infoFiltered": "(從_MAX_個總條目中過濾)",
|
||||
"lengthMenu": "顯示_選單_條目",
|
||||
"loadingRecords": "載入中...",
|
||||
"processing": "請稍等...",
|
||||
"search": "搜尋:",
|
||||
"zeroRecords": "未找到符合的記錄",
|
||||
"paginate": {
|
||||
"first": "第一的",
|
||||
"last": "最後的",
|
||||
"next": "下一個",
|
||||
"previous": "上一個"
|
||||
},
|
||||
"aria": {
|
||||
"sortAscending": ":啟動以升序對列進行排序",
|
||||
"sortDescending": ":啟動以降序對列進行排序"
|
||||
},
|
||||
"expand_all": "展開全部",
|
||||
"info": "顯示 _START_ 到 _END_ 條,共 _TOTAL_ 條",
|
||||
"collapse_all": "全部折疊",
|
||||
"emptyTable": "表中沒有可用數據",
|
||||
"thousands": ",",
|
||||
"decimal": "."
|
||||
},
|
||||
"queue": {
|
||||
"deliver_mail_legend": "嘗試重新投遞選定的郵件。",
|
||||
"show_message": "顯示訊息",
|
||||
"unban": "解除禁令隊列",
|
||||
"legend": "郵件隊列操作功能:",
|
||||
"delete": "刪除所有",
|
||||
"flush": "刷新隊列",
|
||||
"info": "郵件隊列包含所有等待投遞的電子郵件。 如果電子郵件長時間滯留在郵件隊列中,系統會自動將其刪除。<br>對應郵件的錯誤訊息會提供有關郵件無法送達的原因的資訊。",
|
||||
"ays": "請確認您要刪除目前隊列中的所有項目。",
|
||||
"deliver_mail": "遞送",
|
||||
"queue_manager": "隊列管理器",
|
||||
"unhold_mail_legend": "釋放選定的郵件以供投遞。 (需事先持有)"
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,13 @@
|
||||
<input type="number" class="form-control" id="f2b_netban_ipv6" name="netban_ipv6" value="{{ f2b_data.netban_ipv6 }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="f2b_manage_external" value="1" name="manage_external" {% if f2b_data.manage_external == 1 %}checked{% endif %}>
|
||||
<label class="form-check-label" for="f2b_manage_external">{{ lang.admin.f2b_manage_external }}</label>
|
||||
</div>
|
||||
<p class="text-muted">{{ lang.admin.f2b_manage_external_info }}</p>
|
||||
</div>
|
||||
<hr>
|
||||
<p class="text-muted">{{ lang.admin.f2b_list_info|raw }}</p>
|
||||
<div class="mb-2">
|
||||
@ -90,6 +97,15 @@
|
||||
{% if not f2b_data.active_bans and not f2b_data.perm_bans %}
|
||||
<i>{{ lang.admin.no_active_bans }}</i>
|
||||
{% endif %}
|
||||
<form class="form-inline" data-id="f2b_banlist" role="form" method="post">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" aria-label="Banlist url" value="{{ f2b_banlist_url}}" id="banlist_url">
|
||||
{% if is_https %}
|
||||
<button class="btn btn-secondary" type="button" onclick="copyToClipboard('banlist_url')"><i class="bi bi-clipboard"></i></button>
|
||||
{% endif %}
|
||||
<button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
{% for active_ban in f2b_data.active_bans %}
|
||||
<p>
|
||||
<span class="badge fs-7 bg-info d-block d-sm-inline-block">
|
||||
|
@ -114,7 +114,9 @@
|
||||
<li class="logged-in-as nav-item"><a href="#" onclick="logout.submit()" class="nav-link"><b class="username-lia">{{ mailcow_cc_username }} <span class="text-info">({{ dual_login.username }})</span> </b><i class="bi bi-power ms-2"></i></a></li>
|
||||
{% endif %}
|
||||
{% if not is_master %}
|
||||
<li class="text-warning slave-info nav-item">[ slave ]</li>
|
||||
<div class="nav-link form-check form-switch my-auto d-flex align-items-center">
|
||||
<li class="slave-info">[ slave ]</li>
|
||||
</div>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div><!--/.nav-collapse -->
|
||||
|
@ -24,6 +24,7 @@
|
||||
|
||||
<script type='text/javascript'>
|
||||
var lang_user = {{ lang_user|raw }};
|
||||
var lang_admin = {{ lang_admin|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||
|
@ -168,7 +168,7 @@
|
||||
<label class="control-label col-sm-2">{{ lang.edit.ratelimit }}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input name="rl_value" type="number" value="{{ rl.value }}" autocomplete="off" class="form-control placeholder="{{ lang.ratelimit.disabled }}">
|
||||
<input name="rl_value" type="number" value="{{ rl.value }}" autocomplete="off" class="form-control" placeholder="{{ lang.ratelimit.disabled }}">
|
||||
<select name="rl_frame" class="form-control">
|
||||
{% include 'mailbox/rl-frame.twig' %}
|
||||
</select>
|
||||
@ -285,23 +285,49 @@
|
||||
{{ lang.edit.domain_footer_info_vars.from_user }}
|
||||
{{ lang.edit.domain_footer_info_vars.from_name }}
|
||||
{{ lang.edit.domain_footer_info_vars.from_addr }}
|
||||
{{ lang.edit.domain_footer_info_vars.from_domain }}</pre>
|
||||
{{ lang.edit.domain_footer_info_vars.from_domain }}
|
||||
{{ lang.edit.domain_footer_info_vars.custom }}</pre>
|
||||
<form class="form-horizontal mt-4" data-id="domain_footer">
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="mbox_exclude">{{ lang.edit.mbox_exclude }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select data-live-search="true" data-width="100%" style="width:100%" id="editMboxExclude" name="mbox_exclude" size="10" multiple>
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox }}" {% if mailbox in domain_footer.mbox_exclude %}selected{% endif %}>
|
||||
{{ mailbox }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% for alias in aliases %}
|
||||
<option data-subtext="Alias" value="{{ alias }}" {% if alias in domain_footer.mbox_exclude %}selected{% endif %}>
|
||||
{{ alias }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="domain_footer_skip_replies">{{ lang.edit.domain_footer_skip_replies }}:</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" id="domain_footer_skip_replies" name="skip_replies"{% if domain_footer.skip_replies == '1' %} checked{% endif %}></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="footer_html">{{ domain_footer.html }}</textarea>
|
||||
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="html">{{ domain_footer.html }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="footer_plain">{{ domain_footer.plain }}</textarea>
|
||||
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="plain">{{ domain_footer.plain }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="domain_footer" data-api-url='edit/domain-wide-footer' data-api-attr='{"domain":"{{ domain }}"}' href="#">{{ lang.edit.save }}</button>
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="{{ domain }}" data-api-url='edit/domain/footer' data-api-attr='{}' href="#">{{ lang.edit.save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -5,6 +5,7 @@
|
||||
<div id="mailbox-content" class="responsive-tabs">
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#medit">{{ lang.edit.mailbox }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mattr">{{ lang.edit.custom_attributes }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mpushover">{{ lang.edit.pushover }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#macl">{{ lang.edit.acl }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mrl">{{ lang.edit.ratelimit }}</button></li>
|
||||
@ -275,6 +276,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mattr" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-attr">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex d-md-none fs-5">
|
||||
<button class="btn flex-grow-1 text-start" data-bs-target="#collapse-tab-mattr" data-bs-toggle="collapse" aria-controls="collapse-tab-mattr">
|
||||
{{ lang.edit.mailbox }} <span class="badge bg-info table-lines"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="collapse-tab-mattr" class="card-body collapse show" data-bs-parent="#mailbox-content">
|
||||
<form class="form-inline" data-id="mbox_attr" role="form" method="post">
|
||||
<table class="table table-condensed" style="white-space: nowrap;" id="mbox_attr_table">
|
||||
<tr>
|
||||
<th>{{ lang.user.attribute }}</th>
|
||||
<th>{{ lang.user.value }}</th>
|
||||
<th style="width:100px;"> </th>
|
||||
</tr>
|
||||
{% for key, val in result.custom_attributes %}
|
||||
<tr>
|
||||
<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required value="{{ key }}"></td>
|
||||
<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required value="{{ val }}"></td>
|
||||
<td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">{{ lang.admin.remove_row }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p><div class="btn-group">
|
||||
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-success" data-action="edit_selected" data-item="{{ mailbox }}" data-id="mbox_attr" data-api-url='edit/mailbox/custom-attribute' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" type="button" id="add_mbox_attr_row">{{ lang.admin.add_row }}</button>
|
||||
</div></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mpushover" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-pushover">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex d-md-none fs-5">
|
||||
|
@ -155,7 +155,7 @@
|
||||
|
||||
{% if pending_tfa_authmechs["totp"] is defined and pending_tfa_authmechs["u2f"] is not defined %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if pending_tfa_authmechs["totp"] %}active{% endif %}" href="#tfa_tab_totp" data-bs-toggle="tab" id="pending_tfa_tab_totp"><i class="bi bi-clock-history"></i> Time based OTP</a>
|
||||
<a class="nav-link {% if pending_tfa_authmechs["totp"] %}active{% endif %}" href="#tfa_tab_totp" data-bs-toggle="tab" id="pending_tfa_tab_totp"><i class="bi bi-clock-history"></i> Time-based OTP</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@ -173,7 +173,7 @@
|
||||
<form role="form" method="post" id="webauthn_auth_form">
|
||||
<legend class="mt-2 mb-2">
|
||||
<i class="bi bi-shield-fill-check"></i>
|
||||
Authenticators
|
||||
{{ lang.tfa.authenticators }}
|
||||
<hr />
|
||||
</legend>
|
||||
<div class="list-group">
|
||||
@ -216,7 +216,7 @@
|
||||
<form role="form" method="post">
|
||||
<legend class="mt-2 mb-2">
|
||||
<i class="bi bi-shield-fill-check"></i>
|
||||
Authenticate
|
||||
{{ lang.tfa.authenticators }}
|
||||
<hr />
|
||||
</legend>
|
||||
<div class="collapse show pending-tfa-collapse" id="collapseYubiTFA">
|
||||
@ -244,7 +244,7 @@
|
||||
<form role="form" method="post">
|
||||
<legend class="mt-2 mb-2">
|
||||
<i class="bi bi-shield-fill-check"></i>
|
||||
Authenticators
|
||||
{{ lang.tfa.authenticators }}
|
||||
<hr />
|
||||
</legend>
|
||||
<div class="list-group">
|
||||
|
@ -2,9 +2,10 @@ version: '2.1'
|
||||
services:
|
||||
|
||||
unbound-mailcow:
|
||||
image: mailcow/unbound:1.18
|
||||
image: mailcow/unbound:1.21
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- SKIP_UNBOUND_HEALTHCHECK=${SKIP_UNBOUND_HEALTHCHECK:-n}
|
||||
volumes:
|
||||
- ./data/hooks/unbound:/hooks:Z
|
||||
- ./data/conf/unbound/unbound.conf:/etc/unbound/unbound.conf:ro,Z
|
||||
@ -20,6 +21,7 @@ services:
|
||||
image: mariadb:10.5
|
||||
depends_on:
|
||||
- unbound-mailcow
|
||||
- netfilter-mailcow
|
||||
stop_grace_period: 45s
|
||||
volumes:
|
||||
- mysql-vol-1:/var/lib/mysql/
|
||||
@ -45,6 +47,8 @@ services:
|
||||
volumes:
|
||||
- redis-vol-1:/data/
|
||||
restart: always
|
||||
depends_on:
|
||||
- netfilter-mailcow
|
||||
ports:
|
||||
- "${REDIS_PORT:-127.0.0.1:7654}:6379"
|
||||
environment:
|
||||
@ -58,7 +62,7 @@ services:
|
||||
- redis
|
||||
|
||||
clamd-mailcow:
|
||||
image: mailcow/clamd:1.63
|
||||
image: mailcow/clamd:1.64
|
||||
restart: always
|
||||
depends_on:
|
||||
unbound-mailcow:
|
||||
@ -77,7 +81,7 @@ services:
|
||||
- clamd
|
||||
|
||||
rspamd-mailcow:
|
||||
image: mailcow/rspamd:1.92
|
||||
image: mailcow/rspamd:1.95
|
||||
stop_grace_period: 30s
|
||||
depends_on:
|
||||
- dovecot-mailcow
|
||||
@ -107,7 +111,7 @@ services:
|
||||
- rspamd
|
||||
|
||||
php-fpm-mailcow:
|
||||
image: mailcow/phpfpm:1.85
|
||||
image: mailcow/phpfpm:1.87
|
||||
command: "php-fpm -d date.timezone=${TZ} -d expose_php=0"
|
||||
depends_on:
|
||||
- redis-mailcow
|
||||
@ -171,7 +175,7 @@ services:
|
||||
- phpfpm
|
||||
|
||||
sogo-mailcow:
|
||||
image: mailcow/sogo:1.119
|
||||
image: mailcow/sogo:1.122.1
|
||||
environment:
|
||||
- DBNAME=${DBNAME}
|
||||
- DBUSER=${DBUSER}
|
||||
@ -203,7 +207,7 @@ services:
|
||||
labels:
|
||||
ofelia.enabled: "true"
|
||||
ofelia.job-exec.sogo_sessions.schedule: "@every 1m"
|
||||
ofelia.job-exec.sogo_sessions.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-tool expire-sessions $${SOGO_EXPIRE_SESSION} || exit 0\""
|
||||
ofelia.job-exec.sogo_sessions.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-tool -v expire-sessions $${SOGO_EXPIRE_SESSION} || exit 0\""
|
||||
ofelia.job-exec.sogo_ealarms.schedule: "@every 1m"
|
||||
ofelia.job-exec.sogo_ealarms.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-ealarms-notify -p /etc/sogo/sieve.creds || exit 0\""
|
||||
ofelia.job-exec.sogo_eautoreply.schedule: "@every 5m"
|
||||
@ -218,9 +222,10 @@ services:
|
||||
- sogo
|
||||
|
||||
dovecot-mailcow:
|
||||
image: mailcow/dovecot:1.26
|
||||
image: mailcow/dovecot:1.28.2
|
||||
depends_on:
|
||||
- mysql-mailcow
|
||||
- netfilter-mailcow
|
||||
dns:
|
||||
- ${IPV4_NETWORK:-172.22.1}.254
|
||||
cap_add:
|
||||
@ -241,6 +246,8 @@ services:
|
||||
environment:
|
||||
- DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
|
||||
- DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-}
|
||||
- MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
|
||||
- DOVEADM_REPLICA_PORT=${DOVEADM_REPLICA_PORT:-}
|
||||
- LOG_LINES=${LOG_LINES:-9999}
|
||||
- DBNAME=${DBNAME}
|
||||
- DBUSER=${DBUSER}
|
||||
@ -298,7 +305,7 @@ services:
|
||||
- dovecot
|
||||
|
||||
postfix-mailcow:
|
||||
image: mailcow/postfix:1.72
|
||||
image: mailcow/postfix:1.74
|
||||
depends_on:
|
||||
mysql-mailcow:
|
||||
condition: service_started
|
||||
@ -398,7 +405,7 @@ services:
|
||||
condition: service_started
|
||||
unbound-mailcow:
|
||||
condition: service_healthy
|
||||
image: mailcow/acme:1.85
|
||||
image: mailcow/acme:1.87
|
||||
dns:
|
||||
- ${IPV4_NETWORK:-172.22.1}.254
|
||||
environment:
|
||||
@ -434,14 +441,8 @@ services:
|
||||
- acme
|
||||
|
||||
netfilter-mailcow:
|
||||
image: mailcow/netfilter:1.52
|
||||
image: mailcow/netfilter:1.57
|
||||
stop_grace_period: 30s
|
||||
depends_on:
|
||||
- dovecot-mailcow
|
||||
- postfix-mailcow
|
||||
- sogo-mailcow
|
||||
- php-fpm-mailcow
|
||||
- redis-mailcow
|
||||
restart: always
|
||||
privileged: true
|
||||
environment:
|
||||
@ -452,12 +453,14 @@ services:
|
||||
- SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
|
||||
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
|
||||
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
|
||||
- MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
|
||||
- DISABLE_NETFILTER_ISOLATION_RULE=${DISABLE_NETFILTER_ISOLATION_RULE:-n}
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- /lib/modules:/lib/modules:ro
|
||||
|
||||
watchdog-mailcow:
|
||||
image: mailcow/watchdog:1.98
|
||||
image: mailcow/watchdog:2.02
|
||||
dns:
|
||||
- ${IPV4_NETWORK:-172.22.1}.254
|
||||
tmpfs:
|
||||
@ -486,7 +489,10 @@ services:
|
||||
- USE_WATCHDOG=${USE_WATCHDOG:-n}
|
||||
- WATCHDOG_NOTIFY_EMAIL=${WATCHDOG_NOTIFY_EMAIL:-}
|
||||
- WATCHDOG_NOTIFY_BAN=${WATCHDOG_NOTIFY_BAN:-y}
|
||||
- WATCHDOG_NOTIFY_START=${WATCHDOG_NOTIFY_START:-y}
|
||||
- WATCHDOG_SUBJECT=${WATCHDOG_SUBJECT:-Watchdog ALERT}
|
||||
- WATCHDOG_NOTIFY_WEBHOOK=${WATCHDOG_NOTIFY_WEBHOOK:-}
|
||||
- WATCHDOG_NOTIFY_WEBHOOK_BODY=${WATCHDOG_NOTIFY_WEBHOOK_BODY:-}
|
||||
- WATCHDOG_EXTERNAL_CHECKS=${WATCHDOG_EXTERNAL_CHECKS:-n}
|
||||
- WATCHDOG_MYSQL_REPLICATION_CHECKS=${WATCHDOG_MYSQL_REPLICATION_CHECKS:-n}
|
||||
- WATCHDOG_VERBOSE=${WATCHDOG_VERBOSE:-n}
|
||||
@ -526,7 +532,7 @@ services:
|
||||
- watchdog
|
||||
|
||||
dockerapi-mailcow:
|
||||
image: mailcow/dockerapi:2.06
|
||||
image: mailcow/dockerapi:2.07
|
||||
security_opt:
|
||||
- label=disable
|
||||
restart: always
|
||||
@ -544,9 +550,13 @@ services:
|
||||
aliases:
|
||||
- dockerapi
|
||||
|
||||
|
||||
##### Will be removed soon #####
|
||||
solr-mailcow:
|
||||
image: mailcow/solr:1.8.1
|
||||
image: mailcow/solr:1.8.2
|
||||
restart: always
|
||||
depends_on:
|
||||
- netfilter-mailcow
|
||||
volumes:
|
||||
- solr-vol-1:/opt/solr/server/solr/dovecot-fts/data
|
||||
ports:
|
||||
@ -559,9 +569,10 @@ services:
|
||||
mailcow-network:
|
||||
aliases:
|
||||
- solr
|
||||
################################
|
||||
|
||||
olefy-mailcow:
|
||||
image: mailcow/olefy:1.11
|
||||
image: mailcow/olefy:1.12
|
||||
restart: always
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
|
@ -26,7 +26,7 @@ for bin in openssl curl docker git awk sha1sum grep cut; do
|
||||
done
|
||||
|
||||
if docker compose > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep "^2." > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
|
||||
COMPOSE_VERSION=native
|
||||
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
|
||||
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
|
||||
@ -34,7 +34,7 @@ if docker compose > /dev/null 2>&1; then
|
||||
echo -e "\e[33mNotice: You´ll have to update this Compose Version via your Package Manager manually!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
elif docker-compose > /dev/null 2>&1; then
|
||||
@ -47,14 +47,14 @@ elif docker-compose > /dev/null 2>&1; then
|
||||
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -363,6 +363,10 @@ SKIP_IP_CHECK=n
|
||||
|
||||
SKIP_HTTP_VERIFICATION=n
|
||||
|
||||
# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n
|
||||
|
||||
SKIP_UNBOUND_HEALTHCHECK=n
|
||||
|
||||
# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n
|
||||
|
||||
SKIP_CLAMD=${SKIP_CLAMD}
|
||||
@ -398,9 +402,19 @@ USE_WATCHDOG=y
|
||||
#WATCHDOG_NOTIFY_EMAIL=a@example.com,b@example.com,c@example.com
|
||||
#WATCHDOG_NOTIFY_EMAIL=
|
||||
|
||||
# Send notifications to a webhook URL that receives a POST request with the content type "application/json".
|
||||
# You can use this to send notifications to services like Discord, Slack and others.
|
||||
#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
# JSON body included in the webhook POST request. Needs to be in single quotes.
|
||||
# Following variables are available: SUBJECT, BODY
|
||||
#WATCHDOG_NOTIFY_WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
|
||||
|
||||
# Notify about banned IP (includes whois lookup)
|
||||
WATCHDOG_NOTIFY_BAN=n
|
||||
|
||||
# Send a notification when the watchdog is started.
|
||||
WATCHDOG_NOTIFY_START=y
|
||||
|
||||
# Subject for watchdog mails. Defaults to "Watchdog ALERT" followed by the error message.
|
||||
#WATCHDOG_SUBJECT=
|
||||
|
||||
@ -480,6 +494,9 @@ WEBAUTHN_ONLY_TRUSTED_VENDORS=n
|
||||
# Otherwise it will work normally.
|
||||
SPAMHAUS_DQS_KEY=
|
||||
|
||||
# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n
|
||||
# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost
|
||||
DISABLE_NETFILTER_ISOLATION_RULE=n
|
||||
EOF
|
||||
|
||||
mkdir -p data/assets/ssl
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
PATH=${PATH}:/opt/bin
|
||||
DATE=$(date +%Y-%m-%d_%H_%M_%S)
|
||||
LOCAL_ARCH=$(uname -m)
|
||||
export LC_ALL=C
|
||||
|
||||
echo
|
||||
@ -148,6 +149,9 @@ else
|
||||
echo -e "\e[31mCannot find any Docker Compose on remote, exiting...\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_ARCH=$(ssh -o StrictHostKeyChecking=no -i "${REMOTE_SSH_KEY}" ${REMOTE_SSH_HOST} -p ${REMOTE_SSH_PORT} "uname -m")
|
||||
|
||||
}
|
||||
|
||||
SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
|
||||
@ -164,6 +168,17 @@ echo -e "\033[1mFound compose project name ${CMPS_PRJ} for ${MAILCOW_HOSTNAME}\0
|
||||
echo -e "\033[1mFound SQL ${SQLIMAGE}\033[0m"
|
||||
echo
|
||||
|
||||
# Print Message if Local Arch and Remote Arch is not the same
|
||||
if [[ $LOCAL_ARCH != $REMOTE_ARCH ]]; then
|
||||
echo
|
||||
echo -e "\e[1;33m!!!!!!!!!!!!!!!!!!!!!!!!!! CAUTION !!!!!!!!!!!!!!!!!!!!!!!!!!\e[0m"
|
||||
echo -e "\e[3;33mDetected Architecture missmatch from source to destination...\e[0m"
|
||||
echo -e "\e[3;33mYour backup is transferred but some volumes might be skipped!\e[0m"
|
||||
echo -e "\e[1;33m!!!!!!!!!!!!!!!!!!!!!!!!!! CAUTION !!!!!!!!!!!!!!!!!!!!!!!!!!\e[0m"
|
||||
echo
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Make sure destination exists, rsync can fail under some circumstances
|
||||
echo -e "\033[1mPreparing remote...\033[0m"
|
||||
if ! ssh -o StrictHostKeyChecking=no \
|
||||
@ -248,8 +263,21 @@ for vol in $(docker volume ls -qf name="${CMPS_PRJ}"); do
|
||||
# Cleanup
|
||||
rm -rf "${SCRIPT_DIR}/../_tmp_mariabackup/"
|
||||
|
||||
elif [[ "${vol}" =~ "rspamd-vol-1" ]]; then
|
||||
# Exclude rspamd-vol-1 if the Architectures are not the same on source and destination due to compatibility issues.
|
||||
if [[ $LOCAL_ARCH == $REMOTE_ARCH ]]; then
|
||||
echo -e "\033[1mSynchronizing ${vol} from local ${mountpoint}...\033[0m"
|
||||
rsync --delete --info=progress2 -aH -e "ssh -o StrictHostKeyChecking=no \
|
||||
-i \"${REMOTE_SSH_KEY}\" \
|
||||
-p ${REMOTE_SSH_PORT}" \
|
||||
"${mountpoint}/" root@${REMOTE_SSH_HOST}:"${mountpoint}"
|
||||
else
|
||||
echo -e "\e[1;31mSkipping ${vol} from local maschine due to incompatiblity between different architecture...\e[0m"
|
||||
sleep 2
|
||||
continue
|
||||
fi
|
||||
|
||||
else
|
||||
echo -e "\033[1mSynchronizing ${vol} from local ${mountpoint}...\033[0m"
|
||||
rsync --delete --info=progress2 -aH -e "ssh -o StrictHostKeyChecking=no \
|
||||
-i \"${REMOTE_SSH_KEY}\" \
|
||||
|
@ -53,6 +53,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
COMPOSE_FILE=${SCRIPT_DIR}/../docker-compose.yml
|
||||
ENV_FILE=${SCRIPT_DIR}/../.env
|
||||
THREADS=$(echo ${THREADS:-1})
|
||||
ARCH=$(uname -m)
|
||||
|
||||
if ! [[ "${THREADS}" =~ ^[1-9]+$ ]] ; then
|
||||
echo "Thread input is not a number!"
|
||||
@ -96,6 +97,7 @@ function backup() {
|
||||
mkdir -p "${BACKUP_LOCATION}/mailcow-${DATE}"
|
||||
chmod 755 "${BACKUP_LOCATION}/mailcow-${DATE}"
|
||||
cp "${SCRIPT_DIR}/../mailcow.conf" "${BACKUP_LOCATION}/mailcow-${DATE}"
|
||||
touch "${BACKUP_LOCATION}/mailcow-${DATE}/.$ARCH"
|
||||
for bin in docker; do
|
||||
if [[ -z $(which ${bin}) ]]; then
|
||||
>&2 echo -e "\e[31mCannot find ${bin} in local PATH, exiting...\e[0m"
|
||||
@ -231,12 +233,29 @@ function restore() {
|
||||
docker start $(docker ps -aqf name=dovecot-mailcow)
|
||||
;;
|
||||
rspamd)
|
||||
if [[ $(find "${RESTORE_LOCATION}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') == "" ]]; then
|
||||
echo -e "\e[33mCould not find a architecture signature of the loaded backup... Maybe the backup was done before the multiarch update?"
|
||||
sleep 2
|
||||
echo -e "Continuing anyhow. If rspamd is crashing opon boot try remove the rspamd volume with docker volume rm ${CMPS_PRJ}_rspamd-vol-1 after you've stopped the stack.\e[0m"
|
||||
sleep 2
|
||||
docker stop $(docker ps -qf name=rspamd-mailcow)
|
||||
docker run -it --name mailcow-backup --rm \
|
||||
-v ${RESTORE_LOCATION}:/backup:z \
|
||||
-v $(docker volume ls -qf name=^${CMPS_PRJ}_rspamd-vol-1$):/rspamd:z \
|
||||
${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_rspamd.tar.gz
|
||||
docker start $(docker ps -aqf name=rspamd-mailcow)
|
||||
elif [[ $ARCH != $(find "${RESTORE_LOCATION}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') ]]; then
|
||||
echo -e "\e[31mThe Architecture of the backed up mailcow OS is different then your restoring mailcow OS..."
|
||||
sleep 2
|
||||
echo -e "Skipping rspamd due to compatibility issues!\e[0m"
|
||||
else
|
||||
docker stop $(docker ps -qf name=rspamd-mailcow)
|
||||
docker run -it --name mailcow-backup --rm \
|
||||
-v ${RESTORE_LOCATION}:/backup:z \
|
||||
-v $(docker volume ls -qf name=^${CMPS_PRJ}_rspamd-vol-1$):/rspamd:z \
|
||||
${DEBIAN_DOCKER_IMAGE} /bin/tar --use-compress-program="pigz -d -p ${THREADS}" -Pxvf /backup/backup_rspamd.tar.gz
|
||||
docker start $(docker ps -aqf name=rspamd-mailcow)
|
||||
fi
|
||||
;;
|
||||
postfix)
|
||||
docker stop $(docker ps -qf name=postfix-mailcow)
|
||||
@ -360,9 +379,17 @@ elif [[ ${1} == "restore" ]]; then
|
||||
FILE_SELECTION[${i}]="redis"
|
||||
((i++))
|
||||
elif [[ ${file} =~ rspamd ]]; then
|
||||
if [[ $(find "${FOLDER_SELECTION[${input_sel}]}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') == "" ]]; then
|
||||
echo "[ ${i} ] - Rspamd data (unkown Arch detected, restore with caution!)"
|
||||
FILE_SELECTION[${i}]="rspamd"
|
||||
((i++))
|
||||
elif [[ $ARCH != $(find "${FOLDER_SELECTION[${input_sel}]}" \( -name '*x86*' -o -name '*aarch*' \) -exec basename {} \; | sed 's/^\.//' | sed 's/^\.//') ]]; then
|
||||
echo -e "\e[31m[ NaN ] - Rspamd data (incompatible Arch, cannot restore it)\e[0m"
|
||||
else
|
||||
echo "[ ${i} ] - Rspamd data"
|
||||
FILE_SELECTION[${i}]="rspamd"
|
||||
((i++))
|
||||
fi
|
||||
elif [[ ${file} =~ postfix ]]; then
|
||||
echo "[ ${i} ] - Postfix data"
|
||||
FILE_SELECTION[${i}]="postfix"
|
||||
|
@ -26,6 +26,6 @@ services:
|
||||
- /var/run/mysqld/mysqld.sock:/var/run/mysqld/mysqld.sock
|
||||
|
||||
mysql-mailcow:
|
||||
image: alpine:3.18
|
||||
image: alpine:3.19
|
||||
command: /bin/true
|
||||
restart: "no"
|
||||
|
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# renovate: datasource=github-releases depName=nextcloud/server versioning=semver extractVersion=^v(?<version>.*)$
|
||||
NEXTCLOUD_VERSION=27.1.3
|
||||
NEXTCLOUD_VERSION=28.0.1
|
||||
|
||||
echo -ne "Checking prerequisites..."
|
||||
sleep 1
|
||||
|
55
update.sh
55
update.sh
@ -116,11 +116,11 @@ migrate_docker_nat() {
|
||||
echo "Working on IPv6 NAT, please wait..."
|
||||
echo ${NAT_CONFIG} > /etc/docker/daemon.json
|
||||
ip6tables -F -t nat
|
||||
[[ -e /etc/alpine-release ]] && rc-service docker restart || systemctl restart docker.service
|
||||
[[ -e /etc/rc.conf ]] && rc-service docker restart || systemctl restart docker.service
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo -e "\e[31mError:\e[0m Failed to activate IPv6 NAT! Reverting and exiting."
|
||||
rm /etc/docker/daemon.json
|
||||
if [[ -e /etc/alpine-release ]]; then
|
||||
if [[ -e /etc/rc.conf ]]; then
|
||||
rc-service docker restart
|
||||
else
|
||||
systemctl reset-failed docker.service
|
||||
@ -171,7 +171,7 @@ remove_obsolete_nginx_ports() {
|
||||
detect_docker_compose_command(){
|
||||
if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
|
||||
if docker compose > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep "2." > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
|
||||
DOCKER_COMPOSE_VERSION=native
|
||||
COMPOSE_COMMAND="docker compose"
|
||||
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
|
||||
@ -181,7 +181,7 @@ if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
|
||||
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
elif docker-compose > /dev/null 2>&1; then
|
||||
@ -196,14 +196,14 @@ if ! [[ "${DOCKER_COMPOSE_VERSION}" =~ ^(native|standalone)$ ]]; then
|
||||
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease update/install regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -216,7 +216,7 @@ elif [ "${DOCKER_COMPOSE_VERSION}" == "native" ]; then
|
||||
if ! $COMPOSE_COMMAND > /dev/null 2>&1 || ! $COMPOSE_COMMAND --version | grep "^2." > /dev/null 2>&1; then
|
||||
# IF it cannot find Standalone in > 2.X, then script stops
|
||||
echo -e "\e[31mCannot find Docker Compose or the Version is lower then 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
# If it finds the standalone Plugin it will use this instead and change the mailcow.conf Variable accordingly
|
||||
@ -236,7 +236,7 @@ elif [ "${DOCKER_COMPOSE_VERSION}" == "standalone" ]; then
|
||||
if ! $COMPOSE_COMMAND > /dev/null 2>&1; then
|
||||
# IF it cannot find Native in > 2.X, then script stops
|
||||
echo -e "\e[31mCannot find Docker Compose.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/i_u_m/i_u_m_install/\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
# If it finds the native Plugin it will use this instead and change the mailcow.conf Variable accordingly
|
||||
@ -441,7 +441,10 @@ CONFIG_ARRAY=(
|
||||
"SKIP_SOGO"
|
||||
"USE_WATCHDOG"
|
||||
"WATCHDOG_NOTIFY_EMAIL"
|
||||
"WATCHDOG_NOTIFY_WEBHOOK"
|
||||
"WATCHDOG_NOTIFY_WEBHOOK_BODY"
|
||||
"WATCHDOG_NOTIFY_BAN"
|
||||
"WATCHDOG_NOTIFY_START"
|
||||
"WATCHDOG_EXTERNAL_CHECKS"
|
||||
"WATCHDOG_SUBJECT"
|
||||
"SKIP_CLAMD"
|
||||
@ -477,6 +480,8 @@ CONFIG_ARRAY=(
|
||||
"WATCHDOG_VERBOSE"
|
||||
"WEBAUTHN_ONLY_TRUSTED_VENDORS"
|
||||
"SPAMHAUS_DQS_KEY"
|
||||
"SKIP_UNBOUND_HEALTHCHECK"
|
||||
"DISABLE_NETFILTER_ISOLATION_RULE"
|
||||
)
|
||||
|
||||
detect_bad_asn
|
||||
@ -623,12 +628,33 @@ for option in ${CONFIG_ARRAY[@]}; do
|
||||
echo "#MAILDIR_SUB=Maildir" >> mailcow.conf
|
||||
echo "MAILDIR_SUB=" >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "WATCHDOG_NOTIFY_WEBHOOK" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# Send notifications to a webhook URL that receives a POST request with the content type "application/json".' >> mailcow.conf
|
||||
echo '# You can use this to send notifications to services like Discord, Slack and others.' >> mailcow.conf
|
||||
echo '#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "WATCHDOG_NOTIFY_WEBHOOK_BODY" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# JSON body included in the webhook POST request. Needs to be in single quotes.' >> mailcow.conf
|
||||
echo '# Following variables are available: SUBJECT, BODY' >> mailcow.conf
|
||||
WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
|
||||
echo "#WATCHDOG_NOTIFY_WEBHOOK_BODY='${WEBHOOK_BODY}'" >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "WATCHDOG_NOTIFY_BAN" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# Notify about banned IP. Includes whois lookup.' >> mailcow.conf
|
||||
echo "WATCHDOG_NOTIFY_BAN=y" >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "WATCHDOG_NOTIFY_START" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# Send a notification when the watchdog is started.' >> mailcow.conf
|
||||
echo "WATCHDOG_NOTIFY_START=y" >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "WATCHDOG_SUBJECT" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
@ -723,6 +749,19 @@ for option in ${CONFIG_ARRAY[@]}; do
|
||||
echo '# Enable watchdog verbose logging' >> mailcow.conf
|
||||
echo 'WATCHDOG_VERBOSE=n' >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "SKIP_UNBOUND_HEALTHCHECK" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
|
||||
echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
|
||||
fi
|
||||
elif [[ ${option} == "DISABLE_NETFILTER_ISOLATION_RULE" ]]; then
|
||||
if ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo '# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n' >> mailcow.conf
|
||||
echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
|
||||
echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
|
||||
fi
|
||||
elif ! grep -q ${option} mailcow.conf; then
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
echo "${option}=n" >> mailcow.conf
|
||||
|
Loading…
Reference in New Issue
Block a user