brmdoor_libnfc/brmdoor_nfc_daemon.py

488 lines
19 KiB
Python
Executable file

#!/usr/bin/env python2
import sys
import os.path
import logging
import logging.handlers
import time
import ConfigParser
import threading
import irc.client
import ssl
import Queue
import json
import tempfile
from binascii import hexlify
from functools import partial
from nfc_smartcard import NFCDevice, NFCError
from brmdoor_authenticator import UidAuthenticator, YubikeyHMACAuthenthicator, DesfireEd25519Authenthicator
import unlocker
# Map request to change channel's topic to its new prefix. Prefix and rest are delimited with |
# Channel prefix must include the | character at end, e.g. "OPEN |" or "CLOSED |"
channelPrefixMap = {}
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")
self.useStatusUpload = self.config.getboolean("open_switch", "spaceapi_status_upload ")
if self.useStatusUpload:
self.sftpHost = self.config.get("open_switch", "spaceapi_sftp_host")
self.sftpPort= self.config.getint("open_switch", "spaceapi_sftp_port")
self.sftpUsername = self.config.get("open_switch", "spaceapi_sftp_username")
self.sftpKey = self.config.get("open_switch", "spaceapi_sftp_key")
self.sftpDestFile = self.config.get("open_switch", "spaceapi_dest_file")
self.sftpTemplateFile = self.config.get("open_switch", "spaceapi_template_file")
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 finds no cards
logging.debug("Failed to find RFID cards in reader's field: %s", e)
e = threading.Event()
e.wait(timeout=0.2)
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):
""" Set the connection status (result) of last connection attempt"""
with self.threadLock:
self.connected = connected
def getConnected(self):
""" Return whether we are connected to IRC"""
with self.threadLock:
return self.connected
def connect(self):
"""
Connect to server.
:returns true if connection was successful
"""
try:
logging.info("IRC connect attempt")
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,
)
logging.info("IRC connect successful")
return True
except irc.client.ServerConnectionError, e:
logging.error("Could not connect to IRC server: %s", e)
return False
def getTopic(self, channel):
""" Request topic. You need to wait in currenttopic callback for result """
with self.threadLock:
self.connection.topic(channel)
def setTopic(self, channel, newTopic):
""" Change topic for a channel. If not connected yet, won't queue the topic change request. """
with self.threadLock:
return self.connection.topic(channel, newTopic)
def onConnect(self, connection, event):
""" Callback when IRC server is connected. Joins channels specified in config. """
logging.info("Joining channels: %s", self.channels)
for channel in self.channels:
connection.join(channel)
def onDisconnect(self, connection, event):
"""
Disconnect handler that attempts reconnect.
TODO: investigate reconnect and channel re-join behavior upon netsplits.
"""
logging.info("Disconnected, waiting for %s seconds before reconnect", self.reconnectDelay)
self.setConnected(False)
time.sleep(self.reconnectDelay)
reconnected = self.connect()
logging.info("IRC reconnect attempt success: %s", reconnected)
self.setConnected(reconnected)
if reconnected:
try:
logging.info("Rejoining channels: %s", self.channels)
for channel in self.channels:
self.connection.join(channel)
except:
logging.exception("Exception when rejoining channels")
try:
self.reactor.server().add_global_handler("notopic", partial(IrcThread.onNoTopic, self))
self.reactor.server().add_global_handler("currenttopic", partial(IrcThread.onTopic, self))
except:
logging.exception("Exception while reassigning topic handlers")
def onJoin(self, connection, event):
""" Callback when channel is joined """
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):
"""
Callback when we see IRC topic message. It can be caused by us or other users in channel.
Check if there was request to change topic and update it.
"""
global channelPrefixMap
channel = event.arguments[0]
topic = event.arguments[1]
logging.info("Current topic: channel %s, topic %s", channel, topic)
logging.info("Topic event - source %s, target: %s, type: %s", event.source, event.target, event.type)
# if change was requested, update channel topic
if channelPrefixMap.get(channel):
#update topic part before |, or replace entirely if | is not present
topicParts = topic.split("|", 1)
restOfTopic = ""
if (len(topicParts) > 1):
restOfTopic = topicParts[1]
newTopic = channelPrefixMap[channel] + restOfTopic
logging.info("Setting new topic for channel %s: %s", channel, newTopic)
self.setTopic(channel, newTopic)
del channelPrefixMap[channel] # remove request
def onNoTopic(self, connection, event):
""" Callback on empty topic. I couldn't trigger this, so it's untested. """
channel = event.arguments[0]
topic = event.arguments[1]
logging.info("No topic: channel %s, topic %s", channel, topic)
logging.info("No 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))
self.reactor.server().add_global_handler("notopic", partial(IrcThread.onNoTopic, self))
self.reactor.server().add_global_handler("currenttopic", partial(IrcThread.onTopic, 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 SpaceAPIUploader(object):
"""
Class for uploading JSON-formatted SpaceAPI status via SFTP
"""
def __init__(self, config):
""" Create uploader with store settings, not yet connected."""
self.config = config
def upload(self, isOpen):
"""
Upload status via SFTP. Current timestamp will be added as last change.
:param isOpen - whether space is opened.
:raises paramiko.ssh_exception.SSHException when upload fails (timeout, can't connect, host key mismatch...)
"""
import pysftp
dirname, targetFname = os.path.split(self.config.sftpDestFile)
spaceApiJson = json.load(file("spaceapi_template.json"))
spaceApiJson["state"] = {"open": True, "lastchange": time.time()}
with tempfile.NamedTemporaryFile() as tf:
json.dump(spaceApiJson, tf)
localFilename = tf.name
with pysftp.Connection(self.config.sftpHost, username=self.config.sftpUsername, port=self.config.sftpPort,
private_key=self.config.sftpKey) as sftp:
sftp.timeout = 30
with sftp.cd(dirname):
sftp.put(localFilename, remotepath=targetFname)
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:
with open(self.statusFile) as switchFile:
status = switchFile.read().rstrip()
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:
logging.info("Request topic for channel %s with intention to change it, prefix %s",
channel, strStatus)
channelPrefixMap[channel] = strStatus
self.ircThread.getTopic(channel)
except (IOError, OSError):
logging.debug("Could not read switch status file %s", self.statusFile)
e = threading.Event()
e.wait(timeout=5)
pass #just log the error, but don't spam IRC
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)
logging.info("Starting brmdoor-libnfc")
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()