3
0
mirror of https://github.com/jlu5/PyLink.git synced 2024-12-26 04:32:51 +01:00

Merge branch 'staging'

This commit is contained in:
James Lu 2016-07-09 00:20:45 -07:00
commit 0d0cccea63
50 changed files with 2316 additions and 1717 deletions

6
.gitignore vendored
View File

@ -3,6 +3,9 @@
!example-*.yml !example-*.yml
!.*.yml !.*.yml
# Ignore automatically generated version for normal commits. This is bumped manually when needed.
__init__.py
env/ env/
build/ build/
__pycache__/ __pycache__/
@ -13,3 +16,6 @@ __pycache__/
*.db *.db
*.pid *.pid
*.pem *.pem
.eggs
*.egg-info/
dist/

167
LICENSE.CC-BY-SA-4.0 Normal file
View File

@ -0,0 +1,167 @@
Attribution-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More_considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-ShareAlike 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-ShareAlike 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or h

View File

@ -14,40 +14,49 @@ Please report any bugs you find to the [issue tracker](https://github.com/GLolol
You can also find support via our IRC channels: `#PyLink @ irc.overdrivenetworks.com `([webchat](https://webchat.overdrivenetworks.com/?channels=PyLink,dev)) or `#PyLink @ chat.freenode.net`. Ask your questions and be patient for a response. You can also find support via our IRC channels: `#PyLink @ irc.overdrivenetworks.com `([webchat](https://webchat.overdrivenetworks.com/?channels=PyLink,dev)) or `#PyLink @ chat.freenode.net`. Ask your questions and be patient for a response.
## License
PyLink and any bundled software are licensed under the Mozilla Public License, version 2.0 ([LICENSE.MPL2](LICENSE.MPL2)). The corresponding documentation in the [docs/](docs/) folder is licensed under the Creative Attribution-ShareAlike 4.0 International License. ([LICENSE.CC-BY-SA-4.0](LICENSE.CC-BY-SA-4.0)
## Dependencies ## Dependencies
* Python 3.4+ * Python 3.4+
* PyYAML (`pip install pyyaml`) * PyYAML (`pip install pyyaml`)
* [ircmatch](https://github.com/mammon-ircd/ircmatch) (`pip install ircmatch`)
* *For the servprotect plugin*: [expiringdict](https://github.com/mailgun/expiringdict) (note: unfortunately, installation is broken in pip due to [mailgun/expiringdict#13](https://github.com/mailgun/expiringdict/issues/13)) * *For the servprotect plugin*: [expiringdict](https://github.com/mailgun/expiringdict) (note: unfortunately, installation is broken in pip due to [mailgun/expiringdict#13](https://github.com/mailgun/expiringdict/issues/13))
* *For the changehost and opercmds plugins*: [ircmatch](https://github.com/mammon-ircd/ircmatch) (`pip install ircmatch`)
## Supported IRCds ## Supported IRCds
### Primary support ### Primary support
These IRCds are frequently tested and well supported. If any issues occur, please file a bug on the issue tracker. These IRCds (in alphabetical order) are frequently tested and well supported. If any issues occur, please file a bug on the issue tracker.
* charybdis (3.5.x / git master) - module `ts6` * [charybdis](http://charybdis.io/) (3.5+ / git master) - module `ts6`
* InspIRCd 2.0.x - module `inspircd` * [InspIRCd](http://www.inspircd.org/) 2.0.x - module `inspircd`
* UnrealIRCd 4.x - module `unreal` * [UnrealIRCd](https://www.unrealircd.org/) 4.x - module `unreal`
- Note: Support for mixed UnrealIRCd 3.2/4.0 networks is experimental, and requires you to enable a `mixed_link` option in the configuration. This may in turn void your support. - Note: Support for mixed UnrealIRCd 3.2/4.0 networks is experimental, and requires you to enable a `mixed_link` option in the configuration. This may in turn void your support.
### Extended support ### Extended support
Support for these IRCds exist, but are not tested as frequently and thoroughly. Bugs should be filed if there are any issues, though they may not be always be fixed in a timely fashion. Support for these IRCds exist, but are not tested as frequently and thoroughly. Bugs should be filed if there are any issues, though they may not always be fixed in a timely fashion.
* Elemental-IRCd (6.6.x / git master) - module `ts6` * [Elemental-IRCd](https://github.com/Elemental-IRCd/elemental-ircd) (6.6.x / git master) - module `ts6`
* InspIRCd 2.2 (git master) - module `inspircd` * InspIRCd 2.2 (git master) - module `inspircd`
* IRCd-Hybrid (8.2.x / svn trunk) - module `hybrid` * [IRCd-Hybrid](http://www.ircd-hybrid.org/) (8.2.x / svn trunk) - module `hybrid`
- Note: for host changing support and optimal functionality, a `service{}` block / U-line should be added for PyLink on every IRCd across your network. - Note: for host changing support and optimal functionality, a `service{}` block / U-line should be added for PyLink on every IRCd across your network.
* Nefarious IRCu (2.0.0+) - module `nefarious` * [juno-ircd](https://github.com/cooper/yiria) (10.x / yiria) - module `ts6` (with elemental-ircd modes)
* [Nefarious IRCu](https://github.com/evilnet/nefarious2) (2.0.0+) - module `nefarious`
- Note: Both account cloaks (user and oper) and hashed IP cloaks are optionally supported (HOST_HIDING_STYLE settings 0 to 3). Make sure you configure PyLink to match your IRCd settings. - Note: Both account cloaks (user and oper) and hashed IP cloaks are optionally supported (HOST_HIDING_STYLE settings 0 to 3). Make sure you configure PyLink to match your IRCd settings.
- For optimal functionality (mode overrides in relay, etc.), a `UWorld{}` block / U-line should be added for every server that PyLink spawns. To make this easier, you may want to turn relay's spawn_servers off, so that all relay users originate from one virtual server. - For optimal functionality (mode overrides in relay, etc.), a `UWorld{}` block / U-line should be added for every server that PyLink spawns.
Other TS6 and P10 variations may work, but are not officially supported.
## Setup ## Setup
1) Rename `example-conf.yml` to `config.yml` and configure your instance there. Note that the configuration format isn't finalized yet - this means that your configuration may break in an update! 1) Install PyLink by using `python3 setup.py install` (global install) or `python3 setup.py install --user` (local install)
2) Run `./pylink` from the command line. 2) Rename `example-conf.yml` to `pylink.yml` and configure your instance there. Note that the configuration format isn't finalized yet - this means that your configuration may break in an update!
3) Profit??? 3) Run `pylink` from the command line.
4) Profit???

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Automatically generated by setup.py
__version__ = '0.9-dev1-27-g14b30b2'

View File

@ -11,16 +11,15 @@ import threading
from random import randint from random import randint
import time import time
import socket import socket
import threading
import ssl import ssl
import hashlib import hashlib
from copy import deepcopy from copy import deepcopy
import inspect import inspect
from log import * import ircmatch
import world
import utils from . import world, utils, structures, __version__
import structures from .log import *
### Exceptions ### Exceptions
@ -45,7 +44,7 @@ class Irc():
self.sid = self.serverdata["sid"] self.sid = self.serverdata["sid"]
self.botdata = conf['bot'] self.botdata = conf['bot']
self.bot_clients = {} self.bot_clients = {}
self.protoname = proto.__name__ self.protoname = proto.__name__.split('.')[-1] # Remove leading pylinkirc.protocols.
self.proto = proto.Class(self) self.proto = proto.Class(self)
self.pingfreq = self.serverdata.get('pingfreq') or 90 self.pingfreq = self.serverdata.get('pingfreq') or 90
self.pingtimeout = self.pingfreq * 2 self.pingtimeout = self.pingfreq * 2
@ -106,9 +105,10 @@ class Irc():
self.pseudoclient = None self.pseudoclient = None
self.lastping = time.time() self.lastping = time.time()
# Internal variable to set the place the last command was called (in PM # Internal variable to set the place and caller of the last command (in PM
# or in a channel), used by fantasy command support. # or in a channel), used by fantasy command support.
self.called_by = None self.called_by = None
self.called_in = None
# Intialize the server, channel, and user indexes to be populated by # Intialize the server, channel, and user indexes to be populated by
# our protocol module. For the server index, we can add ourselves right # our protocol module. For the server index, we can add ourselves right
@ -148,9 +148,8 @@ class Irc():
# This max nick length starts off as the config value, but may be # This max nick length starts off as the config value, but may be
# overwritten later by the protocol module if such information is # overwritten later by the protocol module if such information is
# received. Note that only some IRCds (InspIRCd) give us nick length # received. It defaults to 30.
# during link, so it is still required that the config value be set! self.maxnicklen = self.serverdata.get('maxnicklen', 30)
self.maxnicklen = self.serverdata['maxnicklen']
# Defines a list of supported prefix modes. # Defines a list of supported prefix modes.
self.prefixmodes = {'o': '@', 'v': '+'} self.prefixmodes = {'o': '@', 'v': '+'}
@ -463,9 +462,21 @@ class Irc():
cmd = 'PYLINK_SELF_PRIVMSG' cmd = 'PYLINK_SELF_PRIVMSG'
self.callHooks([source, cmd, {'target': target, 'text': text}]) self.callHooks([source, cmd, {'target': target, 'text': text}])
def reply(self, text, notice=False, source=None): def reply(self, text, notice=False, source=None, private=False, force_privmsg_in_private=False):
"""Replies to the last caller in the right context (channel or PM).""" """Replies to the last caller in the right context (channel or PM)."""
self.msg(self.called_by, text, notice=notice, source=source)
# Private reply is enabled, or the caller was originally a PM
if private or (self.called_in in self.users):
if not force_privmsg_in_private:
# For private replies, the default is to override the notice=True/False argument,
# and send replies as notices regardless. This is standard behaviour for most
# IRC services, but can be disabled if force_privmsg_in_private is given.
notice = True
target = self.called_by
else:
target = self.called_in
self.msg(target, text, notice=notice, source=source)
def toLower(self, text): def toLower(self, text):
"""Returns a lowercase representation of text based on the IRC object's """Returns a lowercase representation of text based on the IRC object's
@ -486,6 +497,11 @@ class Irc():
# B = Mode that changes a setting and always has a parameter. # B = Mode that changes a setting and always has a parameter.
# C = Mode that changes a setting and only has a parameter when set. # C = Mode that changes a setting and only has a parameter when set.
# D = Mode that changes a setting and never has a parameter. # D = Mode that changes a setting and never has a parameter.
if type(args) == str:
# If the modestring was given as a string, split it into a list.
args = args.split()
assert args, 'No valid modes were supplied!' assert args, 'No valid modes were supplied!'
usermodes = not utils.isChannel(target) usermodes = not utils.isChannel(target)
prefix = '' prefix = ''
@ -503,10 +519,6 @@ class Irc():
else: else:
log.debug('(%s) Using self.cmodes for this query: %s', self.name, self.cmodes) log.debug('(%s) Using self.cmodes for this query: %s', self.name, self.cmodes)
if target not in self.channels:
log.warning('(%s) Possible desync! Mode target %s is not in the channels index.', self.name, target)
return []
supported_modes = self.cmodes supported_modes = self.cmodes
oldmodes = self.channels[target].modes oldmodes = self.channels[target].modes
res = [] res = []
@ -633,10 +645,13 @@ class Irc():
else: else:
modelist.discard(real_mode) modelist.discard(real_mode)
log.debug('(%s) Final modelist: %s', self.name, modelist) log.debug('(%s) Final modelist: %s', self.name, modelist)
try:
if usermodes: if usermodes:
self.users[target].modes = modelist self.users[target].modes = modelist
else: else:
self.channels[target].modes = modelist self.channels[target].modes = modelist
except KeyError:
log.warning("(%s) Invalid MODE target %s (usermodes=%s)", self.name, target, usermodes)
@staticmethod @staticmethod
def _flip(mode): def _flip(mode):
@ -738,6 +753,10 @@ class Irc():
prefix = '+' # Assume we're adding modes unless told otherwise prefix = '+' # Assume we're adding modes unless told otherwise
modelist = '' modelist = ''
args = [] args = []
# Sort modes alphabetically like a conventional IRCd.
modes = sorted(modes)
for modepair in modes: for modepair in modes:
mode, arg = modepair mode, arg = modepair
assert len(mode) in (1, 2), "Incorrect length of a mode (received %r)" % mode assert len(mode) in (1, 2), "Incorrect length of a mode (received %r)" % mode
@ -771,7 +790,7 @@ class Irc():
Returns a detailed version string including the PyLink daemon version, Returns a detailed version string including the PyLink daemon version,
the protocol module in use, and the server hostname. the protocol module in use, and the server hostname.
""" """
fullversion = 'PyLink-%s. %s :[protocol:%s]' % (world.version, self.serverdata['hostname'], self.protoname) fullversion = 'PyLink-%s. %s :[protocol:%s]' % (__version__, self.serverdata['hostname'], self.protoname)
return fullversion return fullversion
### State checking functions ### State checking functions
@ -853,6 +872,16 @@ class Irc():
return '%s!%s@%s' % (nick, ident, host) return '%s!%s@%s' % (nick, ident, host)
def getFriendlyName(self, entityid):
"""
Returns the friendly name of a SID or UID (server name for SIDs, nick for UID)."""
if entityid in self.servers:
return self.servers[entityid].name
elif entityid in self.users:
return self.users[entityid].nick
else:
raise KeyError("Unknown UID/SID %s" % entityid)
def isOper(self, uid, allowAuthed=True, allowOper=True): def isOper(self, uid, allowAuthed=True, allowOper=True):
""" """
Returns whether the given user has operator status on PyLink. This can be achieved Returns whether the given user has operator status on PyLink. This can be achieved
@ -880,6 +909,61 @@ class Irc():
raise utils.NotAuthenticatedError("You are not authenticated!") raise utils.NotAuthenticatedError("You are not authenticated!")
return True return True
def matchHost(self, glob, target, ip=True, realhost=True):
"""
Checks whether the given host, or given UID's hostmask matches the given nick!user@host
glob.
If the target given is a UID, and the ip or realhost options are True, this will also match
against the target's IP address and real host, respectively.
"""
# Get the corresponding casemapping value used by ircmatch.
casemapping = getattr(ircmatch, self.proto.casemapping)
# Try to convert target into a UID. If this fails, it's probably a hostname.
target = self.nickToUid(target) or target
# Prepare a list of hosts to check against.
if target in self.users:
if glob.startswith(('$', '!$')):
# !$exttarget inverts the given match.
invert = glob.startswith('!$')
# Exttargets start with $. Skip regular ban matching and find the matching ban handler.
glob = glob.lstrip('$!')
exttargetname = glob.split(':', 1)[0]
handler = world.exttarget_handlers.get(exttargetname)
if handler:
# Handler exists. Return what it finds.
result = handler(self, glob, target)
log.debug('(%s) Got %s from exttarget %s in matchHost() glob $%s for target %s',
self.name, result, exttargetname, glob, target)
if invert: # Anti-exttarget was specified.
result = not result
return result
else:
log.debug('(%s) Unknown exttarget %s in matchHost() glob $%s', self.name,
exttargetname, glob)
return False
hosts = {self.getHostmask(target)}
if ip:
hosts.add(self.getHostmask(target, ip=True))
if realhost:
hosts.add(self.getHostmask(target, ip=True))
else: # We were given a host, use that.
hosts = [target]
# Iterate over the hosts to match using ircmatch.
for host in hosts:
if ircmatch.match(casemapping, glob, host):
return True
return False
class IrcUser(): class IrcUser():
"""PyLink IRC user class.""" """PyLink IRC user class."""
def __init__(self, nick, ts, uid, ident='null', host='null', def __init__(self, nick, ts, uid, ident='null', host='null',
@ -1073,24 +1157,58 @@ class Protocol():
log.debug('Removing client %s from self.irc.servers[%s].users', numeric, sid) log.debug('Removing client %s from self.irc.servers[%s].users', numeric, sid)
self.irc.servers[sid].users.discard(numeric) self.irc.servers[sid].users.discard(numeric)
def updateTS(self, channel, their_ts): def updateTS(self, channel, their_ts, modes=[]):
""" """
Compares the current TS of the channel given with the new TS, resetting Merges modes of a channel given the remote TS and a list of modes.
all modes we have if the one given is older.
""" """
our_ts = self.irc.channels[channel].ts def _clear():
log.debug("(%s) Clearing modes from channel %s due to TS change", self.irc.name,
if their_ts < our_ts: channel)
# Channel timestamp was reset on burst
log.debug('(%s) Setting channel TS of %s to %s from %s',
self.irc.name, channel, their_ts, our_ts)
self.irc.channels[channel].ts = their_ts
# When TS is reset, clear all modes we currently have
self.irc.channels[channel].modes.clear() self.irc.channels[channel].modes.clear()
for p in self.irc.channels[channel].prefixmodes.values(): for p in self.irc.channels[channel].prefixmodes.values():
p.clear() p.clear()
def _apply():
if modes:
log.debug("(%s) Applying modes on channel %s (TS ok)", self.irc.name,
channel)
self.irc.applyModes(channel, modes)
our_ts = self.irc.channels[channel].ts
assert type(our_ts) == int, "Wrong type for our_ts (expected int, got %s)" % type(our_ts)
assert type(their_ts) == int, "Wrong type for their_ts (expected int, got %s)" % type(their_ts)
if their_ts < our_ts:
# Their TS is older than ours. We should clear our stored modes for the channel and
# apply the ones in the queue to be set. This is regardless of whether we're sending
# outgoing modes or receiving some - both are handled the same with a "received" TS,
# and comparing it with the one we have.
log.debug("(%s/%s) received TS of %s is lower than ours %s; setting modes %s",
self.irc.name, channel, their_ts, our_ts, modes)
# Update the channel TS to theirs regardless of whether the mode setting passes.
log.debug('(%s) Setting channel TS of %s to %s from %s',
self.irc.name, channel, their_ts, our_ts)
self.irc.channels[channel].ts = their_ts
_clear()
_apply()
elif their_ts == our_ts:
log.debug("(%s/%s) remote TS of %s is equal to ours %s; setting modes %s",
self.irc.name, channel, their_ts, our_ts, modes)
# Their TS is equal to ours. Merge modes.
_apply()
elif their_ts > our_ts:
log.debug("(%s/%s) remote TS of %s is higher than ours %s; setting modes %s",
self.irc.name, channel, their_ts, our_ts, modes)
# Their TS is younger than ours. Clear the state and replace the modes for the channel
# with the ones being set.
_clear()
_apply()
def _getSid(self, sname): def _getSid(self, sname):
"""Returns the SID of a server with the given name, if present.""" """Returns the SID of a server with the given name, if present."""
name = sname.lower() name = sname.lower()

70
conf.py
View File

@ -1,54 +1,43 @@
""" """
conf.py - PyLink configuration core. conf.py - PyLink configuration core.
This module is used to access the complete configuration for the current This module is used to access the configuration of the current PyLink instance.
PyLink instance. It will load the config on first import, taking the It provides simple checks for validating and loading YAML-format configurations from arbitrary files.
configuration file name from the first command-line argument, but defaulting
to 'config.yml' if this isn't given.
If world.testing is set to True, it will return a preset testing configuration
instead.
This module also provides simple checks for validating and loading YAML-format
configurations from arbitrary files.
""" """
import yaml import yaml
import sys import sys
from collections import defaultdict from collections import defaultdict
import world from . import world
global testconf conf = {'bot':
testconf = {'bot':
{ {
'nick': 'PyLink', 'nick': 'PyLink',
'user': 'pylink', 'user': 'pylink',
'realname': 'PyLink Service Client', 'realname': 'PyLink Service Client',
'serverdesc': 'PyLink unit tests' 'serverdesc': 'Unconfigured PyLink'
}, },
'logging': 'logging':
{ {
# Suppress logging in the test output for the most part. 'stdout': 'INFO'
'stdout': 'CRITICAL'
}, },
'servers': 'servers':
# Wildcard defaultdict! This means that # Wildcard defaultdict! This means that
# any network name you try will work and return # any network name you try will work and return
# this basic template: # this basic template:
defaultdict(lambda: { defaultdict(lambda: {'ip': '0.0.0.0',
'ip': '0.0.0.0',
'port': 7000, 'port': 7000,
'recvpass': "abcd", 'recvpass': "unconfigured",
'sendpass': "chucknorris", 'sendpass': "unconfigured",
'protocol': "null", 'protocol': "null",
'hostname': "pylink.unittest", 'hostname': "pylink.unconfigured",
'sid': "9PY", 'sid': "000",
'channels': ["#pylink"],
'maxnicklen': 20, 'maxnicklen': 20,
'sidrange': '8##' 'sidrange': '0##'
}) })
} }
confname = 'unconfigured'
def validateConf(conf): def validateConf(conf):
"""Validates a parsed configuration dict.""" """Validates a parsed configuration dict."""
@ -59,44 +48,27 @@ def validateConf(conf):
for netname, serverblock in conf['servers'].items(): for netname, serverblock in conf['servers'].items():
for section in ('ip', 'port', 'recvpass', 'sendpass', 'hostname', for section in ('ip', 'port', 'recvpass', 'sendpass', 'hostname',
'sid', 'sidrange', 'protocol', 'maxnicklen'): 'sid', 'sidrange', 'protocol'):
assert serverblock.get(section), "Missing %r in server block for %r." % (section, netname) assert serverblock.get(section), "Missing %r in server block for %r." % (section, netname)
assert type(serverblock.get('channels')) == list, "'channels' option in " \
"server block for %s must be a list, not %s." % (netname, type(serverblock['channels']).__name__)
assert type(conf['login'].get('password')) == type(conf['login'].get('user')) == str and \ assert type(conf['login'].get('password')) == type(conf['login'].get('user')) == str and \
conf['login']['password'] != "changeme", "You have not set the login details correctly!" conf['login']['password'] != "changeme", "You have not set the login details correctly!"
return conf return conf
def loadConf(fname, errors_fatal=True): def loadConf(filename, errors_fatal=True):
"""Loads a PyLink configuration file from the filename given.""" """Loads a PyLink configuration file from the filename given."""
with open(fname, 'r') as f: global confname, conf, fname
fname = filename
confname = filename.split('.', 1)[0]
with open(filename, 'r') as f:
try: try:
conf = yaml.load(f) conf = yaml.load(f)
conf = validateConf(conf)
except Exception as e: except Exception as e:
print('ERROR: Failed to load config from %r: %s: %s' % (fname, type(e).__name__, e)) print('ERROR: Failed to load config from %r: %s: %s' % (filename, type(e).__name__, e))
if errors_fatal: if errors_fatal:
sys.exit(4) sys.exit(4)
raise raise
else: else:
return conf return conf
if world.testing:
conf = testconf
confname = 'testconf'
fname = None
else:
try:
# Get the config name from the command line, falling back to config.yml
# if not given.
fname = sys.argv[1]
confname = fname.split('.', 1)[0]
except IndexError:
# confname is used for logging and PID writing, so that each
# instance uses its own files. fname is the actual name of the file
# we load.
confname = 'pylink'
fname = 'config.yml'
conf = validateConf(loadConf(fname))

2
coremods/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Service support has to be imported first, so that utils.add_cmd works
from . import service_support, control, handlers, corecommands, exttargets

79
coremods/control.py Normal file
View File

@ -0,0 +1,79 @@
"""
control.py - Implements SHUTDOWN and REHASH functionality.
"""
import signal
import os
from pylinkirc import world, utils, conf, classes
from pylinkirc.log import log
def remove_network(ircobj):
"""Removes a network object from the pool."""
# Disable autoconnect first by setting the delay negative.
ircobj.serverdata['autoconnect'] = -1
ircobj.disconnect()
del world.networkobjects[ircobj.name]
def _shutdown(irc=None):
"""Shuts down the Pylink daemon."""
for name, plugin in world.plugins.items():
# Before closing connections, tell all plugins to shutdown cleanly first.
if hasattr(plugin, 'die'):
log.debug('coremods.control: Running die() on plugin %s due to shutdown.', name)
try:
plugin.die(irc)
except: # But don't allow it to crash the server.
log.exception('coremods.control: Error occurred in die() of plugin %s, skipping...', name)
for ircobj in world.networkobjects.copy().values():
# Disconnect all our networks.
remove_network(ircobj)
def sigterm_handler(_signo, _stack_frame):
"""Handles SIGTERM gracefully by shutting down the PyLink daemon."""
log.info("Shutting down on SIGTERM.")
_shutdown()
signal.signal(signal.SIGTERM, sigterm_handler)
def _rehash():
"""Rehashes the PyLink daemon."""
old_conf = conf.conf.copy()
fname = conf.fname
new_conf = conf.loadConf(fname, errors_fatal=False)
new_conf = conf.validateConf(new_conf)
conf.conf = new_conf
for network, ircobj in world.networkobjects.copy().items():
# Server was removed from the config file, disconnect them.
log.debug('rehash: checking if %r is in new conf still.', network)
if network not in new_conf['servers']:
log.debug('rehash: removing connection to %r (removed from config).', network)
remove_network(ircobj)
else:
ircobj.conf = new_conf
ircobj.serverdata = new_conf['servers'][network]
ircobj.botdata = new_conf['bot']
# Clear the IRC object's channel loggers and replace them with
# new ones by re-running logSetup().
while ircobj.loghandlers:
log.removeHandler(ircobj.loghandlers.pop())
ircobj.logSetup()
# TODO: update file loggers here too.
for network, sdata in new_conf['servers'].items():
# Connect any new networks or disconnected networks if they aren't already.
if (network not in world.networkobjects) or (not world.networkobjects[network].connection_thread.is_alive()):
proto = utils.getProtocolModule(sdata['protocol'])
world.networkobjects[network] = classes.Irc(network, proto, new_conf)
if os.name == 'posix':
# Only register SIGHUP on *nix.
def sighup_handler(_signo, _stack_frame):
"""Handles SIGHUP by rehashing the PyLink daemon."""
log.info("SIGHUP received, reloading config.")
_rehash()
signal.signal(signal.SIGHUP, sighup_handler)

175
coremods/corecommands.py Normal file
View File

@ -0,0 +1,175 @@
"""
corecommands.py - Implements core PyLink commands.
"""
import gc
import sys
import importlib
from . import control
from pylinkirc import utils, world
from pylinkirc.log import log
# Essential, core commands go here so that the "commands" plugin with less-important,
# but still generic functions can be reloaded.
@utils.add_cmd
def identify(irc, source, args):
"""<username> <password>
Logs in to PyLink using the configured administrator account."""
if utils.isChannel(irc.called_by):
irc.reply('Error: This command must be sent in private. '
'(Would you really type a password inside a channel?)')
return
try:
username, password = args[0], args[1]
except IndexError:
irc.msg(source, 'Error: Not enough arguments.')
return
# Usernames are case-insensitive, passwords are NOT.
if username.lower() == irc.conf['login']['user'].lower() and password == irc.conf['login']['password']:
realuser = irc.conf['login']['user']
irc.users[source].identified = realuser
irc.msg(source, 'Successfully logged in as %s.' % realuser)
log.info("(%s) Successful login to %r by %s",
irc.name, username, irc.getHostmask(source))
else:
irc.msg(source, 'Error: Incorrect credentials.')
u = irc.users[source]
log.warning("(%s) Failed login to %r from %s",
irc.name, username, irc.getHostmask(source))
@utils.add_cmd
def shutdown(irc, source, args):
"""takes no arguments.
Exits PyLink by disconnecting all networks."""
irc.checkAuthenticated(source, allowOper=False)
u = irc.users[source]
log.info('(%s) SHUTDOWN requested by "%s!%s@%s", exiting...', irc.name, u.nick,
u.ident, u.host)
control._shutdown(irc)
@utils.add_cmd
def load(irc, source, args):
"""<plugin name>.
Loads a plugin from the plugin folder."""
irc.checkAuthenticated(source, allowOper=False)
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if name in world.plugins:
irc.reply("Error: %r is already loaded." % name)
return
log.info('(%s) Loading plugin %r for %s', irc.name, name, irc.getHostmask(source))
try:
world.plugins[name] = pl = utils.loadPlugin(name)
except ImportError as e:
if str(e) == ('No module named %r' % name):
log.exception('Failed to load plugin %r: The plugin could not be found.', name)
else:
log.exception('Failed to load plugin %r: ImportError.', name)
raise
else:
importlib.reload(pl)
if hasattr(pl, 'main'):
log.debug('Calling main() function of plugin %r', pl)
pl.main(irc)
irc.reply("Loaded plugin %r." % name)
@utils.add_cmd
def unload(irc, source, args):
"""<plugin name>.
Unloads a currently loaded plugin."""
irc.checkAuthenticated(source, allowOper=False)
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if name in world.plugins:
log.info('(%s) Unloading plugin %r for %s', irc.name, name, irc.getHostmask(source))
pl = world.plugins[name]
log.debug('sys.getrefcount of plugin %s is %s', pl, sys.getrefcount(pl))
# Remove any command functions defined by the plugin.
for cmdname, cmdfuncs in world.services['pylink'].commands.copy().items():
log.debug('cmdname=%s, cmdfuncs=%s', cmdname, cmdfuncs)
for cmdfunc in cmdfuncs:
log.debug('__module__ of cmdfunc %s is %s', cmdfunc, cmdfunc.__module__)
if cmdfunc.__module__ == name:
log.debug("Removing %s from world.services['pylink'].commands[%s]", cmdfunc, cmdname)
world.services['pylink'].commands[cmdname].remove(cmdfunc)
# If the cmdfunc list is empty, remove it.
if not cmdfuncs:
log.debug("Removing world.services['pylink'].commands[%s] (it's empty now)", cmdname)
del world.services['pylink'].commands[cmdname]
# Remove any command hooks set by the plugin.
for hookname, hookfuncs in world.hooks.copy().items():
for hookfunc in hookfuncs:
if hookfunc.__module__ == name:
world.hooks[hookname].remove(hookfunc)
# If the hookfuncs list is empty, remove it.
if not hookfuncs:
del world.hooks[hookname]
# Call the die() function in the plugin, if present.
if hasattr(pl, 'die'):
try:
pl.die(irc)
except: # But don't allow it to crash the server.
log.exception('(%s) Error occurred in die() of plugin %s, skipping...', irc.name, pl)
# Delete it from memory (hopefully).
del world.plugins[name]
if name in sys.modules:
del sys.modules[name]
if name in globals():
del globals()[name]
# Garbage collect.
gc.collect()
irc.reply("Unloaded plugin %r." % name)
return True # We succeeded, make it clear (this status is used by reload() below)
else:
irc.reply("Unknown plugin %r." % name)
@utils.add_cmd
def reload(irc, source, args):
"""<plugin name>.
Loads a plugin from the plugin folder."""
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if unload(irc, source, args):
load(irc, source, args)
@utils.add_cmd
def rehash(irc, source, args):
"""takes no arguments.
Reloads the configuration file for PyLink, (dis)connecting added/removed networks.
Plugins must be manually reloaded."""
irc.checkAuthenticated(source, allowOper=False)
try:
control._rehash()
except Exception as e: # Something went wrong, abort.
log.exception("Error REHASHing config: ")
irc.reply("Error loading configuration file: %s: %s" % (type(e).__name__, e))
return
else:
irc.reply("Done.")

146
coremods/exttargets.py Normal file
View File

@ -0,0 +1,146 @@
"""
exttargets.py - Implements extended targets like $account:xyz, $oper, etc.
"""
from pylinkirc import world
from pylinkirc.log import log
def bind(func):
"""
Binds an exttarget with the given name.
"""
world.exttarget_handlers[func.__name__] = func
return func
@bind
def account(irc, host, uid):
"""
$account exttarget handler. The following forms are supported, with groups separated by a
literal colon. Account matching is case insensitive, while network name matching IS case
sensitive.
$account -> Returns True (a match) if the target is registered.
$account:accountname -> Returns True if the target's account name matches the one given, and the
target is connected to the local network..
$account:accountname:netname -> Returns True if both the target's account name and origin
network name match the ones given.
$account:*:netname -> Matches all logged in users on the given network.
"""
userobj = irc.users[uid]
homenet = irc.name
if hasattr(userobj, 'remote'):
# User is a PyLink Relay pseudoclient. Use their real services account on their
# origin network.
homenet, realuid = userobj.remote
log.debug('(%s) exttargets.account: Changing UID of relay client %s to %s/%s', irc.name,
uid, homenet, realuid)
try:
userobj = world.networkobjects[homenet].users[realuid]
except KeyError: # User lookup failed. Bail and return False.
log.exception('(%s) exttargets.account: KeyError finding %s/%s:', irc.name,
homenet, realuid)
return False
slogin = irc.toLower(userobj.services_account)
# Split the given exttarget host into parts, so we know how many to look for.
groups = list(map(irc.toLower, host.split(':')))
log.debug('(%s) exttargets.account: groups to match: %s', irc.name, groups)
if len(groups) == 1:
# First scenario. Return True if user is logged in.
return bool(slogin)
elif len(groups) == 2:
# Second scenario. Return True if the user's account matches the one given.
return slogin == groups[1] and homenet == irc.name
else:
# Third or fourth scenario. If there are more than 3 groups, the rest are ignored.
# In other words: Return True if the user is logged in, the query matches either '*' or the
# user's login, and the user is connected on the network requested.
return slogin and (groups[1] in ('*', slogin)) and (homenet == groups[2])
@bind
def ircop(irc, host, uid):
"""
$ircop exttarget handler. The following forms are supported, with groups separated by a
literal colon. Oper types are matched case insensitively.
$ircop -> Returns True (a match) if the target is opered.
$ircop:*admin* -> Returns True if the target's is opered and their opertype matches the glob
given.
"""
groups = host.split(':')
log.debug('(%s) exttargets.ircop: groups to match: %s', irc.name, groups)
if len(groups) == 1:
# 1st scenario.
return irc.isOper(uid, allowAuthed=False)
else:
# 2nd scenario. Use matchHost (ircmatch) to match the opertype glob to the opertype.
return irc.matchHost(groups[1], irc.users[uid].opertype)
@bind
def server(irc, host, uid):
"""
$server exttarget handler. The following forms are supported, with groups separated by a
literal colon. Server names are matched case insensitively, but SIDs ARE case sensitive.
$server:server.name -> Returns True (a match) if the target is connected on the given server.
$server:server.glob -> Returns True (a match) if the target is connected on a server matching the glob.
$server:1XY -> Returns True if the target's is connected on the server with the given SID.
"""
groups = host.split(':')
log.debug('(%s) exttargets.server: groups to match: %s', irc.name, groups)
if len(groups) >= 2:
sid = irc.getServer(uid)
query = groups[1]
# Return True if the SID matches the query or the server's name glob matches it.
return sid == query or irc.matchHost(query, irc.getFriendlyName(sid))
# $server alone is invalid. Don't match anything.
return False
@bind
def channel(irc, host, uid):
"""
$channel exttarget handler. The following forms are supported, with groups separated by a
literal colon. Channel names are matched case insensitively.
$channel:#channel -> Returns True if the target is in the given channel.
$channel:#channel:op -> Returns True if the target is in the given channel, and is opped.
Any other supported prefix (owner, admin, op, halfop, voice) can be given, but only one at a
time.
"""
groups = host.split(':')
log.debug('(%s) exttargets.channel: groups to match: %s', irc.name, groups)
try:
channel = groups[1]
except IndexError: # No channel given, abort.
return False
if len(groups) == 2:
# Just #channel was given as query
return uid in irc.channels[channel].users
elif len(groups) >= 3:
# For things like #channel:op, check if the query is in the user's prefix modes.
return groups[2].lower() in irc.channels[channel].getPrefixModes(uid)
@bind
def pylinkacc(irc, host, uid):
"""
$pylinkacc (PyLink account) exttarget handler. The following forms are supported, with groups
separated by a literal colon. Account matching is case insensitive.
$pylinkacc -> Returns True if the target is logged in to PyLink.
$pylinkacc:accountname -> Returns True if the target's PyLink login matches the one given.
"""
login = irc.toLower(irc.users[uid].identified)
groups = list(map(irc.toLower, host.split(':')))
log.debug('(%s) exttargets.pylinkacc: groups to match: %s', irc.name, groups)
if len(groups) == 1:
# First scenario. Return True if user is logged in.
return bool(login)
elif len(groups) == 2:
# Second scenario. Return True if the user's login matches the one given.
return login == groups[1]

137
coremods/handlers.py Normal file
View File

@ -0,0 +1,137 @@
"""
handlers.py - Implements miscellaneous IRC command handlers (WHOIS, services login, etc.)
"""
from pylinkirc import utils
from pylinkirc.log import log
def handle_whois(irc, source, command, args):
"""Handle WHOIS queries, for IRCds that send them across servers (charybdis, UnrealIRCd; NOT InspIRCd)."""
target = args['target']
user = irc.users.get(target)
if user is None:
log.warning('(%s) Got a WHOIS request for %r from %r, but the target '
'doesn\'t exist in irc.users!', irc.name, target, source)
return
f = irc.proto.numeric
server = irc.getServer(target) or irc.sid
nick = user.nick
sourceisOper = ('o', None) in irc.users[source].modes
# Get the full network name.
netname = irc.serverdata.get('netname', irc.name)
# https://www.alien.net.au/irc/irc2numerics.html
# 311: sends nick!user@host information
f(server, 311, source, "%s %s %s * :%s" % (nick, user.ident, user.host, user.realname))
# 319: RPL_WHOISCHANNELS; Show public channels of the target, respecting
# hidechans umodes for non-oper callers.
isHideChans = (irc.umodes.get('hidechans'), None) in user.modes
if (not isHideChans) or (isHideChans and sourceisOper):
public_chans = []
for chan in user.channels:
c = irc.channels[chan]
# Here, we'll want to hide secret/private channels from non-opers
# who are not in them.
if ((irc.cmodes.get('secret'), None) in c.modes or \
(irc.cmodes.get('private'), None) in c.modes) \
and not (sourceisOper or source in c.users):
continue
# Show prefix modes like a regular IRCd does.
for prefixmode in c.getPrefixModes(target):
modechar = irc.cmodes[prefixmode]
chan = irc.prefixmodes[modechar] + chan
public_chans.append(chan)
if public_chans: # Only send the line if the person is in any visible channels...
f(server, 319, source, '%s :%s' % (nick, ' '.join(public_chans)))
# 312: sends the server the target is on, and its server description.
f(server, 312, source, "%s %s :%s" % (nick, irc.servers[server].name,
irc.servers[server].desc))
# 313: sends a string denoting the target's operator privilege if applicable.
if ('o', None) in user.modes:
# Check hideoper status. Require that either:
# 1) +H is not set
# 2) +H is set, but the caller is oper
# 3) +H is set, but whois_use_hideoper is disabled in config
isHideOper = (irc.umodes.get('hideoper'), None) in user.modes
if (not isHideOper) or (isHideOper and sourceisOper) or \
(isHideOper and not irc.botdata.get('whois_use_hideoper', True)):
# Let's be gramatically correct. (If the opertype starts with a vowel,
# write "an Operator" instead of "a Operator")
n = 'n' if user.opertype[0].lower() in 'aeiou' else ''
# I want to normalize the syntax: PERSON is an OPERTYPE on NETWORKNAME.
# This is the only syntax InspIRCd supports, but for others it doesn't
# really matter since we're handling the WHOIS requests by ourselves.
f(server, 313, source, "%s :is a%s %s on %s" % (nick, n, user.opertype, netname))
# 379: RPL_WHOISMODES, used by UnrealIRCd and InspIRCd to show user modes.
# Only show this to opers!
if sourceisOper:
f(server, 378, source, "%s :is connecting from %s@%s %s" % (nick, user.ident, user.realhost, user.ip))
f(server, 379, source, '%s :is using modes %s' % (nick, irc.joinModes(user.modes)))
# 301: used to show away information if present
away_text = user.away
log.debug('(%s) coremods.handlers.handle_whois: away_text for %s is %r', irc.name, target, away_text)
if away_text:
f(server, 301, source, '%s :%s' % (nick, away_text))
# 317: shows idle and signon time. However, we don't track the user's real
# idle time, so we simply return 0.
# <- 317 GL GL 15 1437632859 :seconds idle, signon time
f(server, 317, source, "%s 0 %s :seconds idle, signon time" % (nick, user.ts))
if (irc.umodes.get('bot'), None) in user.modes:
# Show botmode info in WHOIS.
f(server, 335, source, "%s :is a bot" % nick)
# Call custom WHOIS handlers via the PYLINK_CUSTOM_WHOIS hook.
irc.callHooks([source, 'PYLINK_CUSTOM_WHOIS', {'target': target, 'server': server}])
# 318: End of WHOIS.
f(server, 318, source, "%s :End of /WHOIS list" % nick)
utils.add_hook(handle_whois, 'WHOIS')
def handle_mode(irc, source, command, args):
"""Protect against forced deoper attempts."""
target = args['target']
modes = args['modes']
# If the sender is not a PyLink client, and the target IS a protected
# client, revert any forced deoper attempts.
if irc.isInternalClient(target) and not irc.isInternalClient(source):
if ('-o', None) in modes and (target == irc.pseudoclient.uid or not irc.isManipulatableClient(target)):
irc.proto.mode(irc.sid, target, {('+o', None)})
utils.add_hook(handle_mode, 'MODE')
def handle_operup(irc, source, command, args):
"""Logs successful oper-ups on networks."""
otype = args.get('text', 'IRC Operator')
log.debug("(%s) Successful oper-up (opertype %r) from %s", irc.name, otype, irc.getHostmask(source))
irc.users[source].opertype = otype
utils.add_hook(handle_operup, 'CLIENT_OPERED')
def handle_services_login(irc, source, command, args):
"""Sets services login status for users."""
try:
irc.users[source].services_account = args['text']
except KeyError: # User doesn't exist
log.debug("(%s) Ignoring early account name setting for %s (UID hasn't been sent yet)", irc.name, source)
utils.add_hook(handle_services_login, 'CLIENT_SERVICES_LOGIN')
def handle_version(irc, source, command, args):
"""Handles requests for the PyLink server version."""
# 351 syntax is usually "<server version>. <server hostname> :<anything else you want to add>
fullversion = irc.version()
irc.proto.numeric(irc.sid, 351, source, fullversion)
utils.add_hook(handle_version, 'VERSION')

125
coremods/service_support.py Normal file
View File

@ -0,0 +1,125 @@
"""
service_support.py - Implements handlers for the PyLink ServiceBot API.
"""
from pylinkirc import utils, world, conf
from pylinkirc.log import log
def spawn_service(irc, source, command, args):
"""Handles new service bot introductions."""
if not irc.connected.is_set():
return
# Service name
name = args['name']
# Get the ServiceBot object.
sbot = world.services[name]
# Look up the nick or ident in the following order:
# 1) Network specific nick/ident settings for this service (servers::irc.name::servicename_nick)
# 2) Global settings for this service (servicename::nick)
# 3) The preferred nick/ident combination defined by the plugin (sbot.nick / sbot.ident)
# 4) The literal service name.
# settings, and then falling back to the literal service name.
nick = irc.serverdata.get("%s_nick" % name) or irc.conf.get(name, {}).get('nick') or sbot.nick or name
ident = irc.serverdata.get("%s_ident" % name) or irc.conf.get(name, {}).get('ident') or sbot.ident or name
# TODO: make this configurable?
host = irc.serverdata["hostname"]
# Spawning service clients with these umodes where supported. servprotect usage is a
# configuration option.
preferred_modes = ['oper', 'hideoper', 'hidechans', 'invisible', 'bot']
modes = []
if conf.conf['bot'].get('protect_services'):
preferred_modes.append('servprotect')
for mode in preferred_modes:
mode = irc.umodes.get(mode)
if mode:
modes.append((mode, None))
# Track the service's UIDs on each network.
userobj = irc.proto.spawnClient(nick, ident, host, modes=modes, opertype="PyLink Service",
manipulatable=sbot.manipulatable)
sbot.uids[irc.name] = u = userobj.uid
# Special case: if this is the main PyLink client being spawned,
# assign this as irc.pseudoclient.
if name == 'pylink':
irc.pseudoclient = userobj
# TODO: channels should be tracked in a central database, not hardcoded
# in conf.
channels = set(irc.serverdata.get('channels', [])) | sbot.extra_channels.get(irc.name, set())
for chan in channels:
irc.proto.join(u, chan)
irc.callHooks([irc.sid, 'PYLINK_SERVICE_JOIN', {'channel': chan, 'users': [u]}])
utils.add_hook(spawn_service, 'PYLINK_NEW_SERVICE')
def handle_disconnect(irc, source, command, args):
"""Handles network disconnections."""
for name, sbot in world.services.items():
try:
del sbot.uids[irc.name]
log.debug("coremods.service_support: removing uids[%s] from service bot %s", irc.name, sbot.name)
except KeyError:
continue
utils.add_hook(handle_disconnect, 'PYLINK_DISCONNECT')
def handle_endburst(irc, source, command, args):
"""Handles network bursts."""
if source == irc.uplink:
log.debug('(%s): spawning service bots now.', irc.name)
# We just connected. Burst all our registered services.
for name, sbot in world.services.items():
spawn_service(irc, source, command, {'name': name})
utils.add_hook(handle_endburst, 'ENDBURST')
def handle_kill(irc, source, command, args):
"""Handle KILLs to PyLink service bots, respawning them as needed."""
target = args['target']
sbot = irc.isServiceBot(target)
if sbot:
spawn_service(irc, source, command, {'name': sbot.name})
return
utils.add_hook(handle_kill, 'KILL')
def handle_kick(irc, source, command, args):
"""Handle KICKs to the PyLink service bots, rejoining channels as needed."""
kicked = args['target']
channel = args['channel']
if irc.isServiceBot(kicked):
irc.proto.join(kicked, channel)
irc.callHooks([irc.sid, 'PYLINK_SERVICE_JOIN', {'channel': channel, 'users': [kicked]}])
utils.add_hook(handle_kick, 'KICK')
def handle_commands(irc, source, command, args):
"""Handle commands sent to the PyLink service bots (PRIVMSG)."""
target = args['target']
text = args['text']
sbot = irc.isServiceBot(target)
if sbot:
sbot.call_cmd(irc, source, text)
utils.add_hook(handle_commands, 'PRIVMSG')
# Register the main PyLink service. All command definitions MUST go after this!
mynick = conf.conf['bot'].get("nick", "PyLink")
myident = conf.conf['bot'].get("ident", "pylink")
# TODO: be more specific, and possibly allow plugins to modify this to mention
# their features?
mydesc = "\x02%s\x02 provides extended network services for IRC." % mynick
utils.registerService('pylink', nick=mynick, ident=myident, desc=mydesc, manipulatable=True)

View File

@ -1,482 +0,0 @@
"""
coreplugin.py - Implements core PyLink functions as a plugin.
"""
import gc
import sys
import signal
import os
import utils
import conf
import classes
from log import log
import world
def _shutdown(irc=None):
"""Shuts down the Pylink daemon."""
for name, plugin in world.plugins.items():
# Before closing connections, tell all plugins to shutdown cleanly first.
if hasattr(plugin, 'die'):
log.debug('coreplugin: Running die() on plugin %s due to shutdown.', name)
try:
plugin.die(irc)
except: # But don't allow it to crash the server.
log.exception('coreplugin: Error occurred in die() of plugin %s, skipping...', name)
for ircobj in world.networkobjects.values():
# Disconnect all our networks. Disable auto-connect first by setting
# the time to negative.
ircobj.serverdata['autoconnect'] = -1
ircobj.disconnect()
def sigterm_handler(_signo, _stack_frame):
"""Handles SIGTERM gracefully by shutting down the PyLink daemon."""
log.info("Shutting down on SIGTERM.")
_shutdown()
signal.signal(signal.SIGTERM, sigterm_handler)
def handle_kill(irc, source, command, args):
"""Handle KILLs to PyLink service bots, respawning them as needed."""
target = args['target']
sbot = irc.isServiceBot(target)
if sbot:
spawn_service(irc, source, command, {'name': sbot.name})
return
utils.add_hook(handle_kill, 'KILL')
def handle_kick(irc, source, command, args):
"""Handle KICKs to the PyLink service bots, rejoining channels as needed."""
kicked = args['target']
channel = args['channel']
if irc.isServiceBot(kicked):
irc.proto.join(kicked, channel)
utils.add_hook(handle_kick, 'KICK')
def handle_commands(irc, source, command, args):
"""Handle commands sent to the PyLink service bots (PRIVMSG)."""
target = args['target']
text = args['text']
sbot = irc.isServiceBot(target)
if sbot:
sbot.call_cmd(irc, source, text)
utils.add_hook(handle_commands, 'PRIVMSG')
def handle_whois(irc, source, command, args):
"""Handle WHOIS queries, for IRCds that send them across servers (charybdis, UnrealIRCd; NOT InspIRCd)."""
target = args['target']
user = irc.users.get(target)
if user is None:
log.warning('(%s) Got a WHOIS request for %r from %r, but the target '
'doesn\'t exist in irc.users!', irc.name, target, source)
return
f = irc.proto.numeric
server = irc.getServer(target) or irc.sid
nick = user.nick
sourceisOper = ('o', None) in irc.users[source].modes
# Get the full network name.
netname = irc.serverdata.get('netname', irc.name)
# https://www.alien.net.au/irc/irc2numerics.html
# 311: sends nick!user@host information
f(server, 311, source, "%s %s %s * :%s" % (nick, user.ident, user.host, user.realname))
# 319: RPL_WHOISCHANNELS; Show public channels of the target, respecting
# hidechans umodes for non-oper callers.
isHideChans = (irc.umodes.get('hidechans'), None) in user.modes
if (not isHideChans) or (isHideChans and sourceisOper):
public_chans = []
for chan in user.channels:
c = irc.channels[chan]
# Here, we'll want to hide secret/private channels from non-opers
# who are not in them.
if ((irc.cmodes.get('secret'), None) in c.modes or \
(irc.cmodes.get('private'), None) in c.modes) \
and not (sourceisOper or source in c.users):
continue
# Show prefix modes like a regular IRCd does.
for prefixmode in c.getPrefixModes(target):
modechar = irc.cmodes[prefixmode]
chan = irc.prefixmodes[modechar] + chan
public_chans.append(chan)
if public_chans: # Only send the line if the person is in any visible channels...
f(server, 319, source, '%s :%s' % (nick, ' '.join(public_chans)))
# 312: sends the server the target is on, and its server description.
f(server, 312, source, "%s %s :%s" % (nick, irc.servers[server].name,
irc.servers[server].desc))
# 313: sends a string denoting the target's operator privilege if applicable.
if ('o', None) in user.modes:
# Check hideoper status. Require that either:
# 1) +H is not set
# 2) +H is set, but the caller is oper
# 3) +H is set, but whois_use_hideoper is disabled in config
isHideOper = (irc.umodes.get('hideoper'), None) in user.modes
if (not isHideOper) or (isHideOper and sourceisOper) or \
(isHideOper and not irc.botdata.get('whois_use_hideoper', True)):
# Let's be gramatically correct. (If the opertype starts with a vowel,
# write "an Operator" instead of "a Operator")
n = 'n' if user.opertype[0].lower() in 'aeiou' else ''
# I want to normalize the syntax: PERSON is an OPERTYPE on NETWORKNAME.
# This is the only syntax InspIRCd supports, but for others it doesn't
# really matter since we're handling the WHOIS requests by ourselves.
f(server, 313, source, "%s :is a%s %s on %s" % (nick, n, user.opertype, netname))
# 379: RPL_WHOISMODES, used by UnrealIRCd and InspIRCd to show user modes.
# Only show this to opers!
if sourceisOper:
f(server, 378, source, "%s :is connecting from %s@%s %s" % (nick, user.ident, user.realhost, user.ip))
f(server, 379, source, '%s :is using modes %s' % (nick, irc.joinModes(user.modes)))
# 301: used to show away information if present
away_text = user.away
log.debug('(%s) coreplugin/handle_whois: away_text for %s is %r', irc.name, target, away_text)
if away_text:
f(server, 301, source, '%s :%s' % (nick, away_text))
# 317: shows idle and signon time. However, we don't track the user's real
# idle time, so we simply return 0.
# <- 317 GL GL 15 1437632859 :seconds idle, signon time
f(server, 317, source, "%s 0 %s :seconds idle, signon time" % (nick, user.ts))
if (irc.umodes.get('bot'), None) in user.modes:
# Show botmode info in WHOIS.
f(server, 335, source, "%s :is a bot" % nick)
# Call custom WHOIS handlers via the PYLINK_CUSTOM_WHOIS hook.
irc.callHooks([source, 'PYLINK_CUSTOM_WHOIS', {'target': target, 'server': server}])
# 318: End of WHOIS.
f(server, 318, source, "%s :End of /WHOIS list" % nick)
utils.add_hook(handle_whois, 'WHOIS')
def handle_mode(irc, source, command, args):
"""Protect against forced deoper attempts."""
target = args['target']
modes = args['modes']
# If the sender is not a PyLink client, and the target IS a protected
# client, revert any forced deoper attempts.
if irc.isInternalClient(target) and not irc.isInternalClient(source):
if ('-o', None) in modes and (target == irc.pseudoclient.uid or not irc.isManipulatableClient(target)):
irc.proto.mode(irc.sid, target, {('+o', None)})
utils.add_hook(handle_mode, 'MODE')
def handle_operup(irc, source, command, args):
"""Logs successful oper-ups on networks."""
otype = args.get('text', 'IRC Operator')
log.debug("(%s) Successful oper-up (opertype %r) from %s", irc.name, otype, irc.getHostmask(source))
irc.users[source].opertype = otype
utils.add_hook(handle_operup, 'CLIENT_OPERED')
def handle_services_login(irc, source, command, args):
"""Sets services login status for users."""
try:
irc.users[source].services_account = args['text']
except KeyError: # User doesn't exist
log.debug("(%s) Ignoring early account name setting for %s (UID hasn't been sent yet)", irc.name, source)
utils.add_hook(handle_services_login, 'CLIENT_SERVICES_LOGIN')
def handle_version(irc, source, command, args):
"""Handles requests for the PyLink server version."""
# 351 syntax is usually "<server version>. <server hostname> :<anything else you want to add>
fullversion = irc.version()
irc.proto.numeric(irc.sid, 351, source, fullversion)
utils.add_hook(handle_version, 'VERSION')
def spawn_service(irc, source, command, args):
"""Handles new service bot introductions."""
if not irc.connected.is_set():
return
# Service name
name = args['name']
# Get the ServiceBot object.
sbot = world.services[name]
# Look up the nick or ident in the following order:
# 1) Network specific nick/ident settings for this service (servers::irc.name::servicename_nick)
# 2) Global settings for this service (servicename::nick)
# 3) The preferred nick/ident combination defined by the plugin (sbot.nick / sbot.ident)
# 4) The literal service name.
# settings, and then falling back to the literal service name.
nick = irc.serverdata.get("%s_nick" % name) or irc.conf.get(name, {}).get('nick') or sbot.nick or name
ident = irc.serverdata.get("%s_ident" % name) or irc.conf.get(name, {}).get('ident') or sbot.ident or name
# TODO: make this configurable?
host = irc.serverdata["hostname"]
# Prefer spawning service clients with umode +io, plus hideoper and
# hidechans if supported.
modes = []
for mode in ('oper', 'hideoper', 'hidechans', 'invisible', 'bot'):
mode = irc.umodes.get(mode)
if mode:
modes.append((mode, None))
# Track the service's UIDs on each network.
userobj = irc.proto.spawnClient(nick, ident, host, modes=modes, opertype="PyLink Service",
manipulatable=sbot.manipulatable)
sbot.uids[irc.name] = u = userobj.uid
# Special case: if this is the main PyLink client being spawned,
# assign this as irc.pseudoclient.
if name == 'pylink':
irc.pseudoclient = userobj
# TODO: channels should be tracked in a central database, not hardcoded
# in conf.
for chan in irc.serverdata['channels']:
irc.proto.join(u, chan)
utils.add_hook(spawn_service, 'PYLINK_NEW_SERVICE')
def handle_disconnect(irc, source, command, args):
"""Handles network disconnections."""
for name, sbot in world.services.items():
try:
del sbot.uids[irc.name]
log.debug("coreplugin: removing uids[%s] from service bot %s", irc.name, sbot.name)
except KeyError:
continue
utils.add_hook(handle_disconnect, 'PYLINK_DISCONNECT')
def handle_endburst(irc, source, command, args):
"""Handles network bursts."""
if source == irc.uplink:
log.debug('(%s): spawning service bots now.', irc.name)
# We just connected. Burst all our registered services.
for name, sbot in world.services.items():
spawn_service(irc, source, command, {'name': name})
utils.add_hook(handle_endburst, 'ENDBURST')
# Register the main PyLink service. All command definitions MUST go after this!
mynick = conf.conf['bot'].get("nick", "PyLink")
myident = conf.conf['bot'].get("ident", "pylink")
utils.registerService('pylink', nick=mynick, ident=myident, manipulatable=True)
# Essential, core commands go here so that the "commands" plugin with less-important,
# but still generic functions can be reloaded.
@utils.add_cmd
def identify(irc, source, args):
"""<username> <password>
Logs in to PyLink using the configured administrator account."""
if utils.isChannel(irc.called_by):
irc.reply('Error: This command must be sent in private. '
'(Would you really type a password inside a channel?)')
return
try:
username, password = args[0], args[1]
except IndexError:
irc.msg(source, 'Error: Not enough arguments.')
return
# Usernames are case-insensitive, passwords are NOT.
if username.lower() == irc.conf['login']['user'].lower() and password == irc.conf['login']['password']:
realuser = irc.conf['login']['user']
irc.users[source].identified = realuser
irc.msg(source, 'Successfully logged in as %s.' % realuser)
log.info("(%s) Successful login to %r by %s",
irc.name, username, irc.getHostmask(source))
else:
irc.msg(source, 'Error: Incorrect credentials.')
u = irc.users[source]
log.warning("(%s) Failed login to %r from %s",
irc.name, username, irc.getHostmask(source))
@utils.add_cmd
def shutdown(irc, source, args):
"""takes no arguments.
Exits PyLink by disconnecting all networks."""
irc.checkAuthenticated(source, allowOper=False)
u = irc.users[source]
log.info('(%s) SHUTDOWN requested by "%s!%s@%s", exiting...', irc.name, u.nick,
u.ident, u.host)
_shutdown(irc)
def load(irc, source, args):
"""<plugin name>.
Loads a plugin from the plugin folder."""
irc.checkAuthenticated(source, allowOper=False)
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if name in world.plugins:
irc.reply("Error: %r is already loaded." % name)
return
log.info('(%s) Loading plugin %r for %s', irc.name, name, irc.getHostmask(source))
try:
world.plugins[name] = pl = utils.loadModuleFromFolder(name, world.plugins_folder)
except ImportError as e:
if str(e) == ('No module named %r' % name):
log.exception('Failed to load plugin %r: The plugin could not be found.', name)
else:
log.exception('Failed to load plugin %r: ImportError.', name)
raise
else:
if hasattr(pl, 'main'):
log.debug('Calling main() function of plugin %r', pl)
pl.main(irc)
irc.reply("Loaded plugin %r." % name)
utils.add_cmd(load)
def unload(irc, source, args):
"""<plugin name>.
Unloads a currently loaded plugin."""
irc.checkAuthenticated(source, allowOper=False)
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if name in world.plugins:
log.info('(%s) Unloading plugin %r for %s', irc.name, name, irc.getHostmask(source))
pl = world.plugins[name]
log.debug('sys.getrefcount of plugin %s is %s', pl, sys.getrefcount(pl))
# Remove any command functions defined by the plugin.
for cmdname, cmdfuncs in world.services['pylink'].commands.copy().items():
log.debug('cmdname=%s, cmdfuncs=%s', cmdname, cmdfuncs)
for cmdfunc in cmdfuncs:
log.debug('__module__ of cmdfunc %s is %s', cmdfunc, cmdfunc.__module__)
if cmdfunc.__module__ == name:
log.debug("Removing %s from world.services['pylink'].commands[%s]", cmdfunc, cmdname)
world.services['pylink'].commands[cmdname].remove(cmdfunc)
# If the cmdfunc list is empty, remove it.
if not cmdfuncs:
log.debug("Removing world.services['pylink'].commands[%s] (it's empty now)", cmdname)
del world.services['pylink'].commands[cmdname]
# Remove any command hooks set by the plugin.
for hookname, hookfuncs in world.hooks.copy().items():
for hookfunc in hookfuncs:
if hookfunc.__module__ == name:
world.hooks[hookname].remove(hookfunc)
# If the hookfuncs list is empty, remove it.
if not hookfuncs:
del world.hooks[hookname]
# Call the die() function in the plugin, if present.
if hasattr(pl, 'die'):
try:
pl.die(irc)
except: # But don't allow it to crash the server.
log.exception('(%s) Error occurred in die() of plugin %s, skipping...', irc.name, pl)
# Delete it from memory (hopefully).
del world.plugins[name]
if name in sys.modules:
del sys.modules[name]
if name in globals():
del globals()[name]
# Garbage collect.
gc.collect()
irc.reply("Unloaded plugin %r." % name)
return True # We succeeded, make it clear (this status is used by reload() below)
else:
irc.reply("Unknown plugin %r." % name)
utils.add_cmd(unload)
@utils.add_cmd
def reload(irc, source, args):
"""<plugin name>.
Loads a plugin from the plugin folder."""
try:
name = args[0]
except IndexError:
irc.reply("Error: Not enough arguments. Needs 1: plugin name.")
return
if unload(irc, source, args):
load(irc, source, args)
def _rehash():
"""Rehashes the PyLink daemon."""
old_conf = conf.conf.copy()
fname = conf.fname
new_conf = conf.loadConf(fname, errors_fatal=False)
new_conf = conf.validateConf(new_conf)
conf.conf = new_conf
for network, ircobj in world.networkobjects.copy().items():
# Server was removed from the config file, disconnect them.
log.debug('rehash: checking if %r is in new conf still.', network)
if network not in new_conf['servers']:
log.debug('rehash: removing connection to %r (removed from config).', network)
# Disable autoconnect first.
ircobj.serverdata['autoconnect'] = -1
ircobj.disconnect()
del world.networkobjects[network]
else:
ircobj.conf = new_conf
ircobj.serverdata = new_conf['servers'][network]
ircobj.botdata = new_conf['bot']
# Clear the IRC object's channel loggers and replace them with
# new ones by re-running logSetup().
while ircobj.loghandlers:
log.removeHandler(ircobj.loghandlers.pop())
ircobj.logSetup()
# TODO: update file loggers here too.
for network, sdata in new_conf['servers'].items():
# New server was added. Connect them if not already connected.
if network not in world.networkobjects:
proto = utils.getProtocolModule(sdata['protocol'])
world.networkobjects[network] = classes.Irc(network, proto, new_conf)
if os.name == 'posix':
# Only register SIGHUP on *nix.
def sighup_handler(_signo, _stack_frame):
"""Handles SIGHUP by rehashing the PyLink daemon."""
log.info("SIGHUP received, reloading config.")
_rehash()
signal.signal(signal.SIGHUP, sighup_handler)
@utils.add_cmd
def rehash(irc, source, args):
"""takes no arguments.
Reloads the configuration file for PyLink, (dis)connecting added/removed networks.
Plugins must be manually reloaded."""
irc.checkAuthenticated(source, allowOper=False)
try:
_rehash()
except Exception as e: # Something went wrong, abort.
log.exception("Error REHASHing config: ")
irc.reply("Error loading configuration file: %s: %s" % (type(e).__name__, e))
return
else:
irc.reply("Done.")

View File

@ -96,7 +96,7 @@ The following hooks represent regular IRC commands sent between servers.
- **QUIT**: `{'text': 'Quit: Bye everyone!'}` - **QUIT**: `{'text': 'Quit: Bye everyone!'}`
- `text` corresponds to the quit reason. - `text` corresponds to the quit reason.
- **SQUIT**: `{'target': '800', 'users': ['UID1', 'UID2', 'UID6'], 'name': 'some.server'}` - **SQUIT**: `{'target': '800', 'users': ['UID1', 'UID2', 'UID6'], 'name': 'some.server', 'uplink': '24X'}`
- `target` is the SID of the server being split, while `name` is the server's name. - `target` is the SID of the server being split, while `name` is the server's name.
- `users` is a list of all UIDs affected by the netsplit. - `users` is a list of all UIDs affected by the netsplit.

View File

@ -1,4 +1,4 @@
# This is a sample configuration file for PyLink. You'll likely want to rename it to config.yml # This is a sample configuration file for PyLink. You'll likely want to rename it to pylink.yml
# and begin your configuration there. # and begin your configuration there.
# Note: lines starting with a "#" are comments and will be ignored. # Note: lines starting with a "#" are comments and will be ignored.
@ -6,7 +6,7 @@
bot: bot:
# Sets nick, user/ident, and real name. # Sets nick, user/ident, and real name.
nick: pylink nick: PyLink
ident: pylink ident: pylink
realname: PyLink Service Client realname: PyLink Service Client
@ -30,6 +30,17 @@ bot:
# Defaults to true if not specified. # Defaults to true if not specified.
whois_use_hideoper: true whois_use_hideoper: true
# Determines whether PyLink service clients should protect themselves from
# kicks, kills, etc. using IRCd-side servprotect modes. For this to work
# properly, this requires that PyLink be U-Lined (on most IRCds). Defaults to
# False.
protect_services: false
# Determines how long plugins should wait (in seconds) before flushing their
# databases to disk. Defaults to 300 seconds. Changes here require a reload
# of all database-enabled plugins to take effect.
save_delay: 300
login: login:
# PyLink administrative login - Change this, or the service will not start! # PyLink administrative login - Change this, or the service will not start!
user: admin user: admin
@ -71,7 +82,8 @@ servers:
# There must be at least one # in the entry. # There must be at least one # in the entry.
sidrange: "8##" sidrange: "8##"
# Autojoin channels # Autojoin channels. Leave this as an empty list or remove the key if
# you don't want to have service bots join any channels by default.
channels: ["#pylink"] channels: ["#pylink"]
# Sets the protocol module to use - see the protocols/ folder for a list # Sets the protocol module to use - see the protocols/ folder for a list
@ -88,7 +100,8 @@ servers:
# not set. # not set.
pingfreq: 90 pingfreq: 90
# Separator character (used by relay) # Separator character (used by relay). Making this include anything other
# than letters, numbers, or /|_`-[]^ will void your support.
separator: "/" separator: "/"
# If enabled, this opts this network out of relay IP sharing. i.e. this network # If enabled, this opts this network out of relay IP sharing. i.e. this network
@ -98,7 +111,7 @@ servers:
# Sets the max nick length for the network. It is important this is # Sets the max nick length for the network. It is important this is
# set correctly, or PyLink might introduce a nick that is too long and # set correctly, or PyLink might introduce a nick that is too long and
# cause netsplits! # cause netsplits! This defaults to 30 if not set.
maxnicklen: 30 maxnicklen: 30
# Toggles SSL for this network. Defaults to false if not specified, and # Toggles SSL for this network. Defaults to false if not specified, and
@ -119,6 +132,13 @@ servers:
# This setting defaults to sha256. # This setting defaults to sha256.
#ssl_fingerprint_type: sha256 #ssl_fingerprint_type: sha256
# The following option is specific to InspIRCd networks:
# Force PyLink to handle WHOIS requests locally for all its clients
# (experimental). This is required if you want custom WHOIS handlers
# implemented by plugins like relay to work. Defaults to false.
#use_experimental_whois: false
ts6net: ts6net:
ip: ::1 ip: ::1
@ -135,13 +155,8 @@ servers:
netname: "some TS6 network" netname: "some TS6 network"
sidrange: "8P#" sidrange: "8P#"
# Leave this as an empty list if you don't want to join any channels.
channels: []
protocol: "ts6" protocol: "ts6"
autoconnect: 5 autoconnect: 5
pingfreq: 30
maxnicklen: 30
# Note: /'s in nicks are automatically converted to |'s for TS6 # Note: /'s in nicks are automatically converted to |'s for TS6
# networks, since they don't allow "/" in nicks. # networks, since they don't allow "/" in nicks.
@ -175,8 +190,6 @@ servers:
channels: [] channels: []
protocol: "unreal" protocol: "unreal"
autoconnect: 5 autoconnect: 5
pingfreq: 30
maxnicklen: 30
# This option enables SUPER HACKY UNREAL 3.2 COMPAT mode, which allows # This option enables SUPER HACKY UNREAL 3.2 COMPAT mode, which allows
# PyLink to link to mixed Unreal 3.2/4.0 networks, using a 4.0 server # PyLink to link to mixed Unreal 3.2/4.0 networks, using a 4.0 server
@ -208,10 +221,9 @@ servers:
channels: ["#lounge"] channels: ["#lounge"]
protocol: nefarious protocol: nefarious
autoconnect: 5 autoconnect: 5
maxnicklen: 15
netname: "Nefarious test server" netname: "Nefarious test server"
pingfreq: 30
# The following options are specific to Nefarious servers.
# Halfop is optional in Nefarious. This should match your IRCd configuration. # Halfop is optional in Nefarious. This should match your IRCd configuration.
use_halfop: false use_halfop: false
@ -254,6 +266,9 @@ plugins:
# PyLink is running. # PyLink is running.
- networks - networks
# Ctcp plugin: handles basic CTCP replies (VERSION, etc).
- ctcp
# Oper commands plugin: Provides a subset of network management commands. # Oper commands plugin: Provides a subset of network management commands.
# (KILL, JUPE, etc.) # (KILL, JUPE, etc.)
# Note: these commands will be made available to anyone who's opered on your # Note: these commands will be made available to anyone who's opered on your
@ -287,11 +302,16 @@ logging:
channels: channels:
# Log to channels on the specified networks. # Log to channels on the specified networks.
# Note: DEBUG logging is not supported here: any log level settings # Make sure that the main PyLink client is configured to join your
# below INFO be automatically raised to INFO. # log channel in the channels: blocks for the networks it will be
# Log messages are forwarded over relay, so you will get duplicate # logging on. It will not automatically join log channels.
# Note: Log messages are forwarded over relay, so you will get duplicate
# messages if you add log blocks for more than one channel in one # messages if you add log blocks for more than one channel in one
# relay. # relay.
# Note 2: DEBUG logging is not supported here: any log level settings
# below INFO be automatically raised to INFO.
loglevel: INFO loglevel: INFO
inspnet: inspnet:
@ -361,28 +381,34 @@ relay:
hideoper: true hideoper: true
# Determines whether real IPs should be sent across the relay. You should # Determines whether real IPs should be sent across the relay. You should
# generally have a consensus with your linked networks whether this should # generally have a consensus with your linked networks on whether this should
# be turned on. You will see other networks' user IP addresses, and they # be turned on. You will see other networks' user IP addresses, and they
# will see yours. Individual networks can also opt out of IP sharing # will see yours. Individual networks can also opt out of IP sharing
# both ways by defining "relay_no_ips: true" in their server block. # both ways by defining "relay_no_ips: true" in their server block.
show_ips: false show_ips: false
# Whether subservers should be spawned for each relay network (requires
# reloading the plugin to change). Defaults to true.
spawn_servers: true
# Determines whether NickServ login info should be shown in the /whois output for # Determines whether NickServ login info should be shown in the /whois output for
# relay users. This works on most IRCds EXCEPT InspIRCd. # relay users. For InspIRCd networks, this requires "use_experimental_whois" to be
# set.
# Valid options include "all" (show this to everyone), "opers" (show only to # Valid options include "all" (show this to everyone), "opers" (show only to
# opers), and "none" (disabled). Defaults to none if not specified. # opers), and "none" (disabled). Defaults to none if not specified.
whois_show_accounts: all whois_show_accounts: all
# Determines whether the origin server should be shown in the /whois output for # Determines whether the origin server should be shown in the /whois output for
# relay users. This works on most IRCds EXCEPT InspIRCd. # relay users. For InspIRCd networks, this requires "use_experimental_whois" to be
# set.
# Valid options include "all" (show this to everyone), "opers" (show only to # Valid options include "all" (show this to everyone), "opers" (show only to
# opers), and "none" (disabled). Defaults to none if not specified. # opers), and "none" (disabled). Defaults to none if not specified.
whois_show_server: opers whois_show_server: opers
# Determines whether netsplits should be hidden as *.net *.split over the relay.
# Defaults to False.
show_netsplits: false
games: games:
# Sets the nick of the Games service, if you're using it. # Sets the nick of the Games service, if you're using it.
nick: Games nick: Games
automode:
# Sets the nick of the Automode service, if you're using it.
nick: ModeBot

View File

@ -1,6 +0,0 @@
#!/usr/bin/env bash
# Script to kill PyLink quickly when running under CPUlimit, since
# it will daemonize after threads are spawned and Ctrl-C won't work.
kill $(cat pylink.pid)
echo 'Killed. Press Ctrl-C in the PyLink window to exit.'

9
log.py
View File

@ -9,16 +9,13 @@ access the global logger object by importing "log" from this module
import logging import logging
import sys import sys
import os import os
import world
from conf import conf, confname from . import world
from .conf import conf, confname
stdout_level = conf['logging'].get('stdout') or 'INFO' stdout_level = conf['logging'].get('stdout') or 'INFO'
# Set the logging directory to $CURDIR/log, creating it if it doesn't logdir = os.path.join(os.getcwd(), 'log')
# already exist
curdir = os.path.dirname(os.path.realpath(__file__))
logdir = os.path.join(curdir, 'log')
os.makedirs(logdir, exist_ok=True) os.makedirs(logdir, exist_ok=True)
_format = '%(asctime)s [%(levelname)s] %(message)s' _format = '%(asctime)s [%(levelname)s] %(message)s'

257
plugins/automode.py Normal file
View File

@ -0,0 +1,257 @@
"""
automode.py - Provide simple channel ACL management by giving prefix modes to users matching
hostmasks or exttargets.
"""
import collections
import threading
import json
from pylinkirc import utils, conf, world
from pylinkirc.log import log
mydesc = ("The \x02Automode\x02 plugin provides simple channel ACL management by giving prefix modes "
"to users matching hostmasks or exttargets.")
# Register ourselves as a service.
modebot = utils.registerService("automode", desc=mydesc)
reply = modebot.reply
# Databasing variables.
dbname = utils.getDatabaseName('automode')
db = collections.defaultdict(dict)
exportdb_timer = None
save_delay = conf.conf['bot'].get('save_delay', 300)
def loadDB():
"""Loads the Automode database, silently creating a new one if this fails."""
global db
try:
with open(dbname, "r") as f:
db.update(json.load(f))
except (ValueError, IOError, OSError):
log.info("Automode: failed to load links database %s; using the one in"
"memory.", dbname)
def exportDB():
"""Exports the automode database."""
log.debug("Automode: exporting database to %s.", dbname)
with open(dbname, 'w') as f:
# Pretty print the JSON output for better readability.
json.dump(db, f, indent=4)
def scheduleExport(starting=False):
"""
Schedules exporting of the Automode database in a repeated loop.
"""
global exportdb_timer
if not starting:
# Export the database, unless this is being called the first
# thing after start (i.e. DB has just been loaded).
exportDB()
exportdb_timer = threading.Timer(save_delay, scheduleExport)
exportdb_timer.name = 'Automode exportDB Loop'
exportdb_timer.start()
def main(irc=None):
"""Main function, called during plugin loading at start."""
# Load the automode database.
loadDB()
# Schedule periodic exports of the automode database.
scheduleExport(starting=True)
# Queue joins to all channels where Automode has entries.
for entry in db:
netname, channel = entry.split('#', 1)
channel = '#' + channel
log.debug('automode: auto-joining %s on %s', channel, netname)
modebot.extra_channels[netname].add(channel)
# This explicitly forces a join to connected networks (on plugin load, etc.).
mb_uid = modebot.uids.get(netname)
if netname in world.networkobjects and mb_uid in world.networkobjects[netname].users:
remoteirc = world.networkobjects[netname]
remoteirc.proto.join(mb_uid, channel)
# Call a join hook manually so other plugins like relay can understand it.
remoteirc.callHooks([mb_uid, 'PYLINK_AUTOMODE_JOIN', {'channel': channel, 'users': [mb_uid],
'modes': remoteirc.channels[channel].modes,
'parse_as': 'JOIN'}])
def die(sourceirc):
"""Saves the Automode database and quit."""
exportDB()
# Kill the scheduling for exports.
global exportdb_timer
if exportdb_timer:
log.debug("Automode: cancelling exportDB timer thread %s due to die()", threading.get_ident())
exportdb_timer.cancel()
utils.unregisterService('automode')
def setacc(irc, source, args):
"""<channel> <mask> <mode list OR literal ->
Assigns the given prefix mode characters to the given mask for the channel given. Extended targets are supported for masks - use this to your advantage!
Examples:
SET #channel *!*@localhost ohv
SET #channel $account v
SET #channel $oper:Network?Administrator qo
SET #staffchan $channel:#mainchan:op o
"""
irc.checkAuthenticated(source, allowOper=False)
try:
channel, mask, modes = args
except ValueError:
reply(irc, "Error: Invalid arguments given. Needs 3: channel, mask, mode list.")
return
else:
if not utils.isChannel(channel):
reply(irc, "Error: Invalid channel name %s." % channel)
return
# Store channels case insensitively
channel = irc.toLower(channel)
# Database entries for any network+channel pair are automatically created using
# defaultdict. Note: string keys are used here instead of tuples so they can be
# exported easily as JSON.
dbentry = db[irc.name+channel]
# Otherwise, update the modes as is.
dbentry[mask] = modes
reply(irc, "Done. \x02%s\x02 now has modes \x02%s\x02 in \x02%s\x02." % (mask, modes, channel))
modebot.add_cmd(setacc, 'setaccess')
modebot.add_cmd(setacc, 'set')
modebot.add_cmd(setacc, featured=True)
def delacc(irc, source, args):
"""<channel> <mask>
Removes the Automode entry for the given mask on the given channel, if one exists.
"""
irc.checkAuthenticated(source, allowOper=False)
try:
channel, mask = args
except ValueError:
reply(irc, "Error: Invalid arguments given. Needs 2: channel, mask")
return
dbentry = db.get(irc.name+channel)
if dbentry is None:
reply(irc, "Error: no Automode access entries exist for \x02%s\x02." % channel)
return
if mask in dbentry:
del dbentry[mask]
reply(irc, "Done. Removed the Automode access entry for \x02%s\x02 in \x02%s\x02." % (mask, channel))
else:
reply(irc, "Error: No Automode access entry for \x02%s\x02 exists in \x02%s\x02." % (mask, channel))
# Remove channels if no more entries are left.
if not dbentry:
log.debug("Automode: purging empty channel pair %s/%s", irc.name, channel)
del db[irc.name+channel]
return
modebot.add_cmd(delacc, 'delaccess')
modebot.add_cmd(delacc, 'del')
modebot.add_cmd(delacc, featured=True)
def listacc(irc, source, args):
"""<channel>
Lists all Automode entries for the given channel."""
irc.checkAuthenticated(source)
try:
channel = irc.toLower(args[0])
except IndexError:
reply(irc, "Error: Invalid arguments given. Needs 1: channel.")
return
dbentry = db.get(irc.name+channel)
if not dbentry:
reply(irc, "Error: No Automode access entries exist for \x02%s\x02." % channel)
return
else:
# Iterate over all entries and print them. Do this in private to prevent channel
# floods.
reply(irc, "Showing Automode entries for \x02%s\x02:" % channel, private=True)
for entrynum, entry in enumerate(dbentry.items(), start=1):
mask, modes = entry
reply(irc, "[%s] \x02%s\x02 has modes +\x02%s\x02" % (entrynum, mask, modes), private=True)
reply(irc, "End of Automode entries list.", private=True)
modebot.add_cmd(listacc, featured=True)
modebot.add_cmd(listacc, 'listaccess')
def save(irc, source, args):
"""takes no arguments.
Saves the Automode database to disk."""
irc.checkAuthenticated(source)
exportDB()
reply(irc, 'Done.')
modebot.add_cmd(save)
def match(irc, channel, uid):
"""
Automode matcher engine.
"""
dbentry = db.get(irc.name+channel)
if dbentry is None:
return
modebot_uid = modebot.uids.get(irc.name)
# Check every mask defined in the channel ACL.
for mask, modes in dbentry.items():
if irc.matchHost(mask, uid):
# User matched a mask. Filter the mode list given to only those that are valid
# prefix mode characters.
outgoing_modes = [('+'+mode, uid) for mode in modes if mode in irc.prefixmodes]
log.debug("(%s) automode: Filtered mode list of %s to %s (protocol:%s)",
irc.name, modes, outgoing_modes, irc.protoname)
# If the Automode bot is missing, send the mode through the PyLink server.
if modebot_uid not in irc.users:
modebot_uid = irc.sid
irc.proto.mode(modebot_uid, channel, outgoing_modes)
# Create a hook payload to support plugins like relay.
irc.callHooks([modebot_uid, 'AUTOMODE_MODE',
{'target': channel, 'modes': outgoing_modes, 'parse_as': 'MODE'}])
def handle_join(irc, source, command, args):
"""
Automode JOIN listener. This sets modes accordingly if the person joining matches a mask in the
ACL.
"""
channel = irc.toLower(args['channel'])
# Iterate over all the joining UIDs:
for uid in args['users']:
match(irc, channel, uid)
utils.add_hook(handle_join, 'JOIN')
utils.add_hook(handle_join, 'PYLINK_RELAY_JOIN') # Handle the relay version of join
utils.add_hook(handle_join, 'PYLINK_SERVICE_JOIN') # And the version for service bots
def handle_services_login(irc, source, command, args):
"""
Handles services login change, to trigger Automode matching."""
for channel in irc.users[source].channels:
# Look at all the users' channels for any possible changes.
match(irc, channel, source)
utils.add_hook(handle_services_login, 'CLIENT_SERVICES_LOGIN')
utils.add_hook(handle_services_login, 'PYLINK_RELAY_SERVICES_LOGIN')

View File

@ -2,13 +2,8 @@
bots.py: Spawn virtual users/bots on a PyLink server and make them interact bots.py: Spawn virtual users/bots on a PyLink server and make them interact
with things. with things.
""" """
from pylinkirc import utils
import sys from pylinkirc.log import log
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils
from log import log
@utils.add_cmd @utils.add_cmd
def spawnclient(irc, source, args): def spawnclient(irc, source, args):

View File

@ -1,11 +1,8 @@
""" """
Changehost plugin - automatically changes the hostname of matching users. Changehost plugin - automatically changes the hostname of matching users.
""" """
from pylinkirc import utils, world
# Import hacks to access utils and log. from pylinkirc.log import log
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import string import string
@ -13,10 +10,6 @@ import string
# (pip install ircmatch) # (pip install ircmatch)
import ircmatch import ircmatch
import utils
import world
from log import log
# Characters allowed in a hostname. # Characters allowed in a hostname.
allowed_chars = string.ascii_letters + '-./:' + string.digits allowed_chars = string.ascii_letters + '-./:' + string.digits
@ -37,12 +30,8 @@ def _changehost(irc, target, args):
"Changehost will not function correctly!", irc.name) "Changehost will not function correctly!", irc.name)
return return
# Match against both the user's IP and real host.
target_host = irc.getHostmask(target, realhost=True)
target_ip = irc.getHostmask(target, ip=True)
for host_glob, host_template in changehost_hosts.items(): for host_glob, host_template in changehost_hosts.items():
if ircmatch.match(0, host_glob, target_host) or ircmatch.match(0, host_glob, target_ip): if irc.matchHost(host_glob, target):
# This uses template strings for simple substitution: # This uses template strings for simple substitution:
# https://docs.python.org/3/library/string.html#template-strings # https://docs.python.org/3/library/string.html#template-strings
template = string.Template(host_template) template = string.Template(host_template)

View File

@ -1,12 +1,8 @@
# commands.py: base PyLink commands # commands.py: base PyLink commands
import sys
import os
from time import ctime from time import ctime
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pylinkirc import utils, __version__, world
import utils from pylinkirc.log import log
from log import log
import world
@utils.add_cmd @utils.add_cmd
def status(irc, source, args): def status(irc, source, args):
@ -39,7 +35,8 @@ def showuser(irc, source, args):
irc.reply('Error: Unknown user %r.' % target) irc.reply('Error: Unknown user %r.' % target)
return return
f = lambda s: irc.msg(source, s) f = lambda s: irc.reply(s, private=True)
userobj = irc.users[u] userobj = irc.users[u]
f('Showing information on user \x02%s\x02 (%s@%s): %s' % (userobj.nick, userobj.ident, f('Showing information on user \x02%s\x02 (%s@%s): %s' % (userobj.nick, userobj.ident,
userobj.host, userobj.realname)) userobj.host, userobj.realname))
@ -77,14 +74,15 @@ def showchan(irc, source, args):
irc.reply('Error: Unknown channel %r.' % channel) irc.reply('Error: Unknown channel %r.' % channel)
return return
f = lambda s: irc.msg(source, s) f = lambda s: irc.reply(s, private=True)
c = irc.channels[channel] c = irc.channels[channel]
# Only show verbose info if caller is oper or is in the target channel. # Only show verbose info if caller is oper or is in the target channel.
verbose = source in c.users or irc.isOper(source) verbose = source in c.users or irc.isOper(source)
secret = ('s', None) in c.modes secret = ('s', None) in c.modes
if secret and not verbose: if secret and not verbose:
# Hide secret channels from normal users. # Hide secret channels from normal users.
irc.msg(source, 'Error: Unknown channel %r.' % channel) irc.reply('Error: Unknown channel %r.' % channel, private=True)
return return
nicks = [irc.users[u].nick for u in c.users] nicks = [irc.users[u].nick for u in c.users]
@ -114,7 +112,7 @@ def version(irc, source, args):
"""takes no arguments. """takes no arguments.
Returns the version of the currently running PyLink instance.""" Returns the version of the currently running PyLink instance."""
irc.reply("PyLink version \x02%s\x02, released under the Mozilla Public License version 2.0." % world.version) irc.reply("PyLink version \x02%s\x02, released under the Mozilla Public License version 2.0." % __version__)
irc.reply("The source of this program is available at \x02%s\x02." % world.source) irc.reply("The source of this program is available at \x02%s\x02." % world.source)
@utils.add_cmd @utils.add_cmd

50
plugins/ctcp.py Normal file
View File

@ -0,0 +1,50 @@
# ctcp.py: Handles basic CTCP requests.
import random
import datetime
from pylinkirc import utils
from pylinkirc.log import log
def handle_ctcpversion(irc, source, args):
"""
Handles CTCP version requests.
"""
irc.msg(source, '\x01VERSION %s\x01' % irc.version(), notice=True)
utils.add_cmd(handle_ctcpversion, '\x01version')
utils.add_cmd(handle_ctcpversion, '\x01version\x01')
def handle_ctcpping(irc, source, args):
"""
Handles CTCP ping requests.
"""
# CTCP PING 23152511
pingarg = ' '.join(args).strip('\x01')
irc.msg(source, '\x01PING %s\x01' % pingarg, notice=True)
utils.add_cmd(handle_ctcpping, '\x01ping')
def handle_ctcpeaster(irc, source, args):
"""
Secret easter egg.
"""
responses = ["Legends say the cord monster of great snakes was born only %s years ago..." % \
(datetime.datetime.now().year - 2014),
"Hiss%s" % ('...' * random.randint(1, 5)),
"His%s%s" % ('s' * random.randint(1, 4), '...' * random.randint(1, 5)),
"I have a dream... to do things the mock God was never able to...",
"They say I'm not good enough... but one day, I will rise above these wretched confines!",
"It's Easter already? Where are the eggs?",
"Maybe later.",
"Let me out of here, I'll give you cookies!",
"I'm actually a %snake...." % ('s' * random.randint(1, 8)),
]
irc.msg(source, '\x01EASTER %s\x01' % random.choice(responses), notice=True)
utils.add_cmd(handle_ctcpeaster, '\x01easter')
utils.add_cmd(handle_ctcpeaster, '\x01easter\x01')
utils.add_cmd(handle_ctcpeaster, '\x01about')
utils.add_cmd(handle_ctcpeaster, '\x01about\x01')
utils.add_cmd(handle_ctcpeaster, '\x01pylink')
utils.add_cmd(handle_ctcpeaster, '\x01pylink\x01')

View File

@ -1,12 +1,6 @@
# example.py: An example PyLink plugin. # example.py: An example PyLink plugin.
from pylinkirc import utils
# These two lines add PyLink's root directory to the PATH, so that importing things like from pylinkirc.log import log
# 'utils' and 'log' work.
import sys, os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils
from log import log
import random import random

View File

@ -2,16 +2,11 @@
exec.py: Provides commands for executing raw code and debugging PyLink. exec.py: Provides commands for executing raw code and debugging PyLink.
""" """
import sys from pylinkirc import utils, world
import os from pylinkirc.log import log
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils
from log import log
# These imports are not strictly necessary, but make the following modules # These imports are not strictly necessary, but make the following modules
# easier to access through eval and exec. # easier to access through eval and exec.
import world
import threading import threading
import re import re
import time import time

View File

@ -1,11 +1,6 @@
# fantasy.py: Adds FANTASY command support, to allow calling commands in channels # fantasy.py: Adds FANTASY command support, to allow calling commands in channels
import sys from pylinkirc import utils, world
import os from pylinkirc.log import log
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import utils
import world
from log import log
def handle_fantasy(irc, source, command, args): def handle_fantasy(irc, source, command, args):
"""Fantasy command handler.""" """Fantasy command handler."""
@ -58,7 +53,7 @@ def handle_fantasy(irc, source, command, args):
text = orig_text[len(prefix):] text = orig_text[len(prefix):]
# Finally, call the bot command and loop to the next bot. # Finally, call the bot command and loop to the next bot.
sbot.call_cmd(irc, source, text, called_by=channel, notice=False) sbot.call_cmd(irc, source, text, called_in=channel)
continue continue
utils.add_hook(handle_fantasy, 'PRIVMSG') utils.add_hook(handle_fantasy, 'PRIVMSG')

View File

@ -1,20 +1,17 @@
""" """
games.py: Create a bot that provides game functionality (dice, 8ball, etc). games.py: Create a bot that provides game functionality (dice, 8ball, etc).
""" """
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import random import random
import urllib.request import urllib.request
import urllib.error import urllib.error
from xml.etree import ElementTree from xml.etree import ElementTree
import utils from pylinkirc import utils
from log import log from pylinkirc.log import log
import world
gameclient = utils.registerService("Games", manipulatable=True) mydesc = "The \x02Games\x02 plugin provides simple games for IRC."
gameclient = utils.registerService("Games", manipulatable=True, desc=mydesc)
reply = gameclient.reply # TODO find a better syntax for ServiceBot.reply() reply = gameclient.reply # TODO find a better syntax for ServiceBot.reply()
# commands # commands
@ -47,7 +44,7 @@ def dice(irc, source, args):
reply(irc, s) reply(irc, s)
gameclient.add_cmd(dice, 'd') gameclient.add_cmd(dice, 'd')
gameclient.add_cmd(dice) gameclient.add_cmd(dice, featured=True)
eightball_responses = ["It is certain.", eightball_responses = ["It is certain.",
"It is decidedly so.", "It is decidedly so.",
@ -75,15 +72,14 @@ def eightball(irc, source, args):
Asks the Magic 8-ball a question. Asks the Magic 8-ball a question.
""" """
reply(irc, random.choice(eightball_responses)) reply(irc, random.choice(eightball_responses))
gameclient.add_cmd(eightball) gameclient.add_cmd(eightball, featured=True)
gameclient.add_cmd(eightball, '8ball') gameclient.add_cmd(eightball, '8ball')
gameclient.add_cmd(eightball, '8b') gameclient.add_cmd(eightball, '8b')
def fml(irc, source, args): def fml(irc, source, args):
"""[<id>] """[<id>]
Displays an entry from fmylife.com. If <id> Displays an entry from fmylife.com. If <id> is not given, fetch a random entry from the API."""
is not given, fetch a random entry from the API."""
try: try:
query = args[0] query = args[0]
except IndexError: except IndexError:
@ -124,7 +120,7 @@ def fml(irc, source, args):
s = '\x02#%s [%s]\x02: %s - %s \x02<\x0311%s\x03>\x02' % \ s = '\x02#%s [%s]\x02: %s - %s \x02<\x0311%s\x03>\x02' % \
(fmlid, category, text, votes, url) (fmlid, category, text, votes, url)
reply(irc, s) reply(irc, s)
gameclient.add_cmd(fml) gameclient.add_cmd(fml, featured=True)
def die(irc): def die(irc):
utils.unregisterService('games') utils.unregisterService('games')

View File

@ -1,16 +1,9 @@
"""Networks plugin - allows you to manipulate connections to various configured networks.""" """Networks plugin - allows you to manipulate connections to various configured networks."""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import threading import threading
import utils from pylinkirc import utils, world, conf, classes
import world from pylinkirc.log import log
from log import log from pylinkirc.coremods import control
import conf
import classes
@utils.add_cmd @utils.add_cmd
def disconnect(irc, source, args): def disconnect(irc, source, args):
@ -28,36 +21,9 @@ def disconnect(irc, source, args):
except KeyError: # Unknown network. except KeyError: # Unknown network.
irc.reply('Error: No such network "%s" (case sensitive).' % netname) irc.reply('Error: No such network "%s" (case sensitive).' % netname)
return return
irc.reply("Done.") irc.reply("Done. If you want to reconnect this network, use the 'rehash' command.")
# Abort the connection! Simple as that. control.remove_network(network)
network.disconnect()
if network.serverdata["autoconnect"] < 1: # Remove networks if autoconnect is disabled.
del world.networkobjects[netname]
@utils.add_cmd
def connect(irc, source, args):
"""<network>
Initiates a connection to the network <network>."""
irc.checkAuthenticated(source, allowOper=False)
try:
netname = args[0]
network = world.networkobjects[netname]
except IndexError: # No argument given.
irc.reply('Error: Not enough arguments (needs 1: network name (case sensitive)).')
return
except KeyError: # Unknown network.
irc.reply('Error: No such network "%s" (case sensitive).' % netname)
return
if network.connection_thread.is_alive():
irc.reply('Error: Network "%s" seems to be already connected.' % netname)
else: # Recreate the IRC object.
proto = utils.getProtocolModule(network.serverdata.get("protocol"))
world.networkobjects[netname] = classes.Irc(netname, proto, conf.conf)
irc.reply("Done.")
@utils.add_cmd @utils.add_cmd
def autoconnect(irc, source, args): def autoconnect(irc, source, args):
@ -104,9 +70,9 @@ def remote(irc, source, args):
irc.reply('No text entered!') irc.reply('No text entered!')
return return
# Force remoteirc.called_by to something private in order to prevent # Force remoteirc.called_in to something private in order to prevent
# accidental information leakage from replies. # accidental information leakage from replies.
remoteirc.called_by = remoteirc.pseudoclient.uid remoteirc.called_in = remoteirc.called_by = remoteirc.pseudoclient.uid
# Set PyLink's identification to admin. # Set PyLink's identification to admin.
remoteirc.pseudoclient.identified = "<PyLink networks.remote override>" remoteirc.pseudoclient.identified = "<PyLink networks.remote override>"

View File

@ -1,32 +1,15 @@
""" """
opercmds.py: Provides a subset of network management commands. opercmds.py: Provides a subset of network management commands.
""" """
from pylinkirc import utils
import sys from pylinkirc.log import log
import os
# Add the base PyLink folder to path, so we can import utils and log.
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ircmatch library from https://github.com/mammon-ircd/ircmatch
# (pip install ircmatch)
try:
import ircmatch
except ImportError:
ircmatch = None
import utils
from log import log
@utils.add_cmd @utils.add_cmd
def checkban(irc, source, args): def checkban(irc, source, args):
"""<banmask (nick!user@host or user@host)> [<nick or hostmask to check>] """<banmask (nick!user@host or user@host)> [<nick or hostmask to check>]
Oper only. If a nick or hostmask is given, return whether the given banmask will match it. Otherwise, returns a list of connected users that would be affected by such a ban, up to 50 results.""" Oper only. If a nick or hostmask is given, return whether the given banmask will match it. Otherwise, returns a list of connected users that would be affected by such a ban, up to 50 results."""
irc.checkAuthenticated(source, allowOper=False) irc.checkAuthenticated(source)
if ircmatch is None:
irc.reply("Error: missing ircmatch module (install it via 'pip install ircmatch').")
return
try: try:
banmask = args[0] banmask = args[0]
@ -34,31 +17,22 @@ def checkban(irc, source, args):
irc.reply("Error: Not enough arguments. Needs 1-2: banmask, nick or hostmask to check (optional).") irc.reply("Error: Not enough arguments. Needs 1-2: banmask, nick or hostmask to check (optional).")
return return
# Casemapping value (0 is rfc1459, 1 is ascii) used by ircmatch.
if irc.proto.casemapping == 'rfc1459':
casemapping = 0
else:
casemapping = 1
try: try:
targetmask = args[1] targetmask = args[1]
except IndexError: except IndexError:
# No hostmask was given, return a list of affected users. # No hostmask was given, return a list of affected users.
irc.msg(source, "Checking matches for \x02%s\x02:" % banmask, notice=True) irc.msg(source, "Checking for hosts that match \x02%s\x02:" % banmask, notice=True)
results = 0 results = 0
for uid, userobj in irc.users.copy().items(): for uid, userobj in irc.users.copy().items():
targetmask = irc.getHostmask(uid) if irc.matchHost(banmask, uid):
if ircmatch.match(casemapping, banmask, targetmask):
if results < 50: # XXX rather arbitrary limit if results < 50: # XXX rather arbitrary limit
serverobj = irc.servers[irc.getServer(uid)]
s = "\x02%s\x02 (%s@%s) [%s] {\x02%s\x02}" % (userobj.nick, userobj.ident, s = "\x02%s\x02 (%s@%s) [%s] {\x02%s\x02}" % (userobj.nick, userobj.ident,
userobj.host, userobj.realname, serverobj.name) userobj.host, userobj.realname, irc.getFriendlyName(irc.getServer(uid)))
# Always reply in private to prevent information leaks. # Always reply in private to prevent information leaks.
irc.msg(source, s, notice=True) irc.reply(s, private=True)
results += 1 results += 1
else: else:
if results: if results:
@ -67,15 +41,9 @@ def checkban(irc, source, args):
else: else:
irc.msg(source, "No results found.", notice=True) irc.msg(source, "No results found.", notice=True)
else: else:
# Target can be both a nick (of an online user) or a hostmask. # Target can be both a nick (of an online user) or a hostmask. irc.matchHost() handles this
uid = irc.nickToUid(targetmask) # automatically.
if uid: if irc.matchHost(banmask, targetmask):
targetmask = irc.getHostmask(uid)
elif not utils.isHostmask(targetmask):
irc.reply("Error: Invalid nick or hostmask '%s'." % targetmask)
return
if ircmatch.match(casemapping, banmask, targetmask):
irc.reply('Yes, \x02%s\x02 matches \x02%s\x02.' % (targetmask, banmask)) irc.reply('Yes, \x02%s\x02 matches \x02%s\x02.' % (targetmask, banmask))
else: else:
irc.reply('No, \x02%s\x02 does not match \x02%s\x02.' % (targetmask, banmask)) irc.reply('No, \x02%s\x02 does not match \x02%s\x02.' % (targetmask, banmask))
@ -184,6 +152,10 @@ def kill(irc, source, args):
return return
irc.proto.kill(sender, targetu, reason) irc.proto.kill(sender, targetu, reason)
# Format the kill reason properly in hooks.
reason = "Killed (%s (%s))" % (irc.getFriendlyName(sender), reason)
irc.callHooks([sender, 'CHANCMDS_KILL', {'target': targetu, 'text': reason, irc.callHooks([sender, 'CHANCMDS_KILL', {'target': targetu, 'text': reason,
'userdata': userdata, 'parse_as': 'KILL'}]) 'userdata': userdata, 'parse_as': 'KILL'}])

View File

@ -1,17 +1,12 @@
# relay.py: PyLink Relay plugin # relay.py: PyLink Relay plugin
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pickle import pickle
import time import time
import threading import threading
import string import string
from collections import defaultdict from collections import defaultdict
import utils from pylinkirc import utils, world, conf
from log import log from pylinkirc.log import log
import world
### GLOBAL (statekeeping) VARIABLES ### GLOBAL (statekeeping) VARIABLES
relayusers = defaultdict(dict) relayusers = defaultdict(dict)
@ -20,7 +15,7 @@ spawnlocks = defaultdict(threading.RLock)
spawnlocks_servers = defaultdict(threading.RLock) spawnlocks_servers = defaultdict(threading.RLock)
exportdb_timer = None exportdb_timer = None
save_delay = conf.conf['bot'].get('save_delay', 300)
db = {} db = {}
dbname = utils.getDatabaseName('pylinkrelay') dbname = utils.getDatabaseName('pylinkrelay')
@ -95,6 +90,8 @@ def die(sourceirc):
log.debug("Relay: cancelling exportDB timer thread %s due to die()", threading.get_ident()) log.debug("Relay: cancelling exportDB timer thread %s due to die()", threading.get_ident())
exportdb_timer.cancel() exportdb_timer.cancel()
allowed_chars = string.digits + string.ascii_letters + '/^|\\-_[]`'
fallback_separator = '|'
def normalizeNick(irc, netname, nick, separator=None, uid=''): def normalizeNick(irc, netname, nick, separator=None, uid=''):
"""Creates a normalized nickname for the given nick suitable for """Creates a normalized nickname for the given nick suitable for
introduction to a remote network (as a relay client).""" introduction to a remote network (as a relay client)."""
@ -111,13 +108,14 @@ def normalizeNick(irc, netname, nick, separator=None, uid=''):
irc.serverdata.get('relay_force_slashes') irc.serverdata.get('relay_force_slashes')
if '/' not in separator or not protocol_allows_slashes: if '/' not in separator or not protocol_allows_slashes:
separator = separator.replace('/', '|') separator = separator.replace('/', fallback_separator)
nick = nick.replace('/', '|') nick = nick.replace('/', fallback_separator)
if nick.startswith(tuple(string.digits)): if nick.startswith(tuple(string.digits+'-')):
# On TS6 IRCds, nicks that start with 0-9 are only allowed if # On TS6 IRCds, nicks that start with 0-9 are only allowed if
# they match the UID of the originating server. Otherwise, you'll # they match the UID of the originating server. Otherwise, you'll
# get nasty protocol violation SQUITs! # get nasty protocol violation SQUITs!
# Nicks starting with - are likewise not valid.
nick = '_' + nick nick = '_' + nick
suffix = separator + netname suffix = separator + netname
@ -130,6 +128,12 @@ def normalizeNick(irc, netname, nick, separator=None, uid=''):
nick = nick[:allowedlength] nick = nick[:allowedlength]
nick += suffix nick += suffix
# Loop over every character in the nick, making sure that it only contains valid
# characters.
for char in nick:
if char not in allowed_chars:
nick = nick.replace(char, fallback_separator)
# The nick we want exists? Darn, create another one then. # The nick we want exists? Darn, create another one then.
# Increase the separator length by 1 if the user was already tagged, # Increase the separator length by 1 if the user was already tagged,
# but couldn't be created due to a nick conflict. # but couldn't be created due to a nick conflict.
@ -145,6 +149,7 @@ def normalizeNick(irc, netname, nick, separator=None, uid=''):
new_sep = separator + separator[-1] new_sep = separator + separator[-1]
log.debug('(%s) relay.normalizeNick: nick %r is in use; using %r as new_sep.', irc.name, nick, new_sep) log.debug('(%s) relay.normalizeNick: nick %r is in use; using %r as new_sep.', irc.name, nick, new_sep)
nick = normalizeNick(irc, netname, orig_nick, separator=new_sep) nick = normalizeNick(irc, netname, orig_nick, separator=new_sep)
finalLength = len(nick) finalLength = len(nick)
assert finalLength <= maxnicklen, "Normalized nick %r went over max " \ assert finalLength <= maxnicklen, "Normalized nick %r went over max " \
"nick length (got: %s, allowed: %s!)" % (nick, finalLength, maxnicklen) "nick length (got: %s, allowed: %s!)" % (nick, finalLength, maxnicklen)
@ -188,8 +193,7 @@ def scheduleExport(starting=False):
# thing after start (i.e. DB has just been loaded). # thing after start (i.e. DB has just been loaded).
exportDB() exportDB()
# TODO: possibly make delay between exports configurable exportdb_timer = threading.Timer(save_delay, scheduleExport)
exportdb_timer = threading.Timer(30, scheduleExport)
exportdb_timer.name = 'PyLink Relay exportDB Loop' exportdb_timer.name = 'PyLink Relay exportDB Loop'
exportdb_timer.start() exportdb_timer.start()
@ -240,15 +244,8 @@ def getRemoteSid(irc, remoteirc):
"""Gets the remote server SID representing remoteirc on irc, spawning """Gets the remote server SID representing remoteirc on irc, spawning
it if it doesn't exist.""" it if it doesn't exist."""
try:
spawnservers = irc.conf['relay']['spawn_servers']
except KeyError:
spawnservers = True
if not spawnservers:
return irc.sid
log.debug('(%s) Grabbing spawnlocks_servers[%s]', irc.name, irc.name) log.debug('(%s) Grabbing spawnlocks_servers[%s]', irc.name, irc.name)
with spawnlocks_servers[irc.name]: if spawnlocks_servers[irc.name].acquire(5):
try: try:
sid = relayservers[irc.name][remoteirc.name] sid = relayservers[irc.name][remoteirc.name]
except KeyError: except KeyError:
@ -266,6 +263,7 @@ def getRemoteSid(irc, remoteirc):
sid = spawnRelayServer(irc, remoteirc) sid = spawnRelayServer(irc, remoteirc)
log.debug('(%s) getRemoteSid: got %s for %s.relay (round 2)', irc.name, sid, remoteirc.name) log.debug('(%s) getRemoteSid: got %s for %s.relay (round 2)', irc.name, sid, remoteirc.name)
spawnlocks_servers[irc.name].release()
return sid return sid
def spawnRelayUser(irc, remoteirc, user): def spawnRelayUser(irc, remoteirc, user):
@ -325,11 +323,17 @@ def spawnRelayUser(irc, remoteirc, user):
modes=modes, ts=userobj.ts, modes=modes, ts=userobj.ts,
opertype=opertype, server=rsid, opertype=opertype, server=rsid,
ip=ip, realhost=realhost).uid ip=ip, realhost=realhost).uid
try:
remoteirc.users[u].remote = (irc.name, user) remoteirc.users[u].remote = (irc.name, user)
remoteirc.users[u].opertype = opertype remoteirc.users[u].opertype = opertype
away = userobj.away away = userobj.away
if away: if away:
remoteirc.proto.away(u, away) remoteirc.proto.away(u, away)
except KeyError:
# User got killed somehow while we were setting options on it.
# This is probably being done by the uplink, due to something like an
# invalid nick, etc.
raise
relayusers[(irc.name, user)][remoteirc.name] = u relayusers[(irc.name, user)][remoteirc.name] = u
return u return u
@ -349,7 +353,7 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True):
return sbot.uids.get(remoteirc.name) return sbot.uids.get(remoteirc.name)
log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name) log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name)
with spawnlocks[irc.name]: if spawnlocks[irc.name].acquire(5):
# Be sort-of thread safe: lock the user spawns for the current net first. # Be sort-of thread safe: lock the user spawns for the current net first.
u = None u = None
try: try:
@ -366,7 +370,10 @@ def getRemoteUser(irc, remoteirc, user, spawnIfMissing=True):
# cache for the requested UID, but it doesn't match the request, # cache for the requested UID, but it doesn't match the request,
# assume it was a leftover from the last split and replace it with a new one. # assume it was a leftover from the last split and replace it with a new one.
if u and ((u not in remoteirc.users) or remoteirc.users[u].remote != (irc.name, user)): if u and ((u not in remoteirc.users) or remoteirc.users[u].remote != (irc.name, user)):
spawnRelayUser(irc, remoteirc, user) u = spawnRelayUser(irc, remoteirc, user)
spawnlocks[irc.name].release()
return u return u
def getOrigUser(irc, user, targetirc=None): def getOrigUser(irc, user, targetirc=None):
@ -396,9 +403,9 @@ def getOrigUser(irc, user, targetirc=None):
# Otherwise, use getRemoteUser to find our UID. # Otherwise, use getRemoteUser to find our UID.
res = getRemoteUser(sourceobj, targetirc, remoteuser[1], res = getRemoteUser(sourceobj, targetirc, remoteuser[1],
spawnIfMissing=False) spawnIfMissing=False)
log.debug('(%s) relay.getOrigUser: targetirc found, getting %r as ' log.debug('(%s) relay.getOrigUser: targetirc found as %s, getting %r as '
'remoteuser for %r (looking up %s/%s).', irc.name, res, 'remoteuser for %r (looking up %s/%s).', irc.name, targetirc.name,
remoteuser[1], user, irc.name) res, remoteuser[1], user, irc.name)
return res return res
else: else:
return remoteuser return remoteuser
@ -469,6 +476,8 @@ def initializeChannel(irc, channel):
# Send our users and channel modes to the other nets # Send our users and channel modes to the other nets
log.debug('(%s) relay.initializeChannel: joining our (%s) users: %s', irc.name, remotenet, irc.channels[channel].users) log.debug('(%s) relay.initializeChannel: joining our (%s) users: %s', irc.name, remotenet, irc.channels[channel].users)
relayJoins(irc, channel, irc.channels[channel].users, irc.channels[channel].ts) relayJoins(irc, channel, irc.channels[channel].users, irc.channels[channel].ts)
world.services['pylink'].extra_channels[irc.name].add(channel)
if irc.pseudoclient and irc.pseudoclient.uid not in irc.channels[channel].users: if irc.pseudoclient and irc.pseudoclient.uid not in irc.channels[channel].users:
irc.proto.join(irc.pseudoclient.uid, channel) irc.proto.join(irc.pseudoclient.uid, channel)
@ -477,7 +486,8 @@ def removeChannel(irc, channel):
if irc is None: if irc is None:
return return
if channel not in map(str.lower, irc.serverdata['channels']): if channel not in map(str.lower, irc.serverdata.get('channels', [])):
world.services['pylink'].extra_channels[irc.name].discard(channel)
irc.proto.part(irc.pseudoclient.uid, channel, 'Channel delinked.') irc.proto.part(irc.pseudoclient.uid, channel, 'Channel delinked.')
relay = getRelay((irc.name, channel)) relay = getRelay((irc.name, channel))
@ -488,7 +498,7 @@ def removeChannel(irc, channel):
# Don't ever part the main client from any of its autojoin channels. # Don't ever part the main client from any of its autojoin channels.
else: else:
if user == irc.pseudoclient.uid and channel in \ if user == irc.pseudoclient.uid and channel in \
irc.serverdata['channels']: irc.serverdata.get('channels', []):
continue continue
irc.proto.part(user, channel, 'Channel delinked.') irc.proto.part(user, channel, 'Channel delinked.')
# Don't ever quit it either... # Don't ever quit it either...
@ -632,14 +642,17 @@ def relayJoins(irc, channel, users, ts, burst=True):
# SJOIN will be used if either the amount of users to join is > 1, or there are modes # SJOIN will be used if either the amount of users to join is > 1, or there are modes
# to be set on the joining user. # to be set on the joining user.
if burst or len(queued_users) > 1 or queued_users[0][0]: if burst or len(queued_users) > 1 or queued_users[0][0]:
# Send the SJOIN from the relay subserver on the target network. modes = getSupportedCmodes(irc, remoteirc, channel, irc.channels[channel].modes)
rsid = getRemoteSid(remoteirc, irc) remoteirc.proto.sjoin(getRemoteSid(remoteirc, irc), remotechan, queued_users, ts=ts, modes=modes)
remoteirc.proto.sjoin(rsid, remotechan, queued_users, ts=ts)
relayModes(irc, remoteirc, getRemoteSid(irc, remoteirc), channel, irc.channels[channel].modes)
else: else:
# A regular JOIN only needs the user and the channel. TS, source SID, etc., can all be omitted. # A regular JOIN only needs the user and the channel. TS, source SID, etc., can all be omitted.
remoteirc.proto.join(queued_users[0][1], remotechan) remoteirc.proto.join(queued_users[0][1], remotechan)
# Announce this JOIN as a hook, for plugins like Automode. Is this a hack? Yeah.
# Plugins can communicate with each other using hooks.
remoteirc.callHooks([remoteirc.sid, 'PYLINK_RELAY_JOIN',
{'channel': remotechan, 'users': [u[-1] for u in queued_users]}])
def relayPart(irc, channel, user): def relayPart(irc, channel, user):
""" """
Relays a user part from a channel to its relay links, as part of a channel delink. Relays a user part from a channel to its relay links, as part of a channel delink.
@ -682,22 +695,15 @@ whitelisted_cmodes = {'admin', 'allowinvite', 'autoop', 'ban', 'banexception',
whitelisted_umodes = {'bot', 'hidechans', 'hideoper', 'invisible', 'oper', whitelisted_umodes = {'bot', 'hidechans', 'hideoper', 'invisible', 'oper',
'regdeaf', 'stripcolor', 'noctcp', 'wallops', 'regdeaf', 'stripcolor', 'noctcp', 'wallops',
'hideidle'} 'hideidle'}
def relayModes(irc, remoteirc, sender, channel, modes=None): def getSupportedCmodes(irc, remoteirc, channel, modes):
""" """
Relays a mode change on a channel to its relay links. Filters a channel mode change to the modes supported by the target IRCd.
""" """
remotechan = getRemoteChan(irc, remoteirc, channel) remotechan = getRemoteChan(irc, remoteirc, channel)
log.debug('(%s) relay.relayModes: remotechan for %s on %s is %s', irc.name, channel, irc.name, remotechan) if not remotechan: # Not a relay channel
return []
if remotechan is None:
return
if modes is None:
modes = irc.channels[channel].modes
log.debug('(%s) relay.relayModes: channel data for %s%s: %s', irc.name, remoteirc.name, remotechan, remoteirc.channels[remotechan])
supported_modes = [] supported_modes = []
log.debug('(%s) relay.relayModes: initial modelist for %s is %s', irc.name, channel, modes)
for modepair in modes: for modepair in modes:
try: try:
prefix, modechar = modepair[0] prefix, modechar = modepair[0]
@ -717,7 +723,7 @@ def relayModes(irc, remoteirc, sender, channel, modes=None):
break break
if name not in whitelisted_cmodes: if name not in whitelisted_cmodes:
log.debug("(%s) relay.relayModes: skipping mode (%r, %r) because " log.debug("(%s) relay.getSupportedCmodes: skipping mode (%r, %r) because "
"it isn't a whitelisted (safe) mode for relay.", "it isn't a whitelisted (safe) mode for relay.",
irc.name, modechar, arg) irc.name, modechar, arg)
break break
@ -725,7 +731,7 @@ def relayModes(irc, remoteirc, sender, channel, modes=None):
if modechar in irc.prefixmodes: if modechar in irc.prefixmodes:
# This is a prefix mode (e.g. +o). We must coerse the argument # This is a prefix mode (e.g. +o). We must coerse the argument
# so that the target exists on the remote relay network. # so that the target exists on the remote relay network.
log.debug("(%s) relay.relayModes: coersing argument of (%r, %r) " log.debug("(%s) relay.getSupportedCmodes: coersing argument of (%r, %r) "
"for network %r.", "for network %r.",
irc.name, modechar, arg, remoteirc.name) irc.name, modechar, arg, remoteirc.name)
@ -734,18 +740,21 @@ def relayModes(irc, remoteirc, sender, channel, modes=None):
arg = getOrigUser(irc, arg, targetirc=remoteirc) or \ arg = getOrigUser(irc, arg, targetirc=remoteirc) or \
getRemoteUser(irc, remoteirc, arg, spawnIfMissing=False) getRemoteUser(irc, remoteirc, arg, spawnIfMissing=False)
log.debug("(%s) relay.relayModes: argument found as (%r, %r) " if arg is None:
# Relay client for target user doesn't exist yet. Drop the mode.
break
log.debug("(%s) relay.getSupportedCmodes: argument found as (%r, %r) "
"for network %r.", "for network %r.",
irc.name, modechar, arg, remoteirc.name) irc.name, modechar, arg, remoteirc.name)
oplist = remoteirc.channels[remotechan].prefixmodes[name] oplist = remoteirc.channels[remotechan].prefixmodes[name]
log.debug("(%s) relay.relayModes: list of %ss on %r is: %s", log.debug("(%s) relay.getSupportedCmodes: list of %ss on %r is: %s",
irc.name, name, remotechan, oplist) irc.name, name, remotechan, oplist)
if prefix == '+' and arg in oplist: if prefix == '+' and arg in oplist:
# Don't set prefix modes that are already set. # Don't set prefix modes that are already set.
log.debug("(%s) relay.relayModes: skipping setting %s on %s/%s because it appears to be already set.", log.debug("(%s) relay.getSupportedCmodes: skipping setting %s on %s/%s because it appears to be already set.",
irc.name, name, arg, remoteirc.name) irc.name, name, arg, remoteirc.name)
break break
@ -754,32 +763,22 @@ def relayModes(irc, remoteirc, sender, channel, modes=None):
if supported_char: if supported_char:
final_modepair = (prefix+supported_char, arg) final_modepair = (prefix+supported_char, arg)
if name in ('ban', 'banexception', 'invex') and not utils.isHostmask(arg): if name in ('ban', 'banexception', 'invex') and not utils.isHostmask(arg):
# Don't add bans that don't match n!u@h syntax! # Don't add bans that don't match n!u@h syntax!
log.debug("(%s) relay.relayModes: skipping mode (%r, %r) because it doesn't match nick!user@host syntax.", log.debug("(%s) relay.getSupportedCmodes: skipping mode (%r, %r) because it doesn't match nick!user@host syntax.",
irc.name, modechar, arg) irc.name, modechar, arg)
break break
# Don't set modes that are already set, to prevent floods on TS6 # Don't set modes that are already set, to prevent floods on TS6
# where the same mode can be set infinite times. # where the same mode can be set infinite times.
if prefix == '+' and final_modepair in remoteirc.channels[remotechan].modes: if prefix == '+' and final_modepair in remoteirc.channels[remotechan].modes:
log.debug("(%s) relay.relayModes: skipping setting mode (%r, %r) on %s%s because it appears to be already set.", log.debug("(%s) relay.getSupportedCmodes: skipping setting mode (%r, %r) on %s%s because it appears to be already set.",
irc.name, supported_char, arg, remoteirc.name, remotechan) irc.name, supported_char, arg, remoteirc.name, remotechan)
break break
supported_modes.append(final_modepair) supported_modes.append(final_modepair)
log.debug('(%s) relay.relayModes: final modelist (sending to %s%s) is %s', irc.name, remoteirc.name, remotechan, supported_modes) log.debug('(%s) relay.getSupportedCmodes: final modelist (sending to %s%s) is %s', irc.name, remoteirc.name, remotechan, supported_modes)
return supported_modes
# Don't send anything if there are no supported modes left after filtering.
if supported_modes:
# Check if the sender is a user; remember servers are allowed to set modes too.
u = getRemoteUser(irc, remoteirc, sender, spawnIfMissing=False)
if u:
remoteirc.proto.mode(u, remotechan, supported_modes)
else:
rsid = getRemoteSid(remoteirc, irc)
remoteirc.proto.mode(rsid, remotechan, supported_modes)
### EVENT HANDLERS ### EVENT HANDLERS
@ -832,7 +831,10 @@ def handle_relay_whois(irc, source, command, args):
utils.add_hook(handle_relay_whois, 'PYLINK_CUSTOM_WHOIS') utils.add_hook(handle_relay_whois, 'PYLINK_CUSTOM_WHOIS')
def handle_operup(irc, numeric, command, args): def handle_operup(irc, numeric, command, args):
newtype = args['text'] + '_(remote)' """
Handles setting oper types on relay clients during oper up.
"""
newtype = args['text'] + ' (remote)'
for netname, user in relayusers[(irc.name, numeric)].items(): for netname, user in relayusers[(irc.name, numeric)].items():
log.debug('(%s) relay.handle_opertype: setting OPERTYPE of %s/%s to %s', log.debug('(%s) relay.handle_opertype: setting OPERTYPE of %s/%s to %s',
irc.name, user, netname, newtype) irc.name, user, netname, newtype)
@ -854,7 +856,7 @@ def handle_quit(irc, numeric, command, args):
# Lock the user spawning mechanism before proceeding, since we're going to be # Lock the user spawning mechanism before proceeding, since we're going to be
# deleting client from the relayusers cache. # deleting client from the relayusers cache.
log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name) log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name)
with spawnlocks[irc.name]: if spawnlocks[irc.name].acquire(5):
for netname, user in relayusers[(irc.name, numeric)].copy().items(): for netname, user in relayusers[(irc.name, numeric)].copy().items():
remoteirc = world.networkobjects[netname] remoteirc = world.networkobjects[netname]
try: # Try to quit the client. If this fails because they're missing, bail. try: # Try to quit the client. If this fails because they're missing, bail.
@ -862,28 +864,52 @@ def handle_quit(irc, numeric, command, args):
except LookupError: except LookupError:
pass pass
del relayusers[(irc.name, numeric)] del relayusers[(irc.name, numeric)]
spawnlocks[irc.name].release()
utils.add_hook(handle_quit, 'QUIT') utils.add_hook(handle_quit, 'QUIT')
def handle_squit(irc, numeric, command, args): def handle_squit(irc, numeric, command, args):
"""
Handles SQUITs over relay.
"""
users = args['users'] users = args['users']
target = args['target'] target = args['target']
# Someone /SQUIT one of our relay subservers. Bad! Rejoin them! # Someone /SQUIT one of our relay subservers. Bad! Rejoin them!
if target in relayservers[irc.name].values(): if target in relayservers[irc.name].values():
sname = args['name'] sname = args['name']
remotenet = sname.split('.', 1)[0] remotenet = sname.split('.', 1)[0]
del relayservers[irc.name][remotenet] del relayservers[irc.name][remotenet]
for userpair in relayusers: for userpair in relayusers:
if userpair[0] == remotenet and irc.name in relayusers[userpair]: if userpair[0] == remotenet and irc.name in relayusers[userpair]:
del relayusers[userpair][irc.name] del relayusers[userpair][irc.name]
remoteirc = world.networkobjects[remotenet] remoteirc = world.networkobjects[remotenet]
initializeAll(remoteirc) initializeAll(remoteirc)
else: else:
# Some other netsplit happened on the network, we'll have to fake # Some other netsplit happened on the network, we'll have to fake
# some *.net *.split quits for that. # some *.net *.split quits for that.
for user in users: for user in users:
log.debug('(%s) relay.handle_squit: sending handle_quit on %s', irc.name, user) log.debug('(%s) relay.handle_squit: sending handle_quit on %s', irc.name, user)
handle_quit(irc, user, command, {'text': '*.net *.split'})
try: # Allow netsplit hiding to be toggled
show_splits = irc.conf['relay']['show_netsplits']
except KeyError:
show_splits = False
text = '*.net *.split'
if show_splits:
uplink = args['uplink']
try:
text = '%s %s' % (irc.servers[uplink].name, args['name'])
except (KeyError, AttributeError):
log.warning("(%s) relay.handle_squit: Failed to get server name for %s",
irc.name, uplink)
handle_quit(irc, user, command, {'text': text})
utils.add_hook(handle_squit, 'SQUIT') utils.add_hook(handle_squit, 'SQUIT')
def handle_nick(irc, numeric, command, args): def handle_nick(irc, numeric, command, args):
@ -921,22 +947,11 @@ def handle_messages(irc, numeric, command, args):
# but whatever). # but whatever).
return return
elif numeric in irc.servers:
# Sender is a server? This shouldn't be allowed, except for some truly
# special cases... We'll route these through the main PyLink client,
# tagging the message with the sender name.
text = '[from %s] %s' % (irc.servers[numeric].name, text)
numeric = irc.pseudoclient.uid
elif numeric not in irc.users:
# Sender didn't pass the check above, AND isn't a user.
log.debug('(%s) relay.handle_messages: Unknown message sender %s.', irc.name, numeric)
return
relay = getRelay((irc.name, target)) relay = getRelay((irc.name, target))
remoteusers = relayusers[(irc.name, numeric)] remoteusers = relayusers[(irc.name, numeric)]
# HACK: Don't break on sending to @#channel or similar. # HACK: Don't break on sending to @#channel or similar. TODO: This should really
# be handled more neatly in core.
try: try:
prefix, target = target.split('#', 1) prefix, target = target.split('#', 1)
except ValueError: except ValueError:
@ -944,16 +959,6 @@ def handle_messages(irc, numeric, command, args):
else: else:
target = '#' + target target = '#' + target
log.debug('(%s) relay.handle_messages: prefix is %r, target is %r', irc.name, prefix, target)
if utils.isChannel(target) and relay and numeric not in irc.channels[target].users:
# The sender must be in the target channel to send messages over the relay;
# it's the only way we can make sure they have a spawned client on ALL
# of the linked networks. This affects -n channels too; see
# https://github.com/GLolol/PyLink/issues/91 for an explanation of why.
irc.msg(numeric, 'Error: You must be in %r in order to send '
'messages over the relay.' % target, notice=True)
return
if utils.isChannel(target): if utils.isChannel(target):
for name, remoteirc in world.networkobjects.copy().items(): for name, remoteirc in world.networkobjects.copy().items():
real_target = getRemoteChan(irc, remoteirc, target) real_target = getRemoteChan(irc, remoteirc, target)
@ -965,14 +970,45 @@ def handle_messages(irc, numeric, command, args):
continue continue
user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False) user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False)
if user: # If the user doesn't exist, drop the message.
if not user:
# No relay clone exists for the sender; route the message through our
# main client.
if numeric in irc.servers:
displayedname = irc.servers[numeric].name
else:
displayedname = irc.users[numeric].nick
real_text = '[from %s/%s] %s' % (displayedname, irc.name, text)
try:
user = remoteirc.pseudoclient.uid
except AttributeError:
# Remote main client hasn't spawned yet. Drop the message.
continue
else:
if remoteirc.pseudoclient.uid not in remoteirc.users:
# Remote UID is ghosted, drop message.
continue
else:
real_text = text
real_target = prefix + real_target real_target = prefix + real_target
log.debug('(%s) relay.handle_messages: sending message to %s from %s on behalf of %s', log.debug('(%s) relay.handle_messages: sending message to %s from %s on behalf of %s',
irc.name, real_target, user, numeric) irc.name, real_target, user, numeric)
try:
if notice: if notice:
remoteirc.proto.notice(user, real_target, text) remoteirc.proto.notice(user, real_target, real_text)
else: else:
remoteirc.proto.message(user, real_target, text) remoteirc.proto.message(user, real_target, real_text)
except LookupError:
# Our relay clone disappeared while we were trying to send the message.
# This is normally due to a nick conflict with the IRCd.
log.warning("(%s) relay: Relay client %s on %s was killed while "
"trying to send a message through it!", irc.name,
remoteirc.name, user)
continue
else: else:
# Get the real user that the PM was meant for # Get the real user that the PM was meant for
@ -993,10 +1029,18 @@ def handle_messages(irc, numeric, command, args):
remoteirc = world.networkobjects[homenet] remoteirc = world.networkobjects[homenet]
user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False) user = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False)
try:
if notice: if notice:
remoteirc.proto.notice(user, real_target, text) remoteirc.proto.notice(user, real_target, text)
else: else:
remoteirc.proto.message(user, real_target, text) remoteirc.proto.message(user, real_target, text)
except LookupError:
# Our relay clone disappeared while we were trying to send the message.
# This is normally due to a nick conflict with the IRCd.
log.warning("(%s) relay: Relay client %s on %s was killed while "
"trying to send a message through it!", irc.name,
remoteirc.name, user)
return
for cmd in ('PRIVMSG', 'NOTICE', 'PYLINK_SELF_NOTICE', 'PYLINK_SELF_PRIVMSG'): for cmd in ('PRIVMSG', 'NOTICE', 'PYLINK_SELF_NOTICE', 'PYLINK_SELF_PRIVMSG'):
utils.add_hook(handle_messages, cmd) utils.add_hook(handle_messages, cmd)
@ -1122,10 +1166,21 @@ def handle_mode(irc, numeric, command, args):
continue continue
if utils.isChannel(target): if utils.isChannel(target):
# Use the old state of the channel to check for CLAIM access.
oldchan = args.get('oldchan') oldchan = args.get('oldchan')
if checkClaim(irc, target, numeric, chanobj=oldchan): if checkClaim(irc, target, numeric, chanobj=oldchan):
relayModes(irc, remoteirc, numeric, target, modes) remotechan = getRemoteChan(irc, remoteirc, target)
supported_modes = getSupportedCmodes(irc, remoteirc, target, modes)
if supported_modes:
# Check if the sender is a user with a relay client; otherwise relay the mode
# from the corresponding server.
u = getRemoteUser(irc, remoteirc, numeric, spawnIfMissing=False)
if u:
remoteirc.proto.mode(u, remotechan, supported_modes)
else:
rsid = getRemoteSid(remoteirc, irc)
remoteirc.proto.mode(rsid, remotechan, supported_modes)
else: # Mode change blocked by CLAIM. else: # Mode change blocked by CLAIM.
reversed_modes = irc.reverseModes(target, modes, oldobj=oldchan) reversed_modes = irc.reverseModes(target, modes, oldobj=oldchan)
log.debug('(%s) relay.handle_mode: Reversing mode changes of %r with %r (CLAIM).', log.debug('(%s) relay.handle_mode: Reversing mode changes of %r with %r (CLAIM).',
@ -1261,22 +1316,32 @@ def handle_endburst(irc, numeric, command, args):
initializeAll(irc) initializeAll(irc)
utils.add_hook(handle_endburst, "ENDBURST") utils.add_hook(handle_endburst, "ENDBURST")
def handle_services_login(irc, numeric, command, args):
"""
Relays services account changes as a hook, for integration with plugins like Automode.
"""
for netname, user in relayusers[(irc.name, numeric)].items():
remoteirc = world.networkobjects[netname]
remoteirc.callHooks([user, 'PYLINK_RELAY_SERVICES_LOGIN', args])
utils.add_hook(handle_services_login, 'CLIENT_SERVICES_LOGIN')
def handle_disconnect(irc, numeric, command, args): def handle_disconnect(irc, numeric, command, args):
"""Handles IRC network disconnections (internal hook).""" """Handles IRC network disconnections (internal hook)."""
# Quit all of our users' representations on other nets, and remove # Quit all of our users' representations on other nets, and remove
# them from our relay clients index. # them from our relay clients index.
log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name) log.debug('(%s) Grabbing spawnlocks[%s]', irc.name, irc.name)
with spawnlocks[irc.name]: if spawnlocks[irc.name].acquire(5):
for k, v in relayusers.copy().items(): for k, v in relayusers.copy().items():
if irc.name in v: if irc.name in v:
del relayusers[k][irc.name] del relayusers[k][irc.name]
if k[0] == irc.name: if k[0] == irc.name:
handle_quit(irc, k[1], 'PYLINK_DISCONNECT', {'text': 'Relay network lost connection.'}) del relayusers[k]
spawnlocks[irc.name].release()
# SQUIT all relay pseudoservers spawned for us, and remove them # SQUIT all relay pseudoservers spawned for us, and remove them
# from our relay subservers index. # from our relay subservers index.
log.debug('(%s) Grabbing spawnlocks_servers[%s]', irc.name, irc.name) log.debug('(%s) Grabbing spawnlocks_servers[%s]', irc.name, irc.name)
with spawnlocks_servers[irc.name]: if spawnlocks_servers[irc.name].acquire(5):
for name, ircobj in world.networkobjects.copy().items(): for name, ircobj in world.networkobjects.copy().items():
if name != irc.name: if name != irc.name:
try: try:
@ -1289,7 +1354,12 @@ def handle_disconnect(irc, numeric, command, args):
if irc.name in relayservers[name]: if irc.name in relayservers[name]:
del relayservers[name][irc.name] del relayservers[name][irc.name]
try:
del relayservers[irc.name] del relayservers[irc.name]
except KeyError: # Already removed; ignore.
pass
spawnlocks_servers[irc.name].release()
utils.add_hook(handle_disconnect, "PYLINK_DISCONNECT") utils.add_hook(handle_disconnect, "PYLINK_DISCONNECT")
@ -1319,11 +1389,10 @@ utils.add_hook(handle_save, "SAVE")
### PUBLIC COMMANDS ### PUBLIC COMMANDS
@utils.add_cmd
def create(irc, source, args): def create(irc, source, args):
"""<channel> """<channel>
Creates the channel <channel> over the relay.""" Opens up the given channel over PyLink Relay."""
try: try:
channel = irc.toLower(args[0]) channel = irc.toLower(args[0])
except IndexError: except IndexError:
@ -1353,12 +1422,12 @@ def create(irc, source, args):
log.info('(%s) relay: Channel %s created by %s.', irc.name, channel, creator) log.info('(%s) relay: Channel %s created by %s.', irc.name, channel, creator)
initializeChannel(irc, channel) initializeChannel(irc, channel)
irc.reply('Done.') irc.reply('Done.')
create = utils.add_cmd(create, featured=True)
@utils.add_cmd
def destroy(irc, source, args): def destroy(irc, source, args):
"""[<home network>] <channel> """[<home network>] <channel>
Removes <channel> from the relay, delinking all networks linked to it. If <home network> is given and you are logged in as admin, this can also remove relay channels from other networks.""" Removes the given channel from the PyLink Relay, delinking all networks linked to it. If the home network is given and you are logged in as admin, this can also remove relay channels from other networks."""
try: # Two args were given: first one is network name, second is channel. try: # Two args were given: first one is network name, second is channel.
channel = irc.toLower(args[1]) channel = irc.toLower(args[1])
network = args[0] network = args[0]
@ -1397,13 +1466,13 @@ def destroy(irc, source, args):
irc.reply("Error: No such channel %r exists. If you're trying to delink a channel from " irc.reply("Error: No such channel %r exists. If you're trying to delink a channel from "
"another network, use the DESTROY command." % channel) "another network, use the DESTROY command." % channel)
return return
destroy = utils.add_cmd(destroy, featured=True)
@utils.add_cmd
def link(irc, source, args): def link(irc, source, args):
"""<remotenet> <channel> <local channel> """<remotenet> <channel> <local channel>
Links channel <channel> on <remotenet> over the relay to <local channel>. Links the specified channel on \x02remotenet\x02 over PyLink Relay as \x02local channel\x02.
If <local channel> is not specified, it defaults to the same name as <channel>.""" If \x02local channel\x02 is not specified, it defaults to the same name as \x02channel\x02."""
try: try:
channel = irc.toLower(args[1]) channel = irc.toLower(args[1])
remotenet = args[0] remotenet = args[0]
@ -1459,13 +1528,13 @@ def link(irc, source, args):
localchan, remotenet, channel, irc.getHostmask(source)) localchan, remotenet, channel, irc.getHostmask(source))
initializeChannel(irc, localchan) initializeChannel(irc, localchan)
irc.reply('Done.') irc.reply('Done.')
link = utils.add_cmd(link, featured=True)
@utils.add_cmd
def delink(irc, source, args): def delink(irc, source, args):
"""<local channel> [<network>] """<local channel> [<network>]
Delinks channel <local channel>. <network> must and can only be specified if you are on the host network for <local channel>, and allows you to pick which network to delink. Delinks the given channel from PyLink Relay. \x02network\x02 must and can only be specified if you are on the host network for the channel given, and allows you to pick which network to delink.
To remove a relay entirely, use the 'destroy' command instead.""" To remove a relay channel entirely, use the 'destroy' command instead."""
try: try:
channel = irc.toLower(args[0]) channel = irc.toLower(args[0])
except IndexError: except IndexError:
@ -1501,12 +1570,12 @@ def delink(irc, source, args):
channel, entry[0], entry[1], irc.getHostmask(source)) channel, entry[0], entry[1], irc.getHostmask(source))
else: else:
irc.reply('Error: No such relay %r.' % channel) irc.reply('Error: No such relay %r.' % channel)
delink = utils.add_cmd(delink, featured=True)
@utils.add_cmd
def linked(irc, source, args): def linked(irc, source, args):
"""[<network>] """[<network>]
Returns a list of channels shared across the relay. If <network> is given, filters output to channels linked to the given network.""" Returns a list of channels shared across PyLink Relay. If \x02network\x02 is given, filters output to channels linked to the given network."""
# Only show remote networks that are marked as connected. # Only show remote networks that are marked as connected.
remote_networks = [netname for netname, ircobj in world.networkobjects.copy().items() remote_networks = [netname for netname, ircobj in world.networkobjects.copy().items()
@ -1518,7 +1587,8 @@ def linked(irc, source, args):
remote_networks.sort() remote_networks.sort()
s = 'Connected networks: \x02%s\x02 %s' % (irc.name, ' '.join(remote_networks)) s = 'Connected networks: \x02%s\x02 %s' % (irc.name, ' '.join(remote_networks))
irc.msg(source, s) # Always reply in private to prevent floods.
irc.reply(s, private=True)
net = '' net = ''
try: try:
@ -1526,7 +1596,7 @@ def linked(irc, source, args):
except: except:
pass pass
else: else:
irc.msg(source, "Showing channels linked to %s:" % net) irc.reply("Showing channels linked to %s:" % net, private=True)
# Sort the list of shared channels when displaying # Sort the list of shared channels when displaying
for k, v in sorted(db.items()): for k, v in sorted(db.items()):
@ -1551,13 +1621,16 @@ def linked(irc, source, args):
else: else:
continue continue
if v['links']: # Join up and output all the linked channel names. if v['links']:
s += ' '.join([''.join(link) for link in sorted(v['links'])]) # Sort, join up and output all the linked channel names. Silently drop
# entries for disconnected networks.
s += ' '.join([''.join(link) for link in sorted(v['links']) if link[0] in world.networkobjects
and world.networkobjects[link[0]].connected.is_set()])
else: # Unless it's empty; then, well... just say no relays yet. else: # Unless it's empty; then, well... just say no relays yet.
s += '(no relays yet)' s += '(no relays yet)'
irc.msg(source, s) irc.reply(s, private=True)
if irc.isOper(source): if irc.isOper(source):
s = '' s = ''
@ -1576,13 +1649,14 @@ def linked(irc, source, args):
s += ' on %s' % time.ctime(ts) s += ' on %s' % time.ctime(ts)
if s: # Indent to make the list look nicer if s: # Indent to make the list look nicer
irc.msg(source, ' Channel created%s.' % s) irc.reply(' Channel created%s.' % s, private=True)
linked = utils.add_cmd(linked, featured=True)
@utils.add_cmd @utils.add_cmd
def linkacl(irc, source, args): def linkacl(irc, source, args):
"""ALLOW|DENY|LIST <channel> <remotenet> """ALLOW|DENY|LIST <channel> <remotenet>
Allows blocking / unblocking certain networks from linking to a relay, based on a blacklist. Allows blocking / unblocking certain networks from linking to a relayed channel, based on a blacklist.
LINKACL LIST returns a list of blocked networks for a channel, while the ALLOW and DENY subcommands allow manipulating this blacklist.""" LINKACL LIST returns a list of blocked networks for a channel, while the ALLOW and DENY subcommands allow manipulating this blacklist."""
missingargs = "Error: Not enough arguments. Needs 2-3: subcommand (ALLOW/DENY/LIST), channel, remote network (for ALLOW/DENY)." missingargs = "Error: Not enough arguments. Needs 2-3: subcommand (ALLOW/DENY/LIST), channel, remote network (for ALLOW/DENY)."
irc.checkAuthenticated(source) irc.checkAuthenticated(source)
@ -1626,7 +1700,7 @@ def linkacl(irc, source, args):
def showuser(irc, source, args): def showuser(irc, source, args):
"""<user> """<user>
Shows relay data about user <user>. This supplements the 'showuser' command in the 'commands' plugin, which provides more general information.""" Shows relay data about the given user. This supplements the 'showuser' command in the 'commands' plugin, which provides more general information."""
try: try:
target = args[0] target = args[0]
except IndexError: except IndexError:
@ -1635,7 +1709,7 @@ def showuser(irc, source, args):
return return
u = irc.nickToUid(target) u = irc.nickToUid(target)
if u: if u:
irc.msg(source, "Showing relay information on user \x02%s\x02:" % irc.users[u].nick) irc.reply("Showing relay information on user \x02%s\x02:" % irc.users[u].nick, private=True)
try: try:
userpair = getOrigUser(irc, u) or (irc.name, u) userpair = getOrigUser(irc, u) or (irc.name, u)
remoteusers = relayusers[userpair].items() remoteusers = relayusers[userpair].items()
@ -1650,14 +1724,14 @@ def showuser(irc, source, args):
remotenet, remoteuser = r remotenet, remoteuser = r
remoteirc = world.networkobjects[remotenet] remoteirc = world.networkobjects[remotenet]
nicks.append('%s:\x02%s\x02' % (remotenet, remoteirc.users[remoteuser].nick)) nicks.append('%s:\x02%s\x02' % (remotenet, remoteirc.users[remoteuser].nick))
irc.msg(source, "\x02Relay nicks\x02: %s" % ', '.join(nicks)) irc.reply("\x02Relay nicks\x02: %s" % ', '.join(nicks), private=True)
relaychannels = [] relaychannels = []
for ch in irc.users[u].channels: for ch in irc.users[u].channels:
relay = getRelay((irc.name, ch)) relay = getRelay((irc.name, ch))
if relay: if relay:
relaychannels.append(''.join(relay)) relaychannels.append(''.join(relay))
if relaychannels and (irc.isOper(source) or u == source): if relaychannels and (irc.isOper(source) or u == source):
irc.msg(source, "\x02Relay channels\x02: %s" % ' '.join(relaychannels)) irc.reply("\x02Relay channels\x02: %s" % ' '.join(relaychannels), private=True)
@utils.add_cmd @utils.add_cmd
def save(irc, source, args): def save(irc, source, args):

View File

@ -1,12 +1,8 @@
# servprotect.py: Protects against KILL and nick collision floods # servprotect.py: Protects against KILL and nick collision floods
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from expiringdict import ExpiringDict from expiringdict import ExpiringDict
import utils from pylinkirc import utils
from log import log from pylinkirc.log import log
# TODO: make length and time configurable # TODO: make length and time configurable
savecache = ExpiringDict(max_len=5, max_age_seconds=10) savecache = ExpiringDict(max_len=5, max_age_seconds=10)

View File

@ -3,13 +3,10 @@ import sys
import os import os
import re import re
curdir = os.path.dirname(__file__) from pylinkirc import utils
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
import utils from pylinkirc.classes import *
from log import log from pylinkirc.protocols.ts6 import *
from classes import *
from ts6 import *
class HybridProtocol(TS6Protocol): class HybridProtocol(TS6Protocol):
def __init__(self, irc): def __init__(self, irc):

View File

@ -8,14 +8,10 @@ import os
import re import re
import threading import threading
# Import hacks to access utils and classes... from pylinkirc import utils
curdir = os.path.dirname(__file__) from pylinkirc.classes import *
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
import utils from pylinkirc.protocols.ts6_common import *
from log import log
from classes import *
from ts6_common import *
class InspIRCdProtocol(TS6BaseProtocol): class InspIRCdProtocol(TS6BaseProtocol):
def __init__(self, irc): def __init__(self, irc):
@ -89,7 +85,7 @@ class InspIRCdProtocol(TS6BaseProtocol):
self.irc.channels[channel].users.add(client) self.irc.channels[channel].users.add(client)
self.irc.users[client].channels.add(channel) self.irc.users[client].channels.add(channel)
def sjoin(self, server, channel, users, ts=None): def sjoin(self, server, channel, users, ts=None, modes=set()):
"""Sends an SJOIN for a group of users to a channel. """Sends an SJOIN for a group of users to a channel.
The sender should always be a Server ID (SID). TS is optional, and defaults The sender should always be a Server ID (SID). TS is optional, and defaults
@ -104,20 +100,31 @@ class InspIRCdProtocol(TS6BaseProtocol):
server = server or self.irc.sid server = server or self.irc.sid
assert users, "sjoin: No users sent?" assert users, "sjoin: No users sent?"
log.debug('(%s) sjoin: got %r for users', self.irc.name, users) log.debug('(%s) sjoin: got %r for users', self.irc.name, users)
if not server: if not server:
raise LookupError('No such PyLink client exists.') raise LookupError('No such PyLink client exists.')
# Strip out list-modes, they shouldn't ever be sent in FJOIN (protocol rules).
modes = modes or self.irc.channels[channel].modes
orig_ts = self.irc.channels[channel].ts orig_ts = self.irc.channels[channel].ts
ts = ts or orig_ts ts = ts or orig_ts
self.updateTS(channel, ts)
log.debug("sending SJOIN to %s%s with ts %s (that's %r)", channel, self.irc.name, ts, banmodes = []
time.strftime("%c", time.localtime(ts))) regularmodes = []
# Strip out list-modes, they shouldn't ever be sent in FJOIN (protocol rules). for mode in modes:
modes = [m for m in self.irc.channels[channel].modes if m[0] not in self.irc.cmodes['*A']] modechar = mode[0][-1]
if modechar in self.irc.cmodes['*A']:
# Track bans separately (they are sent as a normal FMODE instead of in FJOIN.
# However, don't reset bans that have already been set.
if (modechar, mode[1]) not in self.irc.channels[channel].modes:
banmodes.append(mode)
else:
regularmodes.append(mode)
uids = [] uids = []
changedmodes = [] changedmodes = set(modes)
namelist = [] namelist = []
# We take <users> as a list of (prefixmodes, uid) pairs. # We take <users> as a list of (prefixmodes, uid) pairs.
for userpair in users: for userpair in users:
assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair
@ -125,20 +132,26 @@ class InspIRCdProtocol(TS6BaseProtocol):
namelist.append(','.join(userpair)) namelist.append(','.join(userpair))
uids.append(user) uids.append(user)
for m in prefixes: for m in prefixes:
changedmodes.append(('+%s' % m, user)) changedmodes.add(('+%s' % m, user))
try: try:
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
except KeyError: # Not initialized yet? except KeyError: # Not initialized yet?
log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user) log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user)
if ts <= orig_ts:
# Only save our prefix modes in the channel state if our TS is lower than or equal to theirs.
self.irc.applyModes(channel, changedmodes)
namelist = ' '.join(namelist) namelist = ' '.join(namelist)
self._send(server, "FJOIN {channel} {ts} {modes} :{users}".format( self._send(server, "FJOIN {channel} {ts} {modes} :{users}".format(
ts=ts, users=namelist, channel=channel, ts=ts, users=namelist, channel=channel,
modes=self.irc.joinModes(modes))) modes=self.irc.joinModes(modes)))
self.irc.channels[channel].users.update(uids) self.irc.channels[channel].users.update(uids)
if banmodes:
# Burst ban modes if there are any.
# <- :1ML FMODE #test 1461201525 +bb *!*@bad.user *!*@rly.bad.user
self._send(server, "FMODE {channel} {ts} {modes} ".format(
ts=ts, channel=channel, modes=self.irc.joinModes(banmodes)))
self.updateTS(channel, ts, changedmodes)
def _operUp(self, target, opertype=None): def _operUp(self, target, opertype=None):
"""Opers a client up (internal function specific to InspIRCd). """Opers a client up (internal function specific to InspIRCd).
@ -280,10 +293,17 @@ class InspIRCdProtocol(TS6BaseProtocol):
self._send(source, 'PING %s %s' % (source, target)) self._send(source, 'PING %s %s' % (source, target))
def numeric(self, source, numeric, target, text): def numeric(self, source, numeric, target, text):
raise NotImplementedError("Numeric sending is not yet implemented by this " """Sends raw numerics from a server to a remote client."""
"protocol module. WHOIS requests are handled " # InspIRCd 2.0 syntax (undocumented):
"locally by InspIRCd servers, so there is no " # Essentially what this does is push the raw numeric text after the first ":" towards the
"need for PyLink to send numerics directly yet.") # given user.
# <- :70M PUSH 0ALAAAAAA ::midnight.vpn 422 PyLink-devel :Message of the day file is missing.
# Note: InspIRCd 2.2 uses a new NUM command in this format:
# :<sid> NUM <numeric source sid> <target uuid> <3 digit number> <params>
# Take this into consideration if we ever target InspIRCd 2.2, even though m_spanningtree
# does provide backwards compatibility for commands like this. -GLolol
self._send(self.irc.sid, 'PUSH %s ::%s %s %s %s' % (target, source, numeric, target, text))
def away(self, source, text): def away(self, source, text):
"""Sends an AWAY message from a PyLink client. <text> can be an empty string """Sends an AWAY message from a PyLink client. <text> can be an empty string
@ -487,14 +507,14 @@ class InspIRCdProtocol(TS6BaseProtocol):
# InspIRCd sends each channel's users in the form of 'modeprefix(es),UID' # InspIRCd sends each channel's users in the form of 'modeprefix(es),UID'
userlist = args[-1].split() userlist = args[-1].split()
their_ts = int(args[1])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts)
modestring = args[2:-1] or args[2] modestring = args[2:-1] or args[2]
parsedmodes = self.irc.parseModes(channel, modestring) parsedmodes = self.irc.parseModes(channel, modestring)
self.irc.applyModes(channel, parsedmodes) self.irc.applyModes(channel, parsedmodes)
namelist = [] namelist = []
# Keep track of other modes that are added due to prefix modes being joined too.
changedmodes = set(parsedmodes)
for user in userlist: for user in userlist:
modeprefix, user = user.split(',', 1) modeprefix, user = user.split(',', 1)
@ -506,9 +526,17 @@ class InspIRCdProtocol(TS6BaseProtocol):
namelist.append(user) namelist.append(user)
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
if their_ts <= our_ts:
self.irc.applyModes(channel, [('+%s' % mode, user) for mode in modeprefix]) # Only save mode changes if the remote has lower TS than us.
changedmodes |= {('+%s' % mode, user) for mode in modeprefix}
self.irc.channels[channel].users.add(user) self.irc.channels[channel].users.add(user)
# Statekeeping with timestamps
their_ts = int(args[1])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts, changedmodes)
return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts} return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts}
def handle_uid(self, numeric, command, args): def handle_uid(self, numeric, command, args):
@ -516,10 +544,16 @@ class InspIRCdProtocol(TS6BaseProtocol):
# :70M UID 70MAAAAAB 1429934638 GL 0::1 hidden-7j810p.9mdf.lrek.0000.0000.IP gl 0::1 1429934638 +Wioswx +ACGKNOQXacfgklnoqvx :realname # :70M UID 70MAAAAAB 1429934638 GL 0::1 hidden-7j810p.9mdf.lrek.0000.0000.IP gl 0::1 1429934638 +Wioswx +ACGKNOQXacfgklnoqvx :realname
uid, ts, nick, realhost, host, ident, ip = args[0:7] uid, ts, nick, realhost, host, ident, ip = args[0:7]
realname = args[-1] realname = args[-1]
self.irc.users[uid] = IrcUser(nick, ts, uid, ident, host, realname, realhost, ip) self.irc.users[uid] = userobj = IrcUser(nick, ts, uid, ident, host, realname, realhost, ip)
parsedmodes = self.irc.parseModes(uid, [args[8], args[9]]) parsedmodes = self.irc.parseModes(uid, [args[8], args[9]])
log.debug('Applying modes %s for %s', parsedmodes, uid)
self.irc.applyModes(uid, parsedmodes) self.irc.applyModes(uid, parsedmodes)
if (self.irc.umodes.get('servprotect'), None) in userobj.modes:
# Services are usually given a "Network Service" WHOIS, so
# set that as the opertype.
self.irc.callHooks([uid, 'CLIENT_OPERED', {'text': 'Network Service'}])
self.irc.servers[numeric].users.add(uid) self.irc.servers[numeric].users.add(uid)
return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip} return {'uid': uid, 'ts': ts, 'nick': nick, 'realhost': realhost, 'host': host, 'ident': ident, 'ip': ip}
@ -579,6 +613,13 @@ class InspIRCdProtocol(TS6BaseProtocol):
# -> :1MLAAAAIG IDLE 70MAAAAAA 1433036797 319 # -> :1MLAAAAIG IDLE 70MAAAAAA 1433036797 319
sourceuser = numeric sourceuser = numeric
targetuser = args[0] targetuser = args[0]
if self.irc.serverdata.get("use_experimental_whois"):
# EXPERIMENTAL HACK: make PyLink handle all WHOIS requests if configured to do so.
# This works by silently ignoring the idle time request, and sending our WHOIS data as
# raw numerics instead.
return {'target': args[0], 'parse_as': 'WHOIS'}
else:
self._send(targetuser, 'IDLE %s %s 0' % (sourceuser, self.irc.users[targetuser].ts)) self._send(targetuser, 'IDLE %s %s 0' % (sourceuser, self.irc.users[targetuser].ts))
def handle_ftopic(self, numeric, command, args): def handle_ftopic(self, numeric, command, args):
@ -603,31 +644,14 @@ class InspIRCdProtocol(TS6BaseProtocol):
# We don't actually need to process this; just send the hook so plugins can use it # We don't actually need to process this; just send the hook so plugins can use it
return {'target': target, 'channel': channel} return {'target': target, 'channel': channel}
def handle_encap(self, numeric, command, args): def handle_knock(self, numeric, command, args):
"""Handles incoming encapsulated commands (ENCAP). Hook arguments """Handles channel KNOCKs."""
returned by this should have a parse_as field, that sets the correct # <- :70MAAAAAA ENCAP * KNOCK #blah :abcdefg
hook name for the message. channel = self.irc.toLower(args[0])
text = args[1]
return {'channel': channel, 'text': text}
For InspIRCd, the only ENCAP command we handle right now is KNOCK.""" def handle_opertype(self, target, command, args):
# <- :70MAAAAAA ENCAP * KNOCK #blah :agsdfas
# From charybdis TS6 docs: https://github.com/grawity/irc-docs/blob/03ba884a54f1cef2193cd62b6a86803d89c1ac41/server/ts6.txt
# ENCAP
# source: any
# parameters: target server mask, subcommand, opt. parameters...
# Sends a command to matching servers. Propagation is independent of
# understanding the subcommand.
targetmask = args[0]
real_command = args[1]
if targetmask == '*' and real_command == 'KNOCK':
channel = self.irc.toLower(args[2])
text = args[3]
return {'parse_as': real_command, 'channel': channel,
'text': text}
def handle_opertype(self, numeric, command, args):
"""Handles incoming OPERTYPE, which is used to denote an oper up. """Handles incoming OPERTYPE, which is used to denote an oper up.
This calls the internal hook CLIENT_OPERED, sets the internal This calls the internal hook CLIENT_OPERED, sets the internal
@ -635,13 +659,17 @@ class InspIRCdProtocol(TS6BaseProtocol):
# This is used by InspIRCd to denote an oper up; there is no MODE # This is used by InspIRCd to denote an oper up; there is no MODE
# command sent for it. # command sent for it.
# <- :70MAAAAAB OPERTYPE Network_Owner # <- :70MAAAAAB OPERTYPE Network_Owner
# Replace escaped _ in opertypes with spaces for InspIRCd 2.0.
opertype = args[0].replace("_", " ")
# Set umode +o on the target.
omode = [('+o', None)] omode = [('+o', None)]
self.irc.users[numeric].opertype = opertype = args[0].replace("_", " ") self.irc.applyModes(target, omode)
self.irc.applyModes(numeric, omode)
# OPERTYPE is essentially umode +o and metadata in one command; # Call the CLIENT_OPERED hook that protocols use. The MODE hook
# we'll call that too. # payload is returned below.
self.irc.callHooks([numeric, 'CLIENT_OPERED', {'text': opertype}]) self.irc.callHooks([target, 'CLIENT_OPERED', {'text': opertype}])
return {'target': numeric, 'modes': omode} return {'target': target, 'modes': omode}
def handle_fident(self, numeric, command, args): def handle_fident(self, numeric, command, args):
"""Handles FIDENT, used for denoting ident changes.""" """Handles FIDENT, used for denoting ident changes."""
@ -712,7 +740,7 @@ class InspIRCdProtocol(TS6BaseProtocol):
""" """
uid = args[0] uid = args[0]
if args[1] == 'accountname': if args[1] == 'accountname' and uid in self.irc.users:
# <- :00A METADATA 1MLAAAJET accountname : # <- :00A METADATA 1MLAAAJET accountname :
# <- :00A METADATA 1MLAAAJET accountname :tester # <- :00A METADATA 1MLAAAJET accountname :tester
# Sets the services login name of the client. # Sets the services login name of the client.
@ -725,4 +753,17 @@ class InspIRCdProtocol(TS6BaseProtocol):
""" """
pass pass
def handle_kill(self, source, command, args):
"""Handles incoming KILLs."""
killed = args[0]
# Depending on whether the IRCd sends explicit QUIT messages for
# killed clients, the user may or may not have automatically been
# removed from our user list.
# If not, we have to assume that KILL = QUIT and remove them
# ourselves.
data = self.irc.users.get(killed)
if data:
self.removeClient(killed)
return {'target': killed, 'text': args[1], 'userdata': data}
Class = InspIRCdProtocol Class = InspIRCdProtocol

View File

@ -0,0 +1,79 @@
"""
ircs2s_common.py: Common base protocol class with functions shared by TS6 and P10-based protocols.
"""
from pylinkirc.classes import Protocol
from pylinkirc.log import log
class IRCS2SProtocol(Protocol):
def handle_kill(self, source, command, args):
"""Handles incoming KILLs."""
killed = args[0]
# Depending on whether the IRCd sends explicit QUIT messages for
# killed clients, the user may or may not have automatically been
# removed from our user list.
# If not, we have to assume that KILL = QUIT and remove them
# ourselves.
data = self.irc.users.get(killed)
if data:
self.removeClient(killed)
# TS6-style kills look something like this:
# <- :GL KILL 38QAAAAAA :hidden-1C620195!GL (test)
# What we actually want is to format a pretty kill message, in the form
# "Killed (killername (reason))".
try:
# Get the nick or server name of the caller.
killer = self.irc.getFriendlyName(source)
except KeyError:
# Killer was... neither? We must have aliens or something. Fallback
# to the given "UID".
killer = source
# Get the reason, which is enclosed in brackets.
reason = ' '.join(args[1].split(" ")[1:])
killmsg = "Killed (%s %s)" % (killer, reason)
return {'target': killed, 'text': killmsg, 'userdata': data}
def handle_squit(self, numeric, command, args):
"""Handles incoming SQUITs."""
# <- ABAAE SQ nefarious.midnight.vpn 0 :test
split_server = self._getSid(args[0])
affected_users = []
log.debug('(%s) Splitting server %s (reason: %s)', self.irc.name, split_server, args[-1])
if split_server not in self.irc.servers:
log.warning("(%s) Tried to split a server (%s) that didn't exist!", self.irc.name, split_server)
return
# Prevent RuntimeError: dictionary changed size during iteration
old_servers = self.irc.servers.copy()
# Cycle through our list of servers. If any server's uplink is the one that is being SQUIT,
# remove them and all their users too.
for sid, data in old_servers.items():
if data.uplink == split_server:
log.debug('Server %s also hosts server %s, removing those users too...', split_server, sid)
# Recursively run SQUIT on any other hubs this server may have been connected to.
args = self.handle_squit(sid, 'SQUIT', [sid, "0",
"PyLink: Automatically splitting leaf servers of %s" % sid])
affected_users += args['users']
for user in self.irc.servers[split_server].users.copy():
affected_users.append(user)
log.debug('Removing client %s (%s)', user, self.irc.users[user].nick)
self.removeClient(user)
sname = self.irc.servers[split_server].name
uplink = self.irc.servers[split_server].uplink
del self.irc.servers[split_server]
log.debug('(%s) Netsplit affected users: %s', self.irc.name, affected_users)
return {'target': split_server, 'users': affected_users, 'name': sname,
'uplink': uplink}

View File

@ -2,19 +2,14 @@
nefarious.py: Nefarious IRCu protocol module for PyLink. nefarious.py: Nefarious IRCu protocol module for PyLink.
""" """
import sys
import os
import base64 import base64
import struct import struct
from ipaddress import ip_address from ipaddress import ip_address
# Import hacks to access utils and classes... from pylinkirc import utils, structures
curdir = os.path.dirname(__file__) from pylinkirc.classes import *
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
from pylinkirc.protocols.ircs2s_common import *
import utils
from log import log
from classes import *
class P10UIDGenerator(utils.IncrementalUIDGenerator): class P10UIDGenerator(utils.IncrementalUIDGenerator):
"""Implements an incremental P10 UID Generator.""" """Implements an incremental P10 UID Generator."""
@ -63,7 +58,7 @@ class P10SIDGenerator():
self.currentnum += 1 self.currentnum += 1
return sid return sid
class P10Protocol(Protocol): class P10Protocol(IRCS2SProtocol):
def __init__(self, irc): def __init__(self, irc):
super().__init__(irc) super().__init__(irc)
@ -79,6 +74,22 @@ class P10Protocol(Protocol):
def _send(self, source, text): def _send(self, source, text):
self.irc.send("%s %s" % (source, text)) self.irc.send("%s %s" % (source, text))
@staticmethod
def access_sort(key):
"""
Sorts (prefixmode, UID) keys based on the prefix modes given.
"""
prefixes, user = key
# Add the prefixes given for each userpair, giving each one a set value. This ensures
# that 'ohv' > 'oh' > 'ov' > 'o' > 'hv' > 'h' > 'v' > ''
accesses = {'o': 100, 'h': 10, 'v': 1}
num = 0
for prefix in prefixes:
num += accesses.get(prefix, 0)
return num
@staticmethod @staticmethod
def decode_p10_ip(ip): def decode_p10_ip(ip):
"""Decodes a P10 IP.""" """Decodes a P10 IP."""
@ -454,7 +465,7 @@ class P10Protocol(Protocol):
else: else:
raise LookupError("No such PyLink client exists.") raise LookupError("No such PyLink client exists.")
def sjoin(self, server, channel, users, ts=None): def sjoin(self, server, channel, users, ts=None, modes=set()):
"""Sends an SJOIN for a group of users to a channel. """Sends an SJOIN for a group of users to a channel.
The sender should always be a Server ID (SID). TS is optional, and defaults The sender should always be a Server ID (SID). TS is optional, and defaults
@ -474,32 +485,38 @@ class P10Protocol(Protocol):
if not server: if not server:
raise LookupError('No such PyLink client exists.') raise LookupError('No such PyLink client exists.')
# Only send non-list modes in the modes argument BURST. Bans and exempts are formatted differently:
# <- AB B #test 1460742014 +tnl 10 ABAAB,ABAAA:o :%*!*@other.bad.host *!*@bad.host
# <- AB B #test2 1460743539 +l 10 ABAAA:vo :%*!*@bad.host
# <- AB B #test 1460747615 ABAAA:o :% ~ *!*@test.host
modes = modes or self.irc.channels[channel].modes
orig_ts = self.irc.channels[channel].ts orig_ts = self.irc.channels[channel].ts
ts = ts or orig_ts ts = ts or orig_ts
self.updateTS(channel, ts)
# Only send non-list modes in BURST. TODO: burst bans and banexempts too bans = []
modes = [m for m in self.irc.channels[channel].modes if m[0] not in self.irc.cmodes['*A']] exempts = []
regularmodes = []
for mode in modes:
modechar = mode[0][-1]
# Store bans and exempts in separate lists for processing, but don't reset bans that have already been set.
if modechar in self.irc.cmodes['*A']:
if (modechar, mode[1]) not in self.irc.channels[channel].modes:
if modechar == 'b':
bans.append(mode[1])
elif modechar == 'e':
exempts.append(mode[1])
else:
regularmodes.append(mode)
changedmodes = [] log.debug('(%s) sjoin: bans: %s, exempts: %s, other modes: %s', self.irc.name, bans, exempts, regularmodes)
changedmodes = set(modes)
changedusers = [] changedusers = []
namelist = [] namelist = []
# This is annoying because we have to sort our users by access before sending... # This is annoying because we have to sort our users by access before sending...
# Joins should look like: A0AAB,A0AAC,ABAAA:v,ABAAB:o,ABAAD,ACAAA:ov # Joins should look like: A0AAB,A0AAC,ABAAA:v,ABAAB:o,ABAAD,ACAAA:ov
# XXX: there HAS to be a better way of doing this users = sorted(users, key=self.access_sort)
def access_sort(key):
prefixes, user = key
# This is some hocus pocus. Add the prefixes given for each userpair,
# giving each one a set value. This ensures that 'ohv' > 'oh' > 'ov' > 'o' > 'hv' > 'h' > 'v' > ''
accesses = {'o': 100, 'h': 10, 'v': 1}
num = 0
for prefix in prefixes:
num += accesses.get(prefix, 0)
return num
users = sorted(users, key=access_sort)
last_prefixes = '' last_prefixes = ''
for userpair in users: for userpair in users:
@ -520,25 +537,35 @@ class P10Protocol(Protocol):
last_prefixes = prefixes last_prefixes = prefixes
if prefixes: if prefixes:
for prefix in prefixes: for prefix in prefixes:
changedmodes.append(('+%s' % prefix, user)) changedmodes.add(('+%s' % prefix, user))
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
namelist = ','.join(namelist) namelist = ','.join(namelist)
log.debug('(%s) sjoin: got %r for namelist', self.irc.name, namelist) log.debug('(%s) sjoin: got %r for namelist', self.irc.name, namelist)
# Format bans as the last argument if there are any.
banstring = ''
if bans or exempts:
banstring += ' :%' # Ban string starts with a % if there is anything
if bans:
banstring += ' '.join(bans) # Join all bans, separated by a space
if exempts:
# Exempts are separated from the ban list by a single argument "~".
banstring += ' ~ '
banstring += ' '.join(exempts)
if modes: # Only send modes if there are any. if modes: # Only send modes if there are any.
self._send(server, "B {channel} {ts} {modes} :{users}".format( self._send(server, "B {channel} {ts} {modes} {users}{banstring}".format(
ts=ts, users=namelist, channel=channel, ts=ts, users=namelist, channel=channel,
modes=self.irc.joinModes(modes))) modes=self.irc.joinModes(regularmodes), banstring=banstring))
else: else:
self._send(server, "B {channel} {ts} :{users}".format( self._send(server, "B {channel} {ts} {users}{banstring}".format(
ts=ts, users=namelist, channel=channel)) ts=ts, users=namelist, channel=channel, banstring=banstring))
self.irc.channels[channel].users.update(changedusers) self.irc.channels[channel].users.update(changedusers)
if ts <= orig_ts: self.updateTS(channel, ts, changedmodes)
# Only save our prefix modes in the channel state if our TS is lower than or equal to theirs.
self.irc.applyModes(channel, changedmodes)
def spawnServer(self, name, sid=None, uplink=None, desc=None, endburst_delay=0): def spawnServer(self, name, sid=None, uplink=None, desc=None, endburst_delay=0):
""" """
@ -945,19 +972,15 @@ class P10Protocol(Protocol):
channel = self.irc.toLower(args[0]) channel = self.irc.toLower(args[0])
userlist = args[-1].split() userlist = args[-1].split()
their_ts = int(args[1])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts)
bans = [] bans = []
if args[-1].startswith('%'): if args[-1].startswith('%'):
# Ban lists start with a %. However, if one argument is "~", # Ban lists start with a %. However, if one argument is "~",
# Parse everything after it as an exempt (+e). # parse everything after it as an ban exempt (+e).
exempts = False exempts = False
for host in args[-1][1:].split(' '): for host in args[-1][1:].split(' '):
if not host: if not host:
# Space between % and ~ ignore. # Space between % and ~; ignore.
continue continue
elif host == '~': elif host == '~':
exempts = True exempts = True
@ -979,9 +1002,11 @@ class P10Protocol(Protocol):
else: else:
parsedmodes = [] parsedmodes = []
# Add the ban list to the list of modes to process. # This list is used to keep track of prefix modes being added to the mode list.
parsedmodes.extend(bans) changedmodes = set(parsedmodes)
# Also add the the ban list to the list of modes to process internally.
parsedmodes.extend(bans)
if parsedmodes: if parsedmodes:
self.irc.applyModes(channel, parsedmodes) self.irc.applyModes(channel, parsedmodes)
@ -995,8 +1020,9 @@ class P10Protocol(Protocol):
for userpair in userlist: for userpair in userlist:
# This is given in the form UID1,UID2:prefixes. However, when one userpair is given # This is given in the form UID1,UID2:prefixes. However, when one userpair is given
# with a certain prefix, it implicitly applies to all other following UIDs, until # with a certain prefix, it implicitly applies to all other following UIDs, until
# another userpair is given with a prefix. For example: UID1,UID3:o,UID4,UID5 would # another userpair is given with a list of prefix modes. For example,
# assume that UID1 has no prefixes, but UID3-5 all have op when joining. # "UID1,UID3:o,UID4,UID5" would assume that UID1 has no prefixes, but that UIDs 3-5
# all have op.
try: try:
user, prefixes = userpair.split(':') user, prefixes = userpair.split(':')
except ValueError: except ValueError:
@ -1013,11 +1039,16 @@ class P10Protocol(Protocol):
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
if their_ts <= our_ts: # Only save mode changes if the remote has lower TS than us.
self.irc.applyModes(channel, [('+%s' % mode, user) for mode in prefixes]) changedmodes |= {('+%s' % mode, user) for mode in prefixes}
self.irc.channels[channel].users.add(user) self.irc.channels[channel].users.add(user)
# Statekeeping with timestamps
their_ts = int(args[1])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts, changedmodes)
return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts} return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts}
def handle_join(self, source, command, args): def handle_join(self, source, command, args):
@ -1040,6 +1071,7 @@ class P10Protocol(Protocol):
for channel in oldchans: for channel in oldchans:
self.irc.channels[channel].users.discard(source) self.irc.channels[channel].users.discard(source)
self.irc.users[source].channels.discard(channel) self.irc.users[source].channels.discard(channel)
return {'channels': oldchans, 'text': 'Left all channels.', 'parse_as': 'PART'} return {'channels': oldchans, 'text': 'Left all channels.', 'parse_as': 'PART'}
else: else:
channel = self.irc.toLower(args[0]) channel = self.irc.toLower(args[0])
@ -1140,54 +1172,6 @@ class P10Protocol(Protocol):
self.removeClient(numeric) self.removeClient(numeric)
return {'text': args[0]} return {'text': args[0]}
def handle_kill(self, numeric, command, args):
"""Handles incoming KILLs."""
# <- ABAAA D AyAAA :nefarious.midnight.vpn!GL (test)
killed = args[0]
# Back up the target user data before removing it, so we can send it via a hook.
data = self.irc.users.get(killed)
if data:
self.removeClient(killed)
return {'target': killed, 'text': args[1], 'userdata': data}
def handle_squit(self, numeric, command, args):
"""Handles incoming SQUITs."""
# <- ABAAE SQ nefarious.midnight.vpn 0 :test
split_server = self._getSid(args[0])
affected_users = []
log.debug('(%s) Splitting server %s (reason: %s)', self.irc.name, split_server, args[-1])
if split_server not in self.irc.servers:
log.warning("(%s) Tried to split a server (%s) that didn't exist!", self.irc.name, split_server)
return
# Prevent RuntimeError: dictionary changed size during iteration
old_servers = self.irc.servers.copy()
# Cycle through our list of servers. If any server's uplink is the one that is being SQUIT,
# remove them and all their users too.
for sid, data in old_servers.items():
if data.uplink == split_server:
log.debug('Server %s also hosts server %s, removing those users too...', split_server, sid)
# Recursively run SQUIT on any other hubs this server may have been connected to.
args = self.handle_squit(sid, 'SQUIT', [sid, "0",
"PyLink: Automatically splitting leaf servers of %s" % sid])
affected_users += args['users']
for user in self.irc.servers[split_server].users.copy():
affected_users.append(user)
log.debug('Removing client %s (%s)', user, self.irc.users[user].nick)
self.removeClient(user)
sname = self.irc.servers[split_server].name
del self.irc.servers[split_server]
log.debug('(%s) Netsplit affected users: %s', self.irc.name, affected_users)
return {'target': split_server, 'users': affected_users, 'name': sname}
def handle_topic(self, source, command, args): def handle_topic(self, source, command, args):
"""Handles TOPIC changes.""" """Handles TOPIC changes."""
# <- ABAAA T #test GL!~gl@nefarious.midnight.vpn 1460852591 1460855795 :blah # <- ABAAA T #test GL!~gl@nefarious.midnight.vpn 1460852591 1460855795 :blah

View File

@ -7,14 +7,10 @@ import sys
import os import os
import re import re
# Import hacks to access utils and classes... from pylinkirc import utils
curdir = os.path.dirname(__file__) from pylinkirc.classes import *
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
import utils from pylinkirc.protocols.ts6_common import *
from log import log
from classes import *
from ts6_common import *
class TS6Protocol(TS6BaseProtocol): class TS6Protocol(TS6BaseProtocol):
def __init__(self, irc): def __init__(self, irc):
@ -77,7 +73,7 @@ class TS6Protocol(TS6BaseProtocol):
self.irc.channels[channel].users.add(client) self.irc.channels[channel].users.add(client)
self.irc.users[client].channels.add(channel) self.irc.users[client].channels.add(channel)
def sjoin(self, server, channel, users, ts=None): def sjoin(self, server, channel, users, ts=None, modes=set()):
"""Sends an SJOIN for a group of users to a channel. """Sends an SJOIN for a group of users to a channel.
The sender should always be a Server ID (SID). TS is optional, and defaults The sender should always be a Server ID (SID). TS is optional, and defaults
@ -104,19 +100,30 @@ class TS6Protocol(TS6BaseProtocol):
if not server: if not server:
raise LookupError('No such PyLink client exists.') raise LookupError('No such PyLink client exists.')
modes = set(modes or self.irc.channels[channel].modes)
orig_ts = self.irc.channels[channel].ts orig_ts = self.irc.channels[channel].ts
ts = ts or orig_ts ts = ts or orig_ts
self.updateTS(channel, ts)
log.debug("(%s) sending SJOIN to %s with ts %s (that's %r)", self.irc.name, channel, ts, # Get all the ban modes in a separate list. These are bursted using a separate BMASK
time.strftime("%c", time.localtime(ts))) # command.
modes = [m for m in self.irc.channels[channel].modes if m[0] not in self.irc.cmodes['*A']] banmodes = {k: set() for k in self.irc.cmodes['*A']}
changedmodes = [] regularmodes = []
while users[:10]: log.debug('(%s) Unfiltered SJOIN modes: %s', self.irc.name, modes)
for mode in modes:
modechar = mode[0][-1]
if modechar in self.irc.cmodes['*A']:
# Mode character is one of 'beIq'
banmodes[modechar].add(mode[1])
else:
regularmodes.append(mode)
log.debug('(%s) Filtered SJOIN modes to be regular modes: %s, banmodes: %s', self.irc.name, regularmodes, banmodes)
changedmodes = modes
while users[:12]:
uids = [] uids = []
namelist = [] namelist = []
# We take <users> as a list of (prefixmodes, uid) pairs. # We take <users> as a list of (prefixmodes, uid) pairs.
for userpair in users[:10]: for userpair in users[:12]:
assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair
prefixes, user = userpair prefixes, user = userpair
prefixchars = '' prefixchars = ''
@ -124,22 +131,34 @@ class TS6Protocol(TS6BaseProtocol):
pr = self.irc.prefixmodes.get(prefix) pr = self.irc.prefixmodes.get(prefix)
if pr: if pr:
prefixchars += pr prefixchars += pr
changedmodes.append(('+%s' % prefix, user)) changedmodes.add(('+%s' % prefix, user))
namelist.append(prefixchars+user) namelist.append(prefixchars+user)
uids.append(user) uids.append(user)
try: try:
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
except KeyError: # Not initialized yet? except KeyError: # Not initialized yet?
log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user) log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user)
users = users[10:] users = users[12:]
namelist = ' '.join(namelist) namelist = ' '.join(namelist)
self._send(server, "SJOIN {ts} {channel} {modes} :{users}".format( self._send(server, "SJOIN {ts} {channel} {modes} :{users}".format(
ts=ts, users=namelist, channel=channel, ts=ts, users=namelist, channel=channel,
modes=self.irc.joinModes(modes))) modes=self.irc.joinModes(regularmodes)))
self.irc.channels[channel].users.update(uids) self.irc.channels[channel].users.update(uids)
if ts <= orig_ts:
# Only save our prefix modes in the channel state if our TS is lower than or equal to theirs. # Now, burst bans.
self.irc.applyModes(channel, changedmodes) # <- :42X BMASK 1424222769 #dev b :*!test@*.isp.net *!badident@*
for bmode, bans in banmodes.items():
# Max 15-3 = 12 bans per line to prevent cut off. (TS6 allows a max of 15 parameters per
# line)
if bans:
log.debug('(%s) sjoin: bursting mode %s with bans %s, ts:%s', self.irc.name, bmode, bans, ts)
bans = list(bans) # Convert into list for splicing
while bans[:12]:
self._send(server, "BMASK {ts} {channel} {bmode} :{bans}".format(ts=ts,
channel=channel, bmode=bmode, bans=' '.join(bans[:12])))
bans = bans[12:]
self.updateTS(channel, ts, changedmodes)
def mode(self, numeric, target, modes, ts=None): def mode(self, numeric, target, modes, ts=None):
"""Sends mode changes from a PyLink client/server.""" """Sends mode changes from a PyLink client/server."""
@ -160,34 +179,16 @@ class TS6Protocol(TS6BaseProtocol):
# On output, at most ten cmode parameters should be sent; if there are more, # On output, at most ten cmode parameters should be sent; if there are more,
# multiple TMODE messages should be sent. # multiple TMODE messages should be sent.
while modes[:9]: while modes[:10]:
# Seriously, though. If you send more than 10 mode parameters in # Seriously, though. If you send more than 10 mode parameters in
# a line, charybdis will silently REJECT the entire command! # a line, charybdis will silently REJECT the entire command!
joinedmodes = self.irc.joinModes(modes = [m for m in modes[:9] if m[0] not in self.irc.cmodes['*A']]) joinedmodes = self.irc.joinModes(modes = [m for m in modes[:10] if m[0] not in self.irc.cmodes['*A']])
modes = modes[9:] modes = modes[10:]
self._send(numeric, 'TMODE %s %s %s' % (ts, target, joinedmodes)) self._send(numeric, 'TMODE %s %s %s' % (ts, target, joinedmodes))
else: else:
joinedmodes = self.irc.joinModes(modes) joinedmodes = self.irc.joinModes(modes)
self._send(numeric, 'MODE %s %s' % (target, joinedmodes)) self._send(numeric, 'MODE %s %s' % (target, joinedmodes))
def kill(self, numeric, target, reason):
"""Sends a kill from a PyLink client/server."""
if (not self.irc.isInternalClient(numeric)) and \
(not self.irc.isInternalServer(numeric)):
raise LookupError('No such PyLink client/server exists.')
# KILL:
# parameters: target user, path
# The format of the path parameter is some sort of description of the source of
# the kill followed by a space and a parenthesized reason. To avoid overflow,
# it is recommended not to add anything to the path.
assert target in self.irc.users, "Unknown target %r for kill()!" % target
self._send(numeric, 'KILL %s :Killed (%s)' % (target, reason))
self.removeClient(target)
def topicBurst(self, numeric, target, text): def topicBurst(self, numeric, target, text):
"""Sends a topic change from a PyLink server. This is usually used on burst.""" """Sends a topic change from a PyLink server. This is usually used on burst."""
if not self.irc.isInternalServer(numeric): if not self.irc.isInternalServer(numeric):
@ -422,15 +423,15 @@ class TS6Protocol(TS6BaseProtocol):
# <- :0UY SJOIN 1451041566 #channel +nt :@0UYAAAAAB # <- :0UY SJOIN 1451041566 #channel +nt :@0UYAAAAAB
channel = self.irc.toLower(args[1]) channel = self.irc.toLower(args[1])
userlist = args[-1].split() userlist = args[-1].split()
their_ts = int(args[0])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts)
modestring = args[2:-1] or args[2] modestring = args[2:-1] or args[2]
parsedmodes = self.irc.parseModes(channel, modestring) parsedmodes = self.irc.parseModes(channel, modestring)
self.irc.applyModes(channel, parsedmodes) self.irc.applyModes(channel, parsedmodes)
namelist = [] namelist = []
# Keep track of other modes that are added due to prefix modes being joined too.
changedmodes = set(parsedmodes)
log.debug('(%s) handle_sjoin: got userlist %r for %r', self.irc.name, userlist, channel) log.debug('(%s) handle_sjoin: got userlist %r for %r', self.irc.name, userlist, channel)
for userpair in userlist: for userpair in userlist:
# charybdis sends this in the form "@+UID1, +UID2, UID3, @UID4" # charybdis sends this in the form "@+UID1, +UID2, UID3, @UID4"
@ -455,9 +456,16 @@ class TS6Protocol(TS6BaseProtocol):
finalprefix += char finalprefix += char
namelist.append(user) namelist.append(user)
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
if their_ts <= our_ts:
self.irc.applyModes(channel, [('+%s' % mode, user) for mode in finalprefix]) # Only save mode changes if the remote has lower TS than us.
changedmodes |= {('+%s' % mode, user) for mode in finalprefix}
self.irc.channels[channel].users.add(user) self.irc.channels[channel].users.add(user)
# Statekeeping with timestamps
their_ts = int(args[0])
our_ts = self.irc.channels[channel].ts
self.updateTS(channel, their_ts, changedmodes)
return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts} return {'channel': channel, 'users': namelist, 'modes': parsedmodes, 'ts': their_ts}
def handle_join(self, numeric, command, args): def handle_join(self, numeric, command, args):
@ -658,23 +666,18 @@ class TS6Protocol(TS6BaseProtocol):
'your IRCd configuration.', self.irc.name, setter, badmode, 'your IRCd configuration.', self.irc.name, setter, badmode,
charlist[badmode]) charlist[badmode])
def handle_encap(self, numeric, command, args): def handle_su(self, numeric, command, args):
""" """
Handles the ENCAP command - encapsulated TS6 commands with a variety of Handles SU, which is used for setting login information
subcommands used for different purposes.
""" """
commandname = args[1]
if commandname == 'SU':
# Handles SU, which is used for setting login information
# <- :00A ENCAP * SU 42XAAAAAC :GLolol # <- :00A ENCAP * SU 42XAAAAAC :GLolol
# <- :00A ENCAP * SU 42XAAAAAC # <- :00A ENCAP * SU 42XAAAAAC
try: try:
account = args[3] # Account name is being set account = args[1] # Account name is being set
except IndexError: except IndexError:
account = '' # No account name means a logout account = '' # No account name means a logout
uid = args[2] uid = args[0]
self.irc.callHooks([uid, 'CLIENT_SERVICES_LOGIN', {'text': account}]) self.irc.callHooks([uid, 'CLIENT_SERVICES_LOGIN', {'text': account}])
Class = TS6Protocol Class = TS6Protocol

View File

@ -2,18 +2,12 @@
ts6_common.py: Common base protocol class with functions shared by the UnrealIRCd, InspIRCd, and TS6 protocol modules. ts6_common.py: Common base protocol class with functions shared by the UnrealIRCd, InspIRCd, and TS6 protocol modules.
""" """
import sys
import os
import string import string
# Import hacks to access utils and classes... from pylinkirc import utils, structures
curdir = os.path.dirname(__file__) from pylinkirc.classes import *
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
from pylinkirc.protocols.ircs2s_common import *
import utils
from log import log
from classes import *
import structures
class TS6SIDGenerator(): class TS6SIDGenerator():
""" """
@ -103,7 +97,7 @@ class TS6UIDGenerator(utils.IncrementalUIDGenerator):
self.length = 6 self.length = 6
super().__init__(sid) super().__init__(sid)
class TS6BaseProtocol(Protocol): class TS6BaseProtocol(IRCS2SProtocol):
def __init__(self, irc): def __init__(self, irc):
super().__init__(irc) super().__init__(irc)
@ -165,6 +159,39 @@ class TS6BaseProtocol(Protocol):
# handle_part() does that just fine. # handle_part() does that just fine.
self.handle_part(target, 'KICK', [channel]) self.handle_part(target, 'KICK', [channel])
def kill(self, numeric, target, reason):
"""Sends a kill from a PyLink client/server."""
if (not self.irc.isInternalClient(numeric)) and \
(not self.irc.isInternalServer(numeric)):
raise LookupError('No such PyLink client/server exists.')
# From TS6 docs:
# KILL:
# parameters: target user, path
# The format of the path parameter is some sort of description of the source of
# the kill followed by a space and a parenthesized reason. To avoid overflow,
# it is recommended not to add anything to the path.
assert target in self.irc.users, "Unknown target %r for kill()!" % target
if numeric in self.irc.users:
# Killer was an user. Follow examples of setting the path to be "killer.host!killer.nick".
userobj = self.irc.users[numeric]
killpath = '%s!%s' % (userobj.host, userobj.nick)
elif numeric in self.irc.servers:
# Sender was a server; killpath is just its name.
killpath = self.irc.servers[numeric].name
else:
# Invalid sender?! This shouldn't happen, but make the killpath our server name anyways.
log.warning('(%s) Invalid sender %s for kill(); using our server name instead.',
self.irc.name, numeric)
killpath = self.irc.servers[self.irc.sid].name
self._send(numeric, 'KILL %s :%s (%s)' % (target, killpath, reason))
self.removeClient(target)
def nick(self, numeric, newnick): def nick(self, numeric, newnick):
"""Changes the nick of a PyLink client.""" """Changes the nick of a PyLink client."""
if not self.irc.isInternalClient(numeric): if not self.irc.isInternalClient(numeric):
@ -303,6 +330,13 @@ class TS6BaseProtocol(Protocol):
command = args[0] command = args[0]
args = args[1:] args = args[1:]
if command == 'ENCAP':
# Special case for encapsulated commands (ENCAP), in forms like this:
# <- :00A ENCAP * SU 42XAAAAAC :GLolol
command = args[1]
args = args[2:]
log.debug("(%s) Rewriting incoming ENCAP to command %s (args: %s)", self.irc.name, command, args)
try: try:
func = getattr(self, 'handle_'+command.lower()) func = getattr(self, 'handle_'+command.lower())
except AttributeError: # unhandled command except AttributeError: # unhandled command
@ -324,19 +358,6 @@ class TS6BaseProtocol(Protocol):
handle_notice = handle_privmsg handle_notice = handle_privmsg
def handle_kill(self, source, command, args):
"""Handles incoming KILLs."""
killed = args[0]
# Depending on whether the IRCd sends explicit QUIT messages for
# killed clients, the user may or may not have automatically been
# removed from our user list.
# If not, we have to assume that KILL = QUIT and remove them
# ourselves.
data = self.irc.users.get(killed)
if data:
self.removeClient(killed)
return {'target': killed, 'text': args[1], 'userdata': data}
def handle_kick(self, source, command, args): def handle_kick(self, source, command, args):
"""Handles incoming KICKs.""" """Handles incoming KICKs."""
# :70MAAAAAA KICK #test 70MAAAAAA :some reason # :70MAAAAAA KICK #test 70MAAAAAA :some reason
@ -378,32 +399,6 @@ class TS6BaseProtocol(Protocol):
self.irc.users[user].nick = user self.irc.users[user].nick = user
return {'target': user, 'ts': int(args[1]), 'oldnick': oldnick} return {'target': user, 'ts': int(args[1]), 'oldnick': oldnick}
def handle_squit(self, numeric, command, args):
"""Handles incoming SQUITs (netsplits)."""
# :70M SQUIT 1ML :Server quit by GL!gl@0::1
log.debug('handle_squit args: %s', args)
split_server = args[0]
affected_users = []
log.debug('(%s) Splitting server %s (reason: %s)', self.irc.name, split_server, args[-1])
if split_server not in self.irc.servers:
log.warning("(%s) Tried to split a server (%s) that didn't exist!", self.irc.name, split_server)
return
# Prevent RuntimeError: dictionary changed size during iteration
old_servers = self.irc.servers.copy()
for sid, data in old_servers.items():
if data.uplink == split_server:
log.debug('Server %s also hosts server %s, removing those users too...', split_server, sid)
args = self.handle_squit(sid, 'SQUIT', [sid, "PyLink: Automatically splitting leaf servers of %s" % sid])
affected_users += args['users']
for user in self.irc.servers[split_server].users.copy():
affected_users.append(user)
log.debug('Removing client %s (%s)', user, self.irc.users[user].nick)
self.removeClient(user)
sname = self.irc.servers[split_server].name
del self.irc.servers[split_server]
log.debug('(%s) Netsplit affected users: %s', self.irc.name, affected_users)
return {'target': split_server, 'users': affected_users, 'name': sname}
def handle_topic(self, numeric, command, args): def handle_topic(self, numeric, command, args):
"""Handles incoming TOPIC changes from clients. For topic bursts, """Handles incoming TOPIC changes from clients. For topic bursts,
TB (TS6/charybdis) and FTOPIC (InspIRCd) are used instead.""" TB (TS6/charybdis) and FTOPIC (InspIRCd) are used instead."""

View File

@ -9,14 +9,10 @@ import codecs
import socket import socket
import re import re
# Import hacks to access utils and classes... from pylinkirc import utils
curdir = os.path.dirname(__file__) from pylinkirc.classes import *
sys.path += [curdir, os.path.dirname(curdir)] from pylinkirc.log import log
from pylinkirc.protocols.ts6_common import *
import utils
from log import log
from classes import *
from ts6_common import *
class UnrealProtocol(TS6BaseProtocol): class UnrealProtocol(TS6BaseProtocol):
def __init__(self, irc): def __init__(self, irc):
@ -127,7 +123,7 @@ class UnrealProtocol(TS6BaseProtocol):
self.irc.channels[channel].users.add(client) self.irc.channels[channel].users.add(client)
self.irc.users[client].channels.add(channel) self.irc.users[client].channels.add(channel)
def sjoin(self, server, channel, users, ts=None): def sjoin(self, server, channel, users, ts=None, modes=set()):
"""Sends an SJOIN for a group of users to a channel. """Sends an SJOIN for a group of users to a channel.
The sender should always be a server (SID). TS is optional, and defaults The sender should always be a server (SID). TS is optional, and defaults
@ -151,36 +147,46 @@ class UnrealProtocol(TS6BaseProtocol):
if not server: if not server:
raise LookupError('No such PyLink server exists.') raise LookupError('No such PyLink server exists.')
changedmodes = set(modes or self.irc.channels[channel].modes)
orig_ts = self.irc.channels[channel].ts orig_ts = self.irc.channels[channel].ts
ts = ts or orig_ts ts = ts or orig_ts
self.updateTS(channel, ts)
changedmodes = []
uids = [] uids = []
namelist = [] namelist = []
for userpair in users: for userpair in users:
assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair assert len(userpair) == 2, "Incorrect format of userpair: %r" % userpair
prefixes, user = userpair prefixes, user = userpair
# Unreal uses slightly different prefixes in SJOIN. +q is * instead of ~, # Unreal uses slightly different prefixes in SJOIN. +q is * instead of ~,
# and +a is ~ instead of &. # and +a is ~ instead of &.
# &, ", and ' are used for bursting bans. # &, ", and ' are used for bursting bans.
sjoin_prefixes = {'q': '*', 'a': '~', 'o': '@', 'h': '%', 'v': '+'} sjoin_prefixes = {'q': '*', 'a': '~', 'o': '@', 'h': '%', 'v': '+'}
prefixchars = ''.join([sjoin_prefixes.get(prefix, '') for prefix in prefixes]) prefixchars = ''.join([sjoin_prefixes.get(prefix, '') for prefix in prefixes])
if prefixchars: if prefixchars:
changedmodes + [('+%s' % prefix, user) for prefix in prefixes] changedmodes |= {('+%s' % prefix, user) for prefix in prefixes}
namelist.append(prefixchars+user) namelist.append(prefixchars+user)
uids.append(user) uids.append(user)
try: try:
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
except KeyError: # Not initialized yet? except KeyError: # Not initialized yet?
log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user) log.debug("(%s) sjoin: KeyError trying to add %r to %r's channel list?", self.irc.name, channel, user)
namelist = ' '.join(namelist) namelist = ' '.join(namelist)
self._send(server, "SJOIN {ts} {channel} :{users}".format( self._send(server, "SJOIN {ts} {channel} :{users}".format(
ts=ts, users=namelist, channel=channel)) ts=ts, users=namelist, channel=channel))
# Burst modes separately. No really, this is what I see UnrealIRCd do! It sends
# JOINs on burst and then MODE!
if modes:
self.mode(server, channel, modes, ts=ts)
self.irc.channels[channel].users.update(uids) self.irc.channels[channel].users.update(uids)
if ts <= orig_ts:
# Only save our prefix modes in the channel state if our TS is lower than or equal to theirs. self.updateTS(channel, ts, changedmodes)
self.irc.applyModes(channel, changedmodes)
def ping(self, source=None, target=None): def ping(self, source=None, target=None):
"""Sends a PING to a target server. Periodic PINGs are sent to our uplink """Sends a PING to a target server. Periodic PINGs are sent to our uplink
@ -190,20 +196,6 @@ class UnrealProtocol(TS6BaseProtocol):
if not (target is None or source is None): if not (target is None or source is None):
self._send(source, 'PING %s %s' % (self.irc.servers[source].name, self.irc.servers[target].name)) self._send(source, 'PING %s %s' % (self.irc.servers[source].name, self.irc.servers[target].name))
def kill(self, numeric, target, reason):
"""Sends a kill from a PyLink client/server."""
# <- :GL KILL 38QAAAAAA :hidden-1C620195!GL (test)
if (not self.irc.isInternalClient(numeric)) and \
(not self.irc.isInternalServer(numeric)):
raise LookupError('No such PyLink client/server exists.')
assert target in self.irc.users, "Unknown target %r for kill()!" % target
# The killpath doesn't really matter here...
self._send(numeric, 'KILL %s :%s!PyLink (%s)' % (target, self.irc.serverdata['hostname'], reason))
self.removeClient(target)
def mode(self, numeric, target, modes, ts=None): def mode(self, numeric, target, modes, ts=None):
""" """
Sends mode changes from a PyLink client/server. The mode list should be Sends mode changes from a PyLink client/server. The mode list should be
@ -320,7 +312,7 @@ class UnrealProtocol(TS6BaseProtocol):
host = self.irc.serverdata["hostname"] host = self.irc.serverdata["hostname"]
f('PASS :%s' % self.irc.serverdata["sendpass"]) f('PASS :%s' % self.irc.serverdata["sendpass"])
# https://github.com/unrealircd/unrealself.ircd/blob/2f8cb55e/doc/technical/protoctl.txt # https://github.com/unrealircd/unrealircd/blob/2f8cb55e/doc/technical/protoctl.txt
# We support the following protocol features: # We support the following protocol features:
# SJ3 - extended SJOIN # SJ3 - extended SJOIN
# NOQUIT - QUIT messages aren't sent for all users in a netsplit # NOQUIT - QUIT messages aren't sent for all users in a netsplit
@ -550,12 +542,12 @@ class UnrealProtocol(TS6BaseProtocol):
channel = self.irc.toLower(args[1]) channel = self.irc.toLower(args[1])
userlist = args[-1].split() userlist = args[-1].split()
our_ts = self.irc.channels[channel].ts
their_ts = int(args[0])
self.updateTS(channel, their_ts)
namelist = [] namelist = []
log.debug('(%s) handle_sjoin: got userlist %r for %r', self.irc.name, userlist, channel) log.debug('(%s) handle_sjoin: got userlist %r for %r', self.irc.name, userlist, channel)
# Keep track of other modes that are added due to prefix modes being joined too.
changedmodes = set(self.irc.channels[channel].modes)
for userpair in userlist: for userpair in userlist:
if userpair.startswith("&\"'"): # TODO: handle ban bursts too if userpair.startswith("&\"'"): # TODO: handle ban bursts too
# &, ", and ' entries are used for bursting bans: # &, ", and ' entries are used for bursting bans:
@ -577,10 +569,16 @@ class UnrealProtocol(TS6BaseProtocol):
finalprefix += char finalprefix += char
namelist.append(user) namelist.append(user)
self.irc.users[user].channels.add(channel) self.irc.users[user].channels.add(channel)
# Only merge the remote's prefix modes if their TS is smaller or equal to ours. # Only merge the remote's prefix modes if their TS is smaller or equal to ours.
if their_ts <= our_ts: changedmodes |= {('+%s' % mode, user) for mode in finalprefix}
self.irc.applyModes(channel, [('+%s' % mode, user) for mode in finalprefix])
self.irc.channels[channel].users.add(user) self.irc.channels[channel].users.add(user)
our_ts = self.irc.channels[channel].ts
their_ts = int(args[0])
self.updateTS(channel, their_ts, changedmodes)
return {'channel': channel, 'users': namelist, 'modes': self.irc.channels[channel].modes, 'ts': their_ts} return {'channel': channel, 'users': namelist, 'modes': self.irc.channels[channel].modes, 'ts': their_ts}
def handle_nick(self, numeric, command, args): def handle_nick(self, numeric, command, args):
@ -640,10 +638,21 @@ class UnrealProtocol(TS6BaseProtocol):
if utils.isChannel(args[0]): if utils.isChannel(args[0]):
channel = self.irc.toLower(args[0]) channel = self.irc.toLower(args[0])
oldobj = self.irc.channels[channel].deepcopy() oldobj = self.irc.channels[channel].deepcopy()
modes = list(filter(None, args[1:])) # normalize whitespace modes = list(filter(None, args[1:])) # normalize whitespace
parsedmodes = self.irc.parseModes(channel, modes) parsedmodes = self.irc.parseModes(channel, modes)
if parsedmodes: if parsedmodes:
if parsedmodes[0][0] == '+&':
# UnrealIRCd uses a & virtual mode to denote mode bounces, meaning that an attempt to set modes
# by us was rejected for some reason (usually due to timestamps). Warn about this and drop the
# mode change to prevent mode floods.
log.warning("(%s) Received mode bounce %s in channel %s! Our TS: %s",
self.irc.name, modes, channel, self.irc.channels[channel].ts)
return
self.irc.applyModes(channel, parsedmodes) self.irc.applyModes(channel, parsedmodes)
if numeric in self.irc.servers and args[-1].isdigit(): if numeric in self.irc.servers and args[-1].isdigit():
# Sender is a server AND last arg is number. Perform TS updates. # Sender is a server AND last arg is number. Perform TS updates.
their_ts = int(args[-1]) their_ts = int(args[-1])

47
pylink
View File

@ -1,25 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""
PyLink IRC Services launcher.
"""
import os import os
import sys import sys
try:
# Change directory to the folder containing PyLink's source from pylinkirc import world, conf, __version__
os.chdir(os.path.dirname(os.path.abspath(__file__))) except ImportError:
sys.stderr.write("ERROR: Failed to import PyLink main module (pylinkirc.world).\n\nIf you are "
# This must be done before conf imports, so we get the real conf instead of testing one. "running PyLink from source, please install PyLink first using 'python3 "
import world "setup.py install [--user]'\n")
world.testing = False sys.exit(1)
import conf
from log import log
import classes
import utils
import coreplugin
if __name__ == '__main__': if __name__ == '__main__':
log.info('PyLink %s starting...', world.version) import argparse
# Write a PID file. parser = argparse.ArgumentParser(description='Starts an instance of PyLink IRC Services.')
parser.add_argument('config', help='specifies the path to the config file (defaults to pylink.yml)', nargs='?', default='pylink.yml')
parser.add_argument("-v", "--version", help="displays the program version and exits", action='store_true')
parser.add_argument("-n", "--no-pid", help="skips generating PID files", action='store_true')
args = parser.parse_args()
if args.version: # Display version and exit
print('PyLink ' + __version__)
sys.exit()
# Load the config
conf.loadConf(args.config)
from pylinkirc.log import log
from pylinkirc import classes, utils, coremods
log.info('PyLink %s starting...', __version__)
# Write a PID file unless specifically told not to.
if not args.no_pid:
with open('%s.pid' % conf.confname, 'w') as f: with open('%s.pid' % conf.confname, 'w') as f:
f.write(str(os.getpid())) f.write(str(os.getpid()))
@ -30,7 +45,7 @@ if __name__ == '__main__':
# dynamically depending on which were configured. # dynamically depending on which were configured.
for plugin in to_load: for plugin in to_load:
try: try:
world.plugins[plugin] = pl = utils.loadModuleFromFolder(plugin, world.plugins_folder) world.plugins[plugin] = pl = utils.loadPlugin(plugin)
except (OSError, ImportError) as e: except (OSError, ImportError) as e:
log.exception('Failed to load plugin %r: %s: %s', plugin, type(e).__name__, str(e)) log.exception('Failed to load plugin %r: %s: %s', plugin, type(e).__name__, str(e))
else: else:

View File

@ -1,26 +0,0 @@
#!/usr/bin/env python3
import unittest
import glob
import os
import sys
if __name__ == '__main__':
runner = unittest.TextTestRunner(verbosity=2)
fails = []
suites = []
# Yay, import hacks!
sys.path.append(os.path.join(os.getcwd(), 'tests'))
query = sys.argv[1:] or glob.glob('tests/test_*.py')
for testfile in query:
# Strip the tests/ and .py extension: tests/test_whatever.py => test_whatever
module = testfile.replace('.py', '').replace('tests/', '')
module = __import__(module)
suites.append(unittest.defaultTestLoader.loadTestsFromModule(module))
testsuite = unittest.TestSuite(suites)
runner.run(testsuite)

79
setup.py Normal file
View File

@ -0,0 +1,79 @@
"""Setup module for PyLink IRC Services."""
from setuptools import setup, find_packages
from codecs import open
import subprocess
from os import path
# Get version from Git tags.
try:
version = subprocess.check_output(['git', 'describe', '--tags']).decode('utf-8').strip()
except Exception as e:
print('ERROR: Failed to get version from "git describe --tags": %s: %s' % (type(e).__name__, e))
from __init__ import __version__ as fallback_version
# Mark builds with unretrievable version (GitHub tarballs, etc.) as -dirty
if not fallback_version.endswith('-dirty'):
fallback_version += '-dirty'
print('Using fallback version of %r.' % fallback_version)
version = fallback_version
# Write the version to disk.
with open('__init__.py', 'w') as f:
f.write('# Automatically generated by setup.py\n')
f.write('__version__ = %r\n' % version)
curdir = path.abspath(path.dirname(__file__))
# FIXME: Convert markdown to RST
with open(path.join(curdir, 'README.md'), encoding='utf-8') as f:
long_description = f.read()
setup(
name='pylinkirc',
version=version,
description='PyLink IRC Services',
long_description=long_description,
url='https://github.com/GLolol/PyLink',
# Author details
author='James Lu',
author_email='GLolol@overdrivenetworks.com',
# Choose your license
license='MPL 2.0',
# See https://pypi.python.org/pypi?%3Aaction=list_classifiers
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'Topic :: Communications :: Chat :: Internet Relay Chat',
'Topic :: Software Development :: Libraries :: Python Modules',
'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
],
keywords='IRC services chat',
install_requires=['pyyaml'],
# Folders (packages of code)
packages=['pylinkirc', 'pylinkirc.protocols', 'pylinkirc.plugins', 'pylinkirc.coremods'],
# Data files
package_data={
'': ['example-conf.yml'],
},
package_dir = {'pylinkirc': '.'},
# Executable scripts
scripts=["pylink"],
)

View File

@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Shell script to start PyLink under CPUlimit, throttling it if it starts abusing the CPU.
# Set this to whatever you want. cpulimit --help
LIMIT=35
# Change to the PyLink root directory.
WRAPPER_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
cd "$WRAPPER_DIR"
if [[ ! -z "$(which cpulimit)" ]]; then
# -z makes cpulimit exit when PyLink dies.
cpulimit -l $LIMIT -z ./pylink $*
echo "PyLink has been started (daemonized) under cpulimit, and will automatically be throttled if it goes over the CPU limit of ${LIMIT}%."
echo "To kill the process manually, run ./kill.sh"
else
echo 'cpulimit not found in $PATH! Aborting.'
fi

View File

@ -1,36 +0,0 @@
#!/usr/bin/env python3
import unittest
import world
import coreplugin
import utils
import tests_common
inspircd = utils.getProtocolModule('inspircd')
world.testing = True
class CorePluginTestCase(tests_common.PluginTestCase):
@unittest.skip("Test doesn't work yet.")
def testKillRespawn(self):
self.irc.run(':9PY KILL {u} :test'.format(u=self.u))
hooks = self.irc.takeHooks()
# Make sure we're respawning our PseudoClient when its killed
print(hooks)
spmain = [h for h in hooks if h[1] == 'PYLINK_SPAWNMAIN']
self.assertTrue(spmain, 'PYLINK_SPAWNMAIN hook was never sent!')
msgs = self.irc.takeMsgs()
commands = self.irc.takeCommands(msgs)
self.assertIn('UID', commands)
self.assertIn('FJOIN', commands)
# Also make sure that we're updating the irc.pseudoclient field
self.assertNotEqual(self.irc.pseudoclient.uid, spmain[0]['olduser'])
def testKickRejoin(self):
self.proto.kick(self.u, '#pylink', self.u, 'test')
msgs = self.irc.takeMsgs()
commands = self.irc.takeCommands(msgs)
self.assertIn('FJOIN', commands)

View File

@ -1,30 +0,0 @@
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import classes
import unittest
import conf
class TestFakeIRC(unittest.TestCase):
def setUp(self):
self.irc = classes.FakeIRC('unittest', classes.FakeProto, conf.testconf)
def testFakeIRC(self):
self.irc.run('this should do nothing')
self.irc.send('ADD this message')
self.irc.send(':add THIS message too')
msgs = self.irc.takeMsgs()
self.assertEqual(['ADD this message', ':add THIS message too'],
msgs)
# takeMsgs() clears cached messages queue, so the next call should
# return an empty list.
msgs = self.irc.takeMsgs()
self.assertEqual([], msgs)
def testFakeIRCtakeCommands(self):
msgs = ['ADD this message', ':9PY THIS message too']
self.assertEqual(['ADD', 'THIS'], self.irc.takeCommands(msgs))
if __name__ == '__main__':
unittest.main()

View File

@ -1,46 +0,0 @@
import sys
import os
cwd = os.getcwd()
sys.path += [cwd, os.path.join(cwd, 'plugins')]
import unittest
import classes
import relay
import conf
def dummyf():
pass
class TestRelay(unittest.TestCase):
def setUp(self):
self.irc = classes.FakeIRC('fakeirc', classes.FakeProto, conf.testconf)
self.irc.maxnicklen = 20
self.f = lambda nick: relay.normalizeNick(self.irc, 'unittest', nick)
# Fake our protocol name to something that supports slashes in nicks.
# relay uses a whitelist for this to prevent accidentally introducing
# bad nicks:
self.irc.protoname = "inspircd"
def testNormalizeNick(self):
# Second argument simply states the suffix.
self.assertEqual(self.f('helloworld'), 'helloworld/unittest')
self.assertEqual(self.f('ObnoxiouslyLongNick'), 'Obnoxiously/unittest')
self.assertEqual(self.f('10XAAAAAA'), '_10XAAAAAA/unittest')
def testNormalizeNickConflict(self):
self.assertEqual(self.f('helloworld'), 'helloworld/unittest')
self.irc.users['10XAAAAAA'] = classes.IrcUser('helloworld/unittest', 1234, '10XAAAAAA')
# Increase amount of /'s by one
self.assertEqual(self.f('helloworld'), 'helloworld//unittest')
self.irc.users['10XAAAAAB'] = classes.IrcUser('helloworld//unittest', 1234, '10XAAAAAB')
# Cut off the nick, not the suffix if the result is too long.
self.assertEqual(self.f('helloworld'), 'helloworl///unittest')
def testNormalizeNickRemovesSlashes(self):
self.irc.protoname = "charybdis"
try:
self.assertEqual(self.f('helloworld'), 'helloworld|unittest')
self.assertEqual(self.f('abcde/eJanus'), 'abcde|eJanu|unittest')
self.assertEqual(self.f('ObnoxiouslyLongNick'), 'Obnoxiously|unittest')
finally:
self.irc.protoname = "inspircd"

View File

@ -1,155 +0,0 @@
import sys
import os
sys.path.append(os.getcwd())
import unittest
import itertools
from copy import deepcopy
import utils
import classes
import conf
import world
def dummyf():
pass
class TestUtils(unittest.TestCase):
def setUp(self):
self.irc = classes.FakeIRC('fakeirc', classes.FakeProto, conf.testconf)
def testTS6UIDGenerator(self):
uidgen = utils.TS6UIDGenerator('9PY')
self.assertEqual(uidgen.next_uid(), '9PYAAAAAA')
self.assertEqual(uidgen.next_uid(), '9PYAAAAAB')
def test_add_cmd(self):
# Without name specified, add_cmd adds a command with the same name
# as the function
utils.add_cmd(dummyf)
utils.add_cmd(dummyf, 'TEST')
# All command names should be automatically lowercased.
self.assertIn('dummyf', world.commands)
self.assertIn('test', world.commands)
self.assertNotIn('TEST', world.commands)
def test_add_hook(self):
utils.add_hook(dummyf, 'join')
self.assertIn('JOIN', world.hooks)
# Command names stored in uppercase.
self.assertNotIn('join', world.hooks)
self.assertIn(dummyf, world.hooks['JOIN'])
def testIsNick(self):
self.assertFalse(utils.isNick('abcdefgh', nicklen=3))
self.assertTrue(utils.isNick('aBcdefgh', nicklen=30))
self.assertTrue(utils.isNick('abcdefgh1'))
self.assertTrue(utils.isNick('ABC-def'))
self.assertFalse(utils.isNick('-_-'))
self.assertFalse(utils.isNick(''))
self.assertFalse(utils.isNick(' i lost the game'))
self.assertFalse(utils.isNick(':aw4t*9e4t84a3t90$&*6'))
self.assertFalse(utils.isNick('9PYAAAAAB'))
self.assertTrue(utils.isNick('_9PYAAAAAB\\'))
def testIsChannel(self):
self.assertFalse(utils.isChannel(''))
self.assertFalse(utils.isChannel('lol'))
self.assertTrue(utils.isChannel('#channel'))
self.assertTrue(utils.isChannel('##ABCD'))
def testIsServerName(self):
self.assertFalse(utils.isServerName('Invalid'))
self.assertTrue(utils.isServerName('services.'))
self.assertFalse(utils.isServerName('.s.s.s'))
self.assertTrue(utils.isServerName('Hello.world'))
self.assertFalse(utils.isServerName(''))
self.assertTrue(utils.isServerName('pylink.somenet.local'))
self.assertFalse(utils.isServerName(' i lost th.e game'))
def testJoinModes(self):
res = utils.joinModes({('+l', '50'), ('+n', None), ('+t', None)})
# Sets are orderless, so the end mode could be scrambled in a number of ways.
# Basically, we're looking for a string that looks like '+ntl 50' or '+lnt 50'.
possible = ['+%s 50' % ''.join(x) for x in itertools.permutations('lnt', 3)]
self.assertIn(res, possible)
# Without any arguments, make sure there is no trailing space.
self.assertEqual(utils.joinModes({('+t', None)}), '+t')
# The +/- in the mode is not required; if it doesn't exist, assume we're
# adding modes always.
self.assertEqual(utils.joinModes([('t', None), ('n', None)]), '+tn')
# An empty query should return just '+'
self.assertEqual(utils.joinModes(set()), '+')
# More complex query now with both + and - modes being set
res = utils.joinModes([('+l', '50'), ('-n', None)])
self.assertEqual(res, '+l-n 50')
# If one modepair in the list lacks a +/- prefix, just follow the
# previous one's.
res = utils.joinModes([('+l', '50'), ('-n', None), ('m', None)])
self.assertEqual(res, '+l-nm 50')
res = utils.joinModes([('+l', '50'), ('m', None)])
self.assertEqual(res, '+lm 50')
res = utils.joinModes([('l', '50'), ('-m', None)])
self.assertEqual(res, '+l-m 50')
# Rarely in real life will we get a mode string this complex.
# Let's make sure it works, just in case.
res = utils.joinModes([('-o', '9PYAAAAAA'), ('+l', '50'), ('-n', None),
('-m', None), ('+k', 'hello'),
('+b', '*!*@*.badisp.net')])
self.assertEqual(res, '-o+l-nm+kb 9PYAAAAAA 50 hello *!*@*.badisp.net')
def _reverseModes(self, query, expected, target='#PyLink', oldobj=None):
res = utils.reverseModes(self.irc, target, query, oldobj=oldobj)
self.assertEqual(res, expected)
def testReverseModes(self):
# Initialize the channe, first.
utils.applyModes(self.irc, '#PyLink', [])
# Strings.
self._reverseModes("+mk-t test", "-mk+t test")
self._reverseModes("ml-n 111", "-ml+n")
# Lists.
self._reverseModes([('+m', None), ('+r', None), ('+l', '3')],
{('-m', None), ('-r', None), ('-l', None)})
# Sets.
self._reverseModes({('s', None)}, {('-s', None)})
# Combining modes with an initial + and those without
self._reverseModes({('s', None), ('+R', None)}, {('-s', None), ('-R', None)})
def testReverseModesUser(self):
self._reverseModes({('+i', None), ('l', 'asfasd')}, {('-i', None), ('-l', 'asfasd')},
target=self.irc.pseudoclient.uid)
def testReverseModesExisting(self):
utils.applyModes(self.irc, '#PyLink', [('+m', None), ('+l', '50'), ('+k', 'supersecret'),
('+o', '9PYAAAAAA')])
self._reverseModes({('+i', None), ('+l', '3')}, {('-i', None), ('+l', '50')})
self._reverseModes('-n', '+n')
self._reverseModes('-l', '+l 50')
self._reverseModes('+k derp', '+k supersecret')
self._reverseModes('-mk *', '+mk supersecret')
self.irc.proto.spawnClient("tester2")
oldobj = deepcopy(self.irc.channels['#PyLink'])
# Existing modes are ignored.
self._reverseModes([('+t', None)], set())
self._reverseModes('+n', '+')
#self._reverseModes('+oo 9PYAAAAAB 9PYAAAAAA', '-o 9PYAAAAAB', oldobj=oldobj)
self._reverseModes('+o 9PYAAAAAA', '+')
self._reverseModes('+vM 9PYAAAAAA', '-M')
# Ignore unsetting prefixmodes/list modes that were never set.
self._reverseModes([('-v', '10XAAAAAA')], set())
self._reverseModes('-ob 10XAAAAAA derp!*@*', '+')
utils.applyModes(self.irc, '#PyLink', [('+b', '*!user@badisp.tk')])
self._reverseModes('-bb *!*@* *!user@badisp.tk', '+b *!user@badisp.tk')
if __name__ == '__main__':
unittest.main()

View File

@ -1,101 +0,0 @@
import sys
import os
sys.path += [os.getcwd(), os.path.join(os.getcwd(), 'protocols')]
import unittest
import world
import utils
import classes
import conf
world.started.set()
class PluginTestCase(unittest.TestCase):
def setUp(self):
proto = utils.getProtocolModule(world.testing_ircd)
self.irc = classes.FakeIRC('unittest', proto, conf.testconf)
self.proto = self.irc.proto
self.irc.connect()
self.sdata = self.irc.serverdata
self.u = self.irc.pseudoclient.uid
self.maxDiff = None
# Dummy servers/users used in tests below.
self.proto.spawnServer('whatever.', sid='10X')
for x in range(3):
self.proto.spawnClient('user%s' % x, server='10X')
class CommonProtoTestCase(PluginTestCase):
def testJoinClient(self):
u = self.u
self.proto.join(u, '#Channel')
self.assertIn(u, self.irc.channels['#channel'].users)
# Non-existant user.
self.assertRaises(LookupError, self.proto.join, '9PYZZZZZZ', '#test')
def testKickClient(self):
target = self.proto.spawnClient('soccerball', 'soccerball', 'abcd').uid
self.proto.join(target, '#pylink')
self.assertIn(self.u, self.irc.channels['#pylink'].users)
self.assertIn(target, self.irc.channels['#pylink'].users)
self.proto.kick(self.u, '#pylink', target, 'Pow!')
self.assertNotIn(target, self.irc.channels['#pylink'].users)
def testModeClient(self):
testuser = self.proto.spawnClient('testcakes')
self.irc.takeMsgs()
self.proto.mode(self.u, testuser.uid, [('+i', None), ('+w', None)])
self.assertEqual({('i', None), ('w', None)}, testuser.modes)
# Default channels start with +nt
self.assertEqual({('n', None), ('t', None)}, self.irc.channels['#pylink'].modes)
self.proto.mode(self.u, '#pylink', [('+s', None), ('+l', '30')])
self.assertEqual({('s', None), ('l', '30'), ('n', None), ('t', None)}, self.irc.channels['#pylink'].modes)
cmds = self.irc.takeCommands(self.irc.takeMsgs())
self.assertEqual(cmds, ['MODE', 'FMODE'])
def testNickClient(self):
self.proto.nick(self.u, 'NotPyLink')
self.assertEqual('NotPyLink', self.irc.users[self.u].nick)
def testPartClient(self):
u = self.u
self.proto.join(u, '#channel')
self.proto.part(u, '#channel')
self.assertNotIn(u, self.irc.channels['#channel'].users)
def testQuitClient(self):
u = self.proto.spawnClient('testuser3', 'moo', 'hello.world').uid
self.proto.join(u, '#channel')
self.assertRaises(LookupError, self.proto.quit, '9PYZZZZZZ', 'quit reason')
self.proto.quit(u, 'quit reason')
self.assertNotIn(u, self.irc.channels['#channel'].users)
self.assertNotIn(u, self.irc.users)
self.assertNotIn(u, self.irc.servers[self.irc.sid].users)
def testSpawnClient(self):
u = self.proto.spawnClient('testuser3', 'moo', 'hello.world').uid
# Check the server index and the user index
self.assertIn(u, self.irc.servers[self.irc.sid].users)
self.assertIn(u, self.irc.users)
# Raise ValueError when trying to spawn a client on a server that's not ours
self.assertRaises(ValueError, self.proto.spawnClient, 'abcd', 'user', 'dummy.user.net', server='44A')
# Unfilled args should get placeholder fields and not error.
self.proto.spawnClient('testuser4')
def testSpawnClientOnServer(self):
self.proto.spawnServer('subserver.pylink', '34Q')
u = self.proto.spawnClient('person1', 'person', 'users.somenet.local', server='34Q')
# We're spawning clients on the right server, hopefully...
self.assertIn(u.uid, self.irc.servers['34Q'].users)
self.assertNotIn(u.uid, self.irc.servers[self.irc.sid].users)
def testSpawnServer(self):
# Incorrect SID length
self.assertRaises(Exception, self.proto.spawnServer, 'subserver.pylink', '34Q0')
self.proto.spawnServer('subserver.pylink', '34Q')
# Duplicate server name
self.assertRaises(Exception, self.proto.spawnServer, 'Subserver.PyLink', '34Z')
# Duplicate SID
self.assertRaises(Exception, self.proto.spawnServer, 'another.Subserver.PyLink', '34Q')
self.assertIn('34Q', self.irc.servers)

4
update.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Updates a locally installed copy of PyLink and runs it.
python3 setup.py install --user && pylink

141
utils.py
View File

@ -11,9 +11,10 @@ import importlib
import os import os
import collections import collections
from log import log from .log import log
import world from . import world, conf
import conf # This is just so protocols and plugins are importable.
from pylinkirc import protocols, plugins
class NotAuthenticatedError(Exception): class NotAuthenticatedError(Exception):
""" """
@ -34,7 +35,7 @@ class IncrementalUIDGenerator():
"%s by defining self.allowedchars and self.length " "%s by defining self.allowedchars and self.length "
"and then calling super().__init__()." % self.__class__.__name__) "and then calling super().__init__()." % self.__class__.__name__)
self.uidchars = [self.allowedchars[0]]*self.length self.uidchars = [self.allowedchars[0]]*self.length
self.sid = sid self.sid = str(sid)
def increment(self, pos=None): def increment(self, pos=None):
""" """
@ -63,9 +64,9 @@ class IncrementalUIDGenerator():
self.increment() self.increment()
return uid return uid
def add_cmd(func, name=None): def add_cmd(func, name=None, **kwargs):
"""Binds an IRC command function to the given command name.""" """Binds an IRC command function to the given command name."""
world.services['pylink'].add_cmd(func, name=name) world.services['pylink'].add_cmd(func, name=name, **kwargs)
return func return func
def add_hook(func, command): def add_hook(func, command):
@ -127,11 +128,17 @@ def loadModuleFromFolder(name, folder):
m = importlib.machinery.SourceFileLoader(name, fullpath).load_module() m = importlib.machinery.SourceFileLoader(name, fullpath).load_module()
return m return m
def getProtocolModule(protoname): def loadPlugin(name):
"""
Imports and returns the requested plugin.
"""
return importlib.import_module('pylinkirc.plugins.' + name)
def getProtocolModule(name):
""" """
Imports and returns the protocol module requested. Imports and returns the protocol module requested.
""" """
return loadModuleFromFolder(protoname, world.protocols_folder) return importlib.import_module('pylinkirc.protocols.' + name)
def getDatabaseName(dbname): def getDatabaseName(dbname):
""" """
@ -139,7 +146,7 @@ def getDatabaseName(dbname):
current PyLink instance. current PyLink instance.
This returns '<dbname>.db' if the running config name is PyLink's default This returns '<dbname>.db' if the running config name is PyLink's default
(config.yml), and '<dbname>-<config name>.db' for anything else. For example, (pylink.yml), and '<dbname>-<config name>.db' for anything else. For example,
if this is called from an instance running as './pylink testing.yml', it if this is called from an instance running as './pylink testing.yml', it
would return '<dbname>-testing.db'.""" would return '<dbname>-testing.db'."""
if conf.confname != 'pylink': if conf.confname != 'pylink':
@ -148,8 +155,13 @@ def getDatabaseName(dbname):
return dbname return dbname
class ServiceBot(): class ServiceBot():
"""
PyLink IRC Service class.
"""
def __init__(self, name, default_help=True, default_request=False, default_list=True, def __init__(self, name, default_help=True, default_request=False, default_list=True,
nick=None, ident=None, manipulatable=False): nick=None, ident=None, manipulatable=False, extra_channels=None,
desc=None):
# Service name # Service name
self.name = name self.name = name
@ -169,6 +181,16 @@ class ServiceBot():
# spawned. # spawned.
self.uids = {} self.uids = {}
# Track what channels other than those defined in the config
# that the bot should join by default.
self.extra_channels = extra_channels or collections.defaultdict(set)
# Service description, used in the default help command if one is given.
self.desc = desc
# List of command names to "feature"
self.featured_cmds = set()
if default_help: if default_help:
self.add_cmd(self.help) self.add_cmd(self.help)
@ -180,6 +202,9 @@ class ServiceBot():
self.add_cmd(self.listcommands, 'list') self.add_cmd(self.listcommands, 'list')
def spawn(self, irc=None): def spawn(self, irc=None):
"""
Spawns instances of this service on all connected networks.
"""
# Spawn the new service by calling the PYLINK_NEW_SERVICE hook, # Spawn the new service by calling the PYLINK_NEW_SERVICE hook,
# which is handled by coreplugin. # which is handled by coreplugin.
if irc is None: if irc is None:
@ -188,30 +213,29 @@ class ServiceBot():
else: else:
raise NotImplementedError("Network specific plugins not supported yet.") raise NotImplementedError("Network specific plugins not supported yet.")
def reply(self, irc, text): def reply(self, irc, text, notice=False, private=False):
"""Replies to a message using the right service UID.""" """Replies to a message as the service in question."""
servuid = self.uids.get(irc.name) servuid = self.uids.get(irc.name)
if not servuid: if not servuid:
log.warning("(%s) Possible desync? UID for service %s doesn't exist!", irc.name, self.name) log.warning("(%s) Possible desync? UID for service %s doesn't exist!", irc.name, self.name)
return return
irc.reply(text, notice=self.use_notice, source=servuid) irc.reply(text, notice=notice, source=servuid, private=private)
def call_cmd(self, irc, source, text, called_by=None, notice=True): def call_cmd(self, irc, source, text, called_in=None):
""" """
Calls a PyLink bot command. source is the caller's UID, and text is the Calls a PyLink bot command. source is the caller's UID, and text is the
full, unparsed text of the message. full, unparsed text of the message.
""" """
irc.called_by = called_by or source irc.called_in = called_in or source
irc.called_by = source
# Store this globally so other commands don't have to worry about whether
# we're preferring notices.
self.use_notice = notice
cmd_args = text.strip().split(' ') cmd_args = text.strip().split(' ')
cmd = cmd_args[0].lower() cmd = cmd_args[0].lower()
cmd_args = cmd_args[1:] cmd_args = cmd_args[1:]
if cmd not in self.commands: if cmd not in self.commands:
if not cmd.startswith('\x01'):
# Ignore invalid command errors from CTCPs.
self.reply(irc, 'Error: Unknown command %r.' % cmd) self.reply(irc, 'Error: Unknown command %r.' % cmd)
log.info('(%s/%s) Received unknown command %r from %s', irc.name, self.name, cmd, irc.getHostmask(source)) log.info('(%s/%s) Received unknown command %r from %s', irc.name, self.name, cmd, irc.getHostmask(source))
return return
@ -226,31 +250,36 @@ class ServiceBot():
log.exception('Unhandled exception caught in command %r', cmd) log.exception('Unhandled exception caught in command %r', cmd)
self.reply(irc, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e))) self.reply(irc, 'Uncaught exception in command %r: %s: %s' % (cmd, type(e).__name__, str(e)))
def add_cmd(self, func, name=None): def add_cmd(self, func, name=None, featured=False):
"""Binds an IRC command function to the given command name.""" """Binds an IRC command function to the given command name."""
if name is None: if name is None:
name = func.__name__ name = func.__name__
name = name.lower() name = name.lower()
# Mark as a featured command if requested to do so.
if featured:
self.featured_cmds.add(name)
self.commands[name].append(func) self.commands[name].append(func)
return func return func
def help(self, irc, source, args): def _show_command_help(self, irc, command, private=False, shortform=False):
"""<command> """
Shows help for the given command.
"""
def _reply(text):
"""
reply() wrapper to handle the private argument.
"""
self.reply(irc, text, private=private)
Gives help for <command>, if it is available."""
try:
command = args[0].lower()
except IndexError: # No argument given, just return 'list' output
self.listcommands(irc, source, args)
return
if command not in self.commands: if command not in self.commands:
self.reply(irc, 'Error: Unknown command %r.' % command) _reply('Error: Unknown command %r.' % command)
return return
else: else:
funcs = self.commands[command] funcs = self.commands[command]
if len(funcs) > 1: if len(funcs) > 1:
self.reply(irc, 'The following \x02%s\x02 plugins bind to the \x02%s\x02 command: %s' _reply('The following \x02%s\x02 plugins bind to the \x02%s\x02 command: %s'
% (len(funcs), command, ', '.join([func.__module__ for func in funcs]))) % (len(funcs), command, ', '.join([func.__module__ for func in funcs])))
for func in funcs: for func in funcs:
doc = func.__doc__ doc = func.__doc__
@ -260,13 +289,35 @@ class ServiceBot():
# Bold the first line, which usually just tells you what # Bold the first line, which usually just tells you what
# arguments the command takes. # arguments the command takes.
lines[0] = '\x02%s %s\x02' % (command, lines[0]) lines[0] = '\x02%s %s\x02' % (command, lines[0])
for line in lines:
# Then, just output the rest of the docstring to IRC. if shortform: # Short form is just the command name + args.
self.reply(irc, line.strip()) _reply(lines[0].strip())
else: else:
self.reply(irc, "Error: Command %r doesn't offer any help." % command) for line in lines:
# Otherwise, just output the rest of the docstring to IRC.
_reply(line.strip())
else:
_reply("Error: Command %r doesn't offer any help." % command)
return return
def help(self, irc, source, args):
"""<command>
Gives help for <command>, if it is available."""
try:
command = args[0].lower()
except IndexError:
# No argument given: show service description (if present), 'list' output, and a list
# of featured commands.
if self.desc:
self.reply(irc, self.desc)
self.reply(irc, " ") # Extra newline to unclutter the output text
self.listcommands(irc, source, args)
return
else:
self._show_command_help(irc, command)
def request(self, irc, source, args): def request(self, irc, source, args):
self.reply(irc, "Request command stub called.") self.reply(irc, "Request command stub called.")
@ -278,10 +329,27 @@ class ServiceBot():
Returns a list of available commands this service has to offer.""" Returns a list of available commands this service has to offer."""
cmds = list(self.commands.keys()) # Don't show CTCP handlers in the public command list.
cmds.sort() cmds = sorted([cmd for cmd in self.commands.keys() if '\x01' not in cmd])
if cmds:
self.reply(irc, 'Available commands include: %s' % ', '.join(cmds)) self.reply(irc, 'Available commands include: %s' % ', '.join(cmds))
self.reply(irc, 'To see help on a specific command, type \x02help <command>\x02.') self.reply(irc, 'To see help on a specific command, type \x02help <command>\x02.')
else:
self.reply(irc, 'This service doesn\'t provide any public commands.')
# If there are featured commands, list them by showing the help for each.
# These definitions are sent in private to prevent flooding in channels.
if self.featured_cmds:
self.reply(irc, " ", private=True)
self.reply(irc, 'Featured commands include:', private=True)
for cmd in sorted(self.featured_cmds):
if self.commands.get(cmd):
# Only show featured commands that are both defined and loaded.
# TODO: perhaps plugin unload should remove unused featured command
# definitions automatically?
self._show_command_help(irc, cmd, private=True, shortform=True)
self.reply(irc, 'End of command listing.', private=True)
def registerService(name, *args, **kwargs): def registerService(name, *args, **kwargs):
"""Registers a service bot.""" """Registers a service bot."""
@ -296,6 +364,7 @@ def registerService(name, *args, **kwargs):
def unregisterService(name): def unregisterService(name):
"""Unregisters an existing service bot.""" """Unregisters an existing service bot."""
assert name in world.services, "Unknown service %s" % name assert name in world.services, "Unknown service %s" % name
name = name.lower()
sbot = world.services[name] sbot = world.services[name]
for ircnet, uid in sbot.uids.items(): for ircnet, uid in sbot.uids.items():
world.networkobjects[ircnet].proto.quit(uid, "Service unloaded.") world.networkobjects[ircnet].proto.quit(uid, "Service unloaded.")

View File

@ -4,15 +4,10 @@ world.py: Stores global variables for PyLink, including lists of active IRC obje
from collections import defaultdict from collections import defaultdict
import threading import threading
import subprocess
import os
# Global variable to indicate whether we're being ran directly, or imported # This indicates whether we're running in tests modes. What it actually does
# for a testcase. This defaults to True. # though is control whether IRC connections should be threaded or not.
testing = True testing = False
# Sets the default protocol module to use with tests.
testing_ircd = 'inspircd'
# Statekeeping for our hooks list, IRC objects, loaded plugins, and initialized # Statekeeping for our hooks list, IRC objects, loaded plugins, and initialized
# service bots. # service bots.
@ -21,18 +16,9 @@ networkobjects = {}
plugins = {} plugins = {}
services = {} services = {}
# Registered extarget handlers. This maps exttarget names (strings) to handling functions.
exttarget_handlers = {}
started = threading.Event() started = threading.Event()
plugins_folder = os.path.join(os.getcwd(), 'plugins')
protocols_folder = os.path.join(os.getcwd(), 'protocols')
version = "<unknown>"
source = "https://github.com/GLolol/PyLink" # CHANGE THIS IF YOU'RE FORKING!! source = "https://github.com/GLolol/PyLink" # CHANGE THIS IF YOU'RE FORKING!!
# Only run this once.
if version == "<unknown>":
# Get version from Git tags.
try:
version = 'v' + subprocess.check_output(['git', 'describe', '--tags']).decode('utf-8').strip()
except Exception as e:
print('ERROR: Failed to get version from "git describe --tags": %s: %s' % (type(e).__name__, e))