]>
Commit | Line | Data |
---|---|---|
7dcf23b1 SH |
1 | # -*- coding: utf-8 -*- |
2 | # | |
e836cfb0 SH |
3 | # network.py - I/O with WeeChat/relay |
4 | # | |
82e0d920 | 5 | # Copyright (C) 2011-2022 Sébastien Helleu <flashcode@flashtux.org> |
7dcf23b1 SH |
6 | # |
7 | # This file is part of QWeeChat, a Qt remote GUI for WeeChat. | |
8 | # | |
9 | # QWeeChat is free software; you can redistribute it and/or modify | |
10 | # it under the terms of the GNU General Public License as published by | |
11 | # the Free Software Foundation; either version 3 of the License, or | |
12 | # (at your option) any later version. | |
13 | # | |
14 | # QWeeChat is distributed in the hope that it will be useful, | |
15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
17 | # GNU General Public License for more details. | |
18 | # | |
19 | # You should have received a copy of the GNU General Public License | |
20 | # along with QWeeChat. If not, see <http://www.gnu.org/licenses/>. | |
21 | # | |
22 | ||
1fe88536 SH |
23 | """I/O with WeeChat/relay.""" |
24 | ||
3b0947c9 SH |
25 | import hashlib |
26 | import secrets | |
7dcf23b1 | 27 | import struct |
8e15c19f | 28 | |
1f27a20e | 29 | from PySide6 import QtCore, QtNetwork |
8e15c19f AR |
30 | |
31 | from qweechat import config | |
3b0947c9 | 32 | from qweechat.debug import DebugDialog |
8e15c19f | 33 | |
356719e0 | 34 | |
3b0947c9 SH |
35 | # list of supported hash algorithms on our side |
36 | # (the hash algorithm will be negotiated with the remote WeeChat) | |
37 | _HASH_ALGOS_LIST = [ | |
38 | 'plain', | |
39 | 'sha256', | |
40 | 'sha512', | |
41 | 'pbkdf2+sha256', | |
42 | 'pbkdf2+sha512', | |
bb968841 | 43 | ] |
3b0947c9 | 44 | _HASH_ALGOS = ':'.join(_HASH_ALGOS_LIST) |
77df9d06 | 45 | |
3b0947c9 SH |
46 | # handshake with remote WeeChat (before init) |
47 | _PROTO_HANDSHAKE = f'(handshake) handshake password_hash_algo={_HASH_ALGOS}\n' | |
48 | ||
49 | # initialize with the password (plain text) | |
2a814055 | 50 | _PROTO_INIT_PWD = 'init password=%(password)s%(totp)s\n' # nosec |
3b0947c9 SH |
51 | |
52 | # initialize with the hashed password | |
53 | _PROTO_INIT_HASH = ('init password_hash=' | |
54 | '%(algo)s:%(salt)s%(iter)s:%(hash)s%(totp)s\n') | |
55 | ||
0feac51b | 56 | _PROTO_SYNC_CMDS = [ |
bb968841 | 57 | # get buffers |
77df9d06 SH |
58 | '(listbuffers) hdata buffer:gui_buffers(*) number,full_name,short_name,' |
59 | 'type,nicklist,title,local_variables', | |
bb968841 | 60 | # get lines |
77df9d06 SH |
61 | '(listlines) hdata buffer:gui_buffers(*)/own_lines/last_line(-%(lines)d)/' |
62 | 'data date,displayed,prefix,message', | |
bb968841 | 63 | # get nicklist for all buffers |
77df9d06 | 64 | '(nicklist) nicklist', |
bb968841 | 65 | # enable synchronization |
77df9d06 | 66 | 'sync', |
77df9d06 | 67 | ] |
7dcf23b1 | 68 | |
6958c235 SH |
69 | STATUS_DISCONNECTED = 'disconnected' |
70 | STATUS_CONNECTING = 'connecting' | |
3b0947c9 | 71 | STATUS_AUTHENTICATING = 'authenticating' |
6958c235 SH |
72 | STATUS_CONNECTED = 'connected' |
73 | ||
74 | NETWORK_STATUS = { | |
3b0947c9 | 75 | STATUS_DISCONNECTED: { |
6958c235 SH |
76 | 'label': 'Disconnected', |
77 | 'color': '#aa0000', | |
78 | 'icon': 'dialog-close.png', | |
79 | }, | |
3b0947c9 | 80 | STATUS_CONNECTING: { |
6958c235 | 81 | 'label': 'Connecting…', |
3b0947c9 | 82 | 'color': '#dd5f00', |
6958c235 SH |
83 | 'icon': 'dialog-warning.png', |
84 | }, | |
3b0947c9 SH |
85 | STATUS_AUTHENTICATING: { |
86 | 'label': 'Authenticating…', | |
87 | 'color': '#007fff', | |
88 | 'icon': 'dialog-password.png', | |
89 | }, | |
90 | STATUS_CONNECTED: { | |
6958c235 SH |
91 | 'label': 'Connected', |
92 | 'color': 'green', | |
93 | 'icon': 'dialog-ok-apply.png', | |
94 | }, | |
95 | } | |
7dcf23b1 | 96 | |
3b0947c9 | 97 | |
7dcf23b1 SH |
98 | class Network(QtCore.QObject): |
99 | """I/O with WeeChat/relay.""" | |
100 | ||
8e15c19f AR |
101 | statusChanged = QtCore.Signal(str, str) |
102 | messageFromWeechat = QtCore.Signal(QtCore.QByteArray) | |
7dcf23b1 SH |
103 | |
104 | def __init__(self, *args): | |
1f27a20e | 105 | super().__init__(*args) |
3b0947c9 SH |
106 | self._init_connection() |
107 | self.debug_lines = [] | |
108 | self.debug_dialog = None | |
46e5dee0 | 109 | self._lines = config.CONFIG_DEFAULT_RELAY_LINES |
7dcf23b1 | 110 | self._buffer = QtCore.QByteArray() |
77b25057 | 111 | self._socket = QtNetwork.QSslSocket() |
7dcf23b1 | 112 | self._socket.connected.connect(self._socket_connected) |
7dcf23b1 SH |
113 | self._socket.readyRead.connect(self._socket_read) |
114 | self._socket.disconnected.connect(self._socket_disconnected) | |
115 | ||
3b0947c9 SH |
116 | def _init_connection(self): |
117 | self.status = STATUS_DISCONNECTED | |
ae648fd7 | 118 | self._hostname = None |
3b0947c9 SH |
119 | self._port = None |
120 | self._ssl = None | |
121 | self._password = None | |
122 | self._totp = None | |
123 | self._handshake_received = False | |
124 | self._handshake_timer = None | |
125 | self._handshake_timer = False | |
126 | self._pwd_hash_algo = None | |
127 | self._pwd_hash_iter = 0 | |
128 | self._server_nonce = None | |
129 | ||
130 | def set_status(self, status): | |
131 | """Set current status.""" | |
132 | self.status = status | |
133 | self.statusChanged.emit(status, None) | |
134 | ||
135 | def pbkdf2(self, hash_name, salt): | |
136 | """Return hashed password with PBKDF2-HMAC.""" | |
137 | return hashlib.pbkdf2_hmac( | |
138 | hash_name, | |
139 | password=self._password.encode('utf-8'), | |
140 | salt=salt, | |
141 | iterations=self._pwd_hash_iter, | |
142 | ).hex() | |
143 | ||
bb968841 SH |
144 | def _build_init_command(self): |
145 | """Build the init command to send to WeeChat.""" | |
3b0947c9 | 146 | totp = f',totp={self._totp}' if self._totp else '' |
ba2cb0c1 | 147 | if self._pwd_hash_algo == 'plain': # nosec |
3b0947c9 SH |
148 | cmd = _PROTO_INIT_PWD % { |
149 | 'password': self._password, | |
150 | 'totp': totp, | |
151 | } | |
152 | else: | |
153 | client_nonce = secrets.token_bytes(16) | |
154 | salt = self._server_nonce + client_nonce | |
155 | pwd_hash = None | |
156 | iterations = '' | |
ba2cb0c1 | 157 | if self._pwd_hash_algo == 'pbkdf2+sha512': # nosec |
3b0947c9 SH |
158 | pwd_hash = self.pbkdf2('sha512', salt) |
159 | iterations = f':{self._pwd_hash_iter}' | |
ba2cb0c1 | 160 | elif self._pwd_hash_algo == 'pbkdf2+sha256': # nosec |
3b0947c9 SH |
161 | pwd_hash = self.pbkdf2('sha256', salt) |
162 | iterations = f':{self._pwd_hash_iter}' | |
ba2cb0c1 | 163 | elif self._pwd_hash_algo == 'sha512': # nosec |
3b0947c9 SH |
164 | pwd = salt + self._password.encode('utf-8') |
165 | pwd_hash = hashlib.sha512(pwd).hexdigest() | |
ba2cb0c1 | 166 | elif self._pwd_hash_algo == 'sha256': # nosec |
3b0947c9 SH |
167 | pwd = salt + self._password.encode('utf-8') |
168 | pwd_hash = hashlib.sha256(pwd).hexdigest() | |
169 | if not pwd_hash: | |
170 | return None | |
171 | cmd = _PROTO_INIT_HASH % { | |
172 | 'algo': self._pwd_hash_algo, | |
173 | 'salt': bytearray(salt).hex(), | |
174 | 'iter': iterations, | |
175 | 'hash': pwd_hash, | |
176 | 'totp': totp, | |
177 | } | |
178 | return cmd | |
bb968841 SH |
179 | |
180 | def _build_sync_command(self): | |
181 | """Build the sync commands to send to WeeChat.""" | |
0feac51b | 182 | cmd = '\n'.join(_PROTO_SYNC_CMDS) + '\n' |
bb968841 SH |
183 | return cmd % {'lines': self._lines} |
184 | ||
3b0947c9 SH |
185 | def handshake_timer_expired(self): |
186 | if self.status == STATUS_AUTHENTICATING: | |
ba2cb0c1 | 187 | self._pwd_hash_algo = 'plain' # nosec |
3b0947c9 SH |
188 | self.send_to_weechat(self._build_init_command()) |
189 | self.sync_weechat() | |
190 | self.set_status(STATUS_CONNECTED) | |
191 | ||
7dcf23b1 SH |
192 | def _socket_connected(self): |
193 | """Slot: socket connected.""" | |
3b0947c9 SH |
194 | self.set_status(STATUS_AUTHENTICATING) |
195 | self.send_to_weechat(_PROTO_HANDSHAKE) | |
196 | self._handshake_timer = QtCore.QTimer() | |
197 | self._handshake_timer.setSingleShot(True) | |
198 | self._handshake_timer.setInterval(2000) | |
199 | self._handshake_timer.timeout.connect(self.handshake_timer_expired) | |
200 | self._handshake_timer.start() | |
7dcf23b1 | 201 | |
7dcf23b1 SH |
202 | def _socket_read(self): |
203 | """Slot: data available on socket.""" | |
6d5927c6 SH |
204 | data = self._socket.readAll() |
205 | self._buffer.append(data) | |
7dcf23b1 SH |
206 | while len(self._buffer) >= 4: |
207 | remainder = None | |
1f27a20e | 208 | length = struct.unpack('>i', self._buffer[0:4].data())[0] |
7dcf23b1 SH |
209 | if len(self._buffer) < length: |
210 | # partial message, just wait for end of message | |
211 | break | |
212 | # more than one message? | |
213 | if length < len(self._buffer): | |
214 | # save beginning of another message | |
215 | remainder = self._buffer[length:] | |
216 | self._buffer = self._buffer[0:length] | |
217 | self.messageFromWeechat.emit(self._buffer) | |
77b25057 SH |
218 | if not self.is_connected(): |
219 | return | |
7dcf23b1 SH |
220 | self._buffer.clear() |
221 | if remainder: | |
222 | self._buffer.append(remainder) | |
223 | ||
224 | def _socket_disconnected(self): | |
225 | """Slot: socket disconnected.""" | |
3b0947c9 SH |
226 | if self._handshake_timer: |
227 | self._handshake_timer.stop() | |
228 | self._init_connection() | |
229 | self.set_status(STATUS_DISCONNECTED) | |
7dcf23b1 SH |
230 | |
231 | def is_connected(self): | |
ac53e98c | 232 | """Return True if the socket is connected, False otherwise.""" |
7dcf23b1 SH |
233 | return self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState |
234 | ||
77b25057 | 235 | def is_ssl(self): |
ac53e98c | 236 | """Return True if SSL is used, False otherwise.""" |
77b25057 SH |
237 | return self._ssl |
238 | ||
ae648fd7 | 239 | def connect_weechat(self, hostname, port, ssl, password, totp, lines): |
ac53e98c | 240 | """Connect to WeeChat.""" |
ae648fd7 | 241 | self._hostname = hostname |
7dcf23b1 SH |
242 | try: |
243 | self._port = int(port) | |
87a9710d | 244 | except ValueError: |
7dcf23b1 | 245 | self._port = 0 |
77b25057 | 246 | self._ssl = ssl |
7dcf23b1 | 247 | self._password = password |
3b0947c9 | 248 | self._totp = totp |
46e5dee0 SH |
249 | try: |
250 | self._lines = int(lines) | |
87a9710d | 251 | except ValueError: |
46e5dee0 | 252 | self._lines = config.CONFIG_DEFAULT_RELAY_LINES |
7dcf23b1 SH |
253 | if self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState: |
254 | return | |
255 | if self._socket.state() != QtNetwork.QAbstractSocket.UnconnectedState: | |
256 | self._socket.abort() | |
77b25057 SH |
257 | if self._ssl: |
258 | self._socket.ignoreSslErrors() | |
ae648fd7 | 259 | self._socket.connectToHostEncrypted(self._hostname, self._port) |
1f27a20e | 260 | else: |
ae648fd7 | 261 | self._socket.connectToHost(self._hostname, self._port) |
3b0947c9 | 262 | self.set_status(STATUS_CONNECTING) |
7dcf23b1 SH |
263 | |
264 | def disconnect_weechat(self): | |
ac53e98c | 265 | """Disconnect from WeeChat.""" |
77df9d06 | 266 | if self._socket.state() == QtNetwork.QAbstractSocket.UnconnectedState: |
3b0947c9 | 267 | self.set_status(STATUS_DISCONNECTED) |
77df9d06 SH |
268 | return |
269 | if self._socket.state() == QtNetwork.QAbstractSocket.ConnectedState: | |
270 | self.send_to_weechat('quit\n') | |
271 | self._socket.waitForBytesWritten(1000) | |
272 | else: | |
3b0947c9 | 273 | self.set_status(STATUS_DISCONNECTED) |
77df9d06 | 274 | self._socket.abort() |
7dcf23b1 SH |
275 | |
276 | def send_to_weechat(self, message): | |
ac53e98c | 277 | """Send a message to WeeChat.""" |
3b0947c9 | 278 | self.debug_print(0, '<==', message, forcecolor='#AA0000') |
e09c80ab | 279 | self._socket.write(message.encode('utf-8')) |
7dcf23b1 | 280 | |
3b0947c9 SH |
281 | def init_with_handshake(self, response): |
282 | """Initialize with WeeChat using the handshake response.""" | |
283 | self._pwd_hash_algo = response['password_hash_algo'] | |
284 | self._pwd_hash_iter = int(response['password_hash_iterations']) | |
285 | self._server_nonce = bytearray.fromhex(response['nonce']) | |
286 | if self._pwd_hash_algo: | |
287 | cmd = self._build_init_command() | |
288 | if cmd: | |
289 | self.send_to_weechat(cmd) | |
290 | self.sync_weechat() | |
291 | self.set_status(STATUS_CONNECTED) | |
292 | return | |
293 | # failed to initialize: disconnect | |
294 | self.disconnect_weechat() | |
295 | ||
beaa8775 | 296 | def desync_weechat(self): |
ac53e98c | 297 | """Desynchronize from WeeChat.""" |
77b25057 | 298 | self.send_to_weechat('desync\n') |
beaa8775 SH |
299 | |
300 | def sync_weechat(self): | |
ac53e98c | 301 | """Synchronize with WeeChat.""" |
bb968841 | 302 | self.send_to_weechat(self._build_sync_command()) |
beaa8775 | 303 | |
6958c235 SH |
304 | def status_label(self, status): |
305 | """Return the label for a given status.""" | |
306 | return NETWORK_STATUS.get(status, {}).get('label', '') | |
307 | ||
308 | def status_color(self, status): | |
309 | """Return the color for a given status.""" | |
310 | return NETWORK_STATUS.get(status, {}).get('color', 'black') | |
311 | ||
7dcf23b1 | 312 | def status_icon(self, status): |
890d4fa2 | 313 | """Return the name of icon for a given status.""" |
6958c235 | 314 | return NETWORK_STATUS.get(status, {}).get('icon', '') |
b548a19e | 315 | |
316 | def get_options(self): | |
890d4fa2 SH |
317 | """Get connection options.""" |
318 | return { | |
ae648fd7 | 319 | 'hostname': self._hostname, |
890d4fa2 SH |
320 | 'port': self._port, |
321 | 'ssl': 'on' if self._ssl else 'off', | |
322 | 'password': self._password, | |
323 | 'lines': str(self._lines), | |
324 | } | |
3b0947c9 SH |
325 | |
326 | def debug_print(self, *args, **kwargs): | |
327 | """Display a debug message.""" | |
328 | self.debug_lines.append((args, kwargs)) | |
329 | if self.debug_dialog: | |
330 | self.debug_dialog.chat.display(*args, **kwargs) | |
331 | ||
332 | def _debug_dialog_closed(self, result): | |
333 | """Called when debug dialog is closed.""" | |
334 | self.debug_dialog = None | |
335 | ||
336 | def debug_input_text_sent(self, text): | |
337 | """Send debug buffer input to WeeChat.""" | |
338 | if self.network.is_connected(): | |
339 | text = str(text) | |
340 | pos = text.find(')') | |
341 | if text.startswith('(') and pos >= 0: | |
342 | text = '(debug_%s)%s' % (text[1:pos], text[pos+1:]) | |
343 | else: | |
344 | text = '(debug) %s' % text | |
345 | self.network.debug_print(0, '<==', text, forcecolor='#AA0000') | |
346 | self.network.send_to_weechat(text + '\n') | |
347 | ||
348 | def open_debug_dialog(self): | |
349 | """Open a dialog with debug messages.""" | |
350 | if not self.debug_dialog: | |
351 | self.debug_dialog = DebugDialog() | |
352 | self.debug_dialog.input.textSent.connect( | |
353 | self.debug_input_text_sent) | |
354 | self.debug_dialog.finished.connect(self._debug_dialog_closed) | |
355 | self.debug_dialog.display_lines(self.debug_lines) | |
356 | self.debug_dialog.chat.scroll_bottom() |