Alcuin

July 30th, 2021

Below is an updated version of the IRC emulator using UDP for links between servers that I published recently. In this update:

  • A new name: alcuin
  • Refactored into multiple files for ease of reading
  • Messages are now sent between servers with hashes attached so that garbage messages can be easily discarded
  • Apparently JOIN/PART messages are actually supported, your server just has to be running in time to catch them
  • Added a perfunctory amount of documentation in README.txt

Download the source.

1 diff -uNr a/README.txt b/README.txt
2 --- a/README.txt false
3 +++ b/README.txt 108109133483d7f71451e5fac1e61216f4a8ef8e20f328ebed7c9bc4e25128a3decf998bd56ab26181902f406f274e09c2e4f5822b9e902e2c3167392115d53b
4 @@ -0,0 +1,18 @@
5 +Alcuin implements an IRC server with a udp client built in
6 +such that it can connect to other alcuin servers via a
7 +gossip network. It is a work in progress and much is
8 +yet to be completed including but not limited to:
9 +
10 +- gossip style message forwarding
11 +- message deduplication
12 +- symmetric encryption of messages
13 +- support for broadcasting several important IRC commands
14 + over the gossip net.
15 +- mitigation of hash length extension attacks
16 +
17 +GETTING STARTED
18 +
19 +1. Copy config.py.example to config.py (nothing in the config file is
20 +used yet, but alcuin will crash if it doesn't exist).
21 +2. Launch alcuin with something like the following command:
22 +./alcuin --verbose --port=6668 --peers=10.0.0.1
23 diff -uNr a/alcuin b/alcuin
24 --- a/alcuin false
25 +++ b/alcuin 2b29662ab3b761920ef05135c33bfc3a3e8a426283fe2074e0a46cf2ce45ac79270f50c8d270e4c3a335be3f41cf03690745c94bcf8f3311ce36ab5eead6de90
26 @@ -0,0 +1,146 @@
27 +#! /usr/bin/env python
28 +
29 +import os
30 +import re
31 +import select
32 +import socket
33 +import string
34 +import sys
35 +import tempfile
36 +import time
37 +from lib.server import VERSION
38 +from lib.server import Server
39 +from lib.peer import Peer
40 +from datetime import datetime
41 +from optparse import OptionParser
42 +import config as cfg
43 +
44 +
45 +def main(argv):
46 + op = OptionParser(
47 + version=VERSION,
48 + description="alcuin is a small and limited IRC server emulator for gossip networks.")
49 + op.add_option(
50 + "-d", "--daemon",
51 + action="store_true",
52 + help="fork and become a daemon")
53 + op.add_option(
54 + "--debug",
55 + action="store_true",
56 + help="print debug messages to stdout")
57 + op.add_option(
58 + "--listen",
59 + metavar="X",
60 + help="listen on specific IP address X")
61 + op.add_option(
62 + "--logdir",
63 + metavar="X",
64 + help="store channel log in directory X")
65 + op.add_option(
66 + "--motd",
67 + metavar="X",
68 + help="display file X as message of the day")
69 + op.add_option(
70 + "-s", "--ssl-pem-file",
71 + metavar="FILE",
72 + help="enable SSL and use FILE as the .pem certificate+key")
73 + op.add_option(
74 + "-p", "--password",
75 + metavar="X",
76 + help="require connection password X; default: no password")
77 + op.add_option(
78 + "--ports",
79 + metavar="X",
80 + help="listen to ports X (a list separated by comma or whitespace);"
81 + " default: 6667 or 6697 if SSL is enabled")
82 + op.add_option(
83 + "--udp-port",
84 + metavar="X",
85 + help="listen for UDP packets on X;"
86 + " default: 7778")
87 + op.add_option(
88 + "--peers",
89 + metavar="X",
90 + help="Broadcast to X (a list of IP addresses separated by comma or whitespace)")
91 + op.add_option(
92 + "--statedir",
93 + metavar="X",
94 + help="save persistent channel state (topic, key) in directory X")
95 + op.add_option(
96 + "--verbose",
97 + action="store_true",
98 + help="be verbose (print some progress messages to stdout)")
99 + if os.name == "posix":
100 + op.add_option(
101 + "--chroot",
102 + metavar="X",
103 + help="change filesystem root to directory X after startup"
104 + " (requires root)")
105 + op.add_option(
106 + "--setuid",
107 + metavar="U[:G]",
108 + help="change process user (and optionally group) after startup"
109 + " (requires root)")
110 +
111 + (options, args) = op.parse_args(argv[1:])
112 + if options.debug:
113 + options.verbose = True
114 + if options.ports is None:
115 + if options.ssl_pem_file is None:
116 + options.ports = "6667"
117 + else:
118 + options.ports = "6697"
119 + if options.peers is None:
120 + options.peers = ""
121 + if options.udp_port is None:
122 + options.udp_port = 7778
123 + else:
124 + options.udp_port = int(options.udp_port)
125 + if options.chroot:
126 + if os.getuid() != 0:
127 + op.error("Must be root to use --chroot")
128 + if options.setuid:
129 + from pwd import getpwnam
130 + from grp import getgrnam
131 + if os.getuid() != 0:
132 + op.error("Must be root to use --setuid")
133 + matches = options.setuid.split(":")
134 + if len(matches) == 2:
135 + options.setuid = (getpwnam(matches[0]).pw_uid,
136 + getgrnam(matches[1]).gr_gid)
137 + elif len(matches) == 1:
138 + options.setuid = (getpwnam(matches[0]).pw_uid,
139 + getpwnam(matches[0]).pw_gid)
140 + else:
141 + op.error("Specify a user, or user and group separated by a colon,"
142 + " e.g. --setuid daemon, --setuid nobody:nobody")
143 + if (os.getuid() == 0 or os.getgid() == 0) and not options.setuid:
144 + op.error("Running this service as root is not recommended. Use the"
145 + " --setuid option to switch to an unprivileged account after"
146 + " startup. If you really intend to run as root, use"
147 + " \"--setuid root\".")
148 +
149 + ports = []
150 + for port in re.split(r"[,\s]+", options.ports):
151 + try:
152 + ports.append(int(port))
153 + except ValueError:
154 + op.error("bad port: %r" % port)
155 + options.ports = ports
156 + peers = []
157 + for peer in re.split(r"[,\s]+", options.peers):
158 + try:
159 + peers.append(Peer(peer))
160 + except ValueError:
161 + op.error("bad peer ip: %r" % peer)
162 + options.peers = peers
163 + server = Server(options)
164 + if options.daemon:
165 + server.daemonize()
166 + try:
167 + server.start()
168 + except KeyboardInterrupt:
169 + server.print_error("Interrupted.")
170 +
171 +
172 +main(sys.argv)
173 diff -uNr a/config.py.example b/config.py.example
174 --- a/config.py.example false
175 +++ b/config.py.example 2ee0181c6605a25bcdac392096c6d6e06757c600eb1bf7169229699dd01bb7fb12d635f5e13a1d6e23f691cba160666ed65cf4295d1176b8be8020bb9e2ed313
176 @@ -0,0 +1,4 @@
177 +secret = "SEEKRIT"
178 +peer_secrets = {
179 + "10.0.0.1":"K33P-0U7!"
180 +}
181 diff -uNr a/lib/channel.py b/lib/channel.py
182 --- a/lib/channel.py false
183 +++ b/lib/channel.py 29051fff4c05169a752059f5102291f981a73ec3c5112cb8135865919a2a8ce0f02db32c7c95639f39b51e2f444af5229febca7fe113d06b7d5c347c3f46bf30
184 @@ -0,0 +1,60 @@
185 +class Channel(object):
186 + def __init__(self, server, name):
187 + self.server = server
188 + self.name = name
189 + self.members = set()
190 + self._topic = ""
191 + self._key = None
192 + if self.server.statedir:
193 + self._state_path = "%s/%s" % (
194 + self.server.statedir,
195 + name.replace("_", "__").replace("/", "_"))
196 + self._read_state()
197 + else:
198 + self._state_path = None
199 +
200 + def add_member(self, client):
201 + self.members.add(client)
202 +
203 + def get_topic(self):
204 + return self._topic
205 +
206 + def set_topic(self, value):
207 + self._topic = value
208 + self._write_state()
209 +
210 + topic = property(get_topic, set_topic)
211 +
212 + def get_key(self):
213 + return self._key
214 +
215 + def set_key(self, value):
216 + self._key = value
217 + self._write_state()
218 +
219 + key = property(get_key, set_key)
220 +
221 + def remove_client(self, client):
222 + self.members.discard(client)
223 + if not self.members:
224 + self.server.remove_channel(self)
225 +
226 + def _read_state(self):
227 + if not (self._state_path and os.path.exists(self._state_path)):
228 + return
229 + data = {}
230 + exec(open(self._state_path), {}, data)
231 + self._topic = data.get("topic", "")
232 + self._key = data.get("key")
233 +
234 + def _write_state(self):
235 + if not self._state_path:
236 + return
237 + (fd, path) = tempfile.mkstemp(dir=os.path.dirname(self._state_path))
238 + fp = os.fdopen(fd, "w")
239 + fp.write("topic = %r\n" % self.topic)
240 + fp.write("key = %r\n" % self.key)
241 + fp.close()
242 + os.rename(path, self._state_path)
243 +
244 +
245 diff -uNr a/lib/client.py b/lib/client.py
246 --- a/lib/client.py false
247 +++ b/lib/client.py 9b39f4e54bed90281c6385f33688a7e2c239c51ebc92022cd5929550b684b26a4688f59812c68ed47ab2cecbcccb07778b176e372827d02c4e9dfae0bae047ec
248 @@ -0,0 +1,548 @@
249 +import time
250 +import sys
251 +import re
252 +import string
253 +from lib.server import VERSION
254 +from lib.infosec import Infosec
255 +from funcs import *
256 +
257 +class Client(object):
258 + __linesep_regexp = re.compile(r"\r?\n")
259 + # The RFC limit for nicknames is 9 characters, but what the heck.
260 + __valid_nickname_regexp = re.compile(
261 + r"^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9-]{0,50}$")
262 + __valid_channelname_regexp = re.compile(
263 + r"^[&#+!][^\x00\x07\x0a\x0d ,:]{0,50}$")
264 +
265 + def __init__(self, server, socket):
266 + self.server = server
267 + self.socket = socket
268 + self.channels = {} # irc_lower(Channel name) --> Channel
269 + self.nickname = None
270 + self.user = None
271 + self.realname = None
272 + (self.host, self.port) = socket.getpeername()
273 + self.__timestamp = time.time()
274 + self.__readbuffer = ""
275 + self.__writebuffer = ""
276 + self.__sent_ping = False
277 + self.infosec = Infosec()
278 + if self.server.password:
279 + self.__handle_command = self.__pass_handler
280 + else:
281 + self.__handle_command = self.__registration_handler
282 +
283 + def get_prefix(self):
284 + return "%s!%s@%s" % (self.nickname, self.user, self.host)
285 + prefix = property(get_prefix)
286 +
287 + def check_aliveness(self):
288 + now = time.time()
289 + if self.__timestamp + 180 < now:
290 + self.disconnect("ping timeout")
291 + return
292 + if not self.__sent_ping and self.__timestamp + 90 < now:
293 + if self.__handle_command == self.__command_handler:
294 + # Registered.
295 + self.message("PING :%s" % self.server.name)
296 + self.__sent_ping = True
297 + else:
298 + # Not registered.
299 + self.disconnect("ping timeout")
300 +
301 + def write_queue_size(self):
302 + return len(self.__writebuffer)
303 +
304 + def __parse_read_buffer(self):
305 + lines = self.__linesep_regexp.split(self.__readbuffer)
306 + self.__readbuffer = lines[-1]
307 + lines = lines[:-1]
308 + for line in lines:
309 + if not line:
310 + # Empty line. Ignore.
311 + continue
312 + x = line.split(" ", 1)
313 + command = x[0].upper()
314 + if len(x) == 1:
315 + arguments = []
316 + else:
317 + if len(x[1]) > 0 and x[1][0] == ":":
318 + arguments = [x[1][1:]]
319 + else:
320 + y = string.split(x[1], " :", 1)
321 + arguments = string.split(y[0])
322 + if len(y) == 2:
323 + arguments.append(y[1])
324 + self.__handle_command(command, arguments)
325 +
326 + def __pass_handler(self, command, arguments):
327 + server = self.server
328 + if command == "PASS":
329 + if len(arguments) == 0:
330 + self.reply_461("PASS")
331 + else:
332 + if arguments[0].lower() == server.password:
333 + self.__handle_command = self.__registration_handler
334 + else:
335 + self.reply("464 :Password incorrect")
336 + elif command == "QUIT":
337 + self.disconnect("Client quit")
338 + return
339 +
340 + def __registration_handler(self, command, arguments):
341 + server = self.server
342 + if command == "NICK":
343 + if len(arguments) < 1:
344 + self.reply("431 :No nickname given")
345 + return
346 + nick = arguments[0]
347 + if server.get_client(nick):
348 + self.reply("433 * %s :Nickname is already in use" % nick)
349 + elif not self.__valid_nickname_regexp.match(nick):
350 + self.reply("432 * %s :Erroneous nickname" % nick)
351 + else:
352 + self.nickname = nick
353 + server.client_changed_nickname(self, None)
354 + elif command == "USER":
355 + if len(arguments) < 4:
356 + self.reply_461("USER")
357 + return
358 + self.user = arguments[0]
359 + self.realname = arguments[3]
360 + elif command == "QUIT":
361 + self.disconnect("Client quit")
362 + return
363 + if self.nickname and self.user:
364 + self.reply("001 %s :Hi, welcome to IRC" % self.nickname)
365 + self.reply("002 %s :Your host is %s, running version miniircd-%s"
366 + % (self.nickname, server.name, VERSION))
367 + self.reply("003 %s :This server was created sometime"
368 + % self.nickname)
369 + self.reply("004 %s :%s miniircd-%s o o"
370 + % (self.nickname, server.name, VERSION))
371 + self.send_lusers()
372 + self.send_motd()
373 + self.__handle_command = self.__command_handler
374 +
375 + def __command_handler(self, command, arguments):
376 + def away_handler():
377 + pass
378 +
379 + def ison_handler():
380 + if len(arguments) < 1:
381 + self.reply_461("ISON")
382 + return
383 + nicks = arguments
384 + online = [n for n in nicks if server.get_client(n)]
385 + self.reply("303 %s :%s" % (self.nickname, " ".join(online)))
386 +
387 + def join_handler():
388 + if len(arguments) < 1:
389 + self.reply_461("JOIN")
390 + return
391 + if arguments[0] == "0":
392 + for (channelname, channel) in self.channels.items():
393 + self.message_channel(channel, "PART", channelname, True)
394 + self.channel_log(channel, "left", meta=True)
395 + server.remove_member_from_channel(self, channelname)
396 + self.channels = {}
397 + return
398 + channelnames = arguments[0].split(",")
399 + if len(arguments) > 1:
400 + keys = arguments[1].split(",")
401 + else:
402 + keys = []
403 + keys.extend((len(channelnames) - len(keys)) * [None])
404 + for (i, channelname) in enumerate(channelnames):
405 + if irc_lower(channelname) in self.channels:
406 + continue
407 + if not valid_channel_re.match(channelname):
408 + self.reply_403(channelname)
409 + continue
410 + channel = server.get_channel(channelname)
411 + if channel.key is not None and channel.key != keys[i]:
412 + self.reply(
413 + "475 %s %s :Cannot join channel (+k) - bad key"
414 + % (self.nickname, channelname))
415 + continue
416 + channel.add_member(self)
417 + self.channels[irc_lower(channelname)] = channel
418 + self.message_channel(channel, "JOIN", channelname, True)
419 + self.channel_log(channel, "joined", meta=True)
420 + if channel.topic:
421 + self.reply("332 %s %s :%s"
422 + % (self.nickname, channel.name, channel.topic))
423 + else:
424 + self.reply("331 %s %s :No topic is set"
425 + % (self.nickname, channel.name))
426 + self.reply("353 %s = %s :%s"
427 + % (self.nickname,
428 + channelname,
429 + " ".join(sorted(x.nickname
430 + for x in channel.members))))
431 + self.reply("366 %s %s :End of NAMES list"
432 + % (self.nickname, channelname))
433 +
434 + def list_handler():
435 + if len(arguments) < 1:
436 + channels = server.channels.values()
437 + else:
438 + channels = []
439 + for channelname in arguments[0].split(","):
440 + if server.has_channel(channelname):
441 + channels.append(server.get_channel(channelname))
442 + channels.sort(key=lambda x: x.name)
443 + for channel in channels:
444 + self.reply("322 %s %s %d :%s"
445 + % (self.nickname, channel.name,
446 + len(channel.members), channel.topic))
447 + self.reply("323 %s :End of LIST" % self.nickname)
448 +
449 + def lusers_handler():
450 + self.send_lusers()
451 +
452 + def mode_handler():
453 + if len(arguments) < 1:
454 + self.reply_461("MODE")
455 + return
456 + targetname = arguments[0]
457 + if server.has_channel(targetname):
458 + channel = server.get_channel(targetname)
459 + if len(arguments) < 2:
460 + if channel.key:
461 + modes = "+k"
462 + if irc_lower(channel.name) in self.channels:
463 + modes += " %s" % channel.key
464 + else:
465 + modes = "+"
466 + self.reply("324 %s %s %s"
467 + % (self.nickname, targetname, modes))
468 + return
469 + flag = arguments[1]
470 + if flag == "+k":
471 + if len(arguments) < 3:
472 + self.reply_461("MODE")
473 + return
474 + key = arguments[2]
475 + if irc_lower(channel.name) in self.channels:
476 + channel.key = key
477 + self.message_channel(
478 + channel, "MODE", "%s +k %s" % (channel.name, key),
479 + True)
480 + self.channel_log(
481 + channel, "set channel key to %s" % key, meta=True)
482 + else:
483 + self.reply("442 %s :You're not on that channel"
484 + % targetname)
485 + elif flag == "-k":
486 + if irc_lower(channel.name) in self.channels:
487 + channel.key = None
488 + self.message_channel(
489 + channel, "MODE", "%s -k" % channel.name,
490 + True)
491 + self.channel_log(
492 + channel, "removed channel key", meta=True)
493 + else:
494 + self.reply("442 %s :You're not on that channel"
495 + % targetname)
496 + else:
497 + self.reply("472 %s %s :Unknown MODE flag"
498 + % (self.nickname, flag))
499 + elif targetname == self.nickname:
500 + if len(arguments) == 1:
501 + self.reply("221 %s +" % self.nickname)
502 + else:
503 + self.reply("501 %s :Unknown MODE flag" % self.nickname)
504 + else:
505 + self.reply_403(targetname)
506 +
507 + def motd_handler():
508 + self.send_motd()
509 +
510 + def nick_handler():
511 + if len(arguments) < 1:
512 + self.reply("431 :No nickname given")
513 + return
514 + newnick = arguments[0]
515 + client = server.get_client(newnick)
516 + if newnick == self.nickname:
517 + pass
518 + elif client and client is not self:
519 + self.reply("433 %s %s :Nickname is already in use"
520 + % (self.nickname, newnick))
521 + elif not self.__valid_nickname_regexp.match(newnick):
522 + self.reply("432 %s %s :Erroneous Nickname"
523 + % (self.nickname, newnick))
524 + else:
525 + for x in self.channels.values():
526 + self.channel_log(
527 + x, "changed nickname to %s" % newnick, meta=True)
528 + oldnickname = self.nickname
529 + self.nickname = newnick
530 + server.client_changed_nickname(self, oldnickname)
531 + self.message_related(
532 + ":%s!%s@%s NICK %s"
533 + % (oldnickname, self.user, self.host, self.nickname),
534 + True)
535 +
536 + def notice_and_privmsg_handler():
537 + if len(arguments) == 0:
538 + self.reply("411 %s :No recipient given (%s)"
539 + % (self.nickname, command))
540 + return
541 + if len(arguments) == 1:
542 + self.reply("412 %s :No text to send" % self.nickname)
543 + return
544 + targetname = arguments[0]
545 + message = arguments[1]
546 + client = server.get_client(targetname)
547 +
548 + if client:
549 + client.message(":%s %s %s :%s"
550 + % (self.prefix, command, targetname, message))
551 + elif server.has_channel(targetname):
552 + channel = server.get_channel(targetname)
553 + self.message_channel(
554 + channel, command, "%s :%s" % (channel.name, message))
555 + self.channel_log(channel, message)
556 + else:
557 + self.reply("401 %s %s :No such nick/channel"
558 + % (self.nickname, targetname))
559 +
560 + def part_handler():
561 + if len(arguments) < 1:
562 + self.reply_461("PART")
563 + return
564 + if len(arguments) > 1:
565 + partmsg = arguments[1]
566 + else:
567 + partmsg = self.nickname
568 + for channelname in arguments[0].split(","):
569 + if not valid_channel_re.match(channelname):
570 + self.reply_403(channelname)
571 + elif not irc_lower(channelname) in self.channels:
572 + self.reply("442 %s %s :You're not on that channel"
573 + % (self.nickname, channelname))
574 + else:
575 + channel = self.channels[irc_lower(channelname)]
576 + self.message_channel(
577 + channel, "PART", "%s :%s" % (channelname, partmsg),
578 + True)
579 + self.channel_log(channel, "left (%s)" % partmsg, meta=True)
580 + del self.channels[irc_lower(channelname)]
581 + server.remove_member_from_channel(self, channelname)
582 +
583 + def ping_handler():
584 + if len(arguments) < 1:
585 + self.reply("409 %s :No origin specified" % self.nickname)
586 + return
587 + self.reply("PONG %s :%s" % (server.name, arguments[0]))
588 +
589 + def pong_handler():
590 + pass
591 +
592 + def quit_handler():
593 + if len(arguments) < 1:
594 + quitmsg = self.nickname
595 + else:
596 + quitmsg = arguments[0]
597 + self.disconnect(quitmsg)
598 +
599 + def topic_handler():
600 + if len(arguments) < 1:
601 + self.reply_461("TOPIC")
602 + return
603 + channelname = arguments[0]
604 + channel = self.channels.get(irc_lower(channelname))
605 + if channel:
606 + if len(arguments) > 1:
607 + newtopic = arguments[1]
608 + channel.topic = newtopic
609 + self.message_channel(
610 + channel, "TOPIC", "%s :%s" % (channelname, newtopic),
611 + True)
612 + self.channel_log(
613 + channel, "set topic to %r" % newtopic, meta=True)
614 + else:
615 + if channel.topic:
616 + self.reply("332 %s %s :%s"
617 + % (self.nickname, channel.name,
618 + channel.topic))
619 + else:
620 + self.reply("331 %s %s :No topic is set"
621 + % (self.nickname, channel.name))
622 + else:
623 + self.reply("442 %s :You're not on that channel" % channelname)
624 +
625 + def wallops_handler():
626 + if len(arguments) < 1:
627 + self.reply_461(command)
628 + message = arguments[0]
629 + for client in server.clients.values():
630 + client.message(":%s NOTICE %s :Global notice: %s"
631 + % (self.prefix, client.nickname, message))
632 +
633 + def who_handler():
634 + if len(arguments) < 1:
635 + return
636 + targetname = arguments[0]
637 + if server.has_channel(targetname):
638 + channel = server.get_channel(targetname)
639 + for member in channel.members:
640 + self.reply("352 %s %s %s %s %s %s H :0 %s"
641 + % (self.nickname, targetname, member.user,
642 + member.host, server.name, member.nickname,
643 + member.realname))
644 + self.reply("315 %s %s :End of WHO list"
645 + % (self.nickname, targetname))
646 +
647 + def whois_handler():
648 + if len(arguments) < 1:
649 + return
650 + username = arguments[0]
651 + user = server.get_client(username)
652 + if user:
653 + self.reply("311 %s %s %s %s * :%s"
654 + % (self.nickname, user.nickname, user.user,
655 + user.host, user.realname))
656 + self.reply("312 %s %s %s :%s"
657 + % (self.nickname, user.nickname, server.name,
658 + server.name))
659 + self.reply("319 %s %s :%s"
660 + % (self.nickname, user.nickname,
661 + " ".join(user.channels)))
662 + self.reply("318 %s %s :End of WHOIS list"
663 + % (self.nickname, user.nickname))
664 + else:
665 + self.reply("401 %s %s :No such nick"
666 + % (self.nickname, username))
667 +
668 + handler_table = {
669 + "AWAY": away_handler,
670 + "ISON": ison_handler,
671 + "JOIN": join_handler,
672 + "LIST": list_handler,
673 + "LUSERS": lusers_handler,
674 + "MODE": mode_handler,
675 + "MOTD": motd_handler,
676 + "NICK": nick_handler,
677 + "NOTICE": notice_and_privmsg_handler,
678 + "PART": part_handler,
679 + "PING": ping_handler,
680 + "PONG": pong_handler,
681 + "PRIVMSG": notice_and_privmsg_handler,
682 + "QUIT": quit_handler,
683 + "TOPIC": topic_handler,
684 + "WALLOPS": wallops_handler,
685 + "WHO": who_handler,
686 + "WHOIS": whois_handler,
687 + }
688 + server = self.server
689 + valid_channel_re = self.__valid_channelname_regexp
690 + try:
691 + handler_table[command]()
692 + except KeyError:
693 + self.reply("421 %s %s :Unknown command" % (self.nickname, command))
694 +
695 + def udp_data_received(self, data):
696 + if data:
697 + message = self.infosec.unpack(data)
698 + if(message != None):
699 + self.message(message)
700 +
701 + def socket_readable_notification(self):
702 + try:
703 + data = self.socket.recv(2 ** 10)
704 + self.server.print_debug(
705 + "[%s:%d] -> %r" % (self.host, self.port, data))
706 + quitmsg = "EOT"
707 + except socket.error as x:
708 + data = ""
709 + quitmsg = x
710 + if data:
711 + self.__readbuffer += data
712 + self.__parse_read_buffer()
713 + self.__timestamp = time.time()
714 + self.__sent_ping = False
715 + for peer in self.server.peers:
716 + peer.send(self, data)
717 + else:
718 + self.disconnect(quitmsg)
719 +
720 + def socket_writable_notification(self):
721 + try:
722 + print("socket_writable_notification: %s" % self.__writebuffer)
723 + sent = self.socket.send(self.__writebuffer)
724 + self.server.print_debug(
725 + "[%s:%d] <- %r" % (
726 + self.host, self.port, self.__writebuffer[:sent]))
727 + self.__writebuffer = self.__writebuffer[sent:]
728 + except socket.error as x:
729 + self.disconnect(x)
730 +
731 + def disconnect(self, quitmsg):
732 + self.message("ERROR :%s" % quitmsg)
733 + self.server.print_info(
734 + "Disconnected connection from %s:%s (%s)." % (
735 + self.host, self.port, quitmsg))
736 + self.socket.close()
737 + self.server.remove_client(self, quitmsg)
738 +
739 + def message(self, msg):
740 + self.__writebuffer += msg + "\r\n"
741 +
742 + def reply(self, msg):
743 + self.message(":%s %s" % (self.server.name, msg))
744 +
745 + def reply_403(self, channel):
746 + self.reply("403 %s %s :No such channel" % (self.nickname, channel))
747 +
748 + def reply_461(self, command):
749 + nickname = self.nickname or "*"
750 + self.reply("461 %s %s :Not enough parameters" % (nickname, command))
751 +
752 + def message_channel(self, channel, command, message, include_self=False):
753 + line = ":%s %s %s" % (self.prefix, command, message)
754 + for client in channel.members:
755 + if client != self or include_self:
756 + client.message(line)
757 +
758 + def channel_log(self, channel, message, meta=False):
759 + if not self.server.logdir:
760 + return
761 + if meta:
762 + format = "[%s] * %s %s\n"
763 + else:
764 + format = "[%s] <%s> %s\n"
765 + timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
766 + logname = channel.name.replace("_", "__").replace("/", "_")
767 + fp = open("%s/%s.log" % (self.server.logdir, logname), "a")
768 + fp.write(format % (timestamp, self.nickname, message))
769 + fp.close()
770 +
771 + def message_related(self, msg, include_self=False):
772 + clients = set()
773 + if include_self:
774 + clients.add(self)
775 + for channel in self.channels.values():
776 + clients |= channel.members
777 + if not include_self:
778 + clients.discard(self)
779 + for client in clients:
780 + client.message(msg)
781 +
782 + def send_lusers(self):
783 + self.reply("251 %s :There are %d users and 0 services on 1 server"
784 + % (self.nickname, len(self.server.clients)))
785 +
786 + def send_motd(self):
787 + server = self.server
788 + motdlines = server.get_motd_lines()
789 + if motdlines:
790 + self.reply("375 %s :- %s Message of the day -"
791 + % (self.nickname, server.name))
792 + for line in motdlines:
793 + self.reply("372 %s :- %s" % (self.nickname, line.rstrip()))
794 + self.reply("376 %s :End of /MOTD command" % self.nickname)
795 + else:
796 + self.reply("422 %s :MOTD File is missing" % self.nickname)
797 diff -uNr a/lib/funcs.py b/lib/funcs.py
798 --- a/lib/funcs.py false
799 +++ b/lib/funcs.py 26f103d62d35c8d8e94a6537afea06a33b8cf920ebedbf6b2a38fafb9f3be31379386194dc70c0a2e87b01f6725b43d3ad182037f6cd1d9db1b8f36cf8483542
800 @@ -0,0 +1,11 @@
801 +import sys
802 +import string
803 +
804 +_maketrans = str.maketrans if sys.version_info[0] == 3 else string.maketrans
805 +_ircstring_translation = _maketrans(
806 + string.ascii_lowercase.upper() + "[]\\^",
807 + string.ascii_lowercase + "{}|~")
808 +
809 +def irc_lower(s):
810 + return string.translate(s, _ircstring_translation)
811 +
812 diff -uNr a/lib/infosec.py b/lib/infosec.py
813 --- a/lib/infosec.py false
814 +++ b/lib/infosec.py b2338b69782d666bc263dbd1ba50b7f29915bef679807b13ed7539d7f2dbcbde1e99871675864a1407b2485492931cfdbc9d3871f35d3df10e9d63a6fbf3bea7
815 @@ -0,0 +1,29 @@
816 +import hashlib
817 +PACKET_SIZE = 1024
818 +MAX_MESSAGE_SIZE = 512
819 +
820 +class Infosec(object):
821 + #def __init__(self):
822 + # do nothing
823 +
824 + def pack(self, message):
825 + digest = hashlib.sha512(self._pad(message)).hexdigest()
826 + return digest + message
827 +
828 + def unpack(self, package):
829 + print("received package: %s" % package)
830 + received_digest = package[0:128]
831 + message = package[128:1023]
832 + digest = hashlib.sha512(self._pad(message)).hexdigest()
833 + print("received_digest: %s" % received_digest)
834 + print("digest: %s" % digest)
835 + print("message: %s") % message
836 + if(received_digest == digest):
837 + return message
838 + else:
839 + print("unable to validate package: %s" % package)
840 + return None
841 +
842 + def _pad(self, text):
843 + return str(text.ljust(MAX_MESSAGE_SIZE)).encode("ascii")
844 +
845 diff -uNr a/lib/peer.py b/lib/peer.py
846 --- a/lib/peer.py false
847 +++ b/lib/peer.py b9b742b95b484d92cbdbdab9923f48f8f48b05ec081d4f3536344d635ca2e7c204a6b87d497b41ddb8f0066138e8b29ad311860023d4215e23b885cb8354342c
848 @@ -0,0 +1,13 @@
849 +import socket
850 +from infosec import Infosec
851 +
852 +class Peer(object):
853 + def __init__(self, address):
854 + self.address = address
855 + self.socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
856 + self.infosec = Infosec()
857 +
858 + def send(self, client, msg):
859 + full_message = str.encode(":%s %s" % (client.nickname, msg))
860 + print("sending formatted_msg: %s" % full_message)
861 + self.socket.sendto(self.infosec.pack(full_message), (self.address, 7778))
862 diff -uNr a/lib/server.py b/lib/server.py
863 --- a/lib/server.py false
864 +++ b/lib/server.py 8360fe2f038001fe1f2a7aa8f5724b79164d7ddfd814ecc1f9ebfb9c0038e47ac474d4e489e089a66d945fb70fa2a9c237c5bd29801a035d3bb7e86bfbaa5a65
865 @@ -0,0 +1,208 @@
866 +VERSION = "9999"
867 +
868 +import os
869 +import select
870 +import socket
871 +import sys
872 +import sys
873 +import tempfile
874 +import time
875 +import string
876 +from datetime import datetime
877 +from lib.client import Client
878 +from lib.channel import Channel
879 +from lib.infosec import PACKET_SIZE
880 +from lib.infosec import Infosec
881 +from lib.peer import Peer
882 +from funcs import *
883 +
884 +class Server(object):
885 + def __init__(self, options):
886 + self.ports = options.ports
887 + self.peers = options.peers
888 + self.udp_port = options.udp_port
889 + self.password = options.password
890 + self.ssl_pem_file = options.ssl_pem_file
891 + self.motdfile = options.motd
892 + self.verbose = options.verbose
893 + self.debug = options.debug
894 + self.logdir = options.logdir
895 + self.chroot = options.chroot
896 + self.setuid = options.setuid
897 + self.statedir = options.statedir
898 +
899 + if options.listen:
900 + self.address = socket.gethostbyname(options.listen)
901 + else:
902 + self.address = ""
903 + server_name_limit = 63 # From the RFC.
904 + self.name = socket.getfqdn(self.address)[:server_name_limit]
905 +
906 + self.channels = {} # irc_lower(Channel name) --> Channel instance.
907 + self.clients = {} # Socket --> Client instance..peers = ""
908 + self.nicknames = {} # irc_lower(Nickname) --> Client instance.
909 + if self.logdir:
910 + create_directory(self.logdir)
911 + if self.statedir:
912 + create_directory(self.statedir)
913 +
914 + def daemonize(self):
915 + try:
916 + pid = os.fork()
917 + if pid > 0:
918 + sys.exit(0)
919 + except OSError:
920 + sys.exit(1)
921 + os.setsid()
922 + try:
923 + pid = os.fork()
924 + if pid > 0:
925 + self.print_info("PID: %d" % pid)
926 + sys.exit(0)
927 + except OSError:
928 + sys.exit(1)
929 + os.chdir("/")
930 + os.umask(0)
931 + dev_null = open("/dev/null", "r+")
932 + os.dup2(dev_null.fileno(), sys.stdout.fileno())
933 + os.dup2(dev_null.fileno(), sys.stderr.fileno())
934 + os.dup2(dev_null.fileno(), sys.stdin.fileno())
935 +
936 + def get_client(self, nickname):
937 + return self.nicknames.get(irc_lower(nickname))
938 +
939 + def has_channel(self, name):
940 + return irc_lower(name) in self.channels
941 +
942 + def get_channel(self, channelname):
943 + if irc_lower(channelname) in self.channels:
944 + channel = self.channels[irc_lower(channelname)]
945 + else:
946 + channel = Channel(self, channelname)
947 + self.channels[irc_lower(channelname)] = channel
948 + return channel
949 +
950 + def get_motd_lines(self):
951 + if self.motdfile:
952 + try:
953 + return open(self.motdfile).readlines()
954 + except IOError:
955 + return ["Could not read MOTD file %r." % self.motdfile]
956 + else:
957 + return []
958 +
959 + def print_info(self, msg):
960 + if self.verbose:
961 + print(msg)
962 + sys.stdout.flush()
963 +
964 + def print_debug(self, msg):
965 + if self.debug:
966 + print(msg)
967 + sys.stdout.flush()
968 +
969 + def print_error(self, msg):
970 + sys.stderr.write("%s\n" % msg)
971 +
972 + def client_changed_nickname(self, client, oldnickname):
973 + if oldnickname:
974 + del self.nicknames[irc_lower(oldnickname)]
975 + self.nicknames[irc_lower(client.nickname)] = client
976 +
977 + def remove_member_from_channel(self, client, channelname):
978 + if irc_lower(channelname) in self.channels:
979 + channel = self.channels[irc_lower(channelname)]
980 + channel.remove_client(client)
981 +
982 + def remove_client(self, client, quitmsg):
983 + client.message_related(":%s QUIT :%s" % (client.prefix, quitmsg))
984 + for x in client.channels.values():
985 + client.channel_log(x, "quit (%s)" % quitmsg, meta=True)
986 + x.remove_client(client)
987 + if client.nickname \
988 + and irc_lower(client.nickname) in self.nicknames:
989 + del self.nicknames[irc_lower(client.nickname)]
990 + del self.clients[client.socket]
991 +
992 + def remove_channel(self, channel):
993 + del self.channels[irc_lower(channel.name)]
994 +
995 + def start(self):
996 + # Setup UDP first
997 + udp_server_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
998 + udp_server_socket.bind((self.address, self.udp_port))
999 +
1000 + serversockets = []
1001 + for port in self.ports:
1002 + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1003 + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1004 + try:
1005 + s.bind((self.address, port))
1006 + except socket.error as e:
1007 + self.print_error("Could not bind port %s: %s." % (port, e))
1008 + sys.exit(1)
1009 + s.listen(5)
1010 + serversockets.append(s)
1011 + del s
1012 + self.print_info("Listening on port %d." % port)
1013 + if self.chroot:
1014 + os.chdir(self.chroot)
1015 + os.chroot(self.chroot)
1016 + self.print_info("Changed root directory to %s" % self.chroot)
1017 + if self.setuid:
1018 + os.setgid(self.setuid[1])
1019 + os.setuid(self.setuid[0])
1020 + self.print_info("Setting uid:gid to %s:%s"
1021 + % (self.setuid[0], self.setuid[1]))
1022 + last_aliveness_check = time.time()
1023 + while True:
1024 + (inputready,outputready,exceptready) = select.select([udp_server_socket],[],[],0)
1025 + (iwtd, owtd, ewtd) = select.select(
1026 + serversockets + [x.socket for x in self.clients.values()],
1027 + [x.socket for x in self.clients.values()
1028 + if x.write_queue_size() > 0],
1029 + [],
1030 + 0)
1031 + for x in inputready:
1032 + if x == udp_server_socket:
1033 + bytes_address_pair = udp_server_socket.recvfrom(PACKET_SIZE)
1034 + message = bytes_address_pair[0]
1035 + address = bytes_address_pair[1]
1036 + print message
1037 + for c in self.clients:
1038 + self.clients[c].udp_data_received(message)
1039 + for x in iwtd:
1040 + if x in self.clients:
1041 + self.clients[x].socket_readable_notification()
1042 + else:
1043 + (conn, addr) = x.accept()
1044 + if self.ssl_pem_file:
1045 + import ssl
1046 + try:
1047 + conn = ssl.wrap_socket(
1048 + conn,
1049 + server_side=True,
1050 + certfile=self.ssl_pem_file,
1051 + keyfile=self.ssl_pem_file)
1052 + except ssl.SSLError as e:
1053 + self.print_error(
1054 + "SSL error for connection from %s:%s: %s" % (
1055 + addr[0], addr[1], e))
1056 + continue
1057 + self.clients[conn] = Client(self, conn)
1058 + self.print_info("Accepted connection from %s:%s." % (
1059 + addr[0], addr[1]))
1060 + for x in owtd:
1061 + if x in self.clients: # client may have been disconnected
1062 + self.clients[x].socket_writable_notification()
1063 + now = time.time()
1064 + if last_aliveness_check + 10 < now:
1065 + for client in self.clients.values():
1066 + client.check_aliveness()
1067 + last_aliveness_check = now
1068 +
1069 +
1070 +def create_directory(path):
1071 + if not os.path.isdir(path):
1072 + os.makedirs(path)
1073 +
1074

Prototype UDP based IRC Emulator

July 28th, 2021

I have hacked up a very simple IRC server written in python that broadcasts messages via UDP to a list of other servers of the same type.
The original IRC server is from this Shithub. I made this mostly just to experiment with UDP and learn something about the IRC protocol.

At the moment all it will do is share PRIVMSGs between servers. It doesn't handle pretty much anything else, such as for example JOIN/PART messages.

1 #! /usr/bin/env python
2
3 VERSION = "1.1"
4
5 import os
6 import re
7 import select
8 import socket
9 import string
10 import sys
11 import tempfile
12 import time
13 from datetime import datetime
14 from optparse import OptionParser
15
16
17 def create_directory(path):
18 if not os.path.isdir(path):
19 os.makedirs(path)
20
21
22 class Channel(object):
23 def __init__(self, server, name):
24 self.server = server
25 self.name = name
26 self.members = set()
27 self._topic = ""
28 self._key = None
29 if self.server.statedir:
30 self._state_path = "%s/%s" % (
31 self.server.statedir,
32 name.replace("_", "__").replace("/", "_"))
33 self._read_state()
34 else:
35 self._state_path = None
36
37 def add_member(self, client):
38 self.members.add(client)
39
40 def get_topic(self):
41 return self._topic
42
43 def set_topic(self, value):
44 self._topic = value
45 self._write_state()
46
47 topic = property(get_topic, set_topic)
48
49 def get_key(self):
50 return self._key
51
52 def set_key(self, value):
53 self._key = value
54 self._write_state()
55
56 key = property(get_key, set_key)
57
58 def remove_client(self, client):
59 self.members.discard(client)
60 if not self.members:
61 self.server.remove_channel(self)
62
63 def _read_state(self):
64 if not (self._state_path and os.path.exists(self._state_path)):
65 return
66 data = {}
67 exec(open(self._state_path), {}, data)
68 self._topic = data.get("topic", "")
69 self._key = data.get("key")
70
71 def _write_state(self):
72 if not self._state_path:
73 return
74 (fd, path) = tempfile.mkstemp(dir=os.path.dirname(self._state_path))
75 fp = os.fdopen(fd, "w")
76 fp.write("topic = %r\n" % self.topic)
77 fp.write("key = %r\n" % self.key)
78 fp.close()
79 os.rename(path, self._state_path)
80
81
82 class Client(object):
83 __linesep_regexp = re.compile(r"\r?\n")
84 # The RFC limit for nicknames is 9 characters, but what the heck.
85 __valid_nickname_regexp = re.compile(
86 r"^[][\`_^{|}A-Za-z][][\`_^{|}A-Za-z0-9-]{0,50}$")
87 __valid_channelname_regexp = re.compile(
88 r"^[&#+!][^\x00\x07\x0a\x0d ,:]{0,50}$")
89
90 def __init__(self, server, socket):
91 self.server = server
92 self.socket = socket
93 self.channels = {} # irc_lower(Channel name) --> Channel
94 self.nickname = None
95 self.user = None
96 self.realname = None
97 (self.host, self.port) = socket.getpeername()
98 self.__timestamp = time.time()
99 self.__readbuffer = ""
100 self.__writebuffer = ""
101 self.__sent_ping = False
102 if self.server.password:
103 self.__handle_command = self.__pass_handler
104 else:
105 self.__handle_command = self.__registration_handler
106
107 def get_prefix(self):
108 return "%s!%s@%s" % (self.nickname, self.user, self.host)
109 prefix = property(get_prefix)
110
111 def check_aliveness(self):
112 now = time.time()
113 if self.__timestamp + 180 < now:
114 self.disconnect("ping timeout")
115 return
116 if not self.__sent_ping and self.__timestamp + 90 < now:
117 if self.__handle_command == self.__command_handler:
118 # Registered.
119 self.message("PING :%s" % self.server.name)
120 self.__sent_ping = True
121 else:
122 # Not registered.
123 self.disconnect("ping timeout")
124
125 def write_queue_size(self):
126 return len(self.__writebuffer)
127
128 def __parse_read_buffer(self):
129 lines = self.__linesep_regexp.split(self.__readbuffer)
130 self.__readbuffer = lines[-1]
131 lines = lines[:-1]
132 for line in lines:
133 if not line:
134 # Empty line. Ignore.
135 continue
136 x = line.split(" ", 1)
137 command = x[0].upper()
138 if len(x) == 1:
139 arguments = []
140 else:
141 if len(x[1]) > 0 and x[1][0] == ":":
142 arguments = \[x\[1][1:\]\]
143 else:
144 y = string.split(x[1], " :", 1)
145 arguments = string.split(y[0])
146 if len(y) == 2:
147 arguments.append(y[1])
148 self.__handle_command(command, arguments)
149
150 def __pass_handler(self, command, arguments):
151 server = self.server
152 if command == "PASS":
153 if len(arguments) == 0:
154 self.reply_461("PASS")
155 else:
156 if arguments[0].lower() == server.password:
157 self.__handle_command = self.__registration_handler
158 else:
159 self.reply("464 :Password incorrect")
160 elif command == "QUIT":
161 self.disconnect("Client quit")
162 return
163
164 def __registration_handler(self, command, arguments):
165 server = self.server
166 if command == "NICK":
167 if len(arguments) < 1:
168 self.reply("431 :No nickname given")
169 return
170 nick = arguments[0]
171 if server.get_client(nick):
172 self.reply("433 * %s :Nickname is already in use" % nick)
173 elif not self.__valid_nickname_regexp.match(nick):
174 self.reply("432 * %s :Erroneous nickname" % nick)
175 else:
176 self.nickname = nick
177 server.client_changed_nickname(self, None)
178 elif command == "USER":
179 if len(arguments) < 4:
180 self.reply_461("USER")
181 return
182 self.user = arguments[0]
183 self.realname = arguments[3]
184 elif command == "QUIT":
185 self.disconnect("Client quit")
186 return
187 if self.nickname and self.user:
188 self.reply("001 %s :Hi, welcome to IRC" % self.nickname)
189 self.reply("002 %s :Your host is %s, running version miniircd-%s"
190 % (self.nickname, server.name, VERSION))
191 self.reply("003 %s :This server was created sometime"
192 % self.nickname)
193 self.reply("004 %s :%s miniircd-%s o o"
194 % (self.nickname, server.name, VERSION))
195 self.send_lusers()
196 self.send_motd()
197 self.__handle_command = self.__command_handler
198
199 def __command_handler(self, command, arguments):
200 def away_handler():
201 pass
202
203 def ison_handler():
204 if len(arguments) < 1:
205 self.reply_461("ISON")
206 return
207 nicks = arguments
208 online = [n for n in nicks if server.get_client(n)]
209 self.reply("303 %s :%s" % (self.nickname, " ".join(online)))
210
211 def join_handler():
212 if len(arguments) < 1:
213 self.reply_461("JOIN")
214 return
215 if arguments[0] == "0":
216 for (channelname, channel) in self.channels.items():
217 self.message_channel(channel, "PART", channelname, True)
218 self.channel_log(channel, "left", meta=True)
219 server.remove_member_from_channel(self, channelname)
220 self.channels = {}
221 return
222 channelnames = arguments[0].split(",")
223 if len(arguments) > 1:
224 keys = arguments[1].split(",")
225 else:
226 keys = []
227 keys.extend((len(channelnames) - len(keys)) * [None])
228 for (i, channelname) in enumerate(channelnames):
229 if irc_lower(channelname) in self.channels:
230 continue
231 if not valid_channel_re.match(channelname):
232 self.reply_403(channelname)
233 continue
234 channel = server.get_channel(channelname)
235 if channel.key is not None and channel.key != keys[i]:
236 self.reply(
237 "475 %s %s :Cannot join channel (+k) - bad key"
238 % (self.nickname, channelname))
239 continue
240 channel.add_member(self)
241 self.channels[irc_lower(channelname)] = channel
242 self.message_channel(channel, "JOIN", channelname, True)
243 self.channel_log(channel, "joined", meta=True)
244 if channel.topic:
245 self.reply("332 %s %s :%s"
246 % (self.nickname, channel.name, channel.topic))
247 else:
248 self.reply("331 %s %s :No topic is set"
249 % (self.nickname, channel.name))
250 self.reply("353 %s = %s :%s"
251 % (self.nickname,
252 channelname,
253 " ".join(sorted(x.nickname
254 for x in channel.members))))
255 self.reply("366 %s %s :End of NAMES list"
256 % (self.nickname, channelname))
257
258 def list_handler():
259 if len(arguments) < 1:
260 channels = server.channels.values()
261 else:
262 channels = []
263 for channelname in arguments[0].split(","):
264 if server.has_channel(channelname):
265 channels.append(server.get_channel(channelname))
266 channels.sort(key=lambda x: x.name)
267 for channel in channels:
268 self.reply("322 %s %s %d :%s"
269 % (self.nickname, channel.name,
270 len(channel.members), channel.topic))
271 self.reply("323 %s :End of LIST" % self.nickname)
272
273 def lusers_handler():
274 self.send_lusers()
275
276 def mode_handler():
277 if len(arguments) < 1:
278 self.reply_461("MODE")
279 return
280 targetname = arguments[0]
281 if server.has_channel(targetname):
282 channel = server.get_channel(targetname)
283 if len(arguments) < 2:
284 if channel.key:
285 modes = "+k"
286 if irc_lower(channel.name) in self.channels:
287 modes += " %s" % channel.key
288 else:
289 modes = "+"
290 self.reply("324 %s %s %s"
291 % (self.nickname, targetname, modes))
292 return
293 flag = arguments[1]
294 if flag == "+k":
295 if len(arguments) < 3:
296 self.reply_461("MODE")
297 return
298 key = arguments[2]
299 if irc_lower(channel.name) in self.channels:
300 channel.key = key
301 self.message_channel(
302 channel, "MODE", "%s +k %s" % (channel.name, key),
303 True)
304 self.channel_log(
305 channel, "set channel key to %s" % key, meta=True)
306 else:
307 self.reply("442 %s :You're not on that channel"
308 % targetname)
309 elif flag == "-k":
310 if irc_lower(channel.name) in self.channels:
311 channel.key = None
312 self.message_channel(
313 channel, "MODE", "%s -k" % channel.name,
314 True)
315 self.channel_log(
316 channel, "removed channel key", meta=True)
317 else:
318 self.reply("442 %s :You're not on that channel"
319 % targetname)
320 else:
321 self.reply("472 %s %s :Unknown MODE flag"
322 % (self.nickname, flag))
323 elif targetname == self.nickname:
324 if len(arguments) == 1:
325 self.reply("221 %s +" % self.nickname)
326 else:
327 self.reply("501 %s :Unknown MODE flag" % self.nickname)
328 else:
329 self.reply_403(targetname)
330
331 def motd_handler():
332 self.send_motd()
333
334 def nick_handler():
335 if len(arguments) < 1:
336 self.reply("431 :No nickname given")
337 return
338 newnick = arguments[0]
339 client = server.get_client(newnick)
340 if newnick == self.nickname:
341 pass
342 elif client and client is not self:
343 self.reply("433 %s %s :Nickname is already in use"
344 % (self.nickname, newnick))
345 elif not self.__valid_nickname_regexp.match(newnick):
346 self.reply("432 %s %s :Erroneous Nickname"
347 % (self.nickname, newnick))
348 else:
349 for x in self.channels.values():
350 self.channel_log(
351 x, "changed nickname to %s" % newnick, meta=True)
352 oldnickname = self.nickname
353 self.nickname = newnick
354 server.client_changed_nickname(self, oldnickname)
355 self.message_related(
356 ":%s!%s@%s NICK %s"
357 % (oldnickname, self.user, self.host, self.nickname),
358 True)
359
360 def notice_and_privmsg_handler():
361 if len(arguments) == 0:
362 self.reply("411 %s :No recipient given (%s)"
363 % (self.nickname, command))
364 return
365 if len(arguments) == 1:
366 self.reply("412 %s :No text to send" % self.nickname)
367 return
368 targetname = arguments[0]
369 message = arguments[1]
370 client = server.get_client(targetname)
371
372 if client:
373 client.message(":%s %s %s :%s"
374 % (self.prefix, command, targetname, message))
375 elif server.has_channel(targetname):
376 channel = server.get_channel(targetname)
377 self.message_channel(
378 channel, command, "%s :%s" % (channel.name, message))
379 self.channel_log(channel, message)
380 else:
381 self.reply("401 %s %s :No such nick/channel"
382 % (self.nickname, targetname))
383
384 def part_handler():
385 if len(arguments) < 1:
386 self.reply_461("PART")
387 return
388 if len(arguments) > 1:
389 partmsg = arguments[1]
390 else:
391 partmsg = self.nickname
392 for channelname in arguments[0].split(","):
393 if not valid_channel_re.match(channelname):
394 self.reply_403(channelname)
395 elif not irc_lower(channelname) in self.channels:
396 self.reply("442 %s %s :You're not on that channel"
397 % (self.nickname, channelname))
398 else:
399 channel = self.channels[irc_lower(channelname)]
400 self.message_channel(
401 channel, "PART", "%s :%s" % (channelname, partmsg),
402 True)
403 self.channel_log(channel, "left (%s)" % partmsg, meta=True)
404 del self.channels[irc_lower(channelname)]
405 server.remove_member_from_channel(self, channelname)
406
407 def ping_handler():
408 if len(arguments) < 1:
409 self.reply("409 %s :No origin specified" % self.nickname)
410 return
411 self.reply("PONG %s :%s" % (server.name, arguments[0]))
412
413 def pong_handler():
414 pass
415
416 def quit_handler():
417 if len(arguments) < 1:
418 quitmsg = self.nickname
419 else:
420 quitmsg = arguments[0]
421 self.disconnect(quitmsg)
422
423 def topic_handler():
424 if len(arguments) < 1:
425 self.reply_461("TOPIC")
426 return
427 channelname = arguments[0]
428 channel = self.channels.get(irc_lower(channelname))
429 if channel:
430 if len(arguments) > 1:
431 newtopic = arguments[1]
432 channel.topic = newtopic
433 self.message_channel(
434 channel, "TOPIC", "%s :%s" % (channelname, newtopic),
435 True)
436 self.channel_log(
437 channel, "set topic to %r" % newtopic, meta=True)
438 else:
439 if channel.topic:
440 self.reply("332 %s %s :%s"
441 % (self.nickname, channel.name,
442 channel.topic))
443 else:
444 self.reply("331 %s %s :No topic is set"
445 % (self.nickname, channel.name))
446 else:
447 self.reply("442 %s :You're not on that channel" % channelname)
448
449 def wallops_handler():
450 if len(arguments) < 1:
451 self.reply_461(command)
452 message = arguments[0]
453 for client in server.clients.values():
454 client.message(":%s NOTICE %s :Global notice: %s"
455 % (self.prefix, client.nickname, message))
456
457 def who_handler():
458 if len(arguments) < 1:
459 return
460 targetname = arguments[0]
461 if server.has_channel(targetname):
462 channel = server.get_channel(targetname)
463 for member in channel.members:
464 self.reply("352 %s %s %s %s %s %s H :0 %s"
465 % (self.nickname, targetname, member.user,
466 member.host, server.name, member.nickname,
467 member.realname))
468 self.reply("315 %s %s :End of WHO list"
469 % (self.nickname, targetname))
470
471 def whois_handler():
472 if len(arguments) < 1:
473 return
474 username = arguments[0]
475 user = server.get_client(username)
476 if user:
477 self.reply("311 %s %s %s %s * :%s"
478 % (self.nickname, user.nickname, user.user,
479 user.host, user.realname))
480 self.reply("312 %s %s %s :%s"
481 % (self.nickname, user.nickname, server.name,
482 server.name))
483 self.reply("319 %s %s :%s"
484 % (self.nickname, user.nickname,
485 " ".join(user.channels)))
486 self.reply("318 %s %s :End of WHOIS list"
487 % (self.nickname, user.nickname))
488 else:
489 self.reply("401 %s %s :No such nick"
490 % (self.nickname, username))
491
492 handler_table = {
493 "AWAY": away_handler,
494 "ISON": ison_handler,
495 "JOIN": join_handler,
496 "LIST": list_handler,
497 "LUSERS": lusers_handler,
498 "MODE": mode_handler,
499 "MOTD": motd_handler,
500 "NICK": nick_handler,
501 "NOTICE": notice_and_privmsg_handler,
502 "PART": part_handler,
503 "PING": ping_handler,
504 "PONG": pong_handler,
505 "PRIVMSG": notice_and_privmsg_handler,
506 "QUIT": quit_handler,
507 "TOPIC": topic_handler,
508 "WALLOPS": wallops_handler,
509 "WHO": who_handler,
510 "WHOIS": whois_handler,
511 }
512 server = self.server
513 valid_channel_re = self.__valid_channelname_regexp
514 try:
515 handler_table[command]()
516 except KeyError:
517 self.reply("421 %s %s :Unknown command" % (self.nickname, command))
518
519 def udp_data_received(self, data):
520 if data:
521 self.message(data)
522
523 def socket_readable_notification(self):
524 try:
525 data = self.socket.recv(2 ** 10)
526 self.server.print_debug(
527 "[%s:%d] -> %r" % (self.host, self.port, data))
528 quitmsg = "EOT"
529 except socket.error as x:
530 data = ""
531 quitmsg = x
532 if data:
533 self.__readbuffer += data
534 self.__parse_read_buffer()
535 self.__timestamp = time.time()
536 self.__sent_ping = False
537 for peer in self.server.peers:
538 peer.send(self, data)
539 else:
540 self.disconnect(quitmsg)
541
542 def socket_writable_notification(self):
543 try:
544 print("socket_writable_notification: %s" % self.__writebuffer)
545 sent = self.socket.send(self.__writebuffer)
546 self.server.print_debug(
547 "[%s:%d] <- %r" % (
548 self.host, self.port, self.__writebuffer[:sent]))
549 self.__writebuffer = self.__writebuffer[sent:]
550 except socket.error as x:
551 self.disconnect(x)
552
553 def disconnect(self, quitmsg):
554 self.message("ERROR :%s" % quitmsg)
555 self.server.print_info(
556 "Disconnected connection from %s:%s (%s)." % (
557 self.host, self.port, quitmsg))
558 self.socket.close()
559 self.server.remove_client(self, quitmsg)
560
561 def message(self, msg):
562 self.__writebuffer += msg + "\r\n"
563
564 def reply(self, msg):
565 self.message(":%s %s" % (self.server.name, msg))
566
567 def reply_403(self, channel):
568 self.reply("403 %s %s :No such channel" % (self.nickname, channel))
569
570 def reply_461(self, command):
571 nickname = self.nickname or "*"
572 self.reply("461 %s %s :Not enough parameters" % (nickname, command))
573
574 def message_channel(self, channel, command, message, include_self=False):
575 line = ":%s %s %s" % (self.prefix, command, message)
576 for client in channel.members:
577 if client != self or include_self:
578 client.message(line)
579
580 def channel_log(self, channel, message, meta=False):
581 if not self.server.logdir:
582 return
583 if meta:
584 format = "[%s] * %s %s\n"
585 else:
586 format = "[%s] <%s> %s\n"
587 timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
588 logname = channel.name.replace("_", "__").replace("/", "_")
589 fp = open("%s/%s.log" % (self.server.logdir, logname), "a")
590 fp.write(format % (timestamp, self.nickname, message))
591 fp.close()
592
593 def message_related(self, msg, include_self=False):
594 clients = set()
595 if include_self:
596 clients.add(self)
597 for channel in self.channels.values():
598 clients |= channel.members
599 if not include_self:
600 clients.discard(self)
601 for client in clients:
602 client.message(msg)
603
604 def send_lusers(self):
605 self.reply("251 %s :There are %d users and 0 services on 1 server"
606 % (self.nickname, len(self.server.clients)))
607
608 def send_motd(self):
609 server = self.server
610 motdlines = server.get_motd_lines()
611 if motdlines:
612 self.reply("375 %s :- %s Message of the day -"
613 % (self.nickname, server.name))
614 for line in motdlines:
615 self.reply("372 %s :- %s" % (self.nickname, line.rstrip()))
616 self.reply("376 %s :End of /MOTD command" % self.nickname)
617 else:
618 self.reply("422 %s :MOTD File is missing" % self.nickname)
619
620 class Peer(object):
621 def __init__(self, address):
622 self.address = address
623 self.socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
624
625 def send(self, client, msg):
626 print(client)
627 print(":%s %s" % (client.nickname, msg))
628 self.socket.sendto(str.encode(":%s %s" % (client.nickname, msg)), (self.address, 7778))
629
630 class Server(object):
631 def __init__(self, options):
632 self.ports = options.ports
633 self.peers = options.peers
634 self.udp_port = options.udp_port
635 self.password = options.password
636 self.ssl_pem_file = options.ssl_pem_file
637 self.motdfile = options.motd
638 self.verbose = options.verbose
639 self.debug = options.debug
640 self.logdir = options.logdir
641 self.chroot = options.chroot
642 self.setuid = options.setuid
643 self.statedir = options.statedir
644
645 if options.listen:
646 self.address = socket.gethostbyname(options.listen)
647 else:
648 self.address = ""
649 server_name_limit = 63 # From the RFC.
650 self.name = socket.getfqdn(self.address)[:server_name_limit]
651
652 self.channels = {} # irc_lower(Channel name) --> Channel instance.
653 self.clients = {} # Socket --> Client instance..peers = ""
654 self.nicknames = {} # irc_lower(Nickname) --> Client instance.
655 if self.logdir:
656 create_directory(self.logdir)
657 if self.statedir:
658 create_directory(self.statedir)
659
660 def daemonize(self):
661 try:
662 pid = os.fork()
663 if pid > 0:
664 sys.exit(0)
665 except OSError:
666 sys.exit(1)
667 os.setsid()
668 try:
669 pid = os.fork()
670 if pid > 0:
671 self.print_info("PID: %d" % pid)
672 sys.exit(0)
673 except OSError:
674 sys.exit(1)
675 os.chdir("/")
676 os.umask(0)
677 dev_null = open("/dev/null", "r+")
678 os.dup2(dev_null.fileno(), sys.stdout.fileno())
679 os.dup2(dev_null.fileno(), sys.stderr.fileno())
680 os.dup2(dev_null.fileno(), sys.stdin.fileno())
681
682 def get_client(self, nickname):
683 return self.nicknames.get(irc_lower(nickname))
684
685 def has_channel(self, name):
686 return irc_lower(name) in self.channels
687
688 def get_channel(self, channelname):
689 if irc_lower(channelname) in self.channels:
690 channel = self.channels[irc_lower(channelname)]
691 else:
692 channel = Channel(self, channelname)
693 self.channels[irc_lower(channelname)] = channel
694 return channel
695
696 def get_motd_lines(self):
697 if self.motdfile:
698 try:
699 return open(self.motdfile).readlines()
700 except IOError:
701 return ["Could not read MOTD file %r." % self.motdfile]
702 else:
703 return []
704
705 def print_info(self, msg):
706 if self.verbose:
707 print(msg)
708 sys.stdout.flush()
709
710 def print_debug(self, msg):
711 if self.debug:
712 print(msg)
713 sys.stdout.flush()
714
715 def print_error(self, msg):
716 sys.stderr.write("%s\n" % msg)
717
718 def client_changed_nickname(self, client, oldnickname):
719 if oldnickname:
720 del self.nicknames[irc_lower(oldnickname)]
721 self.nicknames[irc_lower(client.nickname)] = client
722
723 def remove_member_from_channel(self, client, channelname):
724 if irc_lower(channelname) in self.channels:
725 channel = self.channels[irc_lower(channelname)]
726 channel.remove_client(client)
727
728 def remove_client(self, client, quitmsg):
729 client.message_related(":%s QUIT :%s" % (client.prefix, quitmsg))
730 for x in client.channels.values():
731 client.channel_log(x, "quit (%s)" % quitmsg, meta=True)
732 x.remove_client(client)
733 if client.nickname \
734 and irc_lower(client.nickname) in self.nicknames:
735 del self.nicknames[irc_lower(client.nickname)]
736 del self.clients[client.socket]
737
738 def remove_channel(self, channel):
739 del self.channels[irc_lower(channel.name)]
740
741 def start(self):
742 # Setup UDP first
743 udp_server_socket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
744 udp_server_socket.bind((self.address, self.udp_port))
745
746 serversockets = []
747 for port in self.ports:
748 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
749 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
750 try:
751 s.bind((self.address, port))
752 except socket.error as e:
753 self.print_error("Could not bind port %s: %s." % (port, e))
754 sys.exit(1)
755 s.listen(5)
756 serversockets.append(s)
757 del s
758 self.print_info("Listening on port %d." % port)
759 if self.chroot:
760 os.chdir(self.chroot)
761 os.chroot(self.chroot)
762 self.print_info("Changed root directory to %s" % self.chroot)
763 if self.setuid:
764 os.setgid(self.setuid[1])
765 os.setuid(self.setuid[0])
766 self.print_info("Setting uid:gid to %s:%s"
767 % (self.setuid[0], self.setuid[1]))
768 last_aliveness_check = time.time()
769 while True:
770 (inputready,outputready,exceptready) = select.select([udp_server_socket],[],[],0)
771 (iwtd, owtd, ewtd) = select.select(
772 serversockets + [x.socket for x in self.clients.values()],
773 [x.socket for x in self.clients.values()
774 if x.write_queue_size() > 0],
775 [],
776 0)
777 for x in inputready:
778 if x == udp_server_socket:
779 bytes_address_pair = udp_server_socket.recvfrom(1024)
780 message = bytes_address_pair[0]
781 address = bytes_address_pair[1]
782 print message
783 for c in self.clients:
784 self.clients[c].udp_data_received(message)
785 for x in iwtd:
786 if x in self.clients:
787 self.clients[x].socket_readable_notification()
788 else:
789 (conn, addr) = x.accept()
790 if self.ssl_pem_file:
791 import ssl
792 try:
793 conn = ssl.wrap_socket(
794 conn,
795 server_side=True,
796 certfile=self.ssl_pem_file,
797 keyfile=self.ssl_pem_file)
798 except ssl.SSLError as e:
799 self.print_error(
800 "SSL error for connection from %s:%s: %s" % (
801 addr[0], addr[1], e))
802 continue
803 self.clients[conn] = Client(self, conn)
804 self.print_info("Accepted connection from %s:%s." % (
805 addr[0], addr[1]))
806 for x in owtd:
807 if x in self.clients: # client may have been disconnected
808 self.clients[x].socket_writable_notification()
809 now = time.time()
810 if last_aliveness_check + 10 < now:
811 for client in self.clients.values():
812 client.check_aliveness()
813 last_aliveness_check = now
814
815 _maketrans = str.maketrans if sys.version_info[0] == 3 else string.maketrans
816 _ircstring_translation = _maketrans(
817 string.ascii_lowercase.upper() + "[]\\^",
818 string.ascii_lowercase + "{}|~")
819
820
821 def irc_lower(s):
822 return string.translate(s, _ircstring_translation)
823
824
825 def main(argv):
826 op = OptionParser(
827 version=VERSION,
828 description="miniircd is a small and limited IRC server.")
829 op.add_option(
830 "-d", "--daemon",
831 action="store_true",
832 help="fork and become a daemon")
833 op.add_option(
834 "--debug",
835 action="store_true",
836 help="print debug messages to stdout")
837 op.add_option(
838 "--listen",
839 metavar="X",
840 help="listen on specific IP address X")
841 op.add_option(
842 "--logdir",
843 metavar="X",
844 help="store channel log in directory X")
845 op.add_option(
846 "--motd",
847 metavar="X",
848 help="display file X as message of the day")
849 op.add_option(
850 "-s", "--ssl-pem-file",
851 metavar="FILE",
852 help="enable SSL and use FILE as the .pem certificate+key")
853 op.add_option(
854 "-p", "--password",
855 metavar="X",
856 help="require connection password X; default: no password")
857 op.add_option(
858 "--ports",
859 metavar="X",
860 help="listen to ports X (a list separated by comma or whitespace);"
861 " default: 6667 or 6697 if SSL is enabled")
862 op.add_option(
863 "--udp-port",
864 metavar="X",
865 help="listen for UDP packets on X;"
866 " default: 7778")
867 op.add_option(
868 "--peers",
869 metavar="X",
870 help="Broadcast to X (a list of IP addresses separated by comma or whitespace)")
871 op.add_option(
872 "--statedir",
873 metavar="X",
874 help="save persistent channel state (topic, key) in directory X")
875 op.add_option(
876 "--verbose",
877 action="store_true",
878 help="be verbose (print some progress messages to stdout)")
879 if os.name == "posix":
880 op.add_option(
881 "--chroot",
882 metavar="X",
883 help="change filesystem root to directory X after startup"
884 " (requires root)")
885 op.add_option(
886 "--setuid",
887 metavar="U[:G]",
888 help="change process user (and optionally group) after startup"
889 " (requires root)")
890
891 (options, args) = op.parse_args(argv[1:])
892 if options.debug:
893 options.verbose = True
894 if options.ports is None:
895 if options.ssl_pem_file is None:
896 options.ports = "6667"
897 else:
898 options.ports = "6697"
899 if options.peers is None:
900 options.peers = ""
901 if options.udp_port is None:
902 options.udp_port = 7778
903 else:
904 options.udp_port = int(options.udp_port)
905 if options.chroot:
906 if os.getuid() != 0:
907 op.error("Must be root to use --chroot")
908 if options.setuid:
909 from pwd import getpwnam
910 from grp import getgrnam
911 if os.getuid() != 0:
912 op.error("Must be root to use --setuid")
913 matches = options.setuid.split(":")
914 if len(matches) == 2:
915 options.setuid = (getpwnam(matches[0]).pw_uid,
916 getgrnam(matches[1]).gr_gid)
917 elif len(matches) == 1:
918 options.setuid = (getpwnam(matches[0]).pw_uid,
919 getpwnam(matches[0]).pw_gid)
920 else:
921 op.error("Specify a user, or user and group separated by a colon,"
922 " e.g. --setuid daemon, --setuid nobody:nobody")
923 if (os.getuid() == 0 or os.getgid() == 0) and not options.setuid:
924 op.error("Running this service as root is not recommended. Use the"
925 " --setuid option to switch to an unprivileged account after"
926 " startup. If you really intend to run as root, use"
927 " \"--setuid root\".")
928
929 ports = []
930 for port in re.split(r"[,\s]+", options.ports):
931 try:
932 ports.append(int(port))
933 except ValueError:
934 op.error("bad port: %r" % port)
935 options.ports = ports
936 peers = []
937 for peer in re.split(r"[,\s]+", options.peers):
938 try:
939 peers.append(Peer(peer))
940 except ValueError:
941 op.error("bad peer ip: %r" % peer)
942 options.peers = peers
943 server = Server(options)
944 if options.daemon:
945 server.daemonize()
946 try:
947 server.start()
948 except KeyboardInterrupt:
949 server.print_error("Interrupted.")
950
951
952 main(sys.argv)
953
954

The Comms Chronicle

July 4th, 2021

I attempt here to gather together and make a succinct chronical of the comms network(s) efforts discussed in the logs over time.

The first item I can recall proposed was gossipd.

This item is no longer under discussion for reasons that are not clear to me due to my inability to properly study and digest the comment thread in the above article.

As Freenode IRC's ultimate decline and collapse has become evidently imminent, asciilifeform has begun evacuation procedures. The first step he took was to stand up dulapnet, his own ircd running the Unreal ircd.

Subsequently, members of #asciilifeform, including signpost, gregorynyssa, asciilifeform, and myself attempted to form a new type of IRC network in which each participant runs his own ircd. Each ircd would be linked to each other ircd. In addition to being decentralized, such a network would have the advantage of being accessible via any existing IRC compliant client. We then discovered that IRC networks do not allow for cycles, and can only exist in a tree topology. This topology was not acceptable to asciilifeform or signpost due to centralizing requirement for there to be a root node1.

Discussion moved on to another type of network in which the peers would communicate via UDP, but clients would connect to peers using the IRC protocol. It would be trivial to architect such a network using shared secret keys. However, asciilifeform also proposes that this network should allow unknowns to broadcast to the network via a PoW mechanism. This would allow unknown and untrusted entities to gain entry into the network without manual intervention, which would allow anyone to drop in for a chat while at the same time minimizing the attack surface of the network, eliminating the need for anyone to check their email ever again.

At the present time, this type of network is not feasible due to the apparent intractability of sharing the required difficulty level for the PoW mechanism. I'm unaware of any further actions I or any other interested parties can take to make progress down this path.

Signpost is investigating a third network type based on the Kademlia DHT. Objections to this approach are here

It is interesting to note that Freenode ultimately did not succumb to any sort of technical attack such as a ddos, but due to sabotage by long time admins who walked away with user data stored in a centralized database.

  1. This network is notable that it is the first publicly available prototype of any potential comms network discussed in the logs []

Log link index

June 13th, 2021

A .sql file containing an index that allows searching by content linked to in the logs is available here. It's only about 200mb compressed.

Thimbronion's Violin

June 13th, 2021

I picked up my violin again after several years of neglect. I hadn't really played since I put together a quartet to perform (actually I can't remember what we played) at a small recital back in 2007 or 2008. I think one reason I stopped playing was because my soon to be ex-wife strongly disliked the instrument.

