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.
@@ -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
+ )
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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