idc.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. #!/usr/bin/env python3
  2. #
  3. # Internet Delay Chat server written in Python Trio.
  4. #
  5. # Written by: Andrew <https://www.andrewyu.org>
  6. # luk3yx <https://luk3yx.github.io>
  7. #
  8. # This is free and unencumbered software released into the public
  9. # domain.
  10. #
  11. # Anyone is free to copy, modify, publish, use, compile, sell, or
  12. # distribute this software, either in source code form or as a compiled
  13. # binary, for any purpose, commercial or non-commercial, and by any
  14. # means.
  15. #
  16. # In jurisdictions that recognize copyright laws, the author or authors
  17. # of this software dedicate any and all copyright interest in the
  18. # software to the public domain. We make this dedication for the benefit
  19. # of the public at large and to the detriment of our heirs and
  20. # successors. We intend this dedication to be an overt act of
  21. # relinquishment in perpetuity of all present and future rights to this
  22. # software under copyright law.
  23. #
  24. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  25. # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  26. # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
  27. # IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
  28. # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
  29. # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
  30. # OTHER DEALINGS IN THE SOFTWARE.
  31. #
  32. # This program requires Python 3.9 or later due to its extensive use of
  33. # type annotations. Usage with an older version would likely cause
  34. # SyntaxErrors. If mypy has problems detecting types on the Trio
  35. # library, install trio-typing. Please mypy after every runnable edit.
  36. #
  37. from __future__ import annotations
  38. from typing import Awaitable, Callable
  39. import time
  40. from pprint import pprint
  41. import trio
  42. import ssl
  43. import traceback
  44. import exceptions
  45. import entities
  46. import minilog
  47. import utils
  48. import config
  49. starttime = time.time()
  50. PORT = 6835
  51. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  52. ctx.load_cert_chain(
  53. "/etc/letsencrypt/live/fcm.andrewyu.org/fullchain.pem",
  54. "/etc/letsencrypt/live/fcm.andrewyu.org/privkey.pem",
  55. )
  56. client_id_counter = -1
  57. local_users: dict[bytes, entities.User] = {}
  58. local_channels: dict[bytes, entities.Channel] = {}
  59. for username in config.users:
  60. local_users[username] = entities.User(
  61. username=username,
  62. password=config.users[username]["password"],
  63. options=config.users[username]["options"],
  64. )
  65. for channelname in config.channels:
  66. local_channels[channelname] = entities.Channel(
  67. channelname=channelname,
  68. broadcast_to=[
  69. local_users[username]
  70. for username in config.channels[channelname]["broadcast_to"]
  71. ],
  72. guild=None,
  73. )
  74. for u in local_channels[channelname].broadcast_to:
  75. u.in_channels.append(local_channels[channelname])
  76. _CMD_HANDLER = Callable[
  77. [entities.Client, "dict[str, bytes]"], Awaitable[None]
  78. ]
  79. _registered_commands: dict[bytes, _CMD_HANDLER] = {}
  80. def register_command(
  81. command: str,
  82. ) -> Callable[[_CMD_HANDLER], _CMD_HANDLER]:
  83. def register_inner(func: _CMD_HANDLER) -> _CMD_HANDLER:
  84. _registered_commands[command.encode("ascii")] = func
  85. return func
  86. return register_inner
  87. @register_command("HELP")
  88. async def _help_cmd(
  89. client: entities.Client, args: dict[str, bytes]
  90. ) -> None:
  91. await utils.send(
  92. client,
  93. b"HELP",
  94. AVAILABLE_COMMANDS=b" ".join(_registered_commands),
  95. )
  96. @register_command("LOGIN")
  97. async def _login_cmd(
  98. client: entities.Client, args: dict[str, bytes]
  99. ) -> None:
  100. if client.user:
  101. raise exceptions.AlreadyLoggedIn(
  102. b"You are already logged in as "
  103. + client.user.username
  104. + b"."
  105. )
  106. attempting_username = utils.carg(args, "USERNAME", b"LOGIN")
  107. attempting_password = utils.carg(args, "PASSWORD", b"LOGIN")
  108. try:
  109. if (
  110. local_users[attempting_username].password
  111. == attempting_password
  112. ):
  113. client.user = local_users[attempting_username]
  114. local_users[attempting_username].connected_clients.append(
  115. client
  116. )
  117. await utils.send(
  118. client,
  119. b"LOGIN_GOOD",
  120. USERNAME=attempting_username,
  121. COMMENT=b"Login is good.",
  122. )
  123. assert client.user is not None
  124. for c in client.user.in_channels:
  125. await utils.send(
  126. client,
  127. b"JOIN",
  128. CHANNEL=c.channelname,
  129. USERS=b" ".join(
  130. [u.username for u in c.broadcast_to]
  131. ),
  132. )
  133. await utils.send(
  134. client,
  135. b"END_BURST",
  136. COMMENT=b"I'm finished telling you the state you're in.",
  137. )
  138. if client.user.queue:
  139. for i in range(len(client.user.queue)):
  140. b = client.user.queue.pop(0)
  141. # Do not pop "i" here, because we're modifying the
  142. # iterated object within the iteration, so the
  143. # indexes change! Therefore pop 0.
  144. await utils.quote(client, b)
  145. await utils.send(
  146. client,
  147. b"END_OFFLINE_MESSAGES",
  148. COMMENT=b"I'm finished telling you your offline messages.",
  149. )
  150. else:
  151. raise exceptions.LoginFailed(
  152. b"Invalid password for " + attempting_username + b"."
  153. )
  154. except KeyError:
  155. raise exceptions.LoginFailed(
  156. attempting_username + b" is not a registered username."
  157. )
  158. @register_command("PING")
  159. async def _ping_cmd(
  160. client: entities.Client, args: dict[str, bytes]
  161. ) -> None:
  162. await utils.send(client, b"PONG", COOKIE=utils.carg(args, "COOKIE"))
  163. @register_command("EGG")
  164. async def _egg_cmd(
  165. client: entities.Client, args: dict[str, bytes]
  166. ) -> None:
  167. await utils.send(
  168. client,
  169. b"EASTER_EGG",
  170. YAY=b"Andrew: Never gonna give you up\nnever gonna let you down\nnever gonna run around and desert you\nnever gonna make you cry\nnever gonna say goodbye\nnever gonna tell a lie and hurt you",
  171. )
  172. @register_command("PRIVMSG")
  173. async def _privmsg_cmd(
  174. client: entities.Client, args: dict[str, bytes]
  175. ) -> None: # in the future this should return the raw line sent to the target client
  176. if not client.user:
  177. raise exceptions.NotLoggedIn(
  178. b"You can't use PRIVMSG before logging in!"
  179. )
  180. else:
  181. target_name = utils.carg(args, "TARGET")
  182. try:
  183. target_user = local_users[target_name]
  184. except KeyError:
  185. raise exceptions.NonexistantTargetError(
  186. b"The target " + target_name + b" is nonexistant."
  187. )
  188. else:
  189. await utils.send(
  190. target_user,
  191. b"PRIVMSG",
  192. SOURCE=client.user.username,
  193. TYPE=args.get("TYPE", b"NORMAL"),
  194. TARGET=utils.carg(args, "TARGET"),
  195. MESSAGE=utils.carg(args, "MESSAGE"),
  196. )
  197. if target_user is not client.user:
  198. await utils.send(
  199. client.user,
  200. b"PRIVMSG",
  201. SOURCE=client.user.username,
  202. TYPE=args.get("TYPE", b"NORMAL"),
  203. TARGET=utils.carg(args, "TARGET"),
  204. MESSAGE=utils.carg(args, "MESSAGE"),
  205. )
  206. # Do you think that we should put echo-message here, or in utils.send()?
  207. @register_command("CHANMSG")
  208. async def _chanmsg_cmd(
  209. client: entities.Client, args: dict[str, bytes]
  210. ) -> None:
  211. if not client.user:
  212. raise exceptions.NotLoggedIn(
  213. b"You can't use CHANMSG before logging in!"
  214. )
  215. else:
  216. target_channel_name = utils.carg(args, "TARGET")
  217. try:
  218. target_channel = local_channels[target_channel_name]
  219. except KeyError:
  220. raise exceptions.NonexistantTargetError(
  221. b"The target channel "
  222. + target_channel_name
  223. + b"is nonexistant."
  224. )
  225. else:
  226. await utils.send(
  227. target_channel,
  228. b"CHANMSG",
  229. SOURCE=client.user.username,
  230. TYPE=args.get("TYPE", b"NORMAL"),
  231. TARGET=target_channel_name,
  232. MESSAGE=utils.carg(args, "MESSAGE"),
  233. )
  234. # await utils.send(
  235. # client.user,
  236. # b"CHANMSG",
  237. # source=client.user.username,
  238. # target=target_channel_name,
  239. # message=utils.carg(args, "MESSAGE"),
  240. # )
  241. async def connection_loop(stream: trio.SSLStream) -> None:
  242. global client_id_counter
  243. client_id_counter += 1
  244. ident = str(client_id_counter).encode("ascii")
  245. minilog.note(f"Connection {str(ident)} has started.")
  246. client = entities.Client(cid=ident, stream=stream)
  247. await utils.send(client, b"MOTD", MESSAGE=config.motd)
  248. client.ccrt = stream.getpeercert()
  249. await utils.send(client, b"CLIENT_CERT", FINGERPRINT=repr(client.ccrt).encode("utf-8"))
  250. try:
  251. msg = b""
  252. async for newmsg in stream:
  253. msg += newmsg
  254. split_msg = msg.split(b"\n")
  255. if split_msg[-1] == b"\r":
  256. split_msg = split_msg[:-1]
  257. if len(split_msg) < 2:
  258. continue
  259. data = split_msg[0:-1]
  260. msg = split_msg[-1]
  261. minilog.debug(f"{ident.decode('ascii')} >>> {data!r}")
  262. for cmdline in data:
  263. try:
  264. cmd, args = utils.bytesToStd(cmdline)
  265. cmd = cmd.upper()
  266. if cmd in _registered_commands:
  267. await _registered_commands[cmd](client, args)
  268. else:
  269. raise exceptions.UnknownCommand(
  270. cmd + b" is an unknown command."
  271. )
  272. except exceptions.IDCUserCausedException as e:
  273. await utils.send(
  274. client,
  275. e.severity,
  276. PROBLEM=e.error_type,
  277. COMMENT=e.args[0],
  278. )
  279. except Exception as exc:
  280. traceback.print_exc()
  281. minilog.warning(f"{ident!r}: crashed: {exc!r}")
  282. finally:
  283. if client.user:
  284. client.user.connected_clients.remove(client)
  285. del client.stream
  286. del client
  287. minilog.note(f"Connection {str(ident)} has ended.")
  288. async def tls_wrapper(s: trio.SocketStream) -> None:
  289. try:
  290. await connection_loop(trio.SSLStream(s, ctx, server_side=True))
  291. except trio.BrokenResourceError:
  292. minilog.caution("Some client has messed-up TLS.")
  293. async def main() -> None:
  294. await trio.serve_tcp(tls_wrapper, PORT)
  295. def run_i_guess() -> None:
  296. trio.run(main)
  297. if __name__ == "__main__":
  298. try:
  299. minilog.note("Definitions complete. Establishing listener.")
  300. trio.run(main)
  301. except KeyboardInterrupt:
  302. minilog.error("KeyboardInterrupt!")
  303. finally:
  304. minilog.note(
  305. f"I've ran for {str(time.time() - starttime)} seconds!"
  306. )