I started to play again for purely incidental reasons. One is that a girl I've been seeing saw my violin and asked to hear me play. Sadly at the time I couldn't due to having severly injured my wrists in a completely avoidable electric unicycle accident. As time has passed and my wrists have healed, I have indeed begun to be able to pick up my violin and play again. The accident has definitely given me a new found appreciation for my wrists, which, even though they are of the utmost importance to me because of my profession I have perhaps taken for granted. In any case I'm glad she asked because I'm enjoying it again after really having given up on it. Another reason is that I met some musicians in Costa Rica and I think it would be a blast to give some performances with them.

I never really had a good violin before now. I had played on student violins, and if you asked me before, I couldn't tell you how to tell the difference between a good violin and a bad one. As I started to play again this last time, I was forced between choosing to repair my low quality student violin and purchaing a different instrument. I chose to purchase a new (well, used) instrument of higher quality. It indeed sounds better. Anything played on the E-string in particular just sounds ... brighter.

I am now working on playing the 4th part of Romanian Folk Dances, by Bartok. I don't have any particular reason to play it other than that I still have the sheet music, and I love playing it. In a way, the distance between now and the last time I practiced anything is a gift. I can hear myself critically now, from a distance, including all of the flaws in my technique. I don't know why I couldn't hear or didn't care to hear the problems before but I believe I now have a chance to improve, whereas if I'd continued as before, I might never have improved. In particular my bowing is problematic. The bow bounces in the middle of a long stroke. It touches adjacent strings when it shouldn't. It starts strokes roughly, and change between upstrokes and down strokes is not smooth. My intonation also is not great, and my 4th finger vibrato is non-existant. There are a lot more resources available now than before that I can use to improve, including, yes, even youtube. There are hours of videos to watch on vibrato alone.

