brmdoor_libnfc/brmdoor_nfc_daemon.py

383 lines
14 KiB
Python
Executable file

#!/usr/bin/env python2
import sys
import logging
import logging.handlers
import time
import ConfigParser
import threading
import irc.client
import ssl
import Queue
import re
from binascii import hexlify
from functools import partial
from nfc_smartcard import NFCDevice, NFCError
from brmdoor_authenticator import UidAuthenticator, YubikeyHMACAuthenthicator, DesfireEd25519Authenthicator
import unlocker
class BrmdoorConfigError(ConfigParser.Error):
"""
Signifies that config has missing or bad values.
"""
pass
class BrmdoorConfig(object):
"""
Configuration parser. Holds config variables from config file.
"""
_defaults = {
"lock_opened_secs": "5",
"unknown_uid_timeout_secs": "5",
"log_level": "info"
}
def __init__(self, filename):
"""
Parse and read config from given filename.
@throws ConfigParser.Error if parsing failed
@throws BrmdoorConfigError if some value was missing or invalid
"""
self.config = ConfigParser.SafeConfigParser(defaults=BrmdoorConfig._defaults)
self.config.read(filename)
self.authDbFilename = self.config.get("brmdoor", "auth_db_filename")
self.desfirePubkey = self.config.get("brmdoor", "desfire_ed25519_pubkey")
self.lockOpenedSecs = self.config.getint("brmdoor", "lock_opened_secs")
self.unknownUidTimeoutSecs = self.config.getint("brmdoor", "unknown_uid_timeout_secs")
self.logFile = self.config.get("brmdoor", "log_file")
self.logLevel = self.convertLoglevel(self.config.get("brmdoor", "log_level"))
self.unlocker = self.config.get("brmdoor", "unlocker")
self.useIRC = self.config.getboolean("irc", "enabled")
if self.useIRC:
self.ircServer = self.config.get("irc", "server")
self.ircPort = self.config.getint("irc", "port")
self.ircNick = self.config.get("irc", "nick")
self.ircPassword = self.config.get("irc", "password") if self.config.has_option("irc", "password") else None
self.ircChannels = self.config.get("irc", "channels").split(" ")
if len(self.ircChannels) < 1:
print >> sys.stderr, "You must specify at least one channel for IRC when IRC is enabled"
sys.exit(1)
self.ircUseTLS = self.config.getboolean("irc", "tls")
self.ircReconnectDelay = self.config.getint("irc", "reconnect_delay")
self.useOpenSwitch = self.config.getboolean("open_switch", "enabled")
if self.useOpenSwitch:
self.switchStatusFile = self.config.get("open_switch", "status_file")
self.switchOpenValue = self.config.get("open_switch", "open_value")
def convertLoglevel(self, levelString):
"""Converts string 'debug', 'info', etc. into corresponding
logging.XXX value which is returned.
@raises BrmdoorConfigError if the level is undefined
"""
try:
return getattr(logging, levelString.upper())
except AttributeError:
raise BrmdoorConfigError("No such loglevel - %s" % levelString)
class NFCScanner(object):
"""Thread reading data from NFC reader"""
def __init__(self, config, msgQueue, ircThread):
"""Create worker reading UIDs from PN53x reader.
"""
self.authenticator = UidAuthenticator(config.authDbFilename)
self.hmacAuthenticator = None
self.desfireAuthenticator = None
self.unknownUidTimeoutSecs = config.unknownUidTimeoutSecs
self.lockOpenedSecs = config.lockOpenedSecs
self.msgQueue = msgQueue
self.ircThread = ircThread
unlockerClassName = config.unlocker
unlockerClass = getattr(unlocker, unlockerClassName)
self.unlocker = unlockerClass(config)
def run(self):
"""
Waits for a card to get into reader field. Reads its UID and
compares to database of authorized users. Unlocks lock if
authorized.
"""
self.nfc = NFCDevice()
self.hmacAuthenticator = YubikeyHMACAuthenthicator(
config.authDbFilename, self.nfc
)
self.desfireAuthenticator = DesfireEd25519Authenthicator(
config.authDbFilename, self.nfc,
config.desfirePubkey.decode("hex")
)
#self.nfc.pollNr = 0xFF #poll indefinitely
while True:
try:
uid_hex = hexlify(self.nfc.scanUID())
logging.debug("Got UID %s", uid_hex)
if len(uid_hex) > 0:
self.actOnUid(uid_hex)
else:
#prevent busy loop if reader goes awry
e = threading.Event()
e.wait(timeout=0.3)
except NFCError, e:
#this exception happens also when scanUID times out
logging.debug("Failed to wait for RFID card: %s", e)
except KeyboardInterrupt:
logging.info("Exiting on keyboard interrupt")
self.nfc.close()
self.nfc.unload()
self.unlocker.lock()
sys.exit(2)
except Exception:
logging.exception("Exception in main unlock thread")
def sendIrcMessage(self, msg):
"""
Send message to IRC bot. Message is dropped if bot is not connected
:param msg: message to be displayed in joined channels
"""
if not self.ircThread:
return
if self.ircThread.getConnected():
self.msgQueue.put(msg)
def actOnUid(self, uid_hex):
"""
Do something with the UID scanned. Try to authenticate it against
database and open lock if authorized.
"""
record = self.authenticator.fetchUidRecord(uid_hex)
#direct UID match
if record is not None:
logging.info("Unlocking for UID %s", record)
self.sendIrcMessage("Unlocking door")
self.unlocker.unlock()
return
#test for Yubikey HMAC auth
record = self.hmacAuthenticator.checkHMACforUID(uid_hex)
if record is not None:
logging.info("Unlocking after HMAC for UID %s", record)
self.sendIrcMessage("Unlocking door")
self.unlocker.unlock()
return
#test for Desfire NDEF auth
record = self.desfireAuthenticator.checkUIDSignature(uid_hex)
if record is not None:
logging.info("Unlocking after Desfire NDEF ed25519 check for UID %s", record)
self.sendIrcMessage("Unlocking door")
self.unlocker.unlock()
return
logging.info("Unknown UID %s", uid_hex)
self.sendIrcMessage("Denied unauthorized card")
e = threading.Event()
e.wait(timeout=self.unknownUidTimeoutSecs)
class IrcThread(threading.Thread):
"""
Class for showing messages about lock events and denied/accepted cards
"""
def __init__(self, config, msgQueue):
"""
Create thread for IRC connection.
:param config - BrmdoorConfig object
:param msgQueue: Queue.Queue instance where we will get messages to show
"""
self.server = config.ircServer
self.port = config.ircPort
self.nick = config.ircNick
self.password = config.ircPassword
self.channels = config.ircChannels
self.useSSL = config.ircUseTLS
self.reconnectDelay = config.ircReconnectDelay
self.msgQueue = msgQueue
self.connection = None
self.reactor = None
self.connected = False
self.threadLock = threading.Lock()
self.connection = None
threading.Thread.__init__(self)
def setConnected(self, connected):
with self.threadLock:
self.connected = connected
def getConnected(self):
with self.threadLock:
return self.connected
def connect(self):
"""
Connect to server.
:returns true if connection was successful
"""
try:
ssl_factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
self.reactor = irc.client.Reactor()
self.connection = self.reactor.server().connect(
self.server,
self.port,
self.nick,
self.password,
"brmdoor-libnfc",
connect_factory=ssl_factory if self.useSSL else lambda sock: sock,
)
return True
except irc.client.ServerConnectionError, e:
logging.error("Could not connect to IRC server: %s", e)
return False
def getTopic(self, channel):
""" TODO: this doesn't work, the implementation always returns None"""
with self.threadLock:
return self.connection.topic(channel)
def setTopic(self, channel, newTopic):
with self.threadLock:
return self.connection.topic(channel, newTopic)
def onConnect(self, connection, event):
for channel in self.channels:
connection.join(channel)
def onDisconnect(self, connection, event):
logging.info("Disconnected, waiting for %s seconds before reconnect", self.reconnectDelay)
self.setConnected(False)
time.sleep(self.reconnectDelay)
self.setConnected(self.connect())
def onJoin(self, connection, event):
nick, _ = event.source.split("!", 2)
if (nick == config.ircNick):
logging.info("Joined channel, event: %s", event)
logging.debug("join event - source %s, target: %s, type: %s", event.source, event.target, event.type)
#connection.privmsg(self.channels[0], "brmbot-libfc starting")
def onTopic(self, connection, event):
logging.debug("topic event - source %s, target: %s, type: %s", event.source, event.target, event.type)
def run(self):
logging.debug("Starting IRC thread")
while True:
try:
connected = self.connect()
logging.info("IRC connected: %s", connected)
self.setConnected(connected)
self.connection.add_global_handler("welcome", partial(IrcThread.onConnect, self))
self.connection.add_global_handler("disconnect", partial(IrcThread.onDisconnect, self))
self.connection.add_global_handler("join", partial(IrcThread.onJoin, self))
# Topic handler requires sadly completely different API to retrieve topic
# see https://github.com/jaraco/irc/issues/132
while self.getConnected():
self.reactor.process_once(timeout=5)
try:
with self.threadLock:
msg = self.msgQueue.get_nowait()
self.connection.privmsg_many(self.channels, msg)
except Queue.Empty:
pass
except Exception:
logging.exception("Exception in IRC thread")
time.sleep(self.reconnectDelay)
class OpenSwitchThread(threading.Thread):
"""
Class for watching OPEN/CLOSED switch that
"""
def __init__(self, config, ircThread):
"""
Create thread for IRC connection.
:param config - BrmdoorConfig object
:param ircThread: IrcThread through which we can set and receive current topics
"""
self.statusFile = config.switchStatusFile
self.openValue = config.switchOpenValue
self.ircThread = ircThread
threading.Thread.__init__(self)
def run(self):
logging.info("Switch thread start")
if self.ircThread is None: #no point in running this thread if we can't report it anywhere
return
lastStatus = None #Some random value so that first time it will be registered as change
while True:
try:
switchFile = open(self.statusFile)
status = switchFile.read(1)
switchFile.close()
if status != lastStatus:
logging.info("Open switch status changed, new status: %s", status)
lastStatus = status
if status == self.openValue:
strStatus = "OPEN |"
else:
strStatus = "CLOSED |"
if self.ircThread.connected:
for channel in self.ircThread.channels:
#TODO: getTopic always returns None, the problem is in implementenation
topic = self.ircThread.getTopic(channel)
if not topic or not re.match(r"^\s*(OPEN|CLOSED) \|", topic):
newTopic = strStatus
else:
newTopic = re.sub(r"^\s*(OPEN|CLOSED) \|", strStatus, topic)
self.ircThread.setTopic(channel, newTopic)
except (IOError, OSError):
logging.exception("Could not read switch status")
pass #silently ignore non-existent file and other errors, otherwise it'd spam log
except Exception:
logging.exception("Exception in open switch thread")
e = threading.Event()
e.wait(timeout=1)
if __name__ == "__main__":
if len(sys.argv) < 2:
print >> sys.stderr, "Syntax: brmdoor_nfc_daemon.py brmdoor_nfc.config"
sys.exit(1)
config = BrmdoorConfig(sys.argv[1])
fmt="%(asctime)s %(levelname)s %(message)s [%(pathname)s:%(lineno)d]"
if config.logFile == "-":
logging.basicConfig(stream=sys.stderr, level=config.logLevel, format=fmt)
else:
handler = logging.handlers.RotatingFileHandler(filename=config.logFile, maxBytes=1000000, backupCount=5)
handler.setLevel(config.logLevel)
handler.setFormatter(logging.Formatter(fmt))
mainLogger = logging.getLogger('')
mainLogger.addHandler(handler)
mainLogger.setLevel(config.logLevel)
ircMsgQueue = Queue.Queue()
ircThread = None
openSwitchThread = None
if config.useIRC:
ircThread = IrcThread(config, ircMsgQueue)
ircThread.setDaemon(True)
ircThread.start()
if config.useOpenSwitch:
openSwitchThread = OpenSwitchThread(config, ircThread)
openSwitchThread.setDaemon(True)
openSwitchThread.start()
nfcScanner = NFCScanner(config, ircMsgQueue, ircThread)
nfcScanner.run()