├── .gitignore ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── easy_install.py ├── idarling ├── __init__.py ├── core │ ├── __init__.py │ ├── core.py │ ├── events.py │ └── hooks.py ├── interface │ ├── __init__.py │ ├── actions.py │ ├── dialogs.py │ ├── filter.py │ ├── interface.py │ ├── invites.py │ ├── painter.py │ └── widget.py ├── module.py ├── network │ ├── __init__.py │ ├── client.py │ ├── network.py │ └── server.py ├── plugin.py ├── resources │ ├── clear.png │ ├── cold.png │ ├── connected.png │ ├── connecting.png │ ├── disconnected.png │ ├── download.png │ ├── empty.png │ ├── hot.png │ ├── idarling.png │ ├── invite.png │ ├── location.png │ ├── settings.png │ ├── upload.png │ ├── user.png │ ├── users.png │ └── warm.png ├── server.py └── shared │ ├── __init__.py │ ├── commands.py │ ├── discovery.py │ ├── models.py │ ├── packets.py │ ├── server.py │ ├── sockets.py │ ├── storage.py │ └── utils.py ├── idarling_plugin.py ├── idarling_server.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom directories 2 | .idea/ 3 | files/ 4 | logs/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # IPython Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: 5 | - pip install black flake8 flake8-import-order pep8-naming 6 | script: 7 | - black -l 79 --check **/*.py 8 | - flake8 --ignore=W503 **/*.py --import-order-style=google 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include idarling/resources *.png 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # Warning 7 | 8 | This project is no longer under active development and the more featured and up-to-date [fork](https://github.com/fidgetingbits/IDArling) is probably something more interesting for new comers. Also, IDA has announced an official support for collaborative reverse engineering session and one could also wait for this. 9 | 10 | ## Overview 11 | 12 | IDArling is a collaborative reverse engineering plugin for [IDA Pro](https://www.hex-rays.com/products/ida/) 13 | and [Hex-Rays](https://www.hex-rays.com/products/decompiler/index.shtml). It 14 | allows to synchronize in real-time the changes made to a database by multiple 15 | users, by connecting together different instances of IDA Pro. 16 | 17 | The main features of IDArling are: 18 | * hooking general user events 19 | * structure and enumeration support 20 | * Hex-Rays decompiler syncing 21 | * replay engine and auto-saving 22 | * database loading and saving 23 | * interactive status bar widget 24 | * user cursors (instructions, functions, navbar) 25 | * invite and following an user moves 26 | * dedicated server using Qt5 27 | * integrated server within IDA 28 | * LAN servers discovery 29 | * following an user moves in real time 30 | 31 | If you have any questions not worthy of a bug report, feel free to ping us at 32 | [#idarling on freenode](https://kiwiirc.com/client/irc.freenode.net/idarling) 33 | and ask away. 34 | 35 | ## Releases 36 | 37 | This project is under active development. Feel free to send a PR if you would 38 | like to help! :-) 39 | 40 | **It is not really stable in its current state, please stayed tuned for a first 41 | release of the project!** 42 | 43 | ## Installation 44 | 45 | Install the IDArling client into the IDA plugins folder. 46 | 47 | - Copy `idarling_plugin.py` and the `idarling` folder to the IDA plugins folder. 48 | - On Windows, the folder is at `C:\Program Files\IDA 7.x\plugins` 49 | - On macOS, the folder is at `/Applications/IDA\ Pro\ 7.x/idabin/plugins` 50 | - On Linux, the folder may be at `~/ida-7.x/plugins/` 51 | - Alternatively, you can use the "easy install" method by copying the following 52 | line into the console: 53 | ``` 54 | import urllib2; exec(urllib2.urlopen('https://raw.githubusercontent.com/IDArlingTeam/IDArling/master/easy_install.py')).read() 55 | ``` 56 | 57 | **Warning:** The plugin is only compatible with IDA Pro 7.x on Windows, macOS, 58 | and Linux. 59 | 60 | The dedicated server requires PyQt5, which is integrated into IDA. If you're 61 | using an external Python installation, we recommand using Python 3, which offers 62 | a pre-built package that can be installed with a simple `pip install PyQt5`. 63 | 64 | ## Usage 65 | 66 | Open the *Settings* dialog accessible from the right-clicking the widget located 67 | in the status bar. Show the servers list by clicking on the *Network Settings* 68 | tabs and add your server to it. Connect to the server by clicking on it after 69 | right-clicking the widget again. Finally, you should be able to access the 70 | following menus to upload or download a database: 71 | 72 | ``` 73 | - File --> Open from server 74 | - File --> Save to server 75 | ``` 76 | 77 | # Thanks 78 | 79 | This project is inspired by [Sol[IDA]rity](https://solidarity.re/). It started 80 | after contacting its authors and asking if it was ever going to be released to 81 | the public. [Lighthouse](https://github.com/gaasedelen/lighthouse) source code 82 | was also carefully studied to understand how to write better IDA plugins. 83 | 84 | * Previous plugins, namely [CollabREate](https://github.com/cseagle/collabREate), 85 | [IDASynergy](https://github.com/CubicaLabs/IDASynergy), 86 | [YaCo](https://github.com/DGA-MI-SSI/YaCo), were studied during the development 87 | process; 88 | * The icons are edited and combined versions from the sites [freeiconshop.com](http://freeiconshop.com/) 89 | and [www.iconsplace.com](http://www.iconsplace.com). 90 | 91 | Thanks to Quarkslab for allowing this release. 92 | 93 | # Authors 94 | 95 | * Alexandre Adamski <> 96 | * Joffrey Guilbon <> 97 | -------------------------------------------------------------------------------- /easy_install.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import os 14 | import shutil 15 | import urllib2 16 | import zipfile 17 | 18 | import ida_diskio 19 | import ida_loader 20 | 21 | # Allow the user to override the download URL 22 | if "URL" not in locals(): 23 | URL = "https://github.com/IDArlingTeam/IDArling/archive/master.zip" 24 | 25 | print("[*] Installing IDArling...") 26 | # Install into the user directory on all platforms 27 | user_dir = ida_diskio.get_user_idadir() 28 | plug_dir = os.path.join(user_dir, "plugins") 29 | if not os.path.exists(plug_dir): 30 | os.makedirs(plug_dir, 493) # 0755 31 | 32 | print("[*] Downloading master.zip archive...") 33 | archive_path = os.path.join(plug_dir, "master.zip") 34 | if os.path.exists(archive_path): 35 | os.remove(archive_path) 36 | with open(archive_path, "wb") as f: 37 | f.write(urllib2.urlopen(URL).read()) 38 | 39 | print("[*] Unzipping master.zip archive...") 40 | archive_dir = os.path.join(plug_dir, "IDArling-master") 41 | if os.path.exists(archive_dir): 42 | shutil.rmtree(archive_dir) 43 | with zipfile.ZipFile(archive_path, "r") as zip: 44 | for zip_file in zip.namelist(): 45 | if zip_file.startswith(os.path.basename(archive_dir)): 46 | zip.extract(zip_file, plug_dir) 47 | 48 | print("[*] Moving the IDArling files...") 49 | src_path = os.path.join(archive_dir, "idarling_plugin.py") 50 | dst_path = os.path.join(plug_dir, os.path.basename(src_path)) 51 | if os.path.exists(dst_path): 52 | os.remove(dst_path) 53 | shutil.move(src_path, dst_path) 54 | src_dir = os.path.join(archive_dir, "idarling") 55 | dst_dir = os.path.join(plug_dir, os.path.basename(src_dir)) 56 | if os.path.exists(dst_dir): 57 | shutil.rmtree(dst_dir) 58 | shutil.move(src_dir, dst_dir) 59 | 60 | print("[*] Removing master.zip archive...") 61 | if os.path.exists(archive_path): 62 | os.remove(archive_path) 63 | if os.path.exists(archive_dir): 64 | shutil.rmtree(archive_dir) 65 | 66 | print("[*] Loading IDArling into IDA Pro...") 67 | plugin_path = os.path.join(plug_dir, "idarling_plugin.py") 68 | ida_loader.load_plugin(plugin_path) 69 | 70 | print("[*] IDArling installed successfully!") 71 | -------------------------------------------------------------------------------- /idarling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/__init__.py -------------------------------------------------------------------------------- /idarling/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/core/__init__.py -------------------------------------------------------------------------------- /idarling/core/core.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import ctypes 14 | import os 15 | import sys 16 | 17 | import ida_auto 18 | import ida_diskio 19 | import ida_idp 20 | import ida_kernwin 21 | import ida_netnode 22 | 23 | from PyQt5.QtCore import QCoreApplication, QFileInfo # noqa: I202 24 | 25 | from .hooks import HexRaysHooks, IDBHooks, IDPHooks 26 | from ..module import Module 27 | from ..shared.commands import ( 28 | JoinSession, 29 | LeaveSession, 30 | ListDatabases, 31 | UpdateLocation, 32 | ) 33 | 34 | if sys.version_info > (3,): 35 | long = int 36 | 37 | 38 | class Core(Module): 39 | """ 40 | This is the core module. It is responsible for interacting with the IDA 41 | kernel. It will handle hooking, sending, and replaying of user events. 42 | """ 43 | 44 | NETNODE_NAME = "$ idarling" 45 | 46 | @staticmethod 47 | def get_ida_dll(app_name=None): 48 | if app_name is None: 49 | app_path = QCoreApplication.applicationFilePath() 50 | app_name = QFileInfo(app_path).fileName() 51 | idaname = "ida64" if "64" in app_name else "ida" 52 | if sys.platform == "win32": 53 | dllname, dlltype = idaname + ".dll", ctypes.windll 54 | elif sys.platform in ["linux", "linux2"]: 55 | dllname, dlltype = "lib" + idaname + ".so", ctypes.cdll 56 | elif sys.platform == "darwin": 57 | dllname, dlltype = "lib" + idaname + ".dylib", ctypes.cdll 58 | dllpath = ida_diskio.idadir(None) 59 | if not os.path.exists(os.path.join(dllpath, dllname)): 60 | dllpath = dllpath.replace("ida64", "ida") 61 | return dlltype[os.path.join(dllpath, dllname)] 62 | 63 | def __init__(self, plugin): 64 | super(Core, self).__init__(plugin) 65 | self._project = None 66 | self._database = None 67 | self._tick = 0 68 | self._users = {} 69 | 70 | self._idb_hooks = None 71 | self._idp_hooks = None 72 | self._hxe_hooks = None 73 | 74 | self._idb_hooks_core = None 75 | self._idp_hooks_core = None 76 | self._ui_hooks_core = None 77 | self._view_hooks_core = None 78 | self._hooked = False 79 | 80 | @property 81 | def project(self): 82 | return self._project 83 | 84 | @project.setter 85 | def project(self, project): 86 | self._project = project 87 | self.save_netnode() 88 | 89 | @property 90 | def database(self): 91 | return self._database 92 | 93 | @database.setter 94 | def database(self, database): 95 | self._database = database 96 | self.save_netnode() 97 | 98 | @property 99 | def tick(self): 100 | return self._tick 101 | 102 | @tick.setter 103 | def tick(self, tick): 104 | self._tick = tick 105 | self.save_netnode() 106 | 107 | def add_user(self, name, user): 108 | self._users[name] = user 109 | self._plugin.interface.painter.refresh() 110 | self._plugin.interface.widget.refresh() 111 | 112 | def remove_user(self, name): 113 | user = self._users.pop(name) 114 | self._plugin.interface.painter.refresh() 115 | self._plugin.interface.widget.refresh() 116 | return user 117 | 118 | def get_user(self, name): 119 | return self._users[name] 120 | 121 | def get_users(self): 122 | return self._users 123 | 124 | def _install(self): 125 | # Instantiate the hooks 126 | self._idb_hooks = IDBHooks(self._plugin) 127 | self._idp_hooks = IDPHooks(self._plugin) 128 | self._hxe_hooks = HexRaysHooks(self._plugin) 129 | 130 | core = self 131 | self._plugin.logger.debug("Installing core hooks") 132 | 133 | class IDBHooksCore(ida_idp.IDB_Hooks): 134 | def closebase(self): 135 | core._plugin.logger.trace("Closebase hook") 136 | core.leave_session() 137 | core.save_netnode() 138 | 139 | core.project = None 140 | core.database = None 141 | core.ticks = 0 142 | return 0 143 | 144 | self._idb_hooks_core = IDBHooksCore() 145 | self._idb_hooks_core.hook() 146 | 147 | class IDPHooksCore(ida_idp.IDP_Hooks): 148 | def ev_get_bg_color(self, color, ea): 149 | core._plugin.logger.trace("Get bg color hook") 150 | value = core._plugin.interface.painter.get_bg_color(ea) 151 | if value is not None: 152 | ctypes.c_uint.from_address(long(color)).value = value 153 | return 1 154 | return 0 155 | 156 | def auto_queue_empty(self, _): 157 | core._plugin.logger.debug("Auto queue empty hook") 158 | if ida_auto.get_auto_state() == ida_auto.AU_NONE: 159 | client = core._plugin.network.client 160 | if client: 161 | client.call_events() 162 | 163 | self._idp_hooks_core = IDPHooksCore() 164 | self._idp_hooks_core.hook() 165 | 166 | class UIHooksCore(ida_kernwin.UI_Hooks): 167 | def ready_to_run(self): 168 | core._plugin.logger.trace("Ready to run hook") 169 | core.load_netnode() 170 | core.join_session() 171 | core._plugin.interface.painter.ready_to_run() 172 | 173 | def get_ea_hint(self, ea): 174 | core._plugin.logger.trace("Get ea hint hook") 175 | return core._plugin.interface.painter.get_ea_hint(ea) 176 | 177 | def widget_visible(self, widget): 178 | core._plugin.logger.trace("Widget visible") 179 | core._plugin.interface.painter.widget_visible(widget) 180 | 181 | self._ui_hooks_core = UIHooksCore() 182 | self._ui_hooks_core.hook() 183 | 184 | class ViewHooksCore(ida_kernwin.View_Hooks): 185 | def view_loc_changed(self, view, now, was): 186 | core._plugin.logger.trace("View loc changed hook") 187 | if now.plce.toea() != was.plce.toea(): 188 | name = core._plugin.config["user"]["name"] 189 | color = core._plugin.config["user"]["color"] 190 | core._plugin.network.send_packet( 191 | UpdateLocation(name, now.plce.toea(), color) 192 | ) 193 | 194 | self._view_hooks_core = ViewHooksCore() 195 | self._view_hooks_core.hook() 196 | return True 197 | 198 | def _uninstall(self): 199 | self._plugin.logger.debug("Uninstalling core hooks") 200 | self._idb_hooks_core.unhook() 201 | self._ui_hooks_core.unhook() 202 | self._view_hooks_core.unhook() 203 | self.unhook_all() 204 | return True 205 | 206 | def hook_all(self): 207 | """Install all the user events hooks.""" 208 | if self._hooked: 209 | return 210 | 211 | self._plugin.logger.debug("Installing hooks") 212 | self._idb_hooks.hook() 213 | self._idp_hooks.hook() 214 | self._hxe_hooks.hook() 215 | self._hooked = True 216 | 217 | def unhook_all(self): 218 | """Uninstall all the user events hooks.""" 219 | if not self._hooked: 220 | return 221 | 222 | self._plugin.logger.debug("Uninstalling hooks") 223 | self._idb_hooks.unhook() 224 | self._idp_hooks.unhook() 225 | self._hxe_hooks.unhook() 226 | self._hooked = False 227 | 228 | def load_netnode(self): 229 | """ 230 | Load data from our custom netnode. Netnodes are the mechanism used by 231 | IDA to load and save information into a database. IDArling uses its own 232 | netnode to remember which project and database a database belongs to. 233 | """ 234 | node = ida_netnode.netnode(Core.NETNODE_NAME, 0, True) 235 | 236 | self._project = node.hashstr("project") or None 237 | self._database = node.hashstr("database") or None 238 | self._tick = int(node.hashstr("tick") or "0") 239 | 240 | self._plugin.logger.debug( 241 | "Loaded netnode: project=%s, database=%s, tick=%d" 242 | % (self._project, self._database, self._tick) 243 | ) 244 | 245 | def save_netnode(self): 246 | """Save data into our custom netnode.""" 247 | node = ida_netnode.netnode(Core.NETNODE_NAME, 0, True) 248 | 249 | # node.hashset does not work anymore with direct string 250 | # use of hashet_buf instead 251 | # (see https://github.com/idapython/src/blob/master/swig/netnode.i#L162) 252 | if self._project: 253 | node.hashset_buf("project", str(self._project)) 254 | if self._database: 255 | node.hashset_buf("database", str(self._database)) 256 | if self._tick: 257 | node.hashset_buf("tick", str(self._tick)) 258 | 259 | self._plugin.logger.debug( 260 | "Saved netnode: project=%s, database=%s, tick=%d" 261 | % (self._project, self._database, self._tick) 262 | ) 263 | 264 | def join_session(self): 265 | """Join the collaborative session.""" 266 | self._plugin.logger.debug("Joining session") 267 | if self._project and self._database: 268 | 269 | def databases_listed(reply): 270 | if any(d.name == self._database for d in reply.databases): 271 | self._plugin.logger.debug("Database is on the server") 272 | else: 273 | self._plugin.logger.debug("Database is not on the server") 274 | return # Do not go further 275 | 276 | name = self._plugin.config["user"]["name"] 277 | color = self._plugin.config["user"]["color"] 278 | ea = ida_kernwin.get_screen_ea() 279 | self._plugin.network.send_packet( 280 | JoinSession( 281 | self._project, 282 | self._database, 283 | self._tick, 284 | name, 285 | color, 286 | ea, 287 | ) 288 | ) 289 | self.hook_all() 290 | self._users.clear() 291 | 292 | d = self._plugin.network.send_packet( 293 | ListDatabases.Query(self._project) 294 | ) 295 | if d: 296 | d.add_callback(databases_listed) 297 | d.add_errback(self._plugin.logger.exception) 298 | 299 | def leave_session(self): 300 | """Leave the collaborative session.""" 301 | self._plugin.logger.debug("Leaving session") 302 | if self._project and self._database: 303 | name = self._plugin.config["user"]["name"] 304 | self._plugin.network.send_packet(LeaveSession(name)) 305 | self._users.clear() 306 | self.unhook_all() 307 | -------------------------------------------------------------------------------- /idarling/core/hooks.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | # import ctypes 14 | 15 | import ida_auto 16 | import ida_bytes 17 | import ida_enum 18 | import ida_funcs 19 | import ida_hexrays 20 | import ida_idaapi 21 | import ida_idp 22 | import ida_kernwin 23 | import ida_nalt 24 | import ida_pro 25 | import ida_segment 26 | import ida_struct 27 | import ida_typeinf 28 | 29 | from . import events as evt # noqa: I100, I202 30 | from .events import Event # noqa: I201 31 | 32 | 33 | class Hooks(object): 34 | """ 35 | This is a common class for all client hooks. It adds an utility method to 36 | send an user event to all other clients through the server. 37 | """ 38 | 39 | def __init__(self, plugin): 40 | self._plugin = plugin 41 | 42 | def _send_packet(self, event): 43 | """Sends a packet to the server.""" 44 | # Check if it comes from the auto-analyzer 45 | if ida_auto.get_auto_state() == ida_auto.AU_NONE: 46 | self._plugin.network.send_packet(event) 47 | else: 48 | self._plugin.logger.debug("Ignoring a packet") 49 | 50 | 51 | class IDBHooks(Hooks, ida_idp.IDB_Hooks): 52 | def __init__(self, plugin): 53 | ida_idp.IDB_Hooks.__init__(self) 54 | Hooks.__init__(self, plugin) 55 | self.last_local_type = None 56 | 57 | def make_code(self, insn): 58 | self._send_packet(evt.MakeCodeEvent(insn.ea)) 59 | return 0 60 | 61 | def make_data(self, ea, flags, tid, size): 62 | self._send_packet(evt.MakeDataEvent(ea, flags, size, tid)) 63 | return 0 64 | 65 | def renamed(self, ea, new_name, local_name): 66 | self._send_packet(evt.RenamedEvent(ea, new_name, local_name)) 67 | return 0 68 | 69 | def func_added(self, func): 70 | self._send_packet(evt.FuncAddedEvent(func.start_ea, func.end_ea)) 71 | return 0 72 | 73 | def deleting_func(self, func): 74 | self._send_packet(evt.DeletingFuncEvent(func.start_ea)) 75 | return 0 76 | 77 | def set_func_start(self, func, new_start): 78 | self._send_packet(evt.SetFuncStartEvent(func.start_ea, new_start)) 79 | return 0 80 | 81 | def set_func_end(self, func, new_end): 82 | self._send_packet(evt.SetFuncEndEvent(func.start_ea, new_end)) 83 | return 0 84 | 85 | def func_tail_appended(self, func, tail): 86 | self._send_packet( 87 | evt.FuncTailAppendedEvent( 88 | func.start_ea, tail.start_ea, tail.end_ea 89 | ) 90 | ) 91 | return 0 92 | 93 | def func_tail_deleted(self, func, tail_ea): 94 | self._send_packet(evt.FuncTailDeletedEvent(func.start_ea, tail_ea)) 95 | return 0 96 | 97 | def tail_owner_changed(self, tail, owner_func, old_owner): 98 | self._send_packet(evt.TailOwnerChangedEvent(tail.start_ea, owner_func)) 99 | return 0 100 | 101 | def cmt_changed(self, ea, repeatable_cmt): 102 | cmt = ida_bytes.get_cmt(ea, repeatable_cmt) 103 | cmt = "" if not cmt else cmt 104 | self._send_packet(evt.CmtChangedEvent(ea, cmt, repeatable_cmt)) 105 | return 0 106 | 107 | def range_cmt_changed(self, kind, a, cmt, repeatable): 108 | self._send_packet(evt.RangeCmtChangedEvent(kind, a, cmt, repeatable)) 109 | return 0 110 | 111 | def extra_cmt_changed(self, ea, line_idx, cmt): 112 | self._send_packet(evt.ExtraCmtChangedEvent(ea, line_idx, cmt)) 113 | return 0 114 | 115 | def ti_changed(self, ea, type, fname): 116 | type = ida_typeinf.idc_get_type_raw(ea) 117 | self._send_packet(evt.TiChangedEvent(ea, type)) 118 | return 0 119 | 120 | # def local_types_changed(self): 121 | # from .core import Core 122 | 123 | # dll = Core.get_ida_dll() 124 | 125 | # get_idati = dll.get_idati 126 | # get_idati.argtypes = [] 127 | # get_idati.restype = ctypes.c_void_p 128 | 129 | # get_numbered_type = dll.get_numbered_type 130 | # get_numbered_type.argtypes = [ 131 | # ctypes.c_void_p, 132 | # ctypes.c_uint32, 133 | # ctypes.POINTER(ctypes.c_char_p), 134 | # ctypes.POINTER(ctypes.c_char_p), 135 | # ctypes.POINTER(ctypes.c_char_p), 136 | # ctypes.POINTER(ctypes.c_char_p), 137 | # ctypes.POINTER(ctypes.c_int), 138 | # ] 139 | # get_numbered_type.restype = ctypes.c_bool 140 | 141 | # local_types = [] 142 | # py_ti = ida_typeinf.get_idati() 143 | # for py_ord in range(1, ida_typeinf.get_ordinal_qty(py_ti)): 144 | # name = ida_typeinf.get_numbered_type_name(py_ti, py_ord) 145 | 146 | # ti = get_idati() 147 | # ordinal = ctypes.c_uint32(py_ord) 148 | # type = ctypes.c_char_p() 149 | # fields = ctypes.c_char_p() 150 | # cmt = ctypes.c_char_p() 151 | # fieldcmts = ctypes.c_char_p() 152 | # sclass = ctypes.c_int() 153 | # get_numbered_type( 154 | # ti, 155 | # ordinal, 156 | # ctypes.pointer(type), 157 | # ctypes.pointer(fields), 158 | # ctypes.pointer(cmt), 159 | # ctypes.pointer(fieldcmts), 160 | # ctypes.pointer(sclass), 161 | # ) 162 | # local_types.append( 163 | # ( 164 | # py_ord, 165 | # name, 166 | # type.value, 167 | # fields.value, 168 | # cmt.value, 169 | # fieldcmts.value, 170 | # sclass.value, 171 | # ) 172 | # ) 173 | # self._send_packet(evt.LocalTypesChangedEvent(local_types)) 174 | # return 0 175 | 176 | def op_type_changed(self, ea, n): 177 | def gather_enum_info(ea, n): 178 | id = ida_bytes.get_enum_id(ea, n)[0] 179 | serial = ida_enum.get_enum_idx(id) 180 | return id, serial 181 | 182 | extra = {} 183 | mask = ida_bytes.MS_0TYPE if not n else ida_bytes.MS_1TYPE 184 | flags = ida_bytes.get_full_flags(ea) & mask 185 | 186 | def is_flag(type): 187 | return flags == mask & type 188 | 189 | if is_flag(ida_bytes.hex_flag()): 190 | op = "hex" 191 | elif is_flag(ida_bytes.dec_flag()): 192 | op = "dec" 193 | elif is_flag(ida_bytes.char_flag()): 194 | op = "chr" 195 | elif is_flag(ida_bytes.bin_flag()): 196 | op = "bin" 197 | elif is_flag(ida_bytes.oct_flag()): 198 | op = "oct" 199 | elif is_flag(ida_bytes.offflag()): 200 | op = "offset" 201 | elif is_flag(ida_bytes.enum_flag()): 202 | op = "enum" 203 | id, serial = gather_enum_info(ea, n) 204 | ename = ida_enum.get_enum_name(id) 205 | extra["ename"] = Event.decode(ename) 206 | extra["serial"] = serial 207 | elif is_flag(flags & ida_bytes.stroff_flag()): 208 | op = "struct" 209 | path = ida_pro.tid_array(1) 210 | delta = ida_pro.sval_pointer() 211 | path_len = ida_bytes.get_stroff_path( 212 | path.cast(), delta.cast(), ea, n 213 | ) 214 | spath = [] 215 | for i in range(path_len): 216 | sname = ida_struct.get_struc_name(path[i]) 217 | spath.append(Event.decode(sname)) 218 | extra["delta"] = delta.value() 219 | extra["spath"] = spath 220 | elif is_flag(ida_bytes.stkvar_flag()): 221 | op = "stkvar" 222 | # FIXME: No hooks are called when inverting sign 223 | # elif ida_bytes.is_invsign(ea, flags, n): 224 | # op = 'invert_sign' 225 | else: 226 | return 0 # FIXME: Find a better way to do this 227 | self._send_packet(evt.OpTypeChangedEvent(ea, n, op, extra)) 228 | return 0 229 | 230 | def enum_created(self, enum): 231 | name = ida_enum.get_enum_name(enum) 232 | self._send_packet(evt.EnumCreatedEvent(enum, name)) 233 | return 0 234 | 235 | def deleting_enum(self, id): 236 | self._send_packet(evt.EnumDeletedEvent(ida_enum.get_enum_name(id))) 237 | return 0 238 | 239 | def renaming_enum(self, id, is_enum, newname): 240 | if is_enum: 241 | oldname = ida_enum.get_enum_name(id) 242 | else: 243 | oldname = ida_enum.get_enum_member_name(id) 244 | self._send_packet(evt.EnumRenamedEvent(oldname, newname, is_enum)) 245 | return 0 246 | 247 | def enum_bf_changed(self, id): 248 | bf_flag = 1 if ida_enum.is_bf(id) else 0 249 | ename = ida_enum.get_enum_name(id) 250 | self._send_packet(evt.EnumBfChangedEvent(ename, bf_flag)) 251 | return 0 252 | 253 | def enum_cmt_changed(self, tid, repeatable_cmt): 254 | cmt = ida_enum.get_enum_cmt(tid, repeatable_cmt) 255 | emname = ida_enum.get_enum_name(tid) 256 | self._send_packet(evt.EnumCmtChangedEvent(emname, cmt, repeatable_cmt)) 257 | return 0 258 | 259 | def enum_member_created(self, id, cid): 260 | ename = ida_enum.get_enum_name(id) 261 | name = ida_enum.get_enum_member_name(cid) 262 | value = ida_enum.get_enum_member_value(cid) 263 | bmask = ida_enum.get_enum_member_bmask(cid) 264 | self._send_packet( 265 | evt.EnumMemberCreatedEvent(ename, name, value, bmask) 266 | ) 267 | return 0 268 | 269 | def deleting_enum_member(self, id, cid): 270 | ename = ida_enum.get_enum_name(id) 271 | value = ida_enum.get_enum_member_value(cid) 272 | serial = ida_enum.get_enum_member_serial(cid) 273 | bmask = ida_enum.get_enum_member_bmask(cid) 274 | self._send_packet( 275 | evt.EnumMemberDeletedEvent(ename, value, serial, bmask) 276 | ) 277 | return 0 278 | 279 | def struc_created(self, tid): 280 | name = ida_struct.get_struc_name(tid) 281 | is_union = ida_struct.is_union(tid) 282 | self._send_packet(evt.StrucCreatedEvent(tid, name, is_union)) 283 | return 0 284 | 285 | def deleting_struc(self, sptr): 286 | sname = ida_struct.get_struc_name(sptr.id) 287 | self._send_packet(evt.StrucDeletedEvent(sname)) 288 | return 0 289 | 290 | def renaming_struc(self, id, oldname, newname): 291 | self._send_packet(evt.StrucRenamedEvent(oldname, newname)) 292 | return 0 293 | 294 | def struc_member_created(self, sptr, mptr): 295 | extra = {} 296 | sname = ida_struct.get_struc_name(sptr.id) 297 | fieldname = ida_struct.get_member_name(mptr.id) 298 | offset = 0 if mptr.unimem() else mptr.soff 299 | flag = mptr.flag 300 | nbytes = mptr.eoff if mptr.unimem() else mptr.eoff - mptr.soff 301 | mt = ida_nalt.opinfo_t() 302 | is_not_data = ida_struct.retrieve_member_info(mt, mptr) 303 | if is_not_data: 304 | if flag & ida_bytes.off_flag(): 305 | extra["target"] = mt.ri.target 306 | extra["base"] = mt.ri.base 307 | extra["tdelta"] = mt.ri.tdelta 308 | extra["flags"] = mt.ri.flags 309 | self._send_packet( 310 | evt.StrucMemberCreatedEvent( 311 | sname, fieldname, offset, flag, nbytes, extra 312 | ) 313 | ) 314 | # Is it really possible to create an enum? 315 | elif flag & ida_bytes.enum_flag(): 316 | extra["serial"] = mt.ec.serial 317 | self._send_packet( 318 | evt.StrucMemberCreatedEvent( 319 | sname, fieldname, offset, flag, nbytes, extra 320 | ) 321 | ) 322 | elif flag & ida_bytes.stru_flag(): 323 | extra["id"] = mt.tid 324 | if flag & ida_bytes.strlit_flag(): 325 | extra["strtype"] = mt.strtype 326 | self._send_packet( 327 | evt.StrucMemberCreatedEvent( 328 | sname, fieldname, offset, flag, nbytes, extra 329 | ) 330 | ) 331 | else: 332 | self._send_packet( 333 | evt.StrucMemberCreatedEvent( 334 | sname, fieldname, offset, flag, nbytes, extra 335 | ) 336 | ) 337 | return 0 338 | 339 | def struc_member_deleted(self, sptr, off1, off2): 340 | sname = ida_struct.get_struc_name(sptr.id) 341 | self._send_packet(evt.StrucMemberDeletedEvent(sname, off2)) 342 | return 0 343 | 344 | def renaming_struc_member(self, sptr, mptr, newname): 345 | sname = ida_struct.get_struc_name(sptr.id) 346 | offset = mptr.soff 347 | self._send_packet(evt.StrucMemberRenamedEvent(sname, offset, newname)) 348 | return 0 349 | 350 | def struc_cmt_changed(self, id, repeatable_cmt): 351 | fullname = ida_struct.get_struc_name(id) 352 | if "." in fullname: 353 | sname, smname = fullname.split(".", 1) 354 | else: 355 | sname = fullname 356 | smname = "" 357 | cmt = ida_struct.get_struc_cmt(id, repeatable_cmt) 358 | self._send_packet( 359 | evt.StrucCmtChangedEvent(sname, smname, cmt, repeatable_cmt) 360 | ) 361 | return 0 362 | 363 | def struc_member_changed(self, sptr, mptr): 364 | extra = {} 365 | 366 | sname = ida_struct.get_struc_name(sptr.id) 367 | soff = 0 if mptr.unimem() else mptr.soff 368 | flag = mptr.flag 369 | mt = ida_nalt.opinfo_t() 370 | is_not_data = ida_struct.retrieve_member_info(mt, mptr) 371 | if is_not_data: 372 | if flag & ida_bytes.off_flag(): 373 | extra["target"] = mt.ri.target 374 | extra["base"] = mt.ri.base 375 | extra["tdelta"] = mt.ri.tdelta 376 | extra["flags"] = mt.ri.flags 377 | self._send_packet( 378 | evt.StrucMemberChangedEvent( 379 | sname, soff, mptr.eoff, flag, extra 380 | ) 381 | ) 382 | elif flag & ida_bytes.enum_flag(): 383 | extra["serial"] = mt.ec.serial 384 | self._send_packet( 385 | evt.StrucMemberChangedEvent( 386 | sname, soff, mptr.eoff, flag, extra 387 | ) 388 | ) 389 | elif flag & ida_bytes.stru_flag(): 390 | extra["id"] = mt.tid 391 | if flag & ida_bytes.strlit_flag(): 392 | extra["strtype"] = mt.strtype 393 | self._send_packet( 394 | evt.StrucMemberChangedEvent( 395 | sname, soff, mptr.eoff, flag, extra 396 | ) 397 | ) 398 | else: 399 | self._send_packet( 400 | evt.StrucMemberChangedEvent( 401 | sname, soff, mptr.eoff, flag, extra 402 | ) 403 | ) 404 | return 0 405 | 406 | def expanding_struc(self, sptr, offset, delta): 407 | sname = ida_struct.get_struc_name(sptr.id) 408 | self._send_packet(evt.ExpandingStrucEvent(sname, offset, delta)) 409 | return 0 410 | 411 | def segm_added(self, s): 412 | self._send_packet( 413 | evt.SegmAddedEvent( 414 | ida_segment.get_segm_name(s), 415 | ida_segment.get_segm_class(s), 416 | s.start_ea, 417 | s.end_ea, 418 | s.orgbase, 419 | s.align, 420 | s.comb, 421 | s.perm, 422 | s.bitness, 423 | s.flags, 424 | ) 425 | ) 426 | return 0 427 | 428 | # This hook lack of disable addresses option 429 | def segm_deleted(self, start_ea, end_ea): 430 | self._send_packet(evt.SegmDeletedEvent(start_ea)) 431 | return 0 432 | 433 | def segm_start_changed(self, s, oldstart): 434 | self._send_packet(evt.SegmStartChangedEvent(s.start_ea, oldstart)) 435 | return 0 436 | 437 | def segm_end_changed(self, s, oldend): 438 | self._send_packet(evt.SegmEndChangedEvent(s.end_ea, s.start_ea)) 439 | return 0 440 | 441 | def segm_name_changed(self, s, name): 442 | self._send_packet(evt.SegmNameChangedEvent(s.start_ea, name)) 443 | return 0 444 | 445 | def segm_class_changed(self, s, sclass): 446 | self._send_packet(evt.SegmClassChangedEvent(s.start_ea, sclass)) 447 | return 0 448 | 449 | def segm_attrs_updated(self, s): 450 | self._send_packet( 451 | evt.SegmAttrsUpdatedEvent(s.start_ea, s.perm, s.bitness) 452 | ) 453 | return 0 454 | 455 | def segm_moved(self, from_ea, to_ea, size, changed_netmap): 456 | self._send_packet(evt.SegmMoved(from_ea, to_ea, changed_netmap)) 457 | return 0 458 | 459 | def byte_patched(self, ea, old_value): 460 | self._send_packet( 461 | evt.BytePatchedEvent(ea, ida_bytes.get_wide_byte(ea)) 462 | ) 463 | return 0 464 | 465 | def sgr_changed(self, start_ea, end_ea, regnum, value, old_value, tag): 466 | # FIXME: sgr_changed is not triggered when a segment register is 467 | # being deleted by the user, so we need to sent the complete list 468 | sreg_ranges = evt.SgrChanged.get_sreg_ranges(regnum) 469 | self._send_packet(evt.SgrChanged(regnum, sreg_ranges)) 470 | return 0 471 | 472 | 473 | class IDPHooks(Hooks, ida_idp.IDP_Hooks): 474 | def __init__(self, plugin): 475 | ida_idp.IDP_Hooks.__init__(self) 476 | Hooks.__init__(self, plugin) 477 | 478 | def ev_undefine(self, ea): 479 | self._send_packet(evt.UndefinedEvent(ea)) 480 | return ida_idp.IDP_Hooks.ev_undefine(self, ea) 481 | 482 | def ev_adjust_argloc(self, *args): 483 | return ida_idp.IDP_Hooks.ev_adjust_argloc(self, *args) 484 | 485 | # def ev_gen_regvar_def(self, outctx, v): 486 | # self._send_packet( 487 | # evt.GenRegvarDefEvent(outctx.bin_ea, v.canon, v.user, v.cmt) 488 | # ) 489 | # return ida_idp.IDP_Hooks.ev_gen_regvar_def(self, outctx, v) 490 | 491 | 492 | class HexRaysHooks(Hooks): 493 | def __init__(self, plugin): 494 | super(HexRaysHooks, self).__init__(plugin) 495 | self._available = None 496 | self._installed = False 497 | self._func_ea = ida_idaapi.BADADDR 498 | self._labels = {} 499 | self._cmts = {} 500 | self._iflags = {} 501 | self._lvar_settings = {} 502 | self._numforms = {} 503 | 504 | def hook(self): 505 | if self._available is None: 506 | if not ida_hexrays.init_hexrays_plugin(): 507 | self._plugin.logger.info("Hex-Rays SDK is not available") 508 | self._available = False 509 | else: 510 | ida_hexrays.install_hexrays_callback(self._hxe_callback) 511 | self._available = True 512 | 513 | if self._available: 514 | self._installed = True 515 | 516 | def unhook(self): 517 | if self._available: 518 | self._installed = False 519 | 520 | def _hxe_callback(self, event, *_): 521 | if not self._installed: 522 | return 0 523 | 524 | if event == ida_hexrays.hxe_func_printed: 525 | ea = ida_kernwin.get_screen_ea() 526 | func = ida_funcs.get_func(ea) 527 | if func is None: 528 | return 529 | 530 | if self._func_ea != func.start_ea: 531 | self._func_ea = func.start_ea 532 | self._labels = HexRaysHooks._get_user_labels(self._func_ea) 533 | self._cmts = HexRaysHooks._get_user_cmts(self._func_ea) 534 | self._iflags = HexRaysHooks._get_user_iflags(self._func_ea) 535 | self._lvar_settings = HexRaysHooks._get_user_lvar_settings( 536 | self._func_ea 537 | ) 538 | self._numforms = HexRaysHooks._get_user_numforms(self._func_ea) 539 | self._send_user_labels(func.start_ea) 540 | self._send_user_cmts(func.start_ea) 541 | self._send_user_iflags(func.start_ea) 542 | self._send_user_lvar_settings(func.start_ea) 543 | self._send_user_numforms(func.start_ea) 544 | return 0 545 | 546 | @staticmethod 547 | def _get_user_labels(ea): 548 | user_labels = ida_hexrays.restore_user_labels(ea) 549 | if user_labels is None: 550 | user_labels = ida_hexrays.user_labels_new() 551 | labels = [] 552 | it = ida_hexrays.user_labels_begin(user_labels) 553 | while it != ida_hexrays.user_labels_end(user_labels): 554 | org_label = ida_hexrays.user_labels_first(it) 555 | name = ida_hexrays.user_labels_second(it) 556 | labels.append((org_label, Event.decode(name))) 557 | it = ida_hexrays.user_labels_next(it) 558 | ida_hexrays.user_labels_free(user_labels) 559 | return labels 560 | 561 | def _send_user_labels(self, ea): 562 | labels = HexRaysHooks._get_user_labels(ea) 563 | if labels != self._labels: 564 | self._send_packet(evt.UserLabelsEvent(ea, labels)) 565 | self._labels = labels 566 | 567 | @staticmethod 568 | def _get_user_cmts(ea): 569 | user_cmts = ida_hexrays.restore_user_cmts(ea) 570 | if user_cmts is None: 571 | user_cmts = ida_hexrays.user_cmts_new() 572 | cmts = [] 573 | it = ida_hexrays.user_cmts_begin(user_cmts) 574 | while it != ida_hexrays.user_cmts_end(user_cmts): 575 | tl = ida_hexrays.user_cmts_first(it) 576 | cmt = ida_hexrays.user_cmts_second(it) 577 | cmts.append(((tl.ea, tl.itp), Event.decode(str(cmt)))) 578 | it = ida_hexrays.user_cmts_next(it) 579 | ida_hexrays.user_cmts_free(user_cmts) 580 | return cmts 581 | 582 | def _send_user_cmts(self, ea): 583 | cmts = HexRaysHooks._get_user_cmts(ea) 584 | if cmts != self._cmts: 585 | self._send_packet(evt.UserCmtsEvent(ea, cmts)) 586 | self._cmts = cmts 587 | 588 | @staticmethod 589 | def _get_user_iflags(ea): 590 | user_iflags = ida_hexrays.restore_user_iflags(ea) 591 | if user_iflags is None: 592 | user_iflags = ida_hexrays.user_iflags_new() 593 | iflags = [] 594 | it = ida_hexrays.user_iflags_begin(user_iflags) 595 | while it != ida_hexrays.user_iflags_end(user_iflags): 596 | cl = ida_hexrays.user_iflags_first(it) 597 | f = ida_hexrays.user_iflags_second(it) 598 | 599 | # FIXME: Temporary while Hex-Rays update their API 600 | def read_type_sign(obj): 601 | import ctypes 602 | import struct 603 | 604 | buf = ctypes.string_at(id(obj), 4) 605 | return struct.unpack("I", buf)[0] 606 | 607 | f = read_type_sign(f) 608 | iflags.append(((cl.ea, cl.op), f)) 609 | it = ida_hexrays.user_iflags_next(it) 610 | ida_hexrays.user_iflags_free(user_iflags) 611 | return iflags 612 | 613 | def _send_user_iflags(self, ea): 614 | iflags = HexRaysHooks._get_user_iflags(ea) 615 | if iflags != self._iflags: 616 | self._send_packet(evt.UserIflagsEvent(ea, iflags)) 617 | self._iflags = iflags 618 | 619 | @staticmethod 620 | def _get_user_lvar_settings(ea): 621 | dct = {} 622 | lvinf = ida_hexrays.lvar_uservec_t() 623 | if ida_hexrays.restore_user_lvar_settings(lvinf, ea): 624 | dct["lvvec"] = [] 625 | for lv in lvinf.lvvec: 626 | dct["lvvec"].append(HexRaysHooks._get_lvar_saved_info(lv)) 627 | if hasattr(lvinf, "sizes"): 628 | dct["sizes"] = list(lvinf.sizes) 629 | dct["lmaps"] = [] 630 | it = ida_hexrays.lvar_mapping_begin(lvinf.lmaps) 631 | while it != ida_hexrays.lvar_mapping_end(lvinf.lmaps): 632 | key = ida_hexrays.lvar_mapping_first(it) 633 | key = HexRaysHooks._get_lvar_locator(key) 634 | val = ida_hexrays.lvar_mapping_second(it) 635 | val = HexRaysHooks._get_lvar_locator(val) 636 | dct["lmaps"].append((key, val)) 637 | it = ida_hexrays.lvar_mapping_next(it) 638 | dct["stkoff_delta"] = lvinf.stkoff_delta 639 | dct["ulv_flags"] = lvinf.ulv_flags 640 | return dct 641 | 642 | @staticmethod 643 | def _get_lvar_saved_info(lv): 644 | return { 645 | "ll": HexRaysHooks._get_lvar_locator(lv.ll), 646 | "name": Event.decode(lv.name), 647 | "type": HexRaysHooks._get_tinfo(lv.type), 648 | "cmt": Event.decode(lv.cmt), 649 | "flags": lv.flags, 650 | } 651 | 652 | @staticmethod 653 | def _get_tinfo(type): 654 | if type.empty(): 655 | return None, None, None 656 | 657 | type, fields, fldcmts = type.serialize() 658 | type = Event.decode_bytes(type) 659 | fields = Event.decode_bytes(fields) 660 | fldcmts = Event.decode_bytes(fldcmts) 661 | return type, fields, fldcmts 662 | 663 | @staticmethod 664 | def _get_lvar_locator(ll): 665 | return { 666 | "location": HexRaysHooks._get_vdloc(ll.location), 667 | "defea": ll.defea, 668 | } 669 | 670 | @staticmethod 671 | def _get_vdloc(location): 672 | return { 673 | "atype": location.atype(), 674 | "reg1": location.reg1(), 675 | "reg2": location.reg2(), 676 | "stkoff": location.stkoff(), 677 | "ea": location.get_ea(), 678 | } 679 | 680 | def _send_user_lvar_settings(self, ea): 681 | lvar_settings = HexRaysHooks._get_user_lvar_settings(ea) 682 | if lvar_settings != self._lvar_settings: 683 | self._send_packet(evt.UserLvarSettingsEvent(ea, lvar_settings)) 684 | self._lvar_settings = lvar_settings 685 | 686 | @staticmethod 687 | def _get_user_numforms(ea): 688 | user_numforms = ida_hexrays.restore_user_numforms(ea) 689 | if user_numforms is None: 690 | user_numforms = ida_hexrays.user_numforms_new() 691 | numforms = [] 692 | it = ida_hexrays.user_numforms_begin(user_numforms) 693 | while it != ida_hexrays.user_numforms_end(user_numforms): 694 | ol = ida_hexrays.user_numforms_first(it) 695 | nf = ida_hexrays.user_numforms_second(it) 696 | numforms.append( 697 | ( 698 | HexRaysHooks._get_operand_locator(ol), 699 | HexRaysHooks._get_number_format(nf), 700 | ) 701 | ) 702 | it = ida_hexrays.user_numforms_next(it) 703 | ida_hexrays.user_numforms_free(user_numforms) 704 | return numforms 705 | 706 | @staticmethod 707 | def _get_operand_locator(ol): 708 | return {"ea": ol.ea, "opnum": ol.opnum} 709 | 710 | @staticmethod 711 | def _get_number_format(nf): 712 | return { 713 | "flags": nf.flags, 714 | "opnum": nf.opnum, 715 | "props": nf.props, 716 | "serial": nf.serial, 717 | "org_nbytes": nf.org_nbytes, 718 | "type_name": nf.type_name, 719 | } 720 | 721 | def _send_user_numforms(self, ea): 722 | numforms = HexRaysHooks._get_user_numforms(ea) 723 | if numforms != self._numforms: 724 | self._send_packet(evt.UserNumformsEvent(ea, numforms)) 725 | self._numforms = numforms 726 | -------------------------------------------------------------------------------- /idarling/interface/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/interface/__init__.py -------------------------------------------------------------------------------- /idarling/interface/actions.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import ctypes 14 | from functools import partial 15 | import os 16 | import shutil 17 | import tempfile 18 | 19 | import ida_auto 20 | import ida_idaapi 21 | import ida_kernwin 22 | import ida_loader 23 | 24 | from PyQt5.QtCore import QCoreApplication, QFileInfo, Qt # noqa: I202 25 | from PyQt5.QtGui import QIcon 26 | from PyQt5.QtWidgets import QMessageBox, QProgressDialog 27 | 28 | from .dialogs import OpenDialog, SaveDialog 29 | from ..shared.commands import DownloadFile, UpdateFile 30 | 31 | 32 | class Action(object): 33 | """ 34 | An action is attached to a specific menu, has a custom text, icon, tooltip 35 | and finally a handler that is called when it is clicked by the user. 36 | """ 37 | 38 | _ACTION_ID = None 39 | 40 | def __init__(self, plugin, menu, text, tooltip, icon, handler): 41 | super(Action, self).__init__() 42 | self._plugin = plugin 43 | 44 | self._menu = menu 45 | self._text = text 46 | self._tooltip = tooltip 47 | self._icon = icon 48 | self._icon_id = ida_idaapi.BADADDR 49 | self._handler = handler 50 | 51 | @property 52 | def handler(self): 53 | return self._handler 54 | 55 | def install(self): 56 | action_name = self.__class__.__name__ 57 | 58 | # Read and load the icon file 59 | icon_data = open(self._icon, "rb").read() 60 | self._icon_id = ida_kernwin.load_custom_icon(data=icon_data) 61 | 62 | # Create the action descriptor 63 | action_desc = ida_kernwin.action_desc_t( 64 | self._ACTION_ID, 65 | self._text, 66 | self._handler, 67 | None, 68 | self._tooltip, 69 | self._icon_id, 70 | ) 71 | 72 | # Register the action using its descriptor 73 | result = ida_kernwin.register_action(action_desc) 74 | if not result: 75 | raise RuntimeError("Failed to register action %s" % action_name) 76 | 77 | # Attach the action to the chosen menu 78 | result = ida_kernwin.attach_action_to_menu( 79 | self._menu, self._ACTION_ID, ida_kernwin.SETMENU_APP 80 | ) 81 | if not result: 82 | action_name = self.__class__.__name__ 83 | raise RuntimeError("Failed to install action %s" % action_name) 84 | 85 | self._plugin.logger.debug("Installed action %s" % action_name) 86 | return True 87 | 88 | def uninstall(self): 89 | action_name = self.__class__.__name__ 90 | 91 | # Detach the action from the chosen menu 92 | result = ida_kernwin.detach_action_from_menu( 93 | self._menu, self._ACTION_ID 94 | ) 95 | if not result: 96 | return False 97 | 98 | # Un-register the action using its id 99 | result = ida_kernwin.unregister_action(self._ACTION_ID) 100 | if not result: 101 | return False 102 | 103 | # Free the custom icon using its id 104 | ida_kernwin.free_custom_icon(self._icon_id) 105 | self._icon_id = ida_idaapi.BADADDR 106 | 107 | self._plugin.logger.debug("Uninstalled action %s" % action_name) 108 | return True 109 | 110 | def update(self): 111 | """Check if the action should be enabled or not.""" 112 | ida_kernwin.update_action_state( 113 | self._ACTION_ID, self._handler.update(None) 114 | ) 115 | 116 | 117 | class ActionHandler(ida_kernwin.action_handler_t): 118 | """An action handler will display one of the dialogs to the user.""" 119 | 120 | _DIALOG = None 121 | 122 | @staticmethod 123 | def _on_progress(progress, count, total): 124 | """Called when some progress has been made.""" 125 | progress.setRange(0, total) 126 | progress.setValue(count) 127 | 128 | def __init__(self, plugin): 129 | super(ActionHandler, self).__init__() 130 | self._plugin = plugin 131 | 132 | def update(self, ctx): 133 | """Update the state of the associated action.""" 134 | if self._plugin.network.connected: 135 | return ida_kernwin.AST_ENABLE 136 | return ida_kernwin.AST_DISABLE 137 | 138 | def activate(self, ctx): 139 | """Called when the action is clicked by the user.""" 140 | dialog_name = self._DIALOG.__name__ 141 | self._plugin.logger.debug("Showing dialog %s" % dialog_name) 142 | dialog = self._DIALOG(self._plugin) 143 | dialog.accepted.connect(partial(self._dialog_accepted, dialog)) 144 | dialog.exec_() 145 | return 1 146 | 147 | def _dialog_accepted(self, dialog): 148 | """Called when the dialog is accepted by the user.""" 149 | raise NotImplementedError("dialog_accepted() not implemented") 150 | 151 | 152 | class OpenAction(Action): 153 | """The "Open from server..." action installed in the "File" menu.""" 154 | 155 | _ACTION_ID = "idarling:open" 156 | 157 | def __init__(self, plugin): 158 | super(OpenAction, self).__init__( 159 | plugin, 160 | "File/Open", 161 | "Open from server...", 162 | "Load a database from server", 163 | plugin.plugin_resource("download.png"), 164 | OpenActionHandler(plugin), 165 | ) 166 | 167 | 168 | class OpenActionHandler(ActionHandler): 169 | """The action handler for the "Open from server..." action.""" 170 | 171 | _DIALOG = OpenDialog 172 | 173 | def _dialog_accepted(self, dialog): 174 | project, database = dialog.get_result() 175 | 176 | # Create the download progress dialog 177 | text = "Downloading database from server, please wait..." 178 | progress = QProgressDialog(text, "Cancel", 0, 1) 179 | progress.setCancelButton(None) # Remove cancel button 180 | progress.setModal(True) # Set as a modal dialog 181 | window_flags = progress.windowFlags() # Disable close button 182 | progress.setWindowFlags(window_flags & ~Qt.WindowCloseButtonHint) 183 | progress.setWindowTitle("Open from server") 184 | icon_path = self._plugin.plugin_resource("download.png") 185 | progress.setWindowIcon(QIcon(icon_path)) 186 | 187 | # Send a packet to download the file 188 | packet = DownloadFile.Query(project.name, database.name) 189 | callback = partial(self._on_progress, progress) 190 | 191 | def set_download_callback(reply): 192 | reply.downback = callback 193 | 194 | d = self._plugin.network.send_packet(packet) 195 | d.add_initback(set_download_callback) 196 | d.add_callback(partial(self._file_downloaded, database, progress)) 197 | d.add_errback(self._plugin.logger.exception) 198 | progress.show() 199 | 200 | def _file_downloaded(self, database, progress, reply): 201 | """Called when the file has been downloaded.""" 202 | progress.close() 203 | 204 | # Get the absolute path of the file 205 | app_path = QCoreApplication.applicationFilePath() 206 | app_name = QFileInfo(app_path).fileName() 207 | file_ext = "i64" if "64" in app_name else "idb" 208 | file_name = "%s_%s.%s" % (database.project, database.name, file_ext) 209 | file_path = self._plugin.user_resource("files", file_name) 210 | 211 | # Write the file to disk 212 | with open(file_path, "wb") as output_file: 213 | output_file.write(reply.content) 214 | self._plugin.logger.info("Saved file %s" % file_name) 215 | 216 | # Save the old database 217 | database = ida_loader.get_path(ida_loader.PATH_TYPE_IDB) 218 | if database: 219 | ida_loader.save_database(database, ida_loader.DBFL_TEMP) 220 | 221 | # This is a very ugly hack used to open a database into IDA. We don't 222 | # have any function for this in the SDK, so I sorta hijacked the 223 | # snapshot functionality in this effect. 224 | 225 | # Get the library to call functions not present in the bindings 226 | dll = self._plugin.core.get_ida_dll(app_name) 227 | 228 | # Close the old database using the term_database library function 229 | old_path = ida_loader.get_path(ida_loader.PATH_TYPE_IDB) 230 | if old_path: 231 | dll.term_database() 232 | 233 | # Open the new database using the init_database library function 234 | # This call only won't be enough because the user interface won't 235 | # be initialized, this is why the snapshot functionality is used for 236 | args = [app_name, file_path] 237 | argc = len(args) 238 | argv = (ctypes.POINTER(ctypes.c_char) * (argc + 1))() 239 | for i, arg in enumerate(args): 240 | arg = arg.encode("utf-8") 241 | argv[i] = ctypes.create_string_buffer(arg) 242 | 243 | v = ctypes.c_int(0) 244 | av = ctypes.addressof(v) 245 | pv = ctypes.cast(av, ctypes.POINTER(ctypes.c_int)) 246 | dll.init_database(argc, argv, pv) 247 | 248 | # Create a temporary copy of the new database because we cannot use 249 | # the snapshot functionality to restore the currently opened database 250 | file_ext = ".i64" if "64" in app_name else ".idb" 251 | tmp_file, tmp_path = tempfile.mkstemp(suffix=file_ext) 252 | shutil.copyfile(file_path, tmp_path) 253 | 254 | # This hook is used to delete the temporary database when all done 255 | class UIHooks(ida_kernwin.UI_Hooks): 256 | def database_inited(self, is_new_database, idc_script): 257 | self.unhook() 258 | 259 | os.close(tmp_file) 260 | if os.path.exists(tmp_path): 261 | os.remove(tmp_path) 262 | 263 | hooks = UIHooks() 264 | hooks.hook() 265 | 266 | # Call the restore_database_snapshot library function 267 | # This will initialize the user interface, completing the process 268 | s = ida_loader.snapshot_t() 269 | s.filename = tmp_path # Use the temporary database 270 | ida_kernwin.restore_database_snapshot(s, None, None) 271 | 272 | 273 | class SaveAction(Action): 274 | """The "Save to server..." action installed in the "File" menu.""" 275 | 276 | _ACTION_ID = "idarling:save" 277 | 278 | def __init__(self, plugin): 279 | super(SaveAction, self).__init__( 280 | plugin, 281 | "File/Save", 282 | "Save to server...", 283 | "Save a database to server", 284 | plugin.plugin_resource("upload.png"), 285 | SaveActionHandler(plugin), 286 | ) 287 | 288 | 289 | class SaveActionHandler(ActionHandler): 290 | """The action handler for the "Save to server..." action.""" 291 | 292 | _DIALOG = SaveDialog 293 | 294 | @staticmethod 295 | def upload_file(plugin, packet): 296 | # Save the current database 297 | plugin.core.save_netnode() 298 | input_path = ida_loader.get_path(ida_loader.PATH_TYPE_IDB) 299 | ida_loader.save_database(input_path, 0) 300 | 301 | with open(input_path, "rb") as input_file: 302 | packet.content = input_file.read() 303 | 304 | # Create the upload progress dialog 305 | text = "Uploading database to server, please wait..." 306 | progress = QProgressDialog(text, "Cancel", 0, 1) 307 | progress.setCancelButton(None) # Remove cancel button 308 | progress.setModal(True) # Set as a modal dialog 309 | window_flags = progress.windowFlags() # Disable close button 310 | progress.setWindowFlags(window_flags & ~Qt.WindowCloseButtonHint) 311 | progress.setWindowTitle("Save to server") 312 | icon_path = plugin.plugin_resource("upload.png") 313 | progress.setWindowIcon(QIcon(icon_path)) 314 | 315 | # Send the packet to upload the file 316 | packet.upback = partial(SaveActionHandler._on_progress, progress) 317 | d = plugin.network.send_packet(packet) 318 | if d: 319 | d.add_callback( 320 | partial(SaveActionHandler.file_uploaded, plugin, progress) 321 | ) 322 | d.add_errback(plugin.logger.exception) 323 | progress.show() 324 | 325 | @staticmethod 326 | def file_uploaded(plugin, progress, _): 327 | progress.close() 328 | 329 | # Show a success dialog 330 | success = QMessageBox() 331 | success.setIcon(QMessageBox.Information) 332 | success.setStandardButtons(QMessageBox.Ok) 333 | success.setText("Database successfully uploaded!") 334 | success.setWindowTitle("Save to server") 335 | icon_path = plugin.plugin_resource("upload.png") 336 | success.setWindowIcon(QIcon(icon_path)) 337 | success.exec_() 338 | 339 | # Subscribe to the event stream 340 | plugin.core.join_session() 341 | 342 | def update(self, ctx): 343 | if not ida_loader.get_path(ida_loader.PATH_TYPE_IDB): 344 | return ida_kernwin.AST_DISABLE 345 | if not ida_auto.auto_is_ok(): 346 | return ida_kernwin.AST_DISABLE 347 | return super(SaveActionHandler, self).update(ctx) 348 | 349 | def _dialog_accepted(self, dialog): 350 | project, database = dialog.get_result() 351 | self._plugin.core.project = project.name 352 | self._plugin.core.database = database.name 353 | 354 | # Create the packet that will hold the file 355 | packet = UpdateFile.Query(project.name, database.name) 356 | SaveActionHandler.upload_file(self._plugin, packet) 357 | -------------------------------------------------------------------------------- /idarling/interface/filter.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import ida_funcs 14 | import ida_kernwin 15 | 16 | from PyQt5.QtCore import QEvent, QObject, Qt # noqa: I202 17 | from PyQt5.QtGui import QContextMenuEvent, QIcon, QImage, QPixmap, QShowEvent 18 | from PyQt5.QtWidgets import ( 19 | QAction, 20 | qApp, 21 | QDialog, 22 | QGroupBox, 23 | QLabel, 24 | QMenu, 25 | QTableView, 26 | QWidget, 27 | ) 28 | 29 | from .widget import StatusWidget 30 | from ..shared.commands import InviteToLocation 31 | 32 | 33 | class EventFilter(QObject): 34 | """ 35 | This Qt event filter is used to replace the IDA icon with our 36 | own and to setup the invites context menu in the disassembler view. 37 | """ 38 | 39 | def __init__(self, plugin, parent=None): 40 | super(EventFilter, self).__init__(parent) 41 | self._plugin = plugin 42 | self._intercept = False 43 | 44 | def install(self): 45 | self._plugin.logger.debug("Installing the event filter") 46 | qApp.instance().installEventFilter(self) 47 | 48 | def uninstall(self): 49 | self._plugin.logger.debug("Uninstalling the event filter") 50 | qApp.instance().removeEventFilter(self) 51 | 52 | def _replace_icon(self, label): 53 | pixmap = QPixmap(self._plugin.plugin_resource("idarling.png")) 54 | pixmap = pixmap.scaled( 55 | label.sizeHint().width(), 56 | label.sizeHint().height(), 57 | Qt.KeepAspectRatio, 58 | Qt.SmoothTransformation, 59 | ) 60 | label.setPixmap(pixmap) 61 | 62 | def _insert_menu(self, obj): 63 | # Find where to install our submenu 64 | sep = None 65 | for act in obj.actions(): 66 | if act.isSeparator(): 67 | sep = act 68 | if "Undefine" in act.text(): 69 | break 70 | obj.insertSeparator(sep) 71 | 72 | # Setup our custom menu text and icon 73 | menu = QMenu("Invite to location", obj) 74 | pixmap = QPixmap(self._plugin.plugin_resource("invite.png")) 75 | menu.setIcon(QIcon(pixmap)) 76 | 77 | # Setup our first submenu entry text and icon 78 | everyone = QAction("Everyone", menu) 79 | pixmap = QPixmap(self._plugin.plugin_resource("users.png")) 80 | everyone.setIcon(QIcon(pixmap)) 81 | 82 | def invite_to(name): 83 | """Send an invitation to the current location.""" 84 | loc = ida_kernwin.get_screen_ea() 85 | packet = InviteToLocation(name, loc) 86 | self._plugin.network.send_packet(packet) 87 | 88 | # Handler for when the action is clicked 89 | def invite_to_everyone(): 90 | invite_to("everyone") 91 | 92 | everyone.triggered.connect(invite_to_everyone) 93 | menu.addAction(everyone) 94 | 95 | menu.addSeparator() 96 | template = self._plugin.plugin_resource("user.png") 97 | 98 | def create_action(name, color): 99 | action = QAction(name, menu) 100 | pixmap = StatusWidget.make_icon(template, color) 101 | action.setIcon(QIcon(pixmap)) 102 | 103 | # Handler for when the action is clicked 104 | def invite_to_user(): 105 | invite_to(name) 106 | 107 | action.triggered.connect(invite_to_user) 108 | return action 109 | 110 | # Insert an action for each connected user 111 | for name, user in self._plugin.core.get_users().items(): 112 | menu.addAction(create_action(name, user["color"])) 113 | obj.insertMenu(sep, menu) 114 | 115 | def _set_tooltip(self, obj, ev): 116 | cursors = self._plugin.config["cursors"] 117 | if not cursors["funcs"]: 118 | return 119 | 120 | obj.setToolTip("") 121 | index = obj.parent().indexAt(ev.pos()) 122 | func_ea = int(index.sibling(index.row(), 2).data(), 16) 123 | func = ida_funcs.get_func(func_ea) 124 | 125 | # Find the corresponding username 126 | for name, user in self._plugin.core.get_users().items(): 127 | if ida_funcs.func_contains(func, user["ea"]): 128 | # Set the tooltip 129 | obj.setToolTip(name) 130 | break 131 | 132 | def eventFilter(self, obj, ev): # noqa: N802 133 | # Is it a QShowEvent on a QDialog named "Dialog"? 134 | if ( 135 | ev.__class__ == ev, 136 | QShowEvent 137 | and obj.__class__ == QDialog 138 | and obj.windowTitle() == "About", 139 | ): 140 | # Find a child QGroupBox 141 | for groupBox in obj.children(): 142 | if groupBox.__class__ == QGroupBox: 143 | # Find a child QLabel with an icon 144 | for label in groupBox.children(): 145 | if isinstance(label, QLabel) and label.pixmap(): 146 | self._replace_icon(label) 147 | 148 | # Is it a QContextMenuEvent on a QWidget? 149 | if isinstance(obj, QWidget) and isinstance(ev, QContextMenuEvent): 150 | # Find a parent titled "IDA View" 151 | parent = obj 152 | while parent: 153 | if parent.windowTitle().startswith("IDA View"): 154 | # Intercept the next context menu 155 | self._intercept = True 156 | parent = parent.parent() 157 | 158 | # Is it a QShowEvent on a QMenu? 159 | if isinstance(obj, QMenu) and isinstance(ev, QShowEvent): 160 | # Should we intercept? 161 | if self._intercept: 162 | self._insert_menu(obj) 163 | self._intercept = False 164 | 165 | # Is it a ToolTip event on a QWidget with a parent? 166 | if ( 167 | ev.type() == QEvent.ToolTip 168 | and obj.__class__ == QWidget 169 | and obj.parent() 170 | ): 171 | table_view = obj.parent() 172 | # Is it a QTableView with a parent? 173 | if table_view.__class__ == QTableView and table_view.parent(): 174 | func_window = table_view.parent() 175 | # Is it a QWidget titled "Functions window"? 176 | if ( 177 | func_window.__class__ == QWidget 178 | and func_window.windowTitle() == "Functions window" 179 | ): 180 | self._set_tooltip(obj, ev) 181 | 182 | return False 183 | -------------------------------------------------------------------------------- /idarling/interface/interface.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import time 14 | 15 | from PyQt5.QtGui import QPixmap 16 | from PyQt5.QtWidgets import qApp, QMainWindow 17 | 18 | from .actions import OpenAction, SaveAction 19 | from .filter import EventFilter 20 | from .invites import Invite 21 | from .painter import Painter 22 | from .widget import StatusWidget 23 | from ..module import Module 24 | 25 | 26 | class Interface(Module): 27 | """ 28 | This is the interface module. It is responsible for all interactions with 29 | the user interface. It manages the all the actions, dialog, cursors, 30 | invites and the handy status bar widget. 31 | """ 32 | 33 | def __init__(self, plugin): 34 | super(Interface, self).__init__(plugin) 35 | self._invites = [] 36 | self._followed = None 37 | 38 | # Find the QMainWindow instance 39 | self._plugin.logger.debug("Searching for the main window") 40 | for widget in qApp.topLevelWidgets(): 41 | if isinstance(widget, QMainWindow): 42 | self._window = widget 43 | break 44 | 45 | self._open_action = OpenAction(plugin) 46 | self._save_action = SaveAction(plugin) 47 | 48 | self._painter = Painter(plugin) 49 | self._filter = EventFilter(plugin) 50 | self._widget = StatusWidget(plugin) 51 | 52 | @property 53 | def widget(self): 54 | return self._widget 55 | 56 | @property 57 | def painter(self): 58 | return self._painter 59 | 60 | @property 61 | def invites(self): 62 | """Get all active invites.""" 63 | invites = [] 64 | for invite in self._invites: 65 | # Check if still active 66 | if ( 67 | invite.callback 68 | and not invite.triggered 69 | and time.time() - invite.time < 180.0 70 | ): 71 | invites.append(invite) 72 | return invites 73 | 74 | @property 75 | def open_action(self): 76 | return self._open_action 77 | 78 | @property 79 | def save_action(self): 80 | return self._save_action 81 | 82 | @property 83 | def followed(self): 84 | return self._followed 85 | 86 | @followed.setter 87 | def followed(self, followed): 88 | self._followed = followed 89 | 90 | def _install(self): 91 | self._open_action.install() 92 | self._save_action.install() 93 | self._filter.install() 94 | self._widget.install(self._window) 95 | return True 96 | 97 | def _uninstall(self): 98 | self._open_action.uninstall() 99 | self._save_action.uninstall() 100 | self._filter.uninstall() 101 | self._widget.uninstall(self._window) 102 | return True 103 | 104 | def update(self): 105 | """Update the actions and widget.""" 106 | if not self._plugin.network.connected: 107 | self.clear_invites() 108 | 109 | self._open_action.update() 110 | self._save_action.update() 111 | self._widget.refresh() 112 | 113 | def show_invite(self, text, icon, callback=None): 114 | """ 115 | Display a toast notification to the user. The notification will have 116 | the specified text, icon and callback function (triggered on click). 117 | """ 118 | # Check if notifications aren't disabled 119 | if not self._plugin.config["user"]["notifications"]: 120 | return 121 | 122 | invite = Invite(self._plugin, self._window) 123 | invite.time = time.time() 124 | invite.text = text 125 | invite.icon = QPixmap(icon) 126 | invite.callback = callback 127 | invite.show() 128 | self._invites.append(invite) 129 | 130 | def clear_invites(self): 131 | """Clears the invites list.""" 132 | del self._invites[:] 133 | self._widget.refresh() 134 | -------------------------------------------------------------------------------- /idarling/interface/invites.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from PyQt5.QtCore import ( 14 | pyqtProperty, 15 | QPoint, 16 | QPropertyAnimation, 17 | QRect, 18 | Qt, 19 | QTimer, 20 | ) 21 | from PyQt5.QtGui import QBrush, QColor, QPainter 22 | from PyQt5.QtWidgets import QHBoxLayout, QLabel, QWidget 23 | 24 | 25 | class Invite(QWidget): 26 | """ 27 | An invite is a small notification being displayed in the bottom right 28 | corner of the window. It fades in and out, and is used to invite an user 29 | to jump to a certain location. Some other uses might be added later. 30 | """ 31 | 32 | def __init__(self, plugin, parent=None): 33 | super(Invite, self).__init__(parent) 34 | self._plugin = plugin 35 | self._time = 0 36 | 37 | self.setWindowFlags( 38 | Qt.FramelessWindowHint | Qt.Tool | Qt.WindowStaysOnTopHint 39 | ) 40 | self.setAttribute(Qt.WA_ShowWithoutActivating) 41 | self.setAttribute(Qt.WA_TranslucentBackground) 42 | 43 | self._icon = QLabel() 44 | self._icon.setAutoFillBackground(False) 45 | self._icon.setAttribute(Qt.WA_TranslucentBackground) 46 | 47 | self._text = QLabel() 48 | self._text.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) 49 | 50 | self._layout = QHBoxLayout() 51 | self._layout.addWidget(self._text) 52 | self.setLayout(self._layout) 53 | 54 | # Fade in and out animation 55 | self._popup_opacity = 0.0 56 | self._animation = QPropertyAnimation() 57 | self._animation.setTargetObject(self) 58 | self._animation.setPropertyName("popup_opacity") 59 | self._animation.finished.connect(self.hide) 60 | 61 | # Timer used to auto-close the window 62 | self._timer = QTimer() 63 | self._timer.timeout.connect(self.hide_animation) 64 | self._callback = None 65 | self._triggered = False 66 | 67 | @property 68 | def time(self): 69 | return self._time 70 | 71 | @time.setter 72 | def time(self, time): 73 | self._time = time 74 | 75 | @property 76 | def text(self): 77 | return self._text.text() 78 | 79 | @text.setter 80 | def text(self, text): 81 | self._text.setText(text) 82 | self.adjustSize() 83 | 84 | @property 85 | def icon(self): 86 | return self._icon.pixmap() 87 | 88 | @icon.setter 89 | def icon(self, pixmap): 90 | # Resize the given pixmap 91 | pixmap_height = self._text.sizeHint().height() 92 | self._icon.setPixmap( 93 | pixmap.scaled( 94 | pixmap_height, 95 | pixmap_height, 96 | Qt.KeepAspectRatio, 97 | Qt.SmoothTransformation, 98 | ) 99 | ) 100 | self._layout.insertWidget(0, self._icon) 101 | 102 | @property 103 | def callback(self): 104 | return self._callback 105 | 106 | @callback.setter 107 | def callback(self, callback): 108 | self._callback = callback 109 | 110 | @property 111 | def triggered(self): 112 | return self._triggered 113 | 114 | @triggered.setter 115 | def triggered(self, triggered): 116 | self._triggered = triggered 117 | 118 | def paintEvent(self, event): # noqa: N802 119 | """We override the painting event to draw the invite ourselves.""" 120 | painter = QPainter(self) 121 | painter.setRenderHint(QPainter.Antialiasing) 122 | 123 | rect = QRect(self.rect()) 124 | 125 | # Draw the border 126 | painter.setBrush(QBrush(QColor(122, 122, 122))) 127 | painter.setPen(Qt.NoPen) 128 | painter.drawRect(rect) 129 | 130 | rect.setX(rect.x() + 1) 131 | rect.setY(rect.y() + 1) 132 | rect.setWidth(rect.width() - 1) 133 | rect.setHeight(rect.height() - 1) 134 | 135 | # Draw the background 136 | painter.setBrush(QBrush(QColor(255, 255, 225))) 137 | painter.setPen(Qt.NoPen) 138 | painter.drawRect(rect) 139 | 140 | def mouseReleaseEvent(self, event): # noqa: N802 141 | """ 142 | This function is called when the user clicks the invite. It triggers 143 | the callback function is it has been specified, and hides the invite. 144 | """ 145 | if self._callback: 146 | self._callback() 147 | self._triggered = True 148 | self._popup_opacity = 0.0 149 | self.hide() 150 | 151 | def show(self): 152 | """Shows the invite to user. It triggers a fade in effect.""" 153 | self._plugin.logger.debug("Showing invite %s" % self.text) 154 | self.setWindowOpacity(0.0) 155 | 156 | self._animation.setDuration(500) 157 | self._animation.setStartValue(0.0) 158 | self._animation.setEndValue(1.0) 159 | 160 | # Map the notification to the bottom right corner 161 | pos = QPoint(self.parent().width() - 25, self.parent().height() - 50) 162 | pos = self.parent().mapToGlobal(pos) 163 | 164 | self.setGeometry( 165 | pos.x() - self.width(), 166 | pos.y() - self.height(), 167 | self.width(), 168 | self.height(), 169 | ) 170 | super(Invite, self).show() 171 | 172 | self._animation.start() 173 | self._timer.start(3500) 174 | 175 | def hide(self): 176 | """Hides the invite only if it is fully transparent.""" 177 | if self._popup_opacity == 0.0: 178 | self._plugin.interface.widget.refresh() 179 | super(Invite, self).hide() 180 | 181 | def hide_animation(self): 182 | """Hides the invite. It triggers the fade out animation.""" 183 | self._timer.stop() 184 | self._animation.setDuration(500) 185 | self._animation.setStartValue(1.0) 186 | self._animation.setEndValue(0.0) 187 | self._animation.start() 188 | 189 | @pyqtProperty(float) 190 | def popup_opacity(self): 191 | return self._popup_opacity 192 | 193 | @popup_opacity.setter 194 | def popup_opacity(self, opacity): 195 | self._popup_opacity = opacity 196 | self.setWindowOpacity(opacity) 197 | -------------------------------------------------------------------------------- /idarling/interface/painter.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import sys 14 | 15 | import ida_funcs 16 | import ida_kernwin 17 | 18 | from PyQt5.QtCore import ( # noqa: I202 19 | QAbstractItemModel, 20 | QModelIndex, 21 | QObject, 22 | Qt, 23 | ) 24 | from PyQt5.QtGui import QColor 25 | from PyQt5.QtWidgets import QStyledItemDelegate, QWidget 26 | import sip 27 | 28 | from .widget import StatusWidget 29 | 30 | if sys.version_info > (3,): 31 | long = int 32 | 33 | 34 | class Painter(QObject): 35 | class ProxyItemDelegate(QStyledItemDelegate): 36 | def __init__(self, delegate, model, parent=None): 37 | super(Painter.ProxyItemDelegate, self).__init__(parent) 38 | self._delegate = delegate 39 | self._model = model 40 | 41 | def paint(self, painter, option, index): 42 | index = self._model.index(index.row(), index.column()) 43 | self._delegate.paint(painter, option, index) 44 | 45 | class ProxyItemModel(QAbstractItemModel): 46 | def __init__(self, model, plugin, parent=None): 47 | super(Painter.ProxyItemModel, self).__init__(parent) 48 | self._model = model 49 | self._plugin = plugin 50 | 51 | def index(self, row, column, parent=QModelIndex()): 52 | return self.createIndex(row, column) 53 | 54 | def parent(self, index): 55 | index = self._model.index(index.row(), index.column()) 56 | return self._model.parent(index) 57 | 58 | def rowCount(self): # noqa: N802 59 | return self._model.rowCount() 60 | 61 | def columnCount(self): # noqa: N802 62 | return self._model.columnCount() 63 | 64 | def data(self, index, role=Qt.DisplayRole): 65 | # Check if disabled by the user 66 | cursors = self._plugin.config["cursors"] 67 | if role == Qt.BackgroundRole and cursors["funcs"]: 68 | func_ea = int(index.sibling(index.row(), 2).data(), 16) 69 | func = ida_funcs.get_func(func_ea) 70 | for user in self._plugin.core.get_users().values(): 71 | if ida_funcs.func_contains(func, user["ea"]): 72 | r, g, b = StatusWidget.ida_to_python(user["color"]) 73 | return QColor(StatusWidget.python_to_qt(r, g, b)) 74 | index = self._model.index(index.row(), index.column()) 75 | return self._model.data(index, role) 76 | 77 | def __init__(self, plugin): 78 | super(Painter, self).__init__() 79 | self._plugin = plugin 80 | 81 | self._ida_nav_colorizer = None 82 | self._nbytes = 0 83 | 84 | def nav_colorizer(self, ea, nbytes): 85 | """This is the custom nav colorizer used by the painter.""" 86 | self._nbytes = nbytes 87 | 88 | # There is a bug in IDA: with a huge number of segments, all the navbar 89 | # is colored with the user color. This will be resolved in IDA 7.2. 90 | cursors = self._plugin.config["cursors"] 91 | if cursors["navbar"]: 92 | for user in self._plugin.core.get_users().values(): 93 | # Cursor color 94 | if ea - nbytes * 2 <= user["ea"] <= ea + nbytes * 2: 95 | return long(user["color"]) 96 | # Cursor borders 97 | if ea - nbytes * 4 <= user["ea"] <= ea + nbytes * 4: 98 | return long(0) 99 | orig = ida_kernwin.call_nav_colorizer( 100 | self._ida_nav_colorizer, ea, nbytes 101 | ) 102 | return long(orig) 103 | 104 | def ready_to_run(self): 105 | # The default nav colorized can only be recovered once! 106 | ida_nav_colorizer = ida_kernwin.set_nav_colorizer(self.nav_colorizer) 107 | if ida_nav_colorizer is not None: 108 | self._ida_nav_colorizer = ida_nav_colorizer 109 | self.refresh() 110 | 111 | def get_ea_hint(self, ea): 112 | cursors = self._plugin.config["cursors"] 113 | if not cursors["navbar"]: 114 | return None 115 | 116 | for name, user in self._plugin.core.get_users().items(): 117 | start_ea = user["ea"] - self._nbytes * 4 118 | end_ea = user["ea"] + self._nbytes * 4 119 | # Check if the navbar range contains the user's address 120 | if start_ea <= ea <= end_ea: 121 | return str(name) 122 | 123 | def get_bg_color(self, ea): 124 | # Check if disabled by the user 125 | cursors = self._plugin.config["cursors"] 126 | if not cursors["disasm"]: 127 | return None 128 | 129 | for user in self._plugin.core.get_users().values(): 130 | if ea == user["ea"]: 131 | return user["color"] 132 | return None 133 | 134 | def widget_visible(self, twidget): 135 | widget = sip.wrapinstance(long(twidget), QWidget) 136 | if widget.windowTitle() != "Functions window": 137 | return 138 | table = widget.layout().itemAt(0).widget() 139 | 140 | # Replace the table's item delegate 141 | model = Painter.ProxyItemModel(table.model(), self._plugin, self) 142 | old_deleg = table.itemDelegate() 143 | new_deleg = Painter.ProxyItemDelegate(old_deleg, model, self) 144 | table.setItemDelegate(new_deleg) 145 | 146 | def refresh(self): 147 | ida_kernwin.refresh_navband(True) 148 | ida_kernwin.request_refresh(ida_kernwin.IWID_DISASMS) 149 | ida_kernwin.request_refresh(ida_kernwin.IWID_FUNCS) 150 | -------------------------------------------------------------------------------- /idarling/interface/widget.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import colorsys 14 | from functools import partial, lru_cache 15 | import time 16 | 17 | from PyQt5.QtCore import QPoint, QRect, QSize, Qt, QTimer 18 | from PyQt5.QtGui import QIcon, QImage, QPainter, QPixmap, QRegion 19 | from PyQt5.QtWidgets import QAction, QActionGroup, QLabel, QMenu, QWidget 20 | 21 | from .dialogs import SettingsDialog 22 | 23 | 24 | class StatusWidget(QWidget): 25 | """ 26 | This is the widget that is displayed in the status bar of the window. It 27 | allow the user to connect to server, as well as to access the settings. 28 | """ 29 | 30 | @staticmethod 31 | def ida_to_python(c): 32 | # IDA colors are 0xBBGGRR. 33 | r = (c & 255) / 255.0 34 | g = ((c >> 8) & 255) / 255.0 35 | b = ((c >> 16) & 255) / 255.0 36 | return r, g, b 37 | 38 | @staticmethod 39 | def python_to_qt(r, g, b): 40 | # Qt colors are 0xRRGGBB 41 | r = int(r * 255) << 16 42 | g = int(g * 255) << 8 43 | b = int(b * 255) 44 | return 0xFF000000 | r | g | b 45 | 46 | @staticmethod 47 | @lru_cache(maxsize=32) 48 | def make_icon(template, color): 49 | """ 50 | Create an icon for the specified user color. It will be used to 51 | generate on the fly an icon representing the user. 52 | """ 53 | # Get a light and dark version of the user color 54 | r, g, b = StatusWidget.ida_to_python(color) 55 | h, l, s = colorsys.rgb_to_hls(r, g, b) 56 | r, g, b = colorsys.hls_to_rgb(h, 0.5, 1.0) 57 | light = StatusWidget.python_to_qt(r, g, b) 58 | r, g, b = colorsys.hls_to_rgb(h, 0.25, 1.0) 59 | dark = StatusWidget.python_to_qt(r, g, b) 60 | 61 | # Replace the icon pixel with our two colors 62 | image = QImage(template) 63 | for x in range(image.width()): 64 | for y in range(image.height()): 65 | c = image.pixel(x, y) 66 | if (c & 0xFFFFFF) == 0xFFFFFF: 67 | image.setPixel(x, y, light) 68 | if (c & 0xFFFFFF) == 0x000000: 69 | image.setPixel(x, y, dark) 70 | return QPixmap(image) 71 | 72 | def __init__(self, plugin): 73 | super(StatusWidget, self).__init__() 74 | self._plugin = plugin 75 | 76 | # Create the sub-widgets 77 | def new_label(): 78 | widget = QLabel() 79 | widget.setAutoFillBackground(False) 80 | widget.setAttribute(Qt.WA_PaintOnScreen) 81 | widget.setAttribute(Qt.WA_TranslucentBackground) 82 | return widget 83 | 84 | self._servers_text_widget = new_label() 85 | self._servers_icon_widget = new_label() 86 | self._invites_text_widget = new_label() 87 | self._invites_icon_widget = new_label() 88 | self._users_text_widget = new_label() 89 | self._users_icon_widget = new_label() 90 | 91 | # Set a custom context menu policy 92 | self.setContextMenuPolicy(Qt.CustomContextMenu) 93 | self.customContextMenuRequested.connect(self._context_menu) 94 | 95 | # Timer signaling it is time to update the widget 96 | self._timer = QTimer() 97 | self._timer.setInterval(1000) 98 | self._timer.timeout.connect(self.refresh) 99 | 100 | def install(self, window): 101 | self._plugin.logger.debug("Installing the status bar widget") 102 | window.statusBar().addPermanentWidget(self) 103 | self._timer.start() 104 | self.refresh() 105 | 106 | def uninstall(self, window): 107 | self._plugin.logger.debug("Uninstalling the status bar widget") 108 | window.statusBar().removeWidget(self) 109 | self._timer.stop() 110 | 111 | def refresh(self): 112 | """Called to update the widget when the network state has changed.""" 113 | self._plugin.logger.trace("Refreshing the status bar widget") 114 | 115 | # Get the corresponding color, text and icon 116 | if self._plugin.network.connected: 117 | color, text, icon = "green", "Connected", "connected.png" 118 | elif self._plugin.network.client: 119 | color, text, icon = "orange", "Connecting", "connecting.png" 120 | else: 121 | color, text, icon = "red", "Disconnected", "disconnected.png" 122 | 123 | # Update the text of the server widgets 124 | server = self._plugin.network.server 125 | if server is None: 126 | server = "<no server>" 127 | else: 128 | server = "%s:%d" % (server["host"], server["port"]) 129 | text_format = '%s | %s -- %s' 130 | self._servers_text_widget.setText( 131 | text_format % (self._plugin.description(), server, color, text) 132 | ) 133 | self._servers_text_widget.adjustSize() 134 | 135 | # Update the icon of the server widgets 136 | pixmap = QPixmap(self._plugin.plugin_resource(icon)) 137 | pixmap_height = self._servers_text_widget.sizeHint().height() 138 | self._servers_icon_widget.setPixmap( 139 | pixmap.scaled( 140 | pixmap_height, 141 | pixmap_height, 142 | Qt.KeepAspectRatio, 143 | Qt.SmoothTransformation, 144 | ) 145 | ) 146 | 147 | # Get all active invites 148 | invites = self._plugin.interface.invites 149 | # Find the most recent one 150 | most_recent = 0 151 | if invites: 152 | most_recent = max(invite.time for invite in invites) 153 | 154 | # Get the corresponding icon 155 | if most_recent > 0 and time.time() - most_recent < 60.0: 156 | icon = "hot.png" 157 | elif most_recent > 0 and time.time() - most_recent < 300.0: 158 | icon = "warm.png" 159 | elif most_recent > 0: 160 | icon = "cold.png" 161 | else: 162 | icon = "empty.png" 163 | 164 | # Update the text of the invites widgets 165 | self._invites_text_widget.setText(" | %d " % len(invites)) 166 | self._invites_text_widget.adjustSize() 167 | 168 | # Update the icon of the invites widgets 169 | pixmap = QPixmap(self._plugin.plugin_resource(icon)) 170 | pixmap_height = self._servers_text_widget.sizeHint().height() 171 | self._invites_icon_widget.setPixmap( 172 | pixmap.scaled( 173 | pixmap_height, 174 | pixmap_height, 175 | Qt.KeepAspectRatio, 176 | Qt.SmoothTransformation, 177 | ) 178 | ) 179 | 180 | # Update the text of the users widget 181 | users = len(self._plugin.core.get_users()) 182 | self._users_text_widget.setText(" | %d" % users) 183 | self._users_text_widget.adjustSize() 184 | 185 | # Update the icon of the users widget 186 | template = self._plugin.plugin_resource("user.png") 187 | color = self._plugin.config["user"]["color"] 188 | pixmap = self.make_icon(template, color) 189 | pixmap_height = self._servers_text_widget.sizeHint().height() 190 | self._users_icon_widget.setPixmap( 191 | pixmap.scaled( 192 | pixmap_height, 193 | pixmap_height, 194 | Qt.KeepAspectRatio, 195 | Qt.SmoothTransformation, 196 | ) 197 | ) 198 | 199 | # Update the size of the widget 200 | self.updateGeometry() 201 | 202 | def sizeHint(self): # noqa: N802 203 | """Called when the widget size is being determined internally.""" 204 | width = 3 + self._servers_text_widget.sizeHint().width() 205 | width += 3 + self._servers_icon_widget.sizeHint().width() 206 | width += 3 + self._invites_text_widget.sizeHint().width() 207 | width += 3 + self._invites_icon_widget.sizeHint().width() 208 | width += 3 + self._users_text_widget.sizeHint().width() 209 | width += 3 + self._users_icon_widget.sizeHint().width() 210 | return QSize(width, self._servers_text_widget.sizeHint().height()) 211 | 212 | def _context_menu(self, point): 213 | """Called when the context menu is being requested.""" 214 | width_server = 3 + self._servers_text_widget.sizeHint().width() 215 | width_server += 3 + self._servers_icon_widget.sizeHint().width() 216 | width_invites = width_server 217 | width_invites += 3 + self._invites_text_widget.sizeHint().width() 218 | width_invites += 3 + self._invites_icon_widget.sizeHint().width() 219 | 220 | if point.x() < width_server + 3: 221 | self._servers_context_menu(point) 222 | elif width_server < point.x() < width_invites + 3: 223 | self._invites_context_menu(point) 224 | else: 225 | self._users_context_menu(point) 226 | 227 | def _servers_context_menu(self, point): 228 | """Populates the server context menu.""" 229 | menu = QMenu(self) 230 | 231 | # Add the settings action 232 | settings = QAction("Settings...", menu) 233 | icon_path = self._plugin.plugin_resource("settings.png") 234 | settings.setIcon(QIcon(icon_path)) 235 | 236 | # Add a handler on the action 237 | def settings_action_triggered(): 238 | SettingsDialog(self._plugin).exec_() 239 | 240 | settings.triggered.connect(settings_action_triggered) 241 | menu.addAction(settings) 242 | 243 | # Add the integrated server action 244 | menu.addSeparator() 245 | integrated = QAction("Integrated Server", menu) 246 | integrated.setCheckable(True) 247 | 248 | def integrated_action_triggered(): 249 | # Start or stop the server 250 | if integrated.isChecked(): 251 | self._plugin.network.start_server() 252 | else: 253 | self._plugin.network.stop_server() 254 | 255 | integrated.setChecked(self._plugin.network.started) 256 | integrated.triggered.connect(integrated_action_triggered) 257 | menu.addAction(integrated) 258 | 259 | def create_servers_group(servers): 260 | """Create an action group for the specified servers.""" 261 | servers_group = QActionGroup(self) 262 | current_server = self._plugin.network.server 263 | 264 | for server in servers: 265 | is_connected = ( 266 | current_server is not None 267 | and server["host"] == current_server["host"] 268 | and server["port"] == current_server["port"] 269 | ) 270 | server_text = "%s:%d" % (server["host"], server["port"]) 271 | server_action = QAction(server_text, menu) 272 | server_action._server = server 273 | server_action.setCheckable(True) 274 | server_action.setChecked(is_connected) 275 | servers_group.addAction(server_action) 276 | 277 | def server_action_triggered(server_action): 278 | """ 279 | Called when a action is clicked. Connects to the new server 280 | or disconnects from the old server. 281 | """ 282 | server = server_action._server 283 | was_connected = self._plugin.network.server == server 284 | self._plugin.network.stop_server() 285 | self._plugin.network.disconnect() 286 | if not was_connected: 287 | self._plugin.network.connect(server) 288 | 289 | servers_group.triggered.connect(server_action_triggered) 290 | 291 | return servers_group 292 | 293 | # Add the discovered servers 294 | user_servers = self._plugin.config["servers"] 295 | disc_servers = self._plugin.network.discovery.servers 296 | disc_servers = [s for s, t in disc_servers if time.time() - t < 10.0] 297 | disc_servers = [s for s in disc_servers if s not in user_servers] 298 | if ( 299 | self._plugin.network.started 300 | and self._plugin.network.server in disc_servers 301 | ): 302 | disc_servers.remove(self._plugin.network.server) 303 | if disc_servers: 304 | menu.addSeparator() 305 | servers_group = create_servers_group(disc_servers) 306 | menu.addActions(servers_group.actions()) 307 | 308 | # Add the configured servers 309 | if user_servers: 310 | menu.addSeparator() 311 | servers_group = create_servers_group(user_servers) 312 | menu.addActions(servers_group.actions()) 313 | 314 | # Show the context menu 315 | menu.exec_(self.mapToGlobal(point)) 316 | 317 | def _invites_context_menu(self, point): 318 | """Populate the invites context menu.""" 319 | menu = QMenu(self) 320 | 321 | # Get all active invites 322 | invites = self._plugin.interface.invites 323 | # Sort invites by time ascending 324 | invites = sorted(invites, key=lambda x: x.time) 325 | 326 | clear = QAction("Clear invites", menu) 327 | icon_path = self._plugin.plugin_resource("clear.png") 328 | clear.setIcon(QIcon(icon_path)) 329 | clear.triggered.connect(self._plugin.interface.clear_invites) 330 | clear.setEnabled(bool(invites)) 331 | menu.addAction(clear) 332 | 333 | if invites: 334 | menu.addSeparator() 335 | 336 | # Add an action for each invite 337 | for invite in invites: 338 | action = QAction(invite.text, menu) 339 | action.setIcon(QIcon(invite.icon)) 340 | 341 | def action_triggered(): 342 | if invite.callback: 343 | invite.callback() 344 | invite.triggered = True 345 | self.refresh() 346 | 347 | action.triggered.connect(action_triggered) 348 | menu.addAction(action) 349 | 350 | # Show the context menu 351 | menu.exec_(self.mapToGlobal(point)) 352 | 353 | def _users_context_menu(self, point): 354 | """Populate the invites context menu.""" 355 | menu = QMenu(self) 356 | 357 | template = self._plugin.plugin_resource("user.png") 358 | 359 | users = self._plugin.core.get_users() 360 | follow_all = QAction("Follow all", menu) 361 | pixmap = QPixmap(self._plugin.plugin_resource("users.png")) 362 | follow_all.setIcon(QIcon(pixmap)) 363 | follow_all.setEnabled(bool(users)) 364 | follow_all.setCheckable(True) 365 | follow_all.setChecked(self._plugin.interface.followed == "everyone") 366 | 367 | def follow_triggered(name): 368 | interface = self._plugin.interface 369 | interface.followed = name if interface.followed != name else None 370 | 371 | follow_all.triggered.connect(partial(follow_triggered, "everyone")) 372 | menu.addAction(follow_all) 373 | if users: 374 | menu.addSeparator() 375 | 376 | # Get all active users 377 | for name, user in users.items(): 378 | is_followed = self._plugin.interface.followed == name 379 | text = "Follow %s" % name 380 | action = QAction(text, menu) 381 | action.setCheckable(True) 382 | action.setChecked(is_followed) 383 | pixmap = StatusWidget.make_icon(template, user["color"]) 384 | action.setIcon(QIcon(pixmap)) 385 | 386 | action.triggered.connect(partial(follow_triggered, name)) 387 | menu.addAction(action) 388 | 389 | menu.exec_(self.mapToGlobal(point)) 390 | 391 | def paintEvent(self, event): # noqa: N802 392 | """Called when the widget is being painted.""" 393 | # Adjust the buffer size according to the pixel ratio 394 | dpr = self.devicePixelRatioF() 395 | buffer = QPixmap(self.width() * dpr, self.height() * dpr) 396 | buffer.setDevicePixelRatio(dpr) 397 | buffer.fill(Qt.transparent) 398 | 399 | painter = QPainter(buffer) 400 | 401 | # Paint the server text widget 402 | region = QRegion( 403 | QRect(QPoint(0, 0), self._servers_text_widget.sizeHint()) 404 | ) 405 | self._servers_text_widget.render(painter, QPoint(0, 0), region) 406 | # Paint the server icon widget 407 | region = QRegion( 408 | QRect(QPoint(0, 0), self._servers_icon_widget.sizeHint()) 409 | ) 410 | x = self._servers_text_widget.sizeHint().width() + 3 411 | self._servers_icon_widget.render(painter, QPoint(x, 0), region) 412 | # Paint the invites text widget 413 | region = QRegion( 414 | QRect(QPoint(0, 0), self._invites_text_widget.sizeHint()) 415 | ) 416 | x += self._servers_icon_widget.sizeHint().width() + 3 417 | self._invites_text_widget.render(painter, QPoint(x, 0), region) 418 | # Paint the invites icon widget 419 | region = QRegion( 420 | QRect(QPoint(0, 0), self._invites_icon_widget.sizeHint()) 421 | ) 422 | x += self._invites_text_widget.sizeHint().width() + 3 423 | self._invites_icon_widget.render(painter, QPoint(x, 0), region) 424 | # Paint the users text widget 425 | region = QRegion( 426 | QRect(QPoint(0, 0), self._users_text_widget.sizeHint()) 427 | ) 428 | x += self._invites_icon_widget.sizeHint().width() + 3 429 | self._users_text_widget.render(painter, QPoint(x, 0), region) 430 | # Paint the users icon widget 431 | region = QRegion( 432 | QRect(QPoint(0, 0), self._users_icon_widget.sizeHint()) 433 | ) 434 | x += self._users_text_widget.sizeHint().width() + 3 435 | self._users_icon_widget.render(painter, QPoint(x, 0), region) 436 | painter.end() 437 | 438 | painter = QPainter(self) 439 | painter.drawPixmap(event.rect(), buffer, buffer.rect()) 440 | painter.end() 441 | -------------------------------------------------------------------------------- /idarling/module.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | 15 | class Module(object): 16 | """ 17 | The plugin is organized into modules. Modules allow grouping the 18 | functionality of the plugin and facilitate information communication. 19 | """ 20 | 21 | def __init__(self, plugin): 22 | self._plugin = plugin 23 | self._installed = False 24 | 25 | def install(self): 26 | """Install the module. Called by the plugin.""" 27 | if self._installed: 28 | return False 29 | self._installed = True 30 | return self._install() 31 | 32 | def _install(self): 33 | """Install the module. Overloaded by the module.""" 34 | raise NotImplementedError("_install() not implemented") 35 | 36 | def uninstall(self): 37 | """Uninstall the module. Called by the plugin.""" 38 | if not self._installed: 39 | return False 40 | self._installed = False 41 | return self._uninstall() 42 | 43 | def _uninstall(self): 44 | """Uninstall the module. Overloaded by the module.""" 45 | raise NotImplementedError("_uninstall() not implemented") 46 | -------------------------------------------------------------------------------- /idarling/network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/network/__init__.py -------------------------------------------------------------------------------- /idarling/network/client.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import ida_auto 14 | import ida_kernwin 15 | 16 | from PyQt5.QtGui import QImage, QPixmap # noqa: I202 17 | 18 | from ..interface.widget import StatusWidget 19 | from ..shared.commands import ( 20 | DownloadFile, 21 | InviteToLocation, 22 | JoinSession, 23 | LeaveSession, 24 | UpdateLocation, 25 | UpdateUserColor, 26 | UpdateUserName, 27 | ) 28 | from ..shared.packets import Command, Event 29 | from ..shared.sockets import ClientSocket 30 | 31 | 32 | class Client(ClientSocket): 33 | """ 34 | This class represents a client socket for the client. It implements all the 35 | handlers for the packet the server is susceptible to send. 36 | """ 37 | 38 | def __init__(self, plugin, parent=None): 39 | ClientSocket.__init__(self, plugin.logger, parent) 40 | self._plugin = plugin 41 | self._events = [] 42 | 43 | # Setup command handlers 44 | self._handlers = { 45 | JoinSession: self._handle_join_session, 46 | LeaveSession: self._handle_leave_session, 47 | UpdateLocation: self._handle_update_location, 48 | InviteToLocation: self._handle_invite_to_location, 49 | UpdateUserName: self._handle_update_user_name, 50 | UpdateUserColor: self._handle_update_user_color, 51 | DownloadFile.Query: self._handle_download_file, 52 | } 53 | 54 | def call_events(self): 55 | while self._events and ida_auto.get_auto_state() == ida_auto.AU_NONE: 56 | packet = self._events.pop(0) 57 | self._call_event(packet) 58 | 59 | def _call_event(self, packet): 60 | self._plugin.core.unhook_all() 61 | 62 | try: 63 | packet() 64 | except Exception as e: 65 | self._logger.warning("Error while calling event") 66 | self._logger.exception(e) 67 | 68 | self._plugin.core.hook_all() 69 | 70 | # Check for de-synchronization 71 | if self._plugin.core.tick >= packet.tick: 72 | self._logger.warning("De-synchronization detected!") 73 | packet.tick = self._plugin.core.tick 74 | self._plugin.core.tick = packet.tick 75 | self._plugin.logger.debug("returning from call_event") 76 | 77 | def recv_packet(self, packet): 78 | if isinstance(packet, Command): 79 | # Call the corresponding handler 80 | self._handlers[packet.__class__](packet) 81 | 82 | elif isinstance(packet, Event): 83 | # If we already have some events queued 84 | if self._events or ida_auto.get_auto_state() != ida_auto.AU_NONE: 85 | self._events.append(packet) 86 | else: 87 | self._call_event(packet) 88 | 89 | else: 90 | return False 91 | return True 92 | 93 | def send_packet(self, packet): 94 | if isinstance(packet, Event): 95 | self._plugin.core.tick += 1 96 | packet.tick = self._plugin.core.tick 97 | return ClientSocket.send_packet(self, packet) 98 | 99 | def disconnect(self, err=None): 100 | ret = ClientSocket.disconnect(self, err) 101 | self._plugin.network._client = None 102 | self._plugin.network._server = None 103 | 104 | # Update the user interface 105 | self._plugin.interface.update() 106 | self._plugin.interface.clear_invites() 107 | return ret 108 | 109 | def _check_socket(self): 110 | was_connected = self._connected 111 | ret = ClientSocket._check_socket(self) 112 | if not was_connected and self._connected: 113 | # Update the user interface 114 | self._plugin.interface.update() 115 | # Subscribe to the events 116 | self._plugin.core.join_session() 117 | return ret 118 | 119 | def _handle_join_session(self, packet): 120 | # Update the users list 121 | user = {"color": packet.color, "ea": packet.ea} 122 | self._plugin.core.add_user(packet.name, user) 123 | 124 | # Show a toast notification 125 | if packet.silent: 126 | return 127 | text = "%s joined the session" % packet.name 128 | template = self._plugin.plugin_resource("user.png") 129 | icon = StatusWidget.make_icon(template, packet.color) 130 | self._plugin.interface.show_invite(text, icon) 131 | 132 | def _handle_leave_session(self, packet): 133 | # Update the users list 134 | user = self._plugin.core.remove_user(packet.name) 135 | # Refresh the users count 136 | self._plugin.interface.widget.refresh() 137 | 138 | # Show a toast notification 139 | if packet.silent: 140 | return 141 | text = "%s left the session" % packet.name 142 | template = self._plugin.plugin_resource("user.png") 143 | icon = StatusWidget.make_icon(template, user["color"]) 144 | self._plugin.interface.show_invite(text, icon) 145 | 146 | def _handle_invite_to_location(self, packet): 147 | # Show a toast notification 148 | text = "%s - Jump to %#x" % (packet.name, packet.loc) 149 | icon = self._plugin.plugin_resource("location.png") 150 | 151 | def callback(): 152 | ida_kernwin.jumpto(packet.loc) 153 | 154 | self._plugin.interface.show_invite(text, QPixmap(icon), callback) 155 | 156 | def _handle_update_user_name(self, packet): 157 | # Update the users list 158 | user = self._plugin.core.remove_user(packet.old_name) 159 | self._plugin.core.add_user(packet.new_name, user) 160 | 161 | def _handle_update_user_color(self, packet): 162 | # Update the users list 163 | user = self._plugin.core.get_user(packet.name) 164 | user["color"] = packet.new_color 165 | self._plugin.core.add_user(packet.name, user) 166 | 167 | def _handle_update_location(self, packet): 168 | # Update the users list 169 | user = self._plugin.core.get_user(packet.name) 170 | user["ea"] = packet.ea 171 | self._plugin.core.add_user(packet.name, user) 172 | 173 | followed = self._plugin.interface.followed 174 | if followed == packet.name or followed == "everyone": 175 | ida_kernwin.jumpto(packet.ea) 176 | 177 | def _handle_download_file(self, query): 178 | # Upload the current database 179 | self._plugin.interface.save_action.handler.upload_file( 180 | self._plugin, DownloadFile.Reply(query) 181 | ) 182 | -------------------------------------------------------------------------------- /idarling/network/network.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import errno 14 | import socket 15 | import ssl 16 | 17 | from .client import Client 18 | from .server import IntegratedServer 19 | from ..module import Module 20 | from ..shared.discovery import ServersDiscovery 21 | 22 | 23 | class Network(Module): 24 | """ 25 | This is the interface module. It is responsible for interacting with the 26 | server over the network. It manages the three sockets used with the plugin 27 | (client, discovery client, integrated server). 28 | """ 29 | 30 | def __init__(self, plugin): 31 | super(Network, self).__init__(plugin) 32 | self._discovery = ServersDiscovery(plugin.logger) 33 | 34 | self._client = None 35 | self._server = None 36 | self._integrated = None 37 | 38 | @property 39 | def client(self): 40 | return self._client 41 | 42 | @property 43 | def server(self): 44 | return self._server 45 | 46 | @property 47 | def discovery(self): 48 | return self._discovery 49 | 50 | @property 51 | def connected(self): 52 | return self._client.connected if self._client else False 53 | 54 | @property 55 | def started(self): 56 | return bool(self._integrated) 57 | 58 | def _install(self): 59 | self._discovery.start() 60 | return True 61 | 62 | def _uninstall(self): 63 | self._discovery.stop() 64 | self.disconnect() 65 | return True 66 | 67 | def connect(self, server): 68 | """Connect to the specified server.""" 69 | # Make sure we're not already connected 70 | if self._client: 71 | return 72 | 73 | self._client = Client(self._plugin) 74 | self._server = server.copy() # Make a copy 75 | host = self._server["host"] 76 | if host == "0.0.0.0": # Windows can't connect to 0.0.0.0 77 | host = "127.0.0.1" 78 | port = self._server["port"] 79 | no_ssl = self._server["no_ssl"] 80 | 81 | # Update the user interface 82 | self._plugin.interface.update() 83 | self._plugin.logger.info("Connecting to %s:%d..." % (host, port)) 84 | 85 | # Create a new socket 86 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) 87 | # Wrap the socket in a SSL tunnel 88 | if not no_ssl: 89 | ctx = ssl.create_default_context() 90 | sock = ctx.wrap_socket( 91 | sock, server_hostname=host, do_handshake_on_connect=False 92 | ) 93 | self._client.wrap_socket(sock) 94 | 95 | # Set TCP keep-alive options 96 | cnt = self._plugin.config["keep"]["cnt"] 97 | intvl = self._plugin.config["keep"]["intvl"] 98 | idle = self._plugin.config["keep"]["idle"] 99 | self._client.set_keep_alive(cnt, intvl, idle) 100 | 101 | # Connect the socket 102 | sock.settimeout(0) # No timeout 103 | sock.setblocking(0) # No blocking 104 | ret = sock.connect_ex((host, port)) 105 | if ret != 0 and ret != errno.EINPROGRESS and ret != errno.EWOULDBLOCK: 106 | self._client.disconnect() 107 | 108 | def disconnect(self): 109 | """Disconnect from the current server.""" 110 | # Make sure we aren't already disconnected 111 | if not self._client: 112 | return 113 | 114 | self._plugin.logger.info("Disconnecting...") 115 | self._client.disconnect() 116 | 117 | def send_packet(self, packet): 118 | """Send a packet to the server.""" 119 | if self.connected: 120 | return self._client.send_packet(packet) 121 | return None 122 | 123 | def start_server(self): 124 | """Start the integrated server.""" 125 | if self._integrated: 126 | return 127 | 128 | self._plugin.logger.info("Starting the integrated server...") 129 | server = IntegratedServer(self._plugin) 130 | if not server.start("0.0.0.0"): 131 | return # Couldn't start the server 132 | self._integrated = server 133 | integrated_arg = { 134 | "host": "0.0.0.0", 135 | "port": server.port, 136 | "no_ssl": True, 137 | } 138 | # Connect the client to the server 139 | self.disconnect() 140 | self.connect(integrated_arg) 141 | 142 | def stop_server(self): 143 | """Stop the integrated server.""" 144 | if not self._integrated: 145 | return 146 | 147 | self._plugin.logger.info("Stopping the integrated server...") 148 | self.disconnect() 149 | self._integrated.stop() 150 | self._integrated = None 151 | -------------------------------------------------------------------------------- /idarling/network/server.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from ..shared.server import Server 14 | 15 | 16 | class IntegratedServer(Server): 17 | """ 18 | The integrated server inherits the logic from Server. It simply needs to 19 | define the server_file method to indicate where to save the databases. 20 | """ 21 | 22 | def __init__(self, plugin, parent=None): 23 | self._plugin = plugin 24 | Server.__init__(self, plugin.logger, parent) 25 | 26 | def server_file(self, filename): 27 | return self._plugin.user_resource("files", filename) 28 | -------------------------------------------------------------------------------- /idarling/plugin.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import colorsys 14 | import json 15 | import logging 16 | import os 17 | import random 18 | import sys 19 | 20 | import ida_diskio 21 | import ida_idaapi 22 | import ida_kernwin 23 | 24 | from .core.core import Core 25 | from .interface.interface import Interface 26 | from .network.network import Network 27 | from .shared.utils import start_logging 28 | 29 | 30 | if sys.version_info > (3,): 31 | unicode = str 32 | 33 | 34 | class Plugin(ida_idaapi.plugin_t): 35 | """ 36 | This is the main class of the plugin. It subclasses plugin_t as required 37 | by IDA. It holds the modules of plugin, which themselves provides the 38 | functionality of the plugin (hooking/events, interface, networking, etc.). 39 | """ 40 | 41 | # Mandatory definitions 42 | PLUGIN_NAME = "IDArling" 43 | PLUGIN_VERSION = "0.0.1" 44 | PLUGIN_AUTHORS = "The IDArling Team" 45 | 46 | # These flags specify that the plugin should persist between databases 47 | # loading and saving, and should not have a menu entry. 48 | flags = ida_idaapi.PLUGIN_FIX | ida_idaapi.PLUGIN_HIDE 49 | comment = "Collaborative Reverse Engineering plugin" 50 | help = "" 51 | wanted_name = PLUGIN_NAME 52 | wanted_hotkey = "" 53 | 54 | @staticmethod 55 | def description(): 56 | """Return the description displayed in the console.""" 57 | return "{} v{}".format(Plugin.PLUGIN_NAME, Plugin.PLUGIN_VERSION) 58 | 59 | @staticmethod 60 | def plugin_resource(filename): 61 | """ 62 | Return the absolute path to a plugin resource located within the 63 | plugin's installation folder (should be within idarling/resources). 64 | """ 65 | plugin_path = os.path.abspath(os.path.dirname(__file__)) 66 | return os.path.join(plugin_path, "resources", filename) 67 | 68 | @staticmethod 69 | def user_resource(directory, filename): 70 | """ 71 | Return the absolute path to a resource located in the user directory. 72 | It should be: 73 | * %APPDATA%\\Roaming\\Hex-Rays\\IDA Pro\\plugin\\idarling under Windows 74 | * $HOME/.idapro/plugins/idarling under Linux and MacOS. 75 | """ 76 | user_dir = ida_diskio.get_user_idadir() 77 | plug_dir = os.path.join(user_dir, "plugins") 78 | local_dir = os.path.join(plug_dir, "idarling") 79 | res_dir = os.path.join(local_dir, directory) 80 | if not os.path.exists(res_dir): 81 | os.makedirs(res_dir, 493) # 0755 82 | return os.path.join(res_dir, filename) 83 | 84 | @staticmethod 85 | def default_config(): 86 | """ 87 | Return the default configuration options. This is used to initialize 88 | the configuration file the first time the plugin is launched, and also 89 | when the user is resetting the settings via the dialog. 90 | """ 91 | r, g, b = colorsys.hls_to_rgb(random.random(), 0.5, 1.0) 92 | color = int(b * 255) << 16 | int(g * 255) << 8 | int(r * 255) 93 | return { 94 | "level": logging.INFO, 95 | "servers": [], 96 | "keep": {"cnt": 4, "intvl": 15, "idle": 240}, 97 | "cursors": {"navbar": True, "funcs": True, "disasm": True}, 98 | "user": {"color": color, "name": "unnamed", "notifications": True}, 99 | } 100 | 101 | def __init__(self): 102 | # Check if the plugin is running with IDA terminal 103 | if not ida_kernwin.is_idaq(): 104 | raise RuntimeError("IDArling cannot be used in terminal mode") 105 | 106 | # Load the default configuration 107 | self._config = self.default_config() 108 | # Then setup the default logger 109 | log_path = self.user_resource("logs", "idarling.%s.log" % os.getpid()) 110 | level = self.config["level"] 111 | self._logger = start_logging(log_path, "IDArling.Plugin", level) 112 | 113 | self._core = Core(self) 114 | self._interface = Interface(self) 115 | self._network = Network(self) 116 | 117 | @property 118 | def config(self): 119 | return self._config 120 | 121 | @property 122 | def logger(self): 123 | return self._logger 124 | 125 | @property 126 | def core(self): 127 | return self._core 128 | 129 | @property 130 | def interface(self): 131 | return self._interface 132 | 133 | @property 134 | def network(self): 135 | return self._network 136 | 137 | def init(self): 138 | """ 139 | This method is called when IDA is loading the plugin. It will first 140 | load the configuration file, then initialize all the modules. 141 | """ 142 | try: 143 | self.load_config() 144 | 145 | self._interface.install() 146 | self._network.install() 147 | self._core.install() 148 | except Exception as e: 149 | self._logger.error("Failed to initialize") 150 | self._logger.exception(e) 151 | skip = ida_idaapi.PLUGIN_SKIP 152 | return skip 153 | 154 | self._print_banner() 155 | self._logger.info("Initialized properly") 156 | keep = ida_idaapi.PLUGIN_KEEP 157 | return keep 158 | 159 | def _print_banner(self): 160 | """Print the banner that you see in the console.""" 161 | copyright = "(c) %s" % self.PLUGIN_AUTHORS 162 | self._logger.info("-" * 75) 163 | self._logger.info("%s - %s" % (self.description(), copyright)) 164 | self._logger.info("-" * 75) 165 | 166 | def term(self): 167 | """ 168 | This method is called when IDA is unloading the plugin. It will 169 | terminated all the modules, then save the configuration file. 170 | """ 171 | try: 172 | self._core.uninstall() 173 | self._network.uninstall() 174 | self._interface.uninstall() 175 | 176 | self.save_config() 177 | except Exception as e: 178 | self._logger.error("Failed to terminate properly") 179 | self._logger.exception(e) 180 | return 181 | 182 | self._logger.info("Terminated properly") 183 | 184 | def run(self, _): 185 | """ 186 | This method is called when IDA is running the plugin as a script. 187 | Because IDArling isn't runnable per se, we need to return False. 188 | """ 189 | ida_kernwin.warning("IDArling cannot be run as a script") 190 | return False 191 | 192 | @staticmethod 193 | def unicode_to_str(data): 194 | if isinstance(data, unicode): 195 | return data.encode("utf-8") 196 | if isinstance(data, list): 197 | return [Plugin.unicode_to_str(item) for item in data] 198 | if isinstance(data, dict): 199 | return { 200 | Plugin.unicode_to_str(key): Plugin.unicode_to_str(value) 201 | for key, value in data.iteritems() 202 | } 203 | return data 204 | 205 | def load_config(self): 206 | """ 207 | Load the configuration file. It is a JSON file that contains all the 208 | settings of the plugin. The configured log level is set here. 209 | """ 210 | config_path = self.user_resource("files", "config.json") 211 | if not os.path.isfile(config_path): 212 | return 213 | 214 | with open(config_path, "r") as config_file: 215 | try: 216 | hook = self.unicode_to_str if sys.version_info[0] < 3 else None 217 | self._config.update( 218 | json.loads(config_file.read(), object_hook=hook) 219 | ) 220 | except ValueError: 221 | self._logger.warning("Couldn't load config file") 222 | return 223 | self._logger.setLevel(self._config["level"]) 224 | self._logger.debug("Loaded config: %s" % self._config) 225 | 226 | def save_config(self): 227 | """Save the configuration file.""" 228 | config_path = self.user_resource("files", "config.json") 229 | with open(config_path, "w") as config_file: 230 | config_file.write(json.dumps(self._config)) 231 | self._logger.debug("Saved config: %s" % self._config) 232 | -------------------------------------------------------------------------------- /idarling/resources/clear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/clear.png -------------------------------------------------------------------------------- /idarling/resources/cold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/cold.png -------------------------------------------------------------------------------- /idarling/resources/connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/connected.png -------------------------------------------------------------------------------- /idarling/resources/connecting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/connecting.png -------------------------------------------------------------------------------- /idarling/resources/disconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/disconnected.png -------------------------------------------------------------------------------- /idarling/resources/download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/download.png -------------------------------------------------------------------------------- /idarling/resources/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/empty.png -------------------------------------------------------------------------------- /idarling/resources/hot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/hot.png -------------------------------------------------------------------------------- /idarling/resources/idarling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/idarling.png -------------------------------------------------------------------------------- /idarling/resources/invite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/invite.png -------------------------------------------------------------------------------- /idarling/resources/location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/location.png -------------------------------------------------------------------------------- /idarling/resources/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/settings.png -------------------------------------------------------------------------------- /idarling/resources/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/upload.png -------------------------------------------------------------------------------- /idarling/resources/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/user.png -------------------------------------------------------------------------------- /idarling/resources/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/users.png -------------------------------------------------------------------------------- /idarling/resources/warm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/resources/warm.png -------------------------------------------------------------------------------- /idarling/server.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import argparse 14 | import os 15 | import signal 16 | import sys 17 | import traceback 18 | 19 | from PyQt5.QtCore import QCoreApplication, QTimer 20 | 21 | from .shared.server import Server 22 | from .shared.utils import start_logging 23 | 24 | 25 | class DedicatedServer(Server): 26 | """ 27 | This is the dedicated server. It can be invoked from the command line. It 28 | requires only PyQt5 and should be invoked from Python 3. The dedicated 29 | server should be used when the integrated doesn't fulfil the user's needs. 30 | """ 31 | 32 | def __init__(self, level, parent=None): 33 | # Get the path to the log file 34 | log_dir = os.path.join(os.path.dirname(__file__), "logs") 35 | log_dir = os.path.abspath(log_dir) 36 | if not os.path.exists(log_dir): 37 | os.makedirs(log_dir) 38 | log_path = os.path.join(log_dir, "idarling.%s.log" % os.getpid()) 39 | 40 | logger = start_logging(log_path, "IDArling.Server", level) 41 | Server.__init__(self, logger, parent) 42 | 43 | def server_file(self, filename): 44 | """ 45 | This function returns the absolute path to a server's file. It should 46 | be located within a files/ subdirectory of the current directory. 47 | """ 48 | files_dir = os.path.join(os.path.dirname(__file__), "files") 49 | files_dir = os.path.abspath(files_dir) 50 | if not os.path.exists(files_dir): 51 | os.makedirs(files_dir) 52 | return os.path.join(files_dir, filename) 53 | 54 | 55 | def start(args): 56 | app = QCoreApplication(sys.argv) 57 | sys.excepthook = traceback.print_exception 58 | 59 | server = DedicatedServer(args.level) 60 | server.SNAPSHOT_INTERVAL = args.interval 61 | server.start(args.host, args.port, args.ssl) 62 | 63 | # Allow the use of Ctrl-C to stop the server 64 | def sigint_handler(_, __): 65 | server.stop() 66 | app.exit(0) 67 | 68 | signal.signal(signal.SIGINT, sigint_handler) 69 | 70 | # This timer gives the application a chance to be interrupted every 50 ms 71 | # even if it stuck in a loop or something 72 | def safe_timer(timeout, func, *args, **kwargs): 73 | def timer_event(): 74 | try: 75 | func(*args, **kwargs) 76 | finally: 77 | QTimer.singleShot(timeout, timer_event) 78 | 79 | QTimer.singleShot(timeout, timer_event) 80 | 81 | safe_timer(50, lambda: None) 82 | 83 | return app.exec_() 84 | 85 | 86 | def main(): 87 | parser = argparse.ArgumentParser(add_help=False) 88 | parser.add_argument( 89 | "--help", action="help", help="show this help message and exit" 90 | ) 91 | 92 | # Users can specify the host and port to start the server on 93 | parser.add_argument( 94 | "-h", 95 | "--host", 96 | type=str, 97 | default="127.0.0.1", 98 | help="the hostname to start listening on", 99 | ) 100 | parser.add_argument( 101 | "-p", 102 | "--port", 103 | type=int, 104 | default=31013, 105 | help="the port to start listening on", 106 | ) 107 | 108 | # Users must specify the path to the certificate chain and the 109 | # corresponding private key of the server, or disable SSL altogether. 110 | security = parser.add_mutually_exclusive_group(required=True) 111 | security.add_argument( 112 | "--ssl", 113 | type=str, 114 | nargs=2, 115 | metavar=("fullchain.pem", "privkey.pem"), 116 | help="the certificate and private key files", 117 | ) 118 | security.add_argument( 119 | "--no-ssl", action="store_true", help="disable SSL (not recommended)" 120 | ) 121 | 122 | # Users can also change the logging level if the they want some debug 123 | levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "TRACE"] 124 | parser.add_argument( 125 | "-l", 126 | "--level", 127 | type=str, 128 | choices=levels, 129 | default="INFO", 130 | help="the log level", 131 | ) 132 | 133 | # Interval in ticks between database snapshot 134 | parser.add_argument( 135 | "-i", 136 | "--interval", 137 | type=int, 138 | default=0, 139 | help="database snapshot interval", 140 | ) 141 | 142 | start(parser.parse_args()) 143 | -------------------------------------------------------------------------------- /idarling/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDArlingTeam/IDArling/d15b9b7c8bdeb992c569efcc49adf7642bb82cdf/idarling/shared/__init__.py -------------------------------------------------------------------------------- /idarling/shared/commands.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from .models import Database, Project 14 | from .packets import ( 15 | Command, 16 | Container, 17 | DefaultCommand, 18 | ParentCommand, 19 | Query as IQuery, 20 | Reply as IReply, 21 | Serializable, 22 | ) 23 | 24 | 25 | class ListProjects(ParentCommand): 26 | __command__ = "list_projects" 27 | 28 | class Query(IQuery, DefaultCommand): 29 | pass 30 | 31 | class Reply(IReply, Command): 32 | def __init__(self, query, projects): 33 | super(ListProjects.Reply, self).__init__(query) 34 | self.projects = projects 35 | 36 | def build_command(self, dct): 37 | dct["projects"] = [project.build({}) for project in self.projects] 38 | projects = [] 39 | for project in self.projects: 40 | d = {} 41 | project.build(d) 42 | hash = d["hash"] 43 | d["hash"] = Serializable.decode_bytes(hash) 44 | projects.append(d) 45 | dct["projects"] = projects 46 | 47 | def parse_command(self, dct): 48 | self.projects = [ 49 | Project.new(project) for project in dct["projects"] 50 | ] 51 | 52 | 53 | class ListDatabases(ParentCommand): 54 | __command__ = "list_databases" 55 | 56 | class Query(IQuery, DefaultCommand): 57 | def __init__(self, project): 58 | super(ListDatabases.Query, self).__init__() 59 | self.project = project 60 | 61 | class Reply(IReply, Command): 62 | def __init__(self, query, databases): 63 | super(ListDatabases.Reply, self).__init__(query) 64 | self.databases = databases 65 | 66 | def build_command(self, dct): 67 | dct["databases"] = [ 68 | database.build({}) for database in self.databases 69 | ] 70 | 71 | def parse_command(self, dct): 72 | self.databases = [ 73 | Database.new(database) for database in dct["databases"] 74 | ] 75 | 76 | 77 | class CreateProject(ParentCommand): 78 | __command__ = "create_project" 79 | 80 | class Query(IQuery, Command): 81 | def __init__(self, project): 82 | super(CreateProject.Query, self).__init__() 83 | self.project = project 84 | 85 | def build_command(self, dct): 86 | self.project.build(dct["project"]) 87 | hash = dct["project"]["hash"] 88 | dct["project"]["hash"] = Serializable.decode_bytes(hash) 89 | 90 | def parse_command(self, dct): 91 | hash = dct["project"]["hash"] 92 | dct["project"]["hash"] = Serializable.encode_bytes(hash) 93 | self.project = Project.new(dct["project"]) 94 | 95 | class Reply(IReply, Command): 96 | pass 97 | 98 | 99 | class CreateDatabase(ParentCommand): 100 | __command__ = "create_database" 101 | 102 | class Query(IQuery, Command): 103 | def __init__(self, database): 104 | super(CreateDatabase.Query, self).__init__() 105 | self.database = database 106 | 107 | def build_command(self, dct): 108 | self.database.build(dct["database"]) 109 | 110 | def parse_command(self, dct): 111 | self.database = Database.new(dct["database"]) 112 | 113 | class Reply(IReply, Command): 114 | pass 115 | 116 | 117 | class UpdateFile(ParentCommand): 118 | __command__ = "update_file" 119 | 120 | class Query(IQuery, Container, DefaultCommand): 121 | def __init__(self, project, database): 122 | super(UpdateFile.Query, self).__init__() 123 | self.project = project 124 | self.database = database 125 | 126 | class Reply(IReply, Command): 127 | pass 128 | 129 | 130 | class DownloadFile(ParentCommand): 131 | __command__ = "download_file" 132 | 133 | class Query(IQuery, DefaultCommand): 134 | def __init__(self, project, database): 135 | super(DownloadFile.Query, self).__init__() 136 | self.project = project 137 | self.database = database 138 | 139 | class Reply(IReply, Container, Command): 140 | pass 141 | 142 | 143 | class JoinSession(DefaultCommand): 144 | __command__ = "join_session" 145 | 146 | def __init__(self, project, database, tick, name, color, ea, silent=True): 147 | super(JoinSession, self).__init__() 148 | self.project = project 149 | self.database = database 150 | self.tick = tick 151 | self.name = name 152 | self.color = color 153 | self.ea = ea 154 | self.silent = silent 155 | 156 | 157 | class LeaveSession(DefaultCommand): 158 | __command__ = "leave_session" 159 | 160 | def __init__(self, name, silent=True): 161 | super(LeaveSession, self).__init__() 162 | self.name = name 163 | self.silent = silent 164 | 165 | 166 | class UpdateUserName(DefaultCommand): 167 | __command__ = "update_user_name" 168 | 169 | def __init__(self, old_name, new_name): 170 | super(UpdateUserName, self).__init__() 171 | self.old_name = old_name 172 | self.new_name = new_name 173 | 174 | 175 | class UpdateUserColor(DefaultCommand): 176 | __command__ = "update_user_color" 177 | 178 | def __init__(self, name, old_color, new_color): 179 | super(UpdateUserColor, self).__init__() 180 | self.name = name 181 | self.old_color = old_color 182 | self.new_color = new_color 183 | 184 | 185 | class UpdateLocation(DefaultCommand): 186 | __command__ = "update_location" 187 | 188 | def __init__(self, name, ea, color): 189 | super(UpdateLocation, self).__init__() 190 | self.name = name 191 | self.ea = ea 192 | self.color = color 193 | 194 | 195 | class InviteToLocation(DefaultCommand): 196 | __command__ = "invite_to_location" 197 | 198 | def __init__(self, name, loc): 199 | super(InviteToLocation, self).__init__() 200 | self.name = name 201 | self.loc = loc 202 | -------------------------------------------------------------------------------- /idarling/shared/discovery.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import platform 14 | import socket 15 | import time 16 | 17 | from PyQt5.QtCore import QObject, QSocketNotifier, QTimer 18 | 19 | 20 | DISCOVERY_REQUEST = "IDARLING_DISCOVERY_REQUEST" 21 | DISCOVERY_REPLY = "IDARLING_DISCOVERY_REPLY" 22 | 23 | 24 | class ClientsDiscovery(QObject): 25 | """ 26 | This class is used by the server to discover client on the local network. 27 | It uses an UDP socket broadcasting the server hostname and port on the 28 | port 31013. A client will reply back with a simple message. 29 | """ 30 | 31 | def __init__(self, logger, parent=None): 32 | super(ClientsDiscovery, self).__init__(parent) 33 | self._logger = logger 34 | self._info = None 35 | 36 | self._socket = None 37 | self._read_notifier = None 38 | self._started = False 39 | 40 | # Timer signaling that it's time to broadcast 41 | self._timer = QTimer() 42 | self._timer.setInterval(10000) 43 | self._timer.timeout.connect(self._send_request) 44 | 45 | def start(self, host, port, ssl): 46 | """Start the discovery process and broadcast the given information.""" 47 | self._logger.debug("Starting clients discovery") 48 | self._info = "%s %d %s" % (host, port, ssl) 49 | # Create a datagram socket capable of broadcasting 50 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 51 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 52 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 53 | self._socket.settimeout(0) # No timeout 54 | self._socket.setblocking(0) # No blocking 55 | 56 | self._read_notifier = QSocketNotifier( 57 | self._socket.fileno(), QSocketNotifier.Read, self 58 | ) 59 | self._read_notifier.activated.connect(self._notify_read) 60 | self._read_notifier.setEnabled(True) 61 | self._started = True 62 | self._timer.start() 63 | self._send_request() 64 | 65 | def stop(self): 66 | """Stop the discovery process.""" 67 | self._logger.debug("Stopping clients discovery") 68 | self._read_notifier.setEnabled(False) 69 | try: 70 | self._socket.close() 71 | except socket.error: 72 | pass 73 | self._socket = None 74 | self._started = False 75 | self._timer.stop() 76 | 77 | def _send_request(self): 78 | """This function sends to discovery request packets.""" 79 | self._logger.trace("Sending discovery request") 80 | request = DISCOVERY_REQUEST + " " + self._info 81 | request = request.encode("utf-8") 82 | while len(request): 83 | try: 84 | self._socket.setblocking(0) 85 | sent = self._socket.sendto( 86 | request, 0, ("", 31013) 87 | ) 88 | request = request[sent:] 89 | except socket.error as e: 90 | self._logger.warning( 91 | "Couldn't send discovery request: {}".format(e) 92 | ) 93 | # Force return, otherwise the while loop will halt IDA 94 | # This is a temporary fix, and it's gonna yield the above 95 | # warning every every n seconds.. 96 | return 97 | 98 | def _notify_read(self): 99 | """This function is called when a discovery reply is received.""" 100 | response, address = self._socket.recvfrom(4096) 101 | response = response.decode("utf-8") 102 | if response == DISCOVERY_REPLY: 103 | self._logger.trace("Received discovery reply from %s:%d" % address) 104 | 105 | 106 | class ServersDiscovery(QObject): 107 | """ 108 | This class is used by the client to discover servers on the local network. 109 | It uses an UDP socket listening on port 31013 to received the request 110 | broadcasted by the server. Discovery server will be shown in the UI. 111 | """ 112 | 113 | def __init__(self, logger, parent=None): 114 | super(ServersDiscovery, self).__init__(parent) 115 | self._logger = logger 116 | self._servers = [] 117 | 118 | self._socket = None 119 | self._read_notifier = None 120 | self._started = False 121 | 122 | @property 123 | def servers(self): 124 | return self._servers 125 | 126 | def start(self): 127 | """Start the discovery process and listen for discovery requests.""" 128 | self._logger.debug("Starting servers discovery") 129 | 130 | # Create a datagram socket bound on port 31013 131 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 132 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 133 | if platform.system() == "Darwin": 134 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 135 | self._socket.bind(("", 31013)) 136 | self._socket.settimeout(0) 137 | self._socket.setblocking(0) 138 | 139 | self._read_notifier = QSocketNotifier( 140 | self._socket.fileno(), QSocketNotifier.Read, self 141 | ) 142 | self._read_notifier.activated.connect(self._notify_read) 143 | self._read_notifier.setEnabled(True) 144 | self._started = True 145 | 146 | def stop(self): 147 | """Stop the discovery process.""" 148 | self._logger.debug("Stopping servers discovery") 149 | self._read_notifier.setEnabled(False) 150 | try: 151 | self._socket.close() 152 | except socket.errno: 153 | pass 154 | self._socket = None 155 | self._started = False 156 | 157 | def _notify_read(self): 158 | """This function is called when a discovery request is received.""" 159 | request, address = self._socket.recvfrom(4096) 160 | request = request.decode("utf-8") 161 | if request.startswith(DISCOVERY_REQUEST): 162 | self._logger.trace( 163 | "Received discovery request from %s:%d" % address 164 | ) 165 | # Get the server information 166 | _, host, port, ssl = request.split() 167 | server = {"host": host, "port": int(port), "no_ssl": ssl != "True"} 168 | 169 | # Remove the old value 170 | self._servers = [(s, t) for (s, t) in self._servers if s != server] 171 | # Append the new value 172 | self._servers.append((server, time.time())) 173 | 174 | self._logger.trace("Sending discovery reply to %s:%d" % address) 175 | # Reply to the discovery request 176 | reply = DISCOVERY_REPLY 177 | reply = reply.encode("utf-8") 178 | try: 179 | self._socket.sendto(reply, address) 180 | except socket.error: 181 | self._logger.warning("Couldn't send discovery reply") 182 | -------------------------------------------------------------------------------- /idarling/shared/models.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from .packets import Default 14 | 15 | 16 | class Model(Default): 17 | """ 18 | A model is an object can be serialized and sent over the network, but that 19 | can be saved into the SQL database used by the server. 20 | """ 21 | 22 | def build(self, dct): 23 | self.build_default(dct) 24 | return dct 25 | 26 | def parse(self, dct): 27 | self.parse_default(dct) 28 | return self 29 | 30 | def __repr__(self): 31 | """ 32 | Return a textual representation of the object. It will mainly be used 33 | for pretty-printing into the console. 34 | """ 35 | attrs = u", ".join( 36 | [ 37 | u"{}={}".format(key, val) 38 | for key, val in Default.attrs(self.__dict__).items() 39 | ] 40 | ) 41 | return u"{}({})".format(self.__class__.__name__, attrs) 42 | 43 | 44 | class Project(Model): 45 | """ 46 | IDBs are organized into projects and databases. A project regroups 47 | multiples revisions of an IDB. It has a name, the hash of the input file, 48 | the path to the input file, the type of the input file and the date of the 49 | database creation. 50 | """ 51 | 52 | def __init__(self, name, hash, file, type, date): 53 | super(Project, self).__init__() 54 | self.name = name 55 | self.hash = hash 56 | self.file = file 57 | self.type = type 58 | self.date = date 59 | 60 | 61 | class Database(Model): 62 | """ 63 | IDBs are organized into projects and databases. A database corresponds to 64 | a revision of an IDB. It has a project, a name, a date of creation, and a 65 | current tick (events) count. 66 | """ 67 | 68 | def __init__(self, project, name, date, tick=0): 69 | super(Database, self).__init__() 70 | self.project = project 71 | self.name = name 72 | self.date = date 73 | self.tick = tick 74 | -------------------------------------------------------------------------------- /idarling/shared/packets.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import collections 14 | import itertools 15 | import sys 16 | 17 | 18 | if sys.version_info > (3,): 19 | unicode = str 20 | 21 | 22 | def with_metaclass(meta, *bases): 23 | """Python 2 and 3 compatible way to add a meta-class.""" 24 | 25 | class Metaclass(type): 26 | def __new__(cls, name, this_bases, d): 27 | return meta(name, bases, d) 28 | 29 | @classmethod 30 | def __prepare__(cls, name, _): 31 | return meta.__prepare__(name, bases) 32 | 33 | return type.__new__(Metaclass, "temporary_class", (), {}) 34 | 35 | 36 | class Serializable(object): 37 | """ 38 | This base class for an object than can be serialized. More specifically, 39 | such objects can be read from and written into a Python dictionary. 40 | """ 41 | 42 | @classmethod 43 | def new(cls, dct): 44 | """Creates a new instance of the object.""" 45 | obj = cls.__new__(cls) 46 | object.__init__(obj) 47 | obj.parse(dct) 48 | return obj 49 | 50 | def build(self, dct): 51 | """Writes the object into the dictionary.""" 52 | pass 53 | 54 | def parse(self, dct): 55 | """Reads the object from the dictionary.""" 56 | pass 57 | 58 | @staticmethod 59 | def decode_bytes(s): 60 | """Encodes a unicode string into raw bytes.""" 61 | if isinstance(s, unicode): 62 | return s 63 | return s.decode("raw_unicode_escape") 64 | 65 | @staticmethod 66 | def encode_bytes(s): 67 | """Decodes raw bytes into a unicode string.""" 68 | if isinstance(s, bytes): 69 | return s 70 | return s.encode("raw_unicode_escape") 71 | 72 | 73 | class Default(Serializable): 74 | """This object will be serialized using its attributes dictionary.""" 75 | 76 | @staticmethod 77 | def attrs(dct): 78 | """ 79 | Get a filtered version of an attributes dictionary. This method 80 | currently simply removes the private attributes of the object. 81 | """ 82 | return { 83 | key: val for key, val in dct.items() if not key.startswith("_") 84 | } 85 | 86 | def build_default(self, dct): 87 | """Write the object to the dictionary.""" 88 | dct.update(Default.attrs(self.__dict__)) 89 | 90 | def parse_default(self, dct): 91 | """Read the object from the dictionary.""" 92 | self.__dict__.update(Default.attrs(dct)) 93 | 94 | 95 | class PacketFactory(type): 96 | """ 97 | A metaclass that is used to register new packet classes as they are being 98 | defined, and instantiate new packets from their name when necessary. 99 | """ 100 | 101 | _PACKETS = {} 102 | 103 | @staticmethod 104 | def __new__(mcs, name, bases, attrs): 105 | """Register a new packet class into the factory.""" 106 | cls = super(PacketFactory, mcs).__new__(mcs, name, bases, attrs) 107 | if ( 108 | cls.__type__ is not None 109 | and cls.__type__ not in PacketFactory._PACKETS 110 | ): 111 | PacketFactory._PACKETS[cls.__type__] = cls 112 | return cls 113 | 114 | @classmethod 115 | def get_class(mcs, dct, server=False): # noqa: N804 116 | """ 117 | Instantiate the packet corresponding to the serialized dictionary. It 118 | will check if the packet type is registered, the deferred the 119 | request to the specialized packet factory. 120 | """ 121 | cls = PacketFactory._PACKETS[dct["type"]] 122 | if type(cls) != mcs: 123 | cls = type(cls).get_class(dct, server) 124 | return cls 125 | 126 | 127 | class Packet(with_metaclass(PacketFactory, Serializable)): 128 | """ 129 | The base class for every packet received. Currently, the packet can 130 | only be of two kinds: either it is an event or a command. 131 | """ 132 | 133 | __type__ = None 134 | 135 | def __init__(self): 136 | super(Packet, self).__init__() 137 | assert self.__type__ is not None, "__type__ not implemented" 138 | 139 | @staticmethod 140 | def parse_packet(dct, server=False): 141 | """Parse the packet from a dictionary.""" 142 | cls = PacketFactory.get_class(dct, server) 143 | packet = cls.new(dct) 144 | if isinstance(packet, Reply): 145 | packet.trigger_initback() 146 | return packet 147 | 148 | def build_packet(self): 149 | """Build the packet into a dictionary.""" 150 | dct = collections.defaultdict(collections.defaultdict) 151 | self.build(dct) 152 | return dct 153 | 154 | def __repr__(self): 155 | """ 156 | Return a textual representation of a packet. Currently, it is only 157 | used to pretty-print the packet contents into the console. 158 | """ 159 | name = self.__class__.__name__ 160 | if isinstance(self, Query) or isinstance(self, Reply): 161 | name = self.__parent__.__name__ + "." + name 162 | attrs = [ 163 | u"{}={}".format(k, v) 164 | for k, v in Default.attrs(self.__dict__).items() 165 | ] 166 | return u"{}({})".format(name, u", ".join(attrs)) 167 | 168 | 169 | class PacketDeferred(object): 170 | """ 171 | An Twisted-like deferred object that supports a standard callback, as well 172 | as a new callback triggered when the expected packet is being instantiated. 173 | """ 174 | 175 | def __init__(self): 176 | super(PacketDeferred, self).__init__() 177 | self._errback = None 178 | 179 | self._callback = None 180 | self._callresult = None 181 | self._called = False 182 | 183 | self._initback = None 184 | self._initresult = None 185 | self._inited = False 186 | 187 | def add_callback(self, callback): 188 | """Register a callback for this deferred.""" 189 | self._callback = callback 190 | if self._called: 191 | self._run_callback() 192 | return self 193 | 194 | def add_errback(self, errback): 195 | """Register an errback for this deferred.""" 196 | self._errback = errback 197 | return self 198 | 199 | def add_initback(self, initback): 200 | """Register an initback for this deferred.""" 201 | self._initback = initback 202 | if self._inited: 203 | self._run_initback() 204 | return self 205 | 206 | def callback(self, result): 207 | """Trigger the callback function.""" 208 | if self._called: 209 | raise RuntimeError("Callback already triggered") 210 | self._called = True 211 | self._callresult = result 212 | self._run_callback() 213 | 214 | def initback(self, result): 215 | """Trigger the initback function.""" 216 | if self._inited: 217 | raise RuntimeError("Initback already triggered") 218 | self._inited = True 219 | self._initresult = result 220 | self._run_initback() 221 | 222 | def _run_callback(self): 223 | """Internal method that calls the callback/errback function.""" 224 | if self._callback: 225 | try: 226 | self._callback(self._callresult) 227 | except Exception as e: 228 | self._errback(e) 229 | 230 | def _run_initback(self): 231 | """Internal method that call the initback/errback function.""" 232 | if self._initback: 233 | try: 234 | self._initback(self._initresult) 235 | except Exception as e: 236 | self._errback(e) 237 | 238 | 239 | class EventFactory(PacketFactory): 240 | """A packet factory specialized for event packets.""" 241 | 242 | _EVENTS = {} 243 | 244 | @staticmethod 245 | def __new__(mcs, name, bases, attrs): 246 | cls = super(EventFactory, mcs).__new__(mcs, name, bases, attrs) 247 | if ( 248 | cls.__event__ is not None 249 | and cls.__event__ not in EventFactory._EVENTS 250 | ): 251 | EventFactory._EVENTS[cls.__event__] = cls 252 | return cls 253 | 254 | @classmethod 255 | def get_class(mcs, dct, server=False): # noqa: N804 256 | if server: # Server only knows about DefaultEvent 257 | return DefaultEvent 258 | 259 | cls = EventFactory._EVENTS[dct["event_type"]] 260 | if type(cls) != mcs: 261 | cls = type(cls).get_class(dct, server) 262 | return cls 263 | 264 | 265 | class Event(with_metaclass(EventFactory, Packet)): 266 | """Base class for all events. They have a subtype and a tick count.""" 267 | 268 | __type__ = "event" 269 | __event__ = None 270 | 271 | def __init__(self): 272 | super(Event, self).__init__() 273 | assert self.__event__ is not None, "__event__ not implemented" 274 | self._tick = 0 275 | 276 | @property 277 | def tick(self): 278 | """Get the tick count.""" 279 | return self._tick 280 | 281 | @tick.setter 282 | def tick(self, tick): 283 | """Set the tick count.""" 284 | self._tick = tick 285 | 286 | def build(self, dct): 287 | dct["type"] = self.__type__ 288 | dct["event_type"] = self.__event__ 289 | dct["tick"] = self._tick 290 | self.build_event(dct) 291 | return dct 292 | 293 | def parse(self, dct): 294 | self._tick = dct.pop("tick") 295 | self.parse_event(dct) 296 | return self 297 | 298 | def build_event(self, dct): 299 | """Build the event into a dictionary.""" 300 | pass 301 | 302 | def parse_event(self, dct): 303 | """Parse the event from a dictionary.""" 304 | pass 305 | 306 | 307 | class DefaultEvent(Default, Event): 308 | """ 309 | This is a class that should be subclassed for events that can be serialized 310 | from their attributes (which should be almost all of them). 311 | """ 312 | 313 | def build_event(self, dct): 314 | self.build_default(dct) 315 | 316 | def parse_event(self, dct): 317 | self.parse_default(dct) 318 | 319 | 320 | class CommandFactory(PacketFactory): 321 | """A packet factory specialized for commands packets.""" 322 | 323 | _COMMANDS = {} 324 | 325 | @staticmethod 326 | def __new__(mcs, name, bases, attrs): 327 | cls = super(CommandFactory, mcs).__new__(mcs, name, bases, attrs) 328 | if ( 329 | cls.__command__ is not None 330 | and cls.__command__ not in CommandFactory._COMMANDS 331 | ): 332 | # Does this command have a query and a reply 333 | if issubclass(cls, ParentCommand): 334 | # Register the query 335 | cls.Query.__parent__ = cls 336 | cls.Query.__command__ = cls.__command__ + "_query" 337 | CommandFactory._COMMANDS[cls.Query.__command__] = cls.Query 338 | 339 | # Register the reply 340 | cls.Reply.__parent__ = cls 341 | cls.Reply.__command__ = cls.__command__ + "_reply" 342 | CommandFactory._COMMANDS[cls.Reply.__command__] = cls.Reply 343 | else: 344 | CommandFactory._COMMANDS[cls.__command__] = cls 345 | return cls 346 | 347 | @classmethod 348 | def get_class(mcs, dct, server=False): # noqa: N804 349 | cls = CommandFactory._COMMANDS[dct["command_type"]] 350 | if type(cls) != mcs: 351 | cls = type(cls).get_class(dct, server) 352 | return cls 353 | 354 | 355 | class Command(with_metaclass(CommandFactory, Packet)): 356 | """Base class for all commands. Commands have a subtype.""" 357 | 358 | __type__ = "command" 359 | __command__ = None 360 | 361 | def __init__(self): 362 | super(Command, self).__init__() 363 | assert self.__command__ is not None, "__command__ not implemented" 364 | 365 | def build(self, dct): 366 | dct["type"] = self.__type__ 367 | dct["command_type"] = self.__command__ 368 | self.build_command(dct) 369 | return dct 370 | 371 | def parse(self, dct): 372 | self.parse_command(dct) 373 | return self 374 | 375 | def build_command(self, dct): 376 | """Build a command into a dictionary.""" 377 | pass 378 | 379 | def parse_command(self, dct): 380 | """Parse a command from a dictionary.""" 381 | pass 382 | 383 | 384 | class DefaultCommand(Default, Command): 385 | """ 386 | This is a class that should be subclassed for events that can be serialized 387 | from their attributes (which is way rarer than for events). 388 | """ 389 | 390 | def build_command(self, dct): 391 | self.build_default(dct) 392 | 393 | def parse_command(self, dct): 394 | self.parse_default(dct) 395 | 396 | 397 | class ParentCommand(Command): 398 | """ 399 | This class is used to define a command that expects an answer. Basically, 400 | it should subclass this class, and define two instance attributes Query and 401 | Reply that should themselves subclass packets.Query and packets.Reply. 402 | """ 403 | 404 | __callbacks__ = {} 405 | Query, Reply = None, None 406 | 407 | 408 | class Query(Packet): 409 | """A query is a packet sent that will expect to received a reply.""" 410 | 411 | __parent__ = None 412 | 413 | _NEXT_ID = itertools.count() 414 | 415 | def __init__(self): 416 | super(Query, self).__init__() 417 | self._id = next(Query._NEXT_ID) 418 | 419 | @property 420 | def id(self): 421 | """Get the query identifier.""" 422 | return self._id 423 | 424 | def build(self, dct): 425 | super(Query, self).build(dct) 426 | dct["__id__"] = self._id 427 | return dct 428 | 429 | def parse(self, dct): 430 | super(Query, self).parse(dct) 431 | self._id = dct["__id__"] 432 | return self 433 | 434 | def register_callback(self, d): 435 | """Register a callback triggered when the answer is received.""" 436 | self.__parent__.__callbacks__[self._id] = d 437 | 438 | 439 | class Reply(Packet): 440 | """A reply is a packet sent when a query packet is received.""" 441 | 442 | __parent__ = None 443 | 444 | def __init__(self, query): 445 | super(Reply, self).__init__() 446 | self._id = query.id 447 | 448 | @property 449 | def id(self): 450 | """Get the query identifier.""" 451 | return self._id 452 | 453 | def build(self, dct): 454 | super(Reply, self).build(dct) 455 | dct["__id__"] = self._id 456 | return dct 457 | 458 | def parse(self, dct): 459 | super(Reply, self).parse(dct) 460 | self._id = dct["__id__"] 461 | return self 462 | 463 | def trigger_callback(self): 464 | """Trigger the finalization callback of the query.""" 465 | d = self.__parent__.__callbacks__[self._id] 466 | d.callback(self) 467 | del self.__parent__.__callbacks__[self._id] 468 | 469 | def trigger_initback(self): 470 | """Trigger the initialization callback of the query.""" 471 | d = self.__parent__.__callbacks__[self._id] 472 | d.initback(self) 473 | 474 | 475 | class Container(Command): 476 | """ 477 | Containers are a special kind of commands that will contain some raw data. 478 | This is useful for exchanging files as they don't have to be encoded. 479 | """ 480 | 481 | @staticmethod 482 | def __new__(cls, *args, **kwargs): 483 | self = super(Container, cls).__new__(cls) 484 | self._upback = None 485 | self._downback = None 486 | return self 487 | 488 | def __init__(self): 489 | super(Container, self).__init__() 490 | self._size = 0 491 | self._content = None 492 | self._upback = None 493 | self._downback = None 494 | 495 | @property 496 | def content(self): 497 | """Get the raw content.""" 498 | return self._content 499 | 500 | @content.setter 501 | def content(self, content): 502 | """Set the raw content.""" 503 | self._content = content 504 | self._size = len(content) 505 | 506 | @property 507 | def size(self): 508 | """Get the content size.""" 509 | return self._size 510 | 511 | @size.setter 512 | def size(self, size): 513 | """Set the content size.""" 514 | self._size = size 515 | 516 | @property 517 | def upback(self): 518 | """Get the upload callback triggered when some data is sent.""" 519 | return self._upback 520 | 521 | @upback.setter 522 | def upback(self, upback): 523 | """Set the upload callback triggered when some data is sent.""" 524 | self._upback = upback 525 | 526 | @property 527 | def downback(self): 528 | """Get the download callback triggered when some data is received.""" 529 | return self._downback 530 | 531 | @downback.setter 532 | def downback(self, downback): 533 | """Set the download callback triggered when some data is received.""" 534 | self._downback = downback 535 | 536 | def build(self, dct): 537 | super(Container, self).build(dct) 538 | dct["__size__"] = len(self._content) 539 | return dct 540 | 541 | def parse(self, dct): 542 | self._size = dct["__size__"] 543 | super(Container, self).parse(dct) 544 | return self 545 | -------------------------------------------------------------------------------- /idarling/shared/server.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import logging 14 | import os 15 | import socket 16 | import ssl 17 | 18 | from .commands import ( 19 | CreateDatabase, 20 | CreateProject, 21 | DownloadFile, 22 | InviteToLocation, 23 | JoinSession, 24 | LeaveSession, 25 | ListDatabases, 26 | ListProjects, 27 | UpdateFile, 28 | UpdateLocation, 29 | UpdateUserColor, 30 | UpdateUserName, 31 | ) 32 | from .discovery import ClientsDiscovery 33 | from .packets import Command, Event 34 | from .sockets import ClientSocket, ServerSocket 35 | from .storage import Storage 36 | 37 | 38 | class ServerClient(ClientSocket): 39 | """ 40 | This class represents a client socket for the server. It implements all the 41 | handlers for the packet the client is susceptible to send. 42 | """ 43 | 44 | def __init__(self, logger, parent=None): 45 | ClientSocket.__init__(self, logger, parent) 46 | self._project = None 47 | self._database = None 48 | self._name = None 49 | self._color = None 50 | self._ea = None 51 | self._handlers = {} 52 | 53 | @property 54 | def project(self): 55 | return self._project 56 | 57 | @property 58 | def database(self): 59 | return self._database 60 | 61 | @property 62 | def name(self): 63 | return self._name 64 | 65 | @property 66 | def color(self): 67 | return self._color 68 | 69 | @property 70 | def ea(self): 71 | return self._ea 72 | 73 | def wrap_socket(self, sock): 74 | ClientSocket.wrap_socket(self, sock) 75 | 76 | # Setup command handlers 77 | self._handlers = { 78 | ListProjects.Query: self._handle_list_projects, 79 | ListDatabases.Query: self._handle_list_databases, 80 | CreateProject.Query: self._handle_create_project, 81 | CreateDatabase.Query: self._handle_create_database, 82 | UpdateFile.Query: self._handle_upload_file, 83 | DownloadFile.Query: self._handle_download_file, 84 | JoinSession: self._handle_join_session, 85 | LeaveSession: self._handle_leave_session, 86 | UpdateLocation: self._handle_update_location, 87 | InviteToLocation: self._handle_invite_to_location, 88 | UpdateUserName: self._handle_update_user_name, 89 | UpdateUserColor: self._handle_update_user_color, 90 | } 91 | 92 | # Add host and port as a prefix to our logger 93 | prefix = "%s:%d" % sock.getpeername() 94 | 95 | class CustomAdapter(logging.LoggerAdapter): 96 | def process(self, msg, kwargs): 97 | return "(%s) %s" % (prefix, msg), kwargs 98 | 99 | self._logger = CustomAdapter(self._logger, {}) 100 | self._logger.info("Connected") 101 | 102 | def disconnect(self, err=None, notify=True): 103 | # Notify other users that we disconnected 104 | self.parent().reject(self) 105 | if self._project and self._database and notify: 106 | self.parent().forward_users(self, LeaveSession(self.name, False)) 107 | ClientSocket.disconnect(self, err) 108 | self._logger.info("Disconnected") 109 | 110 | def recv_packet(self, packet): 111 | if isinstance(packet, Command): 112 | # Call the corresponding handler 113 | self._handlers[packet.__class__](packet) 114 | 115 | elif isinstance(packet, Event): 116 | if not self._project or not self._database: 117 | self._logger.warning( 118 | "Received a packet from an unsubscribed client" 119 | ) 120 | return True 121 | 122 | # Check for de-synchronization 123 | tick = self.parent().storage.last_tick( 124 | self._project, self._database 125 | ) 126 | if tick >= packet.tick: 127 | self._logger.warning("De-synchronization detected!") 128 | packet.tick = tick + 1 129 | 130 | # Save the event into the database 131 | self.parent().storage.insert_event(self, packet) 132 | # Forward the event to the other users 133 | self.parent().forward_users(self, packet) 134 | 135 | # Ask for a snapshot of the database if needed 136 | interval = self.parent().SNAPSHOT_INTERVAL 137 | if packet.tick and interval and packet.tick % interval == 0: 138 | 139 | def file_downloaded(reply): 140 | file_name = "%s_%s.idb" % (self._project, self._database) 141 | file_path = self.parent().server_file(file_name) 142 | 143 | # Write the file to disk 144 | with open(file_path, "wb") as output_file: 145 | output_file.write(reply.content) 146 | self._logger.info("Auto-saved file %s" % file_name) 147 | 148 | d = self.send_packet( 149 | DownloadFile.Query(self._project, self._database) 150 | ) 151 | d.add_callback(file_downloaded) 152 | d.add_errback(self._logger.exception) 153 | else: 154 | return False 155 | return True 156 | 157 | def _handle_list_projects(self, query): 158 | projects = self.parent().storage.select_projects() 159 | self.send_packet(ListProjects.Reply(query, projects)) 160 | 161 | def _handle_list_databases(self, query): 162 | databases = self.parent().storage.select_databases(query.project) 163 | for database in databases: 164 | database_info = database.project, database.name 165 | file_name = "%s_%s.idb" % database_info 166 | file_path = self.parent().server_file(file_name) 167 | if os.path.isfile(file_path): 168 | database.tick = self.parent().storage.last_tick(*database_info) 169 | else: 170 | database.tick = -1 171 | self.send_packet(ListDatabases.Reply(query, databases)) 172 | 173 | def _handle_create_project(self, query): 174 | self.parent().storage.insert_project(query.project) 175 | self.send_packet(CreateProject.Reply(query)) 176 | 177 | def _handle_create_database(self, query): 178 | self.parent().storage.insert_database(query.database) 179 | self.send_packet(CreateDatabase.Reply(query)) 180 | 181 | def _handle_upload_file(self, query): 182 | database = self.parent().storage.select_database( 183 | query.project, query.database 184 | ) 185 | file_name = "%s_%s.idb" % (database.project, database.name) 186 | file_path = self.parent().server_file(file_name) 187 | 188 | # Write the file received to disk 189 | with open(file_path, "wb") as output_file: 190 | output_file.write(query.content) 191 | self._logger.info("Saved file %s" % file_name) 192 | self.send_packet(UpdateFile.Reply(query)) 193 | 194 | def _handle_download_file(self, query): 195 | database = self.parent().storage.select_database( 196 | query.project, query.database 197 | ) 198 | file_name = "%s_%s.idb" % (database.project, database.name) 199 | file_path = self.parent().server_file(file_name) 200 | 201 | # Read file from disk and sent it 202 | reply = DownloadFile.Reply(query) 203 | with open(file_path, "rb") as input_file: 204 | reply.content = input_file.read() 205 | self._logger.info("Loaded file %s" % file_name) 206 | self.send_packet(reply) 207 | 208 | def _handle_join_session(self, packet): 209 | self._project = packet.project 210 | self._database = packet.database 211 | self._name = packet.name 212 | self._color = packet.color 213 | self._ea = packet.ea 214 | 215 | # Inform the other users that we joined 216 | packet.silent = False 217 | self.parent().forward_users(self, packet) 218 | 219 | # Inform ourselves about the other users 220 | for user in self.parent().get_users(self): 221 | self.send_packet( 222 | JoinSession( 223 | packet.project, 224 | packet.database, 225 | packet.tick, 226 | user.name, 227 | user.color, 228 | user.ea, 229 | ) 230 | ) 231 | 232 | # Send all missed events 233 | events = self.parent().storage.select_events( 234 | self._project, self._database, packet.tick 235 | ) 236 | self._logger.debug("Sending %d missed events" % len(events)) 237 | for event in events: 238 | self.send_packet(event) 239 | 240 | def _handle_leave_session(self, packet): 241 | # Inform others users that we are leaving 242 | packet.silent = False 243 | self.parent().forward_users(self, packet) 244 | 245 | # Inform ourselves that the other users leaved 246 | for user in self.parent().get_users(self): 247 | self.send_packet(LeaveSession(user.name)) 248 | 249 | self._project = None 250 | self._database = None 251 | self._name = None 252 | self._color = None 253 | 254 | def _handle_update_location(self, packet): 255 | self.parent().forward_users(self, packet) 256 | 257 | def _handle_invite_to_location(self, packet): 258 | def matches(other): 259 | return other.name == packet.name or packet.name == "everyone" 260 | 261 | packet.name = self._name 262 | self.parent().forward_users(self, packet, matches) 263 | 264 | def _handle_update_user_name(self, packet): 265 | # FXIME: ensure the name isn't already taken 266 | self._name = packet.new_name 267 | self.parent().forward_users(self, packet) 268 | 269 | def _handle_update_user_color(self, packet): 270 | self.parent().forward_users(self, packet) 271 | 272 | 273 | class Server(ServerSocket): 274 | """ 275 | This class represents a server socket for the server. It is used by both 276 | the integrated and dedicated server implementations. It doesn't do much. 277 | """ 278 | 279 | SNAPSHOT_INTERVAL = 0 # ticks 280 | 281 | def __init__(self, logger, parent=None): 282 | ServerSocket.__init__(self, logger, parent) 283 | self._ssl = None 284 | self._clients = [] 285 | 286 | # Initialize the storage 287 | self._storage = Storage(self.server_file("database.db")) 288 | self._storage.initialize() 289 | 290 | self._discovery = ClientsDiscovery(logger) 291 | 292 | @property 293 | def storage(self): 294 | return self._storage 295 | 296 | @property 297 | def host(self): 298 | return self._socket.getsockname()[0] 299 | 300 | @property 301 | def port(self): 302 | return self._socket.getsockname()[1] 303 | 304 | def start(self, host, port=0, ssl_=None): 305 | """Starts the server on the specified host and port.""" 306 | self._logger.info("Starting the server on %s:%d" % (host, port)) 307 | 308 | # Load the system certificate chain 309 | self._ssl = ssl_ 310 | if self._ssl: 311 | cert, key = self._ssl 312 | self._ssl = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 313 | self._ssl.load_cert_chain(certfile=cert, keyfile=key) 314 | 315 | # Create, bind and set the socket options 316 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 317 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 318 | try: 319 | sock.bind((host, port)) 320 | except socket.error as e: 321 | self._logger.warning("Could not start the server") 322 | self._logger.exception(e) 323 | return False 324 | sock.settimeout(0) # No timeout 325 | sock.setblocking(0) # No blocking 326 | sock.listen(5) 327 | self.connect(sock) 328 | 329 | # Start discovering clients 330 | host, port = sock.getsockname() 331 | self._discovery.start(host, port, self._ssl) 332 | return True 333 | 334 | def stop(self): 335 | """Terminates all the connections and stops the server.""" 336 | self._logger.info("Stopping the server") 337 | self._discovery.stop() 338 | # Disconnect all clients 339 | for client in list(self._clients): 340 | client.disconnect(notify=False) 341 | self.disconnect() 342 | return True 343 | 344 | def _accept(self, sock): 345 | """Called when an user connects.""" 346 | client = ServerClient(self._logger, self) 347 | 348 | if self._ssl: 349 | # Wrap the socket in an SSL tunnel 350 | sock = self._ssl.wrap_socket( 351 | sock, server_side=True, do_handshake_on_connect=False 352 | ) 353 | 354 | sock.settimeout(0) # No timeout 355 | sock.setblocking(0) # No blocking 356 | client.wrap_socket(sock) 357 | self._clients.append(client) 358 | 359 | def reject(self, client): 360 | """Called when a user disconnects.""" 361 | self._clients.remove(client) 362 | 363 | def get_users(self, client, matches=None): 364 | """Get the other users on the same database.""" 365 | users = [] 366 | for user in self._clients: 367 | if ( 368 | user.project != client.project 369 | or user.database != client.database 370 | ): 371 | continue 372 | if user == client or (matches and not matches(user)): 373 | continue 374 | users.append(user) 375 | return users 376 | 377 | def forward_users(self, client, packet, matches=None): 378 | """Sends the packet to the other users on the same database.""" 379 | for user in self.get_users(client, matches): 380 | user.send_packet(packet) 381 | 382 | def server_file(self, filename): 383 | """Get the absolute path of a local resource.""" 384 | raise NotImplementedError("server_file() not implemented") 385 | -------------------------------------------------------------------------------- /idarling/shared/sockets.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import collections 14 | import errno 15 | import json 16 | import os 17 | import socket 18 | import ssl 19 | import sys 20 | 21 | from PyQt5.QtCore import QCoreApplication, QEvent, QObject, QSocketNotifier 22 | 23 | from .packets import Container, Packet, PacketDeferred, Query, Reply 24 | 25 | 26 | class PacketEvent(QEvent): 27 | """ 28 | This Qt event is fired when a new packet is received by the client, 29 | urging it to go check the incoming messages queue. 30 | """ 31 | 32 | EVENT_TYPE = QEvent.Type(QEvent.registerEventType()) 33 | 34 | def __init__(self): 35 | super(PacketEvent, self).__init__(PacketEvent.EVENT_TYPE) 36 | 37 | 38 | class ClientSocket(QObject): 39 | """ 40 | This class is acts a bridge between a client socket and the Qt event loop. 41 | By using a QSocketNotifier, we can be notified when some data is ready to 42 | be read or written on the socket, not requiring an extra thread. 43 | """ 44 | 45 | MAX_DATA_SIZE = 65535 46 | 47 | def __init__(self, logger, parent=None): 48 | QObject.__init__(self, parent) 49 | self._logger = logger 50 | self._socket = None 51 | self._server = parent and isinstance(parent, ServerSocket) 52 | 53 | self._read_buffer = bytearray() 54 | self._read_notifier = None 55 | self._read_packet = None 56 | 57 | self._write_buffer = bytearray() 58 | self._write_cursor = 0 59 | self._write_notifier = None 60 | self._write_packet = None 61 | 62 | self._connected = False 63 | self._outgoing = collections.deque() 64 | self._incoming = collections.deque() 65 | 66 | @property 67 | def connected(self): 68 | """Is the underlying socket connected?""" 69 | return self._connected 70 | 71 | def wrap_socket(self, sock): 72 | """Sets the underlying socket to use.""" 73 | self._read_notifier = QSocketNotifier( 74 | sock.fileno(), QSocketNotifier.Read, self 75 | ) 76 | self._read_notifier.activated.connect(self._notify_read) 77 | self._read_notifier.setEnabled(True) 78 | 79 | self._write_notifier = QSocketNotifier( 80 | sock.fileno(), QSocketNotifier.Write, self 81 | ) 82 | self._write_notifier.activated.connect(self._notify_write) 83 | self._write_notifier.setEnabled(True) 84 | 85 | self._socket = sock 86 | 87 | def disconnect(self, err=None): 88 | """Terminates the current connection.""" 89 | if not self._socket: 90 | return 91 | 92 | self._logger.debug("Disconnected") 93 | if err: 94 | self._logger.exception(err) 95 | self._read_notifier.setEnabled(False) 96 | self._write_notifier.setEnabled(False) 97 | try: 98 | self._socket.shutdown(socket.SHUT_RDWR) 99 | self._socket.close() 100 | except socket.error: 101 | pass 102 | self._socket = None 103 | self._connected = False 104 | 105 | def set_keep_alive(self, cnt, intvl, idle): 106 | """ 107 | Set the TCP keep-alive of the underlying socket. 108 | 109 | It activates after idle seconds of idleness, sends a keep-alive ping 110 | once every intvl seconds, and disconnects after `cnt`failed pings. 111 | """ 112 | # Taken from https://github.com/markokr/skytools/ 113 | tcp_keepcnt = getattr(socket, "TCP_KEEPCNT", None) 114 | tcp_keepintvl = getattr(socket, "TCP_KEEPINTVL", None) 115 | tcp_keepidle = getattr(socket, "TCP_KEEPIDLE", None) 116 | tcp_keepalive = getattr(socket, "TCP_KEEPALIVE", None) 117 | sio_keeplive_vals = getattr(socket, "SIO_KEEPALIVE_VALS", None) 118 | if ( 119 | tcp_keepidle is None 120 | and tcp_keepalive is None 121 | and sys.platform == "darwin" 122 | ): 123 | tcp_keepalive = 0x10 124 | 125 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 126 | if tcp_keepcnt is not None: 127 | self._socket.setsockopt(socket.IPPROTO_TCP, tcp_keepcnt, cnt) 128 | if tcp_keepintvl is not None: 129 | self._socket.setsockopt(socket.IPPROTO_TCP, tcp_keepintvl, intvl) 130 | if tcp_keepidle is not None: 131 | self._socket.setsockopt(socket.IPPROTO_TCP, tcp_keepidle, idle) 132 | elif tcp_keepalive is not None: 133 | self._socket.setsockopt(socket.IPPROTO_TCP, tcp_keepalive, idle) 134 | elif sio_keeplive_vals is not None: 135 | self._socket.ioctl( 136 | sio_keeplive_vals, (1, idle * 1000, intvl * 1000) 137 | ) 138 | 139 | def _check_socket(self): 140 | """Check if the connection has been established yet.""" 141 | # Ignore if you're already connected 142 | if self._connected: 143 | return True 144 | 145 | # Check if the connection was successful 146 | ret = self._socket.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) 147 | if ret != 0 and ret != errno.EINPROGRESS and ret != errno.EWOULDBLOCK: 148 | self.disconnect(socket.error(ret, os.strerror(ret))) 149 | return False 150 | else: 151 | # Do SSL handshake if needed 152 | if isinstance(self._socket, ssl.SSLSocket): 153 | try: 154 | self._socket.do_handshake() 155 | except socket.error as e: 156 | if not isinstance( 157 | e, ssl.SSLWantReadError 158 | ) and not isinstance(e, ssl.SSLWantReadError): 159 | self.disconnect(e) 160 | return False 161 | 162 | self._connected = True 163 | self._logger.debug("Connected") 164 | return True 165 | 166 | def _notify_read(self): 167 | """Callback called when some data is ready to be read on the socket.""" 168 | if not self._check_socket(): 169 | return 170 | 171 | # Read as many bytes as possible 172 | try: 173 | data = self._socket.recv(ClientSocket.MAX_DATA_SIZE) 174 | if not data: 175 | self.disconnect() 176 | return 177 | except socket.error as e: 178 | if ( 179 | e.errno not in (errno.EAGAIN, errno.EWOULDBLOCK) 180 | and not isinstance(e, ssl.SSLWantReadError) 181 | and not isinstance(e, ssl.SSLWantWriteError) 182 | ): 183 | self.disconnect(e) 184 | return # No more data available 185 | self._read_buffer.extend(data) 186 | 187 | # Split the received data on new lines (= packets) 188 | while True: 189 | if self._read_packet is None: 190 | if b"\n" in self._read_buffer: 191 | pos = self._read_buffer.index(b"\n") 192 | line = self._read_buffer[:pos] 193 | self._read_buffer = self._read_buffer[ 194 | pos + 1 : # noqa: E203 195 | ] 196 | 197 | # Try to parse the line (= packet) 198 | try: 199 | dct = json.loads(line.decode("utf-8")) 200 | self._read_packet = Packet.parse_packet( 201 | dct, self._server 202 | ) 203 | except Exception as e: 204 | msg = "Invalid packet received: %s" % line 205 | self._logger.warning(msg) 206 | self._logger.exception(e) 207 | continue 208 | else: 209 | break # Not enough data for a packet 210 | 211 | else: 212 | if isinstance(self._read_packet, Container): 213 | avail = len(self._read_buffer) 214 | total = self._read_packet.size 215 | 216 | # Trigger the downback 217 | if self._read_packet.downback: 218 | self._read_packet.downback(min(avail, total), total) 219 | 220 | # Read the container's content 221 | if avail >= total: 222 | self._read_packet.content = self._read_buffer[:total] 223 | self._read_buffer = self._read_buffer[total:] 224 | else: 225 | break # Not enough data for a packet 226 | 227 | self._incoming.append(self._read_packet) 228 | self._read_packet = None 229 | 230 | if self._incoming: 231 | QCoreApplication.instance().postEvent(self, PacketEvent()) 232 | 233 | def _notify_write(self): 234 | """Callback called when some data is ready to written on the socket.""" 235 | if not self._check_socket(): 236 | return 237 | 238 | if self._write_cursor >= len(self._write_buffer): 239 | if not self._outgoing: 240 | self._write_notifier.setEnabled(False) 241 | return # No more packets to send 242 | self._write_packet = self._outgoing.popleft() 243 | 244 | # Dump the packet as a line 245 | try: 246 | line = json.dumps(self._write_packet.build_packet()) 247 | line = line.encode("utf-8") + b"\n" 248 | except Exception as e: 249 | msg = "Invalid packet being sent: %s" % self._write_packet 250 | self._logger.warning(msg) 251 | self._logger.exception(e) 252 | return 253 | 254 | # Write the container's content 255 | self._write_buffer = bytearray(line) 256 | self._write_cursor = 0 257 | if isinstance(self._write_packet, Container): 258 | data = self._write_packet.content 259 | self._write_buffer.extend(bytearray(data)) 260 | self._write_packet.size += len(line) 261 | 262 | # Send as many bytes as possible 263 | try: 264 | pos = self._write_cursor 265 | avail = len(self._write_buffer) - pos 266 | count = min(avail, ClientSocket.MAX_DATA_SIZE) 267 | data = self._write_buffer[pos : pos + count] # noqa: E203 268 | self._write_cursor += self._socket.send(data) 269 | except socket.error as e: 270 | if ( 271 | e.errno not in (errno.EAGAIN, errno.EWOULDBLOCK) 272 | and not isinstance(e, ssl.SSLWantReadError) 273 | and not isinstance(e, ssl.SSLWantWriteError) 274 | ): 275 | self.disconnect(e) 276 | return # Can't write anything 277 | 278 | # Trigger the upback 279 | if ( 280 | isinstance(self._write_packet, Container) 281 | and self._write_packet.upback 282 | ): 283 | self._write_packet.size -= count 284 | total = len(self._write_packet.content) 285 | sent = max(total - self._write_packet.size, 0) 286 | self._write_packet.upback(sent, total) 287 | 288 | if ( 289 | self._write_cursor >= len(self._write_buffer) 290 | and not self._outgoing 291 | ): 292 | self._write_notifier.setEnabled(False) 293 | 294 | def event(self, event): 295 | """Callback called when a Qt event is fired.""" 296 | if isinstance(event, PacketEvent): 297 | self._dispatch() 298 | event.accept() 299 | return True 300 | else: 301 | event.ignore() 302 | return False 303 | 304 | def _dispatch(self): 305 | """Callback called when a PacketEvent is received.""" 306 | while self._incoming: 307 | packet = self._incoming.popleft() 308 | self._logger.debug("Received packet: %s" % packet) 309 | 310 | # Notify for replies 311 | if isinstance(packet, Reply): 312 | packet.trigger_callback() 313 | 314 | # Otherwise forward to the subclass 315 | elif not self.recv_packet(packet): 316 | self._logger.warning("Unhandled packet received: %s" % packet) 317 | 318 | def send_packet(self, packet): 319 | """Sends a packet the other party.""" 320 | if not self._connected: 321 | self._logger.warning("Sending packet while disconnected") 322 | return None 323 | 324 | self._logger.debug("Sending packet: %s" % packet) 325 | 326 | # Enqueue the packet 327 | self._outgoing.append(packet) 328 | if not self._write_notifier.isEnabled(): 329 | self._write_notifier.setEnabled(True) 330 | 331 | # Queries return a packet deferred 332 | if isinstance(packet, Query): 333 | d = PacketDeferred() 334 | packet.register_callback(d) 335 | return d 336 | return None 337 | 338 | def recv_packet(self, packet): 339 | """Receives a packet from the other party.""" 340 | raise NotImplementedError("recv_packet() not implemented") 341 | 342 | 343 | class ServerSocket(QObject): 344 | """ 345 | This class is acts a bridge between a server socket and the Qt event loop. 346 | See the ClientSocket class for a more detailed explanation. 347 | """ 348 | 349 | def __init__(self, logger, parent=None): 350 | QObject.__init__(self, parent) 351 | self._logger = logger 352 | self._socket = None 353 | self._connected = False 354 | self._accept_notifier = None 355 | 356 | @property 357 | def connected(self): 358 | """Is the underlying socket connected?""" 359 | return self._connected 360 | 361 | def connect(self, sock): 362 | """Sets the underlying socket to utilize.""" 363 | self._accept_notifier = QSocketNotifier( 364 | sock.fileno(), QSocketNotifier.Read, self 365 | ) 366 | self._accept_notifier.activated.connect(self._notify_accept) 367 | self._accept_notifier.setEnabled(True) 368 | 369 | self._socket = sock 370 | self._connected = True 371 | 372 | def disconnect(self, err=None): 373 | """Terminates the current connection.""" 374 | if not self._socket: 375 | return 376 | if err: 377 | self._logger.warning("Connection lost") 378 | self._logger.exception(err) 379 | self._accept_notifier.setEnabled(False) 380 | try: 381 | self._socket.close() 382 | except socket.error: 383 | pass 384 | self._socket = None 385 | self._connected = False 386 | 387 | def _notify_accept(self): 388 | """Callback called when a client is connecting.""" 389 | while True: 390 | try: 391 | sock, address = self._socket.accept() 392 | except socket.error as e: 393 | if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK): 394 | break 395 | self.disconnect(e) 396 | break 397 | self._accept(sock) 398 | 399 | def _accept(self, socket): 400 | """Handles the client who newly connected.""" 401 | raise NotImplementedError("accept() is not implemented") 402 | -------------------------------------------------------------------------------- /idarling/shared/storage.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import json 14 | import sqlite3 15 | 16 | from .models import Database, Project 17 | from .packets import Default, DefaultEvent 18 | 19 | 20 | class Storage(object): 21 | """ 22 | This object is used to access the SQL database used by the server. It 23 | also defines some utility methods. Currently, only SQLite3 is implemented. 24 | """ 25 | 26 | def __init__(self, dbpath): 27 | self._conn = sqlite3.connect(dbpath, check_same_thread=False) 28 | self._conn.isolation_level = None # No need to commit 29 | self._conn.row_factory = sqlite3.Row # Use Row objects 30 | 31 | def initialize(self): 32 | """Create all the default tables.""" 33 | self._create( 34 | "projects", 35 | [ 36 | "name text not null", 37 | "hash text not null", 38 | "file text not null", 39 | "type text not null", 40 | "date text not null", 41 | "primary key (name)", 42 | ], 43 | ) 44 | self._create( 45 | "databases", 46 | [ 47 | "project text not null", 48 | "name text not null", 49 | "date text not null", 50 | "foreign key(project) references projects(name)", 51 | "primary key(project, name)", 52 | ], 53 | ) 54 | self._create( 55 | "events", 56 | [ 57 | "project text not null", 58 | "database text not null", 59 | "tick integer not null", 60 | "dict text not null", 61 | "foreign key(project) references projects(name)", 62 | "foreign key(project, database)" 63 | " references databases(project, name)", 64 | "primary key(project, database, tick)", 65 | ], 66 | ) 67 | 68 | def insert_project(self, project): 69 | """Insert a new project into the database.""" 70 | self._insert("projects", Default.attrs(project.__dict__)) 71 | 72 | def select_project(self, name): 73 | """Select the project with the given name.""" 74 | objects = self.select_projects(name, 1) 75 | return objects[0] if objects else None 76 | 77 | def select_projects(self, name=None, limit=None): 78 | """Select the projects with the given name.""" 79 | results = self._select("projects", {"name": name}, limit) 80 | return [Project(**result) for result in results] 81 | 82 | def insert_database(self, database): 83 | """Insert a new database into the database.""" 84 | attrs = Default.attrs(database.__dict__) 85 | attrs.pop("tick") 86 | self._insert("databases", attrs) 87 | 88 | def select_database(self, project, name): 89 | """Select the database with the given project and name.""" 90 | objects = self.select_databases(project, name, 1) 91 | return objects[0] if objects else None 92 | 93 | def select_databases(self, project=None, name=None, limit=None): 94 | """Select the databases with the given project and name.""" 95 | results = self._select( 96 | "databases", {"project": project, "name": name}, limit 97 | ) 98 | return [Database(**result) for result in results] 99 | 100 | def insert_event(self, client, event): 101 | """Insert a new event into the database.""" 102 | dct = DefaultEvent.attrs(event.__dict__) 103 | self._insert( 104 | "events", 105 | { 106 | "project": client.project, 107 | "database": client.database, 108 | "tick": event.tick, 109 | "dict": json.dumps(dct), 110 | }, 111 | ) 112 | 113 | def select_events(self, project, database, tick): 114 | """Get all events sent after the given tick count.""" 115 | c = self._conn.cursor() 116 | sql = "select * from events where project = ? and database = ?" 117 | sql += "and tick > ? order by tick asc;" 118 | c.execute(sql, [project, database, tick]) 119 | events = [] 120 | for result in c.fetchall(): 121 | dct = json.loads(result["dict"]) 122 | dct["tick"] = result["tick"] 123 | events.append(DefaultEvent.new(dct)) 124 | return events 125 | 126 | def last_tick(self, project, database): 127 | """Get the last tick of the specified project and database.""" 128 | c = self._conn.cursor() 129 | sql = "select tick from events where project = ? and database = ? " 130 | sql += "order by tick desc limit 1;" 131 | c.execute(sql, [project, database]) 132 | result = c.fetchone() 133 | return result["tick"] if result else 0 134 | 135 | def _create(self, table, cols): 136 | """Create a table with the given name and columns.""" 137 | c = self._conn.cursor() 138 | sql = "create table if not exists {} ({});" 139 | c.execute(sql.format(table, ", ".join(cols))) 140 | 141 | def _select(self, table, fields, limit=None): 142 | """Select the rows of a table matching the given values.""" 143 | c = self._conn.cursor() 144 | sql = "select * from {}".format(table) 145 | fields = {key: val for key, val in fields.items() if val} 146 | if len(fields): 147 | cols = ["{} = ?".format(col) for col in fields.keys()] 148 | sql = (sql + " where {}").format(" and ".join(cols)) 149 | sql += " limit {};".format(limit) if limit else ";" 150 | c.execute(sql, list(fields.values())) 151 | return c.fetchall() 152 | 153 | def _insert(self, table, fields): 154 | """Insert a row into a table with the given values.""" 155 | c = self._conn.cursor() 156 | sql = "insert into {} ({}) values ({});" 157 | keys = ", ".join(fields.keys()) 158 | vals = ", ".join(["?"] * len(fields)) 159 | c.execute(sql.format(table, keys, vals), list(fields.values())) 160 | -------------------------------------------------------------------------------- /idarling/shared/utils.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | import logging 14 | 15 | _loggers = {} 16 | 17 | 18 | def start_logging(log_path, log_name, level): 19 | """ 20 | Setup the logger: add a new log level, create a logger which logs into 21 | the console and also into a log files located at: logs/idarling.%pid%.log. 22 | """ 23 | if log_name in _loggers: 24 | return _loggers[log_name] 25 | 26 | # Add a new log level called TRACE, and more verbose that DEBUG. 27 | logging.TRACE = 5 28 | logging.addLevelName(logging.TRACE, "TRACE") 29 | logging.Logger.trace = lambda inst, msg, *args, **kwargs: inst.log( 30 | logging.TRACE, msg, *args, **kwargs 31 | ) 32 | logging.trace = lambda msg, *args, **kwargs: logging.log( 33 | logging.TRACE, msg, *args, **kwargs 34 | ) 35 | 36 | logger = logging.getLogger(log_name) 37 | if not isinstance(level, int): 38 | level = getattr(logging, level) 39 | logger.setLevel(level) 40 | 41 | # Log to the console with a first format 42 | stream_handler = logging.StreamHandler() 43 | log_format = "[%(levelname)s] %(message)s" 44 | formatter = logging.Formatter(fmt=log_format) 45 | stream_handler.setFormatter(formatter) 46 | logger.addHandler(stream_handler) 47 | 48 | # Log to the disk with a second format 49 | file_handler = logging.FileHandler(log_path) 50 | log_format = "[%(asctime)s][%(levelname)s] %(message)s" 51 | formatter = logging.Formatter(fmt=log_format, datefmt="%H:%M:%S") 52 | file_handler.setFormatter(formatter) 53 | logger.addHandler(file_handler) 54 | 55 | _loggers[log_name] = logger 56 | return logger 57 | -------------------------------------------------------------------------------- /idarling_plugin.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from idarling.plugin import Plugin 14 | 15 | 16 | def PLUGIN_ENTRY(): # noqa: N802 17 | """Mandatory entry point for IDAPython plugins.""" 18 | return Plugin() 19 | -------------------------------------------------------------------------------- /idarling_server.py: -------------------------------------------------------------------------------- 1 | # This program is free software: you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation, either version 3 of the License, or 4 | # (at your option) any later version. 5 | 6 | # This program is distributed in the hope that it will be useful, 7 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9 | # GNU General Public License for more details. 10 | 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | from idarling import server 14 | 15 | 16 | if __name__ == "__main__": 17 | server.main() 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | 5 | setup( 6 | name="idarling", 7 | version="0.1", 8 | description="Collaborative Reverse Engineering plugin for IDA Pro", 9 | url="https://github.com/IDArlingTeam/IDArling", 10 | packages=find_packages(), 11 | install_requires=["PyQt5; python_version >= '3.0'"], 12 | include_package_data=True, 13 | entry_points={ 14 | "idapython_plugins": ["idarling=idarling.plugin:Plugin"], 15 | "console_scripts": ["idarling_server=idarling.server:main"], 16 | }, 17 | ) 18 | --------------------------------------------------------------------------------