diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 10cafd6a6f5b0fb9f21366b850bf944b74926929..5027411531fe4fdb3bf1a3f280e6ce1fb1e1a942 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ before_script: build:apt: script: - - ./debian/create_changelog.py uffd-socketmap > debian/changelog + - ./debian/create_changelog.py uffd-socketmapd > debian/changelog - dpkg-buildpackage -us -uc - mv ../*.deb ./ - dpkg-deb -I *.deb @@ -23,14 +23,29 @@ build:apt: paths: - ./*.deb +linter:buster: + image: registry.git.cccv.de/uffd/docker-images/buster + stage: test + script: + - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages! + - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'uffd-socketmapd' > codeclimate.json + - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'uffd-socketmapd' > pylint.html + - python3 -m pylint --rcfile .pylintrc --output-format=text 'uffd-socketmapd' + artifacts: + when: always + paths: + - pylint.html + reports: + codequality: codeclimate.json + linter:bullseye: image: registry.git.cccv.de/uffd/docker-images/bullseye stage: test script: - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages! - - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'server.py' > codeclimate.json - - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'server.py' > pylint.html - - python3 -m pylint --rcfile .pylintrc --output-format=text 'server.py' + - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'uffd-socketmapd' > codeclimate.json + - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'uffd-socketmapd' > pylint.html + - python3 -m pylint --rcfile .pylintrc --output-format=text 'uffd-socketmapd' artifacts: when: always paths: @@ -42,7 +57,7 @@ unittests:buster: image: registry.git.cccv.de/uffd/docker-images/buster stage: test script: - - python3-coverage run --include 'server.py' -m pytest --junitxml=report.xml || true + - python3-coverage run --include 'uffd-socketmapd' -m pytest --junitxml=report.xml || true #- python3-coverage report -m - python3-coverage html #- python3-coverage xml @@ -61,7 +76,7 @@ unittests:bullseye: image: registry.git.cccv.de/uffd/docker-images/bullseye stage: test script: - - python3-coverage run --include 'server.py' -m pytest --junitxml=report.xml || true + - python3-coverage run --include 'uffd-socketmapd' -m pytest --junitxml=report.xml || true - python3-coverage report -m - python3-coverage html - python3-coverage xml diff --git a/.pylintrc b/.pylintrc index 8507faa3cbdd28f2ce2cd70cae06c6286d36abc0..1112222e5eca1847406325a2fb6588ad31c56766 100644 --- a/.pylintrc +++ b/.pylintrc @@ -63,7 +63,8 @@ confidence= disable=missing-module-docstring, missing-class-docstring, missing-function-docstring, - too-few-public-methods + too-few-public-methods, + invalid-name, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/LICENSE b/LICENSE index 917d1d79215f2897eb10f47ac3cce1ac9d9db7a3..eb63dc281588cb4a92456cd2a713f1a81f8609a4 100644 --- a/LICENSE +++ b/LICENSE @@ -629,7 +629,7 @@ to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - socketmap-proxy + uffd-socketmapd Copyright (C) 2021 Julian Rother This program is free software: you can redistribute it and/or modify diff --git a/README.md b/README.md index 670f8e3576983b81fa31eb59cfd69459dfe77c9a..40d478b76a5a2850ebce911255e30d365caceff2 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Socketmap server to integrate uffd mail aliases with postfix [uffd](https://git.cccv.de/uffd/uffd) has features that rely on mail aliases. To make those mail aliases work, it provides an API to lookup alias addresses -for a given address. uffd-socketmap uses this API to integrate alias lookup +for a given address. uffd-socketmapd uses this API to integrate alias lookup with MTAs that support the socketmap protocol, like sendmail and postfix. -uffd-socketmap can be run manually. For production deployments, use the +uffd-socketmapd can be run manually. For production deployments, use the provided debian packages. Add our package mirror to `/etc/sources.list`: ``` @@ -14,16 +14,16 @@ deb https://packages.cccv.de/uffd bullseye main ``` Then download [cccv-archive-key.gpg](cccv-archive-key.gpg) and add it to the trusted repository keys in `/etc/apt/trusted.gpg.d/`. Afterwards run -`apt update && apt install uffd-socketmap` to install the package. +`apt update && apt install uffd-socketmapd` to install the package. -Set the API url and secret in `/etc/uffd-socketmap-postfix.conf`, enable -and start `uffd-socketmap-postfix.socket`. Then configure Postfix, e.g. +Set the API url and secret in `/etc/uffd-socketmapd-postfix.conf`, enable +and start `uffd-socketmapd-postfix.socket`. Then configure Postfix, e.g. by adding the following lines to `/etc/postfix/main.cf`: ``` # Note that postfix runs in a chroot (/var/spool/postfix) and paths are # relative to that! -virtual_alias_maps = socketmap:unix:/uffd-socketmap.sock:virtual +virtual_alias_maps = socketmap:unix:/uffd-socketmapd.sock:virtual # Defaults to $virtual_alias_maps, which does not work here, so unset it virtual_alias_domains = ``` diff --git a/debian/contrib/uffd-socketmap-postfix.conf b/debian/contrib/uffd-socketmapd-postfix.conf similarity index 54% rename from debian/contrib/uffd-socketmap-postfix.conf rename to debian/contrib/uffd-socketmapd-postfix.conf index c427afa4eac80512fc2c05202558e2249ce2e365..b0ce28bea1ff4ce5c7ae3471186ae99055245d47 100644 --- a/debian/contrib/uffd-socketmap-postfix.conf +++ b/debian/contrib/uffd-socketmapd-postfix.conf @@ -2,6 +2,6 @@ #SERVER_API_URL="https://localhost" #SERVER_API_KEY="my_secret_api_token" -# The socket path is hard-coded to "/var/spool/postfix/uffd-socketmap.sock" -# ("/uffd-socketmap.sock" in the postfix sandbox). Use systemd overwrites -# for uffd-socketmap-postfix.socket to change it. +# The socket path is hard-coded to "/var/spool/postfix/uffd-socketmapd.sock" +# ("/uffd-socketmapd.sock" in the postfix sandbox). Use systemd overwrites +# for uffd-socketmapd-postfix.socket to change it. diff --git a/debian/contrib/uffd-socketmap-postfix.service b/debian/contrib/uffd-socketmapd-postfix.service similarity index 81% rename from debian/contrib/uffd-socketmap-postfix.service rename to debian/contrib/uffd-socketmapd-postfix.service index 65a8275dce66124288b40fd1d9e6cfb702766640..fd4f9a2fa087b5a28be26156fcb50c45b7f41ad0 100644 --- a/debian/contrib/uffd-socketmap-postfix.service +++ b/debian/contrib/uffd-socketmapd-postfix.service @@ -2,16 +2,16 @@ Description=Socketmap server to integrate uffd mail aliases with postfix After=network.target Before=postfix.service -BindsTo=uffd-socketmap-postfix.socket +BindsTo=uffd-socketmapd-postfix.socket [Service] -ExecStart=/usr/bin/uffd-socketmap --socket-fd 3 +ExecStart=/usr/sbin/uffd-socketmapd --socket-fd 3 Restart=always RestartSec=10 StandardOutput=journal StandardError=journal -SyslogIdentifier=uffd-socketmap-postfix +SyslogIdentifier=uffd-socketmapd-postfix DynamicUser=true PrivateUsers=true @@ -38,7 +38,7 @@ SystemCallArchitectures=native SystemCallFilter=@system-service MemoryDenyWriteExecute=true -EnvironmentFile=/etc/uffd-socketmap-postfix.conf +EnvironmentFile=/etc/uffd-socketmapd-postfix.conf [Install] WantedBy=default.target diff --git a/debian/contrib/uffd-socketmap-postfix.socket b/debian/contrib/uffd-socketmapd-postfix.socket similarity index 77% rename from debian/contrib/uffd-socketmap-postfix.socket rename to debian/contrib/uffd-socketmapd-postfix.socket index 3c2e0a422b13f0f09ba6ae8837b80f66b5b4e742..4ea2b76a996b35ef3128aaa4b6fab4c5a8288e84 100644 --- a/debian/contrib/uffd-socketmap-postfix.socket +++ b/debian/contrib/uffd-socketmapd-postfix.socket @@ -2,7 +2,7 @@ Description=Socketmap server to integrate uffd mail aliases with postfix [Socket] -ListenStream=/var/spool/postfix/uffd-socketmap.sock +ListenStream=/var/spool/postfix/uffd-socketmapd.sock SocketUser=postfix SocketGroup=postfix SocketMode=0640 diff --git a/debian/control b/debian/control index 22732bed74b10029b629f12fa6f4471ad582ab3f..1597c337d196ab8b3212daee57c0189d5a3f9ae8 100644 --- a/debian/control +++ b/debian/control @@ -1,14 +1,14 @@ -Source: uffd-socketmap +Source: uffd-socketmapd Section: python Priority: optional Maintainer: CCCV <it@cccv.de> Build-Depends: debhelper-compat (= 12), Standards-Version: 4.5.0 -Homepage: https://git.cccv.de/uffd/socketmap-proxy -Vcs-Git: https://git.cccv.de/uffd/socketmap-proxy.git +Homepage: https://git.cccv.de/uffd/uffd-socketmapd +Vcs-Git: https://git.cccv.de/uffd/uffd-socketmapd.git -Package: uffd-socketmap +Package: uffd-socketmapd Architecture: all Depends: ${misc:Depends}, diff --git a/debian/install b/debian/install index e886322c605606d1f23708ba9b7016abd6d60b8c..751244c2373d3fcc1d09260031fced2798fde1b7 100644 --- a/debian/install +++ b/debian/install @@ -1,4 +1,4 @@ -server.py /usr/lib/uffd-socketmap/ -debian/contrib/uffd-socketmap-postfix.service /usr/lib/systemd/system/ -debian/contrib/uffd-socketmap-postfix.socket /usr/lib/systemd/system/ -debian/contrib/uffd-socketmap-postfix.conf /etc/ +uffd-socketmapd /usr/sbin/ +debian/contrib/uffd-socketmapd-postfix.service /usr/lib/systemd/system/ +debian/contrib/uffd-socketmapd-postfix.socket /usr/lib/systemd/system/ +debian/contrib/uffd-socketmapd-postfix.conf /etc/ diff --git a/debian/links b/debian/links deleted file mode 100644 index 5c199f3ebe5c63bffb09745ceb82bf912c220605..0000000000000000000000000000000000000000 --- a/debian/links +++ /dev/null @@ -1 +0,0 @@ -/usr/lib/uffd-socketmap/server.py /usr/bin/uffd-socketmap diff --git a/debian/postinst b/debian/postinst index bdba6dc00488e07d643734fae338985b9b62c6f1..3e727e452c8a1ba71222ed3775b4b5ed1d7efc7c 100755 --- a/debian/postinst +++ b/debian/postinst @@ -4,7 +4,7 @@ set -e case "$1" in configure) - chmod 0640 /etc/uffd-socketmap-postfix.conf + chmod 0640 /etc/uffd-socketmapd-postfix.conf ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/server.py b/server.py deleted file mode 100755 index c13bf10cc587ddc7742fd9ef99cf046a7d933afc..0000000000000000000000000000000000000000 --- a/server.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/python3 -import os -import sys -import logging -import socket -import socketserver - -import click -import requests - -logger = logging.getLogger(__name__) - -class UffdAPI: - def __init__(self, baseurl, key): - self.baseurl = baseurl - self.key = key - self.session = requests.Session() - self.session.headers['Authorization'] = 'Bearer '+self.key - - def get(self, endpoint, **kwargs): - resp = self.session.get(self.baseurl + endpoint, params=kwargs) - resp.raise_for_status() - return resp.json() - - def get_aliases(self, mail_address): - '''Return list of alias addresses for a given mail address''' - results = self.get('/api/v1/getmails', receive_address=mail_address) - destinations = [] - for result in results: - destinations += result['destination_addresses'] - logger.debug('API getmails response for %s: %s', repr(mail_address), destinations) - return destinations - -# From https://cr.yp.to/proto/netstrings.txt -# -# Any string of 8-bit bytes may be encoded as [len]":"[string]",". -# Here [string] is the string and [len] is a nonempty sequence of ASCII -# digits giving the length of [string] in decimal. The ASCII digits are -# <30> for 0, <31> for 1, and so on up through <39> for 9. Extra zeros -# at the front of [len] are prohibited: [len] begins with <30> exactly -# when [string] is empty. -# -# For example, the string "hello world!" is encoded as <31 32 3a 68 -# 65 6c 6c 6f 20 77 6f 72 6c 64 21 2c>, i.e., "12:hello world!,". The -# empty string is encoded as "0:,". -# -# [len]":"[string]"," is called a netstring. [string] is called the -# interpretation of the netstring. - -def encode_netstring(string): - '''Return netstring encoding of a Python str as bytes''' - string = string.encode('utf8') - return b'%d:%s,'%(len(string), string) - -def _parse_netstring_length(netstring): - if b':' not in netstring: - raise ValueError() - length, rest = netstring.split(b':', 1) - if not length.isdigit(): - raise ValueError() - length = int(length) - return length, rest - -def is_netstring_complete(netstring): - '''Return whether the argument (bytes) is a complete netstring - - Raises ValueError if the argument is invalid instead of only incomplete.''' - if b':' not in netstring: - return False - length, rest = _parse_netstring_length(netstring) - return len(rest) >= length + 1 - -def decode_netstring(netstring): - '''Decode netstring at the beginning of argument - - Returns (decoded_str, remaining_bytes). Raises ValueError if argument does - not start with a valid and complete netstring or the encoded string is not - valid UTF-8.''' - length, rest = _parse_netstring_length(netstring) - string, comma, rest = rest[:length], rest[length:length+1], rest[length+1:] - if len(string) != length or comma != b',': - raise ValueError() - # UnicodeDecodeError is a subclass of ValueError - return string.decode('utf8'), rest - -# From socketmap_table(5) -# -# PROTOCOL -# Socketmaps use a simple protocol: the client sends one request, and the -# server sends one reply. Each request and each reply are sent as one -# netstring object. -# -# REQUEST FORMAT -# The socketmap protocol supports only the lookup request. The request -# has the following form: -# -# name <space> key -# Search the named socketmap for the specified key. -# -# [...] -# REPLY FORMAT -# The Postfix socketmap client requires that replies are not longer than -# 100000 characters (not including the netstring encapsulation). Replies -# must have the following form: -# -# OK <space> data -# The requested data was found. -# -# NOTFOUND <space> -# The requested data was not found. -# -# TEMP <space> reason -# -# TIMEOUT <space> reason -# -# PERM <space> reason -# The request failed. The reason, if non-empty, is descriptive -# text. - -class SocketmapsRequestHandler(socketserver.BaseRequestHandler): - api = None # Overwritten - - def handle(self): - buf = b'' - while not is_netstring_complete(buf): - chunk = self.request.recv(4096) - if not chunk: - return - buf += chunk - request, rest = decode_netstring(buf) - if rest: - return - try: - response = self.handle_request(request) - except: # pylint: disable=bare-except - logger.exception('Exception during request') - self.request.sendall(encode_netstring('TEMP Internal Error')) - return - loglevel = logging.INFO - if isinstance(response, tuple) and len(response) == 2: - response, loglevel = response - logger.log(loglevel, 'Request %s -> %s', repr(request), repr(response)) - self.request.sendall(encode_netstring(response)) - - def handle_request(self, request): - if ' ' not in request: - return 'PERM Malformed request', logging.WARNING - name, key = request.split(' ', 1) - if name == 'virtual': - # Postfix option virtual_alias_domains defaults to virtual_alias_maps, - # so we might get some domain requests. Since Postfix does not retry - # failing virtual_alias_domains lookups, PERM is the best option here. - if '@' not in key: - return 'PERM Key is not a valid mail address. Maybe fix the virtual_alias_domains setting.', logging.WARNING - key = key.strip().lower() - # Emulate Postfixes virtual(8) behaviour when used with ldap_table(5). - local, domain = key.split('@', 1) - if '+' in local: - local, extention = local.split('+', 1) - lookup_order = [ - (f'{local}+{extention}@{domain}', ''), - (f'{local}@{domain}', extention), - (f'{local}+{extention}', ''), - (f'{local}', extention), - (f'@{domain}', extention), - ] - else: - lookup_order = [ - (f'{local}@{domain}', ''), - (f'{local}', ''), - (f'@{domain}', ''), - ] - results = [] - for key, extention in lookup_order: - for address in self.api.get_aliases(key): - # All destinations must be full addresses, but we don't want to fail - # here, if any is malformed. - if '@' in address and extention: - local, domain = address.split('@', 1) - address = f'{local}+{extention}@{domain}' - results.append(address) - if results: - break - # Returning "OK " (without any data) is interperted as a protocol error - # and Postfix will retry the request. - if not results: - return 'NOTFOUND ' - # There is no way to escape commas in the result items and it would not - # matter, since Postfix internally processes lookup results as a - # comma-separated list. - return 'OK ' + ','.join(results) - return 'PERM Unknown request name', logging.WARNING - -def make_requesthandler(api): - class RequestHandler(SocketmapsRequestHandler): - pass - RequestHandler.api = api - return RequestHandler - -class FilenoUnixStreamServer(socketserver.UnixStreamServer): - def __init__(self, fd, RequestHandlerClass, bind_and_activate=True): - self.server_fd = fd - super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate) - - def server_bind(self): - self.socket.close() # UnixStreamServer.__init__ creates an unbound socket - self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM) - self.server_address = self.socket.getsockname() - -class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer): - pass - -def cleanup_unix_socket(path): - if not os.path.exists(path): - return - conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - conn.connect(path) - except ConnectionRefusedError: - os.remove(path) - conn.close() - -class StdoutFilter(logging.Filter): - def filter(self, record): - return record.levelno <= logging.INFO - -@click.command(help='Socketmap proxy for integrating Postfix and other compatible MTAs with uffd SSO. Supports virtual alias lookups (request name "virtual").') -@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket') -@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)') -@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)') -@click.option('--api-key', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_KEY instead') -def main(socket_path, socket_fd, api_url, api_key): - if (socket_path is None and socket_fd is None) or \ - (socket_path is not None and socket_fd is not None): - raise click.ClickException('Either --socket-path or --socket-fd must be specified') - - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.setLevel(logging.DEBUG) - stdout_handler.addFilter(StdoutFilter()) - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.WARNING) - logger.setLevel(logging.INFO) - logger.addHandler(stdout_handler) - logger.addHandler(stderr_handler) - - api = UffdAPI(api_url, api_key) - RequestHandler = make_requesthandler(api) - if socket_path is not None: - cleanup_unix_socket(socket_path) - socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler).serve_forever() - else: - ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler).serve_forever() - -if __name__ == '__main__': - # Pylint does not seem to understand the click's decorators - # pylint: disable=unexpected-keyword-arg,no-value-for-parameter - main(auto_envvar_prefix='SERVER') diff --git a/server.py b/server.py new file mode 120000 index 0000000000000000000000000000000000000000..287d5bfc622b3c43fdbb4ce5f83f10bf4f8670ea --- /dev/null +++ b/server.py @@ -0,0 +1 @@ +uffd-socketmapd \ No newline at end of file diff --git a/uffd-socketmapd b/uffd-socketmapd new file mode 100755 index 0000000000000000000000000000000000000000..c13bf10cc587ddc7742fd9ef99cf046a7d933afc --- /dev/null +++ b/uffd-socketmapd @@ -0,0 +1,257 @@ +#!/usr/bin/python3 +import os +import sys +import logging +import socket +import socketserver + +import click +import requests + +logger = logging.getLogger(__name__) + +class UffdAPI: + def __init__(self, baseurl, key): + self.baseurl = baseurl + self.key = key + self.session = requests.Session() + self.session.headers['Authorization'] = 'Bearer '+self.key + + def get(self, endpoint, **kwargs): + resp = self.session.get(self.baseurl + endpoint, params=kwargs) + resp.raise_for_status() + return resp.json() + + def get_aliases(self, mail_address): + '''Return list of alias addresses for a given mail address''' + results = self.get('/api/v1/getmails', receive_address=mail_address) + destinations = [] + for result in results: + destinations += result['destination_addresses'] + logger.debug('API getmails response for %s: %s', repr(mail_address), destinations) + return destinations + +# From https://cr.yp.to/proto/netstrings.txt +# +# Any string of 8-bit bytes may be encoded as [len]":"[string]",". +# Here [string] is the string and [len] is a nonempty sequence of ASCII +# digits giving the length of [string] in decimal. The ASCII digits are +# <30> for 0, <31> for 1, and so on up through <39> for 9. Extra zeros +# at the front of [len] are prohibited: [len] begins with <30> exactly +# when [string] is empty. +# +# For example, the string "hello world!" is encoded as <31 32 3a 68 +# 65 6c 6c 6f 20 77 6f 72 6c 64 21 2c>, i.e., "12:hello world!,". The +# empty string is encoded as "0:,". +# +# [len]":"[string]"," is called a netstring. [string] is called the +# interpretation of the netstring. + +def encode_netstring(string): + '''Return netstring encoding of a Python str as bytes''' + string = string.encode('utf8') + return b'%d:%s,'%(len(string), string) + +def _parse_netstring_length(netstring): + if b':' not in netstring: + raise ValueError() + length, rest = netstring.split(b':', 1) + if not length.isdigit(): + raise ValueError() + length = int(length) + return length, rest + +def is_netstring_complete(netstring): + '''Return whether the argument (bytes) is a complete netstring + + Raises ValueError if the argument is invalid instead of only incomplete.''' + if b':' not in netstring: + return False + length, rest = _parse_netstring_length(netstring) + return len(rest) >= length + 1 + +def decode_netstring(netstring): + '''Decode netstring at the beginning of argument + + Returns (decoded_str, remaining_bytes). Raises ValueError if argument does + not start with a valid and complete netstring or the encoded string is not + valid UTF-8.''' + length, rest = _parse_netstring_length(netstring) + string, comma, rest = rest[:length], rest[length:length+1], rest[length+1:] + if len(string) != length or comma != b',': + raise ValueError() + # UnicodeDecodeError is a subclass of ValueError + return string.decode('utf8'), rest + +# From socketmap_table(5) +# +# PROTOCOL +# Socketmaps use a simple protocol: the client sends one request, and the +# server sends one reply. Each request and each reply are sent as one +# netstring object. +# +# REQUEST FORMAT +# The socketmap protocol supports only the lookup request. The request +# has the following form: +# +# name <space> key +# Search the named socketmap for the specified key. +# +# [...] +# REPLY FORMAT +# The Postfix socketmap client requires that replies are not longer than +# 100000 characters (not including the netstring encapsulation). Replies +# must have the following form: +# +# OK <space> data +# The requested data was found. +# +# NOTFOUND <space> +# The requested data was not found. +# +# TEMP <space> reason +# +# TIMEOUT <space> reason +# +# PERM <space> reason +# The request failed. The reason, if non-empty, is descriptive +# text. + +class SocketmapsRequestHandler(socketserver.BaseRequestHandler): + api = None # Overwritten + + def handle(self): + buf = b'' + while not is_netstring_complete(buf): + chunk = self.request.recv(4096) + if not chunk: + return + buf += chunk + request, rest = decode_netstring(buf) + if rest: + return + try: + response = self.handle_request(request) + except: # pylint: disable=bare-except + logger.exception('Exception during request') + self.request.sendall(encode_netstring('TEMP Internal Error')) + return + loglevel = logging.INFO + if isinstance(response, tuple) and len(response) == 2: + response, loglevel = response + logger.log(loglevel, 'Request %s -> %s', repr(request), repr(response)) + self.request.sendall(encode_netstring(response)) + + def handle_request(self, request): + if ' ' not in request: + return 'PERM Malformed request', logging.WARNING + name, key = request.split(' ', 1) + if name == 'virtual': + # Postfix option virtual_alias_domains defaults to virtual_alias_maps, + # so we might get some domain requests. Since Postfix does not retry + # failing virtual_alias_domains lookups, PERM is the best option here. + if '@' not in key: + return 'PERM Key is not a valid mail address. Maybe fix the virtual_alias_domains setting.', logging.WARNING + key = key.strip().lower() + # Emulate Postfixes virtual(8) behaviour when used with ldap_table(5). + local, domain = key.split('@', 1) + if '+' in local: + local, extention = local.split('+', 1) + lookup_order = [ + (f'{local}+{extention}@{domain}', ''), + (f'{local}@{domain}', extention), + (f'{local}+{extention}', ''), + (f'{local}', extention), + (f'@{domain}', extention), + ] + else: + lookup_order = [ + (f'{local}@{domain}', ''), + (f'{local}', ''), + (f'@{domain}', ''), + ] + results = [] + for key, extention in lookup_order: + for address in self.api.get_aliases(key): + # All destinations must be full addresses, but we don't want to fail + # here, if any is malformed. + if '@' in address and extention: + local, domain = address.split('@', 1) + address = f'{local}+{extention}@{domain}' + results.append(address) + if results: + break + # Returning "OK " (without any data) is interperted as a protocol error + # and Postfix will retry the request. + if not results: + return 'NOTFOUND ' + # There is no way to escape commas in the result items and it would not + # matter, since Postfix internally processes lookup results as a + # comma-separated list. + return 'OK ' + ','.join(results) + return 'PERM Unknown request name', logging.WARNING + +def make_requesthandler(api): + class RequestHandler(SocketmapsRequestHandler): + pass + RequestHandler.api = api + return RequestHandler + +class FilenoUnixStreamServer(socketserver.UnixStreamServer): + def __init__(self, fd, RequestHandlerClass, bind_and_activate=True): + self.server_fd = fd + super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate) + + def server_bind(self): + self.socket.close() # UnixStreamServer.__init__ creates an unbound socket + self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM) + self.server_address = self.socket.getsockname() + +class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer): + pass + +def cleanup_unix_socket(path): + if not os.path.exists(path): + return + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + conn.connect(path) + except ConnectionRefusedError: + os.remove(path) + conn.close() + +class StdoutFilter(logging.Filter): + def filter(self, record): + return record.levelno <= logging.INFO + +@click.command(help='Socketmap proxy for integrating Postfix and other compatible MTAs with uffd SSO. Supports virtual alias lookups (request name "virtual").') +@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket') +@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)') +@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)') +@click.option('--api-key', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_KEY instead') +def main(socket_path, socket_fd, api_url, api_key): + if (socket_path is None and socket_fd is None) or \ + (socket_path is not None and socket_fd is not None): + raise click.ClickException('Either --socket-path or --socket-fd must be specified') + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.DEBUG) + stdout_handler.addFilter(StdoutFilter()) + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(logging.WARNING) + logger.setLevel(logging.INFO) + logger.addHandler(stdout_handler) + logger.addHandler(stderr_handler) + + api = UffdAPI(api_url, api_key) + RequestHandler = make_requesthandler(api) + if socket_path is not None: + cleanup_unix_socket(socket_path) + socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler).serve_forever() + else: + ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler).serve_forever() + +if __name__ == '__main__': + # Pylint does not seem to understand the click's decorators + # pylint: disable=unexpected-keyword-arg,no-value-for-parameter + main(auto_envvar_prefix='SERVER')