pax_global_header 0000666 0000000 0000000 00000000064 13763740600 0014520 g ustar 00root root 0000000 0000000 52 comment=0c46921ba2ff168d8400659b8e0a4f37abfa9498
kdcproxy-1.0.0/ 0000775 0000000 0000000 00000000000 13763740600 0013361 5 ustar 00root root 0000000 0000000 kdcproxy-1.0.0/.coveragerc 0000664 0000000 0000000 00000000535 13763740600 0015505 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 13763740600 0014721 5 ustar 00root root 0000000 0000000 kdcproxy-1.0.0/.github/workflows/ 0000775 0000000 0000000 00000000000 13763740600 0016756 5 ustar 00root root 0000000 0000000 kdcproxy-1.0.0/.github/workflows/build.yml 0000664 0000000 0000000 00000001422 13763740600 0020577 0 ustar 00root root 0000000 0000000 {
"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/.gitignore 0000664 0000000 0000000 00000000133 13763740600 0015346 0 ustar 00root root 0000000 0000000 *.pyc
__pycache__
/.coverage
/.tox
/dist
/build
/MANIFEST
/*.egg-info
/.cache
/.coverage.*
kdcproxy-1.0.0/.project 0000664 0000000 0000000 00000000552 13763740600 0015032 0 ustar 00root root 0000000 0000000
kdcproxy
org.python.pydev.PyDevBuilder
org.python.pydev.pythonNature
kdcproxy-1.0.0/.pydevproject 0000664 0000000 0000000 00000000662 13763740600 0016104 0 ustar 00root root 0000000 0000000
/${PROJECT_DIR_NAME}/kdcproxy
python 3.0
python3.3
kdcproxy-1.0.0/COPYING 0000664 0000000 0000000 00000002071 13763740600 0014414 0 ustar 00root root 0000000 0000000 The 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.in 0000664 0000000 0000000 00000000156 13763740600 0015121 0 ustar 00root root 0000000 0000000 include README COPYING
include tox.ini
include setup.cfg
include tests.py tests.krb5.conf
include .coveragerc
kdcproxy-1.0.0/README 0000664 0000000 0000000 00000015170 13763740600 0014245 0 ustar 00root root 0000000 0000000 Welcome 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.md 0000777 0000000 0000000 00000000000 13763740600 0015513 2README ustar 00root root 0000000 0000000 kdcproxy-1.0.0/kdcproxy/ 0000775 0000000 0000000 00000000000 13763740600 0015224 5 ustar 00root root 0000000 0000000 kdcproxy-1.0.0/kdcproxy/__init__.py 0000664 0000000 0000000 00000024600 13763740600 0017337 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000010545 13763740600 0016660 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 13763740600 0016471 5 ustar 00root root 0000000 0000000 kdcproxy-1.0.0/kdcproxy/config/__init__.py 0000664 0000000 0000000 00000012733 13763740600 0020610 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000024113 13763740600 0017635 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002442 13763740600 0017761 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000007650 13763740600 0020213 0 ustar 00root root 0000000 0000000 # 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.cfg 0000664 0000000 0000000 00000000365 13763740600 0015206 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000005250 13763740600 0015075 0 ustar 00root root 0000000 0000000 # 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.conf 0000664 0000000 0000000 00000000617 13763740600 0016240 0 ustar 00root root 0000000 0000000 [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.py 0000664 0000000 0000000 00000031743 13763740600 0015105 0 ustar 00root root 0000000 0000000 # 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.ini 0000664 0000000 0000000 00000002174 13763740600 0014700 0 ustar 00root root 0000000 0000000 [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