I've already been able to improve my technique by memorizing the piece so I can concentrate on and literally observe my bow, rather than the music on the page.

I am happy to be getting this part of my life back and looking forward to continued advancements and playing more with others.

Alethepedia Improvements

May 3rd, 2021

Alethepedia is an encyclopedia based on an OCR scan of the 11th edition of Encyclopedia Britannica. It is currently hosted in an mp-wp running on a Digital Ocean VM. Mp-wp is a good foundation for an encyclopedia CMS, but much work needs to be done to make it better suited to the task. Following are the things I'd like to accomplish over the next 6 months or so:

Categorize articles alphabetically

I want all articles to be associated with a letter category corresponding the first letter of the title.

  • Deliverable: a python script I can run against the database.

Order articles alphabetically

I want articles to be listed in alphabetical order by title within categories. See: https://codex.wordpress.org/Alphabetizing_Posts for inspiration.

  • Deliverable: a patch against my mp-wp.

Prioritize title matches in search results

Currently it is very difficult to sift through search results and find an article when searching for words in the title, since it seems that possibly matches are made only against the body. I have no idea how the results are sorted. I'm open to other proposals to improve search via the web interface.

  • Deliverable: a patch against my mp-wp.

Any word that matches an article title should link to the article, with exceptions for things like the article on the letter A.

  • Deliverable: a python script I can run against the database.

