pax_global_header00006660000000000000000000000064137637406000014520gustar00rootroot0000000000000052 comment=0c46921ba2ff168d8400659b8e0a4f37abfa9498 kdcproxy-1.0.0/000077500000000000000000000000001376374060000133615ustar00rootroot00000000000000kdcproxy-1.0.0/.coveragerc000066400000000000000000000005351376374060000155050ustar00rootroot00000000000000[run] branch = True source = kdcproxy tests.py [paths] source = kdcproxy .tox/*/lib/python*/site-packages/kdcproxy [report] ignore_errors = False precision = 1 exclude_lines = pragma: no cover raise AssertionError raise NotImplementedError if 0: if False: if __name__ == .__main__.: if PY3 if not PY3 kdcproxy-1.0.0/.github/000077500000000000000000000000001376374060000147215ustar00rootroot00000000000000kdcproxy-1.0.0/.github/workflows/000077500000000000000000000000001376374060000167565ustar00rootroot00000000000000kdcproxy-1.0.0/.github/workflows/build.yml000066400000000000000000000014221376374060000205770ustar00rootroot00000000000000{ "name": "CI", "on": { "pull_request": null }, "jobs": { "linux": { "runs-on": "ubuntu-latest", "strategy": { "fail-fast": false, "matrix": { "python": [ "3.6", "3.7", "3.8", "3.9", ], }, }, "steps": [ { "uses": "actions/checkout@v2" }, { "uses": "actions/setup-python@v2", "with": { "python-version": "${{ matrix.python }}"}, }, { "run": "pip install tox" }, { "run": "tox" }, ], }, }, } kdcproxy-1.0.0/.gitignore000066400000000000000000000001331376374060000153460ustar00rootroot00000000000000*.pyc __pycache__ /.coverage /.tox /dist /build /MANIFEST /*.egg-info /.cache /.coverage.* kdcproxy-1.0.0/.project000066400000000000000000000005521376374060000150320ustar00rootroot00000000000000 kdcproxy org.python.pydev.PyDevBuilder org.python.pydev.pythonNature kdcproxy-1.0.0/.pydevproject000066400000000000000000000006621376374060000161040ustar00rootroot00000000000000 /${PROJECT_DIR_NAME}/kdcproxy python 3.0 python3.3 kdcproxy-1.0.0/COPYING000066400000000000000000000020711376374060000144140ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2013 Red Hat, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. kdcproxy-1.0.0/MANIFEST.in000066400000000000000000000001561376374060000151210ustar00rootroot00000000000000include README COPYING include tox.ini include setup.cfg include tests.py tests.krb5.conf include .coveragerc kdcproxy-1.0.0/README000066400000000000000000000151701376374060000142450ustar00rootroot00000000000000Welcome to kdcproxy! ==================== This package contains a WSGI module for proxying KDC requests over HTTP by following the [MS-KKDCP] protocol. It aims to be simple to deploy, with minimal configuration. Deploying kdcproxy ================== The kdcproxy module follows the standard WSGI protocol for deploying Python web applications. This makes configuration simple. Simply load up your favorite WSGI-enabled web server and point it to the module. For example, if you wish to use mod_wsgi, try something like this:: WSGIDaemonProcess kdcproxy processes=2 threads=15 maximum-requests=1000 \ display-name=%{GROUP} WSGIImportScript /usr/lib/python3.6/site-packages/kdcproxy/__init__.py \ process-group=kdcproxy application-group=kdcproxy WSGIScriptAlias /KdcProxy /usr/lib/python3.6/site-packages/kdcproxy/__init__.py WSGIScriptReloading Off Satisfy Any Order Deny,Allow Allow from all WSGIProcessGroup kdcproxy WSGIApplicationGroup kdcproxy [MS-KKDCP] suggests /KdcProxy as end point. For more information, see the documentation of your WSGI server. Configuring kdcproxy ==================== When kdcproxy receives a request, it needs to know where to proxy it to. This is the purpose of configuration: discovering where to send kerberos requests. One important note: where the underlying configuration does not specify TCP or UDP, both will be attempted. TCP will be attempted before UDP, hence setting `udp_preference_limit = 1` is not required for kdcproxy itself (though krb5 may still need it). This permits the use of longer timeouts and prevents possible lockouts when the KDC packets contain OTP token codes (which should preferably be sent to only one server). Automatic Configuration ----------------------- By default, no configuration is necessary. In this case, kdcproxy will use REALM DNS SRV record lookups to determine remote KDC locations. Master Configuration File ------------------------- If you wish to have more detailed configuration, the first place you can configure kdcproxy is the master configuration file. This file exists at the location specified in the environment variable KDCPROXY_CONFIG. If this variable is unspecified, the default locations are `/usr/local/etc/kdcproxy.conf` or `/etc/kdcproxy.conf`. This configuration file takes precedence over all other configuration modules. This file is an ini-style configuration with a special section **[global]**. Two parameters are available in this section: **configs** and **use_dns**. The **use_dns** allows you to enable or disable use of DNS SRV record lookups. The **configs** parameter allows you to load other configuration modules for finding configuration in other places. The configuration modules specified in here will have priority in the order listed. For instance, if you wished to read configuration from MIT libkrb5, you would set the following: [global] configs = mit Aside from the **[global]** section, you may also specify manual configuration for realms. In this case, each section is the name of the realm and the parameters are **kerberos** or **kpasswd**. These specify the locations of the remote servers for krb5 AS requests and kpasswd requests, respectively. For example: [EXAMPLE.COM] kerberos = kerberos+tcp://kdc.example.com:88 kpasswd = kpasswd+tcp://kpasswd.example.com:464 The realm configuration parameters may list multiple servers separated by a space. The order the realms are specified in will be respected by kdcproxy when forwarding requests. The port number is optional. Possible schemes are: * kerberos:// * kerberos+tcp:// * kerberos+udp:// * kpasswd:// * kpasswd+tcp:// * kpasswd+udp:// MIT libkrb5 ----------- If you load the **mit** config module in the master configuration file, kdcproxy will also read the config using libkrb5 (usually /etc/krb5.conf). If this module is used, kdcproxy will respect the DNS settings from the **[libdefaults]** section and the realm configuration from the **[realms]** section. For more information, see the documentation for MIT's krb5.conf. Configuration reloading ----------------------- kdcproxy reads its configurtion files when package is imported and a global WSGI application object is instantiated. For now kdcproxy does neither monitor its configuration files for changes nor supports runtime updates. You have to restart the WSGI process to make modification available. With Apache HTTP and mod_wsgi, a reload of the server also restarts all WSGI daemons. Configuring a client for kdcproxy ================================= HTTPS proxy support is available since Kerberos 5 release 1.13. Some vendors have backported the feature to older versions of krb5, too. In order to use a HTTPS proxy, simply point the kdc and kpasswd options to the proxy URL like explained in [HTTPS proxy] configuration guide. Your ``/etc/krb5.conf`` may look like this:: [libdefaults] default_realm = EXAMPLE.COM [realms] EXAMPLE.COM = { http_anchors = FILE:/etc/krb5/cacert.pem kdc = https://kerberos.example.com/KdcProxy kpasswd_server = https://kerberos.example.com/KdcProxy } To debug the feature, set the environment variable ``KRB5_TRACE`` to ``/dev/stdout``. When the feature is correctly configured, you should see two POST requests in the access log of the WSGI server and a line containing ``Sending HTTPS request`` in the debug output of kinit:: $ env KRB5_TRACE=/dev/stdout kinit user [1037] 1431509096.26305: Getting initial credentials for user@EXAMPLE.COM [1037] 1431509096.26669: Sending request (169 bytes) to EXAMPLE.COM [1037] 1431509096.26939: Resolving hostname kerberos.example.com [1037] 1431509096.34377: TLS certificate name matched "kerberos.example.com" [1037] 1431509096.38791: Sending HTTPS request to https 128.66.0.1:443 [1037] 1431509096.46387: Received answer (344 bytes) from https 128.66.0.1:443 [1037] 1431509096.46411: Terminating TCP connection to https 128.66.0.1:443 ... If kinit still connects to port 88/TCP or port 88/UDP, then System Security Services Daemon's Kerberos locator plugin might override the settings in /etc/krb5.conf. With the environment variable ``SSSD_KRB5_LOCATOR_DEBUG=1``, kinit and sssd_krb5_locator_plugin print out additional debug information. To disable the KDC locator feature, edit ``/etc/sssd/sssd.conf`` and set ``krb5_use_kdcinfo`` to False: [domain/example.com] krb5_use_kdcinfo = False Don't forget to restart SSSD! [MS-KKDCP]: http://msdn.microsoft.com/en-us/library/hh553774.aspx [HTTPS Proxy]: http://web.mit.edu/kerberos/krb5-current/doc/admin/https.html kdcproxy-1.0.0/README.md000077700000000000000000000000001376374060000155132READMEustar00rootroot00000000000000kdcproxy-1.0.0/kdcproxy/000077500000000000000000000000001376374060000152245ustar00rootroot00000000000000kdcproxy-1.0.0/kdcproxy/__init__.py000066400000000000000000000246001376374060000173370ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import errno import io import logging import select import socket import struct import sys import time import kdcproxy.codec as codec from kdcproxy.config import MetaResolver if sys.version_info.major >= 3: # Python 3.x import http.client as httplib import urllib.parse as urlparse else: import httplib import urlparse class HTTPException(Exception): def __init__(self, code, msg, headers=[]): headers = list(filter(lambda h: h[0] != 'Content-Length', headers)) if 'Content-Type' not in dict(headers): headers.append(('Content-Type', 'text/plain; charset=utf-8')) if sys.version_info.major == 3 and isinstance(msg, str): msg = bytes(msg, "utf-8") headers.append(('Content-Length', str(len(msg)))) super(HTTPException, self).__init__(code, msg, headers) self.code = code self.message = msg self.headers = headers def __str__(self): return "%d %s" % (self.code, httplib.responses[self.code]) class Application: MAX_LENGTH = 128 * 1024 SOCKTYPES = { "tcp": socket.SOCK_STREAM, "udp": socket.SOCK_DGRAM, } def __init__(self): self.__resolver = MetaResolver() def __await_reply(self, pr, rsocks, wsocks, timeout): extra = 0 read_buffers = {} while (timeout + extra) > time.time(): if not wsocks and not rsocks: break r, w, x = select.select(rsocks, wsocks, rsocks + wsocks, (timeout + extra) - time.time()) for sock in x: sock.close() try: rsocks.remove(sock) except ValueError: pass try: wsocks.remove(sock) except ValueError: pass for sock in w: try: if self.sock_type(sock) == socket.SOCK_DGRAM: # If we proxy over UDP, remove the 4-byte length # prefix since it is TCP only. sock.sendall(pr.request[4:]) else: sock.sendall(pr.request) extra = 10 # New connections get 10 extra seconds except Exception as e: logging.warning("Conection broken while writing (%s)", e) continue rsocks.append(sock) wsocks.remove(sock) for sock in r: try: reply = self.__handle_recv(sock, read_buffers) except Exception as e: logging.warning("Connection broken while reading (%s)", e) if self.sock_type(sock) == socket.SOCK_STREAM: # Remove broken TCP socket from readers rsocks.remove(sock) else: if reply is not None: return reply return None def __handle_recv(self, sock, read_buffers): if self.sock_type(sock) == socket.SOCK_DGRAM: # For UDP sockets, recv() returns an entire datagram # package. KDC sends one datagram as reply. reply = sock.recv(1048576) # If we proxy over UDP, we will be missing the 4-byte # length prefix. So add it. reply = struct.pack("!I", len(reply)) + reply return reply # TCP is a different story. The reply must be buffered until the full # answer is accumulated. buf = read_buffers.get(sock) if buf is None: read_buffers[sock] = buf = io.BytesIO() part = sock.recv(1048576) if not part: # EOF received. Return any incomplete data we have on the theory # that a decode error is more apparent than silent failure. The # client will fail faster, at least. read_buffers.pop(sock) reply = buf.getvalue() return reply # Data received, accumulate it in a buffer. buf.write(part) reply = buf.getvalue() if len(reply) < 4: # We don't have the length yet. return None # Got enough data to check if we have the full package. (length, ) = struct.unpack("!I", reply[0:4]) if length + 4 == len(reply): read_buffers.pop(sock) return reply return None def __filter_addr(self, addr): if addr[0] not in (socket.AF_INET, socket.AF_INET6): return False if addr[1] not in (socket.SOCK_STREAM, socket.SOCK_DGRAM): return False if addr[2] not in (socket.IPPROTO_TCP, socket.IPPROTO_UDP): return False return True def sock_type(self, sock): try: return sock.type & ~socket.SOCK_NONBLOCK except AttributeError: return sock.type def __call__(self, env, start_response): try: # Validate the method method = env["REQUEST_METHOD"].upper() if method != "POST": raise HTTPException(405, "Method not allowed (%s)." % method) # Parse the request length = -1 try: length = int(env["CONTENT_LENGTH"]) except KeyError: pass except ValueError: pass if length < 0: raise HTTPException(411, "Length required.") if length > self.MAX_LENGTH: raise HTTPException(413, "Request entity too large.") try: pr = codec.decode(env["wsgi.input"].read(length)) except codec.ParsingError as e: raise HTTPException(400, e.message) # Find the remote proxy servers = self.__resolver.lookup( pr.realm, kpasswd=isinstance(pr, codec.KPASSWDProxyRequest) ) if not servers: raise HTTPException(503, "Can't find remote (%s)." % pr) # Contact the remote server reply = None wsocks = [] rsocks = [] for server in map(urlparse.urlparse, servers): # Enforce valid, supported URIs scheme = server.scheme.lower().split("+", 1) if scheme[0] not in ("kerberos", "kpasswd"): continue if len(scheme) > 1 and scheme[1] not in ("tcp", "udp"): continue # Do the DNS lookup try: port = server.port if port is None: port = scheme[0] addrs = socket.getaddrinfo(server.hostname, port) except socket.gaierror: continue # Sort addresses so that we get TCP first. # # Stick a None address on the end so we can get one # more attempt after all servers have been contacted. addrs = tuple(sorted(filter(self.__filter_addr, addrs), key=lambda a: a[2])) for addr in addrs + (None,): if addr is not None: # Bypass unspecified socktypes if len(scheme) > 1 and \ addr[1] != self.SOCKTYPES[scheme[1]]: continue # Create the socket sock = socket.socket(*addr[:3]) sock.setblocking(0) # Connect try: # In Python 2.x, non-blocking connect() throws # socket.error() with errno == EINPROGRESS. In # Python 3.x, it throws io.BlockingIOError(). sock.connect(addr[4]) except socket.error as e: if e.errno != errno.EINPROGRESS: sock.close() continue except io.BlockingIOError: pass wsocks.append(sock) # Resend packets to UDP servers for sock in tuple(rsocks): if self.sock_type(sock) == socket.SOCK_DGRAM: wsocks.append(sock) rsocks.remove(sock) # Call select() timeout = time.time() + (15 if addr is None else 2) reply = self.__await_reply(pr, rsocks, wsocks, timeout) if reply is not None: break if reply is not None: break for sock in rsocks + wsocks: sock.close() if reply is None: raise HTTPException(503, "Remote unavailable (%s)." % pr) # Return the result to the client raise HTTPException(200, codec.encode(reply), [("Content-Type", "application/kerberos")]) except HTTPException as e: start_response(str(e), e.headers) return [e.message] application = Application() kdcproxy-1.0.0/kdcproxy/codec.py000066400000000000000000000105451376374060000166600ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import struct from kdcproxy import parse_pyasn1 as asn1mod from kdcproxy.exceptions import ParsingError class ProxyRequest(object): TYPE = None OFFSET = 4 @classmethod def parse(cls, data): request, realm, _ = asn1mod.decode_proxymessage(data) # Check the length of the whole request message. (length, ) = struct.unpack("!I", request[0:4]) if length + 4 != len(request): raise ParsingError("Invalid request length.") for subcls in cls.__subclasses__(): try: return subcls.parse_request(realm, request) except ParsingError: pass raise ParsingError("Invalid request.") @classmethod def parse_request(cls, realm, request): pretty_name = asn1mod.try_decode(request[cls.OFFSET:], cls.TYPE) return cls(realm, request, pretty_name) def __init__(self, realm, request, pretty_name): self.realm = realm self.request = request self.pretty_name = pretty_name def __str__(self): return "%s %s (%d bytes)" % (self.realm, self.pretty_name, len(self.request) - 4) class TGSProxyRequest(ProxyRequest): TYPE = asn1mod.TGSREQ class ASProxyRequest(ProxyRequest): TYPE = asn1mod.ASREQ class KPASSWDProxyRequest(ProxyRequest): TYPE = asn1mod.APREQ OFFSET = 10 @classmethod def parse_request(cls, realm, request): # Check the length count in the password change request, assuming it # actually is a password change request. It should be the length of # the rest of the request, including itself. (length, ) = struct.unpack("!H", request[4:6]) if length != len(request) - 4: raise ParsingError("Parsing the KPASSWD request length failed.") # Check the version number in the password change request, assuming it # actually is a password change request. Officially we support version # 1, but 0xff80 is used for set-password, so try to accept that, too. (version, ) = struct.unpack("!H", request[6:8]) if version != 0x0001 and version != 0xff80: raise ParsingError("The KPASSWD request is an incorrect version.") # Read the length of the AP-REQ part of the change request. There # should be at least that may bytes following this length, since the # rest of the request is the KRB-PRIV message. (length, ) = struct.unpack("!H", request[8:10]) if length > len(request) - 10: raise ParsingError("The KPASSWD request appears to be truncated.") # See if the tag looks like an AP request, which would look like the # start of a password change request. The rest of it should be a # KRB-PRIV message. asn1mod.try_decode(request[10:length + 10], asn1mod.APREQ) asn1mod.try_decode(request[length + 10:], asn1mod.KRBPriv) self = cls(realm, request, "KPASSWD-REQ") self.version = version return self def __str__(self): tmp = super(KPASSWDProxyRequest, self).__str__() tmp += " (version 0x%04x)" % self.version return tmp def decode(data): return ProxyRequest.parse(data) def encode(data): return asn1mod.encode_proxymessage(data) kdcproxy-1.0.0/kdcproxy/config/000077500000000000000000000000001376374060000164715ustar00rootroot00000000000000kdcproxy-1.0.0/kdcproxy/config/__init__.py000066400000000000000000000127331376374060000206100ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import importlib import itertools import logging import os try: # Python 3.x import configparser except ImportError: # Python 2.x import ConfigParser as configparser import dns.rdatatype import dns.resolver class IResolver(object): def lookup(self, realm, kpasswd=False): "Returns an iterable of remote server URIs." raise NotImplementedError() class IConfig(IResolver): def use_dns(self): "Returns whether or not DNS should be used. Returns None if not set." raise NotImplementedError() class KDCProxyConfig(IConfig): GLOBAL = "global" default_filenames = ["/usr/local/etc/kdcproxy.conf", "/etc/kdcproxy.conf"] def __init__(self, filenames=None): self.__cp = configparser.ConfigParser() if filenames is None: filenames = os.environ.get("KDCPROXY_CONFIG", None) if filenames is None: filenames = self.default_filenames try: self.__cp.read(filenames) except configparser.Error: logging.error("Unable to read config file(s): %s", filenames) try: mod = self.__cp.get(self.GLOBAL, "configs") try: importlib.import_module("kdcproxy.config." + mod) except ImportError as e: logging.log(logging.ERROR, "Error reading config: %s" % e) except configparser.Error: pass def lookup(self, realm, kpasswd=False): service = "kpasswd" if kpasswd else "kerberos" try: servers = self.__cp.get(realm, service) return map(lambda s: s.strip(), servers.strip().split(" ")) except configparser.Error: return () def use_dns(self): try: return self.__cp.getboolean(self.GLOBAL, "use_dns") except configparser.Error: return None class DNSResolver(IResolver): def __dns(self, service, protocol, realm): query = '_%s._%s.%s' % (service, protocol, realm) try: reply = dns.resolver.query(query, dns.rdatatype.SRV) except dns.exception.DNSException: reply = [] # FIXME: pay attention to weighting, preferably while still # arriving at the same answer every time, for the sake of # clients that are having longer conversations with servers. reply = sorted(reply, key=lambda r: r.priority) for entry in reply: host = str(entry.target).rstrip('.') yield (host, entry.port) def lookup(self, realm, kpasswd=False): service = "kpasswd" if kpasswd else "kerberos" for protocol in ("tcp", "udp"): servers = tuple(self.__dns(service, protocol, realm)) if not servers and kpasswd: servers = self.__dns("kerberos-adm", protocol, realm) for host, port in servers: yield "%s://%s:%d" % (service, host, port) class MetaResolver(IResolver): SCHEMES = ("kerberos", "kerberos+tcp", "kerberos+udp", "kpasswd", "kpasswd+tcp", "kpasswd+udp", "http", "https",) def __init__(self): self.__resolvers = [] for i in itertools.count(0): allsub = IConfig.__subclasses__() if not i < len(allsub): break try: self.__resolvers.append(allsub[i]()) except Exception as e: fmt = (allsub[i], repr(e)) logging.log(logging.WARNING, "Error instantiating %s due to %s" % fmt) assert self.__resolvers # See if we should use DNS dns = None for cfg in self.__resolvers: tmp = cfg.use_dns() if tmp is not None: dns = tmp break # If DNS is enabled, append the DNSResolver at the end if dns in (None, True): self.__resolvers.append(DNSResolver()) def __unique(self, items): "Removes duplicate items from an iterable while maintaining order." items = tuple(items) unique = set(items) for item in items: if item in unique: unique.remove(item) yield item def lookup(self, realm, kpasswd=False): for r in self.__resolvers: servers = tuple(self.__unique(r.lookup(realm, kpasswd))) if servers: return servers return () kdcproxy-1.0.0/kdcproxy/config/mit.py000066400000000000000000000241131376374060000176350ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import ctypes import sys try: import urllib.parse as urlparse except ImportError: # pragma: no cover import urlparse from kdcproxy.config import IConfig class KRB5Error(Exception): pass PY3 = sys.version_info[0] == 3 try: LIBKRB5 = ctypes.CDLL('libkrb5.so.3') except OSError as e: # pragma: no cover LIBKRB5 = e else: class c_text_p(ctypes.c_char_p): # noqa """A c_char_p variant that can handle UTF-8 text""" @classmethod def from_param(cls, value): if value is None: return None if PY3 and isinstance(value, str): return value.encode('utf-8') elif not PY3 and isinstance(value, unicode): # noqa return value.encode('utf-8') elif not isinstance(value, bytes): raise TypeError(value) else: return value @property def text(self): value = self.value if value is None: return None elif not isinstance(value, str): return value.decode('utf-8') return value class _krb5_context(ctypes.Structure): # noqa """krb5/krb5.h struct _krb5_context""" __slots__ = () _fields_ = [] class _profile_t(ctypes.Structure): # noqa """profile.h struct _profile_t""" __slots__ = () _fields_ = [] def krb5_errcheck(result, func, arguments): """Error checker for krb5_error return value""" if result != 0: raise KRB5Error(result, func.__name__, arguments) krb5_context = ctypes.POINTER(_krb5_context) profile_t = ctypes.POINTER(_profile_t) iter_p = ctypes.c_void_p krb5_error = ctypes.c_int32 krb5_init_context = LIBKRB5.krb5_init_context krb5_init_context.argtypes = (ctypes.POINTER(krb5_context), ) krb5_init_context.restype = krb5_error krb5_init_context.errcheck = krb5_errcheck krb5_free_context = LIBKRB5.krb5_free_context krb5_free_context.argtypes = (krb5_context, ) krb5_free_context.restype = None krb5_get_profile = LIBKRB5.krb5_get_profile krb5_get_profile.argtypes = (krb5_context, ctypes.POINTER(profile_t)) krb5_get_profile.restype = krb5_error krb5_get_profile.errcheck = krb5_errcheck profile_release = LIBKRB5.profile_release profile_release.argtypes = (profile_t, ) profile_release.restype = None profile_iterator_create = LIBKRB5.profile_iterator_create profile_iterator_create.argtypes = (profile_t, ctypes.POINTER(c_text_p), ctypes.c_int, ctypes.POINTER(iter_p)) profile_iterator_create.restype = krb5_error profile_iterator_create.errcheck = krb5_errcheck profile_iterator_free = LIBKRB5.profile_iterator_free profile_iterator_free.argtypes = (ctypes.POINTER(iter_p), ) profile_iterator_free.restype = None profile_iterator = LIBKRB5.profile_iterator profile_iterator.argtypes = (ctypes.POINTER(iter_p), ctypes.POINTER(c_text_p), ctypes.POINTER(c_text_p)) profile_iterator.restype = krb5_error profile_iterator.errcheck = krb5_errcheck profile_get_boolean = LIBKRB5.profile_get_boolean profile_get_boolean.argtypes = (profile_t, c_text_p, c_text_p, c_text_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)) profile_get_boolean.restype = krb5_error profile_get_boolean.errcheck = krb5_errcheck class KRB5Profile: class Iterator: def __init__(self, profile, *args): # Convert string arguments to UTF8 bytes args = [c_text_p.from_param(arg) for arg in args] args.append(None) # Create array array = c_text_p * len(args) self.__path = array(*args) # Call the function self.__iterator = iter_p() profile_iterator_create(profile, self.__path, 1, ctypes.byref(self.__iterator)) def __iter__(self): return self def __next__(self): try: name = c_text_p() value = c_text_p() profile_iterator(ctypes.byref(self.__iterator), ctypes.byref(name), ctypes.byref(value)) if not name.value: raise KRB5Error return name.text, value.text except KRB5Error: profile_iterator_free(ctypes.byref(self.__iterator)) self.__iterator = None raise StopIteration() def __del__(self): if self.__iterator: # pragma: no cover profile_iterator_free(ctypes.byref(self.__iterator)) self.__iterator = None # Handle iterator API change if not PY3: next = __next__ def __init__(self): self.__context = self.__profile = None if isinstance(LIBKRB5, Exception): # pragma: no cover raise LIBKRB5 context = krb5_context() krb5_init_context(ctypes.byref(context)) self.__context = context profile = profile_t() krb5_get_profile(context, ctypes.byref(profile)) self.__profile = profile def __enter__(self): return self def __exit__(self, type, value, traceback): if self.__context: krb5_free_context(self.__context) self.__context = None if self.__profile: profile_release(self.__profile) self.__profile = None def __del__(self): self.__exit__(None, None, None) def __getitem__(self, name): return self.section(name) def get_bool(self, name, subname=None, subsubname=None, default=False): val = ctypes.c_int(1) profile_get_boolean(self.__profile, name, subname, subsubname, int(default), ctypes.byref(val)) return bool(val.value) def section(self, *args): output = [] for k, v in KRB5Profile.Iterator(self.__profile, *args): if v is None: tmp = args + (k,) output.append((k, self.section(*tmp))) else: output.append((k, v)) return output class MITConfig(IConfig): CONFIG_KEYS = ('kdc', 'admin_server', 'kpasswd_server') def __init__(self, *args, **kwargs): self.__config = {} with KRB5Profile() as prof: # Load DNS setting self.__config["dns"] = prof.get_bool("libdefaults", "dns_fallback", default=True) if "dns_lookup_kdc" in dict(prof.section("libdefaults")): self.__config["dns"] = prof.get_bool("libdefaults", "dns_lookup_kdc", default=True) # Load all configured realms self.__config["realms"] = {} for realm, values in prof.section("realms"): rconf = self.__config["realms"].setdefault(realm, {}) for server, hostport in values: if server not in self.CONFIG_KEYS: continue parsed = urlparse.urlparse(hostport) if parsed.hostname is None: scheme = {'kdc': 'kerberos'}.get(server, 'kpasswd') parsed = urlparse.urlparse(scheme + "://" + hostport) if parsed.port is not None and server == 'admin_server': hostport = hostport.split(':', 1)[0] parsed = urlparse.urlparse("kpasswd://" + hostport) rconf.setdefault(server, []).append(parsed.geturl()) def lookup(self, realm, kpasswd=False): rconf = self.__config.get("realms", {}).get(realm, {}) if kpasswd: servers = list(rconf.get('kpasswd_server', [])) servers.extend(rconf.get('admin_server', [])) else: servers = rconf.get('kdc', []) return tuple(servers) def use_dns(self, default=True): return self.__config["dns"] if __name__ == "__main__": from pprint import pprint with KRB5Profile() as prof: conf = prof.section() assert conf pprint(conf) conf = MITConfig() for realm in sys.argv[1:]: kdc = conf.lookup(realm) assert kdc print("\n%s (kdc): " % realm) pprint(kdc) kpasswd = conf.lookup(realm, True) assert kpasswd print("\n%s (kpasswd): " % realm) pprint(kpasswd) kdcproxy-1.0.0/kdcproxy/exceptions.py000066400000000000000000000024421376374060000177610ustar00rootroot00000000000000# Copyright (C) 2017, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. class ParsingError(Exception): def __init__(self, message): super(ParsingError, self).__init__(message) self.message = message class ASN1ParsingError(ParsingError): pass kdcproxy-1.0.0/kdcproxy/parse_pyasn1.py000066400000000000000000000076501376374060000202130ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from pyasn1 import error from pyasn1.codec.der import decoder, encoder from pyasn1.type import char, namedtype, tag, univ from kdcproxy.exceptions import ASN1ParsingError, ParsingError class ProxyMessageKerberosMessage(univ.OctetString): tagSet = univ.OctetString.tagSet.tagExplicitly( tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0) ) class ProxyMessageTargetDomain(char.GeneralString): tagSet = char.GeneralString.tagSet.tagExplicitly( tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 1) ) class ProxyMessageDCLocateHint(univ.Integer): tagSet = univ.Integer.tagSet.tagExplicitly( tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 2) ) class ProxyMessage(univ.Sequence): pretty_name = 'KDC-PROXY-MESSAGE' componentType = namedtype.NamedTypes( namedtype.NamedType('message', ProxyMessageKerberosMessage()), namedtype.OptionalNamedType('realm', ProxyMessageTargetDomain()), namedtype.OptionalNamedType('flags', ProxyMessageDCLocateHint()) ) class ASREQ(univ.Sequence): pretty_name = 'AS-REQ' tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 10) ) class TGSREQ(univ.Sequence): pretty_name = 'TGS-REQ' tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 12) ) class APREQ(univ.Sequence): pretty_name = 'AP-REQ' tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 14) ) class KRBPriv(univ.Sequence): pretty_name = 'KRBPRiv' tagSet = univ.Sequence.tagSet.tagExplicitly( tag.Tag(tag.tagClassApplication, tag.tagFormatSimple, 21) ) def decode_proxymessage(data): try: req, tail = decoder.decode(data, asn1Spec=ProxyMessage()) except error.PyAsn1Error as e: raise ASN1ParsingError(e) if tail: raise ParsingError("Invalid request.") message = req.getComponentByName('message').asOctets() realm = req.getComponentByName('realm') if realm.hasValue(): try: # Python 3.x realm = str(realm, "utf-8") except TypeError: # Python 2.x realm = str(realm) else: realm = None flags = req.getComponentByName('flags') flags = int(flags) if flags.hasValue() else None return message, realm, flags def encode_proxymessage(data): rep = ProxyMessage() rep.setComponentByName('message', data) return encoder.encode(rep) def try_decode(data, cls): try: req, tail = decoder.decode(data, asn1Spec=cls()) except error.PyAsn1Error as e: raise ASN1ParsingError(e) if tail: raise ParsingError("%s request has %d extra bytes." % (cls.pretty_name, len(tail))) return cls.pretty_name kdcproxy-1.0.0/setup.cfg000066400000000000000000000003651376374060000152060ustar00rootroot00000000000000[bdist_wheel] universal = 1 [aliases] # requires setuptools and wheel package # dnf install python3-setuptools python3-wheel packages = clean --all egg_info bdist_wheel sdist --format=zip sdist --format=gztar release = packages register upload kdcproxy-1.0.0/setup.py000066400000000000000000000052501376374060000150750ustar00rootroot00000000000000# Copyright (C) 2013, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import os from setuptools import setup install_requires = [ 'pyasn1', 'dnspython' ] extras_require = { "tests": ["pytest", "coverage", "WebTest"], "test_pep8": ['flake8', 'flake8-import-order', 'pep8-naming'] } def read(fname): fname = os.path.join(os.path.dirname(__file__), fname) with open(fname) as f: return f.read() # in chronological order authors = { "Nalin Dahyabhai": "nalin@redhat.com", "Nathaniel McCallum": "npmccallum@redhat.com", "Christian Heimes": "cheimes@redhat.com", "Robbie Harwood": "rharwood@redhat.com", } setup( name="kdcproxy", version="1.0.0", author=", ".join(authors.keys()), author_email=", ".join(authors.values()), description=("A kerberos KDC HTTP proxy WSGI module."), license="MIT", keywords="krb5 proxy http https kerberos", url="http://github.com/latchset/kdcproxy", packages=['kdcproxy', 'kdcproxy.config'], long_description=read('README'), install_requires=install_requires, extras_require=extras_require, classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: Proxy Servers", ], ) kdcproxy-1.0.0/tests.krb5.conf000066400000000000000000000006171376374060000162400ustar00rootroot00000000000000[libdefaults] default_realm = KDCPROXY.TEST dns_lookup_realm = false dns_lookup_kdc = false [realms] KDCPROXY.TEST = { kdc = k1.kdcproxy.test.:88 kdc = k2.kdcproxy.test.:1088 admin_server = adm.kdcproxy.test.:749 kpasswd_server = adm.kdcproxy.test.:1749 default_domain = kdcproxy.test } [domain_realm] .kdcproxy.test = KDCPROXY.TEST kdcproxy.test = KDCPROXY.TEST kdcproxy-1.0.0/tests.py000066400000000000000000000317431376374060000151050ustar00rootroot00000000000000# Copyright (C) 2015, Red Hat, Inc. # All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import os import unittest from base64 import b64decode try: from unittest import mock except ImportError: # pragma: no cover import mock from dns.rdataclass import IN as RDCLASS_IN from dns.rdatatype import SRV as RDTYPE_SRV from dns.rdtypes.IN.SRV import SRV try: from webtest import TestApp as WebTestApp except ImportError: print("webtest not installed! Tests will be skipped") WebTestApp = "skip" import kdcproxy from kdcproxy import codec from kdcproxy import config from kdcproxy.config import mit HERE = os.path.dirname(os.path.abspath(__file__)) KRB5_CONFIG = os.path.join(HERE, 'tests.krb5.conf') @unittest.skipIf(WebTestApp == "skip", "webtest not installed") class KDCProxyWSGITests(unittest.TestCase): addrinfo = [ (2, 1, 6, '', ('128.66.0.2', 88)), (2, 2, 17, '', ('128.66.0.2', 88)), (2, 3, 0, '', ('128.66.0.2', 88)) ] def setUp(self): # noqa self.app = kdcproxy.Application() self.await_reply = self.app._Application__await_reply = mock.Mock() self.await_reply.return_value = b'RESPONSE' self.resolver = self.app._Application__resolver = mock.Mock() self.resolver.lookup.return_value = ["kerberos://k1.kdcproxy.test.:88"] self.tapp = WebTestApp(self.app) def post(self, body, expect_errors=False): return self.tapp.post( '/', body, [("Content-Type", "application/kerberos")], expect_errors=expect_errors ) def assert_response(self, response): self.assertEqual(response.status_code, 200) self.assertEqual(response.content_type, 'application/kerberos') self.assertEqual(response.body, b'0\x0c\xa0\n\x04\x08RESPONSE') def test_get(self): r = self.tapp.get('/', expect_errors=True) self.assertEqual(r.status_code, 405) self.assertEqual(r.status, '405 Method Not Allowed') self.assertEqual(r.text, 'Method not allowed (GET).') @mock.patch('socket.getaddrinfo', return_value=addrinfo) @mock.patch('socket.socket') def test_post_asreq(self, m_socket, m_getaddrinfo): response = self.post(KDCProxyCodecTests.asreq1) self.assert_response(response) self.resolver.lookup.assert_called_once_with('FREEIPA.LOCAL', kpasswd=False) m_getaddrinfo.assert_called_once_with('k1.kdcproxy.test.', 88) m_socket.assert_called_once_with(2, 1, 6) m_socket.return_value.connect.assert_called_once_with( ('128.66.0.2', 88) ) @mock.patch('socket.getaddrinfo', return_value=addrinfo) @mock.patch('socket.socket') def test_post_kpasswd(self, m_socket, m_getaddrinfo): response = self.post(KDCProxyCodecTests.kpasswdreq) self.assert_response(response) self.resolver.lookup.assert_called_once_with('FREEIPA.LOCAL', kpasswd=True) m_getaddrinfo.assert_called_once_with('k1.kdcproxy.test.', 88) m_socket.assert_called_once_with(2, 1, 6) m_socket.return_value.connect.assert_called_once_with( ('128.66.0.2', 88) ) def test_no_server(self): self.resolver.lookup.reset_mock() self.resolver.lookup.return_value = [] response = self.post(KDCProxyCodecTests.asreq1, True) self.resolver.lookup.assert_called_once_with('FREEIPA.LOCAL', kpasswd=False) self.assertEqual(response.status_code, 503) self.resolver.lookup.reset_mock() self.resolver.lookup.return_value = [] response = self.post(KDCProxyCodecTests.kpasswdreq, True) self.resolver.lookup.assert_called_once_with('FREEIPA.LOCAL', kpasswd=True) self.assertEqual(response.status_code, 503) def decode(data): data = data.replace(b'\\n', b'') data = data.replace(b' ', b'') return b64decode(data) class KDCProxyCodecTests(unittest.TestCase): realm = 'FREEIPA.LOCAL' asreq1 = decode(b""" MIHEoIGwBIGtAAAAqWqBpjCBo6EDAgEFogMCAQqjDjAMMAqhBAICAJWiAgQApIGGMIGDo AcDBQBAAAAQoRIwEKADAgEBoQkwBxsFYWRtaW6iDxsNRlJFRUlQQS5MT0NBTKMiMCCgAw IBAqEZMBcbBmtyYnRndBsNRlJFRUlQQS5MT0NBTKURGA8yMDE1MDUxNDEwNDIzOFqnBgI EEchjtagUMBICARICARECARACARcCARkCARqhDxsNRlJFRUlQQS5MT0NBTA== """) asreq2 = decode(b""" MIIBJaCCARAEggEMAAABCGqCAQQwggEAoQMCAQWiAwIBCqNrMGkwDaEEAgIAhaIFBANNS VQwTKEDAgECokUEQzBBoAMCARKiOgQ48A25MkXWM1ZrTvaYMJcbFX7Hp7JW11omIwqOQd SSGKVZ9mzYLuL19RRhX9xrXbQS0klXRVgRWHMwCqEEAgIAlaICBACkgYYwgYOgBwMFAEA AABChEjAQoAMCAQGhCTAHGwVhZG1pbqIPGw1GUkVFSVBBLkxPQ0FMoyIwIKADAgECoRkw FxsGa3JidGd0Gw1GUkVFSVBBLkxPQ0FMpREYDzIwMTUwNTE0MTA0MjM4WqcGAgRXSy38q BQwEgIBEgIBEQIBEAIBFwIBGQIBGqEPGw1GUkVFSVBBLkxPQ0FM """) tgsreq = decode(b""" MIIDxaCCA7AEggOsAAADqGyCA6QwggOgoQMCAQWiAwIBDKOCAxowggMWMIICL6EDAgEBo oICJgSCAiJuggIeMIICGqADAgEFoQMCAQ6iBwMFAAAAAACjggFGYYIBQjCCAT6gAwIBBa EPGw1GUkVFSVBBLkxPQ0FMoiIwIKADAgECoRkwFxsGa3JidGd0Gw1GUkVFSVBBLkxPQ0F Mo4IBADCB/aADAgESoQMCAQGigfAEge3ODJahLoTF0Xl+DeWdBqy79TSJv6+L23WEuBQi CnvmiLGxFhe/zuW6LN9O0Ekb3moX4qFKW7bF/gw0GuuMemkIjLaZ2M5mZiaQQ456fU5dA +ntLs8C407x3TVu68TM1aDvQgyKVpQgTdjxTZVmdinueIxOQ5z2nTIyjA9W94umGrPIcc sOfwvTEqyVpXrQcXr2tj/o/WcDLh/hHMhlHRBr9uLBLdVh2xR1yRbwe/n1UsXckxRi/A/ +YgGSW7YDFBXij9RpGaE0bpa8e4u/EkcQEgu66nwVrfNs/TvsTJ1VnL5LpicDZvXzm0gO y3OkgbowgbegAwIBEqKBrwSBrIWE4ylyvY7JpiGCJQJKpv8sd3tFK054UTDvs1UuBAiWz IwNOddrdb4YKKGC/ce3e/sX+CBvISNPsOqX4skXK0gnMCJaCU6H1QKNeJu1TJm8GxPQ28 1B8ZrCnv9Vzput0YIXAFK1eoAfe9qnJVktLL9uwYfV7D4GDU634KtEvPeDTBVMmTVXpUR 5HIXiE4Qw6bON74Ssg4n8YDoO0ZXdOIOOUh1+soMoUzjg2XIwgeChBAICAIiigdcEgdSg gdEwgc6hFzAVoAMCARChDgQMmmZqel1e6bYuSZBxooGyMIGvoAMCARKigacEgaQwxX40v E6S6aNej2Siwkr/JA/70sbSoR8JrET9q6DW0rtawnOzKGYYSNEs8GLWgeSQaqIKuWXDuT R898vv3RYY4nn1wSNQFFSOHxaVqdRzY55Z7HbO7OPTyQhPI31f1m8Tuxl7kpMM74Yhypj iQCe8RHrJUyCQay8AonQY11pRvRlwzcnbrB5GhegVmtp1Qhtv0Lj//yLHZ4MdVh5FV2N2 8odz7KR2MHSgBwMFAEABAACiDxsNRlJFRUlQQS5MT0NBTKMnMCWgAwIBAaEeMBwbBGh0d HAbFGlwYXNydi5mcmVlaXBhLmxvY2FspREYDzIwMTUwNTE0MTA0MjM4WqcGAgRVUzCzqB QwEgIBEgIBEQIBEAIBFwIBGQIBGqEPGw1GUkVFSVBBLkxPQ0FM """) kpasswdreq = decode(b""" MIICeKCCAmMEggJfAAACWwJbAAECAm6CAf4wggH6oAMCAQWhAwIBDqIHAwUAAAAAAKOCA UFhggE9MIIBOaADAgEFoQ8bDUZSRUVJUEEuTE9DQUyiHTAboAMCAQGhFDASGwZrYWRtaW 4bCGNoYW5nZXB3o4IBADCB/aADAgESoQMCAQGigfAEge3swqU5Z7QS15Hf8+o9UPdl3H7 Xx+ZpEsg2Fj9b0KB/xnnkbTbJs4oic8h30jOtVfq589lWN/jx3CIRdyPndTfJLZCQZN4Q sm6Gye/czzfMFtIOdYSdDL0EpW5/adRsbX253dxqy7431s9Jxsx4xXIowOkD/cCHcrAw3 SLchLXVXGbgcnnphAo+po8cJ7omMF0c0F0eOplKQkbbjoNJSO/TeIQJdgmUrxpy9c8Uhc ScdkajtyxGD9YvXDc8Ik7OCFn03e9bd791qasiBSTgCjWjV3IvcDohjF/RpxftA5LxmGS /C1KSG1AZBqivSMOkgZ8wgZygAwIBEqKBlASBkerR33SV6Gv+yTLbqByadkgmCAu4w1ms NifEss5TAhcEJEnpyqPbZgMfvksc+ULsnsdzovskhd1NbhJx+f9B0mxUzpNw1uRXMVbNw FGUSlYwVr+h1Hzs7/PLSsRV/jPNA+kbqbTcIkPOWe8OGGWuvbp24w6yrY3rcUCbEfhs+m xuSIJwMDwEUb2GqRwTkBhCGgd1UTBPoAMCAQWhAwIBFaNDMEGgAwIBEqI6BDh433pZMyL WiOUtyZnqOyiMoCe7ulv7TVyE5PGccaA3vXPzzBwh5P9wEFDl0alUBuHOKgBbtzOAgKEP Gw1GUkVFSVBBLkxPQ0FM """) def assert_decode(self, data, cls): # manual decode request, realm, _ = codec.asn1mod.decode_proxymessage(data) self.assertEqual(realm, self.realm) inst = cls.parse_request(realm, request) self.assertIsInstance(inst, cls) self.assertEqual(inst.realm, self.realm) self.assertEqual(inst.request, request) if cls is codec.KPASSWDProxyRequest: self.assertEqual(inst.version, 1) # codec decode outer = codec.decode(data) self.assertEqual(outer.realm, self.realm) self.assertIsInstance(outer, cls) # re-decode der = codec.encode(outer.request) self.assertIsInstance(der, bytes) decoded = codec.decode(der) self.assertIsInstance(decoded, cls) return outer def test_asreq(self): outer = self.assert_decode(self.asreq1, codec.ASProxyRequest) self.assertEqual(str(outer), 'FREEIPA.LOCAL AS-REQ (169 bytes)') outer = self.assert_decode(self.asreq2, codec.ASProxyRequest) self.assertEqual(str(outer), 'FREEIPA.LOCAL AS-REQ (264 bytes)') def test_tgsreq(self): outer = self.assert_decode(self.tgsreq, codec.TGSProxyRequest) self.assertEqual(str(outer), 'FREEIPA.LOCAL TGS-REQ (936 bytes)') def test_kpasswdreq(self): outer = self.assert_decode(self.kpasswdreq, codec.KPASSWDProxyRequest) self.assertEqual( str(outer), 'FREEIPA.LOCAL KPASSWD-REQ (603 bytes) (version 0x0001)' ) class KDCProxyConfigTests(unittest.TestCase): def test_mit_config(self): with mock.patch.dict('os.environ', {'KRB5_CONFIG': KRB5_CONFIG}): cfg = mit.MITConfig() self.assertIs(cfg.use_dns(), False) self.assertEqual( cfg.lookup('KDCPROXY.TEST'), ( 'kerberos://k1.kdcproxy.test.:88', 'kerberos://k2.kdcproxy.test.:1088' ) ) # wrong? man page says port 464 on admin server self.assertEqual( cfg.lookup('KDCPROXY.TEST', kpasswd=True), ( 'kpasswd://adm.kdcproxy.test.:1749', 'kpasswd://adm.kdcproxy.test.' ) ) self.assertEqual( cfg.lookup('KDCPROXY.TEST', kpasswd=True), cfg.lookup('KDCPROXY.TEST', True) ) self.assertEqual(cfg.lookup('KDCPROXY.MISSING'), ()) self.assertEqual(cfg.lookup('KDCPROXY.MISSING', True), ()) def mksrv(self, txt): priority, weight, port, target = txt.split(' ') return SRV( rdclass=RDCLASS_IN, # Internet rdtype=RDTYPE_SRV, # Server Selector priority=int(priority), weight=int(weight), port=int(port), target=target ) @mock.patch('dns.resolver.query') def test_dns_config(self, m_query): cfg = config.DNSResolver() tcp = [ self.mksrv('30 100 88 k1_tcp.kdcproxy.test.'), self.mksrv('10 100 1088 k2_tcp.kdcproxy.test.'), ] udp = [ self.mksrv('0 100 88 k1_udp.kdcproxy.test.'), self.mksrv('10 100 1088 k2_udp.kdcproxy.test.'), self.mksrv('0 100 88 k3_udp.kdcproxy.test.'), ] m_query.side_effect = [tcp, udp] self.assertEqual( tuple(cfg.lookup('KDCPROXY.TEST')), ( 'kerberos://k2_tcp.kdcproxy.test:1088', 'kerberos://k1_tcp.kdcproxy.test:88', 'kerberos://k1_udp.kdcproxy.test:88', 'kerberos://k3_udp.kdcproxy.test:88', 'kerberos://k2_udp.kdcproxy.test:1088' ) ) self.assertEqual(m_query.call_count, 2) m_query.assert_any_call('_kerberos._tcp.KDCPROXY.TEST', RDTYPE_SRV) m_query.assert_any_call('_kerberos._udp.KDCPROXY.TEST', RDTYPE_SRV) m_query.reset_mock() adm = [ self.mksrv('0 0 749 adm.kdcproxy.test.'), ] empty = [] m_query.side_effect = (empty, adm, empty, empty) self.assertEqual( tuple(cfg.lookup('KDCPROXY.TEST', kpasswd=True)), ( 'kpasswd://adm.kdcproxy.test:749', ) ) self.assertEqual(m_query.call_count, 4) m_query.assert_any_call('_kpasswd._tcp.KDCPROXY.TEST', RDTYPE_SRV) m_query.assert_any_call('_kerberos-adm._tcp.KDCPROXY.TEST', RDTYPE_SRV) m_query.assert_any_call('_kpasswd._udp.KDCPROXY.TEST', RDTYPE_SRV) m_query.assert_any_call('_kerberos-adm._udp.KDCPROXY.TEST', RDTYPE_SRV) if __name__ == "__main__": unittest.main() kdcproxy-1.0.0/tox.ini000066400000000000000000000021741376374060000147000ustar00rootroot00000000000000[tox] minversion = 2.3.1 envlist = py36,py37,py38,py39,pep8,py3pep8,doc,coverage-report skip_missing_interpreters = true [testenv] deps = .[tests] commands = {envpython} -m coverage run --parallel \ -m pytest --capture=no --strict {posargs} [testenv:coverage-report] deps = coverage skip_install = true commands = {envpython} -m coverage combine {envpython} -m coverage report --show-missing [testenv:pep8] basepython = python3 deps = .[test_pep8] commands = {envpython} -m flake8 [testenv:py3pep8] basepython = python3 deps = .[test_pep8] commands = {envpython} -m flake8 [testenv:doc] deps = doc8 docutils markdown basepython = python3 commands = doc8 --allow-long-titles README python setup.py check --restructuredtext --metadata --strict rst2html.py README {toxworkdir}/README.html markdown_py README.md -f {toxworkdir}/README.md.html [pytest] python_files = tests*.py [flake8] exclude = .tox,*.egg,dist,build show-source = true max-line-length = 79 application-import-names = kdcproxy # N815 is camelCase names; N813 is for changing case on import ignore = N815, N813