Skip to content

Commit 34f5b13

Browse files
committed
Add SASL auth and SSL transport mixins
* ib3.SASL: Authenticate using SASL before joining channels * ib3.SSL: Use SSL on connections to servers
1 parent 2cb3028 commit 34f5b13

3 files changed

Lines changed: 248 additions & 19 deletions

File tree

examples/nickserv.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of IRC Bot Behavior Bundle (IB3)
4+
# Copyright (C) 2017 Bryan Davis and contributors
5+
#
6+
# This program is free software: you can redistribute it and/or modify it
7+
# under the terms of the GNU General Public License as published by the Free
8+
# Software Foundation, either version 3 of the License, or (at your option)
9+
# any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful, but WITHOUT
12+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14+
# more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along with
17+
# this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+
import argparse
20+
import logging
21+
22+
from ib3 import Bot
23+
from ib3 import DisconnectOnError
24+
from ib3 import FreenodePasswdAuth
25+
from ib3 import Ping
26+
from ib3 import RejoinOnBan
27+
from ib3 import RejoinOnKick
28+
29+
30+
logging.basicConfig(
31+
level=logging.DEBUG,
32+
format='%(asctime)s %(name)s %(levelname)s: %(message)s',
33+
datefmt='%Y-%m-%dT%H:%M:%SZ'
34+
)
35+
logging.captureWarnings(True)
36+
37+
38+
class TestBot(
39+
FreenodePasswdAuth, Ping, DisconnectOnError,
40+
RejoinOnBan, RejoinOnKick, Bot
41+
):
42+
pass
43+
44+
45+
if __name__ == '__main__':
46+
parser = argparse.ArgumentParser(
47+
description='Example bot with NickServ auth')
48+
parser.add_argument('nick')
49+
parser.add_argument('password')
50+
parser.add_argument('channel')
51+
args = parser.parse_args()
52+
53+
bot = TestBot(
54+
server_list=[('chat.freenode.net', 6667)],
55+
nickname=args.nick,
56+
realname=args.nick,
57+
ident_password=args.password,
58+
channels=[args.channel]
59+
)
60+
try:
61+
bot.start()
62+
except KeyboardInterrupt:
63+
bot.disconnect('KeyboardInterrupt!')
64+
except Exception:
65+
logging.getLogger('root').exception('Killed by unhandled exception')
66+
bot.disconnect('Exception!')
67+
raise SystemExit()

examples/saslbot.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of IRC Bot Behavior Bundle (IB3)
4+
# Copyright (C) 2017 Bryan Davis and contributors
5+
#
6+
# This program is free software: you can redistribute it and/or modify it
7+
# under the terms of the GNU General Public License as published by the Free
8+
# Software Foundation, either version 3 of the License, or (at your option)
9+
# any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful, but WITHOUT
12+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14+
# more details.
15+
#
16+
# You should have received a copy of the GNU General Public License along with
17+
# this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+
import argparse
20+
import logging
21+
22+
import irc.strings
23+
24+
import ib3
25+
26+
27+
logging.basicConfig(
28+
level=logging.DEBUG,
29+
format='%(asctime)s %(name)s %(levelname)s: %(message)s',
30+
datefmt='%Y-%m-%dT%H:%M:%SZ'
31+
)
32+
logging.captureWarnings(True)
33+
34+
logger = logging.getLogger('saslbot')
35+
36+
37+
class SaslBot(ib3.SASL, ib3.SSL, ib3.Bot):
38+
"""Example bot showing use of SASL auth and SSL encryption."""
39+
def on_privmsg(self, conn, event):
40+
self.do_command(conn, event, event.arguments[0])
41+
42+
def on_pubmsg(self, conn, event):
43+
args = event.arguments[0].split(':', 1)
44+
if len(args) > 1:
45+
to = irc.strings.lower(args[0])
46+
if to == irc.strings.lower(conn.get_nickname()):
47+
self.do_command(conn, event, args[1].strip())
48+
49+
def do_command(self, conn, event, cmd):
50+
to = event.target
51+
if to == conn.get_nickname():
52+
to = event.source.nick
53+
54+
if cmd == 'disconnect':
55+
self.disconnect()
56+
elif cmd == 'die':
57+
self.die()
58+
else:
59+
conn.privmsg(to, 'What does "{}" mean?'.format(cmd))
60+
61+
62+
if __name__ == '__main__':
63+
parser = argparse.ArgumentParser(description='Example bot with SASL auth')
64+
parser.add_argument('nick')
65+
parser.add_argument('password')
66+
parser.add_argument('channel')
67+
args = parser.parse_args()
68+
69+
bot = SaslBot(
70+
server_list=[('chat.freenode.net', 6697)],
71+
nickname=args.nick,
72+
realname=args.nick,
73+
ident_password=args.password,
74+
channels=[args.channel],
75+
)
76+
try:
77+
bot.start()
78+
except KeyboardInterrupt:
79+
bot.disconnect('KeyboardInterrupt!')
80+
except Exception:
81+
logger.exception('Killed by unhandled exception')
82+
bot.disconnect('Exception!')
83+
raise SystemExit()

ib3/__init__.py

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
# this program. If not, see <http://www.gnu.org/licenses/>.
1818
"""Base IRC bot and mixins for commonly desired functionality."""
1919

20+
import base64
2021
import functools
2122
import logging
23+
import ssl
2224

2325
import irc.bot
2426
import irc.buffer
2527
import irc.client
28+
import irc.connection
2629

2730
logger = logging.getLogger(__name__)
2831

@@ -112,11 +115,27 @@ def _handle_bannedfromchan(self, conn, event):
112115
60, functools.partial(conn.join, event.arguments[0]))
113116