Develop a minimalist, monochrome, responsive encyclopedia theme for mp-wp.

I kind of like the WordPress default theme, but I want a theme that is responsive and looks good on mobile devices.
Article dates are irrelevant and shouldn't be displayed
next/prev shouldn't say 'Older entries,' should be something like just next/previous since the date of the article isn't relevant.

  • Deliverable: WordPress theme or patch to existing WordPress theme.

Dump db to public file

  • Deliverable: Python script that can be called by cron to periodically dump the contents of the db into a sanitized .sql file that can be downloaded by interested parties.

Future work:

I am accepting proposals for work on cleaning up the text of the entries themselves. Here are some of the problems:

  • The OCR software mangled many dates within articles, as well as tables.
  • Many words have been mangled as well.

Ircbot Alt-Genesis

November 21st, 2020

While attempting to press trinque's ircbot including whaack's and ben_vulpes's patches, I ran into the issue of the genesis having been created using a differing hashing algorithm than that used for whaack's patch making pressing ircbot impossible using the available patches and genesis. Here you'll find a genesis including trinque's genesis and the changes from ben_vulpes's patch and whaack's patch.

logbot-genesis.vpatch
logbot-genesis.vpatch.asc

1 diff -uNr a/ircbot/INSTALL b/ircbot/INSTALL
2 --- a/ircbot/INSTALL false
3 +++ b/ircbot/INSTALL c4c41d96ddb71db32e8cd54c22e7250abbc52d51f4b25d8092dc094b4a84100949d0a74378fc33131d2d9a5144156a738f5a91d7e949922898993eb4384b4757
4 @@ -0,0 +1,19 @@
5 +INSTALL
6 +
7 + * Install SBCL (with sb-thread) and Quicklisp.
8 +
9 + * From the SBCL REPL:
10 + (ql:quickload :cl-irc)
11 +
12 + * Use V to press `ircbot`
13 +
14 +mkdir -p ~/src/ircbot
15 +cd ~/src/ircbot
16 +
17 +mkdir .wot
18 +cd .wot && wget http://trinque.org/trinque.asc && cd ..
19 +
20 +v.pl init http://trinque.org/src/ircbot
21 +v.pl press ircbot-genesis ircbot-genesis.vpatch
22 +
23 +ln -s ~/src/ircbot/ircbot-genesis ~/quicklisp/local-projects/ircbot
24 diff -uNr a/ircbot/README b/ircbot/README
25 --- a/ircbot/README false
26 +++ b/ircbot/README 6a76028622c6bb986d68d42b7b133221d3659d56da1bd5d4e8b39f0a6075a17d8b3ee33c8e37c7d4b3ec1f108ad940777d762bab01763a63742733a1445660d4
27 @@ -0,0 +1,8 @@
28 +README
29 +
30 +`ircbot` provides a simple CLOS class, `ircbot`, which will maintain a
31 +connection to a single IRC channel via `cl-irc`. The bot will handle
32 +ping/pong and detect failed connections, and is capable of
33 +authenticating with NickServ (using ghost when necessary to
34 +reacquire nick).
35 +
36 diff -uNr a/ircbot/USAGE b/ircbot/USAGE
37 --- a/ircbot/USAGE false
38 +++ b/ircbot/USAGE 20d07e6f190f6655a2884e60c1d6a7eccdd76d019797bb165c716a6919fb5161a31b1956b9dda7927839837a924ae6ea3d0c1a833458bbbd5765f76548d637d2
39 @@ -0,0 +1,14 @@
40 +USAGE
41 +
42 +(asdf:load-system :ircbot)
43 +(defvar *bot*)
44 +(setf *bot*
45 + (ircbot:make-ircbot
46 + "chat.freenode.net" 6667 "nick" "password" "#channel"))
47 +
48 +; connect in separate thread, returning thread
49 +(ircbot:ircbot-connect-thread *bot*)
50 +
51 +; or connect using the current thread
52 +; (ircbot:ircbot-connect *bot*)
53 +
54 diff -uNr a/ircbot/ircbot.asd b/ircbot/ircbot.asd
55 --- a/ircbot/ircbot.asd false
56 +++ b/ircbot/ircbot.asd 9dfba5c2bd97e5ffc2ab071786b14c05dfda1c898ef58a5d87ea020bde042bf76c0e88b78f6d4a4fbd453aabea58e4710bf717defa023dfe410d19ac01c4e2d9
57 @@ -0,0 +1,10 @@
58 +;;;; ircbot.asd
59 +
60 +(asdf:defsystem #:ircbot
61 + :description "ircbot"
62 + :author "Michael Trinque <mike@trinque.org>"
63 + :license "http://trilema.com/2015/a-new-software-licensing-paradigm/"
64 + :depends-on (#:cl-irc)
65 + :components ((:file "package")
66 + (:file "ircbot")))
67 +
68 diff -uNr a/ircbot/ircbot.lisp b/ircbot/ircbot.lisp
69 --- a/ircbot/ircbot.lisp false
70 +++ b/ircbot/ircbot.lisp 738a2c0ca77a69fc7805cbfc668da1b61e25e512d6d9f3bdf200968e39eb201bb87be83c6ae1411c6e6a5c7dd63524a5b5ab71d99a2813ac85fc5ac4360b3b17
71 @@ -0,0 +1,143 @@
72 +(in-package #:ircbot)
73 +
74 +(defvar *max-lag* 60)
75 +(defvar *ping-freq* 30)
76 +
77 +
78 +(defclass ircbot ()
79 + ((connection :accessor ircbot-connection :initform nil)
80 + (channels :reader ircbot-channels :initarg :channels)
81 + (server :reader ircbot-server :initarg :server)
82 + (port :reader ircbot-port :initarg :port)
83 + (nick :reader ircbot-nick :initarg :nick)
84 + (password :reader ircbot-password :initarg :password)
85 + (connection-security :reader ircbot-connection-security
86 + :initarg :connection-security
87 + :initform :none)
88 + (run-thread :accessor ircbot-run-thread :initform nil)
89 + (ping-thread :accessor ircbot-ping-thread :initform nil)
90 + (lag :accessor ircbot-lag :initform nil)
91 + (lag-track :accessor ircbot-lag-track :initform nil)))
92 +
93 +(defmethod ircbot-check-nick ((bot ircbot) message)
94 + (destructuring-bind (target msgtext) (arguments message)
95 + (declare (ignore msgtext))
96 + (if (string= target (ircbot-nick bot))
97 + (ircbot-nickserv-auth bot)
98 + (ircbot-nickserv-ghost bot))))
99 +
100 +(defmethod ircbot-connect :around ((bot ircbot))
101 + (let ((conn (connect :nickname (ircbot-nick bot)
102 + :server (ircbot-server bot)
103 + :port (ircbot-port bot)
104 + :connection-security (ircbot-connection-security bot))))
105 + (setf (ircbot-connection bot) conn)
106 + (call-next-method)
107 + (read-message-loop conn)))
108 +
109 +(defmethod ircbot-connect ((bot ircbot))
110 + (let ((conn (ircbot-connection bot)))
111 + (add-hook conn 'irc-err_nicknameinuse-message (lambda (message)
112 + (declare (ignore message))
113 + (ircbot-randomize-nick bot)))
114 + (add-hook conn 'irc-kick-message (lambda (message)
115 + (declare (ignore message))
116 + (map nil
117 + (lambda (c) (join (ircbot-connection bot) c))
118 + (ircbot-channels bot))))
119 + (add-hook conn 'irc-notice-message (lambda (message)
120 + (ircbot-handle-nickserv bot message)))
121 + (add-hook conn 'irc-pong-message (lambda (message)
122 + (ircbot-handle-pong bot message)))
123 + (add-hook conn 'irc-rpl_welcome-message (lambda (message)
124 + (ircbot-start-ping-thread bot)
125 + (ircbot-check-nick bot message)))))
126 +
127 +(defmethod ircbot-connect-thread ((bot ircbot))
128 + (setf (ircbot-run-thread bot)
129 + (sb-thread:make-thread (lambda () (ircbot-connect bot))
130 + :name "ircbot-run")))
131 +
132 +(defmethod ircbot-disconnect ((bot ircbot) &optional (quit-msg "..."))
133 + (sb-sys:without-interrupts
134 + (quit (ircbot-connection bot) quit-msg)
135 + (setf (ircbot-lag-track bot) nil)
136 + (setf (ircbot-connection bot) nil)
137 + (if (not (null (ircbot-run-thread bot)))
138 + (sb-thread:terminate-thread (ircbot-run-thread bot)))
139 + (if (not (or (null (ircbot-ping-thread bot)) (equal sb-thread:*current-thread* (ircbot-ping-thread bot))))
140 + (sb-thread:terminate-thread (ircbot-ping-thread bot)))))
141 +
142 +(defmethod ircbot-reconnect ((bot ircbot) &optional (quit-msg "..."))
143 + (let ((threaded-p (not (null (ircbot-run-thread bot)))))
144 + (ircbot-disconnect bot quit-msg)
145 + (if threaded-p
146 + (ircbot-connect-thread bot)
147 + (ircbot-connect bot))))
148 +
149 +(defmethod ircbot-handle-nickserv ((bot ircbot) message)
150 + (let ((conn (ircbot-connection bot)))
151 + (if (string= (host message) "services.")
152 + (destructuring-bind (target msgtext) (arguments message)
153 + (declare (ignore target))
154 + (cond ((string= msgtext "This nickname is registered. Please choose a different nickname, or identify via /msg NickServ identify <password>.")
155 + (ircbot-nickserv-auth bot))
156 + ((string= msgtext (format nil "~A has been ghosted." (ircbot-nick bot)))
157 + (nick conn (ircbot-nick bot)))
158 + ((string= msgtext (format nil "~A is not online." (ircbot-nick bot)))
159 + (ircbot-nickserv-auth bot))
160 + ((string= msgtext (format nil "You are now identified for ~A." (ircbot-nick bot)))
161 + (map nil (lambda (c) (join conn c)) (ircbot-channels bot))))))))
162 +
163 +(defmethod ircbot-handle-pong ((bot ircbot) message)
164 + (destructuring-bind (server ping) (arguments message)
165 + (declare (ignore server))
166 + (let ((response (ignore-errors (parse-integer ping))))
167 + (when response
168 + (setf (ircbot-lag-track bot) (delete response (ircbot-lag-track bot) :test #'=))
169 + (setf (ircbot-lag bot) (- (received-time message) response))))))
170 +
171 +(defmethod ircbot-nickserv-auth ((bot ircbot))
172 + (privmsg (ircbot-connection bot) "NickServ"
173 + (format nil "identify ~A" (ircbot-password bot))))
174 +
175 +(defmethod ircbot-nickserv-ghost ((bot ircbot))
176 + (privmsg (ircbot-connection bot) "NickServ"
177 + (format nil "ghost ~A ~A" (ircbot-nick bot) (ircbot-password bot))))
178 +
179 +(defmethod ircbot-randomize-nick ((bot ircbot))
180 + (nick (ircbot-connection bot)
181 + (format nil "~A-~A" (ircbot-nick bot) (+ (random 90000) 10000))))
182 +
183 +(defmethod ircbot-send-message ((bot ircbot) target message-text)
184 + (privmsg (ircbot-connection bot) target message-text))
185 +
186 +(defmethod ircbot-start-ping-thread ((bot ircbot))
187 + (let ((conn (ircbot-connection bot)))
188 + (setf (ircbot-ping-thread bot)
189 + (sb-thread:make-thread
190 + (lambda ()
191 + (loop
192 + do (progn (sleep *ping-freq*)
193 + (let ((ct (get-universal-time)))
194 + (push ct (ircbot-lag-track bot))
195 + (ping conn (princ-to-string ct))))
196 + until (ircbot-timed-out-p bot))
197 + (ircbot-reconnect bot))
198 + :name "ircbot-ping"))))
199 +
200 +(defmethod ircbot-timed-out-p ((bot ircbot))
201 + (loop
202 + with ct = (get-universal-time)
203 + for v in (ircbot-lag-track bot)
204 + when (> (- ct v) *max-lag*)
205 + do (return t)))
206 +
207 +
208 +(defun make-ircbot (server port nick password channels)
209 + (make-instance 'ircbot
210 + :server server
211 + :port port
212 + :nick nick
213 + :password password
214 + :channels channels))
215 diff -uNr a/ircbot/manifest b/ircbot/manifest
216 --- a/ircbot/manifest false
217 +++ b/ircbot/manifest 8a535c4a26e5fba0aa52c44bfcd84176de82568ddd7e98e8fb84ab48b5dbc0bc315c09f37c8eb7201a88fb804a18712d1a876f02e06552157ebefc63a123a9c4
218 @@ -0,0 +1 @@
219 +658020 ircbot_genesis thimbronion This genesis combines trinque's genesis with ben_vulpes' multi-channel fix and whaack's reconnection fix. Theses patches were generated using differing hash algos and can not be applied by any existing v implementation and I do not see any reason that anyone would find ircbot usable without any of them.
220 diff -uNr a/ircbot/package.lisp b/ircbot/package.lisp
221 --- a/ircbot/package.lisp false
222 +++ b/ircbot/package.lisp d186f3af63443337d23a0bfbaae79246fae2b2781acb53109132b42f84cf46acabf1fe12f2aba00c452e679c721ca955daaf302e1a04a56fccb8125d95e1527c
223 @@ -0,0 +1,18 @@
224 +;;;; package.lisp
225 +
226 +(defpackage :ircbot
227 + (:use :cl
228 + :cl-irc)
229 + (:export :make-ircbot
230 + :ircbot
231 + :ircbot-connect
232 + :ircbot-connect-thread
233 + :ircbot-disconnect
234 + :ircbot-reconnect
235 + :ircbot-connection
236 + :ircbot-channels
237 + :ircbot-send-message
238 + :ircbot-server
239 + :ircbot-port
240 + :ircbot-nick
241 + :ircbot-lag))
242 /

Thoughts on WoT Search

October 25th, 2020

Here is how I am looking at WoT search right now.

The work remaining on Lekythion is

  1. Constructing a decent search query in SQL
  2. Hooking the bot up to postgres
  3. Formatting the response
  4. Publishing the lisp code I use to build the index

After publication of the code, my hope to see others build their own indexes and share them using the WoT. Index owners could share read-only access to their postgres instance, perhaps via whitelisted IPs, to others in the WoT. This is similar to the approach suggested by asciilifeform, but I can’t find a reference to his proposal at the moment.

Individuals could stand up their own bots and connect queries to whichever indexes they preferred, be they their own indexes or others’.

My indexes will eventually include at least:

  • All known former republican blogs1
  • All pages linked to in the logs
  • The logs

Perhaps at some point it will become relatively easy to quickly build an index of favorite sites/data sets and and make them available.

  1. As time goes on it may be possible for me to connect directly to blog owner indexes, rather than creating my own []

Socialist Art and Architecture of Sacramento

September 20th, 2020

All of the above images are taken from the back alley behind my apartment.

This is an outdoor olympic weightlifting gym, also in the back alley behind my apartment. I won’t report how much I can lift just yet.

I do like the industrial warehouse look of these two buildings, although both are now used for non-industrial purposes.

Disgusting, don’t you think?

Bladerunner?

This is where Newsom contemplates climate change instead of doing anything useful.

I actually liked this building until I discovered it’s a federal building.

More modern architecture.

Some train pics for the spergs.

Me and my ride.

Notes on Chinese from gregorynyssa

September 20th, 2020

In this article I attempt to condense some interesting information about Chinese I’ve learned in my recent discussions with gregorynyssa, a scholar of Chinese, Greek, and Latin, much after my own heart but far more advanced in all of these studies than myself.

Sentence Structure

According to gregorynyssa speakers of Mandarin Chinese, a language significantly lacking in grammar (such as explicit case), use these tricks with sentences to express more complicated ideas.

Since Chinese does not handle embedded clauses very elegantly, often speakers have a tendency just to avoid them.

Here are three techniques used by Mandarin speakers to express more complicated ideas.

Comma Splicing

Join two complete sentences with a comma. A result of this is there is no consistent distinction between commas and periods in Chinese writing.

我使用那把从抽屉拿出来的刀,切开了水果。

Subject-periodic Construction

This involves combining sentences which share a subject.

我很喜欢去看电影,觉得很有趣

Pivotal Construction

If the object of the first sentence is the same as the subject of the second, omit the latter and join the two sentences with a comma.

我昨天看了一部电影,非常精彩。

Digraphic Verbs, Improper Digraphic Verbs, and Monographic Verbs

Monographic verbs can’t serve as nouns.
Improper digraphic verbs can’t serve as nouns.
Most but not all digraphic verbs can serve as nouns.

The upshot of this is that it is necessary to learn the the noun forms for all improper digraphic verbs. For example, 睡觉 may not serve as a noun. In that case 睡眠 must be used. This partially explains why there are so many two-character words in chinese where both characters, according to the dictionary, mean exactly the same thing.

Improper digraphic verbs generally have a second syllable indicating direction or result.

Minor Grammatical Points

来 before a verb indicates the infinitive.
得 is more idiomatic when used vefore adverbs.
来到 is an improper verb meaning “come to.”
迅速 is used more often as an adverb in declarative sentences than 快, which is most often used in an imperative sense.
去 may only be used before place names, non-monographic verbs, and before terms indicating general vicinity1.

Further Reading

According to gregorynyssa, reading Disyllabic Words in Chinese and
Metrical Phonology have helped him fundamentally understand the nature 普通话(Putonghua). I haven’t yet read these papers and as of yet have no comment on them.

For a better understanding of how 普通话(apparently of relatively recent origin) differs from Ancient Chinese, read Peasant and Merchant as well as Rule of Law.

  1. Thus it is necessary to append 那边 to nouns like 超市 when expressing “going to <non-place name> []