#!/usr/bin/env python3 libexec_dir = __LIBEXECDIR__ offline_timeout = 5 * 60 import enum import os import select import subprocess import sys import time class statuses(enum.Enum): available = 0 unavailable = 1 offline = 2 own_nick = None own_status = None default_target_mac = None peers = {} class Peer: def __init__(self, status, nick, lastseen): self.status = status self.nick = nick self.lastseen = lastseen def __repr__(self): r = 'Peer(%s, %s, %s)' % (repr(self.status), repr(self.nick), repr(self.lastseen)) if __name__ != '__main__': return '%s.%s' % (__name__, r) else: return r send_queue = [] ack_failed = {} class Message: def __init__(self, mac, message, queue_id = None, msgid = None): self.mac = mac self.message = message self.queue_id = queue_id self.msgid = msgid def __repr__(self): r = 'Message(%s, %s, %s, %s)' % (repr(self.mac), repr(self.message), repr(self.queue_id), repr(self.msgid)) if __name__ != '__main__': return '%s.%s' % (__name__, r) else: return r def writeall(f, b): written = 0 while written < len(b): written += f.write(b[written:]) class ReadallError(Exception): pass def readall(f, length): read = bytearray() while len(read) < length: data = f.read(length - len(read)) if data == b'': raise ReadallError('Could not satisfy read of %i bytes' % length) read.extend(data) return bytes(read) def readall_u16(f): u16_bytes = readall(f, 2) return (u16_bytes[0] << 8) | u16_bytes[1] class MACParseError(Exception): pass def parse_mac(text): parts = text.split(':') if len(parts) != 6: raise MACParseError('Invalid MAC format: %s' % text) try: parsed = bytes(int(field, 16) for field in parts) except ValueError: raise MACParseError('Invalid MAC format %s' % text) return parsed class NoMatchesError(Exception): pass class TooManyMatchesError(Exception): pass def mac_from_name(text): global peers # Try to parse as a MAC address try: mac = parse_mac(text) return mac except MACParseError as err: pass # It was not, try to find a matching nick hits = 0 for peer_mac, peer in peers.items(): if text == peer.nick or text == '~' + peer.nick: hits += 1 mac = peer_mac if hits == 0: raise NoMatchesError(text) elif hits > 1: raise TooManyMatchesError(text) return mac def format_mac(mac): return ':'.join(mac[i:i+1].hex() for i in range(len(mac))) def format_status(status): if status == 0: return 'available' elif status == 1: return 'unavailable' elif status == 2: return 'offline' else: raise ValueError('Unknown status %i' % status) def timestamp(): return time.strftime('%H:%M:%S') class NonCharacterError(Exception): pass class ControlCharacterError(Exception): pass def validate_encoding(text, newline_tab_allowed): # We assume text is valid unicode, so we skip stuff like surrogate pairs for char in text: codepoint = ord(char) # Reject non-characters if codepoint & 0xffff in [0xfffe, 0xffff]: # Plane end non-character raise NonCharacterError(char) elif 0xfdd0 <= codepoint <= 0xfdef: # BMP non-character block raise NonCharacterError(char) # Reject control characters if codepoint <= 0x1f: # C0 control characters if not newline_tab_allowed or codepoint not in [0x0a, 0x09]: raise ControlCharacterError(char) elif 0x80 <= codepoint <= 0x9f: # C1 control characters raise ControlCharacterError(char) elif codepoint in [0x2028, 0x2029]: # U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR raise ControlCharacterError(char) class NickLengthError(Exception): pass def validate_nick(nick): validate_encoding(nick, newline_tab_allowed = False) # Nick length is stored as one byte if len(nick.encode('utf-8')) > 255: raise NickLengthError class MessageLengthError(Exception): pass def validate_message(message): validate_encoding(message, newline_tab_allowed = True) # Maximum frame payload is 1500 bytes # -2 for EtherMess packet header # -2 for msgid # -2 for message length # = 1494 if len(message.encode('utf-8')) > 1494: raise MessageLengthError def send_message(backend, mac, message): encoded = message.encode('utf-8') writeall(backend, b'm' + mac + bytes([len(encoded) >> 8, len(encoded) & 0xff]) + encoded) def queue_message(backend, mac, message): global send_queue global next_queue_id validate_message(message) if len(send_queue) == 0: # Nothing being processed atm, send directly send_message(backend, mac, message) send_queue.append(Message(mac, message)) else: # Enqueue with an ID if send_queue[-1].queue_id is None: next_queue_id = 0 else: next_queue_id = send_queue[-1].queue_id + 1 send_queue.append(Message(mac, message, next_queue_id)) print('--- Queued (%i)' % next_queue_id) def send_status_request(backend, mac): writeall(backend, b'r' + mac) def set_status_nick(backend, status, nick): encoded = nick.encode('utf-8') writeall(backend, b's' + bytes([status.value, len(encoded)]) + encoded) def send_file_offer(backend, mac): writeall(backend, b'f' + mac) def handle_user_command(backend, line): global own_nick, own_status global default_target_mac try: if len(line) > 0 and line[0] == '/': index = line.find(' ') if index != -1: command = line[:index] while index < len(line) and line[index] == ' ': index += 1 rest = line[index:] else: command = line rest = '' if command == '/': # Send quoted message if default_target_mac is None: print('--- Default target not set, set with /target') else: queue_message(backend, default_target_mac, rest) elif command == '/msg': # Send message to target target, _, message = rest.partition(' ') mac = mac_from_name(target) queue_message(backend, mac, message) elif command == '/status': if rest != '': # Request status mac = mac_from_name(rest) if mac in peers: peer = peers[mac] print('%s === ~%s (%s) [%s]' % (timestamp(), peer.nick, peer.status.name, format_mac(mac))) send_status_request(backend, mac) else: # Show own print('--- %s' % own_status.name) elif command == '/peers' and rest == '': # List all the known peers for mac, peer in peers.items(): print('--- ~%s (%s) [%s]' % (peer.nick, peer.status.name, format_mac(mac))) elif command == '/available' and rest == '': # Set status to available own_status = statuses.available set_status_nick(backend, own_status, own_nick) elif command == '/unavailable' and rest == '': # Set status to unavailable own_status = statuses.unavailable set_status_nick(backend, own_status, own_nick) elif command == '/nick': if rest != '': # Change nick if rest[0] == '~': # Remove the ~ from the front if it is there nick = rest[1:] else: nick = rest validate_nick(nick) own_nick = nick set_status_nick(backend, own_status, own_nick) else: # Show own print('--- ~%s' % own_nick) elif command == '/target': # Set default target of messages default_target_mac = mac_from_name(rest) elif command == '/sendfile': # Send an offer to transmit the file send_file_offer(backend, mac_from_name(rest)) elif command == '/quit': # Quit return 'quit' else: # Display usage print('--- / ; /msg ; /status []; /peers; /available; /unavailable; /nick []; /target ; /sendfile ; /quit') else: # Send message if default_target_mac is None: print('--- Default target not set, set with /target') else: queue_message(backend, default_target_mac, line) except NoMatchesError as err: print('--- Name %s matches no peers' % err.args[0]) except TooManyMatchesError as err: print('--- Name %s matches several peers' % err.args[0]) except NonCharacterError as err: print('--- Error: contains non-character U+%04X' % ord(err.args[0])) except ControlCharacterError as err: print('--- Error: contains control character U+%04X' % ord(err.args[0])) except NickLengthError: print('--- Error: nick too long (max. 255B)') except MessageLengthError: print('--- Error: message too long (max. 1494B)') def handle_status(mac, status, nick): global peers if mac not in peers: # Never seen before peers[mac] = Peer(nick = None, status = None, lastseen = None) if peers[mac].nick is not None and peers[mac].status != statuses.offline and nick != peers[mac].nick: print('%s === ~%s -> ~%s [%s]' % (timestamp(), peers[mac].nick, nick, format_mac(mac))) peers[mac].nick = nick if status != peers[mac].status: if status == statuses.offline: print('%s <<< ~%s [%s]' % (timestamp(), nick, format_mac(mac))) elif peers[mac].status is None or peers[mac].status == statuses.offline: if status == statuses.available: print('%s >>> ~%s [%s]' % (timestamp(), nick, format_mac(mac))) else: print('%s >>> ~%s (%s) [%s]' % (timestamp(), nick, status.name, format_mac(mac))) else: print('%s === ~%s (%s) [%s]' % (timestamp(), nick, status.name, format_mac(mac))) peers[mac].status = status def nick_from_mac(mac): global peers if mac not in peers: return format_mac(mac) else: nick = peers[mac].nick # Ensure nicks are unique unique = True for peer_mac, peer in peers.items(): if peer_mac == mac: continue if peer.nick == nick: # Nick not unique unique = False break if unique: # Unique nicks: ~nick return '~' + nick else: # Non-unique nicks: [MAC]~nick return '[%s]~%s' % (format_mac(mac), nick) def handle_message(mac, message): nick = nick_from_mac(mac) ts = timestamp() if mac == default_target_mac: for line in message.split('\n'): print('%s <*%s> %s' % (ts, nick, line)) else: for line in message.split('\n'): print('%s < %s> %s' % (ts, nick, line)) def eventloop(proc): global peers global send_queue, ack_failed # Create unbuffered version of stdin and close the old one as we # won't need it anymore unbuf_stdin = open(sys.stdin.buffer.fileno(), 'rb', buffering = 0) sys.stdin.close() # Set up a poll for inputs (but do output blockingly) poll = select.poll() poll.register(proc.stdout, select.POLLIN) poll.register(unbuf_stdin, select.POLLIN) input_buffer = bytearray() running = True while running: # Handle offline timeouts now = time.monotonic() for mac, peer in peers.items(): if peer.lastseen + offline_timeout < now: peer.status = statuses.offline print('%s <<< (timeout) ~%s [%s]' % (timestamp(), peer.nick, format_mac(mac))) # Figure out how long to wait in poll() wait = None for peer in peers.values(): if peer.status != statuses.offline: if wait is None or wait >= peer.lastseen + offline_timeout - now: wait = peer.lastseen + offline_timeout - now # Clamp at 0 if wait is not None and wait < 0: wait = 0 # Convert s to ms if wait is not None: wait = wait * 1000 # Process events for fd, event in poll.poll(wait): if fd == proc.stdout.fileno() and event & select.POLLIN: event_type = readall(proc.stdout, 1) if event_type == b'v': # Speak version source_mac = readall(proc.stdout, 6) version, = readall(proc.stdout, 1) print('--- Speak version %s %i' % (format_mac(source_mac), version)) #debg elif event_type == b's': # Status source_mac = readall(proc.stdout, 6) status, = readall(proc.stdout, 1) nick_length, = readall(proc.stdout, 1) nick = readall(proc.stdout, nick_length,) handle_status(source_mac, statuses(status), nick.decode('utf-8')) peers[source_mac].lastseen = time.monotonic() elif event_type == b'i': # Msgid for message msgid = readall_u16(proc.stdout) send_queue[0].msgid = msgid elif event_type == b'I': # Failed to get msgid for message message = send_queue.pop(0) nick = nick_from_mac(message.mac) if message.queue_id is not None: print('--- Failed to send to %s (%i)' % (nick, message.queue_id)) else: print('--- Failed to send to %s' % nick) # Send next message if there is one queued if len(send_queue) > 0: send_message(proc.stdin, send_queue[0].mac, send_queue[0].message) elif event_type == b'a': # ACK received source_mac = readall(proc.stdout, 6) msgid = readall_u16(proc.stdout) if source_mac in peers: peers[source_mac].lastseen = time.monotonic() # Was it for a message currently waiting? if len(send_queue) > 0 and send_queue[0].msgid == msgid: # Yes, drop is from the queue send_queue.pop(0) # Send next message if there is one queued if len(send_queue) > 0: send_message(proc.stdin, send_queue[0].mac, send_queue[0].message) elif msgid in ack_failed: # No, but it was one we thought to have failed message = ack_failed[msgid] del ack_failed[msgid] nick = nick_from_mac(message.mac) for line in message.message.split('\n'): print('--- %s acknowledged receive: %s' % (nick, line)) elif event_type == b'A': # ACK not received (and message send failed) # Add it to the messages where ack failed message = send_queue.pop(0) ack_failed[message.msgid] = message nick = nick_from_mac(message.mac) if message.queue_id is not None: print('--- Failed to send to %s (%i)' % (nick, message.queue_id)) else: print('--- Failed to send to %s' % nick) # Send next message if there is one queued if len(send_queue) > 0: send_message(proc.stdin, send_queue[0].mac, send_queue[0].message) elif event_type == b'm': # Message received source_mac = readall(proc.stdout, 6) message_length = readall_u16(proc.stdout) message = readall(proc.stdout, message_length) handle_message(source_mac, message.decode('utf-8')) if source_mac in peers: peers[source_mac].lastseen = time.monotonic() else: raise ValueError('Unknown event type from backend: %s' % repr(event_type)) elif fd == proc.stdout.fileno() and event & select.POLLHUP: print('--- Backend exited') sys.exit(1) elif fd == unbuf_stdin.fileno() and event & select.POLLIN: data = unbuf_stdin.read(1024) input_buffer.extend(data) while True: newline_location = input_buffer.find(b'\n') if newline_location == -1: break line, _, input_buffer = input_buffer.partition(b'\n') try: line = line.decode('utf-8') except UnicodeDecodeError: print('--- Error: malformed utf-8') if handle_user_command(proc.stdin, line) == 'quit': writeall(proc.stdin, b'q') running = False if data == b'': # ^D writeall(proc.stdin, b'q') running = False else: raise Exception('Unreachable') def main(): global own_nick, own_status if len(sys.argv) != 3: print("Usage: %s interface nick" % os.path.basename(sys.argv[0]), file=sys.stderr) sys.exit(1) _, interface, own_nick = sys.argv proc = subprocess.Popen([libexec_dir + '/ethermess-backend', interface], stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = sys.stderr, bufsize = 0) # Tell the backend the status and nick try: validate_nick(own_nick) except NonCharacterError as err: print('--- Error: contains non-character U+%04X' % ord(err.args[0])) sys.exit(1) except ControlCharacterError as err: print('--- Error: contains control character U+%04X' % ord(err.args[0])) sys.exit(1) except NickLengthError: print('--- Error: nick too long (max. 255B)') sys.exit(1) own_status = statuses.available encoded = own_nick.encode('utf-8') writeall(proc.stdin, bytes([own_status.value, len(encoded)]) + encoded) # Read our MAC try: mac = readall(proc.stdout, 6) except ReadallError: print('--- Backend exited') sys.exit(1) print('--- MAC: %s' % format_mac(mac)) eventloop(proc) proc.wait() if __name__ == '__main__': main()