114117

115-
class FreenodePasswdAuth(object):
118+
class JoinChannels(object):
119+
"""Join channels one at a time to avoid flooding."""
120+
def join_channels(self, channels):
121+
"""Join a list of channels, one at a time."""
122+
try:
123+
car, cdr = channels[0], channels[1:]
124+
except (IndexError, TypeError):
125+
logger.exception('Failed to find channel to join.')
126+
else:
127+
logger.info('Joining %s', car)
128+
self.connection.join(car)
129+
if cdr:
130+
self.reactor.scheduler.execute_after(
131+
1, functools.partial(self.join_channels, cdr))
132+
133+
134+
class FreenodePasswdAuth(JoinChannels):
116135
"""Authenticate with NickServ before joining channels."""
117136
def __init__(
118137
self, server_list, nickname, realname,
119-
ident_password, channels, **kwargs):
138+
ident_password, channels=[], **kwargs):
120139
"""
121140
:param server_list: List of servers the bot will use.
122141
:param nickname: The bot's nickname
@@ -178,8 +197,7 @@ def _handle_privnotice(self, conn, event):
178197
self._identify_to_nickserv()
179198
elif 'You are now identified' in msg:
180199
logger.debug('Authentication succeeded')
181-
self.reactor.scheduler.execute_after(
182-
1, self._join_next_channel)
200+
self.join_channels(self._channels)
183201
elif 'Invalid password' in msg:
184202
logger.error('Password invalid. Check your config!')
185203
self.die()
@@ -190,21 +208,6 @@ def _identify_to_nickserv(self):
190208
self.connection.privmsg('NickServ', 'identify %s %s' % (
191209
self._primary_nick, self._ident_password))
192210

193-
def _join_next_channel(self, channels=None):
194-
"""Join the next channel in our join list."""
195-
if channels is None:
196-
channels = self._channels
197-
try:
198-
car, cdr = channels[0], channels[1:]
199-
except (IndexError, TypeError):
200-
logger.exception('Failed to find channel to join.')
201-
else:
202-
logger.info('Joining %s', car)
203-
self.connection.join(car)
204-
if cdr:
205-
self.reactor.scheduler.execute_after(
206-
1, functools.partial(self._join_next_channel, cdr))
207-
208211
def _nickserv_regain(self):
209212
if not self.has_primary_nick():
210213
# REGAIN disconnects an old user session, or somebody
@@ -218,3 +221,79 @@ def _nickserv_regain(self):
218221
def has_primary_nick(self):
219222
"""Do we currently have our primary nick?"""
220223
return self.connection.get_nickname() == self._primary_nick
224+
225+
226+
class SASL(JoinChannels):
227+
def __init__(
228+
self, server_list, nickname, realname,
229+
ident_password, channels=[], username=None, **kwargs):
230+
"""
231+
:param server_list: List of servers the bot will use.
232+
:param nickname: The bot's nickname
233+
:param realname: The bot's realname
234+
:param ident_password: The bot's password
235+
:param channels: List of channels to join after authenticating
236+
"""
237+
self._primary_nick = nickname
238+
self._username = username or nickname
239+
self._ident_password = ident_password
240+
self._channels = channels
241+
242+
super(SASL, self).__init__(
243+
server_list=server_list,
244+
nickname=nickname,
245+
realname=realname,
246+
username=self._username,
247+
**kwargs)
248+
self.reactor._on_connect = self._handle_connect
249+
250+
for event in ['cap', 'authenticate', '903', '908', 'welcome']:
251+
logger.debug('Registering for %s', event)
252+
self.connection.add_global_handler(
253+
event, getattr(self, '_handle_%s' % event))
254+
255+
def _handle_connect(self, sock):
256+
"""Send CAP REQ :sasl on connect."""
257+
self.connection.cap('REQ', 'sasl')
258+
259+
def _handle_cap(self, conn, event):
260+
"""Handle CAP responses."""
261+
if event.arguments and event.arguments[0] == 'ACK':
262+
conn.send_raw('AUTHENTICATE PLAIN')
263+
else:
264+
logger.warning('Unexpcted CAP response: %s', event)
265+
conn.disconnect()
266+
267+
def _handle_authenticate(self, conn, event):
268+
"""Handle AUTHENTICATE responses."""
269+
if event.target == '+':
270+
creds = '{username}\0{username}\0{password}'.format(
271+
username=self._username,
272+
password=self._ident_password)
273+
conn.send_raw('AUTHENTICATE {}'.format(
274+
base64.b64encode(creds.encode('utf8')).decode('utf8')))
275+
else:
276+
logger.warning('Unexpcted AUTHENTICATE response: %s', event)
277+
conn.disconnect()
278+
279+
def _handle_903(self, conn, event):
280+
"""Handle 903 RPL_SASLSUCCESS responses."""
281+
self.connection.cap('END')
282+
283+
def _handle_908(self, conn, event):
284+
"""Handle 908 RPL_SASLMECHS responses."""
285+
logger.warning('SASL PLAIN not supported: %s', event)
286+
self.die()
287+
288+
def _handle_welcome(self, conn, event):
289+
"""Handle WELCOME message."""
290+
logger.info('Connected to server %s', conn.get_server_name())
291+
self.join_channels(self._channels)
292+
293+
294+
class SSL(object):
295+
"""Use SSL connections."""
296+
def __init__(self, *args, **kwargs):
297+
kwargs['connect_factory'] = irc.connection.Factory(
298+
wrapper=ssl.wrap_socket)
299+
super(SSL, self).__init__(*args, **kwargs)

0 commit comments

Comments
 (0)