├── .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 |
--------------------------------------------------------------------------------