├── LICENSE ├── README.md ├── classes ├── conf_entries.py ├── configuration.py ├── data.py └── errors.py ├── conf └── examples │ ├── aliases.example.conf │ ├── bans.example.conf │ ├── dnsbl.example.conf │ ├── exceptions.example.conf │ ├── ircd.example.conf │ ├── ircd.example.motd │ ├── links.example.conf │ ├── modules.example.conf │ ├── operclass.example.conf │ ├── opers.example.conf │ ├── require.example.conf │ └── spamfilter.example.conf ├── handle ├── channel.py ├── client.py ├── configparser.py ├── core.py ├── functions.py ├── handleLink.py ├── handle_tls.py ├── log.py ├── logger.py ├── sockets.py └── validate_conf.py ├── ircd.py ├── modules ├── account.py ├── blacklist.py ├── certfp.py ├── chanmodes │ ├── auditorium.py │ ├── ban.py │ ├── chanadmin.py │ ├── chanop.py │ ├── chanowner.py │ ├── excepts.py │ ├── extbans │ │ ├── accountban.py │ │ ├── certfp.py │ │ ├── operclass.py │ │ ├── textban.py │ │ └── timedbans.py │ ├── halfop.py │ ├── invex.py │ ├── key.py │ ├── limit.py │ ├── m_blockcolors.py │ ├── m_chanstrip.py │ ├── m_history.py │ ├── m_regonly.py │ ├── moderated.py │ ├── noctcp.py │ ├── noexternal.py │ ├── noinvite.py │ ├── nokick.py │ ├── nonick.py │ ├── nonotice.py │ ├── opersonly.py │ ├── permanent.py │ ├── redirect.py │ ├── registered.py │ ├── secret.py │ ├── secureonly.py │ ├── topiclimit.py │ └── voice.py ├── chanset.py ├── founder.py ├── geodata.py ├── irc_websockets.py ├── ircv3 │ ├── account-notify.py │ ├── account-tag.py │ ├── batch.py │ ├── channel-context.py │ ├── channel_rename.py │ ├── chathistory.py │ ├── echo-message.py │ ├── labeled-response.py │ ├── message-ids.py │ ├── messagetags.py │ ├── no_implicit_names.py │ ├── oper-tag.py │ ├── reply.py │ ├── server_time.py │ ├── standard-replies.py │ ├── typingtag.py │ └── userhost-tag.py ├── knock.py ├── m_admin.py ├── m_antirandom.py ├── m_away.py ├── m_cap.py ├── m_chghost.py ├── m_chgname.py ├── m_cloak.py ├── m_clones.py ├── m_connect.py ├── m_cycle.py ├── m_die.py ├── m_eos.py ├── m_error.py ├── m_helpop.py ├── m_invite.py ├── m_ircops.py ├── m_ison.py ├── m_joinpart.py ├── m_kick.py ├── m_kill.py ├── m_list.py ├── m_listdelay.py ├── m_lusers.py ├── m_map.py ├── m_md.py ├── m_mode.py ├── m_modules.py ├── m_monitor.py ├── m_motd.py ├── m_msg.py ├── m_names.py ├── m_netinfo.py ├── m_nick.py ├── m_oper.py ├── m_pass.py ├── m_pingpong.py ├── m_protoctl.py ├── m_quit.py ├── m_quotes.py ├── m_rehash.py ├── m_restart.py ├── m_sajoinpart.py ├── m_sanick.py ├── m_sasl.py ├── m_sendumode.py ├── m_server.py ├── m_sethost.py ├── m_setname.py ├── m_sjoin.py ├── m_spamfilter.py ├── m_squit.py ├── m_stats.py ├── m_svsjoinpart.py ├── m_svskill.py ├── m_svsmode.py ├── m_svsnick.py ├── m_swhois.py ├── m_time.py ├── m_tkl.py ├── m_topic.py ├── m_user.py ├── m_version.py ├── m_wallops.py ├── m_watch.py ├── m_webirc.py ├── m_who.py ├── m_whois.py ├── starttls.py └── usermodes │ ├── bot.py │ ├── coremodes.py │ ├── m_blockmsg.py │ ├── m_callerid.py │ └── noctcp.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | A modern IRCd written in Python 3.10. Support for lower versions has officially been dropped. 4 |
5 | Massive code overhaul, so there might still be some issues. 6 | 7 | ## Installation 8 | 9 | Install the required packages: 10 | ```pip3 install -r requirements.txt``` 11 | 12 | Edit conf/examples/ircd.example.conf and save it to conf/ircd.conf.
13 | When you are done editting the configuration files, you can start ProvisionIRCd by running ```python3 ircd.py``` 14 | 15 | ## Features 16 | 17 | - Very modular, all modules can be reloaded on the fly (not always recommended) 18 | - IRCv3 features 19 | - Full TLS support 20 | - Extended channel and server bans 21 | - Linking capabilities 22 | - Flexible oper permissions system 23 | 24 | ## Services 25 | 26 | To use Anope with ProvisionIRCd, load the unreal4 protocol module in Anope services.conf. 27 | 28 | ## Issue 29 | 30 | If you find a bug or have a feature request, you can submit an issue 31 |
32 | or you can contact me on IRC @ irc.provisionweb.org when I'm not afk. 33 | -------------------------------------------------------------------------------- /classes/errors.py: -------------------------------------------------------------------------------- 1 | idx = - 1 2 | 3 | 4 | def error(): 5 | global idx 6 | idx += 1 7 | return idx 8 | 9 | 10 | class Error: 11 | SERVER_SID_EXISTS = error(), "SID {} already exists on the network" 12 | SERVER_NAME_EXISTS = error(), "A server with that name ({}) already exists on the network" 13 | 14 | SERVER_MISSING_USERMODES = error(), "Server {} is missing user modes: {}" 15 | SERVER_MISSING_CHANNELMODES = error(), "Server {} is missing channel modes: {}" 16 | SERVER_MISSING_MEMBERMODES = error(), "Server {} is missing channel member modes: {}" 17 | SERVER_EXTBAN_PREFIX_MISMATCH = error(), "Extban prefixes are not the same." 18 | SERVER_MISSING_EXTBANS = error(), "Extbans mismatch. Missing extbans: {}" 19 | SERVER_PROTOCTL_PARSE_FAIL = error(), "Invalid PROTOCTL received from {}: {}" 20 | 21 | SERVER_LINK_NOMATCH = error(), "No matching link configuration" 22 | SERVER_LINK_NOMATCH_MASK = error(), "Link block mask does not match" 23 | SERVER_LINK_NOMATCH_CERTFP = error(), "Certificate fingerprints do not match" 24 | SERVER_LINK_MISSING_AUTH_CN = error(), "Common-Name authentication requires at least one additional authentication method" 25 | SERVER_LINK_AUTH_NO_TLS = error(), "Server authentication failed: one or more TLS authentication methods were used, but client did not connect via TLS" 26 | SERVER_LINK_NOMATCH_CN = error(), "Certificate Common-Name does not match" 27 | SERVER_LINK_MAXCLASS = error(), "Maximum instances of link class '{}' reached" 28 | SERVER_LINK_NOCLASS = error(), "Remote server was unable to found a matching connection class for us" 29 | SERVER_LINK_NAME_COLLISION = error(), "Server name {} already in use" 30 | SERVER_LINK_INCORRECT_PASSWORD = error(), "Incorrect password" 31 | SERVER_LINK_TS_MISMATCH = error(), "Link denied due to incorrect clocks. Our clocks are {} seconds apart." 32 | 33 | USER_UID_ALREADY_IN_USE = error(), "[UID] UID already in use on the network: {}" 34 | USER_UID_NOT_ENOUGH_PARAMS = error(), "[UID] Not enough parameters for UID from {}: {} != 13" 35 | USER_UID_SIGNON_NO_DIGIT = error(), "Invalid timestamp received in UID: {}. Must be a timestamp (int)." 36 | 37 | @staticmethod 38 | def send(error_code, *args): 39 | error_num, error_string = error_code 40 | error_string = error_string.format(*args) 41 | return error_string 42 | -------------------------------------------------------------------------------- /conf/examples/aliases.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * When using type "services", make sure you configure 3 | * the settings:services block correctly in ircd.conf. 4 | * Otherwise, commands such as /ns and /cs will not work as expected. 5 | */ 6 | 7 | alias nickserv { type services; } 8 | 9 | alias ns { 10 | target nickserv; 11 | type services; 12 | options { 13 | spamfilter; 14 | } 15 | } 16 | 17 | alias chanserv { type services; } 18 | 19 | alias cs { 20 | target chanserv; 21 | type services; 22 | options { 23 | spamfilter; 24 | } 25 | } 26 | 27 | alias memoserv { type services; spamfilter; } 28 | 29 | alias ms { 30 | target memoserv; 31 | type services; 32 | options { 33 | spamfilter; 34 | } 35 | } 36 | 37 | alias operserv { type services; } 38 | 39 | alias os { 40 | target operserv; 41 | type services; 42 | options { 43 | spamfilter; 44 | } 45 | } 46 | 47 | alias botserv { type services; } 48 | 49 | alias bs { 50 | target botserv; 51 | type services; 52 | options { 53 | spamfilter; 54 | } 55 | } 56 | 57 | alias hostserv { type services; } 58 | 59 | alias hs { 60 | target hostserv; 61 | type services; 62 | options { 63 | spamfilter; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /conf/examples/bans.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * These entries will not affect clients matching except:ban entries in exceptions.conf 3 | */ 4 | 5 | ban nick { 6 | mask "*C*h*a*n*S*e*r*v*"; 7 | reason "Reserved for Services"; 8 | }; 9 | 10 | ban nick { 11 | mask "*N*i*c*k*S*e*r*v*"; 12 | reason "Reserved for Services"; 13 | }; 14 | 15 | ban user { 16 | mask *@some.annoying.host; 17 | reason "You are not welcome here"; 18 | }; 19 | -------------------------------------------------------------------------------- /conf/examples/dnsbl.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Configuration file for module 'blacklist.py' 3 | * In the reason, you can put %ip to display the IP address on IRC. 4 | */ 5 | 6 | dnsbl efnetrbl { 7 | dns rbl.efnetrbl.org; 8 | action gzline; 9 | /* 10 | * When using 'gzline' as action, you will also need a duration value. 11 | * If omitted, it will default to 1 day. 12 | */ 13 | duration 6h; 14 | reason "Your IP %ip has been found in efnetrbl blacklist."; 15 | } 16 | 17 | 18 | dnsbl dronebl { 19 | dns dnsbl.dronebl.org; 20 | action gzline; 21 | duration 6h; 22 | reason "Proxy/Drone detected. Check https://dronebl.org/lookup?ip=%ip for details."; 23 | } 24 | 25 | dnsbl sorbs { 26 | dns problems.dnsbl.sorbs.net; 27 | action gzline; 28 | duration 6h; 29 | reason "Your IP %ip has been found in SORBS blacklist."; 30 | } 31 | 32 | dnsbl blacklist_de { 33 | dns bl.blocklist.de; 34 | action gzline; 35 | duration 6h; 36 | reason "Your IP %ip has been found in blacklist.de blacklist."; 37 | } 38 | 39 | dnsbl rizon_net { 40 | dns dnsbl.rizon.net; 41 | action gzline; 42 | duration 6h; 43 | reason "Your IP %ip has been found in Rizon.net blacklist."; 44 | } 45 | 46 | 47 | /* This blacklist blocks most Tor connections on your server. */ 48 | dnsbl tor_dan_me_uk { 49 | dns tor.dan.me.uk; 50 | action gzline; 51 | duration 6h; 52 | reason "Your IP %ip has been found in tor.dan.me.uk blacklist."; 53 | } 54 | -------------------------------------------------------------------------------- /conf/examples/exceptions.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Few examples on how to exempt certain masks from bans. 3 | * CIDR notations are not (yet) supported. 4 | * Exempt bans will also include any entries in bans.conf 5 | */ 6 | 7 | 8 | /* 9 | * General purpose exception block. 10 | * Useful if you want to make individual exception blocks for a single person. 11 | * This is the recommended format. 12 | * You can define exception types in the "type { }" sub-block. 13 | * Types are: kline, gline, zline, gzline, throttle, require, shun, dnsbl, kill, spamfilter 14 | */ 15 | 16 | except ban { 17 | mask { 18 | /* General masks, you can either put an IP or ident@host format here. */ 19 | *@Lenovo; 20 | 127.0.0.1; 21 | // 192.168.*.*; 22 | 23 | certfp { 24 | /* CHANGE OR REMOVE THIS CERT FINGERPRINT! */ 25 | 396f243c2a7ab0bb71eb76becfca9bbf6f4931ec7b76cf9e9ab5552722c503cc; 26 | } 27 | 28 | account { 29 | /* Exempt by services account. Requires Anope to be running. 30 | * Account exemption does not work for Z:lines because those are getting checked 31 | * very early in the connection process, before SASL even takes place. 32 | */ 33 | SomeAccount1; 34 | OtherAccount; 35 | } 36 | } 37 | /* 38 | * Types of ban to exempt for. If left out, this block matches all ban types (not recommended). 39 | * Valid types are: throttle, dnsbl, kline, gline, zline, gzline, shun, spamfilter. 40 | */ 41 | type { shun; kline; throttle; gzline; require; dnsbl; } 42 | 43 | } 44 | 45 | 46 | /* 47 | * Single purpose exception blocks, very basic. 48 | */ 49 | 50 | except shun { 51 | mask *@127.0.0.1; 52 | } 53 | 54 | except spamfilter { 55 | mask { 56 | *@greg.goodguy.com; 57 | /* This channel will be exempt from any spamfilter matches. */ 58 | "#circus"; 59 | } 60 | } 61 | 62 | except throttle { 63 | mask { 64 | *@localhost; 65 | 127.0.0.1; 66 | 192.168.*.*; 67 | } 68 | } 69 | 70 | except require { 71 | mask { 72 | *@localhost; 73 | ip { 74 | 127.0.0.1; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /conf/examples/ircd.example.motd: -------------------------------------------------------------------------------- 1 | Write your motd here. 2 | Place rename this file to ircd.motd and move it to the 'conf' directory. -------------------------------------------------------------------------------- /conf/examples/links.example.conf: -------------------------------------------------------------------------------- 1 | link services.example.org { 2 | /* Allow incoming link request originating from this host. */ 3 | incoming { 4 | mask { 5 | 127.0.0.1; 6 | } 7 | } 8 | auth { 9 | password linklink; 10 | } 11 | class servers; 12 | } 13 | 14 | 15 | link server2.example.org { 16 | /* Outgoing connection to server 127.0.0.1:6900 */ 17 | outgoing { 18 | host 127.0.0.1; 19 | port 6900; 20 | options { 21 | /* 22 | * Uses TLS to connect to the remote server. 23 | * Make sure the remote server and port are listening for TLS connections. 24 | */ 25 | tls; 26 | 27 | /* Auto connect attempts are made in semi-random intervals. */ 28 | autoconnect; 29 | } 30 | } 31 | 32 | /* 33 | * In the auth block you can set specific requirements for incoming links. 34 | * password: Authorisation based on unsecure plain text password. 35 | This password will also be sent to outgoing links and checked on the other side. 36 | * fingerprint: Authorisation based on certificate fingerprint. 37 | Run "ircd.py --certfp" to see your certificate fingerprint and give it to the other side. 38 | This is the preferred method. 39 | * common-name: Additional authorisation based on certificate CN. 40 | If your CN contains spaces, make sure to replace them with underscores. 41 | Run "ircd.py --certcn" to see your certificate CN and give it to the other side. 42 | This method requires at least one additional authentication method (password or fingerprint). 43 | * 44 | * If you combine multiple methods, all methods will be checked and must be valid. 45 | */ 46 | auth { 47 | password legacypassword; 48 | fingerprint 1fd5776df0eb43a06445a1038a2859071f7fe162c475adb2c5deae0e3a3a1db0; 49 | common-name "valid.common.name"; 50 | } 51 | 52 | /* 53 | * The class that this connection will be placed in. 54 | * Must be a class defined in a class { } block. 55 | */ 56 | class servers; 57 | } 58 | -------------------------------------------------------------------------------- /conf/examples/modules.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Modules to load go here. 3 | * You can unload a module by commenting it. 4 | */ 5 | 6 | module "m_pingpong"; 7 | module "m_nick"; 8 | module "m_user"; 9 | module "m_cap"; 10 | 11 | 12 | /* 13 | * Core functionality. 14 | */ 15 | 16 | module "m_admin"; 17 | module "m_away"; 18 | module "m_cycle"; 19 | module "m_ison"; 20 | module "m_invite"; 21 | module "m_joinpart"; 22 | module "m_kick"; 23 | module "m_list"; 24 | module "m_lusers"; 25 | module "m_mode"; 26 | module "m_monitor"; 27 | module "m_motd"; 28 | module "m_msg"; 29 | module "m_names"; 30 | module "m_quit"; 31 | module "m_topic"; 32 | module "m_version"; 33 | module "m_watch"; 34 | module "m_who"; 35 | module "m_whois"; 36 | module "m_tkl"; 37 | module "blacklist"; 38 | module "geodata"; 39 | module "account"; 40 | module "m_webirc"; 41 | module "irc_websockets"; 42 | module "usermodes/coremodes"; 43 | module "chanmodes/moderated"; 44 | module "chanmodes/topiclimit"; 45 | module "chanmodes/noexternal"; 46 | module "chanmodes/key"; 47 | module "chanmodes/limit"; 48 | module "chanmodes/redirect"; 49 | module "chanmodes/chanowner"; 50 | module "chanmodes/chanadmin"; 51 | module "chanmodes/chanop"; 52 | module "chanmodes/halfop"; 53 | module "chanmodes/voice"; 54 | module "chanmodes/ban"; 55 | module "chanmodes/excepts"; 56 | module "chanmodes/invex"; 57 | 58 | 59 | /* 60 | * Recommended modules. 61 | */ 62 | 63 | module "m_chghost"; 64 | module "m_chgname"; 65 | module "m_clones"; 66 | module "m_die"; 67 | module "m_kill"; 68 | module "m_oper"; 69 | module "m_rehash"; 70 | module "m_restart"; 71 | module "m_sajoinpart"; 72 | module "m_sanick"; 73 | module "m_sasl"; 74 | module "m_sethost"; 75 | module "m_setname"; 76 | module "m_spamfilter"; 77 | module "m_stats"; 78 | module "m_time"; 79 | module "m_wallops"; 80 | module "m_helpop"; 81 | module "m_antirandom"; 82 | module "m_cloak"; 83 | module "m_ircops"; 84 | module "m_map"; 85 | module "certfp"; 86 | module "m_modules"; 87 | module "m_listdelay"; 88 | module "knock"; 89 | module "starttls"; 90 | module "m_quotes"; 91 | //module "founder"; 92 | module "ircv3/batch"; 93 | module "ircv3/standard-replies"; 94 | module "ircv3/account-notify"; 95 | module "ircv3/account-tag"; 96 | module "ircv3/echo-message"; 97 | module "ircv3/message-ids"; 98 | module "ircv3/messagetags"; 99 | module "ircv3/oper-tag"; 100 | module "ircv3/userhost-tag"; 101 | module "ircv3/server_time"; 102 | module "ircv3/reply"; 103 | module "ircv3/typingtag"; 104 | module "ircv3/channel-context"; 105 | module "ircv3/labeled-response"; 106 | module "ircv3/chathistory"; 107 | module "ircv3/channel_rename"; 108 | module "ircv3/no_implicit_names"; 109 | 110 | 111 | /* 112 | * Modules required for linking. 113 | */ 114 | 115 | module "m_connect"; 116 | module "m_eos"; 117 | module "m_error"; 118 | module "m_md"; 119 | module "m_netinfo"; 120 | module "m_pass"; 121 | module "m_protoctl"; 122 | module "m_sendumode"; 123 | module "m_server"; 124 | module "m_sjoin"; 125 | module "m_squit"; 126 | module "m_svsjoinpart"; 127 | module "m_svskill"; 128 | module "m_svsmode"; 129 | module "m_svsnick"; 130 | module "m_swhois"; 131 | 132 | 133 | /* 134 | * Extra channel modes. 135 | */ 136 | 137 | module "chanmodes/m_blockcolors"; 138 | module "chanmodes/m_chanstrip"; 139 | module "chanmodes/m_history"; 140 | module "chanmodes/m_regonly"; 141 | module "chanmodes/registered"; 142 | module "chanmodes/secret"; 143 | module "chanmodes/noctcp"; 144 | module "chanmodes/nonotice"; 145 | module "chanmodes/nonick"; 146 | module "chanmodes/noinvite"; 147 | module "chanmodes/opersonly"; 148 | module "chanmodes/secureonly"; 149 | module "chanmodes/nokick"; 150 | module "chanmodes/permanent"; 151 | module "chanmodes/extbans/timedbans"; 152 | module "chanmodes/extbans/textban"; 153 | module "chanmodes/extbans/certfp"; 154 | module "chanmodes/extbans/operclass"; 155 | module "chanmodes/extbans/accountban"; 156 | 157 | 158 | /* 159 | * Extra user modes. 160 | */ 161 | 162 | module "usermodes/m_blockmsg"; 163 | module "usermodes/m_callerid"; 164 | module "usermodes/bot"; 165 | module "usermodes/noctcp"; 166 | -------------------------------------------------------------------------------- /conf/examples/opers.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Do not leave these as the default. 3 | */ 4 | 5 | oper admin { 6 | /* Operclasses are defined in operclass.example.conf */ 7 | operclass netadmin; 8 | 9 | /* The class for the oper as defined in ircd.example.conf `class` block. */ 10 | class opers; 11 | 12 | /* 13 | * Only connections matching these masks can use this oper account. 14 | * If you set a cerfp mask or an account mask, users matching that mask 15 | * will automatically gain oper access on connect. You can add multiple entries. 16 | * If you choose to add a certfp or account to the mask, you do not need a password to oper-up. 17 | * Keep your certificate safe and secure, and never share it with anyone. 18 | * Important: Only one single option needs to pass the check, so don't add too much. 19 | * You can remove/comment unwanted or irrelevant masks. 20 | */ 21 | mask { 22 | *@*.some.trusted.host; 23 | certfp { 24 | /* CHANGE OR REMOVE THIS CERT FINGERPRINT! */ 25 | 396f243c2a7ab0bb71eb76becfca9bbf6f4931ec7b76cf9e9ab5552722c503cc; 26 | } 27 | 28 | /* Users matching these services account are automatically opered up. */ 29 | account { 30 | SomeOperAccount; 31 | } 32 | 33 | /* 34 | * Lock this oper block behind these IP addresses. 35 | * Will be bypassed on account or certfp match. 36 | */ 37 | ip { 38 | 127.0.0.1; 39 | } 40 | } 41 | 42 | 43 | /* 44 | * Password required to oper up. Change this. 45 | * You can also put bcrypt encrypted passwords here, but this requires the bcrypt package installed from pip. 46 | */ 47 | password "adminpass"; 48 | 49 | /* 50 | * These snomasks will be set upon a successful oper up. 51 | * Requires usermode 's' to be present in the "modes" options below. 52 | */ 53 | snomasks "cdfjknostwCFGLNQS"; 54 | 55 | /* These modes will be set upon a successful oper up. */ 56 | modes "stw"; 57 | 58 | /* 59 | * Set the hostmask of the client after successful oper up. 60 | * Requires usermode +t to be set. 61 | */ 62 | operhost "netadmin.example.org"; 63 | 64 | /* Display an extra info line in the /whois for this oper. */ 65 | swhois "is an IRC Administrator"; 66 | } 67 | 68 | 69 | 70 | /* 71 | * Example of limited local IRC operator block 72 | * using 'locop' oper-class as defined in operclass.example.conf 73 | */ 74 | 75 | oper locop { 76 | operclass locop; 77 | class opers; 78 | mask *@*; 79 | password "locoppass"; 80 | snomasks "cdfjkostwCFGNQS"; 81 | modes "s"; 82 | operhost "locop.example.org"; 83 | swhois "is a Local IRC Operator"; 84 | } 85 | -------------------------------------------------------------------------------- /conf/examples/require.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Define specific requirements for incoming connections. 3 | * Currently only 'authentication' is supported. 4 | */ 5 | 6 | 7 | 8 | /* 9 | * Requires connections from *@some.annoying.host to be authenticated via services (Anope) 10 | * before being allowed to connect. This only works with the SASL module enabled. 11 | * Any mask type is supported. 12 | */ 13 | 14 | require authentication { 15 | mask *@some.annoying.host; 16 | reason "Known spam host"; 17 | } 18 | 19 | require authentication { 20 | mask { 21 | country { RU; }; 22 | } 23 | reason "Please configure your client to authenticate via SASL"; 24 | } 25 | -------------------------------------------------------------------------------- /conf/examples/spamfilter.example.conf: -------------------------------------------------------------------------------- 1 | /* 2 | * Valid targets are: channel, private, private-notice, channel-notice, part, topic, away, quitd 3 | * Valid actions are: warn, block, kill, gzline 4 | * 5 | * If you pick gzline as action, you also need to add a duration, such as 6h (6 hours) or 1d (1 day). 6 | /* 7 | 8 | /* Basic example of a simple spamfilter that checks for matches in private/channel targets */ 9 | spamfilter { 10 | match-type simple; 11 | match "something spammy"; 12 | target { private; channel; } 13 | action block; 14 | reason "Spamfilter testing"; 15 | } 16 | 17 | /* Example using regex. */ 18 | spamfilter { 19 | match-type regex; 20 | match "^!packet (?:[0-9]{1,3}\.){3}[0-9]{1,3}"; 21 | target channel; 22 | action block; 23 | reason "Attempting to use a GTBot"; 24 | } 25 | -------------------------------------------------------------------------------- /handle/functions.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import string 4 | import socket 5 | 6 | from handle.logger import logging 7 | 8 | 9 | # Author: strlcat - https://github.com/strlcat 10 | def ip_type(ip): 11 | def isxdigit(s): 12 | return all(c in string.hexdigits for c in s) 13 | 14 | if isxdigit(ip.replace(':', '')): 15 | return socket.AF_INET6 16 | if ip.replace('.', '').isdigit(): 17 | return socket.AF_INET 18 | return 0 19 | 20 | 21 | # Author: strlcat - https://github.com/strlcat 22 | def fixup_ip6(ip6): 23 | ipt = ip_type(ip6) 24 | if ipt != socket.AF_INET6: 25 | return ip6 26 | if ip6[:2] == "::": 27 | return '0' + ip6 28 | return ip6 29 | 30 | 31 | def reverse_ip(ip: str) -> str: 32 | octets = ip.split('.') 33 | return '.'.join(octets[::-1]) 34 | 35 | 36 | def valid_expire(s: str) -> bool | int: 37 | spu = dict(s=1, m=60, h=3600, d=86400, w=604800, M=2592000) 38 | s = str(s) if isinstance(s, int) else s 39 | if s.isdigit(): 40 | return int(s) * 60 41 | if s[-1] not in spu: 42 | return False 43 | try: 44 | return int(s[:-1]) * spu[s[-1]] 45 | except ValueError: 46 | return False 47 | 48 | 49 | def ip_to_base64(ip: str) -> str | None: 50 | if ip == '*': 51 | return None 52 | try: 53 | hex_octets = [f"{int(octet):02X}" for octet in ip.split('.')] 54 | hex_str = ''.join(hex_octets) 55 | binary_data = binascii.unhexlify(hex_str) 56 | return base64.b64encode(binary_data).decode() 57 | except (ValueError, binascii.Error) as ex: 58 | logging.exception(f"Error encoding IP address {ip}: {ex}") 59 | return None 60 | 61 | 62 | def base64_to_ip(base: str): 63 | try: 64 | ip = [] 65 | string = base64.b64decode(base) 66 | hex_string = binascii.hexlify(string).decode() 67 | for e in range(0, len(hex_string), 2): 68 | a = hex_string[e:e + 2] 69 | num = int(a, 16) 70 | ip.append(str(num)) 71 | ip = '.'.join(ip) 72 | return ip 73 | except Exception as ex: 74 | logging.exception(ex) 75 | 76 | 77 | def make_mask(data: str) -> str: 78 | # Check if data should be treated as host 79 | if '!' not in data and '@' not in data and ('.' in data or ':' in data): 80 | nick = '*' 81 | ident = '*' 82 | host = data 83 | else: 84 | # Assign nick 85 | nick = data.split('!')[0] 86 | if not nick or '@' in nick: 87 | nick = '*' 88 | 89 | # Assign ident 90 | if '@' in data: 91 | ident_part = data.split('@')[0] 92 | if '!' in ident_part: 93 | ident = ident_part.split('!')[1] 94 | else: 95 | ident = ident_part 96 | else: 97 | if '!' in data: 98 | ident = data.split('!')[1] 99 | else: 100 | ident = '*' 101 | 102 | if '@' in data: 103 | host = data.split('@')[1] 104 | else: 105 | host = '*' 106 | 107 | nick = f"*{nick[-20:]}" if len(nick) > 32 else nick or '*' 108 | ident = f"*{ident[-12:]}" if len(ident) > 12 else ident or '*' 109 | host = f"*{host[-64:]}" if len(host) > 64 else host or '*' 110 | 111 | return f"{nick}!{ident}@{host}" 112 | 113 | 114 | def is_match(first: str, second: str, memo=None) -> bool: 115 | if memo is None: 116 | memo = {} 117 | 118 | key = (first, second) 119 | if key in memo: 120 | return memo[key] 121 | 122 | if not first: 123 | result = not second 124 | elif first[0] == '*': 125 | result = is_match(first[1:], second, memo) or (second and is_match(first, second[1:], memo)) 126 | elif second and (first[0] == '?' or first[0] == second[0]): 127 | result = is_match(first[1:], second[1:], memo) 128 | else: 129 | result = False 130 | 131 | memo[key] = result 132 | return result 133 | -------------------------------------------------------------------------------- /handle/log.py: -------------------------------------------------------------------------------- 1 | from handle.core import IRCD, Command 2 | from classes.data import Flag 3 | 4 | 5 | class LogEntry: 6 | color_table = {"warn": '7', "error": '4', "info": '3'} 7 | 8 | def __init__(self, client, level, rootevent, event, message): 9 | self.client = client 10 | self.level = level 11 | self.rootevent = rootevent 12 | self.event = event 13 | self.message = message 14 | self.snomask = Log.event_to_snomask(rootevent, event) 15 | 16 | 17 | class Log: 18 | event_map = { 19 | # rootevent, event (optional), snomask 20 | ("connect", "LOCAL_USER_CONNECT"): 'c', 21 | ("connect", "LOCAL_USER_QUIT"): 'c', 22 | ("connect", "REMOTE_USER_CONNECT"): 'C', 23 | ("connect", "REMOTE_USER_QUIT"): 'C', 24 | ("spamfilter", None): 'F', 25 | ("flood", None): 'f', 26 | ("tkl", None): 'G', 27 | ("oper", None): 'o', 28 | ("link", None): 'L', 29 | ("kill", None): 'k', 30 | ("sajoin", None): 'S', 31 | ("sapart", None): 'S', 32 | ("sanick", None): 'S', 33 | ("join", None): 'j', 34 | ("part", None): 'j', 35 | ("kick", None): 'j', 36 | ("nick", "LOCAL_NICK_CHANGE"): 'n', 37 | ("nick", "REMOTE_NICK_CHANGE"): 'N', 38 | ("blacklist", None): 'd', 39 | } 40 | 41 | @staticmethod 42 | def event_to_snomask(rootevent, event): 43 | return Log.event_map.get((rootevent, event), Log.event_map.get((rootevent, None), 's')) 44 | 45 | @staticmethod 46 | def log_to_remote(log_entry: LogEntry): 47 | if IRCD.me.creationtime: 48 | source = log_entry.client.id if log_entry.client.id else log_entry.client.name 49 | data = f":{source} SLOG {log_entry.level} {log_entry.rootevent} {log_entry.event} {log_entry.message}" 50 | IRCD.send_to_servers(log_entry.client.direction, [], data) 51 | 52 | @staticmethod 53 | def log(client, level: str, rootevent: str, event: str, message: str, sync: int = 1): 54 | """ 55 | client: Client information for the log event 56 | """ 57 | 58 | source = client if client.server else client.uplink 59 | log_entry = LogEntry(source, level, rootevent, event, message) 60 | 61 | level_colored = f"{LogEntry.color_table.get(level, '')}[{level}]" if level in LogEntry.color_table else f"[{level}]" 62 | # out_msg = f"14{rootevent}.{event} {level_colored} {message}" 63 | out_msg = f"{level_colored} ({rootevent}) {message}" 64 | 65 | if log_entry.snomask: 66 | IRCD.send_snomask(client, log_entry.snomask, out_msg, sendsno=0) 67 | 68 | if log_chan := IRCD.find_channel(IRCD.get_setting("logchan")): 69 | log_chan.broadcast(source, f":{source.name} PRIVMSG {log_chan.name} :{out_msg}") 70 | 71 | if sync: 72 | Log.log_to_remote(log_entry) 73 | 74 | @staticmethod 75 | def cmd_slog(client, recv): 76 | # :source SLOG :message 77 | # :001 SLOG warn link EVENT :This is a warning 78 | level, rootevent, event = recv[1:4] 79 | message = ' '.join(recv[4:]).removeprefix(':').strip() 80 | IRCD.log(client, level, rootevent, event, message) 81 | 82 | Command.add(None, cmd_slog, "SLOG", 4, Flag.CMD_SERVER) 83 | -------------------------------------------------------------------------------- /ircd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin python3 2 | 3 | import argparse 4 | import sys 5 | import os 6 | from socket import socket 7 | 8 | from OpenSSL.crypto import load_certificate, FILETYPE_PEM, Error 9 | from handle.logger import logging 10 | from classes.configuration import ConfigBuild 11 | from handle.core import IRCD 12 | 13 | if __name__ == "__main__": 14 | if sys.version_info < (3, 10, 0): 15 | logging.error("Python version 3.10 or higher is required.") 16 | sys.exit() 17 | 18 | if sys.platform.startswith("linux") and os.geteuid() == 0: 19 | logging.error("Do not run as root!") 20 | sys.exit() 21 | 22 | parser = argparse.ArgumentParser(description="ProvisionIRCd") 23 | parser.add_argument("-c", "--conf", help="Relative path to main configuration file", default="ircd.conf") 24 | parser.add_argument("--debug", help="Show debug output in console", action="store_true") 25 | parser.add_argument("--fork", help="Fork to the background", action="store_true") 26 | parser.add_argument("--certfp", help="Prints the server certificate fingerprint", action="store_true") 27 | parser.add_argument("--certcn", help="Prints the server certificate CN", action="store_true") 28 | parser.add_argument("--rehash", help="Rehashes the server configuration file", action="store_true") 29 | parser.add_argument("--restart", help="Restarts the IRCd", action="store_true") 30 | 31 | try: 32 | import bcrypt 33 | 34 | parser.add_argument("--mkpasswd", help="Generate bcrypt password to use in opers.conf") 35 | except ImportError: 36 | bcrypt = None 37 | 38 | args = parser.parse_args() 39 | 40 | if bcrypt and args.mkpasswd: 41 | hashed = bcrypt.hashpw(args.mkpasswd.encode(), bcrypt.gensalt()).decode() 42 | logging.info(f"Your salted password: {hashed}") 43 | sys.exit() 44 | 45 | if args.certfp or args.certcn: 46 | for file in filter(lambda f: f.endswith(".pem"), os.listdir("tls")): 47 | with open(os.path.join("tls", file), "rb") as cert_file: 48 | try: 49 | cert = load_certificate(FILETYPE_PEM, cert_file.read()) 50 | if args.certfp: 51 | fingerprint = cert.digest("sha256").decode().replace(':', '').lower() 52 | logging.info(f"[{file}] Fingerprint: {fingerprint}") 53 | if args.certcn: 54 | if cn := cert.get_subject().commonName: 55 | cn = cn.replace(' ', '_') 56 | logging.info(f"[{file}] CN: {cn}") 57 | except Error: 58 | pass 59 | except Exception as ex: 60 | logging.error(f"Unable to read certificate file {file}: {ex}") 61 | sys.exit() 62 | 63 | if args.rehash: 64 | try: 65 | with socket() as sock: 66 | # sock.settimeout(1) 67 | sock.connect(("127.0.0.1", 65432)) 68 | sock.sendall(b"REHASH") 69 | resp = b''.join(iter(lambda: sock.recv(1024), b'')).decode() 70 | 71 | if resp == '1': 72 | logging.info("Configuration file rehashed successfully.") 73 | else: 74 | IRCD.cli_resp(resp) 75 | logging.error("Configuration file failed to reload.") 76 | 77 | except (ConnectionRefusedError, TimeoutError): 78 | logging.error("Rehashing failed. Is the server running?") 79 | 80 | sys.exit() 81 | 82 | if args.restart: 83 | try: 84 | with socket() as sock: 85 | sock.settimeout(0.1) 86 | sock.connect(("127.0.0.1", 65432)) 87 | sock.sendall(b"RESTART") 88 | resp = b''.join(iter(lambda: sock.recv(1024), b'')).decode() 89 | 90 | except (ConnectionRefusedError, TimeoutError): 91 | logging.error("Rehashing failed. Is the server running?") 92 | 93 | sys.exit() 94 | try: 95 | if ConfigBuild(conffile=args.conf, debug=args.debug).is_ok(): 96 | IRCD.boot(fork=args.fork) 97 | except Exception as ex: 98 | logging.exception(ex) 99 | -------------------------------------------------------------------------------- /modules/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides support for account names when logged in with services 3 | """ 4 | 5 | from handle.core import IRCD, Numeric, Hook 6 | 7 | 8 | def account_nickchange(client, newnick): 9 | if 'r' in client.user.modes and newnick.lower() != client.user.account.lower(): 10 | client.user.modes = client.user.modes.replace('r', '') 11 | client.send([], f":{IRCD.me.name} MODE {client.name} -r") 12 | IRCD.send_to_servers(client, [], f":{client.id} MODE {client.name} -r") 13 | 14 | 15 | def account_changed(client, old_account): 16 | if client.user.account == '*': 17 | client.sendnumeric(Numeric.RPL_LOGGEDOUT, client.fullrealhost) 18 | else: 19 | client.sendnumeric(Numeric.RPL_LOGGEDIN, client.fullrealhost, client.user.account, client.user.account) 20 | 21 | 22 | def account_whois(client, whois_client, lines): 23 | if whois_client.user.account != '*': 24 | line = Numeric.RPL_WHOISACCOUNT, whois_client.name, whois_client.user.account 25 | lines.append(line) 26 | 27 | 28 | def account_check_connection(client): 29 | if (IRCD.is_except_client("require", client) 30 | or not all(IRCD.find_command(cmd) for cmd in ["SASL", "AUTHENTICATE"]) 31 | or not IRCD.find_client(IRCD.get_setting("sasl-server"))): 32 | return Hook.CONTINUE 33 | 34 | for require in [r for r in IRCD.configuration.requires if r.what == "authentication"]: 35 | if require.mask.is_match(client) and client.user.account == '*': 36 | msg = f"You need to be logged into an account to connect to this server" 37 | if require.reason and require.reason.strip(): 38 | msg += f": {require.reason}" 39 | else: 40 | msg += '.' 41 | if client.has_capability("standard-replies"): 42 | client.send([], f"FAIL * ACCOUNT_REQUIRED_TO_CONNECT :{msg}") 43 | else: 44 | IRCD.server_notice(client, msg) 45 | return Hook.DENY 46 | 47 | return Hook.CONTINUE 48 | 49 | 50 | def init(module): 51 | Hook.add(Hook.PRE_CONNECT, account_check_connection) 52 | Hook.add(Hook.ACCOUNT_LOGIN, account_changed) 53 | Hook.add(Hook.WHOIS, account_whois) 54 | Hook.add(Hook.LOCAL_NICKCHANGE, account_nickchange) 55 | -------------------------------------------------------------------------------- /modules/certfp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client certificate fingerprint support 3 | """ 4 | 5 | from handle.core import IRCD, Numeric, Hook 6 | 7 | 8 | def certfp_connect(client): 9 | if fingerprint := client.get_md_value("certfp"): 10 | IRCD.server_notice(client, f"Your TLS fingerprint is: {fingerprint}") 11 | 12 | 13 | def extract_client_cn(cert): 14 | subject = cert.get_subject() 15 | for component in subject.get_components(): 16 | if component[0] == b"CN": 17 | return component[1].decode("utf-8") 18 | return None 19 | 20 | 21 | def extract_client_san(cert): 22 | ext_count = cert.get_extension_count() 23 | for i in range(ext_count): 24 | ext = cert.get_extension(i) 25 | if ext.get_short_name() == b"subjectAltName": 26 | return str(ext) 27 | return None 28 | 29 | 30 | def get_certfp(client): 31 | if not client.local.tls or client.get_md_value("certfp"): 32 | return 33 | 34 | if not (cert := client.local.socket.get_peer_certificate()): 35 | return 36 | 37 | if cn := extract_client_cn(cert): 38 | client.add_md(name="cert_cn", value=cn, sync=0) 39 | 40 | if san := extract_client_san(cert): 41 | client.add_md(name="cert_san", value=san, sync=0) 42 | 43 | fingerprint = cert.digest("SHA256").decode().lower().replace(':', '') 44 | client.add_md(name="certfp", value=fingerprint) 45 | 46 | 47 | def certfp_whois(client, target, lines): 48 | if fingerprint := target.get_md_value("certfp"): 49 | line = (Numeric.RPL_WHOISCERTFP, target.name, fingerprint) 50 | lines.append(line) 51 | 52 | 53 | def init(module): 54 | """ Grab certificate first (if any) so that we can work with it. """ 55 | Hook.add(Hook.NEW_CONNECTION, get_certfp, priority=9999) 56 | Hook.add(Hook.SERVER_LINK_OUT_CONNECTED, get_certfp, priority=9999) 57 | # Also call get_certfp() on LOCAL_CONNECT for md.sync() 58 | Hook.add(Hook.LOCAL_CONNECT, get_certfp, priority=9999) 59 | Hook.add(Hook.LOCAL_CONNECT, certfp_connect) 60 | Hook.add(Hook.WHOIS, certfp_whois) 61 | -------------------------------------------------------------------------------- /modules/chanmodes/auditorium.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +u (auditorium) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook 6 | 7 | 8 | def can_see_member(client, target, channel): 9 | if 'u' in channel.modes: 10 | if channel.find_member(client) and (channel.client_has_seen(client, target) 11 | or client.has_permission("channel:see:names") or client == target): 12 | return Hook.CONTINUE 13 | if not channel.client_has_membermodes(target, "hoaq") and not channel.client_has_membermodes(client, "oaq"): 14 | return Hook.DENY 15 | 16 | return Hook.CONTINUE 17 | 18 | 19 | def init(module): 20 | Cmode_u = Channelmode() 21 | Cmode_u.flag = 'u' 22 | Cmode_u.is_ok = Channelmode.allow_chanowner 23 | Cmode_u.desc = "Only +h or higher are visible on the channel" 24 | Channelmode.add(module, Cmode_u) 25 | Hook.add(Hook.VISIBLE_ON_CHANNEL, can_see_member) 26 | -------------------------------------------------------------------------------- /modules/chanmodes/ban.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +b (channel bans) 3 | """ 4 | 5 | from handle.core import Numeric, Channelmode, Hook 6 | 7 | HEADER = { 8 | "name": "channelbans" 9 | } 10 | 11 | 12 | def display_banlist(client, channel, mode): 13 | if mode == 'b': 14 | if channel.client_has_membermodes(client, "hoaq") or client.has_permission("channel:see:banlist"): 15 | for entry in reversed(channel.List[mode]): 16 | client.sendnumeric(Numeric.RPL_BANLIST, channel.name, entry.mask, entry.set_by, entry.set_time) 17 | client.sendnumeric(Numeric.RPL_ENDOFBANLIST, channel.name) 18 | return 1 19 | 20 | 21 | def ban_can_join(client, channel, key): 22 | if (channel.is_banned(client) and not channel.is_exempt(client)) and not client.has_permission("override:channel:join:ban"): 23 | return Numeric.ERR_BANNEDFROMCHAN 24 | return 0 25 | 26 | 27 | def ban_can_send(client, channel, message, sendtype): 28 | if (channel.is_banned(client) and not channel.is_exempt(client)) and not client.has_permission("override:channel:join:ban"): 29 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "Cannot send to channel (+b)") 30 | return Hook.DENY 31 | return Hook.CONTINUE 32 | 33 | 34 | def init(module): 35 | Hook.add(Hook.CHAN_LIST_ENTRY, display_banlist) 36 | Hook.add(Hook.CAN_JOIN, ban_can_join) 37 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, ban_can_send) 38 | Chmode_b = Channelmode() 39 | Chmode_b.flag = 'b' 40 | Chmode_b.sjoin_prefix = '&' 41 | Chmode_b.paramcount = 1 42 | Chmode_b.unset_with_param = 1 43 | Chmode_b.type = Channelmode.LISTMODE 44 | Chmode_b.param_help = "" 45 | Chmode_b.desc = "Bans the given hostmask from joining the channel" 46 | Channelmode.add(module, Chmode_b) 47 | -------------------------------------------------------------------------------- /modules/chanmodes/chanadmin.py: -------------------------------------------------------------------------------- 1 | """ 2 | chanadmin mode (+a) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode, Hook, Numeric 6 | 7 | 8 | def list_chanadmins(client, channel, mode): 9 | if mode == 'a' and (cmode := IRCD.get_channelmode_by_flag(mode)): 10 | for entry in [c for c in reversed(channel.clients()) if channel.client_has_membermodes(c, mode)]: 11 | client.sendnumeric(Numeric.RPL_ALIST, channel.name, entry.name) 12 | client.sendnumeric(Numeric.RPL_ENDOFALIST, channel.name) 13 | return 1 14 | 15 | 16 | def validate_member(client, channel, action, mode, param, CHK_TYPE): 17 | param_client = IRCD.find_client(param) 18 | if CHK_TYPE == Channelmode.CHK_ACCESS: 19 | if channel.client_has_membermodes(client, 'q') or not client.local: 20 | return 1 21 | if action == '-' and param_client == client: 22 | # Always allow unset on self. 23 | return 1 24 | return 0 25 | return 0 26 | 27 | 28 | def init(module): 29 | Cmode_a = Channelmode() 30 | Cmode_a.flag = 'a' 31 | Cmode_a.prefix = '&' 32 | Cmode_a.sjoin_prefix = '~' 33 | Cmode_a.paramcount = 1 34 | Cmode_a.unset_with_param = 1 35 | Cmode_a.type = Channelmode.MEMBER 36 | Cmode_a.rank = 300 # Used to determine the position in PREFIX Isupport 37 | Cmode_a.level = 5 38 | Cmode_a.is_ok = validate_member 39 | Cmode_a.desc = "Give/take channel admin status" 40 | Channelmode.add(module, Cmode_a) 41 | Hook.add(Hook.CHAN_LIST_ENTRY, list_chanadmins) 42 | -------------------------------------------------------------------------------- /modules/chanmodes/chanop.py: -------------------------------------------------------------------------------- 1 | """ 2 | chanop mode (+o) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode 6 | 7 | 8 | def validate_member(client, channel, action, mode, param, CHK_TYPE): 9 | param_client = IRCD.find_client(param) 10 | if CHK_TYPE == Channelmode.CHK_ACCESS: 11 | if channel.client_has_membermodes(client, "oaq") or not client.local: 12 | return 1 13 | if action == '-' and param_client == client: 14 | # Always allow unset on self. 15 | return 1 16 | return 0 17 | return 0 18 | 19 | 20 | def init(module): 21 | Cmode_o = Channelmode() 22 | Cmode_o.flag = 'o' 23 | Cmode_o.prefix = '@' 24 | Cmode_o.sjoin_prefix = '@' 25 | Cmode_o.paramcount = 1 26 | Cmode_o.unset_with_param = 1 27 | Cmode_o.type = Channelmode.MEMBER 28 | Cmode_o.rank = 200 # Used to determine the position in PREFIX Isupport 29 | Cmode_o.level = 3 30 | Cmode_o.is_ok = validate_member 31 | Cmode_o.desc = "Give/take operator status" 32 | Channelmode.add(module, Cmode_o) 33 | -------------------------------------------------------------------------------- /modules/chanmodes/chanowner.py: -------------------------------------------------------------------------------- 1 | """ 2 | chanowner mode (+q) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode, Hook, Numeric 6 | 7 | 8 | def list_chanowners(client, channel, mode): 9 | if mode == 'q' and (cmode := IRCD.get_channelmode_by_flag(mode)): 10 | for entry in [c for c in reversed(channel.clients()) if channel.client_has_membermodes(c, mode)]: 11 | client.sendnumeric(Numeric.RPL_QLIST, channel.name, entry.name) 12 | client.sendnumeric(Numeric.RPL_ENDOFQLIST, channel.name) 13 | return 1 14 | 15 | 16 | def validate_member(client, channel, action, mode, param, CHK_TYPE): 17 | param_client = IRCD.find_client(param) 18 | if CHK_TYPE == Channelmode.CHK_ACCESS: 19 | if channel.client_has_membermodes(client, 'q') or not client.local: 20 | return 1 21 | if action == '-' and param_client == client: 22 | # Always allow unset on self. 23 | return 1 24 | return 0 25 | return 0 26 | 27 | 28 | def init(module): 29 | Cmode_q = Channelmode() 30 | Cmode_q.flag = 'q' 31 | Cmode_q.prefix = '~' 32 | Cmode_q.sjoin_prefix = '*' 33 | Cmode_q.paramcount = 1 34 | Cmode_q.unset_with_param = 1 35 | Cmode_q.type = Channelmode.MEMBER 36 | Cmode_q.rank = 400 # Used to determine the position in PREFIX Isupport 37 | Cmode_q.level = 5 38 | Cmode_q.is_ok = validate_member 39 | Cmode_q.desc = "Give/take channel owner status" 40 | Channelmode.add(module, Cmode_q) 41 | Hook.add(Hook.CHAN_LIST_ENTRY, list_chanowners) 42 | -------------------------------------------------------------------------------- /modules/chanmodes/excepts.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +e (except list) 3 | """ 4 | 5 | from handle.core import Numeric, Channelmode, Hook 6 | 7 | HEADER = { 8 | "name": "channelexcepts" 9 | } 10 | 11 | 12 | def display_exceptlist(client, channel, mode): 13 | if mode == 'e': 14 | if channel.client_has_membermodes(client, "hoaq") or client.has_permission("channel:see:banlist"): 15 | for entry in reversed(channel.List[mode]): 16 | client.sendnumeric(Numeric.RPL_EXLIST, channel.name, entry.mask, entry.set_by, entry.set_time) 17 | client.sendnumeric(Numeric.RPL_ENDOFEXLIST, channel.name) 18 | return 1 19 | 20 | 21 | def init(module): 22 | Hook.add(Hook.CHAN_LIST_ENTRY, display_exceptlist) 23 | Chmode_e = Channelmode() 24 | Chmode_e.flag = 'e' 25 | Chmode_e.sjoin_prefix = '"' 26 | Chmode_e.paramcount = 1 27 | Chmode_e.unset_with_param = 1 28 | Chmode_e.type = Channelmode.LISTMODE 29 | Chmode_e.param_help = "" 30 | Chmode_e.desc = "Exempts the mask from being banned" 31 | Channelmode.add(module, Chmode_e) 32 | -------------------------------------------------------------------------------- /modules/chanmodes/extbans/accountban.py: -------------------------------------------------------------------------------- 1 | """ 2 | account bans/exceptions/invex 3 | +b/e/I ~account:accountname 4 | """ 5 | 6 | from classes.data import Extban, Isupport 7 | 8 | 9 | def account_is_valid(client, channel, action, mode, param): 10 | if len(param.split(':')) != 2: 11 | return 0 12 | account = param.split(':')[1] 13 | if ('*' in account or '0' in account) and len(account) < 2: 14 | return 0 15 | return param 16 | 17 | 18 | def account_is_match(client, channel, mask): 19 | """ 20 | mask == raw ban entry from a channel. 21 | Called by channel.is_banned(), channel.is_exempt() or channel.is_invex() 22 | """ 23 | 24 | account_match = mask.split(':')[-1] 25 | if account_match == '*' and client.user.account != '*': 26 | return 1 27 | if account_match == '0' and client.user.account == '*': 28 | return 1 29 | if account_match.lower() == client.user.account.lower(): 30 | return 1 31 | 32 | 33 | class AccountBan: 34 | name = "account" 35 | flag = 'a' 36 | 37 | # Checks if the param is valid, in which case it returns it. 38 | is_ok = account_is_valid 39 | 40 | # Called by Channel.is_banned() and takes the client, channel, and the mask. 41 | is_match = account_is_match 42 | 43 | 44 | def init(module): 45 | Extban.add(AccountBan) 46 | Isupport.add("ACCOUNTEXTBAN", AccountBan.name + ',' + AccountBan.flag) 47 | -------------------------------------------------------------------------------- /modules/chanmodes/extbans/certfp.py: -------------------------------------------------------------------------------- 1 | """ 2 | certfp bans/exceptions/invex 3 | +b/e/I ~certfp: 4 | """ 5 | 6 | import re 7 | 8 | from classes.data import Extban 9 | 10 | 11 | def certfp_is_valid(client, channel, action, mode, param): 12 | if len(param.split(':')) < 2: 13 | return 0 14 | cert = param.split(':')[-1] 15 | pattern = r"[A-Fa-f0-9]{64}$" 16 | if not re.match(pattern, cert): 17 | return 0 18 | 19 | return param 20 | 21 | 22 | def certfp_is_match(client, channel, mask): 23 | """ 24 | mask == raw ban entry from a channel. 25 | Called by channel.is_banned(), channel.is_exempt() or channel.is_invex() 26 | """ 27 | 28 | fp_ban = mask.split(':')[-1] 29 | if not client.local.tls: 30 | return 0 31 | if (client_fp := client.get_md_value("certfp")) and client_fp.lower() == fp_ban.lower(): 32 | return 1 33 | 34 | 35 | class CertFp: 36 | name = "certfp" 37 | flag = 'S' 38 | paramcount = 1 39 | 40 | # Checks if the param is valid, in which case it returns it. 41 | is_ok = certfp_is_valid 42 | 43 | # Called by Channel.is_banned() and takes the client, channel, and the mask. 44 | is_match = certfp_is_match 45 | 46 | 47 | def init(module): 48 | Extban.add(CertFp) 49 | -------------------------------------------------------------------------------- /modules/chanmodes/extbans/operclass.py: -------------------------------------------------------------------------------- 1 | """ 2 | operclass bans/exceptions/invex 3 | ~operclass:name_of_operclass 4 | """ 5 | 6 | from classes.data import Extban 7 | from handle.functions import is_match 8 | 9 | 10 | def operclass_is_valid(client, channel, action, mode, param): 11 | if 'o' not in client.user.modes or len(param.split(':')) < 2: 12 | return 0 13 | 14 | return param 15 | 16 | 17 | def operclass_is_match(client, channel, mask): 18 | """ mask == raw ban entry from a channel """ 19 | if not client.user.operclass: 20 | return 0 21 | operclass = mask.split(':')[1] 22 | if is_match(operclass, client.user.operclass.name): 23 | return 1 24 | 25 | 26 | class OperclassExtban: 27 | name = "operclass" 28 | flag = 'O' 29 | 30 | # Checks if the param is valid, in which case it returns it. 31 | is_ok = operclass_is_valid 32 | 33 | # Called by Channel.is_banned() and takes the client and the mask. 34 | is_match = operclass_is_match 35 | 36 | 37 | def init(module): 38 | Extban.add(OperclassExtban) 39 | -------------------------------------------------------------------------------- /modules/chanmodes/extbans/textban.py: -------------------------------------------------------------------------------- 1 | """ 2 | ~text extban, textban 3 | """ 4 | 5 | import re 6 | 7 | from handle.core import Numeric, Hook 8 | from classes.data import Extban 9 | from handle.functions import is_match 10 | from handle.logger import logging 11 | from modules.chanmodes.extbans.timedbans import TimedBans 12 | 13 | 14 | def blockmsg_is_valid(client, channel, action, mode, param): 15 | if mode != 'b': 16 | logging.debug(f"Only +b is supported for this extban") 17 | return 0 18 | 19 | pattern = r":(block|replace):([^:]+)[:]?(.*)?$" 20 | if not (matches := re.findall(pattern, param)): 21 | logging.debug(f"Sub-param {param} does not meet the regex critera: {pattern}") 22 | return 0 23 | 24 | match = matches[0] 25 | 26 | tb_type = match[0] 27 | if tb_type == "replace" and len(match) < 3: 28 | return 0 29 | 30 | return param 31 | 32 | 33 | def check_text_block(client, channel, msg: list, prefix: str): 34 | for tb in channel.List['b']: 35 | mask_split = tb.mask.split(':') 36 | is_timed = mask_split[0][1:] == TimedBans.name 37 | 38 | if mask_split[0][0] != Extban.symbol or (mask_split[0][1:] not in [Textban.flag, Textban.name] 39 | and mask_split[2][1:] not in [Textban.flag, Textban.name]): 40 | continue 41 | 42 | tb_type = mask_split[1] if not is_timed else mask_split[3] 43 | tb_match = mask_split[2] if not is_timed else mask_split[4] 44 | 45 | match tb_type: 46 | case "block": 47 | if is_match(tb_match.lower(), ' '.join(msg).lower()): 48 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "Cannot send to channel (+b ~text)") 49 | return Hook.DENY 50 | 51 | case "replace": 52 | tb_replace_to = mask_split[3] 53 | msg[:] = [tb_replace_to if is_match(tb_match, word) else word for word in msg] 54 | 55 | 56 | class Textban: 57 | name = "text" 58 | flag = 'T' 59 | is_ok = blockmsg_is_valid 60 | 61 | 62 | def init(module): 63 | Extban.add(Textban) 64 | Hook.add(Hook.PRE_LOCAL_CHANMSG, check_text_block) 65 | Hook.add(Hook.PRE_LOCAL_CHANNOTICE, check_text_block) 66 | -------------------------------------------------------------------------------- /modules/chanmodes/extbans/timedbans.py: -------------------------------------------------------------------------------- 1 | """ 2 | timed bans, +b/e/I ~timed:: 3 | """ 4 | 5 | import time 6 | 7 | from handle.core import IRCD, Hook, Command 8 | from classes.data import Extban 9 | from handle.functions import make_mask 10 | from handle.logger import logging 11 | 12 | HEADER = { 13 | "name": "extbans/timedbans" 14 | } 15 | 16 | 17 | def check_expired_bans(): 18 | def send_mode_lines(modes): 19 | Command.do(IRCD.me, "MODE", chan.name, f"-{modes}", *bans, chan.creationtime) 20 | 21 | for chan in IRCD.get_channels(): 22 | modes = '' 23 | bans = [] 24 | for listmode in chan.List: 25 | for entry in list(chan.List[listmode]): 26 | mask_split = entry.mask.split(':') 27 | if mask_split[0][0] != Extban.symbol or mask_split[0][1:] not in [TimedBans.flag, TimedBans.name]: 28 | continue 29 | if not mask_split[1].isdigit(): 30 | continue 31 | duration = int(mask_split[1]) 32 | if int(time.time()) >= entry.set_time + (duration * 60): 33 | modes += listmode 34 | bans.append(entry.mask) 35 | if len(bans) >= 12: 36 | send_mode_lines(modes) 37 | bans = [] 38 | modes = '' 39 | if bans: 40 | send_mode_lines(modes) 41 | 42 | 43 | def timedban_is_valid(client, channel, action, mode, param): 44 | param_split = param.split(':') 45 | if len(param_split) < 3 or len(param_split) > 40: 46 | return 0 47 | b_time = param_split[1] 48 | if not b_time.isdigit(): 49 | return 0 50 | if len(param_split) > 3: 51 | extra = param_split[2:][-2] 52 | logging.debug(f"Extra: {extra}") 53 | logging.debug(f"Param split: {param_split}") 54 | extra = param_split[2] 55 | if not extra.startswith(Extban.symbol): 56 | return 0 57 | # (client, channel, action, mode, param): 58 | if not (ext := next((e for e in Extban.table if extra in [Extban.symbol + e.name, Extban.symbol + e.flag]), 0)): 59 | return 0 60 | ext_param = Extban.symbol + ext.name + ':' + param_split[-1] 61 | ext_param = Extban.symbol + ext.name + ':' + ':'.join(param_split[3:]) 62 | logging.debug(f"Ext param: {ext_param}") 63 | if ext.is_ok(client, channel, action, mode, ext_param): 64 | param = Extban.convert_param(param, convert_to_name=1) 65 | return param 66 | banmask = make_mask(param_split[-1]) 67 | param = f"{':'.join(param_split[:-1])}:{banmask}" 68 | return param 69 | 70 | 71 | def timedban_is_match(client, channel, mask): 72 | """ 73 | mask == raw ban entry from a channel. 74 | Called by channel.is_banned(), channel.is_exempt() or channel.is_invex() 75 | """ 76 | mask_split = mask.split(':') 77 | if len(mask_split) < 3: 78 | return 0 79 | mask = mask.split(':')[-1] 80 | if len(mask_split) > 3: 81 | extra = mask_split[2] 82 | if extra := next((e for e in Extban.table if extra in Extban.symbol + e.name == extra), 0): 83 | if extra.is_match(client, channel, mask): 84 | return 1 85 | return IRCD.client_match_mask(client, mask) 86 | 87 | 88 | class TimedBans: 89 | name = "timed" 90 | flag = 't' 91 | is_ok = timedban_is_valid 92 | is_match = timedban_is_match 93 | 94 | 95 | def init(module): 96 | Hook.add(Hook.LOOP, check_expired_bans) 97 | Extban.add(TimedBans) 98 | -------------------------------------------------------------------------------- /modules/chanmodes/halfop.py: -------------------------------------------------------------------------------- 1 | """ 2 | halfop mode (+h) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode 6 | 7 | 8 | def validate_member(client, channel, action, mode, param, CHK_TYPE): 9 | param_client = IRCD.find_client(param) 10 | if CHK_TYPE == Channelmode.CHK_ACCESS: 11 | if channel.client_has_membermodes(client, "oaq") or not client.local: 12 | return 1 13 | if action == '-' and param_client == client: 14 | # Always allow unset on self. 15 | return 1 16 | return 0 17 | return 0 18 | 19 | 20 | def init(module): 21 | Cmode_h = Channelmode() 22 | Cmode_h.flag = 'h' 23 | Cmode_h.prefix = '%' 24 | Cmode_h.sjoin_prefix = '%' 25 | Cmode_h.paramcount = 1 26 | Cmode_h.unset_with_param = 1 27 | Cmode_h.type = Channelmode.MEMBER 28 | Cmode_h.rank = 100 # Used to determine the position in PREFIX Isupport 29 | Cmode_h.level = 3 30 | Cmode_h.is_ok = validate_member 31 | Cmode_h.desc = "Give/take channel halfop status" 32 | Channelmode.add(module, Cmode_h) 33 | -------------------------------------------------------------------------------- /modules/chanmodes/invex.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +I (invex list) 3 | """ 4 | 5 | from handle.core import Channelmode, Isupport, Numeric, Hook 6 | 7 | HEADER = { 8 | "name": "channelinvex" 9 | } 10 | 11 | 12 | def display_invexlist(client, channel, mode): 13 | if mode == 'I': 14 | if channel.client_has_membermodes(client, "hoaq") or client.has_permission("channel:see:banlist"): 15 | for entry in reversed(channel.List[mode]): 16 | client.sendnumeric(Numeric.RPL_INVEXLIST, channel.name, entry.mask, entry.set_by, entry.set_time) 17 | client.sendnumeric(Numeric.RPL_ENDOFINVEXLIST, channel.name) 18 | return 1 19 | 20 | 21 | def init(module): 22 | Hook.add(Hook.CHAN_LIST_ENTRY, display_invexlist) 23 | Chmode_I = Channelmode() 24 | Chmode_I.flag = 'I' 25 | Chmode_I.sjoin_prefix = "'" 26 | Chmode_I.paramcount = 1 27 | Chmode_I.unset_with_param = 1 28 | Chmode_I.type = Channelmode.LISTMODE 29 | Chmode_I.param_help = "" 30 | Chmode_I.desc = "Hosts matching an invex can bypass +i" 31 | Channelmode.add(module, Chmode_I) 32 | Isupport.add("INVEX") 33 | -------------------------------------------------------------------------------- /modules/chanmodes/key.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +k (requires key to join) 3 | """ 4 | 5 | from handle.core import Numeric, Channelmode, Hook 6 | 7 | 8 | def key_is_ok(client, channel, action, mode, param, CHK_TYPE): 9 | if CHK_TYPE == Channelmode.CHK_ACCESS: 10 | if channel.client_has_membermodes(client, "oaq"): 11 | return 1 12 | return 0 13 | 14 | if CHK_TYPE == Channelmode.CHK_PARAM: 15 | for char in param: 16 | if not char.isalpha() and not char.isdigit(): 17 | client.sendnumeric(Numeric.ERR_INVALIDMODEPARAM, channel.name, 'k', '*', f"Key contains invalid character: {char}") 18 | return 0 19 | return 1 20 | 21 | return 0 22 | 23 | 24 | def can_join_key(client, channel, key): 25 | if client.has_permission("channel:override:join:key"): 26 | return 0 27 | 28 | if 'k' in channel.modes and key != channel.get_param('k'): 29 | return Numeric.ERR_BADCHANNELKEY 30 | 31 | return 0 32 | 33 | 34 | def key_conv_param(param): 35 | return param[:24] 36 | 37 | 38 | def sjoin_check_key(ourkey, theirkey): 39 | if ourkey == theirkey: 40 | return 0 41 | 42 | our_score = sum(ord(char) for char in ourkey) 43 | their_score = sum(ord(char) for char in theirkey) 44 | 45 | if our_score > their_score: 46 | return 1 47 | 48 | elif our_score < their_score: 49 | return -1 50 | 51 | return 1 if ourkey > theirkey else -1 52 | 53 | 54 | def init(module): 55 | Cmode_k = Channelmode() 56 | Cmode_k.flag = 'k' 57 | Cmode_k.paramcount = 1 58 | Cmode_k.unset_with_param = 1 59 | Cmode_k.is_ok = key_is_ok 60 | Cmode_k.conv_param = key_conv_param 61 | Cmode_k.sjoin_check = sjoin_check_key 62 | Cmode_k.param_help = "" 63 | Cmode_k.desc = "Channel requires a key to join" 64 | Cmode_k.level = 3 65 | Channelmode.add(module, Cmode_k) 66 | Hook.add(Hook.CAN_JOIN, can_join_key) 67 | -------------------------------------------------------------------------------- /modules/chanmodes/limit.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +l (limit users on channel) 3 | """ 4 | 5 | from handle.core import Channelmode, Numeric, Hook 6 | 7 | 8 | def validate_limit(client, channel, action, mode, param, CHK_TYPE): 9 | if CHK_TYPE == Channelmode.CHK_ACCESS: 10 | if channel.client_has_membermodes(client, "oaq"): 11 | return 1 12 | return 0 13 | 14 | if CHK_TYPE == Channelmode.CHK_PARAM: 15 | if param.startswith('-'): 16 | param = param[1:] 17 | 18 | if not param.isdigit(): 19 | return 0 20 | return 1 21 | 22 | if (action == '+' and param.isdigit()) or (action == '-'): 23 | return 1 24 | 25 | return 0 26 | 27 | 28 | def conv_param_limit(param): 29 | param = int(param) 30 | 31 | if param > 9999: 32 | param = 9999 33 | 34 | if param < 0: 35 | param = 1 36 | 37 | return param 38 | 39 | 40 | def sjoin_check_limit(ourlimit, theirlimit): 41 | if ourlimit == theirlimit: 42 | return 0 43 | 44 | if ourlimit > theirlimit: 45 | return 1 46 | 47 | return -1 48 | 49 | 50 | def limit_can_join(client, channel, key): 51 | if client.has_permission("override:channel:join:limit"): 52 | return 0 53 | 54 | if limit_param := channel.get_param('l'): 55 | if channel.membercount >= int(limit_param): 56 | return Numeric.ERR_CHANNELISFULL 57 | 58 | return 0 59 | 60 | 61 | def init(module): 62 | Cmode_l = Channelmode() 63 | Cmode_l.flag = 'l' 64 | Cmode_l.paramcount = 1 65 | Cmode_l.is_ok = validate_limit 66 | Cmode_l.conv_param = conv_param_limit 67 | Cmode_l.sjoin_check = sjoin_check_limit 68 | Cmode_l.level = 3 69 | Cmode_l.param_help = " (number)" 70 | Cmode_l.desc = "Limits the channel users to users" 71 | Hook.add(Hook.CAN_JOIN, limit_can_join) 72 | Channelmode.add(module, Cmode_l) 73 | -------------------------------------------------------------------------------- /modules/chanmodes/m_blockcolors.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +c (block colors) 3 | """ 4 | 5 | import re 6 | 7 | from handle.core import Channelmode, Numeric, Hook 8 | 9 | 10 | def blockcolors_c(client, channel, msg, sendtype): 11 | if 'c' not in channel.modes: 12 | return Hook.ALLOW 13 | 14 | if channel.client_has_membermodes(client, "aq") or client.has_permission("channel:override:message:color"): 15 | return Hook.ALLOW 16 | 17 | testmsg = ' '.join(msg) 18 | if re.search(r"\x03(?:\d{1,2}(?:,\d{1,2})?)?", testmsg): 19 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "Colors are blocked on this channel") 20 | return Hook.DENY 21 | 22 | return Hook.ALLOW 23 | 24 | 25 | def init(module): 26 | Chmode_c = Channelmode() 27 | Chmode_c.flag = 'c' 28 | Chmode_c.is_ok = Channelmode.allow_chanop 29 | Chmode_c.desc = "Blocks messages containing colors" 30 | Channelmode.add(module, Chmode_c) 31 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, blockcolors_c) 32 | -------------------------------------------------------------------------------- /modules/chanmodes/m_chanstrip.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +S (strip colors, bold, underline from messages) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode, Hook 6 | 7 | 8 | def stripmsg_S(client, channel, msg, prefix): 9 | if 'S' in channel.modes: 10 | for idx, entry in enumerate(msg): 11 | msg[idx] = IRCD.strip_format(entry) 12 | 13 | 14 | def init(module): 15 | Chmode_S = Channelmode() 16 | Chmode_S.flag = 'S' 17 | Chmode_S.is_ok = Channelmode.allow_chanop 18 | Chmode_S.desc = "Strip colors and other formatting from channel messages" 19 | Channelmode.add(module, Chmode_S) 20 | Hook.add(Hook.PRE_LOCAL_CHANMSG, stripmsg_S) 21 | Hook.add(Hook.PRE_LOCAL_CHANNOTICE, stripmsg_S) 22 | -------------------------------------------------------------------------------- /modules/chanmodes/m_regonly.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides chmode +R (only registered users can join) 3 | and +M (only registered or voiced users can speak) 4 | """ 5 | 6 | from handle.core import Channelmode, Numeric, Hook 7 | 8 | 9 | def reg_only_join(client, channel, key): 10 | if 'R' in channel.modes and 'r' not in client.user.modes and not client.has_permission("channel:override:join:regonly"): 11 | return Numeric.ERR_NEEDREGGEDNICK 12 | return 0 13 | 14 | 15 | def reg_only_msg(client, channel, message, sendtype): 16 | if 'M' not in channel.modes: 17 | return Hook.ALLOW 18 | 19 | if ('r' in client.user.modes or channel.client_has_membermodes(client, "vhoaq") 20 | or client.has_permission("channel:override:message:regonly")): 21 | return Hook.ALLOW 22 | 23 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "You need a registered nickname to speak in this channel") 24 | return Hook.DENY 25 | 26 | 27 | def init(module): 28 | Chmode_R = Channelmode() 29 | Chmode_R.flag = 'R' 30 | Chmode_R.is_ok = Channelmode.allow_chanop 31 | Chmode_R.desc = "Only registered users may join" 32 | Channelmode.add(module, Chmode_R) 33 | Chmode_M = Channelmode() 34 | Chmode_M.flag = 'M' 35 | Chmode_M.is_ok = Channelmode.allow_chanop 36 | Chmode_M.desc = "Only registered or voiced users may speak" 37 | Channelmode.add(module, Chmode_M) 38 | Hook.add(Hook.CAN_JOIN, reg_only_join) 39 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, reg_only_msg) 40 | -------------------------------------------------------------------------------- /modules/chanmodes/moderated.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +m (moderated, only +v or higher can speak) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | from modules.m_msg import add_oper_override 7 | 8 | 9 | def moderated_msg_check(client, channel, msg, sendtype): 10 | if 'm' in channel.modes and not channel.client_has_membermodes(client, "vhoaq"): 11 | if not client.has_permission("channel:override:message:moderated"): 12 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "Cannot send to channel (+m)") 13 | return Hook.DENY 14 | add_oper_override('m') 15 | return Hook.CONTINUE 16 | 17 | 18 | def init(module): 19 | Cmode_m = Channelmode() 20 | Cmode_m.flag = 'm' 21 | Cmode_m.paramcount = 0 22 | Cmode_m.is_ok = Channelmode.allow_halfop 23 | Cmode_m.desc = "Users need voice (+v) or higher to speak" 24 | Channelmode.add(module, Cmode_m) 25 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, moderated_msg_check) 26 | -------------------------------------------------------------------------------- /modules/chanmodes/noctcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +C (no CTCP in the channel) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | 7 | 8 | def msg_ctcp(client, channel, message, sendtype): 9 | if 'C' not in channel.modes: 10 | return Hook.ALLOW 11 | 12 | if channel.client_has_membermodes(client, "aq") or client.has_permission("channel:override:message:ctcp"): 13 | return Hook.ALLOW 14 | 15 | if message[0] == '' and message[-1] == '': 16 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "CTCPs are not permitted in this channel") 17 | return Hook.DENY 18 | 19 | return Hook.ALLOW 20 | 21 | 22 | def init(module): 23 | Cmode_C = Channelmode() 24 | Cmode_C.flag = 'C' 25 | Cmode_C.desc = "CTCPs are not allowed in the channel" 26 | Channelmode.add(module, Cmode_C) 27 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, msg_ctcp) 28 | -------------------------------------------------------------------------------- /modules/chanmodes/noexternal.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +n (no external messages) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | from modules.m_msg import add_oper_override 7 | 8 | 9 | def noexternal_msg_check(client, channel, msg, sendtype): 10 | if 'n' in channel.modes and not channel.find_member(client): 11 | if not client.has_permission("channel:override:message:outside"): 12 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "No external messages") 13 | return Hook.DENY 14 | add_oper_override('n') 15 | return Hook.CONTINUE 16 | 17 | 18 | def init(module): 19 | Cmode_n = Channelmode() 20 | Cmode_n.flag = 'n' 21 | Cmode_n.desc = "No external messages allowed" 22 | Channelmode.add(module, Cmode_n) 23 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, noexternal_msg_check) 24 | -------------------------------------------------------------------------------- /modules/chanmodes/noinvite.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +V (only +q can /INVITE) 3 | """ 4 | 5 | from handle.core import Channelmode 6 | 7 | 8 | def init(module): 9 | Cmode_V = Channelmode() 10 | Cmode_V.flag = 'V' 11 | Cmode_V.is_ok = Channelmode.allow_chanop 12 | Cmode_V.desc = "Only channel owners (+q) can /INVITE to the channel" 13 | Channelmode.add(module, Cmode_V) 14 | -------------------------------------------------------------------------------- /modules/chanmodes/nokick.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +Q (only +q can /KICK) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | 7 | 8 | def chmode_Q_can_kick(client, target_client, channel, reason, oper_override): 9 | if 'Q' in channel.modes and channel.level(client) < 5 and not client.has_permission("channel:override:kick:no-kick"): 10 | client.sendnumeric(Numeric.ERR_CANNOTDOCOMMAND, channel.name, "KICKs are not permitted in this channel") 11 | return Hook.DENY 12 | 13 | elif 'Q' in channel.modes and not channel.client_has_membermodes(client, 'q'): 14 | oper_override[0] = 1 15 | return Hook.CONTINUE 16 | 17 | 18 | def init(module): 19 | Cmode_Q = Channelmode() 20 | Cmode_Q.flag = 'Q' 21 | Cmode_Q.is_ok = Channelmode.allow_chanop 22 | Cmode_Q.desc = "Only channel owners can /KICK users from channel" 23 | Channelmode.add(module, Cmode_Q) 24 | Hook.add(Hook.CAN_KICK, chmode_Q_can_kick) 25 | -------------------------------------------------------------------------------- /modules/chanmodes/nonick.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +N (nick changes are not allowed) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | 7 | 8 | def can_change_nick(client, newnick): 9 | if client.has_permission("channel:override:no-nick"): 10 | return Hook.CONTINUE 11 | for channel in client.channels(): 12 | if 'N' in channel.modes and not channel.client_has_membermodes(client, 'q'): 13 | # Channel owners can bypass channel mode +N. 14 | # Client needs channel owner (or channel:override:no-nick oper permission) on all channels 15 | # it's in that channel has +N. 16 | client.sendnumeric(Numeric.ERR_NONICKCHANGE, channel.name) 17 | return Hook.DENY 18 | return Hook.CONTINUE 19 | 20 | 21 | def init(module): 22 | Cmode_N = Channelmode() 23 | Cmode_N.flag = 'N' 24 | Cmode_N.is_ok = Channelmode.allow_chanop 25 | Cmode_N.desc = "Nick changes are not allowed in the channel" 26 | Channelmode.add(module, Cmode_N) 27 | Hook.add(Hook.PRE_LOCAL_NICKCHANGE, can_change_nick) 28 | -------------------------------------------------------------------------------- /modules/chanmodes/nonotice.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +T (no notices in the channel) 3 | """ 4 | 5 | from handle.core import Channelmode, Hook, Numeric 6 | 7 | 8 | def can_channel_notice(client, channel, message, sendtype): 9 | if 'T' not in channel.modes or sendtype != "NOTICE": 10 | return Hook.ALLOW 11 | 12 | if not client.user or channel.client_has_membermodes(client, 'q') or client.has_permission("channel:override:message:notice"): 13 | return Hook.ALLOW 14 | 15 | client.sendnumeric(Numeric.ERR_CANNOTSENDTOCHAN, channel.name, "Notices are not permitted in this channel") 16 | return Hook.DENY 17 | 18 | 19 | def init(module): 20 | Cmode_T = Channelmode() 21 | Cmode_T.flag = 'T' 22 | Cmode_T.desc = "Notices are not allowed in the channel" 23 | Channelmode.add(module, Cmode_T) 24 | Hook.add(Hook.CAN_SEND_TO_CHANNEL, can_channel_notice) 25 | -------------------------------------------------------------------------------- /modules/chanmodes/opersonly.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +O (opers-only channel) 3 | """ 4 | 5 | from handle.core import Numeric, Channelmode, Hook 6 | 7 | 8 | def chmode_O_join(client, channel, key): 9 | if 'O' in channel.modes and 'o' not in client.user.modes: 10 | return Numeric.ERR_OPERONLY 11 | return 0 12 | 13 | 14 | def init(module): 15 | Cmode_O = Channelmode() 16 | Cmode_O.flag = 'O' 17 | Cmode_O.is_ok = Channelmode.allow_opers 18 | Cmode_O.desc = "Only IRC Operators can join the channel" 19 | Channelmode.add(module, Cmode_O) 20 | Hook.add(Hook.CAN_JOIN, chmode_O_join) 21 | -------------------------------------------------------------------------------- /modules/chanmodes/permanent.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +P (permanent channel) 3 | """ 4 | 5 | from handle.core import IRCD, Channel, Channelmode, Hook 6 | 7 | ChannelData = {} 8 | 9 | 10 | def permanent_channel_destroy(client, channel): 11 | if 'P' in channel.modes: 12 | IRCD.channel_count += 1 13 | Channel.table.append(channel) 14 | 15 | 16 | def permanent_channel_join(client, channel): 17 | if 'P' in channel.modes and channel.membercount == 1: 18 | channel.member_take_modes(client, 'o') 19 | 20 | 21 | def save_channel(client, channel, *args): 22 | """ Save channel info to json """ 23 | if 'P' not in channel.modes: 24 | return 25 | 26 | ChannelData[channel.name] = { 27 | "params": {mode: channel.get_param(mode) for mode in channel.modes if channel.get_param(mode)}, 28 | "listmodes": { 29 | mode: [[le.mask, le.set_by, le.set_time] for le in channel.List[mode]] 30 | for mode in [m.flag for m in Channelmode.table if m.type == Channelmode.LISTMODE] 31 | if channel.List[mode] 32 | }, 33 | "topic": (channel.topic, channel.topic_time, channel.topic_author), 34 | "modes": channel.modes, 35 | "creation": channel.creationtime 36 | } 37 | 38 | IRCD.write_data_file(ChannelData, "channels.db") 39 | 40 | 41 | def restore_channel(): 42 | """ Restore channel from json """ 43 | if ChannelData := IRCD.read_data_file("channels.db"): 44 | for chan, data in ChannelData.items(): 45 | channel = IRCD.create_channel(IRCD.me, chan) 46 | channel.creationtime = data["creation"] 47 | channel.modes = data["modes"] 48 | 49 | for mode, param in data["params"].items(): 50 | channel.add_param(mode, param) 51 | 52 | for listmode, entries in data["listmodes"].items(): 53 | for mask, setter, timestamp in entries: 54 | channel.add_to_list(client=IRCD.me, mask=mask, _list=channel.List[listmode], setter=setter, timestamp=timestamp) 55 | 56 | if "topic" in data: 57 | channel.topic, channel.topic_time, channel.topic_author = data["topic"] 58 | 59 | 60 | def save_channel_mode(client, channel, modebuf, parambuf): 61 | if 'P' in modebuf: 62 | ChannelData = IRCD.read_data_file("channels.db") 63 | if 'P' not in channel.modes and channel.name in ChannelData: 64 | del ChannelData[channel.name] 65 | if channel.membercount == 0: 66 | IRCD.destroy_channel(IRCD.me, channel) 67 | IRCD.write_data_file(ChannelData, "channels.db") 68 | 69 | save_channel(client, channel) 70 | 71 | 72 | def init(module): 73 | Cmode_P = Channelmode() 74 | Cmode_P.flag = 'P' 75 | Cmode_P.is_ok = Channelmode.allow_opers 76 | Cmode_P.desc = "Channel is permanent. All data will be restored on server restart" 77 | Channelmode.add(module, Cmode_P) 78 | Hook.add(Hook.LOCAL_CHANNEL_MODE, save_channel_mode) 79 | Hook.add(Hook.TOPIC, save_channel) 80 | Hook.add(Hook.BOOT, restore_channel) 81 | Hook.add(Hook.CHANNEL_DESTROY, permanent_channel_destroy) 82 | Hook.add(Hook.PRE_LOCAL_JOIN, permanent_channel_join) 83 | Hook.add(Hook.REMOTE_JOIN, permanent_channel_join) 84 | -------------------------------------------------------------------------------- /modules/chanmodes/redirect.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +L (redirect to other channel if unable to join) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode, Numeric, Hook, Command 6 | 7 | 8 | def validate_redirect(client, channel, action, mode, param, CHK_TYPE): 9 | if CHK_TYPE == Channelmode.CHK_ACCESS: 10 | if channel.client_has_membermodes(client, "oaq"): 11 | return 1 12 | return 0 13 | 14 | if CHK_TYPE == Channelmode.CHK_PARAM: 15 | if action == '+': 16 | if not IRCD.is_valid_channelname(param): 17 | client.sendnumeric(Numeric.ERR_CANNOTCHANGECHANMODE, 'L', f"Invalid channel for redirect: {param}") 18 | return 0 19 | if not IRCD.find_channel(param): 20 | client.sendnumeric(Numeric.ERR_CANNOTCHANGECHANMODE, 'L', f"Channel does not exist: {param}") 21 | return 0 22 | if (redirect_channel := IRCD.find_channel(param)) == channel: 23 | client.sendnumeric(Numeric.ERR_CANNOTCHANGECHANMODE, 'L', "Channel cannot link to itself") 24 | return 0 25 | if 'L' in redirect_channel.modes: 26 | client.sendnumeric(Numeric.ERR_CANNOTCHANGECHANMODE, 'L', "Destination channel already has +L set") 27 | return 0 28 | 29 | # All good. Allow set. 30 | return 1 31 | 32 | # Always allow unset. 33 | return 1 34 | 35 | return 0 36 | 37 | 38 | def conv_param_redirect(param): 39 | return param 40 | 41 | 42 | def sjoin_check_redirect(ourredirect, theirredirect): 43 | if ourredirect == theirredirect: 44 | return 0 45 | 46 | our_score = their_score = 0 47 | for char in ourredirect: 48 | our_score += ord(char) 49 | for char in theirredirect: 50 | their_score += ord(char) 51 | 52 | if our_score > their_score: 53 | return 1 54 | 55 | 56 | def redirect_to_link(client, channel, error): 57 | if 'L' not in channel.modes: 58 | return 59 | 60 | link_chan = channel.get_param('L') 61 | 62 | if not (link_chan := IRCD.find_channel(link_chan)): 63 | return 64 | 65 | if 'L' in link_chan.modes: 66 | return 67 | 68 | Command.do(client, "JOIN", link_chan.name) 69 | 70 | match error: 71 | case Numeric.ERR_BANNEDFROMCHAN: 72 | IRCD.server_notice(client, 73 | f"You are banned from {channel.name} so you have been redirected to {link_chan.name}") 74 | case Numeric.ERR_INVITEONLYCHAN: 75 | IRCD.server_notice(client, 76 | f"Channel {channel.name} is invite-only so you have been redirected to {link_chan.name}") 77 | case Numeric.ERR_CHANNELISFULL: 78 | IRCD.server_notice(client, 79 | f"Channel {channel.name} is full so you have been redirected to {link_chan.name}") 80 | case Numeric.ERR_NEEDREGGEDNICK: 81 | IRCD.server_notice(client, 82 | f"Channel {channel.name} is for registered users only so you have been redirected to {link_chan.name}") 83 | case Numeric.ERR_SECUREONLY: 84 | IRCD.server_notice(client, 85 | f"Channel {channel.name} is for TLS-users only so you have been redirected to {link_chan.name}") 86 | case Numeric.ERR_OPERONLY: 87 | IRCD.server_notice(client, 88 | f"Channel {channel.name} is for IRC operators only so you have been redirected to {link_chan.name}") 89 | case _: 90 | IRCD.server_notice(client, 91 | f"Unable to join {channel.name}. You have been redirected to {link_chan.name}") 92 | 93 | 94 | def init(module): 95 | Cmode_L = Channelmode() 96 | Cmode_L.flag = 'L' 97 | Cmode_L.paramcount = 1 98 | Cmode_L.is_ok = validate_redirect 99 | Cmode_L.conv_param = conv_param_redirect 100 | Cmode_L.sjoin_check = sjoin_check_redirect 101 | Cmode_L.level = 3 102 | Cmode_L.unset_with_param = 1 # Following IRC protocol. 103 | Cmode_L.param_help = "" 104 | Cmode_L.desc = "If a user is unable to join the channel, it will be redirected to the specified channel" 105 | Hook.add(Hook.JOIN_FAIL, redirect_to_link) 106 | Channelmode.add(module, Cmode_L) 107 | -------------------------------------------------------------------------------- /modules/chanmodes/registered.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +r (registered channel) 3 | """ 4 | 5 | from handle.core import Channelmode 6 | 7 | 8 | def init(module): 9 | Cmode_r = Channelmode() 10 | Cmode_r.flag = 'r' 11 | Cmode_r.is_ok = Channelmode.allow_none 12 | Cmode_r.desc = "Channel is registered" 13 | Channelmode.add(module, Cmode_r) 14 | -------------------------------------------------------------------------------- /modules/chanmodes/secret.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +s (secret channel) 3 | """ 4 | 5 | from handle.core import Channelmode 6 | 7 | 8 | def init(module): 9 | Cmode_s = Channelmode() 10 | Cmode_s.flag = 's' 11 | Cmode_s.paramcount = 0 12 | Cmode_s.is_ok = Channelmode.allow_chanop 13 | Cmode_s.desc = "Secret channel (not showing up in /list, /whois, etc.)" 14 | Channelmode.add(module, Cmode_s) 15 | -------------------------------------------------------------------------------- /modules/chanmodes/secureonly.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +z (requires TLS to join the channel) 3 | """ 4 | 5 | from handle.core import Numeric, Channelmode, Hook 6 | 7 | 8 | def chmode_z_is_ok(client, channel, action, mode, param, CHK_TYPE): 9 | if CHK_TYPE == Channelmode.CHK_ACCESS: 10 | if not channel.client_has_membermodes(client, "oaq"): 11 | return 0 12 | 13 | if 'z' not in client.user.modes and not client.has_permission("channel:override:mode"): 14 | client.sendnumeric(Numeric.ERR_INVALIDMODEPARAM, channel.name, 'z', '*', "You need to be connected with TLS to set mode +z.") 15 | """ Don't return 0 here, to hide ERR_CHANOPRIVSNEEDED """ 16 | return -1 17 | 18 | """ All checks passed. """ 19 | return 1 20 | 21 | return 0 22 | 23 | 24 | def chmode_z_only_join(client, channel, key): 25 | if client.has_permission("channel:override:join:secureonly"): 26 | return 0 27 | if 'z' in channel.modes and 'z' not in client.user.modes: 28 | return Numeric.ERR_SECUREONLY 29 | return 0 30 | 31 | 32 | def init(module): 33 | Cmode_z = Channelmode() 34 | Cmode_z.flag = 'z' 35 | Cmode_z.is_ok = chmode_z_is_ok 36 | Cmode_z.level = 3 37 | Cmode_z.desc = "Requires a TLS connection to join the channel" 38 | Channelmode.add(module, Cmode_z) 39 | Hook.add(Hook.CAN_JOIN, chmode_z_only_join) 40 | -------------------------------------------------------------------------------- /modules/chanmodes/topiclimit.py: -------------------------------------------------------------------------------- 1 | """ 2 | channel mode +t (no topic change from outside) 3 | """ 4 | 5 | from handle.core import Channelmode 6 | 7 | 8 | def init(module): 9 | Chmode_t = Channelmode() 10 | Chmode_t.flag = 't' 11 | Chmode_t.paramcount = 0 12 | Chmode_t.is_ok = Channelmode.allow_halfop 13 | Chmode_t.desc = "Topic cannot be changed from outside" 14 | Channelmode.add(module, Chmode_t) 15 | -------------------------------------------------------------------------------- /modules/chanmodes/voice.py: -------------------------------------------------------------------------------- 1 | """ 2 | voice mode (+v) 3 | """ 4 | 5 | from handle.core import IRCD, Channelmode 6 | 7 | 8 | def validate_member(client, channel, action, mode, param, CHK_TYPE): 9 | param_client = IRCD.find_client(param) 10 | if CHK_TYPE == Channelmode.CHK_ACCESS: 11 | if channel.client_has_membermodes(client, "hoaq") or not client.local: 12 | return 1 13 | if action == '-' and param_client == client: 14 | # Always allow unset on self. 15 | return 1 16 | return 0 17 | return 0 18 | 19 | 20 | def init(module): 21 | Cmode_v = Channelmode() 22 | Cmode_v.flag = 'v' 23 | Cmode_v.prefix = '+' 24 | Cmode_v.sjoin_prefix = '+' 25 | Cmode_v.paramcount = 1 26 | Cmode_v.unset_with_param = 1 27 | Cmode_v.type = Channelmode.MEMBER 28 | Cmode_v.rank = 1 # Used to determine the position in PREFIX Isupport 29 | Cmode_v.is_ok = validate_member 30 | Cmode_v.desc = "Give/take channel voice status" 31 | Channelmode.add(module, Cmode_v) 32 | -------------------------------------------------------------------------------- /modules/founder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic channel founder support 3 | """ 4 | 5 | from time import time 6 | from handle.core import IRCD, Client, Command, Hook 7 | 8 | 9 | class ChannelsDict(dict): 10 | """ 11 | Because I cannot work with Channel objects directly due to how objects are cleaned up after quit events, 12 | I have to work with channel names as strings. Better make them case-insensitive. 13 | """ 14 | 15 | def __setitem__(self, key, value): 16 | super().__setitem__(key.lower(), value) 17 | 18 | def __getitem__(self, key): 19 | return super().__getitem__(key.lower()) 20 | 21 | def __delitem__(self, key): 22 | super().__delitem__(key.lower()) 23 | 24 | def __contains__(self, key): 25 | # noinspection PyUnresolvedReferences 26 | return super().__contains__(key.lower()) 27 | 28 | def pop(self, key, default=None): 29 | return super().pop(key.lower(), default) 30 | 31 | 32 | class Founders: 33 | channels = ChannelsDict() 34 | 35 | @staticmethod 36 | def set(channel: str, client: Client = None): 37 | if client: 38 | Founders.channels[channel] = { 39 | "last_seen": int(time()), 40 | "fullmask": client.fullmask, 41 | "certfp": client.get_md_value("certfp"), 42 | "account": client.user.account, 43 | "ip": client.ip, 44 | "fullrealhost": client.fullrealhost 45 | } 46 | else: 47 | Founders.channels.pop(channel, None) 48 | 49 | @staticmethod 50 | def is_founder(channel: str, client: Client) -> bool: 51 | if not client.user: 52 | return False 53 | 54 | return channel in Founders.channels and ( 55 | client.fullrealhost == Founders.channels[channel].get("fullrealhost") or 56 | client.get_md_value("certfp") == Founders.channels[channel].get("certfp") or 57 | client.user.account != '*' and client.user.account == Founders.channels[channel].get("account") 58 | ) 59 | 60 | @staticmethod 61 | def founder_is_online(channel: str): 62 | """ 63 | Checks if the channel founder is online, and then returns it. 64 | """ 65 | 66 | if chan_obj := IRCD.find_channel(channel): 67 | founder_client = next((client for client in chan_obj.clients() if Founders.is_founder(channel, client)), 0) 68 | return founder_client 69 | 70 | 71 | def expire_founder(): 72 | for channel in list(Founders.channels): 73 | last_seen = Founders.channels[channel]["last_seen"] 74 | if int(time()) >= last_seen + 1800 and not Founders.founder_is_online(channel): 75 | Founders.set(channel, client=None) 76 | 77 | 78 | def check_founder_pre_join(client, channel): 79 | if channel.membercount == 1 and channel.name[0] != '+' and 'P' not in channel.modes: 80 | if channel.name in Founders.channels: 81 | if not Founders.is_founder(channel.name, client): 82 | channel.member_take_modes(client, 'o') 83 | else: 84 | channel.member_give_modes(client, 'o') 85 | if not next((c for c in Founders.channels if Founders.is_founder(c, client)), 0): 86 | Founders.set(channel.name, client=client) 87 | 88 | 89 | def check_founder_join(client, channel): 90 | if channel.name not in Founders.channels or channel.name[0] == '+' or 'r' in channel.modes: 91 | return 92 | 93 | if (founder := Founders.founder_is_online(channel.name)) and founder != client and channel.client_has_membermodes(founder, 'o'): 94 | """ 95 | The current founder is not the same as joining user. 96 | """ 97 | return 98 | 99 | if Founders.is_founder(channel.name, client) and not channel.client_has_membermodes(client, 'o'): 100 | Command.do(IRCD.me, "MODE", channel.name, "+o", client.name) 101 | 102 | 103 | def founder_remove_part(client, channel, *args): 104 | Founders.set(channel.name, client=None) 105 | 106 | 107 | def founder_remove_kick(client, target_client, channel, *args): 108 | Founders.set(channel.name, client=None) 109 | 110 | 111 | def update_founder_timestamp(client, reason): 112 | for channel in (c for c in IRCD.get_channels() if c.name in Founders.channels and Founders.is_founder(c.name, client)): 113 | if client.is_killed(): 114 | Founders.set(channel.name, client=None) 115 | elif client not in Client.table: 116 | Founders.channels[channel.name]["last_seen"] = int(time()) 117 | 118 | 119 | def founder_remove_sjoin(client, recv): 120 | """ 121 | recv[2]: Channel name as string. 122 | """ 123 | 124 | Founders.set(recv[2], client=None) 125 | 126 | 127 | def founder_destroy_channel(client, channel): 128 | Founders.set(channel.name, client=None) 129 | 130 | 131 | def init(module): 132 | Hook.add(Hook.PRE_LOCAL_JOIN, check_founder_pre_join) 133 | Hook.add(Hook.LOCAL_JOIN, check_founder_join) 134 | Hook.add(Hook.LOCAL_QUIT, update_founder_timestamp) 135 | Hook.add(Hook.LOCAL_PART, founder_remove_part) 136 | Hook.add(Hook.LOCAL_KICK, founder_remove_kick) 137 | Hook.add(Hook.SERVER_SJOIN_IN, founder_remove_sjoin) 138 | # Hook.add(Hook.CHANNEL_DESTROY, founder_destroy_channel) 139 | Hook.add(Hook.LOOP, expire_founder) 140 | -------------------------------------------------------------------------------- /modules/geodata.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fetches and saves client geodata from ipapi.co. 3 | Edit API_URL to change. 4 | """ 5 | 6 | import json 7 | import ipaddress 8 | 9 | from time import time 10 | from urllib import request 11 | 12 | from handle.core import IRCD, Hook, Numeric 13 | from handle.logger import logging 14 | 15 | API_URL = "https://ipapi.co/%ip/json/" 16 | 17 | 18 | class GeoData: 19 | data = {} 20 | clients = {} 21 | process = [] 22 | 23 | 24 | def api_call(client): 25 | try: 26 | response = request.urlopen(API_URL.replace("%ip", client.ip), timeout=10) 27 | response_body = response.read() 28 | json_response = json.loads(response_body.decode()) 29 | json_response["ircd_time_added"] = int(time()) 30 | GeoData.data.update({client.ip: json_response}) 31 | GeoData.clients[client] = json_response 32 | IRCD.write_data_file(GeoData.data, filename="geodata.json") 33 | except Exception as ex: 34 | logging.exception(ex) 35 | GeoData.process.remove(client.ip) 36 | IRCD.remove_delay_client(client, "geodata") 37 | 38 | 39 | def country_whois(client, whois_client, lines): 40 | if (country := client.get_md_value("country")) and 'o' in client.user.modes: 41 | line = (Numeric.RPL_WHOISSPECIAL, whois_client.name, f"is connecting from country: {country}") 42 | lines.append(line) 43 | 44 | 45 | def geodata_lookup(client): 46 | if not ipaddress.ip_address(client.ip).is_global: 47 | return 48 | if client.ip in GeoData.data: 49 | """ Assign cached data to this client. """ 50 | GeoData.clients[client] = GeoData.data[client.ip] 51 | client.add_md(name="country", value=GeoData.data[client.ip]["country"], sync=1) 52 | return 53 | 54 | if client.ip not in GeoData.process: 55 | if client.local: 56 | IRCD.delay_client(client, 1, "geodata") 57 | GeoData.process.append(client.ip) 58 | IRCD.run_parallel_function(target=api_call, args=(client,)) 59 | 60 | 61 | def geodata_expire(): 62 | changed = 0 63 | for entry in list(GeoData.data): 64 | added = GeoData.data[entry]["ircd_time_added"] 65 | if int(time() - added >= 2_629_744): 66 | changed = 1 67 | del GeoData.data[entry] 68 | if changed: 69 | IRCD.write_data_file(GeoData.data, filename="geodata.json") 70 | 71 | 72 | def geodata_remote(client): 73 | if country := client.get_md_value("country"): 74 | GeoData.clients.setdefault(client, {})["country"] = country 75 | 76 | 77 | def geodata_quit(client, reason): 78 | if client in GeoData.clients: 79 | del GeoData.clients[client] 80 | 81 | 82 | def init(module): 83 | GeoData.data = IRCD.read_data_file("geodata.json") 84 | Hook.add(Hook.NEW_CONNECTION, geodata_lookup) 85 | Hook.add(Hook.REMOTE_CONNECT, geodata_remote) 86 | Hook.add(Hook.LOCAL_QUIT, geodata_quit) 87 | Hook.add(Hook.WHOIS, country_whois) 88 | Hook.add(Hook.LOOP, geodata_expire) 89 | -------------------------------------------------------------------------------- /modules/ircv3/account-notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | account-notify capability 3 | """ 4 | 5 | from handle.core import IRCD, Capability, Hook 6 | 7 | 8 | def user_login(client, old_account): 9 | if (AccountTag := IRCD.get_attribute_from_module("AccountTag", package="modules.ircv3.account-tag")) and old_account != '*': 10 | client.mtags.append(AccountTag(value=old_account)) 11 | data = f":{client.fullmask} ACCOUNT {client.user.account}" 12 | IRCD.send_to_local_common_chans(client, mtags=client.mtags, client_cap="account-notify", data=data) 13 | 14 | 15 | def init(module): 16 | Capability.add("account-notify") 17 | Hook.add(Hook.ACCOUNT_LOGIN, user_login) 18 | -------------------------------------------------------------------------------- /modules/ircv3/account-tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | account-tag capability 3 | """ 4 | 5 | from handle.core import Capability, Hook 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class AccountTag(MessageTag): 10 | name = "account" 11 | 12 | def __init__(self, value): 13 | super().__init__(name=AccountTag.name, value=value) 14 | 15 | def is_visible_to(self, to_client): 16 | return super().is_visible_to(to_client) or to_client.has_capability("account-tag") 17 | 18 | 19 | def add_account_tag(client): 20 | if client.user and 'r' in client.user.modes and client.user.account != '*': 21 | client.mtags.append(AccountTag(value=client.user.account)) 22 | 23 | 24 | def post_load(module): 25 | Capability.add("account-tag") 26 | Hook.add(Hook.NEW_MESSAGE, add_account_tag) 27 | MessageTag.add(AccountTag) 28 | -------------------------------------------------------------------------------- /modules/ircv3/batch.py: -------------------------------------------------------------------------------- 1 | """ 2 | batch message tag 3 | """ 4 | 5 | import random 6 | import string 7 | 8 | from handle.core import IRCD, Capability 9 | from modules.ircv3.messagetags import MessageTag 10 | 11 | 12 | class Batch: 13 | pool = [] 14 | 15 | def __init__(self, started_by, batch_type=None, additional_data=''): 16 | self.label = ''.join((random.choice(string.ascii_letters + string.digits) for x in range(10))) 17 | self.tag = MessageTag.find_tag("batch")(value=self.label) 18 | # We need to be able to refer to whoever started this batch. 19 | # Also keep in mind that servers can start batches too, for example with netjoin and netsplit. 20 | self.started_by = started_by 21 | 22 | self.batch_type = batch_type 23 | self.additional_data = additional_data 24 | self.users = [] 25 | self.start() 26 | 27 | @staticmethod 28 | def create_new(started_by, batch_type=None, additional_data=''): 29 | batch = Batch(started_by=started_by, batch_type=batch_type, additional_data=additional_data) 30 | return batch 31 | 32 | @staticmethod 33 | def check_batch_event(mtags, started_by, target_client, event): 34 | """ 35 | :param mtags: Message tags list to add BATCH tag to. 36 | :param started_by: Client that started this batch. 37 | :param target_client: Target client to show this BATCH event to. 38 | :param event: Batch event: netjoin or netsplit. 39 | """ 40 | 41 | for batch in Batch.pool: 42 | if batch.started_by in [started_by, started_by.uplink, started_by.direction] and batch.batch_type == event: 43 | if (batch.tag.name, batch.tag.value) not in [(t.name, t.value) for t in mtags]: 44 | mtags[0:0] = [batch.tag] 45 | if target_client not in batch.users: 46 | batch.announce_to(target_client) 47 | 48 | def start(self, batch_id=None): 49 | # TODO: Maybe start/end BATCH with an internal ID? 50 | for user in [u for u in self.users if u.has_capability("batch")]: 51 | data = (f":{IRCD.me.name} BATCH +{self.label}{' ' + self.batch_type if self.batch_type else ''} " 52 | f"{' ' + self.additional_data if self.additional_data else ''}") 53 | user.send([], data) 54 | Batch.pool.append(self) 55 | 56 | def end(self, batch_id=None): 57 | if self in Batch.pool: 58 | Batch.pool.remove(self) 59 | for user in self.users: 60 | user.send([], f":{IRCD.me.name} BATCH -{self.label}") 61 | for client in IRCD.get_clients(): 62 | for tag in list(client.mtags): 63 | if tag.name == "batch" and tag.value == self.label: 64 | client.mtags.remove(tag) 65 | self.users = [] 66 | 67 | def announce_to(self, client): 68 | if not self.tag.is_visible_to(client): 69 | return 70 | if client not in self.users: 71 | data = f":{IRCD.me.name} BATCH +{self.label} {self.batch_type}{' ' + self.additional_data if self.additional_data else ''}" 72 | client.send([tag for tag in client.mtags if tag.name == "label"], data) 73 | self.users.append(client) 74 | 75 | @staticmethod 76 | def find_batch_by(started_by): 77 | return next((batch for batch in Batch.pool if batch.started_by == started_by), 0) 78 | 79 | def __repr__(self): 80 | return f"" 81 | 82 | 83 | class BatchTag(MessageTag): 84 | name = "batch" 85 | local = 1 86 | 87 | def __init__(self, value): 88 | super().__init__(name=BatchTag.name, value=value) 89 | 90 | def is_visible_to(self, to_user): 91 | return super().is_visible_to(to_user) and to_user.has_capability("batch") 92 | 93 | 94 | def post_load(module): 95 | Capability.add("batch") 96 | MessageTag.add(BatchTag) 97 | -------------------------------------------------------------------------------- /modules/ircv3/channel-context.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draft of +channel-context client tag. 3 | https://ircv3.net/specs/client-tags/channel-context 4 | """ 5 | 6 | from handle.core import IRCD 7 | from modules.ircv3.messagetags import MessageTag 8 | 9 | 10 | class ChannelContextTag(MessageTag): 11 | name = "+draft/channel-context" 12 | value_required = 1 13 | 14 | def __init__(self, value): 15 | super().__init__(name=ChannelContextTag.name, value=value) 16 | 17 | def value_is_ok(self, value): 18 | return bool(IRCD.find_channel(value)) 19 | 20 | def filter_value(self, target) -> MessageTag: 21 | channel_name = IRCD.find_channel(self.value).name 22 | return ChannelContextTag(value=channel_name) 23 | 24 | 25 | def post_load(module): 26 | MessageTag.add(ChannelContextTag) 27 | -------------------------------------------------------------------------------- /modules/ircv3/channel_rename.py: -------------------------------------------------------------------------------- 1 | """ 2 | /chgcname command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Capability, Flag, Numeric 6 | 7 | 8 | # https://ircv3.net/specs/extensions/channel-rename 9 | 10 | 11 | class RenameData: 12 | # Keep track of when a channel was renamed. 13 | # Used to prevent excessive renaming and notifying users that weren't present 14 | # during the rename that the channel was renamed. 15 | timestamps = {} 16 | 17 | 18 | def cmd_rename(client, recv): 19 | """ 20 | Change channel name capitalisation. 21 | Example: /RENAME #home #Home 22 | """ 23 | 24 | if not client.has_permission("channel:rename"): 25 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 26 | 27 | name = recv[2] 28 | 29 | if len(recv) > 2: 30 | reason = ' '.join(recv[3:]) 31 | else: 32 | reason = '' 33 | 34 | if not (channel := IRCD.find_channel(recv[1])): 35 | return IRCD.server_notice(client, f"Channel {name} does not exist.") 36 | 37 | if name[0] != channel.name[0]: 38 | return IRCD.server_notice(client, "Converting of channel type is not allowed.") 39 | 40 | if name == channel.name: 41 | return IRCD.server_notice(client, "Channel names are equal; nothing changed.") 42 | 43 | if next((c for c in IRCD.get_channels() if c.name == name), 0): 44 | return IRCD.server_notice(client, f"Unable to change channel name: channel {name} aleady exist.") 45 | 46 | if client.local: 47 | IRCD.server_notice(client, f"Channel {channel.name} successfully changed to {name}") 48 | 49 | IRCD.send_to_servers(client, [], f":{client.id} RENAME {channel.name} {name}") 50 | 51 | old_name = channel.name 52 | channel.name = name 53 | 54 | for user in [u for u in IRCD.get_clients(local=1) if channel.find_member(u) and not u.has_capability("draft/channel-rename")]: 55 | user.send([], f":{user.fullmask} PART {old_name}") 56 | user.send([], f":{user.fullmask} JOIN {channel.name}") 57 | 58 | Command.do(user, "TOPIC", channel.name) 59 | Command.do(user, "NAMES", channel.name) 60 | 61 | for user in [u for u in IRCD.get_clients(local=1, cap="draft/channel-rename") if channel.find_member(u)]: 62 | user.send([], f"RENAME {old_name} {channel.name} :{reason}") 63 | 64 | if client.local: 65 | msg = f"*** {client.name} ({client.user.username}@{client.user.realhost}) used RENAME to change channel name {old_name} to {name}" 66 | IRCD.send_snomask(client, 's', msg) 67 | 68 | 69 | def init(module): 70 | Command.add(module, cmd_rename, "CHGCNAME", 2, Flag.CMD_USER) 71 | Command.add(module, cmd_rename, "RENAME", 2, Flag.CMD_USER) 72 | Capability.add("draft/channel-rename") 73 | -------------------------------------------------------------------------------- /modules/ircv3/chathistory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides IRCv3 CHATHISTORY support. 3 | https://ircv3.net/specs/extensions/chathistory 4 | """ 5 | 6 | from handle.core import IRCD, Command, Isupport, Capability, Numeric 7 | from modules.chanmodes.m_history import HistoryFilter, get_chathistory, send_history, ChatHistory 8 | from datetime import datetime 9 | 10 | 11 | def parse_history_filter(token: str, param: str, history_filter: HistoryFilter, attribute_name: str) -> int: 12 | if len(param.split('=')) != 2: 13 | return 0 14 | filter_name, filter_value = param.split('=') 15 | if token == "timestamp" and filter_name == token: 16 | try: 17 | datetime.strptime(filter_value, "%Y-%m-%dT%H:%M:%S.%fZ") 18 | except ValueError: 19 | return 0 20 | setattr(history_filter, attribute_name, filter_value) 21 | return 1 22 | elif token == "msgid" and filter_name == token: 23 | setattr(history_filter, attribute_name, filter_value) 24 | return 1 25 | else: 26 | return 0 27 | 28 | 29 | def cmd_chathistory(client, recv): 30 | if not client.has_capability("draft/chathistory") or not client.has_capability("server-time") or not client.has_capability( 31 | "message-tags"): 32 | return 33 | target = recv[2] 34 | if not (channel := IRCD.find_channel(target)): 35 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, target) 36 | 37 | if channel.find_member(client) and not client.has_permission("channel:see:history"): 38 | return client.sendnumeric(Numeric.ERR_NOTONCHANNEL) 39 | 40 | cmd = recv[1].lower() 41 | match cmd: 42 | case "before" | "after": 43 | history_filter = HistoryFilter() 44 | history_filter.cmd = ChatHistory.BEFORE if cmd == "before" else ChatHistory.AFTER 45 | if (not parse_history_filter("timestamp", recv[3], history_filter, "timestamp_1") 46 | and not parse_history_filter("msgid", recv[3], history_filter, "msgid_1")): 47 | data = f"FAIL CHATHISTORY INVALID_PARAMS {recv[3]} :Invalid parameter, must be timestamp=xxx or msgid=xxx" 48 | return client.send([], data) 49 | limit = recv[4] 50 | if not limit.isdigit(): 51 | return IRCD.server_notice(client, "Limit must be a number.") 52 | history_filter.limit = int(limit) 53 | results = get_chathistory(channel, history_filter) 54 | send_history(client, channel, results) 55 | return 56 | 57 | case "between": 58 | if len(recv) < 6: 59 | data = f"FAIL CHATHISTORY INVALID_PARAMS {recv[1]} :Insufficient parameters" 60 | return client.send([], data) 61 | history_filter = HistoryFilter() 62 | history_filter.cmd = ChatHistory.BETWEEN 63 | if (not parse_history_filter("timestamp", recv[3], history_filter, "timestamp_1") 64 | and not parse_history_filter("msgid", recv[3], history_filter, "msgid_1")): 65 | data = f"FAIL CHATHISTORY INVALID_PARAMS {recv[3]} :Invalid parameter, must be timestamp=xxx or msgid=xxx" 66 | return client.send([], data) 67 | 68 | if (not parse_history_filter("timestamp", recv[4], history_filter, "timestamp_2") 69 | and not parse_history_filter("msgid", recv[4], history_filter, "msgid_2")): 70 | data = f"FAIL CHATHISTORY INVALID_PARAMS {recv[4]} :Invalid parameter, must be timestamp=xxx or msgid=xxx" 71 | return client.send([], data) 72 | limit = recv[5] 73 | if not limit.isdigit(): 74 | return IRCD.server_notice(client, "Limit must be a number.") 75 | history_filter.limit = int(limit) 76 | results = get_chathistory(channel, history_filter) 77 | send_history(client, channel, results) 78 | 79 | case "latest": 80 | history_filter = HistoryFilter() 81 | history_filter.cmd = ChatHistory.LATEST 82 | limit = recv[4] 83 | if not limit.isdigit(): 84 | return IRCD.server_notice(client, "Limit must be a number.") 85 | history_filter.limit = int(limit) 86 | if recv[3] == '*': 87 | results = get_chathistory(channel, history_filter) 88 | send_history(client, channel, results) 89 | return 90 | if (not parse_history_filter("timestamp", recv[3], history_filter, "timestamp_1") 91 | and not parse_history_filter("msgid", recv[3], history_filter, "msgid_1")): 92 | data = f"FAIL CHATHISTORY INVALID_PARAMS {recv[3]} :Invalid parameter, must be timestamp=xxx or msgid=xxx" 93 | return client.send([], data) 94 | results = get_chathistory(channel, history_filter) 95 | send_history(client, channel, results) 96 | 97 | 98 | def init(module): 99 | Command.add(module, cmd_chathistory, "CHATHISTORY", 3) 100 | Capability.add("draft/chathistory") 101 | Isupport.add("CHATHISTORY") 102 | -------------------------------------------------------------------------------- /modules/ircv3/echo-message.py: -------------------------------------------------------------------------------- 1 | """ 2 | echo-message capability 3 | """ 4 | 5 | from handle.core import Capability, Hook 6 | 7 | 8 | def echo_msg(client, target, message, cmd, prefix): 9 | if client.has_capability("echo-message") and 'd' not in client.user.modes: 10 | data = f":{client.name}!{client.user.username}@{client.user.host} {cmd} {prefix}{target.name} :{message}" 11 | client.send(client.mtags, data) 12 | 13 | 14 | def return_message(client, target, message, prefix=''): 15 | echo_msg(client, target, message, "PRIVMSG", prefix) 16 | 17 | 18 | def return_notice(client, target, message, prefix=''): 19 | echo_msg(client, target, message, "NOTICE", prefix) 20 | 21 | 22 | def init(module): 23 | Capability.add("echo-message") 24 | Hook.add(Hook.LOCAL_CHANMSG, return_message) 25 | Hook.add(Hook.LOCAL_USERMSG, return_message) 26 | Hook.add(Hook.LOCAL_CHANNOTICE, return_notice) 27 | Hook.add(Hook.LOCAL_USERNOTICE, return_notice) 28 | -------------------------------------------------------------------------------- /modules/ircv3/labeled-response.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides labeled response message tag support. 3 | https://ircv3.net/specs/extensions/labeled-response.html 4 | """ 5 | 6 | import re 7 | 8 | from handle.core import IRCD, Capability, Hook 9 | from modules.ircv3.messagetags import MessageTag 10 | from modules.ircv3.batch import Batch 11 | from handle.logger import logging 12 | 13 | 14 | class Currentcmd: 15 | client = None 16 | label = None 17 | buffer = [] 18 | labeltag = None 19 | 20 | 21 | class LabelTag(MessageTag): 22 | name = "label" 23 | local = 1 24 | client_tag = 1 25 | 26 | def __init__(self, value): 27 | super().__init__(name=LabelTag.name, value=value) 28 | self.client = None 29 | 30 | def is_visible_to(self, to_client): 31 | return ((super().is_visible_to(to_client) 32 | or (to_client.has_capability("labeled-response")) 33 | and to_client.has_capability("batch")) 34 | and to_client == self.client) 35 | 36 | 37 | def ircv3_label_packet(from_client, to_client, intended_to, data: list): 38 | if intended_to == Currentcmd.client and not intended_to.is_killed() and intended_to.registered: 39 | Currentcmd.buffer.append(' '.join(data)) 40 | del data[:] 41 | 42 | 43 | @logging.client_context 44 | def ircv3_label_pre_command(client, recv): 45 | for tag in client.recv_mtags: 46 | if tag.name == LabelTag.name and tag.value: 47 | if not re.match(r"^\w{1,32}$", tag.value): 48 | logging.warning(f"Invalid label tag value: {tag.value}") 49 | return 50 | 51 | Currentcmd.client = client 52 | Currentcmd.label = tag.value 53 | tag.client = client 54 | Currentcmd.labeltag = tag 55 | 56 | 57 | def ircv3_label_post_command(client, trigger, recv): 58 | # This is where we will send the buffer, if any. 59 | if Currentcmd.client == client: 60 | Currentcmd.client = None 61 | if Currentcmd.labeltag not in client.mtags: 62 | """ 63 | Labeltag object was saved in case some module clears the mtags list. 64 | """ 65 | client.mtags[0:0] = [Currentcmd.labeltag] 66 | # logging.debug(f"Label tag added to beginning of tags for {client.name}") 67 | batch = None 68 | if len(Currentcmd.buffer) == 0: 69 | data = f":{IRCD.me.name} ACK" 70 | client.send([Currentcmd.labeltag], data) 71 | else: 72 | if len(Currentcmd.buffer) > 1: 73 | if client.has_capability("batch"): 74 | batch = Batch.create_new(started_by=client, batch_type="labeled-response") 75 | batch.announce_to(client) 76 | client.mtags.append(batch.tag) 77 | """ Now send the rest as batch, and remove label tag. """ 78 | client.mtags = [tag for tag in client.mtags if not (tag.name == LabelTag.name and tag.value == Currentcmd.label)] 79 | 80 | for line in Currentcmd.buffer: 81 | client.send(client.mtags, line, call_hook=0) 82 | 83 | if batch: 84 | batch.end() 85 | 86 | Currentcmd.label = None 87 | Currentcmd.buffer = [] 88 | 89 | 90 | def post_load(module): 91 | Capability.add("labeled-response") 92 | Hook.add(Hook.POST_COMMAND, ircv3_label_post_command) 93 | Hook.add(Hook.PRE_COMMAND, ircv3_label_pre_command) 94 | Hook.add(Hook.PACKET, ircv3_label_packet) 95 | MessageTag.add(LabelTag) 96 | -------------------------------------------------------------------------------- /modules/ircv3/message-ids.py: -------------------------------------------------------------------------------- 1 | """ 2 | msgid capability 3 | """ 4 | 5 | import secrets 6 | import time 7 | import base64 8 | 9 | from handle.core import Hook, Isupport 10 | from modules.ircv3.messagetags import MessageTag 11 | 12 | 13 | class MessageId(MessageTag): 14 | name = "msgid" 15 | 16 | def __init__(self, value): 17 | super().__init__(name=MessageId.name, value=value) 18 | 19 | 20 | def get_msgid(client): 21 | random_bytes = secrets.token_bytes(8) 22 | timestamp = (int(time.time_ns()) & ((1 << 48) - 1)).to_bytes(6, "big") 23 | combined = random_bytes + timestamp + secrets.token_bytes(2) 24 | msgid = base64.b64encode(combined).decode("utf-8").rstrip('=') 25 | msgid = msgid.replace('+', 'A').replace('/', 'B') 26 | msgid = msgid[:22] 27 | return msgid 28 | 29 | 30 | def add_msgid(client): 31 | # msgid = str(uuid.uuid1()).replace('-', '')[:22] 32 | msgid = get_msgid(client) 33 | tag = MessageId(value=msgid) 34 | client.mtags.append(tag) 35 | 36 | 37 | def post_load(module): 38 | Hook.add(Hook.NEW_MESSAGE, add_msgid) 39 | MessageTag.add(MessageId) 40 | Isupport.add("MSGREFTYPES", MessageId.name) 41 | -------------------------------------------------------------------------------- /modules/ircv3/messagetags.py: -------------------------------------------------------------------------------- 1 | """ 2 | message-tags capability 3 | """ 4 | 5 | from dataclasses import dataclass 6 | from typing import ClassVar 7 | 8 | from handle.core import IRCD, Isupport, Numeric, Flag, Command, Capability 9 | from handle.logger import logging 10 | 11 | 12 | @dataclass 13 | class MessageTag: 14 | table: ClassVar[list] = [] 15 | 16 | name: str = '' 17 | value: str = '' 18 | value_required: int = 0 19 | local: int = 0 20 | client_tag: int = 0 21 | 22 | @classmethod 23 | def is_client_tag(cls): 24 | return cls.client_tag or cls.name.startswith('+') 25 | 26 | def can_send(self, client): 27 | """ For client-tag only. """ 28 | return 1 29 | 30 | def is_visible_to(self, to_client): 31 | if (tag := MessageTag.find_tag(self.name)) and (tag.local or self.local) and to_client.server: 32 | return 0 33 | return to_client.has_capability("message-tags") 34 | 35 | def filter_value(self, target): 36 | """ Do nothing by default. """ 37 | pass 38 | 39 | def value_is_ok(self, value): 40 | return 1 41 | 42 | @property 43 | def string(self): 44 | return f"{self.name}{'=' + self.value if self.value else ''}" 45 | 46 | @staticmethod 47 | def find_tag(name): 48 | for tag in MessageTag.table: 49 | if tag.name == name or any(value == tag.name for value in name.split('/')): 50 | return tag 51 | 52 | @staticmethod 53 | def add(tag): 54 | MessageTag.table.append(tag) 55 | 56 | @staticmethod 57 | def filter_tags(mtags, destination): 58 | return_tags = list(mtags) 59 | 60 | for index, tag in enumerate(mtags): 61 | if not tag.is_visible_to(destination) or (tag.value_required and not tag.value): 62 | return_tags[index] = None 63 | else: 64 | if filtered_tag := tag.filter_value(destination): 65 | return_tags[index] = filtered_tag 66 | 67 | return_tags = [tag for tag in return_tags if tag] 68 | 69 | return return_tags 70 | 71 | 72 | def cmd_tagmsg(client, recv): 73 | if not client.recv_mtags or len(recv[1]) < 2: 74 | return 75 | 76 | recv_target = recv[1] 77 | prefix = '' 78 | if recv_target[0] in IRCD.get_member_prefix_str_sorted(): 79 | prefix = recv_target[0] 80 | recv_target = recv_target[1:] 81 | 82 | if recv_target[0] in IRCD.CHANPREFIXES + IRCD.get_member_prefix_str_sorted(): 83 | if not (target := IRCD.find_channel(recv_target)): 84 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, recv_target) 85 | broadcast = [c for c in target.clients(client_cap="message-tags", prefix=prefix) if c != client] 86 | else: 87 | if not (target := IRCD.find_client(recv_target, user=1)): 88 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv_target) 89 | if target == client and not client.has_capability("echo-message") or not client.has_capability("message-tags"): 90 | return 91 | broadcast = [target] 92 | 93 | """ Add client-tags to .mtags list. """ 94 | mtags = client.recv_mtags 95 | existing_names = {mtag.name for mtag in mtags} 96 | mtags.extend(tag for tag in client.mtags if tag.name not in existing_names) 97 | client.mtags = mtags 98 | 99 | for user in broadcast: 100 | user.send(client.mtags, f":{client.fullmask} TAGMSG {target.name}") 101 | 102 | IRCD.send_to_servers(client, client.mtags, f":{client.id} TAGMSG {target.name}") 103 | 104 | 105 | def init(module): 106 | Capability.add("message-tags") 107 | Command.add(module, cmd_tagmsg, "TAGMSG", 1, Flag.CMD_USER, Flag.CMD_SERVER) 108 | Isupport.add("MTAGS", server_isupport=1) 109 | -------------------------------------------------------------------------------- /modules/ircv3/no_implicit_names.py: -------------------------------------------------------------------------------- 1 | """ 2 | no-implicit-names capability (draft) 3 | https://ircv3.net/specs/extensions/no-implicit-names 4 | 5 | Do not send NAMES messages to users joining channels. 6 | """ 7 | 8 | from handle.core import Capability 9 | 10 | 11 | def init(module): 12 | Capability.add("draft/no-implicit-names") 13 | -------------------------------------------------------------------------------- /modules/ircv3/oper-tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | oper-tag capability 3 | """ 4 | 5 | from handle.core import IRCD, Hook, Capability 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class OperTag(MessageTag): 10 | name = "oper" 11 | 12 | def __init__(self, value): 13 | super().__init__(name=f"provisionircd/{OperTag.name}", value=value) 14 | 15 | def filter_value(self, target) -> MessageTag | None: 16 | if target.user and 'o' not in target.user.modes: 17 | tag = OperTag(value=None) 18 | tag.name = self.name 19 | return tag 20 | 21 | 22 | def add_opertag(client): 23 | if client.user and client.user.operclass and 'o' in client.user.modes and 'H' not in client.user.modes: 24 | tag = OperTag(value=client.user.operclass.name) 25 | client.mtags.append(tag) 26 | 27 | 28 | def post_load(module): 29 | Capability.add("provisionircd/oper-tag") 30 | Hook.add(Hook.NEW_MESSAGE, add_opertag) 31 | MessageTag.add(OperTag) 32 | -------------------------------------------------------------------------------- /modules/ircv3/reply.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draft of +reply client tag. 3 | https://ircv3.net/specs/client-tags/reply.html 4 | """ 5 | 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class ReplyTag(MessageTag): 10 | name = "+draft/reply" 11 | value_required = 1 12 | 13 | def __init__(self, value): 14 | super().__init__(name=ReplyTag.name, value=value) 15 | 16 | def is_visible_to(self, to_client): 17 | return super().is_visible_to(to_client) and to_client.has_capability("echo-message") 18 | 19 | 20 | def post_load(module): 21 | MessageTag.add(ReplyTag) 22 | -------------------------------------------------------------------------------- /modules/ircv3/server_time.py: -------------------------------------------------------------------------------- 1 | """ 2 | server-time capability 3 | """ 4 | 5 | from handle.core import IRCD, Capability, Hook 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class ServerTime(MessageTag): 10 | name = "time" 11 | 12 | def __init__(self, value): 13 | super().__init__(name=ServerTime.name, value=value) 14 | 15 | def is_visible_to(self, to_client): 16 | return super().is_visible_to(to_client) or to_client.has_capability("server-time") 17 | 18 | 19 | def add_server_time(client): 20 | time_tag = ServerTime(value=IRCD.get_time_string()) 21 | client.mtags.append(time_tag) 22 | 23 | 24 | def post_load(module): 25 | Capability.add("server-time") 26 | Hook.add(Hook.NEW_MESSAGE, add_server_time) 27 | MessageTag.add(ServerTime) 28 | -------------------------------------------------------------------------------- /modules/ircv3/standard-replies.py: -------------------------------------------------------------------------------- 1 | """ 2 | standard-replies capability 3 | """ 4 | 5 | from handle.core import Capability 6 | 7 | 8 | def init(module): 9 | Capability.add("standard-replies") 10 | -------------------------------------------------------------------------------- /modules/ircv3/typingtag.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides +typing client tag. 3 | https://ircv3.net/specs/client-tags/typing.html 4 | """ 5 | 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class TypingTag(MessageTag): 10 | name = "+typing" 11 | 12 | def __init__(self, value): 13 | super().__init__(name=TypingTag.name, value=value) 14 | 15 | 16 | def post_load(module): 17 | MessageTag.add(TypingTag) 18 | -------------------------------------------------------------------------------- /modules/ircv3/userhost-tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | userhost and userip tags 3 | """ 4 | 5 | from handle.core import IRCD, Hook, Capability 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class BaseHostTag(MessageTag): 10 | name = None 11 | 12 | def __init__(self, value): 13 | if self.name is None: 14 | raise NotImplementedError(f"Subclass of BaseHostTag must define 'name'") 15 | super().__init__(name=f"provisionircd/{self.name}", value=value) 16 | 17 | def is_visible_to(self, to_client): 18 | return super().is_visible_to(to_client) and (to_client.server or 'o' in to_client.user.modes) 19 | 20 | 21 | class HostTag(BaseHostTag): 22 | name = "host" 23 | 24 | 25 | class IpTag(BaseHostTag): 26 | name = "ip" 27 | 28 | 29 | def add_userhosttag(client): 30 | if client.user: 31 | client.mtags.append(HostTag(value=client.user.realhost)) 32 | client.mtags.append(IpTag(value=client.ip)) 33 | 34 | 35 | def post_load(module): 36 | Capability.add("provisionircd/host") 37 | Capability.add("provisionircd/ip") 38 | Hook.add(Hook.NEW_MESSAGE, add_userhosttag) 39 | MessageTag.add(HostTag) 40 | MessageTag.add(IpTag) 41 | -------------------------------------------------------------------------------- /modules/knock.py: -------------------------------------------------------------------------------- 1 | """ 2 | /knock command 3 | """ 4 | 5 | from time import time 6 | from handle.core import IRCD, Numeric, Command, Hook 7 | 8 | Knocks = {} 9 | KNOCK_EXPIRE = 60 10 | 11 | 12 | def cmd_knock(client, recv): 13 | """ 14 | Knock on an invite-only (+i) channel to request an invitation. 15 | Syntax: KNOCK 16 | """ 17 | 18 | if not (channel := IRCD.find_channel(recv[1])): 19 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, recv[1]) 20 | 21 | if channel.find_member(client): 22 | return client.sendnumeric(Numeric.ERR_CANNOTKNOCK, channel.name, "You are already on that channel") 23 | 24 | if 'i' not in channel.modes: 25 | return client.sendnumeric(Numeric.ERR_CANNOTKNOCK, channel.name, "Channel is not invite only") 26 | 27 | if channel.get_invite(client): 28 | return client.sendnumeric(Numeric.ERR_CANNOTKNOCK, channel.name, "You have already been invited") 29 | 30 | if channel.is_banned(client) and not channel.is_exempt(client) and not client.has_permission("channel:override:join:ban"): 31 | return client.sendnumeric(Numeric.ERR_CANNOTKNOCK, channel.name, "You are banned") 32 | 33 | Knocks.setdefault(channel, {}) 34 | 35 | if client in Knocks[channel]: 36 | knock_time = Knocks[channel][client] 37 | if int(time() < (knock_time + KNOCK_EXPIRE)) and not client.has_permission("immune:knock-flood"): 38 | if client.local: 39 | client.add_flood_penalty(25_000) 40 | client.sendnumeric(Numeric.ERR_CANNOTKNOCK, channel.name, "Please wait before knocking again") 41 | return 42 | 43 | Knocks[channel][client] = int(time()) 44 | 45 | IRCD.new_message(client) 46 | data = f":{client.fullmask} KNOCK {channel.name}" 47 | broadcast_users = [c for c in channel.clients() if c.local 48 | and (channel.client_has_membermodes(c, "oaq") or c.has_permission("channel:see:knock"))] 49 | for user in broadcast_users: 50 | user.send(client.mtags, data) 51 | 52 | IRCD.server_notice(client, f"You have knocked on {channel.name}") 53 | client.add_flood_penalty(100_000) 54 | 55 | data = f":{client.id} KNOCK {channel.name}" 56 | IRCD.send_to_servers(client, client.mtags, data) 57 | 58 | 59 | def knock_delete_join(client, channel, *args): 60 | Knocks.get(channel, {}).pop(client, None) 61 | 62 | 63 | def knock_delete_quit(client, channel, *args): 64 | for c in [c for c in IRCD.get_channels() if client in Knocks.get(c, {})]: 65 | del Knocks[c][client] 66 | 67 | 68 | def knock_expire(): 69 | for channel in Knocks: 70 | Knocks[channel] = { 71 | client: knock_time 72 | for client, knock_time in Knocks[channel].items() 73 | if int(time()) < knock_time + KNOCK_EXPIRE 74 | } 75 | 76 | 77 | def init(module): 78 | Command.add(module, cmd_knock, "KNOCK", 1) 79 | Hook.add(Hook.LOCAL_QUIT, knock_delete_quit) 80 | Hook.add(Hook.REMOTE_QUIT, knock_delete_quit) 81 | Hook.add(Hook.LOCAL_JOIN, knock_delete_join) 82 | Hook.add(Hook.REMOTE_JOIN, knock_delete_join) 83 | Hook.add(Hook.LOOP, knock_expire) 84 | -------------------------------------------------------------------------------- /modules/m_admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | /admin command 3 | """ 4 | 5 | from handle.core import IRCD, Numeric, Command 6 | 7 | 8 | def cmd_admin(client, recv): 9 | """ 10 | Displays administrative information about the server. 11 | """ 12 | 13 | if not (admin_block := IRCD.configuration.get_block("admin")): 14 | return 15 | client.sendnumeric(Numeric.RPL_ADMINME, IRCD.me.name) 16 | for idx, entry in enumerate(admin_block.entries): 17 | match idx: 18 | case 0: 19 | rpl = Numeric.RPL_ADMINLOC1 20 | case 1: 21 | rpl = Numeric.RPL_ADMINLOC2 22 | case _: 23 | rpl = Numeric.RPL_ADMINEMAIL 24 | client.sendnumeric(rpl, entry.get_single_value()) 25 | 26 | 27 | def init(module): 28 | Command.add(module, cmd_admin, "ADMIN") 29 | -------------------------------------------------------------------------------- /modules/m_away.py: -------------------------------------------------------------------------------- 1 | """ 2 | /away command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Isupport, Numeric, Flag, Capability, Hook 6 | 7 | AWAYLEN = 300 8 | 9 | 10 | def cmd_away(client, recv): 11 | away = ' '.join(recv[1:]).removeprefix(':')[:AWAYLEN] if len(recv) > 1 else '' 12 | 13 | if (not away and not client.user.away) or away == client.user.away: 14 | return 15 | 16 | for result, _ in Hook.call(Hook.PRE_AWAY, args=(client, away)): 17 | if result == Hook.DENY: 18 | return 19 | elif result == Hook.ALLOW: 20 | break 21 | elif result == Hook.CONTINUE: 22 | continue 23 | 24 | if away != client.user.away: 25 | client.user.away = away 26 | client.sendnumeric(Numeric.RPL_NOWAWAY if away else Numeric.RPL_UNAWAY) 27 | 28 | if len(' '.join(recv[1:])) > AWAYLEN: 29 | IRCD.server_notice(client, f"Away message truncated: exceeded limit of {AWAYLEN} characters.") 30 | 31 | chan_data = f":{client.fullmask} AWAY {':' + client.user.away if client.user.away else ''}" 32 | IRCD.send_to_local_common_chans(client=client, mtags=[], client_cap="away-notify", data=chan_data) 33 | 34 | server_data = f":{client.id} AWAY {':' + client.user.away if client.user.away else ''}" 35 | IRCD.send_to_servers(client, mtags=[], data=server_data) 36 | 37 | IRCD.run_hook(Hook.AWAY, client, client.user.away) 38 | 39 | 40 | def init(module): 41 | Command.add(module, cmd_away, "AWAY", 0, Flag.CMD_USER) 42 | Isupport.add("AWAYLEN", AWAYLEN) 43 | Capability.add("away-notify") 44 | -------------------------------------------------------------------------------- /modules/m_cap.py: -------------------------------------------------------------------------------- 1 | """ 2 | /cap command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag, Capability, Hook 6 | 7 | 8 | class CapHandshake: 9 | in_progress = [] 10 | 11 | 12 | def cmd_cap(client, recv): 13 | if len(recv) < 2: 14 | return 15 | 16 | if recv[1].lower() == "ls": 17 | # Don't reg until CAP END 18 | if client not in CapHandshake.in_progress: 19 | CapHandshake.in_progress.append(client) 20 | caps = [] 21 | for c in Capability.table: 22 | caps.append(c.string) 23 | client.send([], f":{IRCD.me.name} CAP {client.name} LS :{' '.join(caps)}") 24 | 25 | elif recv[1].lower() == "list": 26 | if client.local.caps: 27 | data = f":{IRCD.me.name} CAP {client.name} LIST :{' '.join(client.local.caps)}" 28 | client.send([], data) 29 | 30 | elif recv[1].lower() == "req": 31 | # Don't reg until CAP END 32 | if client not in CapHandshake.in_progress: 33 | CapHandshake.in_progress.append(client) 34 | if len(recv) < 3: 35 | return 36 | 37 | caps = ' '.join(recv[2:]).lower().removeprefix(':') 38 | ack_caps = [] 39 | for cap in caps.split(): 40 | if cap.startswith('-'): 41 | cap = cap[1:] 42 | if client.remove_capability(cap): 43 | ack_caps.append('-' + cap) 44 | continue 45 | 46 | if Capability.find_cap(cap) and not client.has_capability(cap): 47 | client.set_capability(cap) 48 | ack_caps.append(cap) 49 | 50 | if ack_caps: 51 | data = f":{IRCD.me.name} CAP {client.name} ACK :{' '.join(ack_caps)}" 52 | client.send([], data) 53 | 54 | elif recv[1].lower() == "end": 55 | if client.registered: 56 | return 57 | if client in CapHandshake.in_progress: 58 | CapHandshake.in_progress.remove(client) 59 | if client.user: 60 | if client.handshake_finished(): 61 | client.register_user() 62 | else: 63 | client.sendnumeric(Numeric.ERR_INVALIDCAPCMD, recv[1]) 64 | 65 | 66 | def cap_handshake_finished(client): 67 | return client not in CapHandshake.in_progress 68 | 69 | 70 | def cap_cleanup(client, reason): 71 | if client in CapHandshake.in_progress: 72 | CapHandshake.in_progress.remove(client) 73 | 74 | 75 | def init(module): 76 | Command.add(module, cmd_cap, "CAP", 1, Flag.CMD_UNKNOWN) 77 | Capability.add("cap-notify") 78 | Hook.add(Hook.IS_HANDSHAKE_FINISHED, cap_handshake_finished) 79 | Hook.add(Hook.LOCAL_QUIT, cap_cleanup) 80 | -------------------------------------------------------------------------------- /modules/m_chghost.py: -------------------------------------------------------------------------------- 1 | """ 2 | /chghost and /chgident command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Capability, Numeric, Flag 6 | from handle.logger import logging 7 | 8 | 9 | def cmd_chghost(client, recv): 10 | """ 11 | Changes a users' host. 12 | Syntax: CHGHOST 13 | """ 14 | 15 | permission_parent = "chgcmds:chghost" 16 | 17 | if not client.has_permission(f"{permission_parent}:local") and not client.has_permission(f"{permission_parent}:global"): 18 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 19 | 20 | if not (target := IRCD.find_client(recv[1], user=1)): 21 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 22 | 23 | if client.local: 24 | permission_check = f"{permission_parent}:global" if not target.local else f"{permission_parent}:local" 25 | if not client.has_permission(permission_check) and not client.has_permission(f"{permission_parent}:global"): 26 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 27 | 28 | host = IRCD.clean_string(string=recv[2], charset=IRCD.HOSTCHARS, maxlen=64) 29 | host = host.removeprefix(':') 30 | if host == target.user.host or not host: 31 | return 32 | 33 | target.add_user_modes("xt") 34 | target.set_host(host=host) 35 | 36 | if client.user: 37 | if target.local: 38 | IRCD.server_notice(target, f"Your hostname has now been changed to: {target.user.host}") 39 | data = (f"*** {client.name} ({client.user.username}@{client.user.realhost}) " 40 | f"used CHGHOST to change the host of {target.name} to \"{target.user.host}\"") 41 | IRCD.log(client, "info", "chgcmds", "CHGHOST_COMMAND", data) 42 | 43 | IRCD.send_to_servers(client, [], f":{client.id} CHGHOST {target.id} :{target.user.host}") 44 | 45 | 46 | def cmd_chgident(client, recv): 47 | """ 48 | Changes the ident (username) part of a user. 49 | Syntax: CHGIDENT 50 | """ 51 | 52 | permission_parent = "chgcmds:chgident" 53 | 54 | if not client.has_permission(f"{permission_parent}:local") and not client.has_permission(f"{permission_parent}:global"): 55 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 56 | 57 | if not (target := IRCD.find_client(recv[1], user=1)): 58 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 59 | 60 | if client.local: 61 | permission_check = f"{permission_parent}:global" if not target.local else f"{permission_parent}:local" 62 | if not client.has_permission(permission_check) and not client.has_permission(f"{permission_parent}:global"): 63 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 64 | 65 | ident = IRCD.clean_string(string=recv[2], charset=IRCD.HOSTCHARS, maxlen=12) 66 | ident = ident.removeprefix(':') 67 | 68 | if ident == target.user.username or not ident: 69 | return 70 | 71 | target.user.username = ident 72 | 73 | if client.user: 74 | if target.local: 75 | IRCD.server_notice(target, f"Your ident has now been changed to: {target.user.username}") 76 | data = (f"*** {client.name} ({client.user.username}@{client.user.realhost}) " 77 | f"used CHGIDENT to change the ident of {target.name} to \"{target.user.username}\"") 78 | IRCD.log(client, "info", "chgcmds", "CHGIDENT_COMMAND", data, sync=0) 79 | 80 | IRCD.send_to_servers(client, [], f":{client.id} CHGIDENT {target.id} :{target.user.username}") 81 | 82 | 83 | def init(module): 84 | Command.add(module, cmd_chghost, "CHGHOST", 2, Flag.CMD_OPER) 85 | Command.add(module, cmd_chgident, "CHGIDENT", 2, Flag.CMD_OPER) 86 | Capability.add("chghost") 87 | -------------------------------------------------------------------------------- /modules/m_chgname.py: -------------------------------------------------------------------------------- 1 | """ 2 | /chgname command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_chgname(client, recv): 9 | """ 10 | Changes a users' real nane (GECOS). 11 | Syntax: CHGNAME 12 | """ 13 | 14 | permission_parent = "chgcmds:chgname" 15 | 16 | if not client.has_permission(f"{permission_parent}:local") and not client.has_permission(f"{permission_parent}:global"): 17 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 18 | 19 | if not (target := IRCD.find_client(recv[1], user=1)): 20 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 21 | 22 | if client.local: 23 | permission_check = f"{permission_parent}:global" if not target.local else f"{permission_parent}:local" 24 | if not client.has_permission(permission_check) and not client.has_permission(f"{permission_parent}:global"): 25 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 26 | 27 | gecos = ' '.join(recv[2:])[:50].removeprefix(':').strip() 28 | if gecos == target.info or not gecos: 29 | return 30 | 31 | target.setinfo(gecos, change_type="gecos") 32 | IRCD.send_snomask(client, 's', f"*** {client.name} ({client.user.username}@{client.user.realhost}) " 33 | f"used CHGNAME to change the GECOS of {target.name} to \"{target.info}\"") 34 | 35 | IRCD.send_to_servers(client, [], data=f":{client.id} CHGNAME {target.id} :{target.info}") 36 | 37 | 38 | def init(module): 39 | Command.add(module, cmd_chgname, "CHGNAME", 2, Flag.CMD_OPER) 40 | -------------------------------------------------------------------------------- /modules/m_cloak.py: -------------------------------------------------------------------------------- 1 | """ 2 | /cloak command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_cloak(client, recv): 9 | IRCD.server_notice(client, f"* Cloaked version is: {IRCD.get_cloak(client, host=recv[1])}") 10 | 11 | 12 | def init(module): 13 | Command.add(module, cmd_cloak, "CLOAK", 1, Flag.CMD_OPER) 14 | -------------------------------------------------------------------------------- /modules/m_clones.py: -------------------------------------------------------------------------------- 1 | """ 2 | /clones command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_clones(client, recv): 9 | """ 10 | View clones on the network. 11 | """ 12 | 13 | clones = set() 14 | foundclones = 0 15 | 16 | for user_client in IRCD.get_clients(user=1): 17 | if user_client.ip not in clones: 18 | clones.add(user_client.ip) 19 | logins = [ 20 | c.name for c in IRCD.get_clients(user=1) 21 | if c.registered and not c.is_uline() and 'S' not in c.user.modes and c.ip == user_client.ip 22 | ] 23 | if len(logins) > 1: 24 | foundclones = 1 25 | client.sendnumeric(Numeric.RPL_CLONES, user_client.name, len(logins), user_client.ip, ' '.join(logins)) 26 | 27 | if not foundclones: 28 | client.sendnumeric(Numeric.RPL_NOCLONES, "server" if not any(IRCD.get_clients(server=1)) else "network") 29 | 30 | 31 | def init(module): 32 | Command.add(module, cmd_clones, "CLONES", 0, Flag.CMD_OPER) 33 | -------------------------------------------------------------------------------- /modules/m_connect.py: -------------------------------------------------------------------------------- 1 | """ 2 | /connect command 3 | """ 4 | 5 | from handle.logger import logging 6 | from handle.handleLink import start_outgoing_link 7 | from handle.core import IRCD, Command, Numeric, Flag 8 | 9 | 10 | def connect_to(client, link, auto_connect=0): 11 | if "host" not in link.outgoing or "port" not in link.outgoing: 12 | missing = "host" if "host" not in link.outgoing else "port" 13 | return IRCD.server_notice(client, f"Unable to process outgoing link '{link.name}' because it has no outgoing {missing} defined.") 14 | 15 | # If the host is local, and you are listening for servers on the port, do not connect to yourself. 16 | out_host, out_port = link.outgoing["host"], int(link.outgoing["port"]) 17 | listening_ports = [int(listen.port) for listen in IRCD.configuration.listen] 18 | if out_host in ["127.0.0.1", "0.0.0.0", "localhost"] and out_port in listening_ports: 19 | if client and client.user: 20 | IRCD.server_notice(client, f"Unable to process outgoing link {out_host}:{out_port} because destination is localhost.") 21 | return 22 | 23 | is_tls = "tls" in link.outgoing_options or "ssl" in link.outgoing_options 24 | 25 | if client.user: 26 | connection_type = "secure" if is_tls else "unsecure" 27 | msg = (f"*** {client.name} ({client.user.username}@{client.user.realhost}) " 28 | f"has opened a{('n ' if connection_type == 'unsecure' else ' ')}{connection_type} link channel to {link.name}...") 29 | IRCD.log(client, "info", "link", "LINK_CONNECTING", msg) 30 | 31 | if not IRCD.find_client(link.name) and not IRCD.current_link_sync: 32 | IRCD.run_parallel_function(target=start_outgoing_link, args=(link, is_tls, auto_connect, client)) 33 | 34 | 35 | def cmd_connect(client, recv): 36 | """ 37 | Used by IRC Operators to request a link with a pre-configured server. 38 | Syntax: CONNECT 39 | 40 | Note that should match a server in your configuration file. 41 | """ 42 | 43 | if not client.has_permission("server:connect"): 44 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 45 | 46 | if "HTTP/" in recv: 47 | return client.exit("Illegal command") 48 | 49 | if IRCD.current_link_sync: 50 | client.local.flood_penalty += 100_000 51 | logging.debug(f"Current link sync: {IRCD.current_link_sync}") 52 | return IRCD.server_notice(client, f"A link sync is already in process, try again in a few seconds.") 53 | 54 | name = recv[1].strip() 55 | if name.lower() == IRCD.me.name.lower(): 56 | return IRCD.server_notice(client, "*** Cannot link to own local server.") 57 | 58 | if not (link := IRCD.get_link(name)): 59 | return IRCD.server_notice(client, f"*** Server {name} is not configured for linking.") 60 | 61 | server_client = IRCD.find_client(name) 62 | 63 | if server_client: 64 | if not server_client.server.synced: 65 | return IRCD.server_notice(client, f"*** Link to {name} is currently being processed.") 66 | 67 | if server_client.server.synced: 68 | return IRCD.server_notice(client, f"*** Already linked to {name}.") 69 | 70 | if not link.outgoing: 71 | return IRCD.server_notice(client, f"*** Server {name} is not configured as an outgoing link.") 72 | 73 | client.local.flood_penalty += 100_000 74 | connect_to(client, link) 75 | 76 | 77 | def init(module): 78 | Command.add(module, cmd_connect, "CONNECT", 1, Flag.CMD_OPER) 79 | -------------------------------------------------------------------------------- /modules/m_cycle.py: -------------------------------------------------------------------------------- 1 | """ 2 | /cycle command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_cycle(client, recv): 9 | for chan in recv[1].split(','): 10 | chan = IRCD.strip_format(chan) 11 | if not (channel := IRCD.find_channel(chan)): 12 | client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, chan) 13 | continue 14 | 15 | if not client.channels(): 16 | client.sendnumeric(Numeric.ERR_NOTONCHANNEL, chan) 17 | continue 18 | 19 | Command.do(client, "PART", channel.name, "Cycling") 20 | Command.do(client, "JOIN", channel.name) 21 | 22 | 23 | def init(module): 24 | Command.add(module, cmd_cycle, "CYCLE", 1, Flag.CMD_USER) 25 | -------------------------------------------------------------------------------- /modules/m_die.py: -------------------------------------------------------------------------------- 1 | """ 2 | /die command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_die(client, recv): 9 | """ 10 | DIE 11 | - 12 | Shuts down the server. 13 | """ 14 | 15 | if not client.has_permission("server:die"): 16 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 17 | 18 | if client.user: 19 | if recv[1] != IRCD.get_setting("diepass"): 20 | client.local.flood_penalty += 2500001 21 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 22 | 23 | reason = (f"DIE command received by {client.name} ({client.user.username}@{client.user.realhost})" 24 | if client.user else "DIE command received from the command line.") 25 | 26 | IRCD.send_snomask(client, 's', f"*** {reason}") 27 | 28 | for server in IRCD.get_clients(local=1, server=1): 29 | server.send([], f"SQUIT {IRCD.me.name} :{reason}") 30 | 31 | IRCD.run_parallel_function(IRCD.shutdown, delay=0.1) 32 | 33 | 34 | def init(module): 35 | Command.add(module, cmd_die, "DIE", 1, Flag.CMD_OPER) 36 | -------------------------------------------------------------------------------- /modules/m_eos.py: -------------------------------------------------------------------------------- 1 | """ 2 | /eos command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag, Hook 6 | from modules.ircv3.batch import Batch 7 | from handle.logger import logging 8 | 9 | 10 | @logging.client_context 11 | def cmd_eos(client, recv): 12 | if IRCD.current_link_sync in [client, client.uplink, client.direction]: 13 | IRCD.current_link_sync = None 14 | 15 | if client.server.synced: 16 | return 17 | 18 | client.server.synced = 1 19 | client.add_flag(Flag.CLIENT_REGISTERED) 20 | 21 | IRCD.send_to_servers(client, mtags=[], data=f":{client.id} EOS") 22 | logging.debug(f"EOS received. Uplink: {client.uplink.name})") 23 | 24 | """ We can now process other servers' recv buffer """ 25 | IRCD.do_delayed_process() 26 | 27 | """ Send held back data for this client """ 28 | if client in IRCD.send_after_eos: 29 | logging.debug(f"Now sending previously held back server data to {client.name}") 30 | for mtags, data in IRCD.send_after_eos[client]: 31 | IRCD.send_to_one_server(client, mtags, data) 32 | del IRCD.send_after_eos[client] 33 | 34 | for batch in Batch.pool: 35 | started_by = client if client.local else client.uplink 36 | if batch.started_by in [started_by, started_by.direction] and batch.batch_type == "netjoin": 37 | batch.end() 38 | 39 | IRCD.run_hook(Hook.SERVER_SYNCED, client) 40 | 41 | 42 | def init(module): 43 | Command.add(module, cmd_eos, "EOS", 0, Flag.CMD_SERVER) 44 | -------------------------------------------------------------------------------- /modules/m_error.py: -------------------------------------------------------------------------------- 1 | """ 2 | /error command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_error(client, recv): 9 | if (link := IRCD.get_link(client.name)) and link.auto_connect and not client.server.authed: 10 | """ Do not spam ERROR messages on outgoing autoconnect fails. """ 11 | return 12 | msg = ' '.join(recv[1:]).removeprefix(':') 13 | IRCD.log(IRCD.me, "error", "error", "ERROR_LINK", msg) 14 | 15 | 16 | def init(module): 17 | Command.add(module, cmd_error, "ERROR", 1, Flag.CMD_SERVER) 18 | -------------------------------------------------------------------------------- /modules/m_invite.py: -------------------------------------------------------------------------------- 1 | """ 2 | /invite command 3 | """ 4 | 5 | import time 6 | 7 | from handle.core import IRCD, Command, Channelmode, Capability, Flag, Numeric, Hook 8 | 9 | 10 | def cmd_invite(client, recv): 11 | """ 12 | Invites a user to a channel. 13 | Syntax: /INVITE 14 | """ 15 | 16 | oper_override = 0 17 | 18 | if not (invite_client := IRCD.find_client(recv[1], user=1)): 19 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 20 | 21 | if not (channel := IRCD.find_channel(recv[2])): 22 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, recv[2]) 23 | 24 | if not channel.find_member(client): 25 | if invite_client == client and not client.has_permission("channel:override:invite:self"): 26 | return client.sendnumeric(Numeric.ERR_NOTONCHANNEL, channel.name) 27 | elif not client.has_permission("channel:override:invite:notinchannel"): 28 | return client.sendnumeric(Numeric.ERR_NOTONCHANNEL, channel.name) 29 | else: 30 | oper_override = 1 31 | 32 | if 'V' in channel.modes: 33 | if not channel.client_has_membermodes(client, 'q') and not client.has_permission("channel:override:invite:no-invite"): 34 | return client.sendnumeric(Numeric.ERR_NOINVITE, channel.name) 35 | elif client.has_permission("channel:override:invite:no-invite"): 36 | oper_override = 1 37 | 38 | if channel.find_member(invite_client): 39 | return client.sendnumeric(Numeric.ERR_USERONCHANNEL, invite_client.name, channel.name) 40 | 41 | if (inv := channel.get_invite(invite_client)) and inv.by == client: 42 | return 43 | 44 | invite_can_override = 1 if (client.has_permission("channel:override:invite") or channel.client_has_membermodes(client, "oaq")) else 0 45 | channel.add_invite(to=invite_client, by=client, override=invite_can_override) 46 | if oper_override and client.user and client.local: 47 | overrides = { 48 | 'b': " [Overriding +b]" if channel.is_banned(invite_client) else '', 49 | 'i': " [Overriding +i]" if 'i' in channel.modes else '', 50 | 'l': " [Overriding +l]" if 'l' in channel.modes and channel.membercount >= channel.limit else '', 51 | 'k': " [Overriding +k]" if 'k' in channel.modes else '', 52 | 'R': " [Overriding +R]" if 'R' in channel.modes and 'r' not in invite_client.user.modes else '', 53 | 'z': " [Overriding +z]" if 'z' in channel.modes and 'z' not in invite_client.user.modes else '' 54 | } 55 | s = next((msg for key, msg in overrides.items() if msg), '') 56 | IRCD.send_oper_override(client, f"INVITE {invite_client.name} {channel.name}{s}") 57 | 58 | data = f":{client.fullmask} INVITE {invite_client.name} {channel.name}" 59 | invite_client.send([], data) 60 | client.sendnumeric(Numeric.RPL_INVITING, invite_client.name, channel.name) 61 | 62 | broadcast_users = [c for c in channel.clients(client_cap="invite-notify") if c.local 63 | and (channel.client_has_membermodes(c, "hoaq") or c.has_permission("channel:see:invites"))] 64 | 65 | for user in broadcast_users: 66 | user.send([], data) 67 | 68 | # Users who do not have the invite-notify capability should still receive a traditional notice. 69 | notice_users = [c for c in channel.clients() if c.local and c not in broadcast_users 70 | and (channel.client_has_membermodes(c, "hoaq") or c.has_permission("channel:see:invites"))] 71 | broadcast_data = (f"NOTICE {channel.name} :{client.name} ({client.user.username}@{client.user.host}) " 72 | f"has invited {invite_client.name} to join the channel") 73 | for notice_user in notice_users: 74 | IRCD.server_notice(notice_user, broadcast_data) 75 | 76 | IRCD.send_to_servers(client, [], f":{client.id} INVITE {invite_client.name} {channel.name}") 77 | 78 | 79 | def expired_invites(): 80 | # Expire invites after 1 hour. 81 | for chan in IRCD.get_channels(): 82 | chan.invites = [invite for invite in chan.invites if time.time() - invite.when < 3600.0] 83 | 84 | 85 | def invite_can_join(client, channel, key): 86 | if 'i' in channel.modes and not channel.is_invex(client): 87 | return Numeric.ERR_INVITEONLYCHAN 88 | return 0 89 | 90 | 91 | def init(module): 92 | Hook.add(Hook.LOOP, expired_invites) 93 | Hook.add(Hook.CAN_JOIN, invite_can_join) 94 | Cmode_i = Channelmode() 95 | Cmode_i.flag = 'i' 96 | Cmode_i.desc = "You need to be invited to join the channel" 97 | Cmode_i.paramcount = 0 98 | Cmode_i.is_ok = Channelmode.allow_chanop 99 | Channelmode.add(module, Cmode_i) 100 | Command.add(module, cmd_invite, "INVITE", 2, Flag.CMD_USER) 101 | Capability.add("invite-notify") 102 | -------------------------------------------------------------------------------- /modules/m_ircops.py: -------------------------------------------------------------------------------- 1 | """ 2 | /ircops command (show online opers) 3 | """ 4 | 5 | from handle.core import IRCD, Numeric, Command 6 | 7 | 8 | def cmd_ircops(client, recv): 9 | """ 10 | Displays all online IRC Operators. 11 | """ 12 | 13 | header_divider = "§~¤§¤~~¤§¤~~¤§¤~~¤§¤~~¤§¤~~¤§¤~~¤§¤~~¤§¤~~¤§¤~§" 14 | client.sendnumeric(Numeric.RPL_IRCOPS, header_divider) 15 | client.sendnumeric(Numeric.RPL_IRCOPS, "Nick Status Server") 16 | client.sendnumeric(Numeric.RPL_IRCOPS, "-----------------------------------------------") 17 | 18 | aways = opers = 0 19 | for oper_client in (c for c in IRCD.get_clients(user=1, usermodes='o') 20 | if (('H' not in c.user.modes or 'o' in client.user.modes) 21 | and 'S' not in c.user.modes)): 22 | opers += 1 23 | status = '' 24 | 25 | if oper_client.user.away: 26 | aways += 1 27 | status += "(AWAY)" 28 | 29 | if 'H' in oper_client.user.modes: 30 | status += f"{' ' if oper_client.user.away else ''}(+H)" 31 | nick_len = len(oper_client.name[24:]) 32 | width = 31 - len(status) - nick_len 33 | client.sendnumeric(Numeric.RPL_IRCOPS, f"{oper_client.name:23} Oper {status} {oper_client.uplink.name:>{width}}") 34 | 35 | client.sendnumeric(Numeric.RPL_IRCOPS, f"Total: {opers} IRCOP{'s' if opers != 1 else ''} connected - {aways} Away") 36 | client.sendnumeric(Numeric.RPL_IRCOPS, header_divider) 37 | client.sendnumeric(Numeric.RPL_ENDOFIRCOPS) 38 | 39 | 40 | def init(module): 41 | Command.add(module, cmd_ircops, "IRCOPS") 42 | -------------------------------------------------------------------------------- /modules/m_ison.py: -------------------------------------------------------------------------------- 1 | """ 2 | /ison and /userhost command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric 6 | 7 | 8 | def cmd_ison(client, recv): 9 | """ 10 | Checks to see if a nickname is online. 11 | Example: /ISON Nick1 SomeOthernick 12 | """ 13 | 14 | nicks = [] 15 | for nick in recv[1:]: 16 | for u_client in [u_client for u_client in IRCD.get_clients(user=1) 17 | if u_client.name.lower() == nick.lower() and u_client.name not in nicks]: 18 | nicks.append(u_client.name) 19 | client.sendnumeric(Numeric.RPL_ISON, ' '.join(nicks)) 20 | 21 | 22 | def cmd_userhost(client, recv): 23 | """ 24 | Returns the cloaked userhost of the given user. 25 | Example: /USERHOST John 26 | """ 27 | 28 | hosts = [] 29 | for nick in recv[1:]: 30 | for u_client in [u_client for u_client in IRCD.get_clients(user=1) if 31 | u_client.name.lower() == nick.lower() and u_client.name not in hosts]: 32 | h = (f"{u_client.name}*=+{u_client.user.username}@" 33 | f"{u_client.user.host if 'o' not in u_client.user.modes else u_client.user.realhost}") 34 | if h not in hosts: 35 | hosts.append(h) 36 | client.sendnumeric(Numeric.RPL_USERHOST, ' '.join(hosts)) 37 | 38 | 39 | def init(module): 40 | Command.add(module, cmd_ison, "ISON", 1) 41 | Command.add(module, cmd_userhost, "USERHOST", 1) 42 | -------------------------------------------------------------------------------- /modules/m_joinpart.py: -------------------------------------------------------------------------------- 1 | """ 2 | commands /join and /part 3 | """ 4 | 5 | from time import time 6 | from handle.core import IRCD, Command, Capability 7 | from classes.data import Flag, Numeric, Isupport, Hook 8 | 9 | 10 | def cmd_join(client, recv): 11 | """ 12 | Syntax: JOIN [key] 13 | Joins a given channel with optional [key]. 14 | """ 15 | 16 | if recv[1] == '0': 17 | for channel in client.channels(): 18 | IRCD.new_message(client) 19 | channel.do_part(client, reason="Leaving all channels") 20 | return 21 | 22 | if client.local and len(client.channels()) >= 100 and not client.has_permission("channel:override:join:max"): 23 | return client.sendnumeric(Numeric.ERR_TOOMANYCHANNELS) 24 | 25 | pc = 0 26 | key = None 27 | override = client.has_flag(Flag.CLIENT_USER_SAJOIN) 28 | for chan in recv[1].split(',')[:12]: 29 | if client.local and int(time()) - client.creationtime > 5: 30 | client.local.flood_penalty += 10_000 31 | 32 | if (channel := IRCD.find_channel(chan)) and channel.find_member(client): 33 | continue 34 | 35 | if not IRCD.is_valid_channelname(chan) and (client.local and not channel): 36 | client.sendnumeric(Numeric.ERR_FORBIDDENCHANNEL, chan, "Illegal channel name") 37 | continue 38 | 39 | # Blegh. 40 | if len(recv) > 2: 41 | try: 42 | key = recv[2:][pc] 43 | pc += 1 44 | except IndexError: 45 | pass 46 | 47 | if len(chan) > IRCD.CHANLEN and (client.local and not channel) and not override: 48 | client.sendnumeric(Numeric.ERR_FORBIDDENCHANNEL, chan, "Channel name too long") 49 | continue 50 | 51 | if not channel and not override: 52 | if IRCD.get_setting("onlyopersjoin") and 'o' not in client.user.modes and client.local: 53 | IRCD.server_notice(client, "*** Channel creation is limited to IRC operators.") 54 | continue 55 | channel = IRCD.create_channel(client, chan) 56 | 57 | if client.local and not override: 58 | if (error := channel.can_join(client, key)) != 0: 59 | if isinstance(error, tuple): 60 | client.sendnumeric(error, channel.name) 61 | IRCD.run_hook(Hook.JOIN_FAIL, client, channel, error) 62 | continue 63 | 64 | if logchan := IRCD.get_setting("logchan"): 65 | if logchan.lower() == channel.name.lower() and 'o' not in client.user.modes: 66 | return client.sendnumeric(Numeric.ERR_OPERONLY, channel.name) 67 | 68 | IRCD.new_message(client) 69 | channel.do_join(client.mtags, client) 70 | 71 | if client.local: 72 | IRCD.run_hook(Hook.PRE_LOCAL_JOIN, client, channel) 73 | if channel.topic_time != 0: 74 | Command.do(client, "TOPIC", channel.name) 75 | 76 | if not client.has_capability("draft/no-implicit-names"): 77 | Command.do(client, "NAMES", channel.name) 78 | 79 | hook = Hook.LOCAL_JOIN if client.local else Hook.REMOTE_JOIN 80 | IRCD.run_hook(hook, client, channel) 81 | 82 | client.mtags = [] 83 | 84 | 85 | def cmd_part(client, recv): 86 | """ 87 | Syntax: PART [reason] 88 | Parts the given channel with optional [reason]. 89 | """ 90 | 91 | reason = ' '.join(recv[2:]).rstrip().removeprefix(':') if len(recv) > 2 else '' 92 | 93 | if client.seconds_since_signon() <= 10: 94 | reason = client.name 95 | 96 | if (static_part := IRCD.get_setting("static-part")) and not client.has_permission("channel:override:staticpart"): 97 | reason = static_part 98 | 99 | for chan_name in recv[1].split(','): 100 | if client.local and (int(time()) - client.creationtime) > 5: 101 | client.local.flood_penalty += 10_000 102 | 103 | channel = IRCD.find_channel(chan_name) 104 | if not channel or not channel.find_member(client): 105 | client.sendnumeric(Numeric.ERR_NOTONCHANNEL, chan_name) 106 | continue 107 | 108 | hook = Hook.call(Hook.PRE_LOCAL_PART, args=(client, channel, reason)) 109 | for result, callback in hook: 110 | if result: 111 | reason = result 112 | 113 | IRCD.new_message(client) 114 | channel.do_part(client, reason) 115 | 116 | hook = Hook.LOCAL_PART if client.local else Hook.REMOTE_PART 117 | IRCD.run_hook(hook, client, channel, reason) 118 | 119 | client.mtags = [] 120 | 121 | 122 | def init(module): 123 | IRCD.CHANLEN = 32 124 | Command.add(module, cmd_join, "JOIN", 1, Flag.CMD_USER) 125 | Command.add(module, cmd_part, "PART", 1, Flag.CMD_USER) 126 | Isupport.add("CHANTYPES", IRCD.CHANPREFIXES) 127 | Isupport.add("CHANNELLEN", IRCD.CHANLEN) 128 | Capability.add("extended-join") 129 | -------------------------------------------------------------------------------- /modules/m_kick.py: -------------------------------------------------------------------------------- 1 | """ 2 | /kick command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Isupport, Flag, Numeric, Hook 6 | 7 | KICKLEN = 300 8 | 9 | 10 | def client_can_kick_target(client, target_client, channel, reason, oper_override): 11 | for result, callback in Hook.call(Hook.CAN_KICK, args=(client, target_client, channel, reason, oper_override)): 12 | if result == Hook.DENY: 13 | return 0 14 | return 1 15 | 16 | 17 | def do_kick(client, channel, target_client, reason): 18 | fullmask = IRCD.me.name if client == IRCD.me else client.fullmask 19 | 20 | data = f":{fullmask} KICK {channel.name} {target_client.name} :{reason}" 21 | channel.broadcast(client, data) 22 | channel.remove_client(target_client) 23 | 24 | hook = Hook.LOCAL_KICK if target_client.local else Hook.REMOTE_KICK 25 | IRCD.run_hook(hook, client, target_client, channel, reason) 26 | 27 | data = f":{client.id} KICK {channel.name} {target_client.id} :{reason}" 28 | IRCD.send_to_servers(client, mtags=client.mtags, data=data) 29 | 30 | if (client.user and client.local and client.registered) or ( 31 | not client.local and client.uplink.server.synced) and not client.is_uline() and not client.is_service(): 32 | event = "LOCAL_KICK" if client.local else "REMOTE_KICK" 33 | msg = (f"*** {client.name} ({client.user.username}@{client.user.realhost}) " 34 | f"has kicked {target_client.name} off channel {channel.name}: {reason}") 35 | IRCD.log(client, "info", "kick", event, msg, sync=0) 36 | 37 | 38 | def cmd_kick(client, recv): 39 | """ 40 | Kicks a user from the channel. Requires +h or higher. 41 | Syntax: KICK 42 | """ 43 | 44 | chan = recv[1] 45 | if not (channel := IRCD.find_channel(chan)): 46 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, chan) 47 | 48 | reason = client.name if len(recv) == 3 else ' '.join(recv[3:]) 49 | reason = reason[:KICKLEN].removeprefix(':') 50 | 51 | # List, so that modules can change the value. 52 | oper_override = [0] 53 | 54 | if not client.server: 55 | if not channel.client_has_membermodes(client, "hoaq") and not client.has_permission("channel:override:kick:no-ops"): 56 | return client.sendnumeric(Numeric.ERR_CHANOPRIVSNEEDED, channel.name) 57 | 58 | elif not channel.client_has_membermodes(client, "hoaq"): 59 | oper_override[0] = 1 60 | 61 | for target in recv[2].split(','): 62 | if not (target_client := IRCD.find_client(target, user=1)): 63 | client.sendnumeric(Numeric.ERR_NOSUCHNICK, target) 64 | continue 65 | 66 | if not channel.find_member(target_client): 67 | client.sendnumeric(Numeric.ERR_USERNOTINCHANNEL, target_client.name, channel.name) 68 | continue 69 | 70 | if client == IRCD.me or not client.local: 71 | do_kick(client, channel, target_client, reason) 72 | continue 73 | 74 | if ((channel.level(target_client) > channel.level(client) or 'q' in target_client.user.modes) 75 | and not client.has_permission("channel:override:kick:protected")): 76 | client.sendnumeric(Numeric.ERR_ATTACKDENY, channel.name, target_client.name) 77 | continue 78 | 79 | elif channel.level(target_client) > channel.level(client) or 'q' in target_client.user.modes: 80 | oper_override[0] = 1 81 | 82 | if not client_can_kick_target(client, target_client, channel, reason, oper_override): 83 | continue 84 | 85 | do_kick(client, channel, target_client, reason) 86 | 87 | if oper_override[0] and client.user and client.local: 88 | IRCD.send_oper_override(client, f"with KICK {channel.name} {target_client.name} ({reason})") 89 | 90 | 91 | def init(module): 92 | Command.add(module, cmd_kick, "KICK", 2, Flag.CMD_USER) 93 | Isupport.add("KICKLEN", KICKLEN) 94 | -------------------------------------------------------------------------------- /modules/m_kill.py: -------------------------------------------------------------------------------- 1 | """ 2 | /kill command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_kill(client, recv): 9 | """ 10 | Forcefully disconnect a user from the server. 11 | Syntax: /KILL 12 | """ 13 | 14 | if not (target := IRCD.find_client(recv[1], user=1)): 15 | client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 16 | return 17 | 18 | if client.local and (not target.local and not client.has_permission("kill:global") 19 | or target.local and not client.has_permission("kill:local")): 20 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 21 | 22 | if client.is_local_user() and IRCD.is_except_client("kill", target): 23 | return client.sendnumeric(Numeric.ERR_KILLDENY, target.name) 24 | 25 | if not client.has_permission("kill:oper"): 26 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 27 | 28 | reason = ' '.join(recv[2:]).removeprefix(':') 29 | target.kill(reason, killed_by=client) 30 | 31 | 32 | def init(module): 33 | Command.add(module, cmd_kill, "KILL", 2, Flag.CMD_OPER) 34 | -------------------------------------------------------------------------------- /modules/m_list.py: -------------------------------------------------------------------------------- 1 | """ 2 | /list command 3 | """ 4 | 5 | from time import time 6 | 7 | from handle.core import IRCD, Command, Isupport, Numeric 8 | from handle.functions import is_match 9 | 10 | LIST_PROCESS = [] 11 | 12 | 13 | def cmd_list(client, recv): 14 | """ 15 | List channels on the network. 16 | Local channels (channels starting with '&') will not be shown unless you are on the same server. 17 | Some other channels may also not show, depending on the channel modes that are set. 18 | - 19 | Syntax: LIST [conditions] 20 | Optional conditions can be used. 21 | A few examples: 22 | LIST >100 Will only show channels with more than 100 users. 23 | - Use < to negate this condition. 24 | LIST C<`timestamp` Show channels that have been created before `timestamp`. 25 | - Note that `timestamp` must be a UNIX timestamp. 26 | LIST T<`timestamp` Show channels that had their topic set before `timestamp`. 27 | """ 28 | 29 | if client in LIST_PROCESS: 30 | return IRCD.server_notice(client, "*** A /LIST command is already in progress, please wait.") 31 | 32 | LIST_PROCESS.append(client) 33 | client.add_flood_penalty(10_000) 34 | client.sendnumeric(Numeric.RPL_LISTSTART) 35 | 36 | options = recv[1].split(',') if len(recv) >= 2 else [] 37 | minusers = next((opt[1:] for opt in options if opt.startswith(">") and opt[1:].isdigit()), None) 38 | maxusers = next((opt[1:] for opt in options if opt.startswith("<") and opt[1:].isdigit()), None) 39 | created_after = next((opt[2:] for opt in options if opt.startswith("C>")), None) 40 | created_before = next((opt[2:] for opt in options if opt.startswith("C<")), None) 41 | topic_after = next((opt[2:] for opt in options if opt.startswith("T>")), None) 42 | topic_before = next((opt[2:] for opt in options if opt.startswith("T<")), None) 43 | searchmask = next((opt for opt in options if opt[0] in "*!"), None) 44 | 45 | for channel in IRCD.get_channels(): 46 | channel_open_minutes = int(time()) - channel.creationtime 47 | topic_minutes = int(time()) - channel.topic_time if channel.topic_time else None 48 | 49 | if ((maxusers and channel.membercount >= int(maxusers)) or 50 | (minusers and channel.membercount <= int(minusers)) or 51 | (created_before and channel_open_minutes > int(created_before)) or 52 | (created_after and channel_open_minutes < int(created_after)) or 53 | (topic_before and topic_minutes and topic_minutes > int(topic_before)) or 54 | (topic_after and topic_minutes and topic_minutes < int(topic_after))): 55 | continue 56 | 57 | if searchmask: 58 | searchmask = searchmask.lower().lstrip('!') 59 | if (searchmask[0] == '!' and is_match(searchmask, channel.name.lower())) or ( 60 | searchmask[0] != '!' and not is_match(searchmask, channel.name.lower())): 61 | continue 62 | 63 | if ('s' in channel.modes or 'p' in channel.modes) and (not channel.find_member(client) and 'o' not in client.user.modes): 64 | if 'p' in channel.modes: 65 | client.sendnumeric(Numeric.RPL_LIST, '*', len(channel.users)) 66 | continue 67 | 68 | client.add_flood_penalty(100) 69 | list_modes = f"[+{channel.modes}]" if channel.modes else '' 70 | client.sendnumeric(Numeric.RPL_LIST, channel.name, channel.membercount, list_modes, channel.topic) 71 | 72 | client.sendnumeric(Numeric.RPL_LISTEND) 73 | LIST_PROCESS.remove(client) 74 | 75 | 76 | def init(module): 77 | Command.add(module, cmd_list, "LIST") 78 | Isupport.add("SAFELIST") 79 | Isupport.add("ELIST", "CMNTU") 80 | -------------------------------------------------------------------------------- /modules/m_listdelay.py: -------------------------------------------------------------------------------- 1 | """ 2 | blocks /list commands for newly connected users 3 | """ 4 | 5 | from handle.core import IRCD, Hook, Numeric, Isupport 6 | 7 | 8 | def delaylist(client, recv): 9 | if recv[0].lower() == "list" and client.seconds_since_signon() < 10 and 'o' not in client.user.modes: 10 | IRCD.server_notice(client, "*** Please wait a while before requesting channel list.") 11 | client.sendnumeric(Numeric.RPL_LISTEND) 12 | return Hook.DENY 13 | return Hook.CONTINUE 14 | 15 | 16 | def init(module): 17 | Hook.add(Hook.PRE_COMMAND, delaylist) 18 | Isupport.add("SECURELIST") 19 | -------------------------------------------------------------------------------- /modules/m_lusers.py: -------------------------------------------------------------------------------- 1 | """ 2 | /lusers command 3 | """ 4 | 5 | from handle.core import IRCD, Numeric, Command 6 | 7 | 8 | def cmd_lusers(client, recv): 9 | def s(n): 10 | return 's' * (n != 1) 11 | 12 | servers = sum(c.server.synced for c in IRCD.get_clients(server=1)) + 1 13 | users = sum('i' not in c.user.modes for c in IRCD.get_clients(user=1)) 14 | invisible = sum(1 for _ in IRCD.get_clients(user=1, usermodes='i')) 15 | opers = sum(not c.is_uline() and 'H' not in c.user.modes for c in IRCD.get_clients(user=1, usermodes='o')) 16 | unknown = sum(1 for _ in IRCD.get_clients(registered=0)) 17 | my_servers = sum(1 for _ in IRCD.get_clients(local=1, server=1)) 18 | luserclient_args = ("are" if users - 1 else "is", users, 's' * (users != 1), invisible, servers, 's' * (servers != 1)) 19 | 20 | client.sendnumeric(Numeric.RPL_LUSERCLIENT, *luserclient_args) 21 | client.sendnumeric(Numeric.RPL_LUSEROP, opers, s(opers)) 22 | if unknown: 23 | client.sendnumeric(Numeric.RPL_LUSERUNKNOWN, unknown, s(unknown)) 24 | client.sendnumeric(Numeric.RPL_LUSERCHANNELS, IRCD.channel_count, s(IRCD.channel_count)) 25 | client.sendnumeric(Numeric.RPL_LUSERME, IRCD.local_client_count, s(IRCD.local_client_count), my_servers, s(my_servers)) 26 | client.sendnumeric(Numeric.RPL_LOCALUSERS, IRCD.local_user_count, s(IRCD.local_user_count), IRCD.maxusers) 27 | client.sendnumeric(Numeric.RPL_GLOBALUSERS, IRCD.global_user_count, s(IRCD.global_user_count), IRCD.maxgusers) 28 | 29 | 30 | def init(module): 31 | Command.add(module, cmd_lusers, "LUSERS") 32 | -------------------------------------------------------------------------------- /modules/m_map.py: -------------------------------------------------------------------------------- 1 | """ 2 | /map and /links command 3 | """ 4 | 5 | import datetime 6 | import time 7 | 8 | from handle.core import IRCD, Command, Numeric, Isupport, Flag 9 | 10 | 11 | def cmd_map(client, recv): 12 | """ 13 | Displays a detailed overview of all linked servers. 14 | """ 15 | 16 | if not client.has_permission("server:info:map"): 17 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 18 | 19 | current_time = int(time.time()) 20 | for s in [IRCD.me] + [s for s in IRCD.get_clients(server=1) if s.id]: 21 | percentage = round(100 * (usercount := sum(c.uplink == s for c in IRCD.get_clients(user=1))) / IRCD.global_user_count, 2) 22 | uptime = datetime.timedelta(seconds=current_time - s.creationtime) 23 | client.sendnumeric(Numeric.RPL_MAP, f"{s.name} ({s.id})", usercount, percentage, uptime, round(s.lag, 2)) 24 | client.sendnumeric(Numeric.RPL_MAPEND) 25 | 26 | 27 | def cmd_links(client, recv): 28 | """ 29 | Displays an overview of all linked servers. 30 | """ 31 | 32 | if not client.has_permission("server:info:links"): 33 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 34 | 35 | for s in IRCD.get_clients(server=1): 36 | client.sendnumeric(Numeric.RPL_LINKS, s.name, s.direction.name, s.hopcount, s.info) 37 | client.sendnumeric(Numeric.RPL_ENDOFLINKS) 38 | 39 | 40 | def init(module): 41 | Command.add(module, cmd_map, "MAP", 0, Flag.CMD_OPER) 42 | Command.add(module, cmd_links, "LINKS", 0, Flag.CMD_OPER) 43 | Isupport.add("MAP") 44 | -------------------------------------------------------------------------------- /modules/m_md.py: -------------------------------------------------------------------------------- 1 | """ 2 | /md command (server) 3 | Exchange mod data between servers. 4 | """ 5 | 6 | from handle.core import IRCD, Command, Flag 7 | from modules.ircv3.messagetags import MessageTag 8 | 9 | 10 | class S2sMd(MessageTag): 11 | name = "s2s-md" 12 | 13 | def __init__(self, value): 14 | super().__init__(name=S2sMd.name, value=value) 15 | 16 | def is_visible_to(self, to_client): 17 | return super().is_visible_to(to_client) and to_client.server 18 | 19 | 20 | def cmd_md(client, recv): 21 | if recv[1] == "client": 22 | if not (md_client := IRCD.find_client(recv[2])) and not (md_client := IRCD.find_client(recv[2])): 23 | # Closed early. Killed. 24 | return 25 | 26 | if value := recv[4].removeprefix(':'): 27 | md_client.add_md(name=recv[3], value=value) 28 | else: 29 | md_client.del_md(recv[3]) 30 | 31 | 32 | def post_load(module): 33 | Command.add(module, cmd_md, "MD", 3, Flag.CMD_SERVER) 34 | MessageTag.add(S2sMd) 35 | -------------------------------------------------------------------------------- /modules/m_modules.py: -------------------------------------------------------------------------------- 1 | """ 2 | show modules with /modules 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | from handle.functions import logging 7 | 8 | 9 | def cmd_modules(client, recv): 10 | logging.debug(f"Listing all {len(IRCD.configuration.modules)} loaded modules.") 11 | for m in IRCD.configuration.modules: 12 | module = m.module 13 | info = module.__doc__ 14 | cmds = '' 15 | if info: 16 | info = ' '.join(module.__doc__.split('\n')) 17 | for c in Command.table: 18 | if c.module == module: 19 | cmds += f"{', ' if cmds else ''}" + '/' + c.trigger 20 | try: 21 | msg = f"* {module.__name__}{' -- {}'.format(info) if info else ''}" 22 | IRCD.server_notice(client, msg) 23 | except AttributeError as ex: 24 | logging.error(f"Error while listing module: {m}") 25 | logging.exception(ex) 26 | 27 | 28 | def init(module): 29 | Command.add(module, cmd_modules, "MODULES", 0, Flag.CMD_OPER) 30 | -------------------------------------------------------------------------------- /modules/m_motd.py: -------------------------------------------------------------------------------- 1 | """ 2 | /motd and /rules commands 3 | """ 4 | 5 | import os 6 | 7 | from handle.core import IRCD, Command, Numeric 8 | from handle.logger import logging 9 | 10 | 11 | def cmd_motd(client, recv): 12 | """ 13 | Displays the message of the day. 14 | This does usually not change every day, 15 | but it can still contain useful information. 16 | """ 17 | 18 | if client.seconds_since_signon() > 1: 19 | client.add_flood_penalty(50_000) 20 | if len(recv) == 1: 21 | file = IRCD.confdir + "ircd.motd" 22 | if not os.path.isfile(file): 23 | return client.sendnumeric(Numeric.ERR_NOMOTD) 24 | client.sendnumeric(Numeric.RPL_MOTDSTART, IRCD.me.name) 25 | try: 26 | with open(file) as f: 27 | for line in f.read().split('\n'): 28 | client.sendnumeric(Numeric.RPL_MOTD, line.rstrip()) 29 | except Exception as ex: 30 | logging.exception(ex) 31 | client.sendnumeric(Numeric.RPL_ENDOFMOTD) 32 | 33 | 34 | def cmd_rules(client, recv): 35 | if client.local: 36 | client.local.flood_penalty += 50_000 37 | if len(recv) == 1: 38 | file = IRCD.confdir + "ircd.rules" 39 | if not os.path.isfile(file): 40 | return client.sendnumeric(Numeric.ERR_NORULES) 41 | client.sendnumeric(Numeric.RPL_RULESSTART, IRCD.me.name) 42 | try: 43 | with open(file) as f: 44 | for line in f.read().split('\n'): 45 | client.sendnumeric(Numeric.RPL_RULES, line.rstrip()) 46 | except Exception as ex: 47 | logging.exception(ex) 48 | client.sendnumeric(Numeric.RPL_ENDOFRULES) 49 | 50 | 51 | def init(module): 52 | Command.add(module, cmd_motd, "MOTD") 53 | Command.add(module, cmd_rules, "RULES") 54 | -------------------------------------------------------------------------------- /modules/m_names.py: -------------------------------------------------------------------------------- 1 | """ 2 | /names command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Capability, Isupport 6 | 7 | 8 | def format_name(client, channel, user): 9 | prefix = channel.get_membermodes_sorted(client=user, prefix=1, reverse=1) 10 | if prefix and not client.has_capability("multi-prefix"): 11 | prefix = prefix[0] 12 | 13 | formatted = prefix + user.name 14 | 15 | if client.has_capability("userhost-in-names"): 16 | formatted += f"!{user.user.username}@{user.user.host}" 17 | 18 | return formatted 19 | 20 | 21 | def cmd_names(client, recv): 22 | if not (channel := IRCD.find_channel(recv[1])): 23 | return client.sendnumeric(Numeric.RPL_ENDOFNAMES, recv[1]) 24 | 25 | if 's' in channel.modes and (not channel.find_member(client) and not client.has_permission("channel:see:names:secret")): 26 | return client.sendnumeric(Numeric.RPL_ENDOFNAMES, recv[1]) 27 | 28 | users = [] 29 | for names_client in channel.member_by_client: 30 | if ('i' in names_client.user.modes and 31 | (not channel.find_member(client) 32 | and not client.has_permission("channel:see:names:invisible"))): 33 | continue 34 | 35 | if not channel.user_can_see_member(client, names_client): 36 | continue 37 | 38 | if names_client not in channel.seen_dict[client]: 39 | channel.seen_dict[client].append(names_client) 40 | 41 | users.append(format_name(client, channel, names_client)) 42 | 43 | if len(users) >= 24: 44 | client.sendnumeric(Numeric.RPL_NAMEREPLY, channel.name, ' '.join(users)) 45 | users = [] 46 | 47 | if users: 48 | client.sendnumeric(Numeric.RPL_NAMEREPLY, channel.name, ' '.join(users)) 49 | 50 | client.sendnumeric(Numeric.RPL_ENDOFNAMES, channel.name) 51 | 52 | 53 | def init(module): 54 | Capability.add("userhost-in-names") 55 | Capability.add("multi-prefix") 56 | Command.add(module, cmd_names, "NAMES", 1) 57 | Isupport.add("NAMESX") 58 | Isupport.add("UHNAMES") 59 | -------------------------------------------------------------------------------- /modules/m_netinfo.py: -------------------------------------------------------------------------------- 1 | """ 2 | /netinfo command (server) 3 | """ 4 | 5 | import hashlib 6 | from time import time 7 | 8 | from handle.core import IRCD, Command, Flag 9 | from handle.logger import logging 10 | 11 | 12 | def cmd_netinfo(client, recv): 13 | if not client.local: 14 | return 15 | 16 | maxglobal = int(recv[1]) 17 | IRCD.maxgusers = max(IRCD.maxgusers, maxglobal) 18 | end_of_sync = int(recv[2]) 19 | version = recv[3] 20 | cloakhash = recv[4] 21 | creation = int(recv[5]) 22 | remotename = recv[8].removeprefix(':') 23 | current_time = int(time()) 24 | remotehost = client.name 25 | 26 | if remotename != IRCD.me.name and client.name == remotename: 27 | data = f"*** Network name mismatch from {client.name} ({remotename} != {IRCD.me.name}" 28 | IRCD.log(IRCD.me, "warn", "link", "NETWORK_NAME_MISMATCH", data, sync=0) 29 | 30 | if version != IRCD.versionnumber.replace('.', '') and not client.is_uline() and client.name == remotename: 31 | data = (f"*** Remote server {remotehost} is using version {version}," 32 | f"and we are using version {IRCD.versionnumber.replace('.', '')}, but this should not cause issues.") 33 | IRCD.log(IRCD.me, "warn", "link", "VERSION_MISMATCH", data, sync=0) 34 | 35 | if creation: 36 | client.creationtime = creation 37 | 38 | if cloakhash.split(':')[1] != hashlib.md5(IRCD.get_setting("cloak-key").encode("utf-8")).hexdigest(): 39 | data = "*** (warning) Network wide cloak keys are not the same! This will affect channel bans and must be fixed!" 40 | IRCD.log(IRCD.me, "warn", "link", "CLOAK_KEY_MISMATCH", data, sync=0) 41 | 42 | if not client.uplink.server.synced: 43 | sync_time = current_time - end_of_sync 44 | msg = (f"Link {client.uplink.name} -> {client.name} synced [seconds: {sync_time}, " 45 | f"recv: {client.local.bytes_received}, sent: {client.local.bytes_sent}]") 46 | IRCD.log(client.uplink, "info", "link", "SERVER_SYNCED", msg, sync=0) 47 | 48 | 49 | def init(module): 50 | Command.add(module, cmd_netinfo, "NETINFO", 2, Flag.CMD_SERVER) 51 | -------------------------------------------------------------------------------- /modules/m_pass.py: -------------------------------------------------------------------------------- 1 | """ 2 | /pass command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_pass(client, recv): 9 | if not client.registered: 10 | client.local.authpass = recv[1].removeprefix(':') 11 | else: 12 | return client.sendnumeric(Numeric.ERR_ALREADYREGISTRED) 13 | 14 | if client.server: 15 | if not IRCD.configuration.links: 16 | return client.exit("Target has no links configured") 17 | 18 | 19 | def init(module): 20 | Command.add(module, cmd_pass, "PASS", 1, Flag.CMD_UNKNOWN) 21 | -------------------------------------------------------------------------------- /modules/m_pingpong.py: -------------------------------------------------------------------------------- 1 | """ 2 | ping/pong handler 3 | """ 4 | 5 | from time import time 6 | from handle.core import IRCD, Command, Flag 7 | from handle.logger import logging 8 | 9 | 10 | def cmd_ping(client, recv): 11 | if client.server: 12 | if not (ping_from := IRCD.find_client(recv[1])): 13 | logging.error(f"Ping from unknown server: {recv[1]}") 14 | return 15 | if not (ping_to := IRCD.find_client(recv[2])): 16 | logging.error(f"Server {ping_from.name} tries to ping unknown server: {recv[2]}") 17 | return 18 | data = f":{ping_to.id} PONG {ping_to.name} {ping_from.name}" 19 | client.send([], data) 20 | return 21 | response = recv[1].removeprefix(':') 22 | client.send([], f":{IRCD.me.name} PONG {IRCD.me.name} :{response}") 23 | 24 | 25 | def cmd_nospoof(client, reply): 26 | if reply == client.local.nospoof: 27 | client.local.nospoof = 0 28 | elif client.local.nospoof: 29 | IRCD.server_notice(client, f"ERROR: Invalid PING response. Your client must respond back with PONG {client.local.nospoof}") 30 | 31 | 32 | def cmd_pong(client, recv): 33 | """ 34 | Reply to a PING command. 35 | """ 36 | 37 | client.lag = (time() * 1000) - client.last_ping_sent 38 | 39 | if client.user: 40 | if not client.registered: 41 | reply = recv[1] 42 | if reply.startswith(':'): 43 | reply = reply[1:] 44 | cmd_nospoof(client, reply) 45 | 46 | if client.registered: 47 | return 48 | 49 | if client.handshake_finished(): 50 | client.register_user() 51 | 52 | 53 | def init(module): 54 | Command.add(module, cmd_pong, "PONG", 1, Flag.CMD_USER, Flag.CMD_UNKNOWN, Flag.CMD_SERVER) 55 | Command.add(module, cmd_ping, "PING", 1, Flag.CMD_USER, Flag.CMD_UNKNOWN, Flag.CMD_SERVER) 56 | -------------------------------------------------------------------------------- /modules/m_quit.py: -------------------------------------------------------------------------------- 1 | """ 2 | /quit command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Hook, Flag 6 | 7 | 8 | def cmd_quit(client, recv): 9 | if len(recv) > 1: 10 | reason = ' '.join(recv[1:][:128]).removeprefix(':') 11 | else: 12 | reason = client.name 13 | 14 | if static_quit := IRCD.get_setting("static-quit"): 15 | reason = static_quit[:128] 16 | 17 | if hook := Hook.call(Hook.PRE_LOCAL_QUIT, args=(client, reason)): 18 | for result, _ in hook: 19 | if result: 20 | reason = result 21 | 22 | if not (reason := reason.strip()): 23 | reason = client.name 24 | 25 | quitprefix = IRCD.get_setting("quitprefix") or "Quit" 26 | prefix = f"{quitprefix}: " if client.local else '' 27 | client.exit(f"{prefix}{reason}") 28 | 29 | 30 | def init(module): 31 | Command.add(module, cmd_quit, "QUIT", 0, Flag.CMD_USER) 32 | -------------------------------------------------------------------------------- /modules/m_rehash.py: -------------------------------------------------------------------------------- 1 | """ 2 | /rehash command 3 | """ 4 | 5 | import gc 6 | 7 | from handle.core import IRCD, Command, Numeric, Flag 8 | from classes.configuration import ConfigBuild 9 | from handle.logger import logging 10 | 11 | gc.enable() 12 | 13 | 14 | def cmd_rehash(client, recv): 15 | """ 16 | Reloads the configuration files. 17 | """ 18 | 19 | if client.user and not client.has_permission("server:rehash"): 20 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 21 | 22 | if IRCD.rehashing: 23 | return 24 | 25 | IRCD.rehashing = 1 26 | IRCD.current_link_sync = None 27 | cmd_rehash_errors = [] 28 | 29 | if client.is_local_user(): 30 | client.local.flood_penalty += 500_000 31 | if client.user: 32 | msg = f"*** {client.name} ({client.user.username}@{client.user.realhost}) is rehashing the server configuration file..." 33 | else: 34 | msg = f"*** Rehashing configuration file from the command line..." 35 | IRCD.log(client, "info", "config", "CONFIG_REHASH", msg) 36 | 37 | reloadmods = 0 38 | if len(recv) > 1: 39 | for flag in recv[1:]: 40 | if flag.lower() == "--reload-mods": 41 | client.local.flood_penalty += 1_000_000 42 | reloadmods = 1 43 | msg = f"*** Also reloading all modules." 44 | IRCD.log(client, "info", "config", "CONFIG_REHASH", msg) 45 | 46 | client.sendnumeric(Numeric.RPL_REHASHING, IRCD.conf_path) 47 | if ConfigBuild(conffile=IRCD.conf_file).is_ok(rehash=1, 48 | rehash_client=client, 49 | reloadmods=reloadmods, 50 | cmd_rehash_errors=cmd_rehash_errors): 51 | msg = "*** Configuration reloaded without any problems." 52 | else: 53 | msg = "*** Configuration failed to reload." 54 | 55 | IRCD.log(client, "info", "config", "CONFIG_REHASH", msg) 56 | 57 | gc.collect() 58 | IRCD.rehashing = 0 59 | 60 | if not client.user: 61 | if cmd_rehash_errors: 62 | IRCD.command_socket.sendall('\n'.join(cmd_rehash_errors).encode()) 63 | else: 64 | IRCD.command_socket.sendall('1'.encode()) 65 | 66 | 67 | def init(module): 68 | Command.add(module, cmd_rehash, "REHASH", 0, Flag.CMD_OPER) 69 | -------------------------------------------------------------------------------- /modules/m_restart.py: -------------------------------------------------------------------------------- 1 | """ 2 | /restart command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_restart(client, recv): 9 | """ 10 | RESTART 11 | - 12 | Restarts the server. 13 | """ 14 | 15 | if not client.has_permission("server:restart"): 16 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 17 | 18 | if client.user: 19 | if recv[1] != IRCD.get_setting("restartpass"): 20 | client.local.flood_penalty += 2500001 21 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 22 | 23 | reason = (f"RESTART command received by {client.name} ({client.user.username}@{client.user.realhost})" 24 | if client.user else "RESTART command received from the command line.") 25 | 26 | IRCD.send_snomask(client, 's', f"*** {reason}") 27 | 28 | for server in IRCD.get_clients(local=1, server=1): 29 | server.send([], f"SQUIT {IRCD.me.name} :{reason}") 30 | 31 | IRCD.run_parallel_function(IRCD.restart, delay=0.1) 32 | 33 | 34 | def init(module): 35 | Command.add(module, cmd_restart, "RESTART", 1, Flag.CMD_OPER) 36 | -------------------------------------------------------------------------------- /modules/m_sajoinpart.py: -------------------------------------------------------------------------------- 1 | """ 2 | /sajoin and /sapart command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_sajoinpart(client, recv): 9 | if not (Command.find_command(client, "PART")) or not (Command.find_command(client, "JOIN")): 10 | return 11 | 12 | cmd = recv[0].lower() 13 | if not client.has_permission(f"sacmds:{cmd}:local") and not client.has_permission(f"sacmds:{cmd}:global"): 14 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 15 | 16 | if not (target := IRCD.find_client(recv[1], user=1)): 17 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 18 | 19 | if client.local: 20 | permission_check = f"sacmds:{cmd}:global" if not target.local else f"sacmds:{cmd}:local" 21 | 22 | if not client.has_permission(permission_check) and not client.has_permission(f"sacmds:{cmd}:global"): 23 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 24 | 25 | if 'S' in target.user.modes or target.is_uline() or target.is_service(): 26 | return IRCD.server_notice(client, f"*** You cannot use /{cmd.upper()} on services.") 27 | 28 | client.local.flood_penalty += 50_000 29 | 30 | chan = IRCD.strip_format(recv[2]) 31 | if not (channel := IRCD.find_channel(chan)): 32 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, chan) 33 | 34 | if channel.name[0] == '&': 35 | return IRCD.server_notice(client, f"*** You cannot use /{cmd.upper()} on local channels.") 36 | 37 | if (cmd == "sapart" and not channel.find_member(target)) or (cmd == "sajoin" and channel.find_member(target)): 38 | error = Numeric.ERR_USERNOTINCHANNEL if cmd == "sapart" else Numeric.ERR_USERONCHANNEL 39 | return client.sendnumeric(error, target.name, channel.name) 40 | 41 | what = "join" if cmd == "sajoin" else "part" 42 | if target.local: 43 | if what == "join": 44 | target.add_flag(Flag.CLIENT_USER_SAJOIN) 45 | Command.do(target, "JOIN", channel.name) 46 | target.flags.remove(Flag.CLIENT_USER_SAJOIN) 47 | else: 48 | Command.do(target, "PART", channel.name) 49 | 50 | IRCD.send_to_servers(client, [], f":{client.id} SA{what.upper()} {target.name} {channel.name}") 51 | 52 | event = f"{'LOCAL' if target.local else 'REMOTE'}_{cmd.upper()}" 53 | msg = (f"*** {client.name} ({client.user.username}@{client.user.realhost}) used {cmd.upper()} " 54 | f"to make {target.name} {what} {channel.name}") 55 | IRCD.log(client, "info", cmd, event, msg, sync=1 if target.local else 0) 56 | 57 | if target.local: 58 | IRCD.server_notice(target, f"*** Your were forced to {what} {channel.name}.") 59 | 60 | 61 | def init(module): 62 | Command.add(module, cmd_sajoinpart, "SAJOIN", 2, Flag.CMD_OPER) 63 | Command.add(module, cmd_sajoinpart, "SAPART", 2, Flag.CMD_OPER) 64 | -------------------------------------------------------------------------------- /modules/m_sanick.py: -------------------------------------------------------------------------------- 1 | """ 2 | /sanick command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | 7 | 8 | def cmd_sanick(client, recv): 9 | if not (nick_cmd := Command.find_command(client, "NICK")): 10 | return 11 | 12 | if not (target := IRCD.find_client(recv[1], user=1)): 13 | return client.sendnumeric(Numeric.ERR_NOSUCHNICK, recv[1]) 14 | 15 | if client.local: 16 | if not target.local and not client.has_permission("sacmds:sanick:global"): 17 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 18 | if not client.has_permission("sacmds:sanick:local"): 19 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 20 | 21 | client.local.flood_penalty += 100000 22 | 23 | if 'S' in target.user.modes or target.is_uline() or target.is_service(): 24 | return IRCD.server_notice(client, f"*** You cannot use /SANICK on services.") 25 | 26 | if target.name == recv[2]: 27 | return 28 | 29 | if recv[2][0].isdigit(): 30 | return IRCD.server_notice(client, "*** Nicknames may not start with a number") 31 | 32 | if nick_client := IRCD.find_client(recv[2]): 33 | return IRCD.server_notice(client, f"*** Nickname {nick_client.name} is already in use") 34 | 35 | newnick = recv[2][:IRCD.NICKLEN] 36 | for c in newnick: 37 | if c.lower() not in IRCD.NICKCHARS: 38 | return client.sendnumeric(Numeric.ERR_ERRONEUSNICKNAME, newnick, c) 39 | 40 | if not newnick: 41 | return 42 | 43 | event = "LOCAL_SANICK" if target.local else "REMOTE_SANICK" 44 | msg = f"*** {client.name} ({client.user.username}@{client.user.realhost}) used SANICK to change {target.name}'s nickname to {newnick}" 45 | IRCD.log(client, "info", "sanick", event, msg, sync=0) 46 | 47 | data = f":{client.id} SANICK {target.name} {newnick}" 48 | IRCD.send_to_servers(client, [], data) 49 | 50 | if target.local: 51 | target.add_flag(Flag.CLIENT_USER_SANICK) 52 | nick_cmd.do(target, "NICK", newnick) 53 | target.flags.remove(Flag.CLIENT_USER_SANICK) 54 | msg = f"*** Your nickname has been forcefully changed to {target.name}." 55 | IRCD.server_notice(target, msg) 56 | 57 | 58 | def init(module): 59 | Command.add(module, cmd_sanick, "SANICK", 2, Flag.CMD_OPER) 60 | -------------------------------------------------------------------------------- /modules/m_sendumode.py: -------------------------------------------------------------------------------- 1 | """ 2 | /sendumode command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_sendumode(client, recv): 9 | # :00B SENDUMODE o :message 10 | for user_client in IRCD.get_clients(local=1, user=1, usermodes=recv[1]): 11 | Command.do(client, "NOTICE", user_client.name, *recv[2:]) 12 | 13 | IRCD.send_to_servers(client, [], f":{client.id} {' '.join(recv)}") 14 | 15 | 16 | def cmd_sendsno(client, recv): 17 | flag = recv[1] 18 | message = ' '.join(recv[2:]).removeprefix(':') 19 | IRCD.send_snomask(client, flag, message) 20 | 21 | 22 | def init(module): 23 | Command.add(module, cmd_sendsno, "SENDSNO", 2, Flag.CMD_SERVER) 24 | Command.add(module, cmd_sendumode, "SENDUMODE", 2, Flag.CMD_SERVER) 25 | -------------------------------------------------------------------------------- /modules/m_sethost.py: -------------------------------------------------------------------------------- 1 | """ 2 | /sethost and /setident command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Capability, Flag 6 | 7 | 8 | def cmd_sethost(client, recv): 9 | """ 10 | Changes your own hostmask. 11 | Syntax: SETHOST 12 | """ 13 | 14 | host = IRCD.clean_string(string=recv[1], charset=IRCD.HOSTCHARS, maxlen=64) 15 | host = host.removeprefix(':') 16 | 17 | if host and host != client.user.host: 18 | client.add_user_modes("xt") 19 | client.set_host(host=host) 20 | if client.local: 21 | IRCD.server_notice(client, f"*** Your host is now '{client.user.host}'") 22 | 23 | IRCD.send_to_servers(client, [], data=f":{client.id} SETHOST {client.user.host}") 24 | 25 | 26 | def cmd_setident(client, recv): 27 | """ 28 | Changes your own username (ident). 29 | Syntax: SETIDENT 30 | """ 31 | 32 | ident = IRCD.clean_string(string=recv[1], charset=IRCD.HOSTCHARS, maxlen=12) 33 | ident = ident.removeprefix(':') 34 | 35 | if ident == client.user.username or not ident: 36 | return 37 | 38 | client.user.username = ident 39 | 40 | if client.local: 41 | IRCD.server_notice(client, f"*** Your ident is now '{client.user.username}'") 42 | 43 | IRCD.send_to_servers(client, [], data=f":{client.id} SETIDENT {client.user.username}") 44 | 45 | 46 | def init(module): 47 | Command.add(module, cmd_sethost, "SETHOST", 1, Flag.CMD_OPER) 48 | Command.add(module, cmd_setident, "SETIDENT", 1, Flag.CMD_OPER) 49 | Capability.add("chghost") 50 | -------------------------------------------------------------------------------- /modules/m_setname.py: -------------------------------------------------------------------------------- 1 | """ 2 | /setname command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Isupport, Capability 6 | 7 | NAMELEN = 50 8 | 9 | 10 | def cmd_setname(client, recv): 11 | """ 12 | Changes your own 'real name' (GECOS) 13 | Syntax: SETNAME 14 | """ 15 | 16 | realname = ' '.join(recv[1:])[:NAMELEN].removeprefix(':').strip() 17 | if realname.strip() and realname != client.info: 18 | client.setinfo(realname, change_type="gecos") 19 | 20 | IRCD.send_to_servers(client, [], data=f":{client.id} {' '.join(recv)}") 21 | 22 | 23 | def init(module): 24 | Command.add(module, cmd_setname, "SETNAME", 1) 25 | Isupport.add("NAMELEN", NAMELEN) 26 | Capability.add("setname") 27 | -------------------------------------------------------------------------------- /modules/m_squit.py: -------------------------------------------------------------------------------- 1 | """ 2 | /squit command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Flag 6 | from handle.logger import logging 7 | 8 | 9 | @logging.client_context 10 | def cmd_squit(client, recv): 11 | logging.debug(recv) 12 | target_name = recv[1] 13 | reason = client.name if len(recv) < 3 else ' '.join(recv[2:]).removeprefix(':') 14 | 15 | if client.user and client.local and not client.has_permission("server:squit"): 16 | return client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 17 | 18 | if target_name.lower() == IRCD.me.name.lower(): 19 | return IRCD.server_notice(client, "Cannot use /SQUIT on ourself.") 20 | 21 | if not (server_matches := IRCD.find_server_match(target_name)): 22 | if client.user: 23 | client.sendnumeric(Numeric.ERR_NOSUCHSERVER, target_name) 24 | return 25 | 26 | IRCD.send_to_servers(client, [], f":{client.id} SQUIT {target_name} :{reason}") 27 | 28 | for squit_client in server_matches: 29 | if squit_client == IRCD.me: 30 | continue 31 | 32 | if squit_client.server.authed: 33 | msg = f"{client.fullrealhost} used SQUIT for {squit_client.name}: {reason}" 34 | IRCD.log(client, "info", "squit", "LINK_SQUIT", msg, sync=0) 35 | 36 | squit_client.exit(reason) 37 | 38 | 39 | def init(module): 40 | Command.add(module, cmd_squit, "SQUIT", 1, Flag.CMD_OPER, Flag.CMD_SERVER) 41 | -------------------------------------------------------------------------------- /modules/m_svsjoinpart.py: -------------------------------------------------------------------------------- 1 | """ 2 | /svsjoin and /svspart command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_svspartjoin(client, recv): 9 | if not (part_cmd := Command.find_command(client, "PART")) or not (join_cmd := Command.find_command(client, "JOIN")): 10 | return 11 | if not (target := IRCD.find_client(recv[1], user=1)): 12 | return 13 | 14 | match recv[0].lower(): 15 | case "svsjoin": 16 | if target.local: 17 | join_cmd.do(target, "JOIN", recv[2]) 18 | data = f":{client.id} SVSJOIN {target.name} {recv[2]}" 19 | IRCD.send_to_servers(client, [], data) 20 | 21 | case "svspart": 22 | if target.local: 23 | part_cmd.do(target, "PART", recv[2]) 24 | data = f":{client.id} SVSPART {target.name} {recv[2]}" 25 | IRCD.send_to_servers(client, [], data) 26 | 27 | 28 | def init(module): 29 | Command.add(module, cmd_svspartjoin, "SVSJOIN", 2, Flag.CMD_SERVER) 30 | Command.add(module, cmd_svspartjoin, "SVSPART", 2, Flag.CMD_SERVER) 31 | -------------------------------------------------------------------------------- /modules/m_svskill.py: -------------------------------------------------------------------------------- 1 | """ 2 | /svskill command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_svskill(client, recv): 9 | if not (target := IRCD.find_client(recv[1], user=1)): 10 | return 11 | 12 | reason = ' '.join(recv[2:]).removeprefix(':') 13 | data = f":{client.id} SVSKILL {target.id} :{reason}" 14 | target.kill(reason, killed_by=client) 15 | IRCD.send_to_servers(client, mtags=[], data=data) 16 | 17 | 18 | def init(module): 19 | Command.add(module, cmd_svskill, "SVSKILL", 2, Flag.CMD_SERVER) 20 | -------------------------------------------------------------------------------- /modules/m_svsnick.py: -------------------------------------------------------------------------------- 1 | """ 2 | /svsnick command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_svsnick(client, recv): 9 | if not (nick_cmd := Command.find_command(client, "NICK")): 10 | return 11 | 12 | if not (target := IRCD.find_client(recv[1], user=1)): 13 | return 14 | 15 | if target.name == recv[2] or recv[2][0].isdigit() or IRCD.find_client(recv[2]): 16 | return 17 | 18 | newnick = recv[2][:IRCD.NICKLEN] 19 | for c in newnick: 20 | if c.lower() not in IRCD.NICKCHARS: 21 | return 22 | 23 | if not newnick: 24 | return 25 | 26 | data = f":{client.id} SVSNICK {target.name} {newnick}" 27 | IRCD.send_to_servers(client, [], data) 28 | 29 | if target.local: 30 | target.add_flag(Flag.CLIENT_USER_SANICK) 31 | nick_cmd.do(target, "NICK", newnick) 32 | target.del_flag(Flag.CLIENT_USER_SANICK) 33 | 34 | 35 | def init(module): 36 | Command.add(module, cmd_svsnick, "SVSNICK", 2, Flag.CMD_SERVER) 37 | -------------------------------------------------------------------------------- /modules/m_swhois.py: -------------------------------------------------------------------------------- 1 | """ 2 | /swhois command (server) 3 | """ 4 | 5 | from handle.core import IRCD, Command, Flag 6 | 7 | 8 | def cmd_swhois(client, recv): 9 | # :001 SWHOIS + :[swhois] 10 | if not (target_client := IRCD.find_client(recv[1], user=1)): 11 | return 12 | 13 | if len(recv) < 5: 14 | # :001 SWHOIS - 15 | # Clear SWHOIS. 16 | target_client.user.swhois = [] 17 | IRCD.send_to_servers(client, [], recv) 18 | return 19 | 20 | line = ' '.join(recv[4:]).removeprefix(':') 21 | 22 | if recv[2] == '-': 23 | target_client.del_swhois(line) 24 | 25 | elif recv[2] == '+': 26 | target_client.add_swhois(line, tag=recv[3]) 27 | 28 | data = f":{client.id} {' '.join(recv)}" 29 | IRCD.send_to_servers(client, [], data) 30 | 31 | 32 | def init(module): 33 | Command.add(module, cmd_swhois, "SWHOIS", 2, Flag.CMD_SERVER) 34 | -------------------------------------------------------------------------------- /modules/m_time.py: -------------------------------------------------------------------------------- 1 | """ 2 | /time command 3 | """ 4 | 5 | from time import strftime 6 | from handle.core import Command, Numeric 7 | 8 | 9 | def cmd_time(client, recv): 10 | info = strftime("%A %B %d %Y -- %H:%M:%S %z") 11 | formatted_time = info[:-2] + ":" + info[-2:] 12 | client.sendnumeric(Numeric.RPL_TIME, formatted_time) 13 | 14 | 15 | def init(module): 16 | Command.add(module, cmd_time, "TIME") 17 | -------------------------------------------------------------------------------- /modules/m_topic.py: -------------------------------------------------------------------------------- 1 | """ 2 | /topic command 3 | """ 4 | 5 | from time import time 6 | 7 | from handle.core import IRCD, Command, Isupport, Numeric, Hook 8 | from handle.logger import logging 9 | 10 | TOPICLEN = 350 11 | 12 | 13 | def send_topic(client, channel): 14 | data = f":{client.fullmask} TOPIC {channel.name} :{channel.topic}" 15 | channel.broadcast(client, data) 16 | 17 | if channel.name[0] != '&': 18 | data = f":{client.id} TOPIC {channel.name} {channel.topic_author} {channel.topic_time} :{channel.topic}" 19 | IRCD.send_to_servers(client, mtags=client.mtags, data=data) 20 | 21 | 22 | def local_topic_win(client, local_topic, remote_topic): 23 | our_score = sum(ord(char) for char in local_topic) 24 | their_score = sum(ord(char) for char in remote_topic) 25 | 26 | if our_score == their_score: 27 | return 1 if IRCD.me.name < (client.name if client.server else client.uplink.name) else 0 28 | 29 | return 1 if our_score > their_score else 0 30 | 31 | 32 | @logging.client_context 33 | def cmd_topic(client, recv): 34 | """ 35 | Syntax: TOPIC [text] 36 | Set or request the topic of a channel. 37 | To request the topic of a channel, use TOPIC without any text. 38 | To clear the topic, use TOPIC : 39 | """ 40 | 41 | if not client.local or client.server: 42 | # Only change local topic during sync if the remote topic is older. 43 | # After sync, always allow topic changes from remote servers. 44 | if not (channel := IRCD.find_channel(recv[1])): 45 | return logging.error(f"[topic] Unknown channel for topic: {recv[1]}") 46 | 47 | topic_text = ' '.join(recv[4:]).removeprefix(':') 48 | recv_topic_older = 1 if not channel.topic_time or int(recv[3]) < channel.topic_time else 0 49 | remote_chan_older = 1 if 0 < channel.remote_creationtime < channel.local_creationtime else 0 50 | same_ts = 1 if channel.local_creationtime == channel.remote_creationtime else 0 51 | 52 | local_win = local_topic_win(client, channel.topic, topic_text) 53 | if channel.topic and same_ts and int(recv[3]) == channel.topic_time and local_win: 54 | return 55 | 56 | """ 57 | If a remote channel is older, then that topic will always win. 58 | If the timestamps are equal, the winner will be determined 59 | by the outcome of local_topic_win(). 60 | """ 61 | 62 | update_topic = 0 63 | if not channel.topic: 64 | update_topic = 1 65 | elif channel.topic != topic_text and channel.topic_time != int(recv[3]): 66 | if client.uplink.server.synced: 67 | update_topic = 1 68 | elif remote_chan_older: 69 | update_topic = 1 70 | elif same_ts and recv_topic_older and not local_win: 71 | update_topic = 1 72 | 73 | if update_topic: 74 | channel.topic = topic_text 75 | channel.topic_author, channel.topic_time = recv[2], int(recv[3]) 76 | send_topic(client, channel) 77 | return 78 | 79 | if not (channel := IRCD.find_channel(recv[1])): 80 | return client.sendnumeric(Numeric.ERR_NOSUCHCHANNEL, recv[1]) 81 | 82 | if len(recv) < 3: 83 | if not channel.topic: 84 | return client.sendnumeric(Numeric.RPL_NOTOPIC, channel.name) 85 | 86 | client.sendnumeric(Numeric.RPL_TOPIC, channel.name, channel.topic) 87 | client.sendnumeric(Numeric.RPL_TOPICWHOTIME, channel.name, channel.topic_author, channel.topic_time) 88 | return 89 | 90 | oper_override = 0 91 | text = ' '.join(recv[2:]).removeprefix(':') 92 | if not channel.find_member(client): 93 | if not client.has_permission("channel:override:topic:notinchannel"): 94 | return client.sendnumeric(Numeric.ERR_NOTONCHANNEL, channel.name) 95 | oper_override = 1 96 | 97 | if 't' in channel.modes and client.local and not channel.client_has_membermodes(client, "hoaq"): 98 | if not client.has_permission("channel:override:topic:no-ops"): 99 | return client.sendnumeric(Numeric.ERR_CHANOPRIVSNEEDED, channel.name) 100 | oper_override = 1 101 | 102 | if channel.topic == text: 103 | return 104 | 105 | h = Hook.call(Hook.PRE_LOCAL_TOPIC, args=(client, channel, text)) 106 | for result, callback in h: 107 | if result == Hook.DENY: 108 | return 109 | 110 | channel.topic = text 111 | channel.topic_author = client.fullmask if text else None 112 | channel.topic_time = int(time()) if text else 0 113 | send_topic(client, channel) 114 | 115 | if oper_override and client.user and client.local: 116 | IRCD.send_oper_override(client, f"with TOPIC {channel.name} \'{channel.topic}\'") 117 | 118 | IRCD.run_hook(Hook.TOPIC, client, channel, channel.topic) 119 | 120 | 121 | def init(module): 122 | Command.add(module, cmd_topic, "TOPIC", 1) 123 | Isupport.add("TOPICLEN", TOPICLEN) 124 | -------------------------------------------------------------------------------- /modules/m_user.py: -------------------------------------------------------------------------------- 1 | """ 2 | /user command 3 | """ 4 | 5 | from handle.core import IRCD, Flag, Numeric, Command 6 | 7 | 8 | def cmd_user(client, recv): 9 | if client.handshake_finished(): 10 | return client.sendnumeric(Numeric.ERR_ALREADYREGISTRED) 11 | 12 | if client.server: 13 | client.direct_send("ERROR :This port is for servers only") 14 | client.exit(f"This port is for servers only.") 15 | return 16 | 17 | if not client.user: 18 | return 19 | 20 | if "nmap" in ''.join(recv).lower(): 21 | return client.exit("Connection reset by peer") 22 | 23 | ident = str(recv[1][:12]).strip() 24 | realname = ' '.join(recv[4:]).removeprefix(':')[:48] 25 | 26 | for c in ident: 27 | if c.lower() not in IRCD.HOSTCHARS: 28 | ident = ident.replace(c, '') 29 | 30 | if ident and realname: 31 | client.user.username = ident 32 | client.info = realname 33 | 34 | if client.handshake_finished(): 35 | client.register_user() 36 | 37 | 38 | def init(module): 39 | Command.add(module, cmd_user, "USER", 4, Flag.CMD_UNKNOWN) 40 | -------------------------------------------------------------------------------- /modules/m_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | /version command 3 | """ 4 | 5 | import OpenSSL 6 | 7 | from handle.core import IRCD, Command, Isupport, Numeric 8 | 9 | 10 | def cmd_version(client, recv): 11 | client.sendnumeric(Numeric.RPL_VERSION, IRCD.version, IRCD.me.name, IRCD.hostinfo) 12 | if client.local.tls: 13 | IRCD.server_notice(client, f"{OpenSSL.SSL.SSLeay_version(OpenSSL.SSL.SSLEAY_VERSION).decode()}") 14 | 15 | Isupport.send_to_client(client) 16 | 17 | 18 | def init(module): 19 | Command.add(module, cmd_version, "VERSION") 20 | -------------------------------------------------------------------------------- /modules/m_wallops.py: -------------------------------------------------------------------------------- 1 | """ 2 | /wallops command 3 | """ 4 | 5 | from handle.core import IRCD, Usermode, Command, Flag 6 | 7 | 8 | def cmd_wallops(client, recv): 9 | msg = ' '.join(recv[1:]).removeprefix(':') 10 | for user_client in IRCD.get_clients(local=1, user=1, usermodes='w'): 11 | user_client.send([], f":{client.fullmask} WALLOPS :{msg}") 12 | 13 | IRCD.send_to_servers(client, [], f":{client.id} WALLOPS :{msg}") 14 | 15 | 16 | def init(module): 17 | Usermode.add(module, 'w', 1, 0, Usermode.allow_all, "Can see wallops messages") 18 | Command.add(module, cmd_wallops, "WALLOPS", 1, Flag.CMD_OPER) 19 | -------------------------------------------------------------------------------- /modules/m_webirc.py: -------------------------------------------------------------------------------- 1 | """ 2 | webirc support 3 | """ 4 | 5 | import ipaddress 6 | 7 | from handle.core import IRCD, Command, Flag 8 | from handle.validate_conf import conf_error 9 | 10 | 11 | class WebIRCConf: 12 | password = None 13 | options = [] 14 | ip_whitelist = [] 15 | 16 | 17 | def post_load(module): 18 | if not (webirc_settings := IRCD.configuration.get_items("settings:webirc")): 19 | return conf_error("WebIRC module is loaded but settings:webirc block is missing in configuration file") 20 | 21 | password = None 22 | for entry in webirc_settings: 23 | entry_name, entry_value = entry.path[1], entry.path[2] 24 | if entry_name == "password": 25 | password = entry_value 26 | elif entry_name == "options": 27 | WebIRCConf.options.append(entry_value) 28 | elif entry_name == "ip_whitelist": 29 | for ip in entry.get_path("ip_whitelist"): 30 | if ip in WebIRCConf.ip_whitelist: 31 | continue 32 | try: 33 | ipaddress.ip_address(ip) 34 | WebIRCConf.ip_whitelist.append(ip) 35 | except ValueError: 36 | conf_error(f"Invalid IP address '{ip}' in whitelisted_ip", item=entry) 37 | 38 | if not password: 39 | return conf_error(f"settings:webirc:password missing or invalid") 40 | 41 | WebIRCConf.password = password 42 | 43 | 44 | def cmd_webirc(client, recv): 45 | if client.registered or recv[1] != WebIRCConf.password or client.ip not in WebIRCConf.ip_whitelist: 46 | return 47 | client.user.realhost = recv[3] if IRCD.get_setting("resolvehost") else recv[4] 48 | client.ip = recv[4] 49 | client.user.cloakhost = client.user.vhost = IRCD.get_cloak(client) 50 | client.webirc = 1 51 | 52 | 53 | def init(module): 54 | Command.add(module, cmd_webirc, "WEBIRC", 4, Flag.CMD_UNKNOWN) 55 | -------------------------------------------------------------------------------- /modules/starttls.py: -------------------------------------------------------------------------------- 1 | """ 2 | /starttls command 3 | """ 4 | 5 | from handle.core import IRCD, Command, Numeric, Capability, Flag 6 | from handle.sockets import wrap_socket 7 | 8 | 9 | def cmd_starttls(client, recv): 10 | if not client.local or client.registered: 11 | return 12 | try: 13 | if not client.local.tls: 14 | client.local.handshake = 0 15 | client.sendnumeric(Numeric.RPL_STARTTLS, "STARTTLS successful, proceed with TLS handshake") 16 | IRCD.run_parallel_function(wrap_socket, args=(client,), kwargs={"starttls": 1}) 17 | else: 18 | client.sendnumeric(Numeric.ERR_STARTTLS, "Already using TLS.") 19 | except Exception as ex: 20 | client.sendnumeric(Numeric.ERR_STARTTLS, str(ex) or "unknown error") 21 | client.exit(f"STARTTLS failed. Make sure your client supports STARTTLS: {str(ex) or 'unknown error'}") 22 | 23 | 24 | def init(module): 25 | Command.add(module, cmd_starttls, "STARTTLS", 0, Flag.CMD_UNKNOWN) 26 | Capability.add("tls") 27 | -------------------------------------------------------------------------------- /modules/usermodes/bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides usermode +B, mark the user as a bot, and adds support for "bot" message tag. 3 | """ 4 | 5 | from handle.core import Usermode, Isupport, Numeric, Hook 6 | from modules.ircv3.messagetags import MessageTag 7 | 8 | 9 | class BotTag(MessageTag): 10 | name = "bot" 11 | local = 1 12 | 13 | def __init__(self, value=None): 14 | super().__init__(name=BotTag.name) 15 | 16 | def is_visible_to(self, to_client): 17 | return super().is_visible_to(to_client) 18 | 19 | 20 | def add_bot_tag(client): 21 | if client.user and 'B' in client.user.modes: 22 | client.mtags.append(BotTag()) 23 | return 0 24 | 25 | 26 | def bot_whois(client, whois_client, lines): 27 | if 'B' in whois_client.user.modes: 28 | line = (Numeric.RPL_WHOISBOT, whois_client.name, whois_client.uplink.name) 29 | lines.append(line) 30 | 31 | 32 | def bot_who_flag(client, target_client): 33 | if 'B' in target_client.user.modes: 34 | return 'B' 35 | 36 | 37 | def post_load(module): 38 | Usermode.add(module, 'B', 1, 0, Usermode.allow_all, "Marks the user as a bot") 39 | Hook.add(Hook.WHOIS, bot_whois) 40 | Hook.add(Hook.WHO_STATUS, bot_who_flag) 41 | Hook.add(Hook.NEW_MESSAGE, add_bot_tag) 42 | Isupport.add("BOT", 'B') 43 | MessageTag.add(BotTag) 44 | -------------------------------------------------------------------------------- /modules/usermodes/coremodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | core user modes and snomasks 3 | """ 4 | 5 | from handle.core import Usermode, Snomask, Numeric, Hook 6 | from handle.logger import logging 7 | 8 | 9 | def umode_q_is_ok(client): 10 | if client.has_permission("self:protected") or 'q' in client.user.modes: 11 | return 1 12 | if client.user.oper: 13 | client.sendnumeric(Numeric.ERR_NOPRIVILEGES) 14 | return 0 15 | 16 | 17 | def umode_t_is_ok(client): 18 | if not client.local or 't' in client.user.modes: 19 | return 1 20 | return 0 21 | 22 | 23 | def umode_x_unset(client, target, modebuf, mode): 24 | """ Mode 't' cannot be set without 'x'. """ 25 | if mode == 'x' and 't' in target.user.modes: 26 | modebuf.append('t') 27 | 28 | 29 | def umode_t_changed(client, target, oldmodes, newmodes): 30 | modes_unset = set(oldmodes).difference(newmodes) 31 | 32 | if 't' in modes_unset and 'x' in newmodes: 33 | """ Usermode 't' unset. Setting cloakhost. """ 34 | target.set_host(host=target.user.cloakhost) 35 | 36 | 37 | def umode_x_changed(client, target, oldmodes, newmodes): 38 | modes_set = set(newmodes).difference(oldmodes) 39 | modes_unset = set(oldmodes).difference(newmodes) 40 | 41 | if 'x' in modes_set: 42 | target.set_host(host=target.user.cloakhost) 43 | 44 | elif 'x' in modes_unset: 45 | target.set_host(host=target.user.host) 46 | 47 | 48 | def init(module): 49 | # Params: mode flag, is_global (will be synced to servers), unset_on_deoper bool, can_set method, desc 50 | Usermode.add(module, 'i', 1, 0, Usermode.allow_all, "User does not show up in outside /WHO") 51 | Usermode.add(module, 'o', 1, 1, Usermode.allow_opers, "Marks the user as an IRC Operator") 52 | Usermode.add(module, 'q', 1, 1, umode_q_is_ok, "Protected on all channels") 53 | Usermode.add(module, 'r', 1, 0, Usermode.allow_all, "Identifies the nick as being logged in") 54 | Usermode.add(module, 's', 1, 1, Usermode.allow_opers, "Can receive server notices") 55 | Usermode.add(module, 't', 1, 0, umode_t_is_ok, "User is using a vHost") 56 | Usermode.add(module, 'x', 1, 0, Usermode.allow_all, "Hides real host with cloaked host") 57 | Usermode.add(module, 'z', 1, 0, Usermode.allow_all, "User is using a secure connection") 58 | Usermode.add(module, 'H', 1, 1, Usermode.allow_opers, "Hide IRCop status") 59 | Usermode.add(module, 'S', 1, 0, Usermode.allow_none, "Marks the client as a network service") 60 | 61 | Snomask.add(module, 'c', 0, "Can read local connect/disconnect notices") 62 | Snomask.add(module, 'f', 1, "See excess flood alerts") 63 | Snomask.add(module, 'j', 0, "See join, part, and kick messages") 64 | Snomask.add(module, 'k', 0, "View kill notices") 65 | Snomask.add(module, 'n', 0, "Can see local nick changes") 66 | Snomask.add(module, 'o', 1, "See oper-up notices and oper override notices") 67 | Snomask.add(module, 's', 0, "General server notices") 68 | Snomask.add(module, 'C', 0, "Can read global connect/disconnect notices") 69 | Snomask.add(module, 'F', 1, "View spamfilter matches") 70 | Snomask.add(module, 'G', 0, "View TKL usages") 71 | Snomask.add(module, 'L', 0, "View server notices about links") 72 | Snomask.add(module, 'N', 0, "Can see remote nick changes") 73 | Snomask.add(module, 'Q', 1, "View Q:line rejections") 74 | Snomask.add(module, 'S', 0, "Can see /sanick, /sajoin, and /sapart usage") 75 | Hook.add(Hook.UMODE_CHANGE, umode_t_changed) 76 | Hook.add(Hook.UMODE_CHANGE, umode_x_changed) 77 | Hook.add(Hook.UMODE_UNSET, umode_x_unset) 78 | -------------------------------------------------------------------------------- /modules/usermodes/m_blockmsg.py: -------------------------------------------------------------------------------- 1 | """ 2 | provides usermodes +R, +Z, and +D to block private messages 3 | """ 4 | 5 | from handle.core import Usermode, Numeric, Hook 6 | from handle.logger import logging 7 | 8 | 9 | def blockmsg_mode_unset(client, target, modebuf, mode): 10 | if target.local: 11 | if mode == 'r' and 'R' in target.user.modes: 12 | modebuf.append('R') 13 | if mode == 'z' and 'Z' in target.user.modes: 14 | modebuf.append('Z') 15 | 16 | 17 | def blockmsg_mode_R_is_ok(client): 18 | if 'R' in client.user.modes: 19 | return 1 20 | if 'r' not in client.user.modes and not client.has_permission("self:override:usermodes"): 21 | if 'R' not in client.user.modes: 22 | client.sendnumeric(Numeric.ERR_CANNOTCHANGEUMODE, 'R', "You need to be using a registered nickname to set +R.") 23 | return 0 24 | return 1 25 | 26 | 27 | def blockmsg_mode_Z_is_ok(client): 28 | if 'Z' in client.user.modes: 29 | return 1 30 | if 'z' not in client.user.modes and not client.has_permission("self:override:usermodes"): 31 | if 'Z' not in client.user.modes: 32 | client.sendnumeric(Numeric.ERR_CANNOTCHANGEUMODE, 'Z', "You need to be using a secure connection to set +Z.") 33 | return 0 34 | return 1 35 | 36 | 37 | def blockmsg_RZD(client, to_client, msg, sendtype): 38 | if 'o' in client.user.modes or client.is_uline() or client.is_service(): 39 | return msg 40 | 41 | if 'D' in to_client.user.modes: 42 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, "This user does not accept private messages") 43 | return Hook.DENY 44 | 45 | if 'R' in to_client.user.modes: 46 | if 'r' not in client.user.modes: 47 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 48 | "You need a registered nickname to talk privately to this user") 49 | return Hook.DENY 50 | if 'r' not in to_client.user.modes: 51 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 52 | "Message not sent. Recipient has usermode +R but is not using a registered nickname") 53 | return Hook.DENY 54 | 55 | if 'R' in client.user.modes: 56 | if 'r' not in client.user.modes: 57 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 58 | "Message not sent. You have usermode +R but are not using a registered nickname") 59 | return Hook.DENY 60 | if 'r' not in to_client.user.modes: 61 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 62 | "Message not sent. You have usermode +R but recipient is not using a registered nickname") 63 | return Hook.DENY 64 | 65 | if 'Z' in to_client.user.modes: 66 | if 'z' not in client.user.modes: 67 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 68 | "You need to be on a secure connection to talk privately to this user") 69 | return Hook.DENY 70 | if 'z' not in to_client.user.modes: 71 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 72 | "Message not sent. Recipient has usermode +Z but is not using a secure connection") 73 | return Hook.DENY 74 | 75 | if 'Z' in client.user.modes: 76 | if 'z' not in client.user.modes: 77 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 78 | "Message not sent. You have usermode +Z but are not using a secure connection") 79 | return Hook.DENY 80 | if 'z' not in to_client.user.modes: 81 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, 82 | "Message not sent. You have usermode +Z but recipient is not using a secure connection") 83 | return Hook.DENY 84 | 85 | return Hook.CONTINUE 86 | 87 | 88 | def init(module): 89 | Usermode.add(module, 'R', 1, 0, blockmsg_mode_R_is_ok, "Only users with a registered nickname can private message you") 90 | Usermode.add(module, 'Z', 1, 0, blockmsg_mode_Z_is_ok, "Only users on a secure connection can private message you") 91 | Usermode.add(module, 'D', 1, 0, Usermode.allow_all, "No-one can private message you") 92 | Hook.add(Hook.CAN_SEND_TO_USER, blockmsg_RZD) 93 | Hook.add(Hook.UMODE_UNSET, blockmsg_mode_unset) 94 | -------------------------------------------------------------------------------- /modules/usermodes/noctcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | user mode +T (block ctcp messages) 3 | """ 4 | 5 | from handle.core import Usermode, Hook, Numeric 6 | 7 | 8 | def msg_noctcp(client, to_client, message, sendtype): 9 | if client.has_permission("immune:message:ctcp") or client.is_uline() or client == to_client: 10 | return Hook.CONTINUE 11 | 12 | if 'T' in to_client.user.modes and message[0] == '' and message[-1] == '': 13 | client.sendnumeric(Numeric.ERR_CANTSENDTOUSER, to_client.name, "This user does not accept CTCP messages") 14 | return Hook.DENY 15 | 16 | return Hook.CONTINUE 17 | 18 | 19 | def init(module): 20 | Usermode.add(module, 'T', 1, 0, Usermode.allow_all, "Blocks CTCP messages") 21 | Hook.add(Hook.CAN_SEND_TO_USER, msg_noctcp) 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # https://github.com/pyca/pyopenssl 2 | pyopenssl>=22.2 3 | cryptography 4 | 5 | # https://cffi.readthedocs.io/en/latest/ 6 | cffi 7 | 8 | websockets>=13 9 | --------------------------------------------------------------------------------