More cleanup, merged pymumble into the project so I don't have to maintain 2 separate things, tested working version.
This commit is contained in:
11
pymumble_py3/__init__.py
Normal file
11
pymumble_py3/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# pymumble_py3 - Integrated from https://github.com/azlux/pymumble
|
||||
# This is a complete copy of the pymumble library integrated into Bragi
|
||||
# to avoid external dependency issues after the original project maintenance stopped.
|
||||
#
|
||||
import os
|
||||
# Fix protobuf compatibility issue with Python 3.13
|
||||
os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
|
||||
|
||||
from .mumble import Mumble
|
||||
140
pymumble_py3/acl.py
Normal file
140
pymumble_py3/acl.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
|
||||
from .errors import ACLChanGroupNotExist
|
||||
from threading import Lock
|
||||
from . import messages
|
||||
|
||||
|
||||
class ACL(dict):
|
||||
def __init__(self, mumble_object, channel_id):
|
||||
self.mumble_object = mumble_object
|
||||
self.channel_id = channel_id # channel id attached to the ACLS
|
||||
self.inherit_acls = False
|
||||
self.groups = {}
|
||||
self.acls = {}
|
||||
self.lock = Lock()
|
||||
|
||||
def update(self, message):
|
||||
self.lock.acquire()
|
||||
self.inherit_acls = bool(message.inherit_acls)
|
||||
for msg_group in message.groups:
|
||||
if msg_group.name in self.groups:
|
||||
self.groups[msg_group.name].update(msg_group)
|
||||
else:
|
||||
self.groups[msg_group.name] = ChanGroup()
|
||||
self.groups[msg_group.name].update(msg_group)
|
||||
for msg_acl in message.acls:
|
||||
if msg_acl.group in self.acls:
|
||||
self.acls[msg_acl.group].update(msg_acl)
|
||||
else:
|
||||
self.acls[msg_acl.group] = ChanACL()
|
||||
self.acls[msg_acl.group].update(msg_acl)
|
||||
self.lock.release()
|
||||
|
||||
def request_group_update(self, group_name):
|
||||
if group_name not in self.groups:
|
||||
self.mumble_object.channels[self.channel_id].request_acl()
|
||||
i = 0
|
||||
while group_name not in self.groups and i < 20:
|
||||
time.sleep(0.2)
|
||||
i += 1
|
||||
if i == 20:
|
||||
raise ACLChanGroupNotExist(group_name)
|
||||
|
||||
def add_user(self, group_name, user_id):
|
||||
self.request_group_update(group_name)
|
||||
if user_id not in self.groups[group_name].add:
|
||||
self.groups[group_name].add.append(user_id)
|
||||
self.send_update()
|
||||
|
||||
def del_user(self, group_name, user_id):
|
||||
self.request_group_update(group_name)
|
||||
self.groups[group_name].add.remove(user_id)
|
||||
self.send_update()
|
||||
|
||||
def add_remove_user(self, group_name, user_id):
|
||||
self.request_group_update(group_name)
|
||||
if user_id not in self.groups[group_name].remove:
|
||||
self.groups[group_name].remove.append(user_id)
|
||||
self.send_update()
|
||||
|
||||
def del_remove_user(self, group_name, user_id):
|
||||
self.request_group_update(group_name)
|
||||
self.groups[group_name].remove.remove(user_id)
|
||||
self.send_update()
|
||||
|
||||
def send_update(self):
|
||||
all_groups = self.groups.items()
|
||||
res_group = [vars(i[1]) for i in all_groups] # Transform the Class into a dictionary
|
||||
|
||||
all_acls = self.acls.items()
|
||||
res_acl = [vars(i[1]) for i in all_acls] # Transform the Class into a dictionary
|
||||
|
||||
cmd = messages.UpdateACL(channel_id=self.channel_id, inherit_acls=self.inherit_acls, chan_group=res_group, chan_acl=res_acl)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
|
||||
class ChanGroup(dict):
|
||||
"""Object that stores and update all ChanGroups ACL"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = None
|
||||
self.acl = None
|
||||
self.inherited = None
|
||||
self.inherit = None
|
||||
self.inheritable = None
|
||||
self.add = []
|
||||
self.remove = []
|
||||
self.inherited_members = []
|
||||
|
||||
def update(self, message):
|
||||
"""Update a ACL information, based on the incoming message"""
|
||||
self.name = str(message.name)
|
||||
|
||||
if message.HasField('inherit'):
|
||||
self.inherit = bool(message.inherit)
|
||||
if message.HasField('inherited'):
|
||||
self.inherited = bool(message.inherited)
|
||||
if message.HasField('inheritable'):
|
||||
self.inheritable = bool(message.inheritable)
|
||||
|
||||
if message.add:
|
||||
for user in message.add:
|
||||
self.add.append(int(user))
|
||||
if message.remove:
|
||||
for user in message.remove:
|
||||
self.remove.append(int(user))
|
||||
if message.inherited_members:
|
||||
for user in message.inherited_members:
|
||||
self.inherited_members.append(int(user))
|
||||
|
||||
|
||||
class ChanACL(dict):
|
||||
"""Object that stores and update all ChanACL ACL"""
|
||||
|
||||
def __init__(self):
|
||||
self.apply_here = None
|
||||
self.apply_subs = None
|
||||
self.inherited = None
|
||||
self.user_id = None
|
||||
self.group = None
|
||||
self.grant = None
|
||||
self.deny = None
|
||||
|
||||
def update(self, message):
|
||||
"""Update a ACL information, based on the incoming message"""
|
||||
if message.HasField('apply_here'):
|
||||
self.apply_here = bool(message.apply_here)
|
||||
if message.HasField('apply_subs'):
|
||||
self.apply_subs = bool(message.apply_subs)
|
||||
if message.HasField('inherited'):
|
||||
self.inherited = bool(message.inherited)
|
||||
if message.HasField('user_id'):
|
||||
self.user_id = int(message.user_id)
|
||||
if message.HasField('group'):
|
||||
self.group = str(message.group)
|
||||
if message.HasField('grant'):
|
||||
self.grant = int(message.grant)
|
||||
if message.HasField('deny'):
|
||||
self.deny = int(message.deny)
|
||||
42
pymumble_py3/blobs.py
Normal file
42
pymumble_py3/blobs.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import struct
|
||||
|
||||
from .constants import *
|
||||
from .mumble_pb2 import RequestBlob
|
||||
|
||||
|
||||
class Blobs(dict):
|
||||
"""
|
||||
Manage the Blob library
|
||||
"""
|
||||
def __init__(self, mumble_object):
|
||||
self.mumble_object = mumble_object
|
||||
|
||||
def get_user_comment(self, hash):
|
||||
"""Request the comment of a user"""
|
||||
if hash in self:
|
||||
return
|
||||
request = RequestBlob()
|
||||
request.session_comment.extend(struct.unpack("!5I", hash))
|
||||
|
||||
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)
|
||||
|
||||
def get_user_texture(self, hash):
|
||||
"""Request the image of a user"""
|
||||
if hash in self:
|
||||
return
|
||||
|
||||
request = RequestBlob()
|
||||
request.session_texture.extend(struct.unpack("!5I", hash))
|
||||
|
||||
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)
|
||||
|
||||
def get_channel_description(self, hash):
|
||||
"""Request the description/comment of a channel"""
|
||||
if hash in self:
|
||||
return
|
||||
|
||||
request = RequestBlob()
|
||||
request.channel_description.extend(struct.unpack("!5I", hash))
|
||||
|
||||
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)
|
||||
94
pymumble_py3/callbacks.py
Normal file
94
pymumble_py3/callbacks.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .errors import UnknownCallbackError
|
||||
from .constants import *
|
||||
import threading
|
||||
|
||||
|
||||
class CallBacks(dict):
|
||||
"""
|
||||
Define the callbacks that can be registered by the application.
|
||||
Multiple functions can be assigned to a callback using "add_callback"
|
||||
|
||||
The call is done from within the pymumble loop thread, it's important to
|
||||
keep processing short to avoid delays on audio transmission
|
||||
"""
|
||||
def __init__(self):
|
||||
self.update({
|
||||
PYMUMBLE_CLBK_CONNECTED: None, # Connection succeeded
|
||||
PYMUMBLE_CLBK_DISCONNECTED: None, # Connection as been dropped
|
||||
PYMUMBLE_CLBK_CHANNELCREATED: None, # send the created channel object as parameter
|
||||
PYMUMBLE_CLBK_CHANNELUPDATED: None, # send the updated channel object and a dict with all the modified fields as parameter
|
||||
PYMUMBLE_CLBK_CHANNELREMOVED: None, # send the removed channel object as parameter
|
||||
PYMUMBLE_CLBK_USERCREATED: None, # send the added user object as parameter
|
||||
PYMUMBLE_CLBK_USERUPDATED: None, # send the updated user object and a dict with all the modified fields as parameter
|
||||
PYMUMBLE_CLBK_USERREMOVED: None, # send the removed user object and the mumble message as parameter
|
||||
PYMUMBLE_CLBK_SOUNDRECEIVED: None, # send the user object that received the sound and the SoundChunk object itself
|
||||
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED: None, # Send the received message
|
||||
PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED: None, # Send the contextaction message
|
||||
PYMUMBLE_CLBK_ACLRECEIVED: None, # Send the received ACL permissions object
|
||||
PYMUMBLE_CLBK_PERMISSIONDENIED: None, # Permission Denied for some action, send information
|
||||
})
|
||||
|
||||
def set_callback(self, callback, dest):
|
||||
"""Define the function to call for a specific callback. Suppress any existing callback function"""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
self[callback] = [dest]
|
||||
|
||||
def add_callback(self, callback, dest):
|
||||
"""Add the function to call for a specific callback."""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
if self[callback] is None:
|
||||
self[callback] = list()
|
||||
self[callback].append(dest)
|
||||
|
||||
def get_callback(self, callback):
|
||||
"""Get the functions assigned to a callback as a list. Return None if no callback defined"""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
return self[callback]
|
||||
|
||||
def remove_callback(self, callback, dest):
|
||||
"""Remove a specific function from a specific callback. Function object must be the one added before."""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
if self[callback] is None or dest not in self[callback]:
|
||||
raise UnknownCallbackError("Function not registered for callback \"%s\"." % callback)
|
||||
|
||||
self[callback].remove(dest)
|
||||
if len(self[callback]) == 0:
|
||||
self[callback] = None
|
||||
|
||||
def reset_callback(self, callback):
|
||||
"""remove functions for a defined callback"""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
self[callback] = None
|
||||
|
||||
def call_callback(self, callback, *pos_parameters):
|
||||
"""Call all the registered function for a specific callback."""
|
||||
if callback not in self:
|
||||
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
|
||||
|
||||
if self[callback]:
|
||||
for func in self[callback]:
|
||||
if callback is PYMUMBLE_CLBK_TEXTMESSAGERECEIVED:
|
||||
thr = threading.Thread(target=func, args=pos_parameters)
|
||||
thr.start()
|
||||
else:
|
||||
func(*pos_parameters)
|
||||
|
||||
def __call__(self, callback, *pos_parameters):
|
||||
"""shortcut to be able to call the dict element as a function"""
|
||||
self.call_callback(callback, *pos_parameters)
|
||||
|
||||
def get_callbacks_list(self):
|
||||
"""Get a list of all callbacks"""
|
||||
return list(self.keys())
|
||||
274
pymumble_py3/channels.py
Normal file
274
pymumble_py3/channels.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .constants import *
|
||||
from threading import Lock
|
||||
from .errors import UnknownChannelError, TextTooLongError, ImageTooBigError
|
||||
from .acl import ACL
|
||||
from . import messages
|
||||
|
||||
|
||||
class Channels(dict):
|
||||
"""
|
||||
Object that Stores all channels and their properties.
|
||||
"""
|
||||
|
||||
def __init__(self, mumble_object, callbacks):
|
||||
self.mumble_object = mumble_object
|
||||
self.callbacks = callbacks
|
||||
|
||||
self.lock = Lock()
|
||||
|
||||
def update(self, message):
|
||||
"""Update the channel information based on an incoming message"""
|
||||
self.lock.acquire()
|
||||
|
||||
if message.channel_id not in self: # create the channel
|
||||
self[message.channel_id] = Channel(self.mumble_object, message)
|
||||
self.callbacks(PYMUMBLE_CLBK_CHANNELCREATED, self[message.channel_id])
|
||||
else: # update the channel
|
||||
actions = self[message.channel_id].update(message)
|
||||
self.callbacks(PYMUMBLE_CLBK_CHANNELUPDATED, self[message.channel_id], actions)
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def remove(self, id):
|
||||
"""Delete a channel when server signal the channel is removed"""
|
||||
self.lock.acquire()
|
||||
|
||||
if id in self:
|
||||
channel = self[id]
|
||||
del self[id]
|
||||
self.callbacks(PYMUMBLE_CLBK_CHANNELREMOVED, channel)
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def find_by_tree(self, tree):
|
||||
"""Find a channel by its full path (a list with an element for each leaf)"""
|
||||
if not getattr(tree, '__iter__', False):
|
||||
tree = tree # function use argument as a list
|
||||
|
||||
current = self[0]
|
||||
|
||||
for name in tree: # going up the tree
|
||||
found = False
|
||||
for subchannel in self.get_childs(current):
|
||||
if subchannel["name"] == name:
|
||||
current = subchannel
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found: # channel not found
|
||||
err = "Cannot find channel %s" % str(tree)
|
||||
raise UnknownChannelError(err)
|
||||
|
||||
return current
|
||||
|
||||
def get_childs(self, channel):
|
||||
"""Get the child channels of a channel in a list"""
|
||||
childs = list()
|
||||
|
||||
for item in self.values():
|
||||
if item.get('parent') is not None and item["parent"] == channel["channel_id"]:
|
||||
childs.append(item)
|
||||
|
||||
return childs
|
||||
|
||||
def get_descendants(self, channel):
|
||||
"""Get all the descendant of a channel, in nested lists"""
|
||||
descendants = list()
|
||||
|
||||
for subchannel in self.get_childs(channel):
|
||||
descendants.append(self.get_childs(subchannel))
|
||||
|
||||
return descendants
|
||||
|
||||
def get_tree(self, channel):
|
||||
"""Get the whole list of channels, in a multidimensional list"""
|
||||
tree = list()
|
||||
|
||||
current = channel
|
||||
|
||||
while current["channel_id"] != 0:
|
||||
tree.insert(0, current)
|
||||
current = self[current["channel_id"]]
|
||||
|
||||
tree.insert(0, self[0])
|
||||
|
||||
return tree
|
||||
|
||||
def find_by_name(self, name):
|
||||
"""Find a channel by name. Stop on the first that match"""
|
||||
if name == "":
|
||||
return self[0]
|
||||
|
||||
for obj in list(self.values()):
|
||||
if obj["name"] == name:
|
||||
return obj
|
||||
|
||||
err = "Channel %s does not exists" % name
|
||||
raise UnknownChannelError(err)
|
||||
|
||||
def new_channel(self, parent_id, name, temporary=False):
|
||||
cmd = messages.CreateChannel(parent_id, name, temporary)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def remove_channel(self, channel_id):
|
||||
cmd = messages.RemoveChannel(channel_id)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unlink_every_channel(self):
|
||||
"""
|
||||
Unlink every channels in server.
|
||||
So there will be no channel linked to other channel.
|
||||
"""
|
||||
for channel in list(self.values()):
|
||||
if "links" in channel:
|
||||
cmd = messages.UnlinkChannel({"channel_id": channel['channel_id'], "remove_ids": channel["links"]})
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
|
||||
class Channel(dict):
|
||||
"""
|
||||
Stores information about one specific channel
|
||||
"""
|
||||
|
||||
def __init__(self, mumble_object, message):
|
||||
self.mumble_object = mumble_object
|
||||
self["channel_id"] = message.channel_id
|
||||
self.acl = ACL(mumble_object=mumble_object, channel_id=self["channel_id"])
|
||||
self.update(message)
|
||||
|
||||
def get_users(self):
|
||||
users = []
|
||||
for user in list(self.mumble_object.users.values()):
|
||||
if user["channel_id"] == self["channel_id"]:
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
def update(self, message):
|
||||
"""Update a channel based on an incoming message"""
|
||||
actions = dict()
|
||||
|
||||
for (field, value) in message.ListFields():
|
||||
if field.name in ("session", "actor", "description_hash"):
|
||||
continue
|
||||
actions.update(self.update_field(field.name, value))
|
||||
|
||||
if message.HasField("description_hash"):
|
||||
actions.update(self.update_field("description_hash", message.description_hash))
|
||||
if message.HasField("description"):
|
||||
self.mumble_object.blobs[message.description_hash] = message.description
|
||||
else:
|
||||
self.mumble_object.blobs.get_channel_description(message.description_hash)
|
||||
|
||||
return actions # return a dict with updates performed, useful for the callback functions
|
||||
|
||||
def update_acl(self, message):
|
||||
self.acl.update(message)
|
||||
|
||||
def get_id(self):
|
||||
return self["channel_id"]
|
||||
|
||||
def update_field(self, name, field):
|
||||
"""Update one value"""
|
||||
actions = dict()
|
||||
if name not in self or self[name] != field:
|
||||
self[name] = field
|
||||
actions[name] = field
|
||||
|
||||
return actions # return a dict with updates performed, useful for the callback functions
|
||||
|
||||
def get_property(self, property):
|
||||
if property in self:
|
||||
return self[property]
|
||||
else:
|
||||
return None
|
||||
|
||||
def move_in(self, session=None):
|
||||
"""Ask to move a session in a specific channel. By default move pymumble own session"""
|
||||
if session is None:
|
||||
session = self.mumble_object.users.myself_session
|
||||
|
||||
cmd = messages.MoveCmd(session, self["channel_id"])
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def remove(self):
|
||||
cmd = messages.RemoveChannel(self["channel_id"])
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def send_text_message(self, message):
|
||||
"""Send a text message to the channel."""
|
||||
|
||||
# TODO: This check should be done inside execute_command()
|
||||
# However, this is currently not possible because execute_command() does
|
||||
# not actually execute the command.
|
||||
if len(message) > self.mumble_object.get_max_image_length() != 0:
|
||||
raise ImageTooBigError(self.mumble_object.get_max_image_length())
|
||||
|
||||
if not ("<img" in message and "src" in message):
|
||||
if len(message) > self.mumble_object.get_max_message_length() != 0:
|
||||
raise TextTooLongError(self.mumble_object.get_max_message_length())
|
||||
|
||||
session = self.mumble_object.users.myself_session
|
||||
|
||||
cmd = messages.TextMessage(session, self["channel_id"], message)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def link(self, channel_id):
|
||||
"""Link selected channel with other channel"""
|
||||
cmd = messages.LinkChannel({"channel_id": self["channel_id"], "add_id": channel_id})
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unlink(self, channel_id):
|
||||
"""Unlink one channel which is linked to a specific channel."""
|
||||
cmd = messages.UnlinkChannel({"channel_id": self["channel_id"], "remove_ids": [channel_id]})
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unlink_all(self):
|
||||
"""Unlink all channels which is linked to a specific channel."""
|
||||
if "links" in self:
|
||||
cmd = messages.UnlinkChannel({"channel_id": self["channel_id"], "remove_ids": self["links"]})
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def rename_channel(self, name):
|
||||
params = {
|
||||
'channel_id': self['channel_id'],
|
||||
'name': name
|
||||
}
|
||||
cmd = messages.UpdateChannel(params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def move_channel(self, new_parent_id):
|
||||
params = {
|
||||
'channel_id': self['channel_id'],
|
||||
'parent': new_parent_id
|
||||
}
|
||||
cmd = messages.UpdateChannel(params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def set_channel_position(self, position):
|
||||
params = {
|
||||
'channel_id': self['channel_id'],
|
||||
'position': position
|
||||
}
|
||||
cmd = messages.UpdateChannel(params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def set_channel_max_users(self, max_users):
|
||||
params = {
|
||||
'channel_id': self['channel_id'],
|
||||
'max_users': max_users
|
||||
}
|
||||
cmd = messages.UpdateChannel(params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def set_channel_description(self, description):
|
||||
params = {
|
||||
'channel_id': self['channel_id'],
|
||||
'description': description
|
||||
}
|
||||
cmd = messages.UpdateChannel(params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def request_acl(self):
|
||||
cmd = messages.QueryACL(self["channel_id"])
|
||||
self.mumble_object.execute_command(cmd)
|
||||
54
pymumble_py3/commands.py
Normal file
54
pymumble_py3/commands.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from threading import Lock
|
||||
from collections import deque
|
||||
|
||||
|
||||
class Commands:
|
||||
"""
|
||||
Store to commands to be sent to the murmur server,
|
||||
from whatever tread.
|
||||
Each command has it's own lock semaphore to signal is received an answer
|
||||
"""
|
||||
def __init__(self):
|
||||
self.id = 0
|
||||
|
||||
self.queue = deque()
|
||||
|
||||
self.lock = Lock()
|
||||
|
||||
def new_cmd(self, cmd):
|
||||
"""Add a command to the queue"""
|
||||
self.lock.acquire()
|
||||
|
||||
self.id += 1
|
||||
cmd.cmd_id = self.id
|
||||
self.queue.appendleft(cmd)
|
||||
cmd.lock.acquire()
|
||||
|
||||
self.lock.release()
|
||||
return cmd.lock
|
||||
|
||||
def is_cmd(self):
|
||||
"""Check if there is a command waiting in the queue"""
|
||||
if len(self.queue) > 0:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def pop_cmd(self):
|
||||
"""Return the next command and remove it from the queue"""
|
||||
self.lock.acquire()
|
||||
|
||||
if len(self.queue) > 0:
|
||||
question = self.queue.pop()
|
||||
self.lock.release()
|
||||
return question
|
||||
else:
|
||||
self.lock.release()
|
||||
return None
|
||||
|
||||
def answer(self, cmd):
|
||||
"""Unlock the command to signal it's completion"""
|
||||
cmd.lock.release()
|
||||
|
||||
|
||||
99
pymumble_py3/constants.py
Normal file
99
pymumble_py3/constants.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import platform
|
||||
import sys
|
||||
|
||||
PYMUMBLE_VERSION = "1.7"
|
||||
|
||||
# ============================================================================
|
||||
# Tunable parameters
|
||||
# ============================================================================
|
||||
PYMUMBLE_CONNECTION_RETRY_INTERVAL = 10 # in sec
|
||||
PYMUMBLE_AUDIO_PER_PACKET = float(20)/1000 # size of one audio packet in sec
|
||||
PYMUMBLE_BANDWIDTH = 50 * 1000 # total outgoing bitrate in bit/seconds
|
||||
PYMUMBLE_LOOP_RATE = 0.01 # pause done between two iteration of the main loop of the mumble thread, in sec
|
||||
# should be small enough to manage the audio output, so smaller than PYMUMBLE_AUDIO_PER_PACKET
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
PYMUMBLE_PROTOCOL_VERSION = (1, 4, 287)
|
||||
PYMUMBLE_VERSION_STRING = "PyMumble %s" % PYMUMBLE_VERSION
|
||||
PYMUMBLE_OS_STRING = "PyMumble %s" % PYMUMBLE_VERSION
|
||||
PYMUMBLE_OS_VERSION_STRING = "Python %s - %s %s" % (sys.version, platform.system(), platform.release())
|
||||
|
||||
PYMUMBLE_PING_DELAY = 10 # interval between 2 pings in sec
|
||||
|
||||
PYMUMBLE_SAMPLERATE = 48000 # in hz
|
||||
|
||||
PYMUMBLE_SEQUENCE_DURATION = float(10)/1000 # in sec
|
||||
PYMUMBLE_SEQUENCE_RESET_INTERVAL = 5 # in sec
|
||||
PYMUMBLE_READ_BUFFER_SIZE = 4096 # how much bytes to read at a time from the control socket, in bytes
|
||||
|
||||
# client connection state
|
||||
PYMUMBLE_CONN_STATE_NOT_CONNECTED = 0
|
||||
PYMUMBLE_CONN_STATE_AUTHENTICATING = 1
|
||||
PYMUMBLE_CONN_STATE_CONNECTED = 2
|
||||
PYMUMBLE_CONN_STATE_FAILED = 3
|
||||
|
||||
# Mumble control messages types
|
||||
PYMUMBLE_MSG_TYPES_VERSION = 0
|
||||
PYMUMBLE_MSG_TYPES_UDPTUNNEL = 1
|
||||
PYMUMBLE_MSG_TYPES_AUTHENTICATE = 2
|
||||
PYMUMBLE_MSG_TYPES_PING = 3
|
||||
PYMUMBLE_MSG_TYPES_REJECT = 4
|
||||
PYMUMBLE_MSG_TYPES_SERVERSYNC = 5
|
||||
PYMUMBLE_MSG_TYPES_CHANNELREMOVE = 6
|
||||
PYMUMBLE_MSG_TYPES_CHANNELSTATE = 7
|
||||
PYMUMBLE_MSG_TYPES_USERREMOVE = 8
|
||||
PYMUMBLE_MSG_TYPES_USERSTATE = 9
|
||||
PYMUMBLE_MSG_TYPES_BANLIST = 10
|
||||
PYMUMBLE_MSG_TYPES_TEXTMESSAGE = 11
|
||||
PYMUMBLE_MSG_TYPES_PERMISSIONDENIED = 12
|
||||
PYMUMBLE_MSG_TYPES_ACL = 13
|
||||
PYMUMBLE_MSG_TYPES_QUERYUSERS = 14
|
||||
PYMUMBLE_MSG_TYPES_CRYPTSETUP = 15
|
||||
PYMUMBLE_MSG_TYPES_CONTEXTACTIONMODIFY = 16
|
||||
PYMUMBLE_MSG_TYPES_CONTEXTACTION = 17
|
||||
PYMUMBLE_MSG_TYPES_USERLIST = 18
|
||||
PYMUMBLE_MSG_TYPES_VOICETARGET = 19
|
||||
PYMUMBLE_MSG_TYPES_PERMISSIONQUERY = 20
|
||||
PYMUMBLE_MSG_TYPES_CODECVERSION = 21
|
||||
PYMUMBLE_MSG_TYPES_USERSTATS = 22
|
||||
PYMUMBLE_MSG_TYPES_REQUESTBLOB = 23
|
||||
PYMUMBLE_MSG_TYPES_SERVERCONFIG = 24
|
||||
|
||||
# callbacks names
|
||||
PYMUMBLE_CLBK_CONNECTED = "connected"
|
||||
PYMUMBLE_CLBK_DISCONNECTED = "disconnected"
|
||||
PYMUMBLE_CLBK_CHANNELCREATED = "channel_created"
|
||||
PYMUMBLE_CLBK_CHANNELUPDATED = "channel_updated"
|
||||
PYMUMBLE_CLBK_CHANNELREMOVED = "channel_remove"
|
||||
PYMUMBLE_CLBK_USERCREATED = "user_created"
|
||||
PYMUMBLE_CLBK_USERUPDATED = "user_updated"
|
||||
PYMUMBLE_CLBK_USERREMOVED = "user_removed"
|
||||
PYMUMBLE_CLBK_SOUNDRECEIVED = "sound_received"
|
||||
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED = "text_received"
|
||||
PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED = "contextAction_received"
|
||||
PYMUMBLE_CLBK_ACLRECEIVED = "acl_received"
|
||||
PYMUMBLE_CLBK_PERMISSIONDENIED = "permission_denied"
|
||||
|
||||
# audio types
|
||||
PYMUMBLE_AUDIO_TYPE_CELT_ALPHA = 0
|
||||
PYMUMBLE_AUDIO_TYPE_PING = 1
|
||||
PYMUMBLE_AUDIO_TYPE_SPEEX = 2
|
||||
PYMUMBLE_AUDIO_TYPE_CELT_BETA = 3
|
||||
PYMUMBLE_AUDIO_TYPE_OPUS = 4
|
||||
PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE = "audio" # "voip"
|
||||
|
||||
# command names
|
||||
PYMUMBLE_CMD_MOVE = "move"
|
||||
PYMUMBLE_CMD_MODUSERSTATE = "update_user"
|
||||
PYMUMBLE_CMD_TEXTMESSAGE = "text_message"
|
||||
PYMUMBLE_CMD_TEXTPRIVATEMESSAGE = "text_private_message"
|
||||
PYMUMBLE_CMD_LINKCHANNEL = "link"
|
||||
PYMUMBLE_CMD_UNLINKCHANNEL = "unlink"
|
||||
PYMUMBLE_CMD_QUERYACL = "get_acl"
|
||||
PYMUMBLE_CMD_UPDATEACL = "update_acl"
|
||||
PYMUMBLE_CMD_REMOVEUSER = "remove_user"
|
||||
PYMUMBLE_CMD_UPDATECHANNEL = "update_channel"
|
||||
383
pymumble_py3/crypto.py
Normal file
383
pymumble_py3/crypto.py
Normal file
@@ -0,0 +1,383 @@
|
||||
'''
|
||||
OCB2 crypto, broadly following the implementation from Mumble
|
||||
'''
|
||||
from typing import Tuple
|
||||
import struct
|
||||
import time
|
||||
from math import ceil
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
|
||||
AES_BLOCK_SIZE = 128 // 8 # Number of bytes in a block
|
||||
AES_KEY_SIZE_BITS = 128
|
||||
AES_KEY_SIZE_BYTES = AES_KEY_SIZE_BITS // 8
|
||||
SHIFTBITS = 63 # Shift size for S2 operation
|
||||
MAX64 = (1 << 64) - 1 # Maximum value of uint64
|
||||
|
||||
|
||||
class EncryptFailedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DecryptFailedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CryptStateOCB2:
|
||||
"""
|
||||
State tracker for AES-OCB2 crypto.
|
||||
All encryption/decryption should be done through this class
|
||||
and not the `ocb_*` functions.
|
||||
|
||||
A random key and IVs are chosen upon initialization; these can be
|
||||
replaced using `set_key`.
|
||||
|
||||
Attributes intended for external access:
|
||||
raw_key
|
||||
encrypt_iv
|
||||
decrypt_iv
|
||||
decrypt_history
|
||||
|
||||
uiGood
|
||||
uiLate
|
||||
uiLost
|
||||
tLastGood
|
||||
"""
|
||||
_raw_key: bytes # AES key; access through `raw_key` property
|
||||
_aes: object # pycrypto AES cipher object, replaced when `raw_key` is changed
|
||||
_encrypt_iv: bytearray # IV for encryption, access through `encrypt_iv` property
|
||||
_decrypt_iv: bytearray # IV for decryption, access through `decrypt_iv` property
|
||||
decrypt_history: bytearray # History of previous decrypt_iv values
|
||||
|
||||
# Statistics:
|
||||
uiGood: int # Number of packets successfully decrypted
|
||||
uiLate: int # Number of packets which arrived out of order
|
||||
uiLost: int # Number of packets which did not arrive in order (may arrive late)
|
||||
tLastGood: float # time.perf_counter() value for latest good packet
|
||||
|
||||
def __init__(self):
|
||||
self.uiGood = 0
|
||||
self.uiLate = 0
|
||||
self.uiLost = 0
|
||||
self.tLastGood = 0
|
||||
|
||||
self._raw_key = get_random_bytes(AES_KEY_SIZE_BYTES)
|
||||
self._encrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
|
||||
self._decrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
|
||||
self._aes = None
|
||||
self.decrypt_history = bytearray(0x100)
|
||||
|
||||
@property
|
||||
def raw_key(self) -> bytes:
|
||||
return self._raw_key
|
||||
|
||||
@raw_key.setter
|
||||
def raw_key(self, rkey: bytes):
|
||||
if len(rkey) != AES_KEY_SIZE_BYTES:
|
||||
raise Exception('raw_key has wrong length')
|
||||
self._raw_key = bytes(rkey)
|
||||
self._aes = AES.new(key=self.raw_key, mode=AES.MODE_ECB)
|
||||
|
||||
@property
|
||||
def encrypt_iv(self) -> bytearray:
|
||||
return self._encrypt_iv
|
||||
|
||||
@encrypt_iv.setter
|
||||
def encrypt_iv(self, eiv: bytearray):
|
||||
if len(eiv) != AES_BLOCK_SIZE:
|
||||
raise Exception('encrypt_iv wrong length')
|
||||
self._encrypt_iv = bytearray(eiv)
|
||||
|
||||
@property
|
||||
def decrypt_iv(self) -> bytearray:
|
||||
return self._decrypt_iv
|
||||
|
||||
@decrypt_iv.setter
|
||||
def decrypt_iv(self, div: bytearray):
|
||||
if len(div) != AES_BLOCK_SIZE:
|
||||
raise Exception('decrypt_iv has wrong length')
|
||||
self._decrypt_iv = bytearray(div)
|
||||
|
||||
def gen_key(self):
|
||||
"""
|
||||
Randomly generate new keys
|
||||
"""
|
||||
self.raw_key = get_random_bytes(AES_KEY_SIZE_BYTES)
|
||||
self.encrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
|
||||
self.decrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
|
||||
|
||||
def set_key(self, raw_key: bytes, encrypt_iv: bytearray, decrypt_iv: bytearray):
|
||||
"""
|
||||
Set new keys
|
||||
|
||||
Args:
|
||||
raw_key: AES key
|
||||
encrypt_iv: IV for encryption
|
||||
decrypt_iv: IV for decrpytion
|
||||
"""
|
||||
self.raw_key = raw_key
|
||||
self.encrypt_iv = encrypt_iv
|
||||
self.decrypt_iv = decrypt_iv
|
||||
|
||||
def encrypt(self, source: bytes) -> bytes:
|
||||
"""
|
||||
Encrypt a message
|
||||
|
||||
Args:
|
||||
source: The plaintext bytes to be encrypted
|
||||
|
||||
Returns:
|
||||
Encrypted (ciphertext) bytes
|
||||
|
||||
Raises:
|
||||
EncryptFailedException if `source` would result in a vulnerable packet
|
||||
"""
|
||||
eiv = increment_iv(self.encrypt_iv)
|
||||
self.encrypt_iv = eiv
|
||||
|
||||
dst, tag = ocb_encrypt(self._aes, source, bytes(eiv))
|
||||
|
||||
head = bytes((eiv[0], *tag[:3]))
|
||||
return head + dst
|
||||
|
||||
def decrypt(self, source: bytes, len_plain: int) -> bytes:
|
||||
"""
|
||||
Decrypt a message
|
||||
|
||||
Args:
|
||||
source: The ciphertext bytes to be decrypted
|
||||
len_plain: The length of the plaintext
|
||||
|
||||
Returns:
|
||||
Decrypted (plaintext) bytes
|
||||
|
||||
Raises:
|
||||
DecryptFailedException:
|
||||
- if `source` is too short
|
||||
- packet is out of order or duplicate
|
||||
- packet was could have been tampered with
|
||||
"""
|
||||
if len(source) < 4:
|
||||
raise DecryptFailedException('Source <4 bytes long!')
|
||||
|
||||
div = self.decrypt_iv.copy()
|
||||
ivbyte = source[0]
|
||||
late = False
|
||||
lost = 0
|
||||
|
||||
if (div[0] + 1) & 0xFF == ivbyte:
|
||||
# In order as expected.
|
||||
if ivbyte > div[0]:
|
||||
div[0] = ivbyte
|
||||
elif ivbyte < div[0]:
|
||||
div[0] = ivbyte
|
||||
div = increment_iv(div, 1)
|
||||
else:
|
||||
raise DecryptFailedException('ivbyte == decrypt_iv[0]')
|
||||
else:
|
||||
# This is either out of order or a repeat.
|
||||
diff = ivbyte - div[0]
|
||||
if diff > 128:
|
||||
diff -= 256
|
||||
elif diff < -128:
|
||||
diff += 256
|
||||
|
||||
if ivbyte < div[0] and -30 < diff < 0:
|
||||
# Late packet, but no wraparound.
|
||||
late = True
|
||||
lost = -1
|
||||
div[0] = ivbyte
|
||||
elif ivbyte > div[0] and -30 < diff < 0:
|
||||
# Last was 0x02, here comes 0xff from last round
|
||||
late = True
|
||||
lost = -1
|
||||
div[0] = ivbyte
|
||||
div = decrement_iv(div, 1)
|
||||
elif ivbyte > div[0] and diff > 0:
|
||||
# Lost a few packets, but beyond that we're good.
|
||||
lost = ivbyte - div[0] - 1
|
||||
div[0] = ivbyte
|
||||
elif ivbyte < div[0] and diff > 0:
|
||||
# Lost a few packets, and wrapped around
|
||||
lost = 0x100 - div[0] + ivbyte - 1
|
||||
div[0] = ivbyte
|
||||
div = increment_iv(div, 1)
|
||||
else:
|
||||
raise DecryptFailedException('Lost too many packets?')
|
||||
|
||||
if self.decrypt_history[div[0]] == div[1]:
|
||||
raise DecryptFailedException('decrypt_iv in history')
|
||||
|
||||
dst, tag = ocb_decrypt(self._aes, source[4:], bytes(div), len_plain)
|
||||
|
||||
if tag[:3] != source[1:4]:
|
||||
raise DecryptFailedException('Tag did not match!')
|
||||
|
||||
self.decrypt_history[div[0]] = div[1]
|
||||
|
||||
if not late:
|
||||
self.decrypt_iv = div
|
||||
else:
|
||||
self.uiLate += 1
|
||||
|
||||
self.uiGood += 1
|
||||
self.uiLost += lost
|
||||
|
||||
self.tLastGood = time.perf_counter()
|
||||
|
||||
return dst
|
||||
|
||||
|
||||
def ocb_encrypt(aes: object,
|
||||
plain: bytes,
|
||||
nonce: bytes,
|
||||
*,
|
||||
insecure=False,
|
||||
) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Encrypt a message.
|
||||
This should be called from CryptStateOCB2.encrypt() and not independently.
|
||||
|
||||
Args:
|
||||
aes: AES-ECB cipher object
|
||||
plain: The plaintext bytes to be encrypted
|
||||
nonce: The encryption IV
|
||||
|
||||
Returns:
|
||||
Encrypted (ciphertext) bytes and tag
|
||||
|
||||
Raises:
|
||||
EncryptFailedException if `source` would result in a vulnerable packet
|
||||
"""
|
||||
delta = aes.encrypt(nonce)
|
||||
checksum = bytes(AES_BLOCK_SIZE)
|
||||
plain_block = b''
|
||||
|
||||
pos = 0
|
||||
encrypted = bytearray(ceil(len(plain) / AES_BLOCK_SIZE) * AES_BLOCK_SIZE)
|
||||
while len(plain) - pos > AES_BLOCK_SIZE:
|
||||
plain_block = plain[pos:pos + AES_BLOCK_SIZE]
|
||||
delta = S2(delta)
|
||||
encrypted_block = xor(delta, aes.encrypt(xor(delta, plain_block)))
|
||||
checksum = xor(checksum, plain_block)
|
||||
|
||||
encrypted[pos:pos + AES_BLOCK_SIZE] = encrypted_block
|
||||
pos += AES_BLOCK_SIZE
|
||||
|
||||
# Counter-cryptanalysis described in section 9 of https://eprint.iacr.org/2019/311
|
||||
# For an attack, the second to last block (i.e. the last iteration of this loop)
|
||||
# must be all 0 except for the last byte (which may be 0 - 128).
|
||||
if not insecure and bytes(plain_block[:-1]) == bytes(AES_BLOCK_SIZE - 1):
|
||||
raise EncryptFailedException('Insecure input block: ' +
|
||||
'see section 9 of https://eprint.iacr.org/2019/311')
|
||||
|
||||
len_remaining = len(plain) - pos
|
||||
delta = S2(delta)
|
||||
pad_in = struct.pack('>QQ', 0, len_remaining * 8)
|
||||
pad = aes.encrypt(xor(pad_in, delta))
|
||||
plain_block = plain[pos:] + pad[len_remaining - AES_BLOCK_SIZE:]
|
||||
|
||||
checksum = xor(checksum, plain_block)
|
||||
encrypted_block = xor(pad, plain_block)
|
||||
encrypted[pos:] = encrypted_block
|
||||
|
||||
delta = xor(delta, S2(delta))
|
||||
tag = aes.encrypt(xor(delta, checksum))
|
||||
|
||||
return encrypted, tag
|
||||
|
||||
|
||||
def ocb_decrypt(aes: object,
|
||||
encrypted: bytes,
|
||||
nonce: bytes,
|
||||
len_plain: int,
|
||||
*,
|
||||
insecure=False,
|
||||
) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Decrypt a message.
|
||||
This should be called from CryptStateOCB2.decrypt() and not independently.
|
||||
|
||||
Args:
|
||||
aes: AES-ECB cipher object
|
||||
encrypted: The ciphertext bytes to be decrypted
|
||||
nonce: The decryption IV
|
||||
len_plain: The length of the desired plaintext
|
||||
|
||||
Returns:
|
||||
Decrypted (plaintext) bytes and tag
|
||||
|
||||
Raises:
|
||||
DecryptFailedException:
|
||||
- if `source` is too short
|
||||
- packet is out of order or duplicate
|
||||
- packet was could have been tampered with
|
||||
"""
|
||||
delta = aes.encrypt(nonce)
|
||||
checksum = bytes(AES_BLOCK_SIZE)
|
||||
plain = bytearray(len_plain)
|
||||
|
||||
pos = 0
|
||||
while len_plain - pos > AES_BLOCK_SIZE:
|
||||
encrypted_block = encrypted[pos:pos + AES_BLOCK_SIZE]
|
||||
delta = S2(delta)
|
||||
tmp = aes.decrypt(xor(delta, encrypted_block))
|
||||
plain_block = xor(delta, tmp)
|
||||
checksum = xor(checksum, plain_block)
|
||||
|
||||
plain[pos:pos + AES_BLOCK_SIZE] = plain_block
|
||||
pos += AES_BLOCK_SIZE
|
||||
|
||||
len_remaining = len_plain - pos
|
||||
delta = S2(delta)
|
||||
pad_in = struct.pack('>QQ', 0, len_remaining * 8)
|
||||
pad = aes.encrypt(xor(pad_in, delta))
|
||||
encrypted_zeropad = encrypted[pos:] + bytes(AES_BLOCK_SIZE - len_remaining)
|
||||
plain_block = xor(encrypted_zeropad, pad)
|
||||
|
||||
checksum = xor(checksum, plain_block)
|
||||
plain[pos:] = plain_block[:len_remaining]
|
||||
|
||||
# Counter-cryptanalysis described in section 9 of https://eprint.iacr.org/2019/311
|
||||
# In an attack, the decrypted last block would need to equal `delta ^ len(128)`.
|
||||
# With a bit of luck (or many packets), smaller values than 128 (i.e. non-full blocks) are also
|
||||
# feasible, so we check `plain_block` instead of `plain`.
|
||||
# Since our `len` only ever modifies the last byte, we simply check all remaining ones.
|
||||
if not insecure and plain_block[:-1] == delta[:-1]:
|
||||
raise DecryptFailedException('Possibly tampered/able block, discarding.')
|
||||
|
||||
delta = xor(delta, S2(delta))
|
||||
tag = aes.encrypt(xor(delta, checksum))
|
||||
return plain, tag
|
||||
|
||||
|
||||
def increment_iv(iv: bytearray, start: int = 0) -> bytearray:
|
||||
for i in range(start, AES_BLOCK_SIZE):
|
||||
iv[i] = (iv[i] + 1) % 0x100
|
||||
if iv[i] != 0:
|
||||
break
|
||||
return iv
|
||||
|
||||
|
||||
def decrement_iv(iv: bytearray, start: int = 0) -> bytearray:
|
||||
for i in range(start, AES_BLOCK_SIZE):
|
||||
iv[i] = (iv[i] - 1) % 0x100
|
||||
if iv[i] != 0xFF:
|
||||
break
|
||||
return iv
|
||||
|
||||
|
||||
def xor(a: bytes, b: bytes) -> bytes:
|
||||
return bytes(aa ^ bb for aa, bb in zip(a, b))
|
||||
|
||||
|
||||
def S2(block: bytes) -> bytes:
|
||||
ll, uu = struct.unpack('>QQ', block)
|
||||
carry = ll >> 63
|
||||
block = struct.pack('>QQ',
|
||||
((ll << 1) | (uu >> 63)) & MAX64,
|
||||
((uu << 1) ^ (carry * 0x87)) & MAX64)
|
||||
return block
|
||||
|
||||
101
pymumble_py3/errors.py
Normal file
101
pymumble_py3/errors.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
class CodecNotSupportedError(Exception):
|
||||
"""Thrown when receiving an audio packet from an unsupported codec"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class ConnectionRejectedError(Exception):
|
||||
"""Thrown when server reject the connection"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class InvalidFormatError(Exception):
|
||||
"""Thrown when receiving a packet not understood"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class UnknownCallbackError(Exception):
|
||||
"""Thrown when asked for an unknown callback"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class UnknownChannelError(Exception):
|
||||
"""Thrown when using an unknown channel"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class InvalidSoundDataError(Exception):
|
||||
"""Thrown when trying to send an invalid audio pcm data"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class InvalidVarInt(Exception):
|
||||
"""Thrown when trying to decode an invalid varint"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return repr(self.value)
|
||||
|
||||
|
||||
class TextTooLongError(Exception):
|
||||
"""Thrown when trying to send a message which is longer than allowed"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return 'Maximum Text allowed length: {}'.format(self.value)
|
||||
|
||||
|
||||
class ImageTooBigError(Exception):
|
||||
"""Thrown when trying to send a message or image which is longer than allowed"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return 'Maximum Text/Image allowed length: {}'.format(self.value)
|
||||
|
||||
|
||||
class ACLChanGroupNotExist(Exception):
|
||||
"""Thrown when trying to update an non-existant ACL ChanGroup"""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return 'ACL ChanGroup does not exist: {}'.format(self.value)
|
||||
158
pymumble_py3/messages.py
Normal file
158
pymumble_py3/messages.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .constants import *
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class Cmd:
|
||||
"""
|
||||
Define a command object, used to ask an action from the pymumble thread,
|
||||
usually to forward to the murmur server
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cmd_id = None
|
||||
self.lock = Lock()
|
||||
|
||||
self.cmd = None
|
||||
self.parameters = None
|
||||
self.response = None
|
||||
|
||||
|
||||
class MoveCmd(Cmd):
|
||||
"""Command to move a user from channel"""
|
||||
|
||||
def __init__(self, session, channel_id):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_MOVE
|
||||
self.parameters = {"session": session,
|
||||
"channel_id": channel_id}
|
||||
|
||||
|
||||
class TextMessage(Cmd):
|
||||
"""Command to send a text message"""
|
||||
|
||||
def __init__(self, session, channel_id, message):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_TEXTMESSAGE
|
||||
self.parameters = {"session": session,
|
||||
"channel_id": channel_id,
|
||||
"message": message}
|
||||
|
||||
|
||||
class TextPrivateMessage(Cmd):
|
||||
"""Command to send a private text message"""
|
||||
|
||||
def __init__(self, session, message):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_TEXTPRIVATEMESSAGE
|
||||
self.parameters = {"session": session,
|
||||
"message": message}
|
||||
|
||||
|
||||
class ModUserState(Cmd):
|
||||
"""Command to change a user state"""
|
||||
|
||||
def __init__(self, session, params):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_MODUSERSTATE
|
||||
self.parameters = params
|
||||
|
||||
|
||||
class RemoveUser(Cmd):
|
||||
"""Command to kick (ban=False) or ban (ban=True) a user"""
|
||||
|
||||
def __init__(self, session, params):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_REMOVEUSER
|
||||
self.parameters = params
|
||||
|
||||
|
||||
class CreateChannel(Cmd):
|
||||
"""Command to create channel"""
|
||||
|
||||
def __init__(self, parent, name, temporary):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_MSG_TYPES_CHANNELSTATE
|
||||
self.parameters = {"parent": parent,
|
||||
"name": name,
|
||||
"temporary": temporary}
|
||||
|
||||
|
||||
class RemoveChannel(Cmd):
|
||||
"""Command to create channel"""
|
||||
|
||||
def __init__(self, channel_id):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_MSG_TYPES_CHANNELREMOVE
|
||||
self.parameters = {"channel_id": channel_id}
|
||||
|
||||
|
||||
class UpdateChannel(Cmd):
|
||||
"""Command to update channel"""
|
||||
|
||||
def __init__(self, params):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_UPDATECHANNEL
|
||||
self.parameters = params
|
||||
|
||||
|
||||
class VoiceTarget(Cmd):
|
||||
"""Command to create a whisper"""
|
||||
|
||||
def __init__(self, voice_id, targets):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_MSG_TYPES_VOICETARGET
|
||||
self.parameters = {"id": voice_id,
|
||||
"targets": targets}
|
||||
|
||||
|
||||
class LinkChannel(Cmd):
|
||||
"""Command to link channel"""
|
||||
|
||||
def __init__(self, params):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_LINKCHANNEL
|
||||
self.parameters = params
|
||||
|
||||
|
||||
class UnlinkChannel(Cmd):
|
||||
"""Command to unlink channel"""
|
||||
|
||||
def __init__(self, params):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_UNLINKCHANNEL
|
||||
self.parameters = params
|
||||
|
||||
|
||||
class QueryACL(Cmd):
|
||||
"""Command to query ACL information for channel"""
|
||||
|
||||
def __init__(self, channel_id):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_QUERYACL
|
||||
self.parameters = {"channel_id": channel_id}
|
||||
|
||||
|
||||
class UpdateACL(Cmd):
|
||||
"""Command to Update ACL information for channel"""
|
||||
|
||||
def __init__(self, channel_id, inherit_acls, chan_group, chan_acl):
|
||||
Cmd.__init__(self)
|
||||
|
||||
self.cmd = PYMUMBLE_CMD_UPDATEACL
|
||||
self.parameters = {"channel_id": channel_id,
|
||||
"inherit_acls": inherit_acls,
|
||||
"chan_group": chan_group,
|
||||
"chan_acl": chan_acl}
|
||||
802
pymumble_py3/mumble.py
Normal file
802
pymumble_py3/mumble.py
Normal file
@@ -0,0 +1,802 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
|
||||
from .errors import *
|
||||
from .constants import *
|
||||
from . import users
|
||||
from . import channels
|
||||
from . import blobs
|
||||
from . import commands
|
||||
from . import callbacks
|
||||
from . import tools
|
||||
|
||||
from . import mumble_pb2
|
||||
|
||||
|
||||
def _wrap_socket(sock, keyfile=None, certfile=None, verify_mode=ssl.CERT_NONE, server_hostname=None):
|
||||
try:
|
||||
ssl_context = ssl.create_default_context()
|
||||
if certfile:
|
||||
ssl_context.load_cert_chain(certfile, keyfile)
|
||||
ssl_context.check_hostname = (verify_mode != ssl.CERT_NONE) and (server_hostname is not None)
|
||||
ssl_context.verify_mode = verify_mode
|
||||
return ssl_context.wrap_socket(sock, server_hostname=server_hostname)
|
||||
except AttributeError:
|
||||
try:
|
||||
return ssl.wrap_socket(sock, keyfile, certfile, cert_reqs=verify_mode, ssl_version=ssl.PROTOCOL_TLS)
|
||||
except AttributeError:
|
||||
return ssl.wrap_socket(sock, keyfile, certfile, cert_reqs=verify_mode, ssl_version=ssl.PROTOCOL_TLSv1)
|
||||
|
||||
|
||||
class Mumble(threading.Thread):
|
||||
"""
|
||||
Mumble client library main object.
|
||||
basically a thread
|
||||
"""
|
||||
|
||||
def __init__(self, host, user, port=64738, password='', certfile=None, keyfile=None, reconnect=False, tokens=None, stereo=False, debug=False, client_type=0):
|
||||
"""
|
||||
host=mumble server hostname or address
|
||||
port=mumble server port
|
||||
user=user to use for the connection
|
||||
password=password for the connection
|
||||
certfile=client certificate to authenticate the connection
|
||||
keyfile=private key associated with the client certificate
|
||||
reconnect=if True, try to reconnect if disconnected
|
||||
tokens=channel access tokens as a list of strings
|
||||
stereo=enable stereo transmission
|
||||
debug=if True, send debugging messages (lot of...) to the stdout
|
||||
client_type=if 1, flag connection as bot
|
||||
"""
|
||||
# TODO: use UDP audio
|
||||
threading.Thread.__init__(self)
|
||||
|
||||
if tokens is None:
|
||||
tokens = []
|
||||
self.Log = logging.getLogger("PyMumble") # logging object for errors and debugging
|
||||
if debug:
|
||||
self.Log.setLevel(logging.DEBUG)
|
||||
else:
|
||||
self.Log.setLevel(logging.ERROR)
|
||||
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter('%(asctime)s-%(name)s-%(levelname)s-%(message)s')
|
||||
ch.setFormatter(formatter)
|
||||
self.Log.addHandler(ch)
|
||||
|
||||
self.parent_thread = threading.current_thread() # main thread of the calling application
|
||||
self.mumble_thread = None # thread of the mumble client library
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.certfile = certfile
|
||||
self.keyfile = keyfile
|
||||
self.reconnect = reconnect
|
||||
self.tokens = tokens
|
||||
self.__opus_profile = PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE
|
||||
self.stereo = stereo
|
||||
self.client_type = client_type
|
||||
|
||||
if stereo:
|
||||
self.Log.debug("Working in STEREO mode.")
|
||||
else:
|
||||
self.Log.debug("Working in MONO mode.")
|
||||
|
||||
self.receive_sound = False # set to True to treat incoming audio, otherwise it is simply ignored
|
||||
|
||||
self.loop_rate = PYMUMBLE_LOOP_RATE
|
||||
|
||||
self.application = PYMUMBLE_VERSION_STRING
|
||||
|
||||
self.callbacks = callbacks.CallBacks() # callbacks management
|
||||
|
||||
self.ready_lock = threading.Lock() # released when the connection is fully established with the server
|
||||
self.ready_lock.acquire()
|
||||
|
||||
self.positional = None
|
||||
|
||||
def init_connection(self):
|
||||
"""Initialize variables that are local to a connection, (needed if the client automatically reconnect)"""
|
||||
self.ready_lock.acquire(False) # reacquire the ready-lock in case of reconnection
|
||||
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
self.control_socket = None
|
||||
self.media_socket = None # Not implemented - for UDP media
|
||||
|
||||
self.bandwidth = PYMUMBLE_BANDWIDTH # reset the outgoing bandwidth to it's default before connecting
|
||||
self.server_max_bandwidth = None
|
||||
self.udp_active = False
|
||||
|
||||
# defaults according to https://wiki.mumble.info/wiki/Murmur.ini
|
||||
self.server_allow_html = True
|
||||
self.server_max_message_length = 5000
|
||||
self.server_max_image_message_length = 131072
|
||||
|
||||
self.users = users.Users(self, self.callbacks) # contains the server's connected users information
|
||||
self.channels = channels.Channels(self, self.callbacks) # contains the server's channels information
|
||||
self.blobs = blobs.Blobs(self) # manage the blob objects
|
||||
if self.receive_sound:
|
||||
from . import soundoutput
|
||||
self.sound_output = soundoutput.SoundOutput(self, PYMUMBLE_AUDIO_PER_PACKET, self.bandwidth, stereo=self.stereo, opus_profile=self.__opus_profile) # manage the outgoing sounds
|
||||
else:
|
||||
self.sound_output = None
|
||||
self.commands = commands.Commands() # manage commands sent between the main and the mumble threads
|
||||
|
||||
self.receive_buffer = bytes() # initialize the control connection input buffer
|
||||
self.ping_stats = {"last_rcv": 0, "time_send": 0, "nb": 0, "avg": 40.0, "var": 0.0} # Set / reset ping stats
|
||||
|
||||
def run(self):
|
||||
"""Connect to the server and start the loop in its thread. Retry if requested"""
|
||||
self.mumble_thread = threading.current_thread()
|
||||
|
||||
# loop if auto-reconnect is requested
|
||||
while True:
|
||||
self.init_connection() # reset the connection-specific object members
|
||||
|
||||
if self.connect() >= PYMUMBLE_CONN_STATE_FAILED: # some error occurred, exit here
|
||||
self.ready_lock.release()
|
||||
if not self.reconnect or not self.parent_thread.is_alive():
|
||||
raise ConnectionRejectedError("Connection error with the Mumble (murmur) Server")
|
||||
else:
|
||||
time.sleep(PYMUMBLE_CONNECTION_RETRY_INTERVAL)
|
||||
continue
|
||||
|
||||
try:
|
||||
self.loop()
|
||||
except socket.error:
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
|
||||
if not self.reconnect or not self.parent_thread.is_alive():
|
||||
self.callbacks(PYMUMBLE_CLBK_DISCONNECTED)
|
||||
break
|
||||
|
||||
self.callbacks(PYMUMBLE_CLBK_DISCONNECTED)
|
||||
time.sleep(PYMUMBLE_CONNECTION_RETRY_INTERVAL)
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the server"""
|
||||
try:
|
||||
# Get IPv4/IPv6 server address
|
||||
server_info = socket.getaddrinfo(self.host, self.port, type=socket.SOCK_STREAM)
|
||||
|
||||
# Connect the SSL tunnel
|
||||
self.Log.debug("connecting to %s (%s) on port %i.", self.host, server_info[0][1], self.port)
|
||||
std_sock = socket.socket(server_info[0][0], socket.SOCK_STREAM)
|
||||
std_sock.settimeout(10)
|
||||
except socket.error:
|
||||
self.connected = PYMUMBLE_CONN_STATE_FAILED
|
||||
return self.connected
|
||||
|
||||
# FIXME: Default verify_mode and server_hostname are not safe, as no
|
||||
# certificate checks are performed.
|
||||
self.control_socket = _wrap_socket(std_sock, self.keyfile, self.certfile)
|
||||
try:
|
||||
self.control_socket.connect((self.host, self.port))
|
||||
self.control_socket.setblocking(False)
|
||||
|
||||
# Perform the Mumble authentication
|
||||
version = mumble_pb2.Version()
|
||||
if PYMUMBLE_PROTOCOL_VERSION[2] > 255:
|
||||
version.version_v1 = (PYMUMBLE_PROTOCOL_VERSION[0] << 16) + (PYMUMBLE_PROTOCOL_VERSION[1] << 8) + 255
|
||||
else:
|
||||
version.version_v1 = (PYMUMBLE_PROTOCOL_VERSION[0] << 16) + (PYMUMBLE_PROTOCOL_VERSION[1] << 8) + (PYMUMBLE_PROTOCOL_VERSION[2])
|
||||
version.version_v2 = (PYMUMBLE_PROTOCOL_VERSION[0] << 48) + (PYMUMBLE_PROTOCOL_VERSION[1] << 32) + (PYMUMBLE_PROTOCOL_VERSION[2] << 16)
|
||||
version.release = self.application
|
||||
version.os = PYMUMBLE_OS_STRING
|
||||
version.os_version = PYMUMBLE_OS_VERSION_STRING
|
||||
self.Log.debug("sending: version: %s", version)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_VERSION, version)
|
||||
|
||||
authenticate = mumble_pb2.Authenticate()
|
||||
authenticate.username = self.user
|
||||
authenticate.password = self.password
|
||||
authenticate.tokens.extend(self.tokens)
|
||||
authenticate.opus = True
|
||||
authenticate.client_type = self.client_type
|
||||
self.Log.debug("sending: authenticate: %s", authenticate)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_AUTHENTICATE, authenticate)
|
||||
except socket.error:
|
||||
self.connected = PYMUMBLE_CONN_STATE_FAILED
|
||||
return self.connected
|
||||
|
||||
self.connected = PYMUMBLE_CONN_STATE_AUTHENTICATING
|
||||
return self.connected
|
||||
|
||||
def loop(self):
|
||||
"""
|
||||
Main loop
|
||||
waiting for a message from the server for maximum self.loop_rate time
|
||||
take care of sending the ping
|
||||
take care of sending the queued commands to the server
|
||||
check on every iteration for outgoing sound
|
||||
check for disconnection
|
||||
"""
|
||||
self.Log.debug("entering loop")
|
||||
self.exit = False
|
||||
|
||||
last_ping = time.time() # keep track of the last ping time
|
||||
|
||||
# loop as long as the connection and the parent thread are alive
|
||||
while self.connected not in (PYMUMBLE_CONN_STATE_NOT_CONNECTED, PYMUMBLE_CONN_STATE_FAILED) and self.parent_thread.is_alive() and not self.exit:
|
||||
if last_ping + PYMUMBLE_PING_DELAY <= time.time(): # when it is time, send the ping
|
||||
self.ping()
|
||||
last_ping = time.time()
|
||||
|
||||
if self.connected == PYMUMBLE_CONN_STATE_CONNECTED:
|
||||
while self.commands.is_cmd():
|
||||
self.treat_command(self.commands.pop_cmd()) # send the commands coming from the application to the server
|
||||
|
||||
if self.sound_output:
|
||||
self.sound_output.send_audio() # send outgoing audio if available
|
||||
|
||||
(rlist, wlist, xlist) = select.select([self.control_socket], [], [self.control_socket], self.loop_rate) # wait for a socket activity
|
||||
|
||||
if self.control_socket in rlist: # something to be read on the control socket
|
||||
self.read_control_messages()
|
||||
elif self.control_socket in xlist: # socket was closed
|
||||
self.control_socket.close()
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
|
||||
def ping(self):
|
||||
"""Send the keepalive through available channels"""
|
||||
ping = mumble_pb2.Ping()
|
||||
ping.timestamp = int(time.time())
|
||||
ping.tcp_ping_avg = self.ping_stats['avg']
|
||||
ping.tcp_ping_var = self.ping_stats['var']
|
||||
ping.tcp_packets = self.ping_stats['nb']
|
||||
|
||||
self.Log.debug("sending: ping: %s", ping)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_PING, ping)
|
||||
self.ping_stats['time_send'] = int(time.time() * 1000)
|
||||
self.Log.debug(self.ping_stats['last_rcv'])
|
||||
if self.ping_stats['last_rcv'] != 0 and int(time.time() * 1000) > self.ping_stats['last_rcv'] + (60 * 1000):
|
||||
self.Log.debug("Ping too long ! Disconnected ?")
|
||||
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
|
||||
|
||||
def ping_response(self, mess):
|
||||
self.ping_stats['last_rcv'] = int(time.time() * 1000)
|
||||
ping = int(time.time() * 1000) - self.ping_stats['time_send']
|
||||
old_avg = self.ping_stats['avg']
|
||||
nb = self.ping_stats['nb']
|
||||
new_avg = ((self.ping_stats['avg'] * nb) + ping) / (nb + 1)
|
||||
|
||||
try:
|
||||
self.ping_stats['var'] = self.ping_stats['var'] + pow(old_avg - new_avg, 2) + (1 / nb) * pow(ping - new_avg, 2)
|
||||
except ZeroDivisionError:
|
||||
pass
|
||||
|
||||
self.ping_stats['avg'] = new_avg
|
||||
self.ping_stats['nb'] += 1
|
||||
|
||||
def send_message(self, type, message):
|
||||
"""Send a control message to the server"""
|
||||
packet = struct.pack("!HL", type, message.ByteSize()) + message.SerializeToString()
|
||||
|
||||
while len(packet) > 0:
|
||||
self.Log.debug("sending message")
|
||||
sent = self.control_socket.send(packet)
|
||||
if sent < 0:
|
||||
raise socket.error("Server socket error")
|
||||
packet = packet[sent:]
|
||||
|
||||
def read_control_messages(self):
|
||||
"""Read control messages coming from the server"""
|
||||
# from tools import tohex # for debugging
|
||||
|
||||
try:
|
||||
buffer = self.control_socket.recv(PYMUMBLE_READ_BUFFER_SIZE)
|
||||
self.receive_buffer += buffer
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
while len(self.receive_buffer) >= 6: # header is present (type + length)
|
||||
self.Log.debug("read control connection")
|
||||
header = self.receive_buffer[0:6]
|
||||
|
||||
if len(header) < 6:
|
||||
break
|
||||
|
||||
(type, size) = struct.unpack("!HL", header) # decode header
|
||||
|
||||
if len(self.receive_buffer) < size + 6: # if not length data, read further
|
||||
break
|
||||
|
||||
# self.Log.debug("message received : " + tohex(self.receive_buffer[0:size+6])) # for debugging
|
||||
|
||||
message = self.receive_buffer[6:size + 6] # get the control message
|
||||
self.receive_buffer = self.receive_buffer[size + 6:] # remove from the buffer the read part
|
||||
|
||||
self.dispatch_control_message(type, message)
|
||||
|
||||
def dispatch_control_message(self, type, message):
|
||||
"""Dispatch control messages based on their type"""
|
||||
self.Log.debug("dispatch control message")
|
||||
if type == PYMUMBLE_MSG_TYPES_UDPTUNNEL: # audio encapsulated in control message
|
||||
if self.sound_output:
|
||||
self.sound_received(message)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_VERSION:
|
||||
mess = mumble_pb2.Version()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: Version : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_AUTHENTICATE:
|
||||
mess = mumble_pb2.Authenticate()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: Authenticate : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_PING:
|
||||
mess = mumble_pb2.Ping()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: Ping : %s", mess)
|
||||
self.ping_response(mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_REJECT:
|
||||
mess = mumble_pb2.Reject()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: reject : %s", mess)
|
||||
self.connected = PYMUMBLE_CONN_STATE_FAILED
|
||||
self.ready_lock.release()
|
||||
raise ConnectionRejectedError(mess.reason)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_SERVERSYNC: # this message finish the connection process
|
||||
mess = mumble_pb2.ServerSync()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: serversync : %s", mess)
|
||||
self.users.set_myself(mess.session)
|
||||
self.server_max_bandwidth = mess.max_bandwidth
|
||||
self.set_bandwidth(mess.max_bandwidth)
|
||||
|
||||
if self.connected == PYMUMBLE_CONN_STATE_AUTHENTICATING:
|
||||
self.connected = PYMUMBLE_CONN_STATE_CONNECTED
|
||||
self.ready_lock.release() # release the ready-lock
|
||||
self.callbacks(PYMUMBLE_CLBK_CONNECTED)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CHANNELREMOVE:
|
||||
mess = mumble_pb2.ChannelRemove()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: ChannelRemove : %s", mess)
|
||||
|
||||
self.channels.remove(mess.channel_id)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CHANNELSTATE:
|
||||
mess = mumble_pb2.ChannelState()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: channelstate : %s", mess)
|
||||
|
||||
self.channels.update(mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_USERREMOVE:
|
||||
mess = mumble_pb2.UserRemove()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: UserRemove : %s", mess)
|
||||
|
||||
self.users.remove(mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_USERSTATE:
|
||||
mess = mumble_pb2.UserState()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: userstate : %s", mess)
|
||||
|
||||
self.users.update(mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_BANLIST:
|
||||
mess = mumble_pb2.BanList()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: BanList : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_TEXTMESSAGE:
|
||||
mess = mumble_pb2.TextMessage()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: TextMessage : %s", mess)
|
||||
|
||||
self.callbacks(PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_PERMISSIONDENIED:
|
||||
mess = mumble_pb2.PermissionDenied()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: PermissionDenied : %s", mess)
|
||||
|
||||
self.callbacks(PYMUMBLE_CLBK_PERMISSIONDENIED, mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_ACL:
|
||||
mess = mumble_pb2.ACL()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: ACL : %s", mess)
|
||||
self.channels[mess.channel_id].update_acl(mess)
|
||||
self.callbacks(PYMUMBLE_CLBK_ACLRECEIVED, mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_QUERYUSERS:
|
||||
mess = mumble_pb2.QueryUsers()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: QueryUsers : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CRYPTSETUP:
|
||||
mess = mumble_pb2.CryptSetup()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: CryptSetup : %s", mess)
|
||||
self.ping()
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTIONMODIFY:
|
||||
mess = mumble_pb2.ContextActionModify()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: ContextActionModify : %s", mess)
|
||||
|
||||
self.callbacks(PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED, mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTION:
|
||||
mess = mumble_pb2.ContextAction()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: ContextAction : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_USERLIST:
|
||||
mess = mumble_pb2.UserList()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: UserList : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_VOICETARGET:
|
||||
mess = mumble_pb2.VoiceTarget()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: VoiceTarget : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_PERMISSIONQUERY:
|
||||
mess = mumble_pb2.PermissionQuery()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: PermissionQuery : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_CODECVERSION:
|
||||
mess = mumble_pb2.CodecVersion()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: CodecVersion : %s", mess)
|
||||
if self.sound_output:
|
||||
self.sound_output.set_default_codec(mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_USERSTATS:
|
||||
mess = mumble_pb2.UserStats()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: UserStats : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_REQUESTBLOB:
|
||||
mess = mumble_pb2.RequestBlob()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: RequestBlob : %s", mess)
|
||||
|
||||
elif type == PYMUMBLE_MSG_TYPES_SERVERCONFIG:
|
||||
mess = mumble_pb2.ServerConfig()
|
||||
mess.ParseFromString(message)
|
||||
self.Log.debug("message: ServerConfig : %s", mess)
|
||||
for line in str(mess).split('\n'):
|
||||
items = line.split(':')
|
||||
if len(items) != 2:
|
||||
continue
|
||||
if items[0] == 'allow_html':
|
||||
self.server_allow_html = items[1].strip() == 'true'
|
||||
elif items[0] == 'message_length':
|
||||
self.server_max_message_length = int(items[1].strip())
|
||||
elif items[0] == 'image_message_length':
|
||||
self.server_max_image_message_length = int(items[1].strip())
|
||||
|
||||
def set_bandwidth(self, bandwidth):
|
||||
"""Set the total allowed outgoing bandwidth"""
|
||||
if self.server_max_bandwidth is not None and bandwidth > self.server_max_bandwidth:
|
||||
self.bandwidth = self.server_max_bandwidth
|
||||
else:
|
||||
self.bandwidth = bandwidth
|
||||
|
||||
if self.sound_output:
|
||||
self.sound_output.set_bandwidth(self.bandwidth) # communicate the update to the outgoing audio manager
|
||||
|
||||
def sound_received(self, message):
|
||||
"""Manage a received sound message"""
|
||||
# from tools import tohex # for debugging
|
||||
|
||||
pos = 0
|
||||
|
||||
# self.Log.debug("sound packet : " + tohex(message)) # for debugging
|
||||
(header,) = struct.unpack("!B", bytes([message[pos]])) # extract the header
|
||||
type = (header & 0b11100000) >> 5
|
||||
target = header & 0b00011111
|
||||
pos += 1
|
||||
|
||||
if type == PYMUMBLE_AUDIO_TYPE_PING:
|
||||
return
|
||||
|
||||
session = tools.VarInt() # decode session id
|
||||
pos += session.decode(message[pos:pos + 10])
|
||||
|
||||
sequence = tools.VarInt() # decode sequence number
|
||||
pos += sequence.decode(message[pos:pos + 10])
|
||||
|
||||
self.Log.debug("audio packet received from %i, sequence %i, type:%i, target:%i, length:%i", session.value, sequence.value, type, target, len(message))
|
||||
|
||||
terminator = False # set to true if it's the last 10 ms audio frame for the packet (used with CELT codec)
|
||||
while (pos < len(message)) and not terminator: # get the audio frames one by one
|
||||
if type == PYMUMBLE_AUDIO_TYPE_OPUS:
|
||||
size = tools.VarInt() # OPUS use varint for the frame length
|
||||
|
||||
pos += size.decode(message[pos:pos + 10])
|
||||
size = size.value
|
||||
|
||||
if not (size & 0x2000): # terminator is 0x2000 in the resulting int.
|
||||
terminator = True # should actually always be 0 as OPUS can use variable length audio frames
|
||||
|
||||
size &= 0x1fff # isolate the size from the terminator
|
||||
else:
|
||||
(header,) = struct.unpack("!B", message[pos:pos + 1]) # CELT length and terminator is encoded in a 1 byte int
|
||||
if not (header & 0b10000000):
|
||||
terminator = True
|
||||
size = header & 0b01111111
|
||||
pos += 1
|
||||
|
||||
self.Log.debug("Audio frame : time:%f, last:%s, size:%i, type:%i, target:%i, pos:%i", time.time(), str(terminator), size, type, target, pos - 1)
|
||||
|
||||
if size > 0:
|
||||
try:
|
||||
newsound = self.users[session.value].sound.add(message[pos:pos + size],
|
||||
sequence.value,
|
||||
type,
|
||||
target) # add the sound to the user's sound queue
|
||||
|
||||
if newsound is None: # In case audio have been disable for specific users
|
||||
return
|
||||
|
||||
self.callbacks(PYMUMBLE_CLBK_SOUNDRECEIVED, self.users[session.value], newsound)
|
||||
|
||||
sequence.value += int(round(newsound.duration / 1000 * 10)) # add 1 sequence per 10ms of audio
|
||||
|
||||
self.Log.debug("Audio frame : time:%f last:%s, size:%i, uncompressed:%i, type:%i, target:%i", time.time(), str(terminator), size, newsound.size, type, target)
|
||||
except CodecNotSupportedError as msg:
|
||||
print(msg)
|
||||
except KeyError: # sound received after user removed
|
||||
pass
|
||||
|
||||
# if len(message) - pos < size:
|
||||
# raise InvalidFormatError("Invalid audio frame size")
|
||||
|
||||
pos += size # go further in the packet, after the audio frame
|
||||
# TODO: get position info
|
||||
|
||||
def set_application_string(self, string):
|
||||
"""Set the application name, that can be viewed by other clients on the server"""
|
||||
self.application = string
|
||||
|
||||
def set_loop_rate(self, rate):
|
||||
"""Set the current main loop rate (pause per iteration)"""
|
||||
self.loop_rate = rate
|
||||
|
||||
def get_loop_rate(self):
|
||||
"""Get the current main loop rate (pause per iteration)"""
|
||||
return self.loop_rate
|
||||
|
||||
def set_codec_profile(self, profile):
|
||||
"""set the audio profile"""
|
||||
if profile in ["audio", "voip", "restricted_lowdelay"]:
|
||||
self.__opus_profile = profile
|
||||
else:
|
||||
raise ValueError("Unknown profile: " + str(profile))
|
||||
|
||||
def get_codec_profile(self):
|
||||
"""return the audio profile string"""
|
||||
return self.__opus_profile
|
||||
|
||||
def set_receive_sound(self, value):
|
||||
"""Enable or disable the management of incoming sounds"""
|
||||
if value:
|
||||
self.receive_sound = True
|
||||
else:
|
||||
self.receive_sound = False
|
||||
|
||||
def is_ready(self):
|
||||
"""Wait for the connection to be fully completed. To be used in the main thread"""
|
||||
self.ready_lock.acquire()
|
||||
self.ready_lock.release()
|
||||
|
||||
def execute_command(self, cmd, blocking=True):
|
||||
"""Create a command to be sent to the server. To be used in the main thread"""
|
||||
self.is_ready()
|
||||
|
||||
lock = self.commands.new_cmd(cmd)
|
||||
if blocking and self.mumble_thread is not threading.current_thread():
|
||||
lock.acquire()
|
||||
lock.release()
|
||||
|
||||
return lock
|
||||
|
||||
# TODO: manage a timeout for blocking commands. Currently, no command actually waits for the server to execute
|
||||
# The result of these commands should actually be checked against incoming server updates
|
||||
|
||||
def treat_command(self, cmd):
|
||||
"""Send the awaiting commands to the server. Used in the pymumble thread."""
|
||||
if cmd.cmd == PYMUMBLE_CMD_MOVE:
|
||||
userstate = mumble_pb2.UserState()
|
||||
userstate.session = cmd.parameters["session"]
|
||||
userstate.channel_id = cmd.parameters["channel_id"]
|
||||
self.Log.debug("Moving to channel")
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_TEXTMESSAGE:
|
||||
textmessage = mumble_pb2.TextMessage()
|
||||
textmessage.session.append(cmd.parameters["session"])
|
||||
textmessage.channel_id.append(cmd.parameters["channel_id"])
|
||||
textmessage.message = cmd.parameters["message"]
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_TEXTMESSAGE, textmessage)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_TEXTPRIVATEMESSAGE:
|
||||
textprivatemessage = mumble_pb2.TextMessage()
|
||||
textprivatemessage.session.append(cmd.parameters["session"])
|
||||
textprivatemessage.message = cmd.parameters["message"]
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_TEXTMESSAGE, textprivatemessage)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_MSG_TYPES_CHANNELSTATE:
|
||||
channelstate = mumble_pb2.ChannelState()
|
||||
channelstate.parent = cmd.parameters["parent"]
|
||||
channelstate.name = cmd.parameters["name"]
|
||||
channelstate.temporary = cmd.parameters["temporary"]
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_MSG_TYPES_CHANNELREMOVE:
|
||||
channelremove = mumble_pb2.ChannelRemove()
|
||||
channelremove.channel_id = cmd.parameters["channel_id"]
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELREMOVE, channelremove)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_UPDATECHANNEL:
|
||||
channelstate = mumble_pb2.ChannelState()
|
||||
for key, value in cmd.parameters.items():
|
||||
setattr(channelstate, key, value)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_LINKCHANNEL:
|
||||
channelstate = mumble_pb2.ChannelState()
|
||||
channelstate.channel_id = cmd.parameters["channel_id"]
|
||||
channelstate.links_add.append(cmd.parameters["add_id"])
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_UNLINKCHANNEL:
|
||||
channelstate = mumble_pb2.ChannelState()
|
||||
channelstate.channel_id = cmd.parameters["channel_id"]
|
||||
for remove_id in cmd.parameters["remove_ids"]:
|
||||
channelstate.links_remove.append(remove_id)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_MSG_TYPES_VOICETARGET:
|
||||
textvoicetarget = mumble_pb2.VoiceTarget()
|
||||
textvoicetarget.id = cmd.parameters["id"]
|
||||
targets = []
|
||||
if cmd.parameters["id"] == 1:
|
||||
voicetarget = mumble_pb2.VoiceTarget.Target()
|
||||
voicetarget.channel_id = cmd.parameters["targets"][0]
|
||||
targets.append(voicetarget)
|
||||
else:
|
||||
for target in cmd.parameters["targets"]:
|
||||
voicetarget = mumble_pb2.VoiceTarget.Target()
|
||||
voicetarget.session.append(target)
|
||||
targets.append(voicetarget)
|
||||
textvoicetarget.targets.extend(targets)
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_VOICETARGET, textvoicetarget)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_MODUSERSTATE:
|
||||
userstate = mumble_pb2.UserState()
|
||||
userstate.session = cmd.parameters["session"]
|
||||
|
||||
if "mute" in cmd.parameters:
|
||||
userstate.mute = cmd.parameters["mute"]
|
||||
if "self_mute" in cmd.parameters:
|
||||
userstate.self_mute = cmd.parameters["self_mute"]
|
||||
if "deaf" in cmd.parameters:
|
||||
userstate.deaf = cmd.parameters["deaf"]
|
||||
if "self_deaf" in cmd.parameters:
|
||||
userstate.self_deaf = cmd.parameters["self_deaf"]
|
||||
if "suppress" in cmd.parameters:
|
||||
userstate.suppress = cmd.parameters["suppress"]
|
||||
if "recording" in cmd.parameters:
|
||||
userstate.recording = cmd.parameters["recording"]
|
||||
if "comment" in cmd.parameters:
|
||||
userstate.comment = cmd.parameters["comment"]
|
||||
if "texture" in cmd.parameters:
|
||||
userstate.texture = cmd.parameters["texture"]
|
||||
if "user_id" in cmd.parameters:
|
||||
userstate.user_id = cmd.parameters["user_id"]
|
||||
if "plugin_context" in cmd.parameters:
|
||||
userstate.plugin_context = cmd.parameters["plugin_context"]
|
||||
if "listening_channel_add" in cmd.parameters:
|
||||
userstate.listening_channel_add.extend(cmd.parameters["listening_channel_add"])
|
||||
if "listening_channel_remove" in cmd.parameters:
|
||||
userstate.listening_channel_remove.extend(cmd.parameters["listening_channel_remove"])
|
||||
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_REMOVEUSER:
|
||||
userremove = mumble_pb2.UserRemove()
|
||||
userremove.session = cmd.parameters["session"]
|
||||
userremove.reason = cmd.parameters["reason"]
|
||||
userremove.ban = cmd.parameters["ban"]
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_USERREMOVE, userremove)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_QUERYACL:
|
||||
acl = mumble_pb2.ACL()
|
||||
acl.channel_id = cmd.parameters["channel_id"]
|
||||
acl.query = True
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_ACL, acl)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
elif cmd.cmd == PYMUMBLE_CMD_UPDATEACL:
|
||||
acl = mumble_pb2.ACL()
|
||||
acl.channel_id = cmd.parameters["channel_id"]
|
||||
acl.inherit_acls = cmd.parameters["inherit_acls"]
|
||||
|
||||
for msg_group in cmd.parameters["chan_group"]:
|
||||
chan_group = mumble_pb2.ACL.ChanGroup()
|
||||
chan_group.name = msg_group['name']
|
||||
if msg_group['inherited'] is not None:
|
||||
chan_group.inherited = msg_group['inherited']
|
||||
if msg_group['inherit'] is not None:
|
||||
chan_group.inherit = msg_group['inherit']
|
||||
if msg_group['inheritable'] is not None:
|
||||
chan_group.inheritable = msg_group['inheritable']
|
||||
for add_id in msg_group['add']:
|
||||
chan_group.add.append(add_id)
|
||||
for remove_id in msg_group['remove']:
|
||||
chan_group.remove.append(remove_id)
|
||||
acl.groups.append(chan_group)
|
||||
|
||||
for msg_acl in cmd.parameters["chan_acl"]:
|
||||
chan_acl = mumble_pb2.ACL.ChanACL()
|
||||
if msg_acl['apply_here'] is not None:
|
||||
chan_acl.apply_here = msg_acl['apply_here']
|
||||
if msg_acl['apply_subs'] is not None:
|
||||
chan_acl.apply_subs = msg_acl['apply_subs']
|
||||
if msg_acl['inherited'] is not None:
|
||||
chan_acl.inherited = msg_acl['inherited']
|
||||
if msg_acl['user_id'] is not None:
|
||||
chan_acl.user_id = msg_acl['user_id']
|
||||
if msg_acl['group'] is not None:
|
||||
chan_acl.group = msg_acl['group']
|
||||
if msg_acl['grant'] is not None:
|
||||
chan_acl.grant = msg_acl['grant']
|
||||
if msg_acl['deny'] is not None:
|
||||
chan_acl.deny = msg_acl['deny']
|
||||
|
||||
if not chan_acl.inherited:
|
||||
acl.acls.append(chan_acl)
|
||||
|
||||
acl.query = False
|
||||
self.send_message(PYMUMBLE_MSG_TYPES_ACL, acl)
|
||||
cmd.response = True
|
||||
self.commands.answer(cmd)
|
||||
|
||||
def get_max_message_length(self):
|
||||
return self.server_max_message_length
|
||||
|
||||
def get_max_image_length(self):
|
||||
return self.server_max_image_message_length
|
||||
|
||||
def my_channel(self):
|
||||
return self.channels[self.users.myself["channel_id"]]
|
||||
|
||||
def denial_type(self, n):
|
||||
return mumble_pb2.PermissionDenied.DenyType.Name(n)
|
||||
|
||||
def stop(self):
|
||||
self.reconnect = None
|
||||
self.exit = True
|
||||
self.control_socket.close()
|
||||
2589
pymumble_py3/mumble_pb2.py
Normal file
2589
pymumble_py3/mumble_pb2.py
Normal file
File diff suppressed because one or more lines are too long
215
pymumble_py3/soundoutput.py
Normal file
215
pymumble_py3/soundoutput.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from time import time
|
||||
import struct
|
||||
import threading
|
||||
import socket
|
||||
import opuslib
|
||||
|
||||
from .constants import *
|
||||
from .errors import CodecNotSupportedError
|
||||
from .tools import VarInt
|
||||
from .messages import VoiceTarget
|
||||
|
||||
|
||||
class SoundOutput:
|
||||
"""
|
||||
Class managing the sounds that must be sent to the server (best sent in a multiple of audio_per_packet samples)
|
||||
The buffering is the responsibility of the caller, any partial sound will be sent without delay
|
||||
"""
|
||||
|
||||
def __init__(self, mumble_object, audio_per_packet, bandwidth, stereo=False, opus_profile=PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE):
|
||||
"""
|
||||
audio_per_packet=packet audio duration in sec
|
||||
bandwidth=maximum total outgoing bandwidth
|
||||
"""
|
||||
self.mumble_object = mumble_object
|
||||
|
||||
self.Log = self.mumble_object.Log
|
||||
|
||||
self.pcm = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
self.codec = None # codec currently requested by the server
|
||||
self.encoder = None # codec instance currently used to encode
|
||||
self.encoder_framesize = None # size of an audio frame for the current codec (OPUS=audio_per_packet, CELT=0.01s)
|
||||
self.opus_profile = opus_profile
|
||||
self.channels = 1 if not stereo else 2
|
||||
|
||||
self.set_audio_per_packet(audio_per_packet)
|
||||
self.set_bandwidth(bandwidth)
|
||||
|
||||
self.codec_type = None # codec type number to be used in audio packets
|
||||
self.target = 0 # target is not implemented yet, so always 0
|
||||
|
||||
self.sequence_start_time = 0 # time of sequence 1
|
||||
self.sequence_last_time = 0 # time of the last emitted packet
|
||||
self.sequence = 0 # current sequence
|
||||
|
||||
def send_audio(self):
|
||||
"""send the available audio to the server, taking care of the timing"""
|
||||
if not self.encoder or len(self.pcm) == 0: # no codec configured or no audio sent
|
||||
return ()
|
||||
|
||||
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
|
||||
|
||||
while len(self.pcm) > 0 and self.sequence_last_time + self.audio_per_packet <= time(): # audio to send and time to send it (since last packet)
|
||||
current_time = time()
|
||||
if self.sequence_last_time + PYMUMBLE_SEQUENCE_RESET_INTERVAL <= current_time: # waited enough, resetting sequence to 0
|
||||
self.sequence = 0
|
||||
self.sequence_start_time = current_time
|
||||
self.sequence_last_time = current_time
|
||||
elif self.sequence_last_time + (self.audio_per_packet * 2) <= current_time: # give some slack (2*audio_per_frame) before interrupting a continuous sequence
|
||||
# calculating sequence after a pause
|
||||
self.sequence = int((current_time - self.sequence_start_time) / PYMUMBLE_SEQUENCE_DURATION)
|
||||
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
|
||||
else: # continuous sound
|
||||
self.sequence += int(self.audio_per_packet / PYMUMBLE_SEQUENCE_DURATION)
|
||||
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
|
||||
|
||||
payload = bytearray() # content of the whole packet, without tcptunnel header
|
||||
audio_encoded = 0 # audio time already in the packet
|
||||
|
||||
while len(self.pcm) > 0 and audio_encoded < self.audio_per_packet: # more audio to be sent and packet not full
|
||||
self.lock.acquire()
|
||||
to_encode = self.pcm.pop(0)
|
||||
self.lock.release()
|
||||
|
||||
if len(to_encode) != samples: # pad to_encode if needed to match sample length
|
||||
to_encode += b'\x00' * (samples - len(to_encode))
|
||||
|
||||
try:
|
||||
encoded = self.encoder.encode(to_encode, len(to_encode) // (2 * self.channels))
|
||||
except opuslib.exceptions.OpusError:
|
||||
encoded = b''
|
||||
|
||||
audio_encoded += self.encoder_framesize
|
||||
|
||||
# create the audio frame header
|
||||
if self.codec_type == PYMUMBLE_AUDIO_TYPE_OPUS:
|
||||
frameheader = VarInt(len(encoded)).encode()
|
||||
else:
|
||||
frameheader = len(encoded)
|
||||
if audio_encoded < self.audio_per_packet and len(self.pcm) > 0: # if not last frame for the packet, set the terminator bit
|
||||
frameheader += (1 << 7)
|
||||
frameheader = struct.pack('!B', frameheader)
|
||||
|
||||
payload += frameheader + encoded # add the frame to the packet
|
||||
|
||||
header = self.codec_type << 5 # encapsulate in audio packet
|
||||
sequence = VarInt(self.sequence).encode()
|
||||
|
||||
udppacket = struct.pack('!B', header | self.target) + sequence + payload
|
||||
if self.mumble_object.positional:
|
||||
udppacket += struct.pack("fff", self.mumble_object.positional[0], self.mumble_object.positional[1], self.mumble_object.positional[2])
|
||||
|
||||
self.Log.debug("audio packet to send: sequence:{sequence}, type:{type}, length:{len}".format(
|
||||
sequence=self.sequence,
|
||||
type=self.codec_type,
|
||||
len=len(udppacket)
|
||||
))
|
||||
|
||||
tcppacket = struct.pack("!HL", PYMUMBLE_MSG_TYPES_UDPTUNNEL, len(udppacket)) + udppacket # encapsulate in tcp tunnel
|
||||
|
||||
while len(tcppacket) > 0:
|
||||
sent = self.mumble_object.control_socket.send(tcppacket)
|
||||
if sent < 0:
|
||||
raise socket.error("Server socket error")
|
||||
tcppacket = tcppacket[sent:]
|
||||
|
||||
def get_audio_per_packet(self):
|
||||
"""return the configured length of a audio packet (in ms)"""
|
||||
return self.audio_per_packet
|
||||
|
||||
def set_audio_per_packet(self, audio_per_packet):
|
||||
"""set the length of an audio packet (in ms)"""
|
||||
self.audio_per_packet = audio_per_packet
|
||||
self.create_encoder()
|
||||
|
||||
def get_bandwidth(self):
|
||||
"""get the configured bandwidth for the audio output"""
|
||||
return self.bandwidth
|
||||
|
||||
def set_bandwidth(self, bandwidth):
|
||||
"""set the bandwidth for the audio output"""
|
||||
self.bandwidth = bandwidth
|
||||
self._set_bandwidth()
|
||||
|
||||
def _set_bandwidth(self):
|
||||
"""do the calculation of the overhead and configure the actual bitrate for the codec"""
|
||||
if self.encoder:
|
||||
overhead_per_packet = 20 # IP header in bytes
|
||||
overhead_per_packet += (3 * int(self.audio_per_packet / self.encoder_framesize)) # overhead per frame
|
||||
if self.mumble_object.udp_active:
|
||||
overhead_per_packet += 12 # UDP header
|
||||
else:
|
||||
overhead_per_packet += 20 # TCP header
|
||||
overhead_per_packet += 6 # TCPTunnel encapsulation
|
||||
|
||||
overhead_per_second = int(overhead_per_packet * 8 / self.audio_per_packet) # in bits
|
||||
|
||||
self.Log.debug(
|
||||
"Bandwidth is {bandwidth}, downgrading to {bitrate} due to the protocol overhead".format(bandwidth=self.bandwidth, bitrate=self.bandwidth - overhead_per_second))
|
||||
|
||||
self.encoder.bitrate = self.bandwidth - overhead_per_second
|
||||
|
||||
def add_sound(self, pcm):
|
||||
"""add sound to be sent (in PCM 16 bits signed format)"""
|
||||
if len(pcm) % 2 != 0: # check that the data is align on 16 bits
|
||||
raise Exception("pcm data must be 16 bits")
|
||||
|
||||
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
|
||||
|
||||
self.lock.acquire()
|
||||
if len(self.pcm) and len(self.pcm[-1]) < samples:
|
||||
initial_offset = samples - len(self.pcm[-1])
|
||||
self.pcm[-1] += pcm[:initial_offset]
|
||||
else:
|
||||
initial_offset = 0
|
||||
for i in range(initial_offset, len(pcm), samples):
|
||||
self.pcm.append(pcm[i:i + samples])
|
||||
self.lock.release()
|
||||
|
||||
def clear_buffer(self):
|
||||
self.lock.acquire()
|
||||
self.pcm = []
|
||||
self.lock.release()
|
||||
|
||||
def get_buffer_size(self):
|
||||
"""return the size of the unsent buffer in sec"""
|
||||
return sum(len(chunk) for chunk in self.pcm) / 2. / PYMUMBLE_SAMPLERATE / self.channels
|
||||
|
||||
def set_default_codec(self, codecversion):
|
||||
"""Set the default codec to be used to send packets"""
|
||||
self.codec = codecversion
|
||||
self.create_encoder()
|
||||
|
||||
def create_encoder(self):
|
||||
"""create the encoder instance, and set related constants"""
|
||||
if not self.codec:
|
||||
return ()
|
||||
|
||||
if self.codec.opus:
|
||||
self.encoder = opuslib.Encoder(PYMUMBLE_SAMPLERATE, self.channels, self.opus_profile)
|
||||
self.encoder_framesize = self.audio_per_packet
|
||||
self.codec_type = PYMUMBLE_AUDIO_TYPE_OPUS
|
||||
else:
|
||||
raise CodecNotSupportedError('')
|
||||
|
||||
self._set_bandwidth()
|
||||
|
||||
def set_whisper(self, target_id, channel=False):
|
||||
if not target_id:
|
||||
return
|
||||
if type(target_id) is int:
|
||||
target_id = [target_id]
|
||||
self.target = 2
|
||||
if channel:
|
||||
self.target = 1
|
||||
cmd = VoiceTarget(self.target, target_id)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def remove_whisper(self):
|
||||
self.target = 0
|
||||
cmd = VoiceTarget(self.target, [])
|
||||
self.mumble_object.execute_command(cmd)
|
||||
142
pymumble_py3/soundqueue.py
Normal file
142
pymumble_py3/soundqueue.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
from threading import Lock
|
||||
from collections import deque
|
||||
|
||||
import opuslib
|
||||
|
||||
from .constants import *
|
||||
|
||||
|
||||
class SoundQueue:
|
||||
"""
|
||||
Per user storage of received audio frames
|
||||
Takes care of the decoding of the received audio
|
||||
"""
|
||||
def __init__(self, mumble_object):
|
||||
self.mumble_object = mumble_object
|
||||
|
||||
self.queue = deque()
|
||||
self.start_sequence = None
|
||||
self.start_time = None
|
||||
|
||||
self.receive_sound = True
|
||||
|
||||
self.lock = Lock()
|
||||
|
||||
# to be sure, create every supported decoders for all users
|
||||
# sometime, clients still use a codec for a while after server request another...
|
||||
self.decoders = {
|
||||
PYMUMBLE_AUDIO_TYPE_OPUS: opuslib.Decoder(PYMUMBLE_SAMPLERATE, 1)
|
||||
}
|
||||
|
||||
def set_receive_sound(self, value):
|
||||
"""Define if received sounds must be kept or discarded in this specific queue (user)"""
|
||||
if value:
|
||||
self.receive_sound = True
|
||||
else:
|
||||
self.receive_sound = False
|
||||
|
||||
def add(self, audio, sequence, type, target):
|
||||
"""Add a new audio frame to the queue, after decoding"""
|
||||
if not self.receive_sound:
|
||||
return None
|
||||
|
||||
self.lock.acquire()
|
||||
|
||||
try:
|
||||
pcm = self.decoders[type].decode(audio, PYMUMBLE_READ_BUFFER_SIZE)
|
||||
|
||||
if not self.start_sequence or sequence <= self.start_sequence:
|
||||
# New sequence started
|
||||
self.start_time = time.time()
|
||||
self.start_sequence = sequence
|
||||
calculated_time = self.start_time
|
||||
else:
|
||||
# calculating position in current sequence
|
||||
calculated_time = self.start_time + (sequence - self.start_sequence) * PYMUMBLE_SEQUENCE_DURATION
|
||||
|
||||
newsound = SoundChunk(pcm, sequence, len(pcm), calculated_time, type, target)
|
||||
|
||||
if not self.mumble_object.callbacks.get_callback(PYMUMBLE_CLBK_SOUNDRECEIVED):
|
||||
self.queue.appendleft(newsound)
|
||||
|
||||
if len(self.queue) > 1 and self.queue[0].time < self.queue[1].time:
|
||||
# sort the audio chunk if it came out of order
|
||||
cpt = 0
|
||||
while cpt < len(self.queue) - 1 and self.queue[cpt].time < self.queue[cpt+1].time:
|
||||
tmp = self.queue[cpt+1]
|
||||
self.queue[cpt+1] = self.queue[cpt]
|
||||
self.queue[cpt] = tmp
|
||||
|
||||
self.lock.release()
|
||||
return newsound
|
||||
except KeyError:
|
||||
self.lock.release()
|
||||
self.mumble_object.Log.error("Codec not supported (audio packet type {0})".format(type))
|
||||
except Exception as e:
|
||||
self.lock.release()
|
||||
self.mumble_object.Log.error("error while decoding audio. sequence:{seq}, type:{type}. {error}".format(seq=sequence, type=type, error=str(e)))
|
||||
|
||||
def is_sound(self):
|
||||
"""Boolean to check if there is a sound frame in the queue"""
|
||||
if len(self.queue) > 0:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_sound(self, duration=None):
|
||||
"""Return the first sound of the queue and discard it"""
|
||||
self.lock.acquire()
|
||||
|
||||
if len(self.queue) > 0:
|
||||
if duration is None or self.first_sound().duration <= duration:
|
||||
result = self.queue.pop()
|
||||
else:
|
||||
result = self.first_sound().extract_sound(duration)
|
||||
else:
|
||||
result = None
|
||||
|
||||
self.lock.release()
|
||||
return result
|
||||
|
||||
def first_sound(self):
|
||||
"""Return the first sound of the queue, but keep it"""
|
||||
if len(self.queue) > 0:
|
||||
return self.queue[-1]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class SoundChunk:
|
||||
"""
|
||||
Object that contains the actual audio frame, in PCM format"""
|
||||
def __init__(self, pcm, sequence, size, calculated_time, type, target, timestamp=time.time()):
|
||||
self.timestamp = timestamp # measured time of arrival of the sound
|
||||
self.time = calculated_time # calculated time of arrival of the sound (based on sequence)
|
||||
self.pcm = pcm # audio data
|
||||
self.sequence = sequence # sequence of the packet
|
||||
self.size = size # size
|
||||
self.duration = float(size) / 2 / PYMUMBLE_SAMPLERATE # duration in sec
|
||||
self.type = type # type of the audio (codec)
|
||||
self.target = target # target of the audio
|
||||
|
||||
def extract_sound(self, duration):
|
||||
"""Extract part of the chunk, leaving a valid chunk for the remaining part"""
|
||||
size = int(duration*2*PYMUMBLE_SAMPLERATE)
|
||||
result = SoundChunk(
|
||||
self.pcm[:size],
|
||||
self.sequence,
|
||||
size,
|
||||
self.time,
|
||||
self.type,
|
||||
self.target,
|
||||
self.timestamp
|
||||
)
|
||||
|
||||
self.pcm = self.pcm[size:]
|
||||
self.duration -= duration
|
||||
self.time += duration
|
||||
self.size -= size
|
||||
|
||||
return result
|
||||
191
pymumble_py3/test_crypto.py
Normal file
191
pymumble_py3/test_crypto.py
Normal file
@@ -0,0 +1,191 @@
|
||||
'''
|
||||
Ported from Mumble src/tests/TestCrypt/TestCrypt.cpp
|
||||
|
||||
=============================================================
|
||||
|
||||
Copyright 2005-2020 The Mumble Developers. All rights reserved.
|
||||
Use of this source code is governed by a BSD-style license
|
||||
that can be found in the LICENSE file at the root of the
|
||||
Mumble source tree or at <https://www.mumble.info/LICENSE>.
|
||||
'''
|
||||
|
||||
import pytest
|
||||
|
||||
from .crypto import CryptStateOCB2, AES_BLOCK_SIZE, EncryptFailedException, DecryptFailedException
|
||||
from .crypto import ocb_encrypt, ocb_decrypt
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rawkey():
|
||||
return bytes((0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nonce():
|
||||
return bytes((0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00))
|
||||
|
||||
|
||||
def test_reverserecovery():
|
||||
enc = CryptStateOCB2()
|
||||
dec = CryptStateOCB2()
|
||||
enc.gen_key()
|
||||
|
||||
# For our testcase, we're going to FORCE iv
|
||||
enc.encrypt_iv = bytes((0x55,) * AES_BLOCK_SIZE)
|
||||
dec.set_key(enc.raw_key, enc.decrypt_iv, enc.encrypt_iv)
|
||||
|
||||
secret = "abcdefghi".encode('ascii')
|
||||
|
||||
crypted = [enc.encrypt(secret) for _ in range(128)]
|
||||
|
||||
i = 0
|
||||
for crypt in reversed(crypted[-30:]):
|
||||
print(i)
|
||||
i += 1
|
||||
dec.decrypt(crypt, len(secret))
|
||||
for crypt in reversed(crypted[:-30]):
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dec.decrypt(crypt, len(secret))
|
||||
for crypt in reversed(crypted[-30:]):
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dec.decrypt(crypt, len(secret))
|
||||
|
||||
# Extensive replay attack test
|
||||
crypted = [enc.encrypt(secret) for _ in range(512)]
|
||||
|
||||
for crypt in crypted:
|
||||
decr = dec.decrypt(crypt, len(secret))
|
||||
for crypt in crypted:
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dec.decrypt(crypt, len(secret))
|
||||
|
||||
|
||||
def test_ivrecovery():
|
||||
enc = CryptStateOCB2()
|
||||
dec = CryptStateOCB2()
|
||||
enc.gen_key()
|
||||
|
||||
# For our testcase, we're going to FORCE iv
|
||||
enc.encrypt_iv = bytes((0x55,) * AES_BLOCK_SIZE)
|
||||
dec.set_key(enc.raw_key, enc.decrypt_iv, enc.encrypt_iv)
|
||||
|
||||
secret = "abcdefghi".encode('ascii')
|
||||
|
||||
crypted = enc.encrypt(secret)
|
||||
|
||||
# Can decrypt
|
||||
decr = dec.decrypt(crypted, len(secret))
|
||||
# ... correctly.
|
||||
assert secret == decr
|
||||
|
||||
# But will refuse to reuse same IV.
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dec.decrypt(crypted, len(secret))
|
||||
|
||||
# Recover from lost packet.
|
||||
for i in range(16):
|
||||
crypted = enc.encrypt(secret)
|
||||
decr = dec.decrypt(crypted, len(secret))
|
||||
|
||||
# Wraparound.
|
||||
for i in range(128):
|
||||
dec.uiLost = 0
|
||||
for j in range(15):
|
||||
crypted = enc.encrypt(secret)
|
||||
decr = dec.decrypt(crypted, len(secret))
|
||||
assert dec.uiLost == 14
|
||||
|
||||
assert enc.encrypt_iv == dec.decrypt_iv
|
||||
|
||||
# Wrap too far
|
||||
for i in range(257):
|
||||
crypted = enc.encrypt(secret);
|
||||
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dec.decrypt(crypted, len(secret))
|
||||
|
||||
# Sync it
|
||||
dec.decrypt_iv = enc.encrypt_iv
|
||||
crypted = enc.encrypt(secret)
|
||||
|
||||
decr = dec.decrypt(crypted, len(secret))
|
||||
|
||||
def test_testvectors(rawkey):
|
||||
# Test vectors are from draft-krovetz-ocb-00.txt
|
||||
cs = CryptStateOCB2()
|
||||
cs.set_key(rawkey, rawkey, rawkey)
|
||||
|
||||
_, tag = ocb_encrypt(cs._aes, bytes(), rawkey)
|
||||
|
||||
blanktag = bytes((0xBF,0x31,0x08,0x13,0x07,0x73,0xAD,0x5E,0xC7,0x0E,0xC6,0x9E,0x78,0x75,0xA7,0xB0))
|
||||
assert len(blanktag) == AES_BLOCK_SIZE
|
||||
|
||||
assert tag == blanktag
|
||||
|
||||
source = bytes(range(40))
|
||||
crypt, tag = ocb_encrypt(cs._aes, source, rawkey)
|
||||
longtag = bytes((0x9D,0xB0,0xCD,0xF8,0x80,0xF7,0x3E,0x3E,0x10,0xD4,0xEB,0x32,0x17,0x76,0x66,0x88))
|
||||
crypted = bytes((0xF7,0x5D,0x6B,0xC8,0xB4,0xDC,0x8D,0x66,0xB8,0x36,0xA2,0xB0,0x8B,0x32,0xA6,0x36,0x9F,0x1C,0xD3,0xC5,0x22,0x8D,0x79,0xFD,
|
||||
0x6C,0x26,0x7F,0x5F,0x6A,0xA7,0xB2,0x31,0xC7,0xDF,0xB9,0xD5,0x99,0x51,0xAE,0x9C))
|
||||
|
||||
assert tag == longtag
|
||||
assert crypt[:len(crypted)] == crypted
|
||||
|
||||
|
||||
def test_authcrypt(rawkey, nonce):
|
||||
cs = CryptStateOCB2()
|
||||
for ll in range(128):
|
||||
cs.set_key(rawkey, nonce, nonce)
|
||||
src = bytes((i + 1 for i in range(ll)))
|
||||
|
||||
encrypted, enctag = ocb_encrypt(cs._aes, src, nonce)
|
||||
decrypted, dectag = ocb_decrypt(cs._aes, encrypted, nonce, len(src))
|
||||
|
||||
assert enctag == dectag
|
||||
assert src == decrypted
|
||||
|
||||
|
||||
def test_xexstarAttack(rawkey, nonce):
|
||||
''' Test prevention of the attack described in section 4.1 of https://eprint.iacr.org/2019/311 '''
|
||||
cs = CryptStateOCB2()
|
||||
cs.set_key(rawkey, nonce, nonce)
|
||||
|
||||
# Set first block to `len(secondBlock)`
|
||||
# Set second block to arbitrary value
|
||||
src = bytearray(AES_BLOCK_SIZE) + bytearray([42] * AES_BLOCK_SIZE)
|
||||
src[AES_BLOCK_SIZE - 1] = AES_BLOCK_SIZE * 8
|
||||
print(src)
|
||||
|
||||
with pytest.raises(EncryptFailedException):
|
||||
ocb_encrypt(cs._aes, src, nonce)
|
||||
encrypted, enctag = ocb_encrypt(cs._aes, src, nonce, insecure=True)
|
||||
|
||||
# Perform the attack
|
||||
encrypted = bytearray(encrypted)
|
||||
enctag = bytearray(enctag)
|
||||
encrypted[AES_BLOCK_SIZE - 1] ^= AES_BLOCK_SIZE * 8
|
||||
for i in range(AES_BLOCK_SIZE):
|
||||
enctag[i] = src[AES_BLOCK_SIZE + i] ^ encrypted[AES_BLOCK_SIZE + i]
|
||||
|
||||
with pytest.raises(DecryptFailedException):
|
||||
dc, dct = ocb_decrypt(cs._aes, encrypted, nonce, AES_BLOCK_SIZE)
|
||||
print(dc, dct, enctag == dct)
|
||||
decrypted, dectag = ocb_decrypt(cs._aes, encrypted, nonce, AES_BLOCK_SIZE, insecure=True)
|
||||
|
||||
# Verify forged tag (should match if attack is properly implemented)
|
||||
assert enctag == dectag
|
||||
|
||||
|
||||
def test_tamper(rawkey, nonce):
|
||||
cs = CryptStateOCB2()
|
||||
cs.set_key(rawkey, nonce, nonce)
|
||||
|
||||
msg = "It was a funky funky town!".encode('ascii')
|
||||
encrypted = bytearray(cs.encrypt(msg))
|
||||
|
||||
for i in range(len(msg) * 8):
|
||||
encrypted[i // 8] ^= 1 << (i % 8)
|
||||
with pytest.raises(DecryptFailedException):
|
||||
cs.decrypt(encrypted, len(msg))
|
||||
encrypted[i // 8] ^= 1 << (i % 8)
|
||||
decrypted = cs.decrypt(encrypted, len(msg))
|
||||
128
pymumble_py3/tools.py
Normal file
128
pymumble_py3/tools.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import struct
|
||||
|
||||
|
||||
class InvalidVarInt(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VarInt:
|
||||
"""Implement the varint type used in mumble"""
|
||||
def __init__(self, value=0):
|
||||
self.value = value
|
||||
|
||||
def encode(self):
|
||||
"""Encode an integer in the VarInt format, returning a binary string"""
|
||||
result = bytearray()
|
||||
value = abs(self.value)
|
||||
|
||||
if self.value < 0:
|
||||
if self.value >= -3:
|
||||
return struct.pack("!B", (0b11111100 | value))
|
||||
else:
|
||||
result = struct.pack("!B", 0b11111000)
|
||||
|
||||
if value <= 0x7f:
|
||||
return result + struct.pack("!B", value)
|
||||
elif value <= 0x3fff:
|
||||
return result + struct.pack("!H", 0x8000 | value)
|
||||
elif value <= 0x1fffff:
|
||||
return result + struct.pack("!BH", 0xc0 | (value >> 16), 0xffff & value)
|
||||
elif value <= 0xfffffff:
|
||||
return result + struct.pack("!L", 0xe0000000 | value)
|
||||
elif value <= 0xffffffff:
|
||||
return result + struct.pack("!BL", 0b11110000, value)
|
||||
else:
|
||||
return result + struct.pack("!BQ", 0b11110100, value)
|
||||
|
||||
def decode(self, value):
|
||||
"""Decode a VarInt contained in a binary string, returning an integer"""
|
||||
varint = value
|
||||
is_negative = False
|
||||
result = None
|
||||
size = 0
|
||||
|
||||
if len(varint) <= 0:
|
||||
raise InvalidVarInt("length can't be 0")
|
||||
|
||||
(first, ) = struct.unpack("!B", varint[0:1])
|
||||
|
||||
if first & 0b11111100 == 0b11111000:
|
||||
is_negative = True
|
||||
size += 1
|
||||
if len(varint) < 2:
|
||||
raise InvalidVarInt("Too short negative varint")
|
||||
varint = varint[1:]
|
||||
(first, ) = struct.unpack("!B", varint[0:1])
|
||||
|
||||
if first & 0b10000000 == 0b00000000:
|
||||
(result, ) = struct.unpack("!B", varint[0:1])
|
||||
size += 1
|
||||
elif first & 0b11111100 == 0b11111100:
|
||||
(result, ) = struct.unpack("!B", varint[0:1])
|
||||
result &= 0b00000011
|
||||
is_negative = True
|
||||
size += 1
|
||||
elif first & 0b11000000 == 0b10000000:
|
||||
if len(varint) < 2:
|
||||
raise InvalidVarInt("Too short 2 bytes varint")
|
||||
(result, ) = struct.unpack("!H", varint[:2])
|
||||
result &= 0b0011111111111111
|
||||
size += 2
|
||||
elif first & 0b11100000 == 0b11000000:
|
||||
if len(varint) < 3:
|
||||
raise InvalidVarInt("Too short 3 bytes varint")
|
||||
(result, ) = struct.unpack("!B", varint[0:1])
|
||||
result &= 0b00011111
|
||||
(tmp, ) = struct.unpack("!H", varint[1:3])
|
||||
result = (result << 16) + tmp
|
||||
size += 3
|
||||
elif first & 0b11110000 == 0b11100000:
|
||||
if len(varint) < 4:
|
||||
raise InvalidVarInt("Too short 4 bytes varint")
|
||||
(result, ) = struct.unpack("!L", varint[:4])
|
||||
result &= 0x0fffffff
|
||||
size += 4
|
||||
elif first & 0b11111100 == 0b11110000:
|
||||
if len(varint) < 5:
|
||||
raise InvalidVarInt("Too short 5 bytes varint")
|
||||
(result, ) = struct.unpack("!L", varint[1:5])
|
||||
size += 5
|
||||
elif first & 0b11111100 == 0b11110100:
|
||||
if len(varint) < 9:
|
||||
raise InvalidVarInt("Too short 9 bytes varint")
|
||||
(result, ) = struct.unpack("!Q", varint[1:9])
|
||||
size += 9
|
||||
|
||||
if is_negative:
|
||||
self.value = - result
|
||||
else:
|
||||
self.value = result
|
||||
|
||||
return size
|
||||
|
||||
|
||||
def tohex(buffer):
|
||||
"""Used for debugging. Output a sting in hex format"""
|
||||
result = "\n"
|
||||
cpt1 = 0
|
||||
cpt2 = 0
|
||||
|
||||
for byte in buffer:
|
||||
result += hex(ord(byte))[2:].zfill(2)
|
||||
cpt1 += 1
|
||||
|
||||
if cpt1 >= 4:
|
||||
result += " "
|
||||
cpt1 = 0
|
||||
cpt2 += 1
|
||||
|
||||
if cpt2 >= 10:
|
||||
result += "\n"
|
||||
cpt2 = 0
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
283
pymumble_py3/users.py
Normal file
283
pymumble_py3/users.py
Normal file
@@ -0,0 +1,283 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .constants import *
|
||||
from .errors import TextTooLongError, ImageTooBigError
|
||||
from threading import Lock
|
||||
from . import messages
|
||||
from . import mumble_pb2
|
||||
|
||||
|
||||
class Users(dict):
|
||||
"""Object that stores and update all connected users"""
|
||||
|
||||
def __init__(self, mumble_object, callbacks):
|
||||
self.mumble_object = mumble_object
|
||||
self.callbacks = callbacks
|
||||
|
||||
self.myself = None # user object of the pymumble thread itself
|
||||
self.myself_session = None # session number of the pymumble thread itself
|
||||
self.lock = Lock()
|
||||
|
||||
def update(self, message):
|
||||
"""Update a user information, based on the incoming message"""
|
||||
self.lock.acquire()
|
||||
|
||||
if message.session not in self:
|
||||
self[message.session] = User(self.mumble_object, message)
|
||||
self.callbacks(PYMUMBLE_CLBK_USERCREATED, self[message.session])
|
||||
if message.session == self.myself_session:
|
||||
self.myself = self[message.session]
|
||||
else:
|
||||
actions = self[message.session].update(message)
|
||||
self.callbacks(PYMUMBLE_CLBK_USERUPDATED, self[message.session], actions)
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def remove(self, message):
|
||||
"""Remove a user object based on server info"""
|
||||
self.lock.acquire()
|
||||
|
||||
if message.session in self:
|
||||
user = self[message.session]
|
||||
del self[message.session]
|
||||
self.callbacks(PYMUMBLE_CLBK_USERREMOVED, user, message)
|
||||
|
||||
self.lock.release()
|
||||
|
||||
def set_myself(self, session):
|
||||
"""Set the "myself" user"""
|
||||
self.myself_session = session
|
||||
if session in self:
|
||||
self.myself = self[session]
|
||||
|
||||
def count(self):
|
||||
"""Return the count of connected users"""
|
||||
return len(self)
|
||||
|
||||
|
||||
class User(dict):
|
||||
"""Object that store one user"""
|
||||
|
||||
def __init__(self, mumble_object, message):
|
||||
self.mumble_object = mumble_object
|
||||
self["session"] = message.session
|
||||
self["channel_id"] = 0
|
||||
self.update(message)
|
||||
|
||||
if mumble_object.receive_sound:
|
||||
from . import soundqueue
|
||||
self.sound = soundqueue.SoundQueue(self.mumble_object) # will hold this user incoming audio
|
||||
else:
|
||||
self.sound = None
|
||||
|
||||
def update(self, message):
|
||||
"""Update user state, based on an incoming message"""
|
||||
actions = dict()
|
||||
|
||||
if message.HasField("actor"):
|
||||
actions["actor"] = message.actor
|
||||
|
||||
for (field, value) in message.ListFields():
|
||||
if field.name in ("session", "actor", "comment", "texture", "plugin_context", "plugin_identity"):
|
||||
continue
|
||||
actions.update(self.update_field(field.name, value))
|
||||
|
||||
if message.HasField("comment_hash"):
|
||||
if message.HasField("comment"):
|
||||
self.mumble_object.blobs[message.comment_hash] = message.comment
|
||||
else:
|
||||
self.mumble_object.blobs.get_user_comment(message.comment_hash)
|
||||
if message.HasField("texture_hash"):
|
||||
if message.HasField("texture"):
|
||||
self.mumble_object.blobs[message.texture_hash] = message.texture
|
||||
else:
|
||||
self.mumble_object.blobs.get_user_texture(message.texture_hash)
|
||||
|
||||
return actions # return a dict, useful for the callback functions
|
||||
|
||||
def update_field(self, name, field):
|
||||
"""Update one state value for a user"""
|
||||
actions = dict()
|
||||
if name not in self or self[name] != field:
|
||||
self[name] = field
|
||||
actions[name] = field
|
||||
|
||||
return actions
|
||||
|
||||
def get_property(self, property):
|
||||
if property in self:
|
||||
return self[property]
|
||||
else:
|
||||
return None
|
||||
|
||||
def mute(self):
|
||||
"""Mute a user"""
|
||||
params = {"session": self["session"]}
|
||||
|
||||
if self["session"] == self.mumble_object.users.myself_session:
|
||||
params["self_mute"] = True
|
||||
else:
|
||||
params["mute"] = True
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unmute(self):
|
||||
"""Unmute a user"""
|
||||
params = {"session": self["session"]}
|
||||
|
||||
if self["session"] == self.mumble_object.users.myself_session:
|
||||
params["self_mute"] = False
|
||||
else:
|
||||
params["mute"] = False
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def deafen(self):
|
||||
"""Deafen a user"""
|
||||
params = {"session": self["session"]}
|
||||
|
||||
if self["session"] == self.mumble_object.users.myself_session:
|
||||
params["self_deaf"] = True
|
||||
else:
|
||||
params["deaf"] = True
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def undeafen(self):
|
||||
"""Undeafen a user"""
|
||||
params = {"session": self["session"]}
|
||||
|
||||
if self["session"] == self.mumble_object.users.myself_session:
|
||||
params["self_deaf"] = False
|
||||
else:
|
||||
params["deaf"] = False
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def suppress(self):
|
||||
"""Disable a user"""
|
||||
params = {"session": self["session"],
|
||||
"suppress": True}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unsuppress(self):
|
||||
"""Enable a user"""
|
||||
params = {"session": self["session"],
|
||||
"suppress": False}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def recording(self):
|
||||
"""Set the user as recording"""
|
||||
params = {"session": self["session"],
|
||||
"recording": True}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def unrecording(self):
|
||||
"""Set the user as not recording"""
|
||||
params = {"session": self["session"],
|
||||
"recording": False}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def comment(self, comment):
|
||||
"""Set the user comment"""
|
||||
params = {"session": self["session"],
|
||||
"comment": comment}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def texture(self, texture):
|
||||
"""Set the user texture"""
|
||||
params = {"session": self["session"],
|
||||
"texture": texture}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def register(self):
|
||||
"""Register the user (mostly for myself)"""
|
||||
params = {"session": self["session"],
|
||||
"user_id": 0}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def update_context(self, context_name):
|
||||
params = {"session": self["session"],
|
||||
"plugin_context": context_name}
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def move_in(self, channel_id, token=None):
|
||||
if token:
|
||||
authenticate = mumble_pb2.Authenticate()
|
||||
authenticate.username = self.mumble_object.user
|
||||
authenticate.password = self.mumble_object.password
|
||||
authenticate.tokens.extend(self.mumble_object.tokens)
|
||||
authenticate.tokens.extend([token])
|
||||
authenticate.opus = True
|
||||
self.mumble_object.Log.debug("sending: authenticate: %s", authenticate)
|
||||
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_AUTHENTICATE, authenticate)
|
||||
|
||||
session = self.mumble_object.users.myself_session
|
||||
cmd = messages.MoveCmd(session, channel_id)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def send_text_message(self, message):
|
||||
"""Send a text message to the user."""
|
||||
|
||||
# TODO: This check should be done inside execute_command()
|
||||
# However, this is currently not possible because execute_command() does
|
||||
# not actually execute the command.
|
||||
if len(message) > self.mumble_object.get_max_image_length() != 0:
|
||||
raise ImageTooBigError(self.mumble_object.get_max_image_length())
|
||||
|
||||
if not ("<img" in message and "src" in message):
|
||||
if len(message) > self.mumble_object.get_max_message_length() != 0:
|
||||
raise TextTooLongError(self.mumble_object.get_max_message_length())
|
||||
|
||||
cmd = messages.TextPrivateMessage(self["session"], message)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def kick(self, reason=""):
|
||||
params = {"session": self["session"],
|
||||
"reason": reason,
|
||||
"ban": False}
|
||||
|
||||
cmd = messages.RemoveUser(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def ban(self, reason=""):
|
||||
params = {"session": self["session"],
|
||||
"reason": reason,
|
||||
"ban": True}
|
||||
|
||||
cmd = messages.RemoveUser(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def add_listening_channels(self, channel):
|
||||
"""Add user to listening channel"""
|
||||
params = {"session": self["session"],
|
||||
"listening_channel_add": channel}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
|
||||
def remove_listening_channels(self, channel):
|
||||
"""Remove user from listening channel"""
|
||||
params = {"session": self["session"],
|
||||
"listening_channel_remove": channel}
|
||||
|
||||
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
|
||||
self.mumble_object.execute_command(cmd)
|
||||
Reference in New Issue
Block a user