ellinetircd 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ellinetircd/__init__.py +34 -0
- ellinetircd/__main__.py +75 -0
- ellinetircd/channel.py +85 -0
- ellinetircd/config.py +14 -0
- ellinetircd/exceptions.py +86 -0
- ellinetircd/sdnotify.py +32 -0
- ellinetircd/server.py +77 -0
- ellinetircd/states.py +497 -0
- ellinetircd/user.py +199 -0
- ellinetircd-1.0.0.dist-info/METADATA +60 -0
- ellinetircd-1.0.0.dist-info/RECORD +14 -0
- ellinetircd-1.0.0.dist-info/WHEEL +5 -0
- ellinetircd-1.0.0.dist-info/licenses/LICENSE +8 -0
- ellinetircd-1.0.0.dist-info/top_level.txt +1 -0
ellinetircd/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import logging
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("ellinetircd")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
|
|
10
|
+
from ellinetircd.config import config as cfg
|
|
11
|
+
logger = logging.getLogger(__package__)
|
|
12
|
+
IO = logging.INFO - 5
|
|
13
|
+
SECURITY = logging.ERROR + 5
|
|
14
|
+
logging.addLevelName(IO, 'IO')
|
|
15
|
+
logging.addLevelName(SECURITY, 'SECURITY')
|
|
16
|
+
logger.setLevel(cfg.LOGLEVEL)
|
|
17
|
+
servlocal = contextvars.ContextVar('servlocal')
|
|
18
|
+
MAXLINELEN = 512
|
|
19
|
+
|
|
20
|
+
import ellinetircd.channel
|
|
21
|
+
import ellinetircd.exceptions
|
|
22
|
+
import ellinetircd.server
|
|
23
|
+
import ellinetircd.sdnotify
|
|
24
|
+
import ellinetircd.states
|
|
25
|
+
import ellinetircd.user
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def update_status() -> None:
|
|
29
|
+
sl = servlocal.get()
|
|
30
|
+
ellinetircd.sdnotify.status(
|
|
31
|
+
f"Listening on {cfg.ADDR} ({cfg.HOST}) port {cfg.PORT}. "
|
|
32
|
+
f"Currently {len(sl.users)} registered users"
|
|
33
|
+
f" in {len(sl.channels)} channels."
|
|
34
|
+
)
|
ellinetircd/__main__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import textwrap
|
|
6
|
+
import trio
|
|
7
|
+
from socket import gethostname, gethostbyname
|
|
8
|
+
|
|
9
|
+
import ellinetircd
|
|
10
|
+
from ellinetircd.config import config as cfg
|
|
11
|
+
from ellinetircd.server import Server
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Color the [LEVEL] part of messages, need new terminal on Windows
|
|
16
|
+
# https://github.com/odoo/odoo/blob/13.0/odoo/netsvc.py#L57-L100
|
|
17
|
+
class ColoredFormatter(logging.Formatter):
|
|
18
|
+
colors = {
|
|
19
|
+
logging.DEBUG: (34, 49), # blue
|
|
20
|
+
ellinetircd.IO: (37, 49), # white
|
|
21
|
+
logging.INFO: (32, 49), # green
|
|
22
|
+
logging.WARNING: (33, 49), # yellow
|
|
23
|
+
logging.ERROR: (31, 49), # red
|
|
24
|
+
ellinetircd.SECURITY: (31, 49), # red
|
|
25
|
+
logging.CRITICAL: (37, 41), # white fg, red bg
|
|
26
|
+
}
|
|
27
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
28
|
+
fg, bg = type(self).colors.get(record.levelno, (32, 49))
|
|
29
|
+
record.levelname = f'\033[1;{fg}m\033[1;{bg}m{record.levelname}\033[0m'
|
|
30
|
+
return super().format(record)
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
stderr = logging.StreamHandler()
|
|
34
|
+
stderr.formatter = (
|
|
35
|
+
ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s')
|
|
36
|
+
if hasattr(sys.stderr, 'fileno') and os.isatty(sys.stderr.fileno()) else
|
|
37
|
+
logging.Formatter('[%(levelname)s] %(message)s')
|
|
38
|
+
)
|
|
39
|
+
root_logger = logging.getLogger('')
|
|
40
|
+
root_logger.handlers.clear()
|
|
41
|
+
root_logger.addHandler(stderr)
|
|
42
|
+
|
|
43
|
+
server = Server(cfg.HOST, cfg.ADDR, cfg.PORT, cfg.PASS)
|
|
44
|
+
try:
|
|
45
|
+
trio.run(server.serve)
|
|
46
|
+
except Exception:
|
|
47
|
+
logger.critical("Dead", exc_info=True)
|
|
48
|
+
finally:
|
|
49
|
+
logging.shutdown()
|
|
50
|
+
|
|
51
|
+
# Dummy argparse, used only for --help and --version
|
|
52
|
+
parser = argparse.ArgumentParser(
|
|
53
|
+
prog=ellinetircd.__name__,
|
|
54
|
+
usage=f"{sys.executable} -m {ellinetircd.__name__}",
|
|
55
|
+
description="single-server minimalist IRC",
|
|
56
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
57
|
+
epilog=textwrap.dedent(f"""\
|
|
58
|
+
environment variables:
|
|
59
|
+
HOST public domain name (default: {gethostname()})
|
|
60
|
+
ADDR ip address to bind (default: {gethostbyname(cfg.HOST)})
|
|
61
|
+
PORT port to bind (default: 6667)
|
|
62
|
+
PASS server password (default: )
|
|
63
|
+
TIMEOUT kick inactive users after x seconds (default: 60)
|
|
64
|
+
PING_TIMEOUT PING inactive users x seconds before timeout (default: 5)
|
|
65
|
+
LOGLEVEL logging verbosity (default: WARNING)
|
|
66
|
+
""")
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
'-V', '--version',
|
|
70
|
+
action='version',
|
|
71
|
+
version=f'{ellinetircd.__name__} {ellinetircd.__version__}',
|
|
72
|
+
)
|
|
73
|
+
parser.parse_args()
|
|
74
|
+
|
|
75
|
+
main()
|
ellinetircd/channel.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import trio
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import List, Set, Union
|
|
5
|
+
|
|
6
|
+
import ellinetircd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Channel:
|
|
13
|
+
"""
|
|
14
|
+
The Channel class represents a chat room.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: str
|
|
18
|
+
the channel name.
|
|
19
|
+
users: Set[User]
|
|
20
|
+
a set of user who is subscribe to the channel.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Parameters:
|
|
26
|
+
name: str
|
|
27
|
+
the channel name.
|
|
28
|
+
"""
|
|
29
|
+
self._name = name
|
|
30
|
+
self.users: Set["ellinetircd.user.User"] = set()
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
"""
|
|
34
|
+
We represents the channel object by his name.
|
|
35
|
+
|
|
36
|
+
Return: str
|
|
37
|
+
the name of the channel.
|
|
38
|
+
"""
|
|
39
|
+
return self._name
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def name(self) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Getter for private attributes channel name.
|
|
45
|
+
|
|
46
|
+
Returns: str
|
|
47
|
+
the name of the channel.
|
|
48
|
+
"""
|
|
49
|
+
return self._name
|
|
50
|
+
|
|
51
|
+
def _log_messages(self, messages: List[str]) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Log all messages sent to a client.
|
|
54
|
+
|
|
55
|
+
Parameters: List[str]
|
|
56
|
+
A list of messages send to the client.
|
|
57
|
+
"""
|
|
58
|
+
for message in messages:
|
|
59
|
+
logger.log(ellinetircd.IO, "send to %s: %s", self, message)
|
|
60
|
+
|
|
61
|
+
async def send(
|
|
62
|
+
self,
|
|
63
|
+
messages: Union[str, List[str]],
|
|
64
|
+
skipusers: Set["ellinetircd.user.User"] = set(),
|
|
65
|
+
) -> None:
|
|
66
|
+
""""
|
|
67
|
+
Send many messages to each user subscribed to this channel.
|
|
68
|
+
|
|
69
|
+
Parameters:
|
|
70
|
+
messages: Union[str, List[str]]
|
|
71
|
+
Messages to send to the users.
|
|
72
|
+
skipusers: Set[User]
|
|
73
|
+
A set of users to skip sending messages to.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(messages, str):
|
|
76
|
+
messages = [messages]
|
|
77
|
+
|
|
78
|
+
async with trio.open_nursery() as self._nursery:
|
|
79
|
+
self._log_messages(messages)
|
|
80
|
+
for user in self.users.difference(skipusers):
|
|
81
|
+
self._nursery.start_soon(partial(
|
|
82
|
+
user.send,
|
|
83
|
+
messages,
|
|
84
|
+
log=logger.isEnabledFor(logging.DEBUG)
|
|
85
|
+
))
|
ellinetircd/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from socket import gethostname, gethostbyname
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
config = SimpleNamespace()
|
|
8
|
+
config.HOST = os.getenv('HOST', gethostname())
|
|
9
|
+
config.ADDR = os.getenv('ADDR', gethostbyname(config.HOST))
|
|
10
|
+
config.PORT = int(os.getenv('PORT', 6667))
|
|
11
|
+
config.PASS = os.getenv('PASS')
|
|
12
|
+
config.TIMEOUT = int(os.getenv('TIMEOUT', 60))
|
|
13
|
+
config.PING_TIMEOUT = int(os.getenv('PING_TIMEOUT', 5))
|
|
14
|
+
config.LOGLEVEL = os.getenv('LOGLEVEL', 'WARNING')
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
__ALL__ = [
|
|
2
|
+
'Disconnect', 'IRCException', 'ErrNoSuchChannel', 'ErrNoRecipient',
|
|
3
|
+
'ErrNoTextToSend', 'ErrUnknownCommand', 'ErrNoNicknameGiven',
|
|
4
|
+
'ErrErroneusNickname', 'ErrNicknameInUse', 'ErrNotOnChannel',
|
|
5
|
+
'ErrNoLogin', 'ErrNeedMoreParams', 'ErrAlreadyRegistred',
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
import ellinetircd
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
class Disconnect(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class IRCException(Exception):
|
|
15
|
+
"""
|
|
16
|
+
Abstract IRC exception
|
|
17
|
+
|
|
18
|
+
They are excepted by dispatch() and forwarded to the user.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, *args: Any) -> None:
|
|
21
|
+
super().__init__(type(self).format(*args))
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def format(cls, *args: Any) -> str:
|
|
25
|
+
return ":{host} {code} {error}".format(
|
|
26
|
+
host=ellinetircd.servlocal.get().host,
|
|
27
|
+
code=cls.code,
|
|
28
|
+
error=cls.msg.format(*args),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class ErrUnknownError(IRCException):
|
|
32
|
+
# <user> <command> :<info>
|
|
33
|
+
msg = "{} {} :{}"
|
|
34
|
+
code = 400
|
|
35
|
+
|
|
36
|
+
class ErrNoSuchNick(IRCException):
|
|
37
|
+
msg = "{} :No such nick/channel"
|
|
38
|
+
code = 401
|
|
39
|
+
|
|
40
|
+
class ErrNoSuchChannel(IRCException):
|
|
41
|
+
msg = "{} :No such channel"
|
|
42
|
+
code = 403
|
|
43
|
+
|
|
44
|
+
class ErrNoRecipient(IRCException):
|
|
45
|
+
msg = ":No recipient given ({})"
|
|
46
|
+
code = 411
|
|
47
|
+
|
|
48
|
+
class ErrNoTextToSend(IRCException):
|
|
49
|
+
msg = ":No text to send"
|
|
50
|
+
code = 412
|
|
51
|
+
|
|
52
|
+
class ErrUnknownCommand(IRCException):
|
|
53
|
+
msg = "{} :Unknown command"
|
|
54
|
+
code = 421
|
|
55
|
+
|
|
56
|
+
class ErrNoNicknameGiven(IRCException):
|
|
57
|
+
msg = ":No nickname given"
|
|
58
|
+
code = 431
|
|
59
|
+
|
|
60
|
+
class ErrErroneusNickname(IRCException):
|
|
61
|
+
msg = "{} :Erroneous nickname"
|
|
62
|
+
code = 432
|
|
63
|
+
|
|
64
|
+
class ErrNicknameInUse(IRCException):
|
|
65
|
+
msg = "{} :Nickname is already in use"
|
|
66
|
+
code = 433
|
|
67
|
+
|
|
68
|
+
class ErrNotOnChannel(IRCException):
|
|
69
|
+
msg = "{} :You're not on that channel"
|
|
70
|
+
code = 442
|
|
71
|
+
|
|
72
|
+
class ErrNoLogin(IRCException):
|
|
73
|
+
msg = ":User not logged in"
|
|
74
|
+
code = 444
|
|
75
|
+
|
|
76
|
+
class ErrNeedMoreParams(IRCException):
|
|
77
|
+
msg = "{} :Not enough parameters"
|
|
78
|
+
code = 461
|
|
79
|
+
|
|
80
|
+
class ErrAlreadyRegistred(IRCException):
|
|
81
|
+
msg = ":Unauthorized command (already registered)"
|
|
82
|
+
code = 462
|
|
83
|
+
|
|
84
|
+
class ErrPasswdMismatch(IRCException):
|
|
85
|
+
msg = ":Password incorrect"
|
|
86
|
+
code = 464
|
ellinetircd/sdnotify.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def notify(payload: bytes) -> None:
|
|
5
|
+
if _sdsocket:
|
|
6
|
+
_sdsocket.sendall(payload)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ready() -> None:
|
|
10
|
+
notify(b"READY=1")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def reloading() -> None:
|
|
14
|
+
notify(b"RELOADING=1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stopping() -> None:
|
|
18
|
+
notify(b"STOPPING=1")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def status(line: str) -> None:
|
|
22
|
+
notify(b"STATUS=" + line.encode())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Setup
|
|
26
|
+
_sdsocket = None
|
|
27
|
+
_notify_socket = os.getenv('NOTIFY_SOCKET', '')
|
|
28
|
+
if _notify_socket:
|
|
29
|
+
if _notify_socket.startswith('@'):
|
|
30
|
+
_notify_socket = f'\0{_notify_socket[1:]}'
|
|
31
|
+
_sdsocket = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
32
|
+
_sdsocket.connect(_notify_socket)
|
ellinetircd/server.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import logging
|
|
3
|
+
import signal
|
|
4
|
+
import trio
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
import ellinetircd
|
|
8
|
+
from ellinetircd import sdnotify
|
|
9
|
+
from ellinetircd.exceptions import Disconnect
|
|
10
|
+
from ellinetircd.user import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Server:
|
|
17
|
+
def __init__(self, host: str, addr: str, port: int, pwd: Optional[str]) -> None:
|
|
18
|
+
self.host = host
|
|
19
|
+
self.addr = addr
|
|
20
|
+
self.port = port
|
|
21
|
+
self.pwd = pwd
|
|
22
|
+
|
|
23
|
+
async def handle(self, stream: trio.abc.Stream) -> None:
|
|
24
|
+
servlocal = ellinetircd.servlocal.get()
|
|
25
|
+
async with trio.open_nursery() as nursery:
|
|
26
|
+
user = User(stream, nursery)
|
|
27
|
+
logger.info("Connection with %s established.", user)
|
|
28
|
+
nursery.start_soon(user.ping_forever)
|
|
29
|
+
try:
|
|
30
|
+
await user.serve()
|
|
31
|
+
except Disconnect as exc:
|
|
32
|
+
logger.warning("Protocol violation while serving %s, %s.", user, repr(exc.__cause__ or exc))
|
|
33
|
+
await user.terminate(exc.args[0] if exc.args else "Protocol violation")
|
|
34
|
+
except Exception:
|
|
35
|
+
logger.exception("Error while serving %s.", user)
|
|
36
|
+
await user.terminate("Internal host error")
|
|
37
|
+
else:
|
|
38
|
+
await user.terminate()
|
|
39
|
+
|
|
40
|
+
logger.info("Connection with %s closed.", user)
|
|
41
|
+
|
|
42
|
+
def started(self, _listeners: Any) -> None:
|
|
43
|
+
sdnotify.ready()
|
|
44
|
+
ellinetircd.update_status()
|
|
45
|
+
|
|
46
|
+
async def _onterm(self) -> None:
|
|
47
|
+
with trio.open_signal_receiver(signal.SIGTERM, signal.SIGINT) as signal_aiter:
|
|
48
|
+
async for _ in signal_aiter:
|
|
49
|
+
if self._nursery.cancel_scope.cancel_called:
|
|
50
|
+
raise KeyboardInterrupt()
|
|
51
|
+
sdnotify.stopping()
|
|
52
|
+
sdnotify.status("Terminating connections...")
|
|
53
|
+
self._nursery.cancel_scope.cancel()
|
|
54
|
+
|
|
55
|
+
async def serve(self) -> None:
|
|
56
|
+
ellinetircd.servlocal.set(ServLocal(self.host, self.pwd, {}, {}))
|
|
57
|
+
async with trio.open_nursery() as self._nursery:
|
|
58
|
+
self._nursery.start_soon(self._onterm)
|
|
59
|
+
logger.info("Listening on %s port %s.", self.addr, self.port)
|
|
60
|
+
await trio.serve_tcp(self.handle, self.port, host=self.addr, task_status=self)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclasses.dataclass(eq=False)
|
|
64
|
+
class ServLocal:
|
|
65
|
+
host: str
|
|
66
|
+
pwd: str # password, "pass" is a reserved keyword
|
|
67
|
+
users: Dict[str, User]
|
|
68
|
+
channels: Dict[str, "ellinetircd.channel.Channel"]
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
return (
|
|
72
|
+
f'{self.__name__}('
|
|
73
|
+
f'host: {self.host!r}, '
|
|
74
|
+
f'pass: {"yes" if self.pwd else "no"}, '
|
|
75
|
+
f'users: {len(self.users)}, '
|
|
76
|
+
f'channels: {len(self.channels)})'
|
|
77
|
+
)
|
ellinetircd/states.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
'UserState', 'ConnectedState', 'PasswordState', 'RegisteredState',
|
|
3
|
+
'QuitState'
|
|
4
|
+
]
|
|
5
|
+
|
|
6
|
+
import abc
|
|
7
|
+
import inspect
|
|
8
|
+
import ipaddress
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import textwrap
|
|
12
|
+
import trio
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar
|
|
14
|
+
|
|
15
|
+
import ellinetircd
|
|
16
|
+
from ellinetircd.channel import Channel
|
|
17
|
+
from ellinetircd.exceptions import *
|
|
18
|
+
|
|
19
|
+
FuncType = TypeVar('FuncType', bound=Callable[..., Any])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
nick_re = re.compile(r"[a-zA-Z][a-zA-Z0-9\-_]{1,15}")
|
|
24
|
+
chan_re = re.compile(r"[&#][a-zA-Z0-9\-_]{1,49}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def command(func: FuncType) -> FuncType:
|
|
28
|
+
""" Denote the function can be triggered by an IRC message """
|
|
29
|
+
func.command = True
|
|
30
|
+
return func
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UserState(metaclass=abc.ABCMeta):
|
|
34
|
+
def __init__(self, user: "ellinetircd.user.User") -> None:
|
|
35
|
+
logger.debug("state of user %s changed: %s -> %s", user, user.state, self)
|
|
36
|
+
self.user = user
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
return type(self).__name__[:-5]
|
|
40
|
+
|
|
41
|
+
async def dispatch(self, cmd: str, *params: str) -> None:
|
|
42
|
+
logger.debug('Dispatch to %s: %s', cmd, params)
|
|
43
|
+
meth = getattr(self, cmd, None)
|
|
44
|
+
if not meth or not getattr(meth, 'command', False):
|
|
45
|
+
raise ErrUnknownError(self.user, '-', f"Command {cmd} is unknown.")
|
|
46
|
+
|
|
47
|
+
sign = inspect.signature(meth)
|
|
48
|
+
try:
|
|
49
|
+
sign.bind(*params)
|
|
50
|
+
except TypeError:
|
|
51
|
+
meth_params_cnt = len(inspect.signature(meth).parameters.values())
|
|
52
|
+
if len(params) < meth_params_cnt:
|
|
53
|
+
raise ErrNeedMoreParams(cmd)
|
|
54
|
+
else:
|
|
55
|
+
raise ErrUnknownError(self.user, cmd, f"Couldn't bind {params} to {sign}.")
|
|
56
|
+
|
|
57
|
+
await meth(*params)
|
|
58
|
+
|
|
59
|
+
@command
|
|
60
|
+
async def PING(self, token: str) -> None:
|
|
61
|
+
host = ellinetircd.servlocal.get().host
|
|
62
|
+
await self.user.send(f":{host} PONG {host} {token}", log=logger.isEnabledFor(logging.DEBUG))
|
|
63
|
+
|
|
64
|
+
@command
|
|
65
|
+
async def PONG(self, token: Optional[str] = None) -> None:
|
|
66
|
+
pass # ignored
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@command
|
|
70
|
+
async def USER(self, username: str, _zero: str, _star: str, realname: str) -> None:
|
|
71
|
+
raise ErrUnknownError(self.user, "USER", "Called while in the wrong state.")
|
|
72
|
+
|
|
73
|
+
@command
|
|
74
|
+
async def PASS(self, password: str) -> None:
|
|
75
|
+
raise ErrUnknownError(self.user, "PASS", "Called while in the wrong state.")
|
|
76
|
+
|
|
77
|
+
@command
|
|
78
|
+
async def NICK(self, nickname: str) -> None:
|
|
79
|
+
raise ErrUnknownError(self.user, "NICK", "Called while in the wrong state.")
|
|
80
|
+
|
|
81
|
+
@command
|
|
82
|
+
async def WHO(self, channel: str) -> None:
|
|
83
|
+
raise ErrUnknownError(self.user, "WHO", "Called while in the wrong state.")
|
|
84
|
+
|
|
85
|
+
@command
|
|
86
|
+
async def WHOIS(self, channel: str) -> None:
|
|
87
|
+
raise ErrUnknownError(self.user, "WHOIS", "Called while in the wrong state.")
|
|
88
|
+
|
|
89
|
+
@command
|
|
90
|
+
async def JOIN(self, channels: str) -> None:
|
|
91
|
+
raise ErrUnknownError(self.user, "JOIN", "Called while in the wrong state.")
|
|
92
|
+
|
|
93
|
+
@command
|
|
94
|
+
async def PART(self, channels: str, reason: Optional[str] = None) -> None:
|
|
95
|
+
raise ErrUnknownError(self.user, "PART", "Called while in the wrong state.")
|
|
96
|
+
|
|
97
|
+
@command
|
|
98
|
+
async def NAMES(self, channel: str) -> None:
|
|
99
|
+
raise ErrUnknownError(self.user, "NAMES", "Called while in the wrong state.")
|
|
100
|
+
|
|
101
|
+
@command
|
|
102
|
+
async def LIST(self) -> None:
|
|
103
|
+
raise ErrUnknownError(self.user, "LIST", "Called while in the wrong state.")
|
|
104
|
+
|
|
105
|
+
@command
|
|
106
|
+
async def PRIVMSG(self, args: str) -> None:
|
|
107
|
+
raise ErrUnknownError(self.user, "PRIVMSG", "Called while in the wrong state.")
|
|
108
|
+
|
|
109
|
+
@command
|
|
110
|
+
async def MODE(self, args: str) -> None:
|
|
111
|
+
raise ErrUnknownError(self.user, "MODE", "Called while in the wrong state.")
|
|
112
|
+
|
|
113
|
+
@command
|
|
114
|
+
async def QUIT(self, reason: str = "", *, kick: bool = False) -> None:
|
|
115
|
+
servlocal = ellinetircd.servlocal.get()
|
|
116
|
+
if not kick:
|
|
117
|
+
reason = 'Quit: ' + reason
|
|
118
|
+
for chan in self.user.channels:
|
|
119
|
+
chan.users.remove(self.user)
|
|
120
|
+
await chan.send(f":{self.user.nick} QUIT :{reason}")
|
|
121
|
+
if not chan.users:
|
|
122
|
+
servlocal.channels.pop(chan.name)
|
|
123
|
+
self.user.channels.clear()
|
|
124
|
+
self.user.state = QuitState(self.user)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PasswordState(UserState):
|
|
128
|
+
@command
|
|
129
|
+
async def PASS(self, password: str) -> None:
|
|
130
|
+
servlocal = ellinetircd.servlocal.get()
|
|
131
|
+
if password != servlocal.pwd:
|
|
132
|
+
logger.log(ellinetircd.SECURITY, "Invalid password for %s", self.user)
|
|
133
|
+
raise ErrPasswdMismatch()
|
|
134
|
+
|
|
135
|
+
self.user.state = ConnectedState(self.user)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ConnectedState(UserState):
|
|
139
|
+
"""
|
|
140
|
+
The user is just connected, he must register via the NICK command
|
|
141
|
+
first before going on.
|
|
142
|
+
"""
|
|
143
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
144
|
+
super().__init__(*args, **kwargs)
|
|
145
|
+
self.has_nick = False
|
|
146
|
+
self.has_user = False
|
|
147
|
+
self.cap_ended = False
|
|
148
|
+
self.cap_started = False
|
|
149
|
+
|
|
150
|
+
@command
|
|
151
|
+
async def CAP(self, subcommand: str, *params: str) -> None:
|
|
152
|
+
host = ellinetircd.servlocal.get().host
|
|
153
|
+
subcommand = subcommand.upper()
|
|
154
|
+
self.cap_started = True
|
|
155
|
+
|
|
156
|
+
CAPS = [
|
|
157
|
+
"away-notify",
|
|
158
|
+
"cap-notify",
|
|
159
|
+
"multi-prefix",
|
|
160
|
+
"chghost",
|
|
161
|
+
]
|
|
162
|
+
caps = " ".join(CAPS)
|
|
163
|
+
|
|
164
|
+
if subcommand == 'LS':
|
|
165
|
+
await self.user.send(f":{host} CAP * LS :{caps}")
|
|
166
|
+
elif subcommand == 'LIST':
|
|
167
|
+
await self.user.send(f":{host} CAP * LIST :{caps}")
|
|
168
|
+
elif subcommand == 'REQ':
|
|
169
|
+
requested = set(params[-1].split())
|
|
170
|
+
|
|
171
|
+
supported = CAPS
|
|
172
|
+
unknown = requested.difference(supported)
|
|
173
|
+
|
|
174
|
+
if unknown:
|
|
175
|
+
await self.user.send(
|
|
176
|
+
f":{host} CAP * NAK :{' '.join(unknown)}"
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
await self.user.send(
|
|
180
|
+
f":{host} CAP * ACK :{' '.join(requested)}"
|
|
181
|
+
)
|
|
182
|
+
elif subcommand == 'END':
|
|
183
|
+
self.cap_ended = True
|
|
184
|
+
if self.has_user and self.has_nick:
|
|
185
|
+
await self.register()
|
|
186
|
+
else:
|
|
187
|
+
raise ErrUnknownError(self.user, "CAP", f"Unknown CAP subcommand {subcommand}.")
|
|
188
|
+
|
|
189
|
+
@command
|
|
190
|
+
async def PASS(self, password: str) -> None:
|
|
191
|
+
raise ErrAlreadyRegistred()
|
|
192
|
+
|
|
193
|
+
@command
|
|
194
|
+
async def USER(self, username: str, _zero: str, _star: str, realname: str) -> None:
|
|
195
|
+
self.has_user = True
|
|
196
|
+
self.user.realname = realname
|
|
197
|
+
if self.has_user and self.has_nick and (not self.cap_started or self.cap_ended):
|
|
198
|
+
await self.register()
|
|
199
|
+
|
|
200
|
+
@command
|
|
201
|
+
async def NICK(self, nickname: str) -> None:
|
|
202
|
+
servlocal = ellinetircd.servlocal.get()
|
|
203
|
+
if nickname in servlocal.users:
|
|
204
|
+
raise ErrNicknameInUse(nickname)
|
|
205
|
+
if not nick_re.match(nickname):
|
|
206
|
+
raise ErrErroneusNickname(nickname)
|
|
207
|
+
if not self.user.can_use_nick(nickname):
|
|
208
|
+
logger.log(ellinetircd.SECURITY, '%s tried to use nick %s', self.user, nickname)
|
|
209
|
+
raise ErrErroneusNickname(nickname)
|
|
210
|
+
|
|
211
|
+
self.user.nick = nickname
|
|
212
|
+
self.has_nick = True
|
|
213
|
+
if self.has_user and self.has_nick and (not self.cap_started or self.cap_ended):
|
|
214
|
+
await self.register()
|
|
215
|
+
|
|
216
|
+
async def register(self) -> None:
|
|
217
|
+
servlocal = ellinetircd.servlocal.get()
|
|
218
|
+
self.user.state = RegisteredState(self.user)
|
|
219
|
+
ellinetircd.update_status()
|
|
220
|
+
await self.user.send(textwrap.dedent("""\
|
|
221
|
+
:{host} 001 {nick} :Welcome to the Internet Relay Network {nick}
|
|
222
|
+
:{host} 002 {nick} :Your host is {host}, running version {version}
|
|
223
|
+
:{host} 003 {nick} :The server was created someday
|
|
224
|
+
:{host} 004 {nick} ellinetircd {version} {usermodes} {chanmodes}
|
|
225
|
+
:{host} 005 {nick} {cap1} :are supported by this server
|
|
226
|
+
:{host} 005 {nick} {cap2} :are supported by this server
|
|
227
|
+
:{host} 422 {nick} :MOTD File is missing""".format(
|
|
228
|
+
host=servlocal.host,
|
|
229
|
+
nick=self.user.nick,
|
|
230
|
+
version=ellinetircd.__version__,
|
|
231
|
+
# RPL_MYINFO (004), advertise availables modes (none)
|
|
232
|
+
usermodes="",
|
|
233
|
+
chanmodes="",
|
|
234
|
+
# RPL_ISUPPORT (005), advertise server capabilities (not much)
|
|
235
|
+
cap1 = "AWAYLEN=0 CASEMAPPING=ascii CHANLIMIT=#: CHANMODES= CHANNELLEN=50 CHANTYPES=# ELIST=",
|
|
236
|
+
cap2 = "HOSTLEN=63 KICKLEN=0 MAXLIST= MAXTARGETS=12 MODES=0 NICKLEN=15 STATUSMSG= TOPICLEN=0 USERLEN=15",
|
|
237
|
+
)
|
|
238
|
+
).split('\n'))
|
|
239
|
+
|
|
240
|
+
class RegisteredState(UserState):
|
|
241
|
+
"""
|
|
242
|
+
The user sent the NICK command, he is fully registered to the server
|
|
243
|
+
and may use any command.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
@command
|
|
247
|
+
async def PASS(self, password: str) -> None:
|
|
248
|
+
raise ErrAlreadyRegistred()
|
|
249
|
+
|
|
250
|
+
@command
|
|
251
|
+
async def USER(self, username: str, _zero: str, _star: str, realname: str) -> None:
|
|
252
|
+
raise ErrAlreadyRegistred()
|
|
253
|
+
|
|
254
|
+
@command
|
|
255
|
+
async def NICK(self, nickname: str) -> None:
|
|
256
|
+
servlocal = ellinetircd.servlocal.get()
|
|
257
|
+
|
|
258
|
+
if nickname in servlocal.users:
|
|
259
|
+
raise ErrNicknameInUse(nickname)
|
|
260
|
+
if not nick_re.match(nickname):
|
|
261
|
+
raise ErrErroneusNickname(nickname)
|
|
262
|
+
|
|
263
|
+
old_nick = self.user.nick
|
|
264
|
+
async with trio.open_nursery() as nursery:
|
|
265
|
+
for chan in self.user.channels:
|
|
266
|
+
nursery.start_soon(chan.send, f":{old_nick} NICK {nickname}")
|
|
267
|
+
|
|
268
|
+
self.user.nick = nickname
|
|
269
|
+
|
|
270
|
+
@command
|
|
271
|
+
async def JOIN(self, channels: str) -> None:
|
|
272
|
+
servlocal = ellinetircd.servlocal.get()
|
|
273
|
+
|
|
274
|
+
for channel in channels.split(','):
|
|
275
|
+
if not chan_re.match(channel):
|
|
276
|
+
await self.user.send(ErrNoSuchChannel.format(channel))
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
# Find or create the channel, add the user in it
|
|
280
|
+
chan = servlocal.channels.get(channel)
|
|
281
|
+
if not chan:
|
|
282
|
+
chan = Channel(channel)
|
|
283
|
+
servlocal.channels[chan.name] = chan
|
|
284
|
+
ellinetircd.update_status()
|
|
285
|
+
chan.users.add(self.user)
|
|
286
|
+
self.user.channels.add(chan)
|
|
287
|
+
|
|
288
|
+
# Send JOIN response to all
|
|
289
|
+
await chan.send(f":{self.user.nick} JOIN {channel}")
|
|
290
|
+
|
|
291
|
+
# Send NAMES list to joiner
|
|
292
|
+
await self.NAMES(channel)
|
|
293
|
+
|
|
294
|
+
@command
|
|
295
|
+
async def PART(self, channels: str, reason: Optional[str] = None) -> None:
|
|
296
|
+
servlocal = ellinetircd.servlocal.get()
|
|
297
|
+
|
|
298
|
+
for channel in channels.split(','):
|
|
299
|
+
chan = servlocal.channels.get(channel)
|
|
300
|
+
if not chan:
|
|
301
|
+
await self.user.send(ErrNoSuchChannel.format(channel))
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
if self.user not in chan.users:
|
|
305
|
+
await self.user.send(ErrNotOnChannel.format(channel))
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
self.user.channels.remove(chan)
|
|
309
|
+
chan.users.remove(self.user)
|
|
310
|
+
if not chan.users:
|
|
311
|
+
servlocal.channels.pop(chan.name)
|
|
312
|
+
ellinetircd.update_status()
|
|
313
|
+
|
|
314
|
+
if reason:
|
|
315
|
+
await chan.send(f":{self.user.nick} PART {channel} :{reason}")
|
|
316
|
+
else:
|
|
317
|
+
await chan.send(f":{self.user.nick} PART {channel}")
|
|
318
|
+
|
|
319
|
+
@command
|
|
320
|
+
async def NAMES(self, channel: str) -> None:
|
|
321
|
+
servlocal = ellinetircd.servlocal.get()
|
|
322
|
+
chan = servlocal.channels.get(channel)
|
|
323
|
+
host = servlocal.host
|
|
324
|
+
nick = self.user.nick
|
|
325
|
+
|
|
326
|
+
if chan:
|
|
327
|
+
await self.user.send([
|
|
328
|
+
f":{host} 353 {nick} = {chan} :{some_users}"
|
|
329
|
+
# merged all users in a str and split it by MAXLINELEN chunks
|
|
330
|
+
for some_users in textwrap.wrap(
|
|
331
|
+
' '.join(sorted(user.nick for user in chan.users)),
|
|
332
|
+
width=ellinetircd.MAXLINELEN - len(host) - len(nick) - len(channel) - 13
|
|
333
|
+
)
|
|
334
|
+
])
|
|
335
|
+
|
|
336
|
+
await self.user.send(f":{host} 366 {nick} {chan} :End of /NAMES list.")
|
|
337
|
+
|
|
338
|
+
@command
|
|
339
|
+
async def LIST(self) -> None:
|
|
340
|
+
servlocal = ellinetircd.servlocal.get()
|
|
341
|
+
host = servlocal.host
|
|
342
|
+
nick = self.user.nick
|
|
343
|
+
|
|
344
|
+
await self.user.send([
|
|
345
|
+
f":{host} 321 {nick} Channel :Users Name",
|
|
346
|
+
*[
|
|
347
|
+
f":{host} 322 {nick} {chan} {len(chan.users)} :"
|
|
348
|
+
for chan in servlocal.channels.values()
|
|
349
|
+
],
|
|
350
|
+
f":{host} 323 {nick} :End of /LIST"
|
|
351
|
+
])
|
|
352
|
+
|
|
353
|
+
@command
|
|
354
|
+
async def PRIVMSG(self, targets: str, text: str) -> None:
|
|
355
|
+
servlocal = ellinetircd.servlocal.get()
|
|
356
|
+
|
|
357
|
+
for target in targets.split(','):
|
|
358
|
+
chan_or_user = (
|
|
359
|
+
servlocal.channels.get(target)
|
|
360
|
+
if target.startswith(('&', '#')) else
|
|
361
|
+
servlocal.users.get(target)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if not chan_or_user:
|
|
365
|
+
await self.user.send(ErrNoSuchNick.format(target))
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
await chan_or_user.send(
|
|
369
|
+
f":{self.user.nick} PRIVMSG {target} :{text}",
|
|
370
|
+
skipusers={self.user}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
@command
|
|
374
|
+
async def WHO(self, target: str = "*") -> None:
|
|
375
|
+
servlocal = ellinetircd.servlocal.get()
|
|
376
|
+
host = servlocal.host
|
|
377
|
+
requester = self.user.nick
|
|
378
|
+
|
|
379
|
+
def match(user: "ellinetircd.user.User", target: str) -> bool:
|
|
380
|
+
if target == "*" or target == "":
|
|
381
|
+
return True
|
|
382
|
+
if target.startswith("#"):
|
|
383
|
+
return user in servlocal.channels.get(target, set()).users
|
|
384
|
+
return target.lower() in user.nick.lower()
|
|
385
|
+
|
|
386
|
+
users = set()
|
|
387
|
+
|
|
388
|
+
# Channel WHO
|
|
389
|
+
if target.startswith("#"):
|
|
390
|
+
chan = servlocal.channels.get(target)
|
|
391
|
+
if chan:
|
|
392
|
+
users = chan.users
|
|
393
|
+
|
|
394
|
+
# Global / nick WHO
|
|
395
|
+
else:
|
|
396
|
+
for chan in servlocal.channels.values():
|
|
397
|
+
users.update(chan.users)
|
|
398
|
+
|
|
399
|
+
users = [u for u in users if target == "*" or target.lower() in u.nick.lower()]
|
|
400
|
+
|
|
401
|
+
# Send WHO replies
|
|
402
|
+
for user in users:
|
|
403
|
+
await self.user.send(
|
|
404
|
+
f":{host} 352 {requester} "
|
|
405
|
+
f"{target} "
|
|
406
|
+
f"~{user.nick} "
|
|
407
|
+
f"{user.host} "
|
|
408
|
+
f"{host} "
|
|
409
|
+
f"{user.nick} "
|
|
410
|
+
f"H :0 {user.realname}"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# End of WHO list
|
|
414
|
+
await self.user.send(
|
|
415
|
+
f":{host} 315 {requester} {target} :End of /WHO list."
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
@command
|
|
419
|
+
async def WHOIS(self, target: str) -> None:
|
|
420
|
+
servlocal = ellinetircd.servlocal.get()
|
|
421
|
+
host = servlocal.host
|
|
422
|
+
requester = self.user.nick
|
|
423
|
+
|
|
424
|
+
user = servlocal.users.get(target)
|
|
425
|
+
if not user:
|
|
426
|
+
raise ErrNoSuchNick(target)
|
|
427
|
+
|
|
428
|
+
await self.user.send(
|
|
429
|
+
f":{host} 311 {requester} {target} ~{user.nick} {user.host} {host} :{user.realname}"
|
|
430
|
+
)
|
|
431
|
+
await self.user.send(
|
|
432
|
+
f":{host} 312 {requester} {target} {host} :ellinetircd"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
channels = ' '.join(sorted(chan.name for chan in user.channels))
|
|
436
|
+
if channels:
|
|
437
|
+
await self.user.send(
|
|
438
|
+
f":{host} 319 {requester} {target} :{channels}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
await self.user.send(
|
|
442
|
+
f":{host} 318 {requester} {target} :End of /WHOIS list."
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
@command
|
|
446
|
+
async def MODE(self, target: Optional[str] = None, *params: str) -> None:
|
|
447
|
+
servlocal = ellinetircd.servlocal.get()
|
|
448
|
+
host = servlocal.host
|
|
449
|
+
nick = self.user.nick
|
|
450
|
+
|
|
451
|
+
# Replace IF I add a MODE system
|
|
452
|
+
|
|
453
|
+
# User MODE query
|
|
454
|
+
if not target or target == self.user.nick:
|
|
455
|
+
await self.user.send(
|
|
456
|
+
f":{host} 221 {nick} +"
|
|
457
|
+
)
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
# Channel MODE query
|
|
461
|
+
if target.startswith("#") or target.startswith("&"):
|
|
462
|
+
if target not in servlocal.channels:
|
|
463
|
+
await self.user.send(ErrNoSuchChannel.format(target))
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
# Placeholder: pretend all channels are "+nt"
|
|
467
|
+
await self.user.send(
|
|
468
|
+
f":{host} 324 {nick} {target} +nt"
|
|
469
|
+
)
|
|
470
|
+
await self.user.send(
|
|
471
|
+
f":{host} 329 {nick} {target} 0"
|
|
472
|
+
)
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
# Unknown target (nick etc.)
|
|
476
|
+
await self.user.send(
|
|
477
|
+
ErrNoSuchNick.format(target)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
class QuitState(UserState):
|
|
481
|
+
""" The user sent the QUIT command, no more message should be processed """
|
|
482
|
+
def __init__(self, user: "ellinetircd.user.User") -> None:
|
|
483
|
+
super().__init__(user)
|
|
484
|
+
user.nick = None
|
|
485
|
+
ellinetircd.update_status()
|
|
486
|
+
|
|
487
|
+
@command
|
|
488
|
+
async def PING(self, server1: Optional[str] = None, server2: Optional[str] = None) -> None:
|
|
489
|
+
raise ErrUnknownError(self.user, "PING", "Called while in the wrong state.")
|
|
490
|
+
|
|
491
|
+
@command
|
|
492
|
+
async def PONG(self, server1: Optional[str] = None, server2: Optional[str] = None) -> None:
|
|
493
|
+
raise ErrUnknownError(self.user, "PONG", "Called while in the wrong state.")
|
|
494
|
+
|
|
495
|
+
@command
|
|
496
|
+
def QUIT(self, args: str) -> None:
|
|
497
|
+
raise ErrUnknownError(self.user, "QUIT", "Called while in the wrong state.")
|
ellinetircd/user.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import trio
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import List, Optional, Set, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import ellinetircd
|
|
9
|
+
from ellinetircd.config import config as cfg
|
|
10
|
+
from ellinetircd.exceptions import IRCException, Disconnect
|
|
11
|
+
from ellinetircd.states import PasswordState, ConnectedState, QuitState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger('ellinetircd.user')
|
|
15
|
+
|
|
16
|
+
message_re = re.compile(r"""
|
|
17
|
+
(?P<command>[A-Z]+)
|
|
18
|
+
(?P<middle>(?:\ [^\ :]+)*)
|
|
19
|
+
(?:\ :(?P<trailing>.+))?
|
|
20
|
+
""", re.VERBOSE)
|
|
21
|
+
|
|
22
|
+
# Nicknames that users should not use
|
|
23
|
+
_unsafe_nicks = {
|
|
24
|
+
# RFC
|
|
25
|
+
'anonymous',
|
|
26
|
+
# anope services
|
|
27
|
+
'ChanServ',
|
|
28
|
+
'NickServ',
|
|
29
|
+
'OperServ',
|
|
30
|
+
'MemoServ',
|
|
31
|
+
'HostServ',
|
|
32
|
+
'BotServ',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Networks allowed to use the above unsafe nicks
|
|
36
|
+
_safenets = [
|
|
37
|
+
ipaddress.ip_network('::1/128'),
|
|
38
|
+
ipaddress.ip_network('127.0.0.0/8'),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class User:
|
|
43
|
+
def __init__(self, stream: trio.abc.Stream, nursery: trio.Nursery) -> None:
|
|
44
|
+
servlocal = ellinetircd.servlocal.get()
|
|
45
|
+
self.stream = stream
|
|
46
|
+
self._nursery = nursery
|
|
47
|
+
self._nick: Optional[str] = None
|
|
48
|
+
self._addr: Tuple[str, int, ...] = stream.socket.getpeername()
|
|
49
|
+
self._realname: Optional[str] = None
|
|
50
|
+
self.state = None
|
|
51
|
+
self.state = (PasswordState if servlocal.pwd else ConnectedState)(self)
|
|
52
|
+
self.channels = set()
|
|
53
|
+
self._ping_timer = trio.CancelScope() # dummy
|
|
54
|
+
self._send_lock = trio.StrictFIFOLock()
|
|
55
|
+
|
|
56
|
+
def __str__(self) -> str:
|
|
57
|
+
if self.nick:
|
|
58
|
+
return self.nick
|
|
59
|
+
|
|
60
|
+
ip, port, *_ = self._addr
|
|
61
|
+
if ':' in ip:
|
|
62
|
+
return f'[{ip}]:{port}'
|
|
63
|
+
return f'{ip}:{port}'
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def nick(self) -> Optional[str]:
|
|
67
|
+
return self._nick
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def addr(self) -> Tuple[str, int, ...]:
|
|
71
|
+
return self._addr
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def host(self) -> str:
|
|
75
|
+
return self._addr[0]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def realname(self) -> Optional[str]:
|
|
79
|
+
return self._realname
|
|
80
|
+
|
|
81
|
+
@nick.setter
|
|
82
|
+
def nick(self, nick: str) -> None:
|
|
83
|
+
servlocal = ellinetircd.servlocal.get()
|
|
84
|
+
servlocal.users[nick] = servlocal.users.pop(self._nick, self)
|
|
85
|
+
self._nick = nick
|
|
86
|
+
|
|
87
|
+
@realname.setter
|
|
88
|
+
def realname(self, realname: str) -> None:
|
|
89
|
+
self._realname = realname
|
|
90
|
+
|
|
91
|
+
def can_use_nick(self, nick: str) -> bool:
|
|
92
|
+
""" Whether this user is allowed to use ``nick``. """
|
|
93
|
+
if nick not in _unsafe_nicks:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
ip = ipaddress.ip_address(self._addr[0])
|
|
97
|
+
return any(ip in net for net in _safenets)
|
|
98
|
+
|
|
99
|
+
async def ping_forever(self) -> None:
|
|
100
|
+
"""
|
|
101
|
+
If the user did not send any message for some time, send him a
|
|
102
|
+
PING message that he should answer ASAP with a PONG message. If
|
|
103
|
+
he fails to answer (maybe because the network failed) he'll be
|
|
104
|
+
automatically disconnected, see :meth:`serve`.
|
|
105
|
+
"""
|
|
106
|
+
while True:
|
|
107
|
+
with trio.move_on_after(cfg.TIMEOUT - cfg.PING_TIMEOUT) as self._ping_timer:
|
|
108
|
+
await trio.sleep_forever()
|
|
109
|
+
await self.send(f'PING {uuid.uuid4().hex}', log=logger.isEnabledFor(logging.DEBUG))
|
|
110
|
+
|
|
111
|
+
async def serve(self) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Read for messages on the user socket, parse them and dispatch
|
|
114
|
+
each message to the current's user state.
|
|
115
|
+
"""
|
|
116
|
+
buffer = b""
|
|
117
|
+
while type(self.state) is not QuitState:
|
|
118
|
+
|
|
119
|
+
# Read the socket in a buffer, wait at most TIMEOUT seconds
|
|
120
|
+
self._ping_timer.deadline = trio.current_time() + (cfg.TIMEOUT - cfg.PING_TIMEOUT)
|
|
121
|
+
with trio.move_on_after(cfg.TIMEOUT) as cs:
|
|
122
|
+
try:
|
|
123
|
+
chunk = await self.stream.receive_some(ellinetircd.MAXLINELEN)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise Disconnect("Network failure") from exc
|
|
126
|
+
if cs.cancelled_caught:
|
|
127
|
+
raise Disconnect("Timeout")
|
|
128
|
+
elif not chunk:
|
|
129
|
+
raise Disconnect("End of transmission")
|
|
130
|
+
|
|
131
|
+
# Split the buffer into as many IRC messages as possible,
|
|
132
|
+
# ensure each message has a length of maximum MAXLINELEN
|
|
133
|
+
*messages, buffer = (buffer + chunk).split(b'\r\n')
|
|
134
|
+
if any(len(m) > ellinetircd.MAXLINELEN - 2 for m in messages + [buffer]):
|
|
135
|
+
raise Disconnect("Payload too long")
|
|
136
|
+
|
|
137
|
+
for message in (m for m in messages if m):
|
|
138
|
+
# IO-log all messages, except PING/PONG that are only
|
|
139
|
+
# log in DEBUG
|
|
140
|
+
if not (message.startswith(b'PING') or message.startswith(b'PONG')
|
|
141
|
+
) or logger.isEnabledFor(logging.DEBUG):
|
|
142
|
+
logger.log(ellinetircd.IO, "recv from %s: %s", self, message)
|
|
143
|
+
|
|
144
|
+
# Parse the message
|
|
145
|
+
try:
|
|
146
|
+
if not (match := message_re.match(message.decode())):
|
|
147
|
+
raise SyntaxError(f"Couldn't parse {message}")
|
|
148
|
+
except UnicodeDecodeError as exc:
|
|
149
|
+
raise Disconnect("Gibberish") from exc
|
|
150
|
+
except SyntaxError as exc:
|
|
151
|
+
raise Disconnect("Parsing error") from exc
|
|
152
|
+
|
|
153
|
+
# Re-construct the arguments
|
|
154
|
+
args = [match.group('command')]
|
|
155
|
+
if middle := match.group('middle'):
|
|
156
|
+
args.extend(middle.split())
|
|
157
|
+
if trailing := match.group('trailing'):
|
|
158
|
+
args.append(trailing)
|
|
159
|
+
|
|
160
|
+
# Execute the command
|
|
161
|
+
try:
|
|
162
|
+
await self.state.dispatch(*args)
|
|
163
|
+
except IRCException as exc:
|
|
164
|
+
logger.warning("Command %s sent by %s failed, code: %s",
|
|
165
|
+
args[0], self, exc.code)
|
|
166
|
+
await self.send(exc.args[0])
|
|
167
|
+
|
|
168
|
+
async def terminate(self, kick_msg: str = "Connection terminated by host") -> None:
|
|
169
|
+
"""
|
|
170
|
+
Terminate the connection with this user by closing the
|
|
171
|
+
underlying socket and cancelling the user's nursery effectively
|
|
172
|
+
cancelling all user's related tasks.
|
|
173
|
+
"""
|
|
174
|
+
logger.info("Terminate connection of %s", self)
|
|
175
|
+
if type(self.state) != QuitState:
|
|
176
|
+
await self.state.QUIT(f":{kick_msg}".split(' '), kick=True)
|
|
177
|
+
with trio.move_on_after(cfg.PING_TIMEOUT) as cs:
|
|
178
|
+
try:
|
|
179
|
+
await self.stream.send_eof()
|
|
180
|
+
except (trio.BrokenResourceError, OSError):
|
|
181
|
+
pass # client already died
|
|
182
|
+
await self.stream.aclose()
|
|
183
|
+
self._nursery.cancel_scope.cancel()
|
|
184
|
+
|
|
185
|
+
async def send(
|
|
186
|
+
self,
|
|
187
|
+
messages: Union[str, List[str]],
|
|
188
|
+
log: bool = True,
|
|
189
|
+
skipusers: Optional[Set["ellinetircd.user.User"]] = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
""" Send many messages to the user. """
|
|
192
|
+
if isinstance(messages, str):
|
|
193
|
+
messages = [messages]
|
|
194
|
+
|
|
195
|
+
async with self._send_lock:
|
|
196
|
+
if log:
|
|
197
|
+
for msg in messages:
|
|
198
|
+
logger.log(ellinetircd.IO, "send to %s: %s", self, msg)
|
|
199
|
+
await self.stream.send_all(b"".join(f"{msg}\r\n".encode() for msg in messages))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ellinetircd
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: ElliNetIRCd - an asynchronous IRC server
|
|
5
|
+
Home-page: https://github.com/ElliNet13/ElliNetIRCd
|
|
6
|
+
Download-URL: https://pypi.org/project/ellinetircd/
|
|
7
|
+
Author: ElliNet13
|
|
8
|
+
Author-email: your@email.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Classifier: Environment :: No Input/Output (Daemon)
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Communications :: Chat :: Internet Relay Chat
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: trio
|
|
20
|
+
Dynamic: download-url
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
ellinetircd
|
|
24
|
+
=======
|
|
25
|
+
|
|
26
|
+
A python asynchronous IRC server based on aioirc.
|
|
27
|
+
|
|
28
|
+
### Installation and Usage
|
|
29
|
+
|
|
30
|
+
Download and install the latest stable version using pip.
|
|
31
|
+
Windows users might replace `python3` by `py`.
|
|
32
|
+
```bash
|
|
33
|
+
python3 -m pip install ellinetircd
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then run the server:
|
|
37
|
+
```bash
|
|
38
|
+
HOST=0.0.0.0 LOGLEVEL=INFO python3 -m ellinetircd
|
|
39
|
+
```
|
|
40
|
+
If you are using powershell you can use the following command:
|
|
41
|
+
```powershell
|
|
42
|
+
$env:HOST="0.0.0.0"
|
|
43
|
+
$env:LOGLEVEL="INFO"
|
|
44
|
+
python -m ellinetircd
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The configuration is done via environment variables, see `--help`:
|
|
48
|
+
|
|
49
|
+
optional arguments:
|
|
50
|
+
-h, --help show this help message and exit
|
|
51
|
+
-V, --version show program's version number and exit
|
|
52
|
+
|
|
53
|
+
environment variables:
|
|
54
|
+
HOST public domain name (default: julien-UX410UAR)
|
|
55
|
+
ADDR ip address to bind (default: 127.0.1.1)
|
|
56
|
+
PORT port to bind (default: 6667)
|
|
57
|
+
PASS server password (default: )
|
|
58
|
+
TIMEOUT kick inactive users after x seconds (default: 60)
|
|
59
|
+
PING_TIMEOUT PING inactive users x seconds before timeout (default: 5)
|
|
60
|
+
LOGLEVEL logging verbosity (default: WARNING)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ellinetircd/__init__.py,sha256=roTAItzYx_WKprglbCjG47tJMmreltbAgT795cteuhA,926
|
|
2
|
+
ellinetircd/__main__.py,sha256=e4tNtoc2NkNIl_Mm07lV_-GpIKzPUB4SorZxmiQGRhs,2626
|
|
3
|
+
ellinetircd/channel.py,sha256=mbGw9OdhJSqwz1NEupk0pv67WRf1XCjWIg2e3eOOA0Q,2197
|
|
4
|
+
ellinetircd/config.py,sha256=rNdhETi-OBQyXr3uRmz82290iGWhwU5QQ8DSjzYbnu4,470
|
|
5
|
+
ellinetircd/exceptions.py,sha256=7LEwSwDuFsz2pCfF7B9WBhORIojJbKyUthFMo-nc6JA,2113
|
|
6
|
+
ellinetircd/sdnotify.py,sha256=nJU4x-WCUXiDbNfSZSnacwwCkdNOI-59gJlVMv73LnI,625
|
|
7
|
+
ellinetircd/server.py,sha256=-cVqK6-rgLmZjSSngthe359Y9lBxB_xxcJ6fG3syIUQ,2733
|
|
8
|
+
ellinetircd/states.py,sha256=e3d7CSsXf8ZeHVsdiQ6JdrYobJyLSd9cF37aExpbVkU,17180
|
|
9
|
+
ellinetircd/user.py,sha256=eYsKObPdQrkED0ptyVazdaEBRYK0XFUA3cchEiuaj8A,7079
|
|
10
|
+
ellinetircd-1.0.0.dist-info/licenses/LICENSE,sha256=INUFoaswHMmCIpYFaWhj7XYkxXw8KbUTSGI0BODWNWM,1080
|
|
11
|
+
ellinetircd-1.0.0.dist-info/METADATA,sha256=52rqeAqExMK50A3YVG7_SoESspGA_OjmHFNQURmtxbc,1874
|
|
12
|
+
ellinetircd-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
ellinetircd-1.0.0.dist-info/top_level.txt,sha256=rW1Qb1lptOI4ayIfFJScLyepNgPj8-zflm_xkjcVcYA,12
|
|
14
|
+
ellinetircd-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright Julien Castiaux 2020
|
|
2
|
+
Copyright ElliNet13 2026
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ellinetircd
|