├── 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 |
--------------------------------------------------------------------------------