ethermess/ethermess.py

605 lines
16 KiB
Python
Raw Permalink Normal View History

2019-07-10 19:10:14 +00:00
#!/usr/bin/env python3
libexec_dir = __LIBEXECDIR__
2019-07-10 19:44:20 +00:00
2019-07-15 16:14:03 +00:00
offline_timeout = 5 * 60
2019-07-14 21:28:30 +00:00
import enum
import os
import select
2019-07-10 19:44:20 +00:00
import subprocess
import sys
2019-07-14 21:28:30 +00:00
import time
class statuses(enum.Enum):
available = 0
unavailable = 1
offline = 2
own_nick = None
own_status = None
default_target_mac = None
2019-07-14 21:28:30 +00:00
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
2019-07-15 17:02:04 +00:00
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
2019-07-10 20:37:39 +00:00
def writeall(f, b):
written = 0
while written < len(b):
written += f.write(b[written:])
2019-07-15 17:40:21 +00:00
class ReadallError(Exception): pass
def readall(f, length):
read = bytearray()
while len(read) < length:
data = f.read(length - len(read))
if data == b'':
2019-07-15 17:40:21 +00:00
raise ReadallError('Could not satisfy read of %i bytes' % length)
read.extend(data)
return bytes(read)
2019-07-14 17:36:12 +00:00
def readall_u16(f):
u16_bytes = readall(f, 2)
2019-07-14 20:13:39 +00:00
return (u16_bytes[0] << 8) | u16_bytes[1]
2019-07-14 17:36:12 +00:00
2019-07-15 17:40:21 +00:00
class MACParseError(Exception): pass
2019-07-10 20:37:39 +00:00
def parse_mac(text):
parts = text.split(':')
if len(parts) != 6:
2019-07-15 17:40:21 +00:00
raise MACParseError('Invalid MAC format: %s' % text)
2019-07-10 20:37:39 +00:00
try:
parsed = bytes(int(field, 16) for field in parts)
except ValueError:
2019-07-15 17:40:21 +00:00
raise MACParseError('Invalid MAC format %s' % text)
2019-07-10 20:37:39 +00:00
return parsed
2019-07-15 17:40:21 +00:00
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)))
2019-07-13 17:51:15 +00:00
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)
2019-07-15 20:05:43 +00:00
def timestamp():
return time.strftime('%H:%M:%S')
2019-07-15 19:57:54 +00:00
class NonCharacterError(Exception): pass
class ControlCharacterError(Exception): pass
2019-07-15 21:43:22 +00:00
def validate_encoding(text, newline_tab_allowed):
2019-07-15 19:57:54 +00:00
# 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
2019-07-15 21:43:22 +00:00
if not newline_tab_allowed or codepoint not in [0x0a, 0x09]:
2019-07-15 19:57:54 +00:00
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):
2019-07-15 21:43:22 +00:00
validate_encoding(nick, newline_tab_allowed = False)
2019-07-15 19:57:54 +00:00
# Nick length is stored as one byte
if len(nick.encode('utf-8')) > 255:
raise NickLengthError
class MessageLengthError(Exception): pass
def validate_message(message):
2019-07-15 21:43:22 +00:00
validate_encoding(message, newline_tab_allowed = True)
2019-07-15 19:57:54 +00:00
# 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
2019-07-14 20:13:39 +00:00
def send_message(backend, mac, message):
encoded = message.encode('utf-8')
writeall(backend, b'm' + mac + bytes([len(encoded) >> 8, len(encoded) & 0xff]) + encoded)
2019-07-10 19:44:20 +00:00
2019-07-15 17:02:04 +00:00
def queue_message(backend, mac, message):
global send_queue
global next_queue_id
2019-07-15 19:57:54 +00:00
validate_message(message)
2019-07-15 17:02:04 +00:00
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)
2019-07-14 20:13:39 +00:00
def send_status_request(backend, mac):
writeall(backend, b'r' + mac)
def set_status_nick(backend, status, nick):
encoded = nick.encode('utf-8')
2019-07-14 21:28:30 +00:00
writeall(backend, b's' + bytes([status.value, len(encoded)]) + encoded)
2019-08-04 08:16:19 +00:00
def send_file_offer(backend, mac):
writeall(backend, b'f' + mac)
2019-07-14 20:13:39 +00:00
def handle_user_command(backend, line):
global own_nick, own_status
global default_target_mac
2019-07-15 19:57:54 +00:00
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 = ''
2019-07-14 20:13:39 +00:00
2019-07-15 17:40:21 +00:00
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)
2019-07-15 17:40:21 +00:00
else:
# Show own
print('--- %s' % own_status.name)
elif command == '/peers' and rest == '':
# List all the known peers
for mac, peer in peers.items():
2019-07-16 09:43:13 +00:00
print('--- ~%s (%s) [%s]' % (peer.nick, peer.status.name, format_mac(mac)))
2019-07-15 17:40:21 +00:00
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
2019-07-15 19:57:54 +00:00
validate_nick(nick)
own_nick = nick
set_status_nick(backend, own_status, own_nick)
else:
# Show own
print('--- ~%s' % own_nick)
2019-07-15 17:40:21 +00:00
elif command == '/target':
# Set default target of messages
default_target_mac = mac_from_name(rest)
2019-08-04 08:16:19 +00:00
elif command == '/sendfile':
# Send an offer to transmit the file
send_file_offer(backend, mac_from_name(rest))
2019-07-15 17:40:21 +00:00
elif command == '/quit':
# Quit
return 'quit'
2019-07-15 17:40:21 +00:00
else:
# Display usage
2019-08-04 08:16:19 +00:00
print('--- / <message>; /msg <target> <message>; /status [<target>]; /peers; /available; /unavailable; /nick [<nick>]; /target <target>; /sendfile <target>; /quit')
2019-07-15 19:57:54 +00:00
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)
2019-07-15 17:04:51 +00:00
2019-07-15 19:57:54 +00:00
except NoMatchesError as err:
print('--- Name %s matches no peers' % err.args[0])
2019-07-15 19:57:54 +00:00
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)')
2019-07-10 21:00:44 +00:00
2019-07-14 21:28:30 +00:00
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:
2019-07-15 20:05:43 +00:00
print('%s === ~%s -> ~%s [%s]' % (timestamp(), peers[mac].nick, nick, format_mac(mac)))
2019-07-14 21:28:30 +00:00
peers[mac].nick = nick
if status != peers[mac].status:
if status == statuses.offline:
2019-07-15 20:05:43 +00:00
print('%s <<< ~%s [%s]' % (timestamp(), nick, format_mac(mac)))
2019-07-14 21:28:30 +00:00
elif peers[mac].status is None or peers[mac].status == statuses.offline:
if status == statuses.available:
2019-07-15 20:05:43 +00:00
print('%s >>> ~%s [%s]' % (timestamp(), nick, format_mac(mac)))
2019-07-14 21:28:30 +00:00
else:
2019-07-15 20:05:43 +00:00
print('%s >>> ~%s (%s) [%s]' % (timestamp(), nick, status.name, format_mac(mac)))
2019-07-14 21:28:30 +00:00
else:
2019-07-15 20:05:43 +00:00
print('%s === ~%s (%s) [%s]' % (timestamp(), nick, status.name, format_mac(mac)))
2019-07-14 21:28:30 +00:00
peers[mac].status = status
2019-07-15 17:02:04 +00:00
def nick_from_mac(mac):
2019-07-14 21:28:30 +00:00
global peers
if mac not in peers:
2019-07-15 17:02:04 +00:00
return format_mac(mac)
2019-07-14 21:28:30 +00:00
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
2019-07-15 17:02:04 +00:00
return '~' + nick
2019-07-14 21:28:30 +00:00
else:
# Non-unique nicks: [MAC]~nick
2019-07-15 17:02:04 +00:00
return '[%s]~%s' % (format_mac(mac), nick)
2019-07-14 21:28:30 +00:00
2019-07-15 17:02:04 +00:00
def handle_message(mac, message):
nick = nick_from_mac(mac)
2019-07-15 20:05:43 +00:00
ts = timestamp()
2019-07-15 20:24:15 +00:00
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))
2019-07-14 21:28:30 +00:00
2019-07-14 17:12:45 +00:00
def eventloop(proc):
2019-07-15 16:14:03 +00:00
global peers
2019-07-15 17:02:04 +00:00
global send_queue, ack_failed
2019-07-15 16:14:03 +00:00
# Create unbuffered version of stdin and close the old one as we
# won't need it anymore
2019-07-14 17:12:45 +00:00
unbuf_stdin = open(sys.stdin.buffer.fileno(), 'rb', buffering = 0)
sys.stdin.close()
2019-07-14 17:12:45 +00:00
# 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:
2019-07-15 16:14:03 +00:00
# Handle offline timeouts
now = time.monotonic()
for mac, peer in peers.items():
if peer.lastseen + offline_timeout < now:
peer.status = statuses.offline
2019-07-15 20:05:43 +00:00
print('%s <<< (timeout) ~%s [%s]' % (timestamp(), peer.nick, format_mac(mac)))
2019-07-15 16:14:03 +00:00
# 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):
2019-07-14 17:12:45 +00:00
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':
2019-07-14 17:12:45 +00:00
# Status
source_mac = readall(proc.stdout, 6)
status, = readall(proc.stdout, 1)
nick_length, = readall(proc.stdout, 1)
nick = readall(proc.stdout, nick_length,)
2019-07-14 21:28:30 +00:00
handle_status(source_mac, statuses(status), nick.decode('utf-8'))
2019-07-10 20:37:39 +00:00
2019-07-15 16:14:03 +00:00
peers[source_mac].lastseen = time.monotonic()
elif event_type == b'i':
# Msgid for message
2019-07-14 17:39:45 +00:00
msgid = readall_u16(proc.stdout)
2019-07-15 17:02:04 +00:00
send_queue[0].msgid = msgid
2019-07-14 17:36:12 +00:00
2019-07-14 19:49:12 +00:00
elif event_type == b'I':
# Failed to get msgid for message
2019-07-15 17:02:04 +00:00
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)
2019-07-14 19:49:12 +00:00
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()
2019-07-15 16:14:03 +00:00
2019-07-15 17:02:04 +00:00
# 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))
2019-07-14 19:49:12 +00:00
elif event_type == b'A':
# ACK not received (and message send failed)
2019-07-15 17:02:04 +00:00
# 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)
2019-07-14 19:49:12 +00:00
2019-07-14 17:36:12 +00:00
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)
2019-07-14 21:28:30 +00:00
handle_message(source_mac, message.decode('utf-8'))
2019-07-14 17:39:45 +00:00
if source_mac in peers:
peers[source_mac].lastseen = time.monotonic()
2019-07-15 16:14:03 +00:00
2019-07-14 17:12:45 +00:00
else:
2019-07-14 21:28:30 +00:00
raise ValueError('Unknown event type from backend: %s' % repr(event_type))
2019-07-14 17:12:45 +00:00
elif fd == proc.stdout.fileno() and event & select.POLLHUP:
2019-07-15 19:57:54 +00:00
print('--- Backend exited')
sys.exit(1)
2019-07-14 17:12:45 +00:00
elif fd == unbuf_stdin.fileno() and event & select.POLLIN:
data = unbuf_stdin.read(1024)
input_buffer.extend(data)
2019-07-10 20:37:39 +00:00
2019-07-14 17:12:45 +00:00
while True:
newline_location = input_buffer.find(b'\n')
if newline_location == -1:
break
2019-07-14 17:12:45 +00:00
line, _, input_buffer = input_buffer.partition(b'\n')
2019-07-15 19:57:54 +00:00
try:
line = line.decode('utf-8')
except UnicodeDecodeError:
print('--- Error: malformed utf-8')
if handle_user_command(proc.stdin, line) == 'quit':
2019-07-15 17:04:51 +00:00
writeall(proc.stdin, b'q')
running = False
2019-07-13 17:51:15 +00:00
2019-07-14 17:12:45 +00:00
if data == b'':
# ^D
writeall(proc.stdin, b'q')
running = False
2019-07-13 17:51:15 +00:00
else:
2019-07-14 17:12:45 +00:00
raise Exception('Unreachable')
2019-07-15 16:14:03 +00:00
2019-07-14 17:12:45 +00:00
def main():
global own_nick, own_status
2019-07-15 20:47:03 +00:00
if len(sys.argv) != 3:
print("Usage: %s interface nick" % os.path.basename(sys.argv[0]), file=sys.stderr)
2019-07-15 20:47:03 +00:00
sys.exit(1)
_, interface, own_nick = sys.argv
2019-07-15 20:47:03 +00:00
proc = subprocess.Popen([libexec_dir + '/ethermess-backend', interface], stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = sys.stderr, bufsize = 0)
2019-07-10 20:37:39 +00:00
2019-07-14 17:12:45 +00:00
# Tell the backend the status and nick
2019-07-16 11:44:22 +00:00
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)
2019-07-14 21:28:30 +00:00
own_status = statuses.available
encoded = own_nick.encode('utf-8')
2019-07-14 21:28:30 +00:00
writeall(proc.stdin, bytes([own_status.value, len(encoded)]) + encoded)
2019-07-14 17:12:45 +00:00
# Read our MAC
try:
mac = readall(proc.stdout, 6)
except ReadallError:
print('--- Backend exited')
sys.exit(1)
2019-07-14 21:28:30 +00:00
print('--- MAC: %s' % format_mac(mac))
2019-07-14 17:12:45 +00:00
eventloop(proc)
2019-07-14 17:12:45 +00:00
proc.wait()
2019-07-12 22:05:33 +00:00
2019-07-14 17:12:45 +00:00
if __name__ == '__main__':
main()