gimrr-server-krb: simple server for adding public keys with krb auth
authorFrantisek Hrbata <frantisek@hrbata.com>
Mon, 4 Jan 2021 16:53:38 +0000 (17:53 +0100)
committerFrantisek Hrbata <frantisek@hrbata.com>
Mon, 4 Jan 2021 18:28:26 +0000 (19:28 +0100)
Signed-off-by: Frantisek Hrbata <frantisek@hrbata.com>
gimrr-server-krb [new file with mode: 0755]

diff --git a/gimrr-server-krb b/gimrr-server-krb
new file mode 100755 (executable)
index 0000000..7860a7a
--- /dev/null
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
+
+"""
+Copyright © 2020 Frantisek Hrbata <frantisek@hrbata.com>
+This program is free software. It comes without any warranty, to
+the extent permitted by applicable law. You can redistribute it
+and/or modify it under the terms of the Do What The Fuck You Want
+To Public License, Version 2, as published by Sam Hocevar. See
+http://www.wtfpl.net/ for more details.
+"""
+
+import argparse
+import socket
+import gssapi
+import sys
+import os
+import gimrrdataprot
+import select
+import signal
+import tempfile
+import re
+import fcntl
+import logging
+
+parser = argparse.ArgumentParser(description="Server for adding gimrr users public keys using krb authentication.");
+parser.add_argument("--address", metavar="ADDR", default="",
+        help="Bind server to ADDR (default: INADDR_ANY)")
+parser.add_argument("--port", metavar="PORT", default=9876, type=int,
+        help="Listen at specified port (default: 9876)")
+parser.add_argument("--timeout", metavar="SECONDS", default=3, type=int,
+        help="Socket timeout for client connections (default: 3 sec.)")
+parser.add_argument("--principal", metavar="PRINCIPAL",
+        help="Kerberos principal name.")
+parser.add_argument("--keytab", metavar="FILE", default="/etc/krb5.keytab",
+        help="Keytab with key for server principal (default: /etc/krb5.keytab)")
+parser.add_argument("--auth-keys", metavar="FILE", required=True,
+        help="Ssh authorized_keys file where to store public keys.")
+parser.add_argument("--daemon", action="store_true",
+        help="Start server as daemon in background.")
+parser.add_argument("--log", metavar="FILE",
+        help="Server log file.")
+
+args = parser.parse_args()
+os.environ["KRB5_KTNAME"] = args.keytab
+
+logger = logging.getLogger()
+logger.setLevel(logging.DEBUG)
+logfmt = logging.Formatter("%(asctime)s: %(levelname)s - %(message)s")
+
+if args.log:
+    h = logging.FileHandler(args.log)
+    h.setFormatter(logfmt)
+    logger.addHandler(h)
+
+if not args.daemon:
+    h = logging.StreamHandler()
+    h.setFormatter(logfmt)
+    logger.addHandler(h)
+
+server_name = gssapi.Name(args.principal)
+server_cred = gssapi.Credentials(name=server_name, usage='accept')
+
+ss = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ss.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ss.bind((args.address, args.port))
+ss.listen()
+cs = None
+run = True
+
+def stop(signum, frame):
+    global run
+    run = False
+
+signal.signal(signal.SIGHUP, stop)
+signal.signal(signal.SIGINT, stop)
+signal.signal(signal.SIGTERM, stop)
+
+if args.daemon:
+    pid = os.fork()
+    if pid > 0:
+        sys.exit()
+
+    os.setsid()
+    sys.stdin.close()
+    sys.stdout.close()
+    sys.stderr.close()
+
+tmpdir = tempfile.TemporaryDirectory(dir=os.path.dirname(os.path.realpath(args.auth_keys)))
+auth_keys_fn = "{}/auth_keys".format(tmpdir.name)
+
+logger.info("server started")
+
+while run:
+    try:
+        r, w, e = select.select([ss], [], [], 1)
+        if ss not in r:
+            continue
+
+        # serving users synchronously should be fine for this use case
+        cs, addr = ss.accept()
+        logger.info("connection accepted from {}:{}".format(addr[0], addr[1]))
+
+        ctx = gssapi.SecurityContext(creds=server_cred, usage='accept')
+        while not ctx.complete:
+            data_type, token_in = gimrrdataprot.recv_data(cs)
+            if data_type != gimrrdataprot.DATA_TYPE_AUTH_TOKEN:
+                raise Exception("expecting gssapi auth token")
+
+            token_out = ctx.step(token_in)
+            if not token_out:
+                break
+            gimrrdataprot.send_data(cs, gimrrdataprot.DATA_TYPE_AUTH_TOKEN, token_out)
+
+        if not ctx.complete:
+            raise Exception("no security context")
+
+        user = str(ctx.initiator_name)
+        user = user.split("@")[0]
+        if not user:
+            raise Exception("no user found")
+
+        logger.info("user {} authenticated".format(user))
+
+        data_type, key = gimrrdataprot.recv_data_dec(ctx, cs)
+        if data_type != gimrrdataprot.DATA_TYPE_PUB_KEY:
+            raise Exception("expecting public key")
+
+        # TODO Probably check at least the key format as described in RFC 4253 6.6 
+
+        logger.info("received key: {}".format(key.decode().strip()))
+
+        user_re = re.compile('"GIMRR_USER={}"'.format(user))
+
+        with open(args.auth_keys, "a+") as ofd:
+            fcntl.lockf(ofd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+            ofd.seek(0)
+            lines = ofd.readlines()
+            with open(os.open(auth_keys_fn, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600), "w") as nfd:
+                for line in lines:
+                    match = user_re.search(line)
+                    if not match:
+                        nfd.write(line)
+                    else:
+                        logger.info("removing: {}".format(line.strip()))
+
+                nfd.write('environment="GIMRR_USER={}",restrict,\
+command="/usr/bin/git-shell -c \\"$SSH_ORIGINAL_COMMAND\\"" \
+{}'.format(user, key.decode()))
+
+            os.rename(auth_keys_fn, args.auth_keys)
+        logger.info("new key added for user {}".format(user))
+
+        gimrrdataprot.send_data_enc(ctx, cs, gimrrdataprot.DATA_TYPE_MSG,
+                "public key successfully registered for '{}'".format(user))
+    except Exception as e:
+        logger.error(e)
+        if cs:
+            gimrrdataprot.send_data(cs, gimrrdataprot.DATA_TYPE_ERR, str(e))
+    finally:
+        if cs:
+            cs.close()
+        cs = None
+
+logger.info("server stopped")