diff --git a/.gitignore b/.gitignore index 1a546f6..7c65121 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ /infobob.cfg *.sqlite +*.py[co] +.tox +.coverage +dist/ +build/ +_trial_temp +twisted/plugins/dropin.cache diff --git a/functional-tests/.gitignore b/functional-tests/.gitignore new file mode 100644 index 0000000..a3f57b4 --- /dev/null +++ b/functional-tests/.gitignore @@ -0,0 +1,3 @@ +infobob-env/ +tests-env/ +cornell_movie_dialogs_corpus.* diff --git a/functional-tests/README.md b/functional-tests/README.md new file mode 100644 index 0000000..cd360b1 --- /dev/null +++ b/functional-tests/README.md @@ -0,0 +1,125 @@ +Functional test suite for infobob +================================= + +Uses charybdis (ircd) and atheme (services), configured to resemble Freenode. +Freenode uses modified versions of these, but so far I haven't been able to +find important differences, so hopefully it's close enough. + +Requirements: + +- Docker for the ircd and services +- Python 3.6+ for the tests +- Python 2.7 and virtualenv for infobob itself + +The `features/` directory has some [Gherkin](https://cucumber.io/docs/gherkin/) +feature files. I don't intend to make these actually executable; they're +primarily intended to document test cases that haven't yet been implemented. + + +Setup and Running +----------------- + + # tests env + python3 -m venv tests-env + tests-env/bin/pip install -r requirements.txt + # infobob env (with py2) + python2 -m virtualenv infobob-env + infobob-env/bin/pip install -e .. + export INFOBOB_PYTHON=infobob-env/bin/python + +Run the ircd and services in another terminal: + + export COMPOSE_PROJECT_NAME=infobob-functests + docker-compose build && docker-compose up + +Wait for them to come up: atheme will output something like +`m_pong(): finished synching with uplink (465 ms)`. + +Then run the tests: + + tests-env/bin/pytest + + +Other Goodies +------------- + +If you want to poke around, point your IRC client at localhost:6667 +(plaintext). The superadmin is `god`, password `letmein` (for both +NickServ and full IRCops). + +There's a `simulate.py` script you can use to run infobob with a fresh db, +along with a few chatty bots. The script takes a single optional argument, +a filename containing phrases to use. + +You can use the `movie_lines.py` script to generate a better phrases file, +e.g. `./movie_lines.py lines -n m13` if you're a fan of "Airplane!". Don't +forget to initialize with `./movie_lines.py initdb` first. + + +Updating the initial services database +-------------------------------------- + +The NickServ account credentials and channel configurations (with their +associated ChanServ settings) in `config.py` are preloaded in a database file +which is written into the atheme container image on build. If you change the +tests to rely on different services data (e.g. additional accounts or +registered channels), you'll need to update apps/atheme/initial.db. + +While it appears to be a flat text file, you should consider it opaque: it's +difficult to hand-edit accurately, and it's probably a bad idea to assume +atheme can gracefully deal with invalid data. Instead, follow this procedure +to start from a clean slate, change the things you need, and update the file: + +1. Note which changes you need to make. If you don't remember exactly, you + can run this to persist the db for manual comparison: + + docker run \ + --mount type=volume,source=infobob-functests_atheme-db,dst=/athemedb,readonly \ + debian:buster \ + cat /athemedb/services.db \ + > backup.db + +2. Stop docker-compose, then delete the container and volume: + + docker rm infobob-functests_atheme_1 + docker volume rm infobob-functests_atheme-db + +3. Spin it back up: + + export COMPOSE_PROJECT_NAME=infobob-functests + docker-compose build && docker-compose up + +4. Make the changes you need. The convention for account password is + `nickname + 'pass'` and email `nickname + '@infobobtest.local'`. + At least with irssi, you can oneline stuff with `/eval`, which makes + registering a bunch of users fairly easy: + + /eval nick flubber; \ + msg nickserv register flubberpass flubber@infobobtest.local; \ + msg nickserv logout + +5. Since atheme only writes its db every 5 minutes, you'll need to make sure + the services DB is updated: log in as `god`, make sure you're identified + with NickServ, use the `/oper` command to escalate, then + `/msg operserv update`. Make sure you got a notice from OperServ saying + `UPDATE completed`. + +6. Stop docker-compose, then grab the DB: + + docker run \ + --rm \ + --mount type=volume,source=infobob-functests_atheme-db,dst=/athemedb,readonly \ + debian:buster \ + cat /athemedb/services.db \ + > apps/atheme/initial.db + +7. Sanitize the DB by replacing your host username, which might show up: + edit apps/atheme/initial.db and look for MDU lines, e.g. + + MDU somenick private:host:actual ~cdunklau@172.18.0.1 + MDU somenick private:host:vhost ~cdunklau@172.18.0.1 + + Replace your username with `someone`, but be careful about it. + +8. Finally, repeat steps 2 and 3, then double check that the changes persist + and the tests pass. You're ready to commit! diff --git a/functional-tests/apps/atheme/.gitattributes b/functional-tests/apps/atheme/.gitattributes new file mode 100644 index 0000000..0abd223 --- /dev/null +++ b/functional-tests/apps/atheme/.gitattributes @@ -0,0 +1,6 @@ +# Don't allow git to mess with the DB. +# diffs could still be interesting, so leave that set, +# but don't let it play with line endings, or try to +# merge two versions of it. +# Also ignore whitespace errors. +initial.db diff -text -merge -whitespace diff --git a/functional-tests/apps/atheme/Dockerfile b/functional-tests/apps/atheme/Dockerfile new file mode 100644 index 0000000..f2f2249 --- /dev/null +++ b/functional-tests/apps/atheme/Dockerfile @@ -0,0 +1,14 @@ +FROM debian:buster + +RUN apt-get update \ + && apt-get install -y \ + atheme-services + +COPY atheme.conf /etc/atheme/atheme.conf +COPY initial.db /var/lib/atheme/services.db + +# HTTP port for XMLRPC +EXPOSE 8080 +# Volume for services database +VOLUME /var/lib/atheme +ENTRYPOINT ["atheme-services", "-n", "-p", "/var/run/atheme.pid"] diff --git a/functional-tests/apps/atheme/atheme.conf b/functional-tests/apps/atheme/atheme.conf new file mode 100644 index 0000000..fc1a44f --- /dev/null +++ b/functional-tests/apps/atheme/atheme.conf @@ -0,0 +1,2302 @@ +/* This is an example configuration for Services. + * + * All statements end in semi-colons (';'). + * Shell style, C style, and C++ style comments may be used. + * + * Items marked with "(*)" are reconfigurable at runtime via REHASH. + */ + +/****************************************************************************** + * MODULES SECTION. * + ******************************************************************************/ + +/* + * These are the modules included with the core distribution of Services. + * + * You may be interested in the atheme community modules distribution as + * well, which adds additional features that may or may not be compatible + * with the project paradigms intended for maintainance of the core of + * atheme-services. + * + * Visit the atheme-services website for more information and to download them. + * + * Modules marked [experimental] will taint your atheme-services instance. Do + * not file any bug reports with us about using Services with those modules; + * they will be ignored. + */ + +/* Dynamic security modules. + * + * WARNING: If you select one of these modules, the default security policy included + * with Atheme may break. These modules are intended for people who know what they + * are doing and understand the implications of what they do. Security modules which + * are likely to break the default policy are prefixed with [!], if you are new to + * Atheme, you should avoid enabling them. + * + * If you find your security policy is broken, you may debug it while allowing normal + * operation of your IRC network by putting Atheme into "permissive mode". To do this, + * enable general::permissive_mode. + * + * [!] Infer "command:" namespace permissions modules/security/cmdperm + */ +#loadmodule "modules/security/cmdperm"; + +/* Protocol module. + * + * Please select a protocol module. Different servers use different protocols. + * Below is a listing of ircd's known to work with the various protocol modules + * available. + * + * Asuka 1.2.1 or later modules/protocol/asuka + * Bahamut 1.8.x modules/protocol/bahamut + * Charybdis IRCd modules/protocol/charybdis + * DreamForge 4.6.7 or later modules/protocol/dreamforge + * InspIRCd 2.0 modules/protocol/inspircd + * ircd-ratbox 2.0 and later modules/protocol/ratbox + * IRCNet ircd (ircd 2.11) modules/protocol/ircnet + * ircd-seven modules/protocol/ircd-seven + * Nefarious IRCu 0.4.0 or later modules/protocol/nefarious + * ngIRCd 19 or later [experimental] modules/protocol/ngircd + * UnrealIRCd 3.2.* modules/protocol/unreal + * UnrealIRCd 4 or later modules/protocol/unreal4 + * + * If your IRCd vendor has supplied a module file, build it and load it here + * instead of one above. + */ +loadmodule "modules/protocol/charybdis"; + +/* Protocol mixins. + * + * These should be used if you do not have/want certain features on your + * network that your ircd normally has. If you do not know what this means, + * you do not need any of them. + * + * Disable halfops modules/protocol/mixin_nohalfops + * Disable holdnick (use enforcer clients) modules/protocol/mixin_noholdnick + * Disable "protect" mode on channels modules/protocol/mixin_noprotect + * Disable "owner" mode on channels modules/protocol/mixin_noowner + */ +#loadmodule "modules/protocol/mixin_nohalfops"; +#loadmodule "modules/protocol/mixin_noholdnick"; +#loadmodule "modules/protocol/mixin_noprotect"; +#loadmodule "modules/protocol/mixin_noowner"; + +/* Database backend module. + * + * Please select a database backend module. Different backends allow for + * different ways in which the services data can be manipulated. YOU MAY + * ONLY HAVE ONE OF THESE BACKENDS LOADED. + * + * The following backends are available: + * + * Atheme 0.1 flatfile database format modules/backend/flatfile + * Open Services Exchange database format modules/backend/opensex + * + * Most networks will want opensex. + */ +loadmodule "modules/backend/opensex"; + +/* Crypto module. + * + * If you would like encryption for your services passwords, please + * select a module here. Note that upon starting with a crypto module + * YOUR PASSWORDS ARE IMMEDIATELY AND IRREVERSIBLY CONVERTED. Make at + * least TWO backups of your database before experimenting with this. + * If you have several thousand accounts, this conversion may take + * appreciable time. + * + * The following crypto modules are available: + * + * PBKDF2 cryptography (new, recommended) modules/crypto/pbkdf2v2 + * PBKDF2 cryptography (old, compatibility) modules/crypto/pbkdf2 + * POSIX-style crypt(3) modules/crypto/posix + * IRCServices (also Anope etc) compatibility modules/crypto/ircservices + * Raw MD5 (Anope compatibility) modules/crypto/rawmd5 + * Raw SHA1 (Anope compatibility) modules/crypto/rawsha1 + * + * The ircservices, rawmd5 and rawsha1 modules are only recommended for use with + * a database converted from other services with password encryption. + * + * To transition between crypto schemes, load the preferred scheme first + * and as users login, they will be migrated to the new preferred scheme. Like: + * loadmodule "modules/crypto/pbkdf2v2"; + * loadmodule "modules/crypto/pbkdf2"; + * loadmodule "modules/crypto/posix"; + * loadmodule "modules/crypto/ircservices"; + * + * The rawsha1 and pbkdf2/pbkdf2v2 modules require OpenSSL. + */ +#loadmodule "modules/crypto/pbkdf2v2"; +loadmodule "modules/crypto/posix"; + +/* Authentication module. + * + * These allow using passwords from an external system. The password given + * when registering a new account is also checked against the external + * system. + * + * The following authentication modules are available: + * + * LDAP modules/auth/ldap + * + * The LDAP module requires OpenLDAP client libraries. It uses them in a + * synchronous manner, which means that an unresponsive LDAP server can + * freeze services. + */ +#loadmodule "modules/auth/ldap"; + +/* NickServ modules. + * + * Here you can disable or enable certain features of NickServ, by + * defining which modules are loaded. You can even disable NickServ + * entirely. Please note however, that an authentication service + * (either NickServ, or UserServ) is required for proper functionality. + * + * The CrackLib password validation module requires CrackLib to be + * installed on your system in order to use. + * + * Core components modules/nickserv/main + * Nickname access lists modules/nickserv/access + * Bad email address blocking modules/nickserv/badmail + * CertFP fingerprint managment modules/nickserv/cert + * CrackLib password validation modules/nickserv/cracklib + * DROP command modules/nickserv/drop + * Nickname enforcement modules/nickserv/enforce + * GHOST command modules/nickserv/ghost + * GROUP and UNGROUP commands modules/nickserv/group + * HELP command modules/nickserv/help + * Nickname expiry override (HOLD command) modules/nickserv/hold + * IDENTIFY command modules/nickserv/identify + * INFO command modules/nickserv/info + * Last quit message in INFO modules/nickserv/info_lastquit + * LIST command modules/nickserv/list + * LISTMAIL command modules/nickserv/listmail + * LISTOWNMAIL command modules/nickserv/listownmail + * LOGIN command (for no_nick_ownership) modules/nickserv/login + * LOGOUT command modules/nickserv/logout + * MARK command modules/nickserv/mark + * FREEZE command modules/nickserv/freeze + * LISTCHANS command modules/nickserv/listchans + * LISTGROUPS command modules/nickserv/listgroups + * REGISTER command modules/nickserv/register + * Bypass registration limits (REGNOLIMIT) modules/nickserv/regnolimit + * Password reset (RESETPASS command) modules/nickserv/resetpass + * RESTRICT command modules/nickserv/restrict + * Password return (RETURN command) modules/nickserv/return + * Password retrieval (SENDPASS command) modules/nickserv/sendpass + * Password retrieval allowed to normal users modules/nickserv/sendpass_user + * SET command core modules/nickserv/set_core + * Change primary nickname (SET ACCOUNTNAME) modules/nickserv/set_accountname + * SET EMAIL command modules/nickserv/set_email + * SET EMAILMEMOS command modules/nickserv/set_emailmemos + * SET ENFORCETIME command modules/nickserv/set_enforcetime + * SET HIDEMAIL command modules/nickserv/set_hidemail + * SET LANGUAGE command modules/nickserv/set_language + * SET NEVERGROUP command modules/nickserv/set_nevergroup + * SET NEVEROP command modules/nickserv/set_neverop + * SET NOGREET command modules/nickserv/set_nogreet + * SET NOMEMO command modules/nickserv/set_nomemo + * SET NOOP command modules/nickserv/set_noop + * SET NOPASSWORD command modules/nickserv/set_nopassword + * SET PASSWORD command modules/nickserv/set_password + * PRIVMSG instead of NOTICE (SET PRIVMSG cmd) modules/nickserv/set_privmsg + * Account info hiding (SET PRIVATE command) modules/nickserv/set_private + * SET PROPERTY command modules/nickserv/set_property + * SET PUBKEY command modules/nickserv/set_pubkey + * SET QUIETCHG command modules/nickserv/set_quietchg + * Password retrieval uses code (SETPASS cmd) modules/nickserv/setpass + * STATUS command modules/nickserv/status + * Nickname metadata viewer (TAXONOMY command) modules/nickserv/taxonomy + * VACATION command modules/nickserv/vacation + * VERIFY command modules/nickserv/verify + * VHOST command modules/nickserv/vhost + */ +loadmodule "modules/nickserv/main"; +#loadmodule "modules/nickserv/access"; +loadmodule "modules/nickserv/badmail"; +#loadmodule "modules/nickserv/cert"; +#loadmodule "modules/nickserv/cracklib"; +loadmodule "modules/nickserv/drop"; +#loadmodule "modules/nickserv/enforce"; +loadmodule "modules/nickserv/ghost"; +loadmodule "modules/nickserv/group"; +loadmodule "modules/nickserv/help"; +loadmodule "modules/nickserv/hold"; +loadmodule "modules/nickserv/identify"; +loadmodule "modules/nickserv/info"; +#loadmodule "modules/nickserv/info_lastquit"; +loadmodule "modules/nickserv/list"; +loadmodule "modules/nickserv/listmail"; +#loadmodule "modules/nickserv/listownmail"; +#loadmodule "modules/nickserv/login"; +loadmodule "modules/nickserv/logout"; +loadmodule "modules/nickserv/mark"; +loadmodule "modules/nickserv/freeze"; +loadmodule "modules/nickserv/listchans"; +loadmodule "modules/nickserv/listgroups"; +loadmodule "modules/nickserv/register"; +loadmodule "modules/nickserv/regnolimit"; +loadmodule "modules/nickserv/resetpass"; +loadmodule "modules/nickserv/restrict"; +loadmodule "modules/nickserv/return"; +loadmodule "modules/nickserv/setpass"; +#loadmodule "modules/nickserv/sendpass"; +loadmodule "modules/nickserv/sendpass_user"; +loadmodule "modules/nickserv/set_core"; +loadmodule "modules/nickserv/set_accountname"; +loadmodule "modules/nickserv/set_email"; +loadmodule "modules/nickserv/set_emailmemos"; +#loadmodule "modules/nickserv/set_enforcetime"; +loadmodule "modules/nickserv/set_hidemail"; +loadmodule "modules/nickserv/set_language"; +loadmodule "modules/nickserv/set_nevergroup"; +loadmodule "modules/nickserv/set_neverop"; +loadmodule "modules/nickserv/set_nogreet"; +loadmodule "modules/nickserv/set_nomemo"; +loadmodule "modules/nickserv/set_noop"; +loadmodule "modules/nickserv/set_password"; +#loadmodule "modules/nickserv/set_privmsg"; +#loadmodule "modules/nickserv/set_private"; +loadmodule "modules/nickserv/set_property"; +loadmodule "modules/nickserv/set_pubkey"; +loadmodule "modules/nickserv/set_quietchg"; +loadmodule "modules/nickserv/status"; +loadmodule "modules/nickserv/taxonomy"; +loadmodule "modules/nickserv/vacation"; +loadmodule "modules/nickserv/verify"; +loadmodule "modules/nickserv/vhost"; + +/* ChanServ modules. + * + * Here you can disable or enable certain features of ChanServ, by + * defining which modules are loaded. You can even disable ChanServ + * entirely. Please note that ChanServ requires an authentication + * service, either NickServ or UserServ will do. + * + * Core components modules/chanserv/main + * ACCESS command (simplified ACL editing) modules/chanserv/access + * AKICK command modules/chanserv/akick + * BAN/UNBAN commands modules/chanserv/ban + * UNBAN self only (load ban or this not both) modules/chanserv/unban_self + * CLOSE command modules/chanserv/close + * CLONE command modules/chanserv/clone + * CLEAR command modules/chanserv/clear + * CLEAR AKICKS command modules/chanserv/clear_akicks + * CLEAR BANS command modules/chanserv/clear_bans + * CLEAR FLAGS command modules/chanserv/clear_flags + * CLEAR USERS command modules/chanserv/clear_users + * COUNT command modules/chanserv/count + * DROP command modules/chanserv/drop + * Forced flags changes modules/chanserv/fflags + * FLAGS command modules/chanserv/flags + * Forced foundership transfers modules/chanserv/ftransfer + * GETKEY command modules/chanserv/getkey + * HALFOP/DEHALFOP commands modules/chanserv/halfop + * HELP command modules/chanserv/help + * Channel expiry override (HOLD command) modules/chanserv/hold + * INFO command modules/chanserv/info + * INVITE command modules/chanserv/invite + * KICK/KICKBAN commands modules/chanserv/kick + * LIST command modules/chanserv/list + * MARK command modules/chanserv/mark + * Moderated channel registrations modules/chanserv/moderate + * OP/DEOP commands modules/chanserv/op + * OWNER/DEOWNER commands modules/chanserv/owner + * PROTECT/DEPROTECT commands modules/chanserv/protect + * QUIET command (+q support) modules/chanserv/quiet + * Channel takeover recovery (RECOVER command) modules/chanserv/recover + * REGISTER command modules/chanserv/register + * SET command core modules/chanserv/set_core + * SET EMAIL command modules/chanserv/set_email + * SET ENTRYMSG command modules/chanserv/set_entrymsg + * SET FANTASY command modules/chanserv/set_fantasy + * SET GAMESERV command modules/chanserv/set_gameserv + * SET GUARD command modules/chanserv/set_guard + * SET KEEPTOPIC command modules/chanserv/set_keeptopic + * SET LIMITFLAGS command modules/chanserv/set_limitflags + * SET MLOCK command modules/chanserv/set_mlock + * SET PREFIX command modules/chanserv/set_prefix + * Channel info hiding (SET PRIVATE command) modules/chanserv/set_private + * SET PROPERTY command modules/chanserv/set_property + * SET PUBACL command modules/chanserv/set_pubacl + * SET RESTRICTED command modules/chanserv/set_restricted + * SET SECURE command modules/chanserv/set_secure + * SET TOPICLOCK command modules/chanserv/set_topiclock + * SET URL command modules/chanserv/set_url + * SET VERBOSE command modules/chanserv/set_verbose + * STATUS command modules/chanserv/status + * SYNC command (and automatic ACL syncing) modules/chanserv/sync + * Named Successor ACL flag modules/chanserv/successor_acl + * Channel metadata viewer (TAXONOMY command) modules/chanserv/taxonomy + * TEMPLATE command modules/chanserv/template + * TOPIC/TOPICAPPEND commands modules/chanserv/topic + * VOICE/DEVOICE commands modules/chanserv/voice + * WHY command modules/chanserv/why + * VOP/HOP/AOP/SOP commands modules/chanserv/xop + * This module provides emulation of the ircservices XOP scheme ONLY. + * Do not report discrepencies when using native commands to edit channel + * ACLs. This is intentional. + * Flood protection modules/chanserv/antiflood + * This module should be loaded after at least chanserv/quiet if you want + * the autoquiet feature to work. + */ +loadmodule "modules/chanserv/main"; +loadmodule "modules/chanserv/access"; +loadmodule "modules/chanserv/akick"; +loadmodule "modules/chanserv/ban"; +#loadmodule "modules/chanserv/unban_self"; +loadmodule "modules/chanserv/clone"; +loadmodule "modules/chanserv/close"; +loadmodule "modules/chanserv/clear"; +loadmodule "modules/chanserv/clear_akicks"; +loadmodule "modules/chanserv/clear_bans"; +loadmodule "modules/chanserv/clear_flags"; +loadmodule "modules/chanserv/clear_users"; +loadmodule "modules/chanserv/count"; +loadmodule "modules/chanserv/drop"; +#loadmodule "modules/chanserv/fflags"; +loadmodule "modules/chanserv/flags"; +loadmodule "modules/chanserv/ftransfer"; +loadmodule "modules/chanserv/getkey"; +#loadmodule "modules/chanserv/halfop"; +loadmodule "modules/chanserv/help"; +loadmodule "modules/chanserv/hold"; +loadmodule "modules/chanserv/info"; +loadmodule "modules/chanserv/invite"; +loadmodule "modules/chanserv/kick"; +loadmodule "modules/chanserv/list"; +loadmodule "modules/chanserv/mark"; +#loadmodule "modules/chanserv/moderate"; +loadmodule "modules/chanserv/op"; +#loadmodule "modules/chanserv/owner"; +#loadmodule "modules/chanserv/protect"; +#loadmodule "modules/chanserv/quiet"; +loadmodule "modules/chanserv/recover"; +loadmodule "modules/chanserv/register"; +loadmodule "modules/chanserv/set_core"; +loadmodule "modules/chanserv/set_email"; +loadmodule "modules/chanserv/set_entrymsg"; +loadmodule "modules/chanserv/set_fantasy"; +#loadmodule "modules/chanserv/set_gameserv"; +loadmodule "modules/chanserv/set_guard"; +loadmodule "modules/chanserv/set_keeptopic"; +#loadmodule "modules/chanserv/set_limitflags"; +loadmodule "modules/chanserv/set_mlock"; +loadmodule "modules/chanserv/set_prefix"; +#loadmodule "modules/chanserv/set_private"; +loadmodule "modules/chanserv/set_property"; +#loadmodule "modules/chanserv/set_pubacl"; +loadmodule "modules/chanserv/set_restricted"; +loadmodule "modules/chanserv/set_secure"; +loadmodule "modules/chanserv/set_topiclock"; +loadmodule "modules/chanserv/set_url"; +loadmodule "modules/chanserv/set_verbose"; +loadmodule "modules/chanserv/status"; +loadmodule "modules/chanserv/sync"; +#loadmodule "modules/chanserv/successor_acl"; +loadmodule "modules/chanserv/taxonomy"; +loadmodule "modules/chanserv/template"; +loadmodule "modules/chanserv/topic"; +loadmodule "modules/chanserv/voice"; +loadmodule "modules/chanserv/why"; +#loadmodule "modules/chanserv/xop"; +loadmodule "modules/chanserv/antiflood"; + +/* CHANFIX module. + * + * Here you can disable or enable certain features of CHANFIX, by + * defining which modules are loaded. + * + * Core components modules/chanfix/main + */ +#loadmodule "modules/chanfix/main"; + +/* OperServ modules. + * + * Here you can disable or enable certain features of OperServ, by + * defining which modules are loaded. + * + * Core components modules/operserv/main + * AKILL system modules/operserv/akill + * CLEARCHAN command modules/operserv/clearchan + * CLONES system modules/operserv/clones + * COMPARE command modules/operserv/compare + * GREPLOG command modules/operserv/greplog + * HELP command modules/operserv/help + * IGNORE system modules/operserv/ignore + * IDENTIFY command modules/operserv/identify + * INFO command modules/operserv/info + * INJECT command modules/operserv/inject + * JUPE command modules/operserv/jupe + * MODE command modules/operserv/mode + * MODINSPECT command modules/operserv/modinspect + * MODLIST command modules/operserv/modlist + * MODLOAD command modules/operserv/modload + * MODRELOAD command modules/operserv/modreload + * MODUNLOAD command modules/operserv/modunload + * NOOP system modules/operserv/noop + * Override access (OVERRIDE command) modules/operserv/override + * Regex mass akill (RAKILL command) modules/operserv/rakill + * RAW command modules/operserv/raw + * READONLY command modules/operserv/readonly + * REHASH command modules/operserv/rehash + * RESTART command modules/operserv/restart + * Display regex matching (RMATCH command) modules/operserv/rmatch + * Most common realnames (RNC command) modules/operserv/rnc + * RWATCH system modules/operserv/rwatch + * Temporarily modify config options (SET command) modules/operserv/set + * SGLINE system modules/operserv/sgline + * SHUTDOWN command modules/operserv/shutdown + * Non-config oper privileges (SOPER command) modules/operserv/soper + * Oper privilege display (SPECS command) modules/operserv/specs + * SQLINE system modules/operserv/sqline + * UPDATE command modules/operserv/update + * UPTIME command modules/operserv/uptime + */ +loadmodule "modules/operserv/main"; +loadmodule "modules/operserv/akill"; +#loadmodule "modules/operserv/clearchan"; +#loadmodule "modules/operserv/clones"; +loadmodule "modules/operserv/compare"; +#loadmodule "modules/operserv/greplog"; +loadmodule "modules/operserv/help"; +loadmodule "modules/operserv/identify"; +loadmodule "modules/operserv/ignore"; +loadmodule "modules/operserv/info"; +loadmodule "modules/operserv/jupe"; +loadmodule "modules/operserv/mode"; +loadmodule "modules/operserv/modinspect"; +loadmodule "modules/operserv/modlist"; +loadmodule "modules/operserv/modload"; +loadmodule "modules/operserv/modunload"; +loadmodule "modules/operserv/modreload"; +loadmodule "modules/operserv/noop"; +#loadmodule "modules/operserv/override"; +#loadmodule "modules/operserv/rakill"; +loadmodule "modules/operserv/readonly"; +loadmodule "modules/operserv/rehash"; +loadmodule "modules/operserv/restart"; +loadmodule "modules/operserv/rmatch"; +loadmodule "modules/operserv/rnc"; +loadmodule "modules/operserv/rwatch"; +loadmodule "modules/operserv/set"; +loadmodule "modules/operserv/sgline"; +loadmodule "modules/operserv/shutdown"; +#loadmodule "modules/operserv/soper"; +loadmodule "modules/operserv/specs"; +loadmodule "modules/operserv/sqline"; +loadmodule "modules/operserv/update"; +loadmodule "modules/operserv/uptime"; + +/* MemoServ modules. + * + * Here you can disable or enable certain features of MemoServ, by + * defining which modules are loaded. You can even disable MemoServ + * entirely. + * + * Core components modules/memoserv/main + * HELP command modules/memoserv/help + * SEND command modules/memoserv/send + * Channel memos (SENDOPS command) modules/memoserv/sendops + * Group memos (SENDGROUP command) modules/memoserv/sendgroup + * LIST command modules/memoserv/list + * READ command modules/memoserv/read + * FORWARD command modules/memoserv/forward + * DELETE command modules/memoserv/delete + * IGNORE command modules/memoserv/ignore + */ +loadmodule "modules/memoserv/main"; +loadmodule "modules/memoserv/help"; +loadmodule "modules/memoserv/send"; +loadmodule "modules/memoserv/sendops"; +loadmodule "modules/memoserv/sendgroup"; +loadmodule "modules/memoserv/list"; +loadmodule "modules/memoserv/read"; +loadmodule "modules/memoserv/forward"; +loadmodule "modules/memoserv/delete"; +loadmodule "modules/memoserv/ignore"; + +/* Global module. + * + * Like the other services, the Global noticer is a module. You can + * disable or enable it to your liking below. Please note that the + * Global noticer is dependent on OperServ for full functionality. + */ +loadmodule "modules/global/main"; + +/* InfoServ module. + * + * Like the other services, InfoServ is a module. You can disable or + * enable it to your liking below. + */ +loadmodule "modules/infoserv/main"; + +/* SASL agent module. + * + * Allows clients to authenticate to services via SASL with an appropriate + * ircd. You need the core components and at least one mechanism. + * + * Core components modules/saslserv/main + * PLAIN mechanism modules/saslserv/plain + * ECDSA-NIST256p-CHALLENGE modules/saslserv/ecdsa-nist256p-challenge + * AUTHCOOKIE mechanism (for IRIS) modules/saslserv/authcookie + * EXTERNAL mechanism (IRCv3.1+) modules/saslserv/external + * + * ECDSA-NIST256p-CHALLENGE support requires that Atheme be compiled against OpenSSL. + */ +loadmodule "modules/saslserv/main"; +loadmodule "modules/saslserv/plain"; +loadmodule "modules/saslserv/authcookie"; +#loadmodule "modules/saslserv/external"; +#loadmodule "modules/saslserv/ecdsa-nist256p-challenge"; /* requires SSL */ + +/* GameServ modules. + * + * Here you can disable or enable certain features of GameServ, by + * defining which modules are loaded. You can even disable GameServ + * entirely. + * + * Core components modules/gameserv/main + * DICE/WOD commands modules/gameserv/dice + * EIGHTBALL command modules/gameserv/eightball + * Game-specific dice calculators modules/gameserv/gamecalc + * HELP commands modules/gameserv/help + * LOTTERY command modules/gameserv/lottery + * NAMEGEN command modules/gameserv/namegen + * RPS command modules/gameserv/rps + */ +#loadmodule "modules/gameserv/main"; +#loadmodule "modules/gameserv/dice"; +#loadmodule "modules/gameserv/eightball"; +#loadmodule "modules/gameserv/gamecalc"; +#loadmodule "modules/gameserv/help"; +#loadmodule "modules/gameserv/lottery"; +#loadmodule "modules/gameserv/namegen"; +#loadmodule "modules/gameserv/rps"; + +/* RPGServ modules. + * + * Here you can disable or enable certain features of RPGServ, by + * defining which modules are loaded. You can even disable RPGServ + * entirely. + * + * Core components modules/rpgserv/main + * ENABLE/DISABLE commands modules/rpgserv/enable + * HELP command modules/rpgserv/help + * INFO command modules/rpgserv/info + * LIST command modules/rpgserv/list + * SEARCH command modules/rpgserv/search + * SET commands modules/rpgserv/set + */ +#loadmodule "modules/rpgserv/main"; +#loadmodule "modules/rpgserv/enable"; +#loadmodule "modules/rpgserv/help"; +#loadmodule "modules/rpgserv/info"; +#loadmodule "modules/rpgserv/list"; +#loadmodule "modules/rpgserv/search"; +#loadmodule "modules/rpgserv/set"; + +/* BotServ modules. + * + * Here you can disable or enable certain features of BotServ, by + * defining which modules are loaded. You can even disable BotServ + * entirely. + * + * Core components modules/botserv/main + * HELP command modules/botserv/help + * INFO command modules/botserv/info + * NPC commands (SAY, ACT) modules/botserv/bottalk + * SET command (required for SET commands) modules/botserv/set_core + * SET FANTASY command modules/botserv/set_fantasy + * SET NOBOT command modules/botserv/set_nobot + * SET PRIVATE command modules/botserv/set_private + * SET SAYCALLER command modules/botserv/set_saycaller + */ +#loadmodule "modules/botserv/main"; +#loadmodule "modules/botserv/help"; +#loadmodule "modules/botserv/info"; +#loadmodule "modules/botserv/bottalk"; +#loadmodule "modules/botserv/set_core"; +#loadmodule "modules/botserv/set_fantasy"; +#loadmodule "modules/botserv/set_nobot"; +#loadmodule "modules/botserv/set_private"; +#loadmodule "modules/botserv/set_saycaller"; + +/* HostServ modules. + * + * Here you can disable or enable certain features of HostServ, by + * defining which modules are loaded. You can even disable HostServ + * entirely. + * + * HostServ is a more complex, and optional virtual host management service. + * Users wishing only to set vhosts need not use it (they can use the builtin + * vhost management of NickServ instead). + * + * Core components modules/hostserv/main + * HELP command modules/hostserv/help + * OFFER system modules/hostserv/offer + * ON and OFF commands modules/hostserv/onoff + * REQUEST system modules/hostserv/request + * VHOST and LISTVHOST commands modules/hostserv/vhost + * VHOSTNICK command modules/hostserv/vhostnick + * GROUP command modules/hostserv/group + * DROP command modules/hostserv/drop + */ +#loadmodule "modules/hostserv/main"; +#loadmodule "modules/hostserv/help"; +#loadmodule "modules/hostserv/onoff"; +#loadmodule "modules/hostserv/offer"; +#loadmodule "modules/hostserv/request"; +#loadmodule "modules/hostserv/vhost"; +#loadmodule "modules/hostserv/vhostnick"; +#loadmodule "modules/hostserv/group"; +#loadmodule "modules/hostserv/drop"; + +/* HelpServ modules. + * HelpServ allows users to request help from network staff in a few different ways. + * + * Core components modules/helpserv/main + * HELPME command modules/helpserv/helpme + * Help Ticket system modules/helpserv/ticket + * Service List modules/helpserv/services + * + * The ticket system works like a bugtracker ot helpdesk ticket system, HELPME + * works like a one-time alert. You should probably only load one of the two systems. + */ +#loadmodule "modules/helpserv/main"; +#loadmodule "modules/helpserv/helpme"; +#loadmodule "modules/helpserv/ticket"; +#loadmodule "modules/helpserv/services"; + +/* Channel listing service. + * + * Allows users to list channels with more flexibility than the /list + * command. + * + * Core components modules/alis/main + */ +#loadmodule "modules/alis/main"; + +/* StatServ module. + * StatServ provides basic statistics and split tracking. + * + * Core components modules/statserv/main + * CHANNEL command modules/statserv/channel + * NETSPLIT command modules/statserv/netsplit + * SERVER command modules/statserv/server + */ +loadmodule "modules/statserv/main"; +#loadmodule "modules/statserv/channel"; +loadmodule "modules/statserv/netsplit"; +loadmodule "modules/statserv/server"; + +/* GroupServ module. + * GroupServ allows users to create groups to easily mass-manage channel + * access and more. + * + * Core components modules/groupserv/main + * ACSNOLIMIT command modules/groupserv/acsnolimit + * DROP command modules/groupserv/drop + * FDROP command modules/groupserv/fdrop + * FFLAGS command modules/groupserv/fflags + * FLAGS command modules/groupserv/flags + * HELP command modules/groupserv/help + * INFO command modules/groupserv/info + * JOIN command modules/groupserv/join + * LIST command modules/groupserv/list + * LISTCHANS command modules/groupserv/listchans + * REGISTER command modules/groupserv/register + * REGNOLIMIT command modules/groupserv/regnolimit + * INVITE command modules/groupserv/invite + * SET command modules/groupserv/set + * SET CHANNEL command modules/groupserv/set_channel + * SET DESCRIPTION command modules/groupserv/set_description + * SET EMAIL command modules/groupserv/set_email + * SET GROUPNAME command modules/groupserv/set_groupname + * SET JOINFLAGS command modules/groupserv/set_joinflags + * SET OPEN command modules/groupserv/set_open + * SET PUBLIC command modules/groupserv/set_public + * SET URL command modules/groupserv/set_url + * + */ +loadmodule "modules/groupserv/main"; +loadmodule "modules/groupserv/acsnolimit"; +loadmodule "modules/groupserv/drop"; +loadmodule "modules/groupserv/fdrop"; +loadmodule "modules/groupserv/fflags"; +loadmodule "modules/groupserv/flags"; +loadmodule "modules/groupserv/help"; +loadmodule "modules/groupserv/info"; +loadmodule "modules/groupserv/join"; +loadmodule "modules/groupserv/list"; +loadmodule "modules/groupserv/listchans"; +loadmodule "modules/groupserv/register"; +loadmodule "modules/groupserv/regnolimit"; +#loadmodule "modules/groupserv/invite"; +loadmodule "modules/groupserv/set"; +loadmodule "modules/groupserv/set_channel"; +loadmodule "modules/groupserv/set_description"; +loadmodule "modules/groupserv/set_email"; +loadmodule "modules/groupserv/set_groupname"; +loadmodule "modules/groupserv/set_joinflags"; +loadmodule "modules/groupserv/set_open"; +loadmodule "modules/groupserv/set_public"; +loadmodule "modules/groupserv/set_url"; + +/* + * Various modules. + * + * Atheme includes an optional HTTP server that can be used for integration + * with portal software and other useful things. To enable it, load this + * module, and uncomment the httpd { } block towards the bottom of the config. + * + * HTTP Server modules/misc/httpd + */ +loadmodule "modules/misc/httpd"; + +/* XMLRPC server module. + * + * The XML-RPC handler requires modules/misc/httpd to be loaded as it merely + * registers a path handler for XML-RPC. The path used for XML-RPC is /xmlrpc. + * + * XMLRPC handler for the httpd modules/transport/xmlrpc + */ +loadmodule "modules/transport/xmlrpc"; + +/* Extended target entity types. [EXPERIMENTAL] + * + * Atheme can set up special target mapping entities which match multiple + * users in channel access entries. These target mapping entity types are + * defined through the 'exttarget' modules listed below. + * + * Exttarget handling core modules/exttarget/main + * $oper exttarget match type modules/exttarget/oper + * $registered exttarget match type modules/exttarget/registered + * $channel exttarget match type modules/exttarget/channel + * $chanacs exttarget match type modules/exttarget/chanacs + * $server exttarget match type modules/exttarget/server + */ +#loadmodule "modules/exttarget/main"; +#loadmodule "modules/exttarget/oper"; +#loadmodule "modules/exttarget/registered"; +#loadmodule "modules/exttarget/channel"; +#loadmodule "modules/exttarget/chanacs"; +#loadmodule "modules/exttarget/server"; + +/* Proxyscan (DNSBL) modules. + * + * Atheme can also check set DNS Blacklists for matches and respond + * as set. Activate modules here and customize further down under Proxyscan + * section. + */ +#loadmodule "modules/proxyscan/main"; +#loadmodule "modules/proxyscan/dnsbl"; + +/* Other modules. + * + * Put any other modules you want to load on startup here. The path + * is relative to PREFIX or PREFIX/lib/atheme, depending on how Atheme + * was compiled. + */ +#loadmodule "modules/contrib/ns_listlogins"; + +/****************************************************************************** + * SERVICES RUNTIME CONFIGURATION SECTION. * + ******************************************************************************/ + +/* + * If you are using the crypto/pbkdf2v2 module, you may wish to edit this block + * + * It is recommended to either leave the values at the defaults, or experiment + * with them so that it takes approximately 1 second for users to identify. + */ +pbkdf2v2 { + + /* digest + * Valid values are "SHA256" and "SHA512" + * The default is "SHA512" + */ + #digest = "SHA512"; + + /* rounds + * Valid values are 10000 to 5000000 (inclusive) + * The default is 64000 + */ + #rounds = 64000; +}; + +/* The serverinfo{} block defines how we appear on the IRC network. */ +serverinfo { + /* name + * The server name that this program uses on the IRC network. + * This is the name you'll have to use in C:/N:Lines. It must be + * unique on the IRC network and contain at least one dot, but does + * not have to be equal to any DNS name. + */ + name = "services.int"; + + /* desc + * The ``server comment'' we send to the IRC network. + */ + desc = "Atheme IRC Services"; + + /* numeric + * Some protocol drivers (Charybdis, Ratbox2, P10, IRCNet) + * require a server id, also known as a numeric. Please consult your + * ircd's documentation when providing this value. + */ + numeric = "00A"; + + /* (*)recontime + * The number of seconds before we reconnect to the uplink. + */ + recontime = 10; + + /* (*)netname + * The name of your network. + */ + netname = "misconfigured network"; + + /* (*)hidehostsuffix + * P10 +x host hiding gives .. + * If using +x on asuka, this must agree + * with F:HIDDEN_HOST. + */ + hidehostsuffix = "users.misconfigured"; + + /* (*)adminname + * The name of the person running this service. + */ + adminname = "misconfigured admin"; + + /* (*)adminemail + * The email address of the person running this service. + */ + adminemail = "misconfigured@admin.tld"; + + /* (*)registeremail + * The email address that messages should be originated from. + * If this is not set, then "noreply.$adminemail" will be used. + */ + registeremail = "noreply@admin.tld"; + + /* (*)hidden + * If this is enabled, Atheme will indicate to the uplink IRCd + * that it should not be included in /links output. This only works + * on the following IRCds at present: charybdis, ircd-seven, ratbox. + */ + #hidden; + + /* (*)mta + * The full path to your mail transfer agent. + * This is used for email authorization and password retrieval. + * Comment this out to disable sending email. + * Warning: sending email can disclose the IP of your services + * unless you take precautions (not discussed here further). + */ + mta = "/usr/sbin/sendmail"; + + /* (*)loglevel + * Specify the default categories of logging information to record + * in the master Atheme logfile, usually var/atheme.log. + * + * Options include: + * debug, all - meta-keyword for all possible categories + * trace - meta-keyword for a little bit of info + * misc - like trace, but with some more miscellaneous info + * notice - meta-keyword for notice-like information + * ------------------------------------------------------------------------------ + * error - critical errors + * info - miscillaneous log notices + * verbose - A bit more verbose than info, not quite as spammy as debug + * commands - all command use + * admin - administrative command use + * register - account and channel registrations + * set - changes of account or channel settings + * request - user requests (currently only vhosts) + * network - log notices related to network status + * rawdata - log raw data sent and received by services + * wallops - + */ + loglevel = { error; info; admin; network; wallops; }; + + /* (*)maxlogins + * What is the maximum number of sessions allowed to login to one + * username? This reduces potential abuse. It is only checked on login. + */ + maxlogins = 5; + + /* (*)maxusers + * What are the maximum usernames that one email address can register? + * Set to 0 to disable this check (it can be slow currently). + */ + maxusers = 5; + + /* (*)mdlimit + * How many metadata entries can be added to an object? + */ + mdlimit = 30; + + /* (*)emaillimit, emailtime + * The maximum number of emails allowed to be sent in + * that amount of time (seconds). If this is exceeded, + * wallops will be sent, at most one per minute. + */ + emaillimit = 10; + emailtime = 300; + + /* (*)auth + * What type of username registration authorization do you want? + * If "email", Atheme will send a confirmation email to the address to + * ensure it's valid. If registration is not completed within one day, + * the username will expire. If "none", no message will be sent and + * the username will be fully registered. + * Valid values are: email, none. + */ + auth = none; + + /* casemapping + * Specify the casemapping to use. Almost all TSora (and any that follow + * the RFC correctly) ircds will use rfc1459 casemapping. Bahamut, Unreal, + * and other ``Dalnet'' ircds will use ascii casemapping. + * Valid values are: rfc1459, ascii. + */ + casemapping = rfc1459; +}; + +/* uplink{} blocks define connections to IRC servers. + * Multiple may be defined but only one will be used at a time (IRC + * being a tree shaped network). Atheme does not currently link over SSL. + * To link Atheme over ssl, please connect Atheme to a local ircd and have that + * connect to your network over SSL. + */ +uplink "infobob-testing.arpa" { + // The server name of the ircd you're linking to goes above. + + // host + // The hostname to connect to. + host = "charybdis"; + + // vhost + // The source IP to connect from, used on machines with multiple interfaces. + #vhost = "192.0.2.5"; + + // send_password + // The password sent for linking. + send_password = "password-to-charybdis"; + + // receive_password + // The password received for linking. + receive_password = "password-to-atheme"; + + // port + // The port to connect to. + port = 6667; +}; + +/* Services configuration. + * + * Each of these blocks can contain a nick, user, host, real and aliases. + * Several of them also have options specific to the service. + */ + +/* NickServ configuration. + * + * The nickserv {} block contains settings specific to the NickServ modules. + * + * NickServ provides nickname or username registration and authentication + * services. It provides necessary authentication features required for + * Services to operate correctly. You should make sure these settings + * are properly configured for your network. + */ +nickserv { + /* (*)spam + * Have NickServ tell people about how great it and ChanServ are. + */ + spam; + + /* no_nick_ownership + * Enable this to disable nickname ownership (old userserv{}). + * This changes changes "nickname" to "account" in most messages, + * disables GHOST on users not logged in to the same account and + * makes the spam directive ineffective. + * It is suggested that the nick be set to UserServ, login.so + * be loaded instead of identify.so and ghost.so not be loaded. + */ + #no_nick_ownership; + + /* (*)nick + * The nickname we want NickServ to have. + */ + nick = "NickServ"; + + /* (*)user + * The username we want NickServ to have. + */ + user = "NickServ"; + + /* (*)host + * The hostname we want NickServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want NickServ to have. + */ + real = "Nickname Services"; + + /* (*)aliases + * Command aliases for NickServ. + */ + aliases { + "ID" = "IDENTIFY"; + "MYACCESS" = "LISTCHANS"; + }; + + /* (*)access + * This block allows you to modify the access level required to run + * commands. The list of possible accesses are listed in the operclass + * section later in this .conf . Note that you can only set the access + * on an actual command, not an alias. + */ + access { + }; + + /* (*)maxnicks + * If GROUP is loaded, what are the maximum nicknames that one + * username can register? + */ + maxnicks = 5; + + /* (*)expire + * The number of days before inactive registrations are expired. + */ + expire = 0; + + /* (*)enforce_expire + * The number of days of no use after which to ignore enforcement + * settings on nicks. + */ + #enforce_expire = 14; + + /* (*)enforce_delay + * The number of seconds to delay nickchange enforcement settings + * on nicks. + */ + #enforce_delay = 30; + + /* (*)enforce_prefix + * The prefix to use when changing the user's nick on enforcement + */ + #enforce_prefix = "Guest"; + + /* (*)cracklib_dict + * The location and filename prefix of the cracklib dictionaries + * for use with nickserv/cracklib. This must be provided if you are + * going to be using nickserv/cracklib. + */ + #cracklib_dict = "/var/cache/cracklib/cracklib_dict"; + + /* (*)cracklib_warn + * If this option is set and nickserv/cracklib is loaded, nickserv will just + * warn users that their password is insecure, recommend they change it and + * still register the nick. If this option is unset, it will refuse to + * register the nick at all until the user chooses a better password. + */ + #cracklib_warn; + + /* (*)emailexempts + * A list of email addresses that will be exempt from the check of how many + * accounts one user may have. Any email address in this block may register + * an unlimited number of accounts/usernames. + */ + emailexempts { + }; +}; + +/* ChanServ configuration. + * + * The chanserv {} block contains settings specific to the ChanServ modules. + * + * ChanServ provides channel registration services, which allows users to own + * channels. It is not required, but is strongly recommended. + */ +chanserv { + /* (*)nick + * The nickname we want the client to have. + */ + nick = "ChanServ"; + + /* (*)user + * The username we want the client to have. + */ + user = "ChanServ"; + + /* (*)host + * The hostname we want the client to have. + */ + host = "services.int"; + + /* (*)real + * The GECOS of the client. + */ + real = "Channel Services"; + + /* (*)aliases + * Command aliases for ChanServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for ChanServ. + */ + access { + }; + + /* (*)maxchans + * What are the maximum channels that one username can register? + */ + maxchans = 5; + + /* fantasy + * Do you want to enable fantasy commands? This can + * use a lot of CPU up, and will only work if you have + * join_chans (in general) enabled as well. + */ + fantasy; + + /* (*) hide_xop + * Hide the XOP templates from sight. This is useful if you + * want to use templates and not have the XOP templates displayed. + */ + #hide_xop; + + /* (*) templates + * Defines what flags the global templates comprise. + * + * For the special XOP templates: + * These should all be different and not equal to the empty set, + * except that hop may be equal to vop to disable hop. + * Each subsequent level should have more flags (except +VHO). + * For optimal functioning of /cs forcexop, aop should not have + * any of +sRf, hop should not have any of +sRfoOr and vop should + * not have any of +sRfoOrhHt. + * If this is not specified, the values of Atheme 0.3 are used, + * which are generally less intuitive than these. + * Note: changing these leaves the flags of existing channel access + * entries unchanged, thus removing them of the view of /cs xop list. + * Usually the channel founder can use /cs forcexop to update the + * entries to the new levels. + * + * Advice: + * If you want to add a co-founder role, remove the flags permission + * from the SOP role, and define a co-founder role with flags + * permissions. + */ + templates { + vop = "+AV"; + hop = "+AHehitrv"; + aop = "+AOehiortv"; + sop = "+AOaefhiorstv"; + + founder = "+AFORaefhiorstv"; + + /* some examples (which are commented out...) */ + #member = "+Ai"; + #op = "+AOiortv"; + }; + + /* (*) deftemplates + * Defines default templates to set on new channels, as a + * space-separated list of name=+flags pairs. + * Note: at this time no syntax checking is done on this; it + * is your own responsibility to make sure it is correct. + */ + #deftemplates = "MEMBER=+Ai OP=+AOiortv"; + + /* (*) changets + * Change the channel TS to the registration time when someone + * recreates a registered channel, ensuring that they are deopped + * and all their modes are undone. Note that this involves ChanServ + * joining. When the channel was not recreated no deops will be done + * (apart from the SECURE option). + * This also solves the "join-mode" problem where someone recreates + * a registered channel and then sets some modes before they are + * deopped. + * This is currently supported for charybdis, ratbox, bahamut, + * and inspircd 1.1+. For charybdis and ratbox it only fully + * works with TS6, with TS5 bans and last-moment modes will + * still apply. + * (That can also be used to advantage, when first enabling this.) + */ + #changets; + + /* (*) trigger + * This setting allows you to change the trigger prefix for + * ChanServ's in-channel command feature (disableable via chanserv::fantasy). + * If no setting is provided, the default is used, which is "!". + * + * Other settings you could consider trying: ".", "~", "?", "`", "'". + */ + trigger = "!"; + + /* (*)expire + * The number of days before inactive registrations are expired. + */ + expire = 0; + + /* (*)maxchanacs + * The maximum number of entries allowed in a channel's access list + * (both channel ops and akicks), 0 for unlimited. + */ + maxchanacs = 0; + + /* (*)maxfounders + * The maximum number of founders allowed in a channel. + * Note that all founders have the exact same privileges and + * the list of founders is shown in various places. + */ + maxfounders = 4; + + /* (*)founder_flags + * The flags a user will get when they register a new channel. + * This MUST include at least 'F' or it will be ignored. + * If it is not set, Atheme will give the user all channel flags. + */ + #founder_flags = "AFORefiorstv"; + + /* (*)akick_time + * The default expiration time (in minutes) for AKICKs. + * Comment this option out or set to zero for permanent AKICKs + * by default (the old behaviour). + */ + #akick_time = 10; + + /* (*)antiflood_enforce_method + * The enforcement method to use for flood protection by default. + * This may be overridden by channel staff. + * Available options are: quiet, kickban and akill. + */ + antiflood_enforce_method = quiet; +}; + +/* CHANFIX configuration. + * + * The chanfix {} block contains settings specific to the CHANFIX modules. + * + * CHANFIX provides channel recovery services without registration, which + * allows users to maintain control of channels even if ChanServ is not used + * to register them. + */ +chanfix { + /* (*)nick + * The nickname we want the client to have. + */ + nick = "ChanFix"; + + /* (*)user + * The username we want the client to have. + */ + user = "ChanFix"; + + /* (*)host + * The hostname we want the client to have. + */ + host = "services.int"; + + /* (*)real + * The GECOS of the client. + */ + real = "Channel Fixing Service"; + + /* (*)autofix + * Automatically fix channels if they become opless and meet fixing + * criteria. + */ + autofix; +}; + +/* Global noticing configuration. + * + * The global {} block contains settings specific to the Global notice module. + * + * The Global notice module provides the ability to mass-notify a network. + */ +global { + /* (*)nick + * Sets the nick used for sending out a global notice. + */ + nick = "Global"; + + /* (*)user + * Sets the username used for this client. + */ + user = "Global"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Network Announcements"; +}; + +/* InfoServ configuration + * + * The infoserv {} block contains settings specific to the InfoServ module. + * + * The InfoServ modules provides the ability to mass-notify a network and send + * news to users when they connect to the network. + */ +infoserv { + /* (*)nick + * Sets the nick used for InfoServ and sending out informational messages. + */ + nick = "InfoServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "InfoServ"; + + /* (*)host + * The hostname used for this client, + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Information Service"; + + /* (*)logoninfo_count + * The number of InfoServ messages a user will see upon connect. + * If there are more than this number, the user will be able to + * see the rest with /msg infoserv list . + */ + logoninfo_count = 3; +}; + +/* OperServ configuration. + * + * The operserv {} block contains settings specific to the OperServ modules. + * + * OperServ provides essential network management tools for IRC operators + * on the IRC network. + */ +operserv { + /* (*)nick + * The nickname we want the Operator Service to have. + */ + nick = "OperServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "OperServ"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Operator Services"; + + /* (*)aliases + * Command aliases for OperServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for OperServ. + */ + access { + }; +}; + +/* SaslServ configuration. + * + * The saslserv {} block contains settings specific to the SaslServ modules. + * + * SaslServ provides an authentication agent which is compatible with the + * SASL over IRC (SASL/IRC) protocol extension. + */ +saslserv { + /* (*)nick + * The nickname we want SaslServ to have. + */ + nick = "SaslServ"; + + /* (*)user + * The username we want SaslServ to have. + */ + user = "SaslServ"; + + /* (*)host + * The hostname we want SaslServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want SaslServ to have. + */ + real = "SASL Authentication Agent"; + + /* (*)hide_server_names + * Hide server names in the bad_password message. + */ + #hide_server_names; +}; + +/* MemoServ configuration. + * + * The memoserv {} block contains settings specific to the MemoServ modules. + * + * MemoServ provides a note-taking service that you can use to send notes + * to offline users (provided they are registered with Services). + */ +memoserv { + /* (*)nick + * The nickname we want MemoServ to have. + */ + nick = "MemoServ"; + + /* (*)user + * The username we want MemoServ to have. + */ + user = "MemoServ"; + + /* (*)host + * The hostname we want MemoServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want MemoServ to have. + */ + real = "Memo Services"; + + /* (*)aliases + * Command aliases for MemoServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for MemoServ. + */ + access { + }; + + /* (*)maxmemos + * What is the maximum amount of memos a user can have in their inbox? + */ + maxmemos = 30; +}; + +/* GameServ configuration. + * + * The gameserv {} block contains settings specific to the GameServ modules. + * + * GameServ provides various in-channel commands for games. + */ +gameserv { + /* (*)nick + * The nickname we want GameServ to have. + */ + nick = "GameServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "GameServ"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Game Services"; + + /* (*)aliases + * Command aliases for GameServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for GameServ. + */ + access { + }; +}; + +/* RPGServ configuration. + * + * The rpgserv {} block contains settings specific to the RPGServ modules. + * + * RPGServ provides a facility for finding roleplaying channels. + */ +rpgserv { + /* (*)nick + * The nickname we want RPGServ to have. + */ + nick = "RPGServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "RPGServ"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "RPG Finding Services"; + + /* (*)aliases + * Command aliases for RPGServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for RPGServ. + */ + access { + }; +}; + +/* BotServ configuration. + * + * The botserv {} block contains settings specific to the BotServ modules. + * + * BotServ provides virtual channel bots. + */ +botserv { + /* (*)nick + * The nickname we want BotServ to have. + */ + nick = "BotServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "BotServ"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Bot Services"; + + /* (*)min_users + * Minimum number of users a channel must have before a Bot is allowed + * to be assigned to that channel. + */ + min_users = 0; +}; + +/* GroupServ configuration. + * + * The groupserv {} block contains settings specific to the GroupServ modules. + * + * GroupServ provides features for managing a collection of channels at once. + * + */ +groupserv { + /* (*)nick + * The nickname we want GroupServ to have. + */ + nick = "GroupServ"; + + /* (*)user + * The username we want GroupServ to have. + */ + user = "GroupServ"; + + /* (*)host + * The hostname we want GroupServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want GroupServ to have. + */ + real = "Group Management Services"; + + /* (*)aliases + * Command aliases for GroupServ. + */ + aliases { + }; + + /* (*)access + * Command access changes for GroupServ. + */ + access { + }; + + /* (*)maxgroups + * Maximum number of groups one username can be founder of. + */ + maxgroups = 5; + + /* (*)maxgroupacs + * Maximum number of access entries you may have in a group. + */ + maxgroupacs = 100; + + /* (*)enable_open_groups + * Setting this option will allow any group founder to mark + * their group as "anyone can join". + */ + enable_open_groups; + + /* (*)join_flags + * This is the GroupServ flagset that users who JOIN a open + * group will get upon join. Please check the groupserv/flags + * helpfile before changing this option. Valid flagsets (for + * example) would be: "+v" or "+cv". It is not valid to use + * minus flags (such as "-v") here. + */ + join_flags = "+"; +}; + +/* HostServ configuration. + * + * The hostserv {} block contains settings specific to the HostServ modules. + * + * HostServ provides advanced virtual host management. + */ +hostserv { + /* (*)nick + * The nickname we want HostServ to have. + */ + nick = "HostServ"; + + /* (*)user + * Sets the username used for this client. + */ + user = "HostServ"; + + /* (*)host + * The hostname used for this client. + */ + host = "services.int"; + + /* (*)real + * The GECOS (real name) of the client. + */ + real = "Host Management Services"; + + /* (*)request_per_nick + * Whether the request system should work per nick or per account. + * The recommended setting is to leave this disabled, so that + * vhosts work as consistently as possible. + */ + #request_per_nick; + + /* (*)aliases + * Command aliases for HostServ. + */ + aliases { + "APPROVE" = "ACTIVATE"; + "DENY" = "REJECT"; + }; + + /* (*)access + * Command access changes for HostServ. + */ + access { + }; +}; + +/* HelpServ configuration + * + * The helpserv {} block contains settings specific to the HelpServ modules. + * + * HelpServ adds a few different ways for users to request help from network staff. + */ +helpserv { + /* (*)nick + * The nickname we want HelpServ to have. + */ + nick = "HelpServ"; + + /* (*)user + * The username we want HelpServ to have. + */ + user = "HelpServ"; + + /* (*)host + * The hostname we want HelpServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want HelpServ to have. + */ + real = "Help Services"; +}; + +/* StatServ configuration + * + * The statserv {} block contains settings specific to the StatServ modules. + * + * StatServ adds basic stats and split tracking. + */ +statserv { + /* (*)nick + * The nickname we want StatServ to have. + */ + nick = "StatServ"; + + /* (*)user + * The username we want StatServ to have. + */ + user = "StatServ"; + + /* (*)host + * The hostname we want StatServ to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want StatServ to have. + */ + real = "Statistics Services"; +}; + +/* ALIS configuration. + * + * The alis {} block contains settings specific to the ALIS modules. + */ +alis { + /* (*)nick + * The nickname we want ALIS to have. + */ + nick = "ALIS"; + + /* (*)user + * The username we want ALIS to have. + */ + user = "alis"; + + /* (*)host + * The hostname we want ALIS to have. + */ + host = "services.int"; + + /* (*)real + * The realname (gecos) information we want ALIS to have. + */ + real = "Channel Directory"; +}; + +/* HTTP server configuration. + * + * The httpd {} block contains settings specific to the HTTP server module. + * + * The HTTP server in Services is used for serving XMLRPC requests. It can + * also serve static documents and statistics pages. + */ +httpd { + /* host + * The host that the HTTP server will listen on. + * Use 0.0.0.0 if you want to listen on all available hosts. + */ + host = "0.0.0.0"; + + /* host (ipv6) + * If you want, you can have Atheme listen on an IPv6 host too. + * Use :: if you want to listen on all available IPv6 hosts. + */ + #host = "::"; + + /* www_root + * The directory that contains the files that should be served by the httpd. + */ + www_root = "/var/www"; + + /* port + * The port that the HTTP server will listen on. + */ + port = 8080; +}; + +/* LDAP configuration. + * + * The ldap {} block contains settings specific to the LDAP authentication + * module. + */ +ldap { + /* (*)url + * LDAP URL of the server to use. + */ + url = "ldap://127.0.0.1"; + + /* (*)dnformat + * Format string to convert an account name to an LDAP DN. + * Must contain exactly one %s which will be replaced by the account + * name. + * Services will attempt a simple bind with this DN and the given + * password; if this is successful the password is considered correct. + */ + dnformat = "cn=%s,dc=jillestest,dc=com"; +}; + +/****************************************************************************** + * LOGGING SECTION. * + ******************************************************************************/ + +/* + * logfile{} blocks can be used to set up log files other than the master + * logfile used by services, which is controlled by serverinfo::loglevel. + * + * The various logging categories are: + * debug, all - meta-keyword for all possible categories + * trace - meta-keyword for a little bit of info + * misc - like trace, but with some more miscillaneous info + * notice - meta-keyword for notice-like information + * ------------------------------------------------------------------------------ + * error - critical errors + * info - miscillaneous log notices + * verbose - A bit more verbose than info, not quite as spammy as debug + * commands - all command use + * admin - administrative command use + * register - account and channel registrations + * set - changes of account or channel settings + * request - user requests (currently only vhosts) + * network - log notices related to network status + * rawdata - log raw data sent and received by services + * wallops - + * denycmd - security model denials (commands, permissions) + */ + +/* + * This block logs all account and channel registrations and drops, + * and account and channel setting changes to var/account.log. + */ +logfile "var/account.log" { register; set; }; + +/* + * This block logs all command use to var/commands.log. + */ +logfile "var/commands.log" { commands; }; + +/* + * This block logs all security auditing information. + */ +logfile "var/audit.log" { denycmd; }; + +/* + * You can log to IRC channels, and even split it by category, too. + * This entry provides roughly the same functionality as the old snoop + * feature. + */ +logfile "#services" { error; info; admin; request; register; denycmd; }; + +/* + * This block logs to server notices. + */ +logfile "!snotices" { error; info; request; denycmd; }; + +/****************************************************************************** + * GENERAL PARAMETERS CONFIGURATION SECTION. * + ******************************************************************************/ + +/* The general {} block defines general configuration options. */ +general { + /* (*)permissive_mode + * Whether or not security denials should be soft denials instead of + * hard denials. If security denials are soft denials, then they will + * only be logged to the denial log. + */ + #permissive_mode; + + /* (*)helpchan + * Network help channel. Shown to users when they request + * help for a command that doesn't exist. + */ + #helpchan = "#help"; + + /* (*)helpurl + * Network webpage for services help. Shown to users when they + * request help for a command that doesn't exist. + */ + #helpurl = "http://www.stack.nl/~jilles/irc/atheme-help/"; + + /* (*)silent + * If you want to prevent services from sending + * WALLOPS/GLOBOPS about things uncomment this. + * Not recommended. + */ + #silent; + + /* (*)verbose_wallops + * If you want services to send you more information about + * events that are occuring (in particular AKILLs), uncomment the + * directive below. + * + * WARNING! This may result in large amounts of wallops/globops + * floods. + */ + #verbose_wallops; + + /* (*)join_chans + * Should ChanServ be allowed to join registered channels? + * This option is useful for the fantasy command set. + * + * If enabled, you can tell ChanServ to join via SET GUARD ON. + * + * If you use ircu-like ircd (asuka), you must + * leave this enabled, and put guard in default cflags. + * + * For ratbox it is recommended to leave it on and put guard in + * default cflags, in order that ChanServ does not have to join/part + * to do certain things. On the other hand, enabling this increases + * potential for bots fighting with ChanServ. + * + * Regardless of this option, ChanServ will temporarily join + * channels which would otherwise be empty if necessary to enforce + * akick/restricted/close, and to change the TS if changets is + * enabled. + */ + join_chans; + + /* (*)leave_chans + * Do we leave registered channels after everyone else has left? + * Turning this off serves little purpose, except to mark "official" + * network channels by keeping them open, and to preserve the + * topic and +beI lists. + */ + leave_chans; + + /* secure + * Do you want to require the use of /msg @? + * Turning this on helps protect against spoofers, but is disabled + * as most networks do not presently use it. + */ + #secure; + + /* (*)uflags + * The default flags to set for usernames upon registration. + * Valid values are: hold, neverop, noop, hidemail, nomemo, emailmemos, + * enforce, privmsg, private, quietchg and none. + */ + uflags = { hidemail; }; + + /* (*)cflags + * The default flags to set for channels upon registration. + * Valid values are: hold, secure, verbose, verbose_ops, keeptopic, + * topiclock, guard, private, nosync, limitflags, pubacl and none. + */ + cflags = { verbose; guard; }; + + /* (*)raw + * Do you want to allow SRAs to use the RAW and INJECT commands? + * These commands are for debugging. If you don't know how to use them + * then don't enable them. They are not supported. + */ + #raw; + + /* (*)flood_msgs + * Do you want services to detect floods? + * Set to how many messages before a flood is triggered. + * Note that some messages that need a lot of processing count + * as two or four messages. + * If services receives `flood_msgs' within `flood_time' the user will + * trigger the flood protection. + * Setting this to zero disables flood protection. + */ + flood_msgs = 7; + + /* (*)flood_time + * Do you want services to detect floods? + * Set to how long before the counter resets. + * If services receives `flood_msgs' within `flood_time' the user will + * trigger the flood protection. + */ + flood_time = 10; + + /* (*)ratelimit_uses + * After how many uses of a command will users be throttled. + * After `ratelimit_uses' of a command within `ratelimit_period', users + * will not be able to run that ratelimited command until the period is up. + * Comment this, ratelimit_period below or both options out to disable rate limiting. + * Currently used in helpserv/helpme, helpserv/ticket, hostserv/request, + * nickserv/register and chanserv/register. + */ + ratelimit_uses = 5; + + /* (*)ratelimit_period + * After how much time (in seconds) will the ratelimit_uses counter reset. + * After `ratelimit_uses' of a command within `ratelimit_period', users + * will not be able to run that ratelimited command until the period is up. + * Comment this, ratelimit_uses above or both options out to disable rate limiting. + * Currently used in helpserv/helpme, helpserv/ticket, hostserv/request, + * nickserv/register and chanserv/register. + */ + ratelimit_period = 60; + + /* (*)kline_time + * The default expire time for KLINE's in days. + * Setting this to 0 makes all KLINE's permanent. + */ + kline_time = 7; + + /* (*)kline_with_ident + * KLINE user@host instead of *@host. + * Applies to all automatic KLINE's set by services. + */ + #kline_with_ident; + + /* (*)kline_verified_ident + * KLINE *@host if the first character of the ident is ~, + * irrespective of the value of kline_with_ident. + */ + #kline_verified_ident; + + /* (*)clone_time + * This is the default expiry time for CLONE exemptions in minutes. + * Setting this to 0 makes all CLONE exemptions permanent. + */ + clone_time = 0; + + /* commit_interval + * The time between database writes in minutes. + */ + commit_interval = 5; + + /* (*)default_clone_allowed + * The limit after which clones will be KILLed or TKLINEd. + * Used by operserv/clones. + */ + default_clone_allowed = 5; + + /* (*)default_clone_warn + * The limit after which clones will be warned that they may not + * have any more concurrent connections. Should be lower than + * default_clone_allowed . Used by operserv/clones. + */ + default_clone_warn = 4; + + /* (*)clone_identified_increase_limit + * If this option is enabled, the clone limit for a IP/host will + * be increased by 1 per clone that's identified to services. + * This has a limit of double the clone limits above. + */ + clone_identified_increase_limit; + + /* (*)uplink_sendq_limit + * The maximum amount of data that may be queued to be sent + * to the uplink, in bytes. This should be enough to contain + * Atheme's response to the netburst, but smaller than the + * IRCd's sendq limit for servers. + */ + uplink_sendq_limit = 1048576; + + /* (*)language + * Language to use for channel and oper messages and as default + * for users. + */ + language = "en"; + + /* exempts + * This block contains a list of user@host masks. Users matching any + * of these will not be automatically K:lined by services. + */ + exempts { + }; + + /* allow_taint + * By enabling this option, Atheme will run in configurations where + * the upstream will not provide support. By enabling this feature, + * you void any perceived rights to support. + */ + #allow_taint; + + /* (*)immune_level + * This option allows you to customize the operlevel which gets kick + * immunity privileges. + * + * The following flags are available: + * immune - require whatever ircd usermode is needed for kick + * immunity (this is the default); + * admin - require admin privileges for kick immunity + * ircop - require any ircop privileges for kick immunity (umode +o) + */ + immune_level = immune; + + /* show_entity_id + * This makes nick/user & group entity IDs visible to everyone, rather + * than just opers with user:auspex or group:auspex privileges. + */ + show_entity_id; +}; + +proxyscan { + /* Here you can configure the details of your Proxyscan (DNS Blacklist) + * scanner service. + */ + + nick = "Proxyscan"; + user = "dnsbl"; + host = "services.int"; + real = "Proxyscan Service"; + + blacklists { + "dnsbl.dronebl.org"; + "rbl.efnetrbl.org"; + "tor.efnet.org"; + }; + + /* Available dnsbl_action's: + * NONE - Do nothing + * NOTIFY - Notify user that they are listed in a DNSBL and which one + * SNOOP - Report the user to the logchannel or services channel + * KLINE - AKILL the user from the network (default AKILL is 24 hours) + */ + + dnsbl_action = kline; +}; + +/****************************************************************************** + * OPERATOR AND PRIVILEGES CONFIGURATION SECTION. * + ******************************************************************************/ + +/* Operator configuration + * See the PRIVILEGES document for more information. + * NOTE: All changes apply immediately upon rehash. You may need + * to send a signal (killall -HUP atheme-services) to regain control. + */ +/* (*) Operclasses specify groups of services operator privileges */ +/* The "user" operclass specifies privileges all users get. + * This may be empty (default) in which case users get no special privileges. + * If you use the security/cmdperm module, you will need to grant command: privileges + * to every command that you want users to be able to use. + */ +operclass "user" { }; + +/* The "ircop" operclass specifies privileges all IRCops get. + * This may be empty in which case IRCops get no privs. + * At least chan:cmodes, chan:joinstaffonly and general:auspex are suggested. + */ +operclass "ircop" { + privs { + special:ircop; + }; + + privs { + user:auspex; + user:admin; + user:sendpass; + user:vhost; + user:mark; + }; + + privs { + chan:auspex; + chan:admin; + chan:cmodes; + chan:joinstaffonly; + }; + + privs { + general:auspex; + general:helper; + general:viewprivs; + general:flood; + }; + + privs { + operserv:omode; + operserv:akill; + operserv:jupe; + operserv:global; + }; + + privs { + group:auspex; + group:admin; + }; +}; + +operclass "sra" { + /* You can inherit privileges from a lower operclass. */ + extends "ircop"; + + privs { + user:hold; + user:regnolimit; + }; + + privs { + general:metadata; + general:admin; + }; + + privs { + #operserv:massakill; + #operserv:akill-anymask; + operserv:noop; + operserv:grant; + #operserv:override; + }; + + /* needoper + * Only grant privileges to IRC users in this oper class if they + * are opered; other use of privilege (channel succession, XMLRPC, + * etc.) is unaffected by this. + */ + needoper; +}; + + +/* (*) Operator blocks specify accounts with certain privileges + * Oper classes must be defined before they are used in operator blocks. + */ +operator "god" { + /* operclass */ + operclass = "sra"; + /* password + * Normally, the user needs to identify/log in using the account's + * password, and may need to be an IRCop (see operclass::needoper + * above). If you consider this not secure enough, you can + * specify an additional password here, which the user must enter + * using the OperServ IDENTIFY command, before the privileges can + * be used. + * The password must be encrypted if a crypto module is in use. + */ + #password = "$1$3gJMO9by$0G60YE6GqmuHVH3AnFPor1"; +}; + +/****************************************************************************** + * INCLUDE CONFIGURATION SECTION. * + ******************************************************************************/ + +/* You may also specify other files for inclusion. + * For example: + * + * include "etc/sras.conf"; + */ diff --git a/functional-tests/apps/atheme/initial.db b/functional-tests/apps/atheme/initial.db new file mode 100644 index 0000000..110fb8a --- /dev/null +++ b/functional-tests/apps/atheme/initial.db @@ -0,0 +1,286 @@ +GRVER 1 +DBV 12 +MDEP transport/rfc1459 +MDEP protocol/base36uid +MDEP protocol/ts6-generic +MDEP protocol/charybdis +MDEP backend/corestorage +MDEP backend/opensex +MDEP crypto/posix +MDEP nickserv/main +MDEP nickserv/badmail +MDEP nickserv/drop +MDEP nickserv/ghost +MDEP nickserv/group +MDEP nickserv/help +MDEP nickserv/list +MDEP nickserv/hold +MDEP nickserv/identify +MDEP nickserv/info +MDEP nickserv/listmail +MDEP nickserv/logout +MDEP nickserv/mark +MDEP nickserv/freeze +MDEP nickserv/listchans +MDEP groupserv/main +MDEP nickserv/listgroups +MDEP nickserv/register +MDEP nickserv/regnolimit +MDEP nickserv/resetpass +MDEP nickserv/restrict +MDEP nickserv/return +MDEP nickserv/setpass +MDEP nickserv/sendpass_user +MDEP nickserv/set_core +MDEP nickserv/set_accountname +MDEP nickserv/set_email +MDEP nickserv/set_emailmemos +MDEP nickserv/set_hidemail +MDEP nickserv/set_language +MDEP nickserv/set_nevergroup +MDEP nickserv/set_neverop +MDEP nickserv/set_nogreet +MDEP nickserv/set_nomemo +MDEP nickserv/set_noop +MDEP nickserv/set_password +MDEP nickserv/set_property +MDEP nickserv/set_pubkey +MDEP nickserv/set_quietchg +MDEP nickserv/status +MDEP nickserv/taxonomy +MDEP nickserv/vacation +MDEP nickserv/verify +MDEP nickserv/vhost +MDEP chanserv/main +MDEP chanserv/access +MDEP chanserv/akick +MDEP chanserv/ban +MDEP chanserv/clone +MDEP chanserv/close +MDEP chanserv/clear +MDEP chanserv/clear_akicks +MDEP chanserv/clear_bans +MDEP chanserv/clear_flags +MDEP chanserv/clear_users +MDEP chanserv/count +MDEP chanserv/drop +MDEP chanserv/flags +MDEP chanserv/ftransfer +MDEP chanserv/getkey +MDEP chanserv/help +MDEP chanserv/hold +MDEP chanserv/info +MDEP chanserv/invite +MDEP chanserv/kick +MDEP chanserv/list +MDEP chanserv/mark +MDEP chanserv/op +MDEP chanserv/recover +MDEP chanserv/register +MDEP chanserv/set_core +MDEP chanserv/set_email +MDEP chanserv/set_entrymsg +MDEP chanserv/set_fantasy +MDEP chanserv/set_guard +MDEP chanserv/set_keeptopic +MDEP chanserv/set_mlock +MDEP chanserv/set_prefix +MDEP chanserv/set_property +MDEP chanserv/set_restricted +MDEP chanserv/set_secure +MDEP chanserv/set_topiclock +MDEP chanserv/set_url +MDEP chanserv/set_verbose +MDEP chanserv/status +MDEP chanserv/sync +MDEP chanserv/taxonomy +MDEP chanserv/template +MDEP chanserv/topic +MDEP chanserv/voice +MDEP chanserv/why +MDEP chanserv/quiet +MDEP chanserv/antiflood +MDEP operserv/main +MDEP operserv/akill +MDEP operserv/compare +MDEP operserv/help +MDEP operserv/identify +MDEP operserv/ignore +MDEP operserv/info +MDEP operserv/jupe +MDEP operserv/mode +MDEP operserv/modinspect +MDEP operserv/modlist +MDEP operserv/modload +MDEP operserv/modunload +MDEP operserv/modreload +MDEP operserv/noop +MDEP operserv/readonly +MDEP operserv/rehash +MDEP operserv/restart +MDEP operserv/rmatch +MDEP operserv/rnc +MDEP operserv/rwatch +MDEP operserv/set +MDEP operserv/sgline +MDEP operserv/shutdown +MDEP operserv/specs +MDEP operserv/sqline +MDEP operserv/update +MDEP operserv/uptime +MDEP memoserv/main +MDEP memoserv/help +MDEP memoserv/send +MDEP memoserv/sendops +MDEP memoserv/sendgroup +MDEP memoserv/list +MDEP memoserv/read +MDEP memoserv/forward +MDEP memoserv/delete +MDEP memoserv/ignore +MDEP global/main +MDEP infoserv/main +MDEP saslserv/main +MDEP saslserv/plain +MDEP saslserv/authcookie +MDEP statserv/main +MDEP statserv/netsplit +MDEP statserv/server +MDEP groupserv/acsnolimit +MDEP groupserv/drop +MDEP groupserv/fdrop +MDEP groupserv/fflags +MDEP groupserv/flags +MDEP groupserv/help +MDEP groupserv/info +MDEP groupserv/join +MDEP groupserv/list +MDEP groupserv/listchans +MDEP groupserv/register +MDEP groupserv/regnolimit +MDEP groupserv/set +MDEP groupserv/set_channel +MDEP groupserv/set_description +MDEP groupserv/set_email +MDEP groupserv/set_groupname +MDEP groupserv/set_joinflags +MDEP groupserv/set_open +MDEP groupserv/set_public +MDEP groupserv/set_url +MDEP misc/httpd +MDEP transport/xmlrpc +LUID AAAAAAAAY +CF +AFORVbefiorstv +MU AAAAAAAAX agonzales $1$xulglh$ZomoaxN/IXItJyKWHlFUN. agonzales@infobobtest.local 1581630951 1581630951 +sCb default +MDU agonzales private:host:actual ~someone@172.18.0.1 +MDU agonzales private:host:vhost ~someone@172.18.0.1 +MN agonzales agonzales 1581630951 1581630951 +MU AAAAAAAAK amcdowell $1$hvyoay$xffxvIpAsIqsAZ7nT3bhF/ amcdowell@infobobtest.local 1581630760 1581630760 +sCb default +MDU amcdowell private:host:actual ~someone@172.18.0.1 +MDU amcdowell private:host:vhost ~someone@172.18.0.1 +MN amcdowell amcdowell 1581630760 1581630760 +MU AAAAAAAAU bbutler $1$ssrdqe$VLWh4DVTFnxQtCBLuidUg0 bbutler@infobobtest.local 1581630896 1581630896 +sCb default +MDU bbutler private:host:actual ~someone@172.18.0.1 +MDU bbutler private:host:vhost ~someone@172.18.0.1 +MN bbutler bbutler 1581630896 1581630896 +MU AAAAAAAAY chanop $1$hlervy$NbunLpWKM6cPqhPq96OB8/ chanop@infobobtest.local 1581852053 1581852262 +sCb default +MDU chanop private:host:actual ~someone@172.19.0.1 +MDU chanop private:host:vhost ~someone@172.19.0.1 +MN chanop chanop 1581852053 1581852262 +MU AAAAAAAAN cody67 $1$pdgdml$HMMNcYsOE9emvhbg/eCvX0 cody67@infobobtest.local 1581630816 1581630816 +sCb default +MDU cody67 private:host:actual ~someone@172.18.0.1 +MDU cody67 private:host:vhost ~someone@172.18.0.1 +MN cody67 cody67 1581630816 1581630816 +MU AAAAAAAAM daniel18 $1$jckynz$kPYeHIhdizJ/d/qGAuhlZ0 daniel18@infobobtest.local 1581630778 1581630778 +sCb default +MDU daniel18 private:host:actual ~someone@172.18.0.1 +MDU daniel18 private:host:vhost ~someone@172.18.0.1 +MN daniel18 daniel18 1581630778 1581630778 +MU AAAAAAAAB god $1$twenok$w.GFjS5sd8Dwzy80Rvbao0 god@infobobtest.local 1581288499 1581852223 +sC default +MDU god private:host:actual ~someone@172.19.0.1 +MDU god private:host:vhost ~someone@172.19.0.1 +MN god god 1581288499 1581852223 +MU AAAAAAAAP imcdaniel $1$peyits$XaMHLYcBoabN/b2gBY321. imcdaniel@infobobtest.local 1581630826 1581630826 +sCb default +MDU imcdaniel private:host:actual ~someone@172.18.0.1 +MDU imcdaniel private:host:vhost ~someone@172.18.0.1 +MN imcdaniel imcdaniel 1581630826 1581630826 +MU AAAAAAAAC infotest $1$rbpzdt$T0oFBzoYfOlDVGHXuqPDj1 infotest@infobobtest.local 1581450901 1581631617 +sC default +MDU infotest private:host:actual ~infotest@172.18.0.1 +MDU infotest private:host:vhost ~infotest@172.18.0.1 +MN infotest infotest 1581450901 1581631617 +MU AAAAAAAAI james74 $1$ktjlmz$mHZ6CFxkH9hw.KAbnBBKK0 james74@infobobtest.local 1581630747 1581630747 +sCb default +MDU james74 private:host:actual ~someone@172.18.0.1 +MDU james74 private:host:vhost ~someone@172.18.0.1 +MN james74 james74 1581630747 1581630747 +MU AAAAAAAAF kevin69 $1$bswhpp$s/YfF9kU7OiRqr78.8D6k0 kevin69@infobobtest.local 1581630711 1581630711 +sCb default +MDU kevin69 private:host:actual ~someone@172.18.0.1 +MDU kevin69 private:host:vhost ~someone@172.18.0.1 +MN kevin69 kevin69 1581630711 1581630711 +MU AAAAAAAAE marissa05 $1$lzywhc$7UCDWhMSg4cxXVPEu8R2./ marissa05@infobobtest.local 1581630263 1581630308 +sCb default +MDU marissa05 private:host:actual ~someone@172.18.0.1 +MDU marissa05 private:host:vhost ~someone@172.18.0.1 +MN marissa05 marissa05 1581630263 1581630271 +MU AAAAAAAAQ mary19 $1$pjmhzz$1zjcxK9DbBzbsnxLWTp7K. mary19@infobobtest.local 1581630831 1581630831 +sCb default +MDU mary19 private:host:actual ~someone@172.18.0.1 +MDU mary19 private:host:vhost ~someone@172.18.0.1 +MN mary19 mary19 1581630831 1581630831 +MU AAAAAAAAH michael81 $1$onuqru$ibaKn.sQC1P2GLbbdUXQ40 michael81@infobobtest.local 1581630723 1581630723 +sCb default +MDU michael81 private:host:actual ~someone@172.18.0.1 +MDU michael81 private:host:vhost ~someone@172.18.0.1 +MN michael81 michael81 1581630723 1581630723 +MU AAAAAAAAD monitor $1$ezykto$baCsHKVXc7b9oxIWxJo/1/ monitor@infobobtest.local 1581455969 1581631627 +sCb default +MDU monitor private:host:actual ~monitor@172.18.0.1 +MDU monitor private:host:vhost ~monitor@172.18.0.1 +MN monitor monitor 1581455969 1581631627 +MU AAAAAAAAO paul51 $1$gtemvq$lNdViZcW3LRzb7BjkNF4E1 paul51@infobobtest.local 1581630821 1581630821 +sCb default +MDU paul51 private:host:actual ~someone@172.18.0.1 +MDU paul51 private:host:vhost ~someone@172.18.0.1 +MN paul51 paul51 1581630821 1581630821 +MU AAAAAAAAV paula67 $1$gqdfrr$ClOE9aKCYOAqxPhIBzj/W0 paula67@infobobtest.local 1581630901 1581630901 +sCb default +MDU paula67 private:host:actual ~someone@172.18.0.1 +MDU paula67 private:host:vhost ~someone@172.18.0.1 +MN paula67 paula67 1581630901 1581630901 +MU AAAAAAAAS pward $1$oyemci$gAH48r/TmSORJO7lYLMJA. pward@infobobtest.local 1581630886 1581630886 +sCb default +MDU pward private:host:actual ~someone@172.18.0.1 +MDU pward private:host:vhost ~someone@172.18.0.1 +MN pward pward 1581630886 1581630886 +MU AAAAAAAAT rmiller $1$ihnfkj$815tlVcj26a1jKxlxTC5X. rmiller@infobobtest.local 1581630891 1581630891 +sCb default +MDU rmiller private:host:actual ~someone@172.18.0.1 +MDU rmiller private:host:vhost ~someone@172.18.0.1 +MN rmiller rmiller 1581630891 1581630891 +MU AAAAAAAAW tateroger $1$docefx$7KJC9oG9rWLvSzQMvIyJy. tateroger@infobobtest.local 1581630906 1581630906 +sCb default +MDU tateroger private:host:actual ~someone@172.18.0.1 +MDU tateroger private:host:vhost ~someone@172.18.0.1 +MN tateroger tateroger 1581630906 1581630906 +MU AAAAAAAAR tinasmith $1$clvmng$wEuiqmrZzbtIx/TF3B2Ev. tinasmith@infobobtest.local 1581630837 1581630837 +sCb default +MDU tinasmith private:host:actual ~someone@172.18.0.1 +MDU tinasmith private:host:vhost ~someone@172.18.0.1 +MN tinasmith tinasmith 1581630837 1581630837 +MU AAAAAAAAG tking $1$gmqrwo$xaKo4kfSb1o9bCYrJAqSx1 tking@infobobtest.local 1581630719 1581630719 +sCb default +MDU tking private:host:actual ~someone@172.18.0.1 +MDU tking private:host:vhost ~someone@172.18.0.1 +MN tking tking 1581630719 1581630719 +MU AAAAAAAAJ wendybell $1$azkzfg$yeNM.I6poh6ddzIIKrsMD. wendybell@infobobtest.local 1581630751 1581630751 +sCb default +MDU wendybell private:host:actual ~someone@172.18.0.1 +MDU wendybell private:host:vhost ~someone@172.18.0.1 +MN wendybell wendybell 1581630751 1581630751 +MU AAAAAAAAL zchase $1$rgjkdm$4A7P7PzPdzZJ2YeDPTxpr/ zchase@infobobtest.local 1581630773 1581631607 +sCb default +MDU zchase private:host:actual ~someone@172.18.0.1 +MDU zchase private:host:vhost ~someone@172.18.0.1 +MN zchase zchase 1581630773 1581630773 +GDBV 4 +GFA +fscmviA +MC ##offtopic 1581450607 1581631609 + 272 6 0 +CA ##offtopic god +AFORfiorstv 1581450607 god +CA ##offtopic infotest +Aeiortv 1581451119 god +CA ##offtopic chanop +Aeiortv 1581852188 god +MDC ##offtopic disable_fantasy on +MDC ##offtopic private:channelts 1581631604 +MC #project 1581450377 1581852242 + 8192 0 0 +CA #project god +AFORfiorstv 1581450377 god +CA #project infotest +Aeiortv 1581451092 god +CA #project chanop +Aeiortv 1581852180 god +MDC #project disable_fantasy on +MDC #project private:channelts 1581852242 +KID 0 +XID 0 +QID 0 diff --git a/functional-tests/apps/charybdis/Dockerfile b/functional-tests/apps/charybdis/Dockerfile new file mode 100644 index 0000000..f7a9b8b --- /dev/null +++ b/functional-tests/apps/charybdis/Dockerfile @@ -0,0 +1,13 @@ +FROM debian:buster + +RUN apt-get update \ + && apt-get install -y \ + charybdis \ + netbase +# netbase is needed for /etc/services and /etc/protocols + +COPY ircd.conf /etc/charybdis/ircd.conf +EXPOSE 6667 +USER charybdis +VOLUME /var/log/charybdis +ENTRYPOINT ["charybdis", "-foreground"] diff --git a/functional-tests/apps/charybdis/ircd.conf b/functional-tests/apps/charybdis/ircd.conf new file mode 100644 index 0000000..54768c4 --- /dev/null +++ b/functional-tests/apps/charybdis/ircd.conf @@ -0,0 +1,665 @@ +/* doc/ircd.conf.example - brief example configuration file + * + * Copyright (C) 2000-2002 Hybrid Development Team + * Copyright (C) 2002-2005 ircd-ratbox development team + * Copyright (C) 2005-2006 charybdis development team + * + * See reference.conf for more information. + */ + +/* Extensions */ +#loadmodule "extensions/chm_nonotice"; +#loadmodule "extensions/chm_operonly_compat"; +#loadmodule "extensions/chm_quietunreg_compat"; +#loadmodule "extensions/chm_sslonly_compat"; +#loadmodule "extensions/chm_operpeace"; +#loadmodule "extensions/createauthonly"; +loadmodule "extensions/extb_account"; +#loadmodule "extensions/extb_canjoin"; +#loadmodule "extensions/extb_channel"; +#loadmodule "extensions/extb_combi"; +#loadmodule "extensions/extb_extgecos"; +#loadmodule "extensions/extb_hostmask"; +#loadmodule "extensions/extb_oper"; +#loadmodule "extensions/extb_realname"; +#loadmodule "extensions/extb_server"; +#loadmodule "extensions/extb_ssl"; +#loadmodule "extensions/extb_usermode"; +#loadmodule "extensions/hurt"; +#loadmodule "extensions/m_extendchans"; +#loadmodule "extensions/m_findforwards"; +#loadmodule "extensions/m_identify"; +#loadmodule "extensions/m_locops"; +#loadmodule "extensions/no_oper_invis"; +#loadmodule "extensions/sno_farconnect"; +#loadmodule "extensions/sno_globalkline"; +#loadmodule "extensions/sno_globalnickchange"; +#loadmodule "extensions/sno_globaloper"; +#loadmodule "extensions/sno_whois"; +#loadmodule "extensions/override"; +#loadmodule "extensions/no_kill_services"; + +/* + * IP cloaking extensions: use ip_cloaking_4.0 + * if you're linking 3.2 and later, otherwise use + * ip_cloaking, for compatibility with older 3.x + * releases. + */ + +#loadmodule "extensions/ip_cloaking_4.0"; +#loadmodule "extensions/ip_cloaking"; + +serverinfo { + name = "infobob-testing.arpa"; + sid = "42X"; + description = "charybdis test server"; + network_name = "StaticBox"; + + /* On multi-homed hosts you may need the following. These define + * the addresses we connect from to other servers. */ + /* for IPv4 */ + #vhost = "192.0.2.6"; + /* for IPv6 */ + #vhost6 = "2001:db8:2::6"; + + /* ssl_cert: certificate (and optionally key) for our ssl server */ + ssl_cert = "etc/ssl.pem"; + + /* ssl_private_key: our ssl private key (if not contained in ssl_cert file) */ + #ssl_private_key = "etc/ssl.key"; + + /* ssl_dh_params: DH parameters, generate with openssl dhparam -out dh.pem 2048 + * In general, the DH parameters size should be the same as your key's size. + * However it has been reported that some clients have broken TLS implementations which may + * choke on keysizes larger than 2048-bit, so we would recommend using 2048-bit DH parameters + * for now if your keys are larger than 2048-bit. + * + * If you do not provide parameters, some TLS backends will fail on DHE- ciphers, + * and some will succeed but use weak, common DH groups! */ + ssl_dh_params = "etc/dh.pem"; + + /* ssld_count: number of ssld processes you want to start, if you + * have a really busy server, using N-1 where N is the number of + * cpu/cpu cores you have might be useful. A number greater than one + * can also be useful in case of bugs in ssld and because ssld needs + * two file descriptors per SSL connection. + */ + ssld_count = 1; + + /* default max clients: the default maximum number of clients + * allowed to connect. This can be changed once ircd has started by + * issuing: + * /quote set maxclients + */ + default_max_clients = 1024; + + /* nicklen: enforced nickname length (for this server only; must not + * be longer than the maximum length set while building). + */ + nicklen = 30; +}; + +admin { + name = "Lazy admin (lazya)"; + description = "StaticBox client server"; + email = "nobody@127.0.0.1"; +}; + +log { + fname_userlog = "/var/log/charybdis/userlog"; + #fname_fuserlog = "/var/log/charybdis/fuserlog"; + fname_operlog = "/var/log/charybdis/operlog"; + #fname_foperlog = "/var/log/charybdis/foperlog"; + fname_serverlog = "/var/log/charybdis/serverlog"; + #fname_klinelog = "/var/log/charybdis/klinelog"; + fname_killlog = "/var/log/charybdis/killlog"; + fname_operspylog = "/var/log/charybdis/operspylog"; + #fname_ioerrorlog = "/var/log/charybdis/ioerror"; +}; + +/* class {} blocks MUST be specified before anything that uses them. That + * means they must be defined before auth {} and before connect {}. + */ +class "users" { + ping_time = 2 minutes; + number_per_ident = 10; + number_per_ip = 10; + number_per_ip_global = 50; + cidr_ipv4_bitlen = 24; + cidr_ipv6_bitlen = 64; + number_per_cidr = 200; + max_number = 3000; + sendq = 400 kbytes; +}; + +class "opers" { + ping_time = 5 minutes; + number_per_ip = 10; + max_number = 1000; + sendq = 1 megabyte; +}; + +class "server" { + ping_time = 5 minutes; + connectfreq = 5 minutes; + max_number = 1; + sendq = 4 megabytes; +}; + +listen { + /* defer_accept: wait for clients to send IRC handshake data before + * accepting them. if you intend to use software which depends on the + * server replying first, such as BOPM, you should disable this feature. + * otherwise, you probably want to leave it on. + */ + defer_accept = yes; + + /* If you want to listen on a specific IP only, specify host. + * host definitions apply only to the following port line. + */ + #host = "192.0.2.6"; + port = 5000, 6665 .. 6669; + sslport = 6697; + + /* Listen on IPv6 (if you used host= above). */ + #host = "2001:db8:2::6"; + #port = 5000, 6665 .. 6669; + #sslport = 6697; + + /* wsock: listeners defined with this option enabled will be websocket listeners, + * and will not accept normal clients. + */ + wsock = yes; + sslport = 9999; +}; + +/* auth {}: allow users to connect to the ircd (OLD I:) + * auth {} blocks MUST be specified in order of precedence. The first one + * that matches a user will be used. So place spoofs first, then specials, + * then general access, then restricted. + */ +auth { + /* user: the user@host allowed to connect. Multiple IPv4/IPv6 user + * lines are permitted per auth block. This is matched against the + * hostname and IP address (using :: shortening for IPv6 and + * prepending a 0 if it starts with a colon) and can also use CIDR + * masks. + */ + user = "*god@*"; + + /* password: an optional password that is required to use this block. + * By default this is not encrypted, specify the flag "encrypted" in + * flags = ...; below if it is. + */ + password = "letmein"; + + /* spoof: fake the users user@host to be be this. You may either + * specify a host or a user@host to spoof to. This is free-form, + * just do everyone a favour and dont abuse it. (OLD I: = flag) + */ + spoof = "I.still.hate.packets"; + + /* Possible flags in auth: + * + * encrypted | password is encrypted with mkpasswd + * spoof_notice | give a notice when spoofing hosts + * exceed_limit (old > flag) | allow user to exceed class user limits + * kline_exempt (old ^ flag) | exempt this user from k/g/xlines, + * | dnsbls, and proxies + * proxy_exempt | exempt this user from proxies + * dnsbl_exempt | exempt this user from dnsbls + * spambot_exempt | exempt this user from spambot checks + * shide_exempt | exempt this user from serverhiding + * jupe_exempt | exempt this user from generating + * warnings joining juped channels + * resv_exempt | exempt this user from resvs + * flood_exempt | exempt this user from flood limits + * USE WITH CAUTION. + * no_tilde (old - flag) | don't prefix ~ to username if no ident + * need_ident (old + flag) | require ident for user in this class + * need_ssl | require SSL/TLS for user in this class + * need_sasl | require SASL id for user in this class + */ + flags = kline_exempt, exceed_limit; + + /* class: the class the user is placed in */ + class = "opers"; +}; + +auth { + user = "*@*"; + class = "users"; +}; + +/* privset {} blocks MUST be specified before anything that uses them. That + * means they must be defined before operator {}. + */ +privset "local_op" { + privs = oper:local_kill, oper:operwall; +}; + +privset "server_bot" { + extends = "local_op"; + privs = oper:kline, oper:remoteban, snomask:nick_changes; +}; + +privset "global_op" { + extends = "local_op"; + privs = oper:global_kill, oper:routing, oper:kline, oper:unkline, oper:xline, + oper:resv, oper:mass_notice, oper:remoteban; +}; + +privset "admin" { + extends = "global_op"; + privs = oper:admin, oper:die, oper:rehash, oper:spy, oper:grant; +}; + +operator "god" { + /* name: the name of the oper must go above */ + + /* user: the user@host required for this operator. CIDR *is* + * supported now. auth{} spoofs work here, other spoofs do not. + * multiple user="" lines are supported. + */ + user = "*@*"; + + /* password: the password required to oper. Unless ~encrypted is + * contained in flags = ...; this will need to be encrypted using + * mkpasswd, MD5 is supported + */ + password = "letmein"; + + /* rsa key: the public key for this oper when using Challenge. + * A password should not be defined when this is used, see + * doc/challenge.txt for more information. + */ + #rsa_public_key_file = "/usr/local/ircd/etc/oper.pub"; + + /* umodes: the specific umodes this oper gets when they oper. + * If this is specified an oper will not be given oper_umodes + * These are described above oper_only_umodes in general {}; + */ + #umodes = locops, servnotice, operwall, wallop; + + /* fingerprint: if specified, the oper's client certificate + * fingerprint will be checked against the specified fingerprint + * below. + */ + #fingerprint = "c77106576abf7f9f90cca0f63874a60f2e40a64b"; + + /* snomask: specific server notice mask on oper up. + * If this is specified an oper will not be given oper_snomask. + */ + snomask = "+Zbfkrsuy"; + + /* flags: misc options for the operator. You may prefix an option + * with ~ to disable it, e.g. ~encrypted. + * + * Default flags are encrypted. + * + * Available options: + * + * encrypted: the password above is encrypted [DEFAULT] + * need_ssl: must be using SSL/TLS to oper up + */ + flags = ~encrypted; + + /* privset: privileges set to grant */ + privset = "admin"; +}; + + +/* connect {}: controls servers we connect to (OLD C:, N:, H:, L:) */ +connect "services.int" { + /* the name must go above */ + + /* host: the host or IP to connect to. If a hostname is used it + * must match the reverse dns of the server. + */ + host = "atheme"; + + /* vhost: the host or IP to bind to for this connection. If this + * is not specified, the default vhost (in serverinfo {}) is used. + */ + #vhost = "192.0.2.131"; + + /* passwords: the passwords we send (OLD C:) and accept (OLD N:). + * The remote server will have these passwords reversed. + */ + send_password = "password-to-atheme"; + accept_password = "password-to-charybdis"; + + /* fingerprint: if flags = ssl is specified, the server's + * certificate fingerprint will be checked against the fingerprint + * specified below. required if using flags = ssl. + */ + #fingerprint = "c77106576abf7f9f90cca0f63874a60f2e40a64b"; + + /* port: the port to connect to this server on */ + port = 6666; + + /* hub mask: the mask of servers that this server may hub. Multiple + * entries are permitted + */ + hub_mask = "*"; + + /* leaf mask: the mask of servers this server may not hub. Multiple + * entries are permitted. Useful for forbidding EU -> US -> EU routes. + */ + #leaf_mask = "*.uk"; + + /* class: the class this server is in */ + class = "server"; + + /* flags: controls special options for this server + * encrypted - marks the accept_password as being crypt()'d + * autoconn - automatically connect to this server + * compressed - compress traffic via ziplinks + * topicburst - burst topics between servers + * ssl - ssl/tls encrypted server connections + * no-export - marks the link as a no-export link (not exported to other + * links) + */ + flags = compressed, topicburst; +}; + +service { + name = "services.int"; +}; + +cluster { + name = "*"; + flags = kline, tkline, unkline, xline, txline, unxline, resv, tresv, unresv; +}; + +shared { + oper = "*@*", "*"; + flags = all, rehash; +}; + +/* exempt {}: IPs that are exempt from Dlines and rejectcache. (OLD d:) */ +exempt { + ip = "127.0.0.1"; +}; + +channel { + use_invex = yes; + use_except = yes; + use_forward = yes; + use_knock = yes; + knock_delay = 5 minutes; + knock_delay_channel = 1 minute; + max_chans_per_user = 15; + max_chans_per_user_large = 60; + max_bans = 100; + max_bans_large = 500; + default_split_user_count = 0; + default_split_server_count = 0; + no_create_on_split = no; + no_join_on_split = no; + burst_topicwho = yes; + kick_on_split_riding = no; + only_ascii_channels = no; + resv_forcepart = yes; + channel_target_change = yes; + disable_local_channels = no; + autochanmodes = "+nt"; + displayed_usercount = 3; + strip_topic_colors = no; +}; + +serverhide { + flatten_links = yes; + links_delay = 5 minutes; + hidden = no; + disable_hidden = no; +}; + +/* These are the blacklist settings. + * You can have multiple combinations of host and rejection reasons. + * They are used in pairs of one host/rejection reason. + * + * These settings should be adequate for most networks. + * + * Word to the wise: Do not use blacklists like SPEWS for blocking IRC + * connections. + * + * As of charybdis 2.2, you can do some keyword substitution on the rejection + * reason. The available keyword substitutions are: + * + * ${ip} - the user's IP + * ${host} - the user's canonical hostname + * ${dnsbl-host} - the dnsbl hostname the lookup was done against + * ${nick} - the user's nickname + * ${network-name} - the name of the network + * + * As of charybdis 3.4, a type parameter is supported, which specifies the + * address families the blacklist supports. IPv4 and IPv6 are supported. + * IPv4 is currently the default as few blacklists support IPv6 operation + * as of this writing. + * + * As of charybdis 3.5, a matches parameter is allowed; if omitted, any result + * is considered a match. If included, a comma-separated list of *quoted* + * strings is allowed to match queries. They may be of the format "0" to "255" + * to match the final octet (e.g. 127.0.0.1) or "127.x.y.z" to explicitly match + * an A record. The blacklist is only applied if it matches anything in the + * list. You may freely mix full IP's and final octets. + * + * Consult your blacklist provider for the meaning of these parameters; they + * are usually used to denote different ban types. + */ +blacklist { + host = "rbl.efnetrbl.org"; + type = ipv4; + reject_reason = "${nick}, your IP (${ip}) is listed in EFnet's RBL. For assistance, see http://efnetrbl.org/?i=${ip}"; + + /* Example of a blacklist that supports both IPv4 and IPv6 and using matches */ +# host = "foobl.blacklist.invalid"; +# type = ipv4, ipv6; +# matches = "4", "6", "127.0.0.10"; +# reject_reason = "${nick}, your IP (${ip}) is listed in ${dnsbl-host} for some reason. In order to protect ${network-name} from abuse, we are not allowing connections listed in ${dnsbl-host} to connect"; +}; + +/* These are the OPM settings. + * This is similar to the functionality provided by BOPM. It will scan incoming + * connections for open proxies by connecting to clients and attempting several + * different open proxy handshakes. If they connect back to us (via a dedicated + * listening port), and send back the data we send them, they are considered + * an open proxy. For politeness reasons (users may be confused by the incoming + * connection attempts if they are logging incoming connections), the user is + * notified upon connect if they are being scanned. + * + * WARNING: + * These settings are considered experimental. Only the most common proxy types + * are checked for (Charybdis is immune from POST and GET proxies). If you are + * not comfortable with experimental code, do not use this feature. + */ +#opm { + /* IPv4 address to listen on. This must be a publicly facing IP address + * to be effective. + * If omitted, it defaults to serverinfo::vhost. + */ + #listen_ipv4 = "127.0.0.1"; + + /* IPv4 port to listen on. + * This should not be the same as any existing listeners. + */ + #port_ipv4 = 32000; + + /* IPv6 address to listen on. This must be a publicly facing IP address + * to be effective. + * If omitted, it defaults to serverinfo::vhost6. + */ + #listen_ipv6 = "::1"; + + /* IPv6 port to listen on. + * This should not be the same as any existing listeners. + */ + #port_ipv6 = 32000; + + /* You can also set the listen_port directive which will set both the + * IPv4 and IPv6 ports at once. + */ + #listen_port = 32000; + + /* This sets the timeout in seconds before ending open proxy scans. + * Values less than 1 or greater than 60 are ignored. + * It is advisable to keep it as short as feasible, so clients do not + * get held up by excessively long scan times. + */ + #timeout = 5; + + /* These are the ports to scan for SOCKS4 proxies on. They may overlap + * with other scan types. Sensible defaults are given below. + */ + #socks4_ports = 1080, 10800, 443, 80, 8080, 8000; + + /* These are the ports to scan for SOCKS5 proxies on. They may overlap + * with other scan types. Sensible defaults are given below. + */ + #socks5_ports = 1080, 10800, 443, 80, 8080, 8000; + + /* These are the ports to scan for HTTP connect proxies on (plaintext). + * They may overlap with other scan types. Sensible defaults are given + * below. + */ + #httpconnect_ports = 80, 8080, 8000; + + /* These are the ports to scan for HTTPS CONNECT proxies on (SSL). + * They may overlap with other scan types. Sensible defaults are given + * below. + */ + #httpsconnect_ports = 443, 4443; +#}; + +alias "NickServ" { + target = "NickServ"; +}; + +alias "ChanServ" { + target = "ChanServ"; +}; + +alias "OperServ" { + target = "OperServ"; +}; + +alias "MemoServ" { + target = "MemoServ"; +}; + +alias "NS" { + target = "NickServ"; +}; + +alias "CS" { + target = "ChanServ"; +}; + +alias "OS" { + target = "OperServ"; +}; + +alias "MS" { + target = "MemoServ"; +}; + +general { + hide_error_messages = opers; + hide_spoof_ips = yes; + + /* + * default_umodes: umodes to enable on connect. + * If you have enabled the new ip_cloaking_4.0 module, and you want + * to make use of it, add +x to this option, i.e.: + * default_umodes = "+ix"; + * + * If you have enabled the old ip_cloaking module, and you want + * to make use of it, add +h to this option, i.e.: + * default_umodes = "+ih"; + */ + default_umodes = "+i"; + + default_operstring = "is an IRC Operator"; + default_adminstring = "is a Server Administrator"; + servicestring = "is a Network Service"; + + /* + * Nick of the network's SASL agent. Used to check whether services are here, + * SASL credentials are only sent to its server. Needs to be a service. + * + * Defaults to SaslServ if unspecified. + */ + sasl_service = "SaslServ"; + disable_fake_channels = no; + tkline_expire_notices = no; + default_floodcount = 10; + failed_oper_notice = yes; + dots_in_ident = 2; + min_nonwildcard = 4; + min_nonwildcard_simple = 3; + max_accept = 100; + max_monitor = 100; + anti_nick_flood = yes; + max_nick_time = 20 seconds; + max_nick_changes = 5; + anti_spam_exit_message_time = 5 minutes; + ts_warn_delta = 30 seconds; + ts_max_delta = 5 minutes; + client_exit = yes; + collision_fnc = yes; + resv_fnc = yes; + global_snotices = yes; + dline_with_reason = yes; + kline_delay = 0 seconds; + kline_with_reason = yes; + kline_reason = "K-Lined"; + identify_service = "NickServ@services.int"; + identify_command = "IDENTIFY"; + non_redundant_klines = yes; + warn_no_nline = yes; + use_propagated_bans = yes; + stats_e_disabled = no; + stats_c_oper_only = no; + stats_h_oper_only = no; + stats_y_oper_only = no; + stats_o_oper_only = yes; + stats_P_oper_only = no; + stats_i_oper_only = masked; + stats_k_oper_only = masked; + map_oper_only = no; + operspy_admin_only = no; + operspy_dont_care_user_info = no; + caller_id_wait = 1 minute; + pace_wait_simple = 1 second; + pace_wait = 10 seconds; + short_motd = no; + ping_cookie = no; + connect_timeout = 30 seconds; + default_ident_timeout = 5; + #disable_auth = no; + disable_auth = yes; + no_oper_flood = yes; + max_targets = 4; + client_flood_max_lines = 20; + use_whois_actually = no; + oper_only_umodes = operwall, locops, servnotice; + oper_umodes = locops, servnotice, operwall, wallop; + oper_snomask = "+s"; + burst_away = yes; + nick_delay = 0 seconds; # 15 minutes if you want to enable this + reject_ban_time = 1 minute; + reject_after_count = 3; + reject_duration = 5 minutes; + throttle_duration = 5; + throttle_count = 64; + max_ratelimit_tokens = 30; + away_interval = 30; + certfp_method = spki_sha256; + hide_opers_in_whois = no; +}; + +modules { + path = "modules"; + path = "modules/autoload"; +}; diff --git a/functional-tests/behavior.md b/functional-tests/behavior.md new file mode 100644 index 0000000..be5fcc8 --- /dev/null +++ b/functional-tests/behavior.md @@ -0,0 +1,139 @@ +Infobob Behavioral Notes +======================== + +Mostly for my reference during the maintenance / porting work, but a lot of +this should be a useful starting point for writing actual documentation. + +Connecting +---------- + +Once the connection is established, login is performed by sending in order +PASS (if configured), NICK, USER. This is handled by the base protocol class +(twisted.words.irc.IRCClient). NickServ identification is then performed if +configured. + +Periodic tasks are started for: +- Server ping (this can be removed, heartbeat was added in twisted 11.1's + IRCClient implementation) +- Checking and unsetting expired bans +- Checking for pastebin reachability + + +Event Responses +--------------- + +When the bot... + +- completes login or nickserv identification, it joins the channels + configured as "autojoin". + +- is invited to a channel, it attempts to join it. + +- is kicked from a channel, it attempts to rejoin it. + +- joins a channel that has `anti_redirect` set in the bot's configuration, + it prevents other join event responses, leaves the channel, and 5 seconds + later attempts to join the channel specified by the `anti_redirect` + configuration value. For example, if the configuration has + `"channels": { "#python-unregistered": { "anti_redirect": "#python" } }`, + if the bot joins `#python-unregistered`, it will part, wait 5 seconds, and + attempt to join `#python`. + +- joins a channel that has `has_ops` set to true in the bot's configuration, + it requests the channel's bans and quiets, and updates its database to + add bans/quiets that either don't exist or show as expired, with a message + "ban pulled from banlist on ". + + Note: Quiets are handled with the non-standard RPL_QUIETLIST (728) and + RPL_ENDOFQUIETLIST (729) that freenode's servers implement. + +- joins a channel, it requests the channel's users, and updates its database + of users and channel members. + +- sees a ban or quiet set, or unset, it updates its database to reflect this. + + +Ban Management +-------------- + +In this section, "ban" is either an actual ban (`+b`) or a quiet (`+q`). + +For channels configured with `have_ops`, infobob maintains a list of bans with +associated expiration times. When a ban is set, the bot records this with an +expiration timestamp (default is 8 hours after when infobob saw the ban). + +Infobob tries to keep this list up to date by listening for bans when bans are +set or unset, and it also reviews the list periodically, unsetting the +channel's ban for those that have expired. + +When infobob sees a ban being set, it PMs the op who set the ban, potentially +with some questions: + +- If the ban mask matches a few users, the bot asks for disambiguation. + The op can choose to select a specific nick, and infobob will unset the + triggering ban, and set a nick-specific ban in its place. If the op + rejects this, or doesn't reply for 20 minutes, the bot performs no + disambiguation. + +- If the ban mask is not an "account" (e.g. `$a:disruptiveUser`, see + https://freenode.net/kb/answer/extbans), and matches a single user, the bot + asks if the op wants to change the ban to an account ban. If yes, or if the + op does not reply for 20 minutes, the bot will unset the original ban and + set an account ban. + +The complexity of the updateBan method is... excessive. There are some other +possibilities that appear to depend on how many users are matched by the mask, +if it's a extban mask, etc. It will take some hard looking to figure it out. + +In any event, once the conversation has completed, infobob reports what extra +operations (if any) it performs, and sends a URL for the web UI to the op, +which they can use to add notes and change the expiry. + + +Utilities +--------- + +These are all configurable per-channel (on or off), using an ACL-like array in +the channel config object under the key `commands`. + + +**repaste** feature + +If infobob sees a URL from a pastebin deemed "bad", it will attempt to rehost +it on a "good" pastebin, and post in channel e.g. `$URL (repasted for $NICK)`. +The URLs involved are cached in memory, and the bot will only post the rehosted +URL again if enough time has passed since the last occurance. + + +**lol** feature + +When a user says "lol" or something similar, the bot admonishes them. +Irritating, not used any more. + + +**redent** command + +Usage: `infobob: redent TARGETNICK CODE` + +Lexes CODE as Python code, reformats it with indentation, uploads it to a +pastebin, and replies in-channel to TARGETNICK, e.g. +`alice, https://bpaste.net/ABCD`. + +The lexer recognizes the first line of "compound statements" (`def`, `class`, +loops, conditionals, `try`, etc), inserts a line break, and increases the +indentation level. + +Semicolons are interpreted as line break instructions. A single `;` preserves +the current indentation level, two (`;;`) reduces the indentation level by one, +three (`;;;`) reduces the indentation level by two, and so on. + + +**stop** command + +Usage: `infobob: stop` + +Replies to sending user with "Okay!" and quits. + +This should either be removed (since there's no recovery), or adjusted so it +makes the bot just stop performing actions in that channel (for a certain +period?) until resumed by another command. It isn't really used. diff --git a/functional-tests/clients.py b/functional-tests/clients.py new file mode 100644 index 0000000..2c31b51 --- /dev/null +++ b/functional-tests/clients.py @@ -0,0 +1,652 @@ +from __future__ import annotations +import datetime +import collections +import contextlib +import enum +from typing import ( + Awaitable, + Callable, + Deque, + Generic, + Hashable, + MutableMapping, + Set, + MutableSet, + Optional, + Sequence, + TypeVar, + Union, +) + +from twisted.words.protocols import irc +from twisted.internet import endpoints +from twisted.internet import defer +from twisted import logger +import attr + +import utils + + +async def joinFakeUser( + endpoint, + nickname: str, + password: str, + autojoin: Sequence[str] = (), +) -> Awaitable[ComposedIRCController]: + controller = await ComposedIRCController.connect( + endpoint, nickname, password) + if autojoin: + # NickServ might not have had time to give us +i, give it a moment. + await utils.sleep(0.5) + await defer.gatherResults([ + controller.joinChannel(chan) for chan in autojoin + ]) + return controller + + +@attr.s +class ComposedIRCController: + # TODO: Change methods to coroutines? Deferred isn't generic (is that + # even possible to have?), Awaitable[ComposedIRCController] + # is a nicer type hint, and overall async/await is nicer. + _proto: _ComposedIRCClient = attr.ib() + _actions: _ActionsWrangler[_IRCClientAction, str] = attr.ib() + + @classmethod + @defer.inlineCallbacks + def connect( + cls, + endpoint, + nickname: str, + password: str, + signOnTimeout: int = 1, + ) -> defer.Deferred: + from twisted.internet import reactor + proto = yield endpoints.connectProtocol( + endpoint, _ComposedIRCClient(nickname, password) + ).addTimeout(signOnTimeout, reactor) + ctrl = cls(proto=proto, actions=proto.state.actions) + yield proto.signOnComplete.addTimeout(signOnTimeout, reactor) + return ctrl + + @property + def nickname(self): + return self._proto.nickname + + def disconnect(self) -> defer.Deferred: + self._proto.transport.loseConnection() + return self._proto.disconnected + + def channel(self, channelName: str) -> ChannelController: + chanstate = self._proto.state.channels.get(channelName) + return ChannelController( + name=channelName, proto=self._proto, state=chanstate) + + def joinChannel(self, channelName: str, timeout: int= 1) -> defer.Deferred: + from twisted.internet import reactor + self._proto.join(channelName) + dfd = self._actions.begin(_IRCClientAction.joinChannel, channelName) + return dfd.addTimeout(timeout, reactor) + + def say(self, channelName: str, message: str): + self._proto.say(channelName, message) + + def getPrivateMessages( + self, + sender: Optional[str] = None, + ) -> Sequence[Message]: + messages = self._proto.state.getPrivateMessages() + if sender is None: + return messages + return [msg for msg in messages if sender == msg.sender] + + +@attr.s +class ChannelController: + name: str = attr.ib() + _proto: _ComposedIRCClient = attr.ib() + _state: _ChannelState = attr.ib() + + def say(self, message: str): + self._proto.say(self.name, message) + + def msg(self, nickname: str, message: str): + self._proto.msg(nickname, message) + + def becomeOperator(self, timeout: int = 1) -> defer.Deferred: + if self._isOpped: + return defer.succeed(None) + from twisted.internet import reactor + self.msg('ChanServ', f'op {self.name}') + dfd = self._state.actions.begin(_ChannelAction.becomeOperator, None) + return dfd.addTimeout(timeout, reactor) + + def retrieveBans(self, timeout: int = 1) -> defer.Deferred: + from twisted.internet import reactor + self._proto.mode(self.name, True, 'b') + dfd = self._state.actions.begin(_ChannelAction.receiveBanlist, None) + def cbGetBans(_): + return self._state.getCurrentBans() + return dfd.addTimeout(timeout, reactor).addCallback(cbGetBans) + + def setBan(self, mask: str) -> None: + if not self._isOpped: + raise NotAnOperator + self._proto.mode(self.name, True, 'b', mask=mask) + + def unsetBan(self, mask: str) -> None: + if not self._isOpped: + raise NotAnOperator + self._proto.mode(self.name, False, 'b', mask=mask) + + @property + def _isOpped(self) -> bool: + return self._proto.nickname in self._state.operators + + def getOperators(self) -> Set[str]: + return frozenset(self._state.operators) + + def getMembers(self) -> Set[str]: + return self._state.getMembers() + + def getCurrentBans(self) -> Sequence[Ban]: + return self._state.getCurrentBans() + + def getUnsetBans(self) -> Sequence[Ban]: + return self._state.getUnsetBans() + + def getMessages( + self, + sender: Optional[str] = None, + when: Optional[Union[int, datetime.datetime]] = None, + ) -> Sequence[Message]: + """ + Get messages still in the queue, optionally filtered by + sender or age. + + Note: only a limited number of messages are stored. + + If ``sender`` is provided, only messages from that nickname + will be returned. + + If ``when`` is provided, only younger messages will be + returned. ``when`` can be either: + - a naive :class:`datetime.datetime` instance in UTC, the + earliest time, or + - an integer, the maximum age in seconds relative to when + this method is called. + """ + if isinstance(when, int): + when = _now() - datetime.timedelta(seconds=when) + + messages = self._state.getMessages() + if sender is not None: + messages = [msg for msg in messages if msg.sender == sender] + if when is not None: + messages = [msg for msg in messages if msg.when >= when] + + return messages + + +class NotAnOperator(Exception): + pass + + +@attr.s +class _ChannelCollection: + _channels: MutableMapping[str, _ChannelState] = attr.ib(factory=dict) + + _log = logger.Logger() + + def add(self, channelName: str) -> None: + assert channelName not in self._channels, \ + f'channel {channelName} already exists' + self._channels[channelName] = _ChannelState(name=channelName) + + def remove(self, channelName: str) -> None: + del self._channels[channelName] + + def get(self, channelName: str) -> _ChannelState: + return self._channels[channelName] + + def userRenamed(self, oldnick: str, newnick: str) -> None: + for chan in self._channelsWithUser(oldnick): + chan.removeNick(oldnick) + chan.addNick(newnick) + + def userQuit(self, nickname: str) -> None: + for chan in self._channelsWithUser(nickname): + chan.removeNick(nickname) + + def _channelsWithUser(self, nickname: str) -> Sequence[_ChannelState]: + return [chan for chan in self._channels.values() if nickname in chan] + + +_MAX_MESSAGES = 50 + + +class _ChannelAction(enum.Enum): + becomeOperator = attr.ib() + receiveBanlist = attr.ib() + + def __repr__(self) -> str: + return f'<{self.name}>' + + +@attr.s +class _ChannelState: + # TODO: Need to eventually have some concept of "events" to cover + # joins, parts, quits, kicks, bans (set and unset), and + # nick changes. + name: str = attr.ib() + actions: _ActionsWrangler[_ChannelAction, None] = attr.ib(init=False) + operators: Set[str] = attr.ib(init=False, factory=set) + _messages: Deque[Message] = attr.ib( + init=False, + repr=False, + factory=lambda: collections.deque([], _MAX_MESSAGES), + ) + _members: MutableSet[str] = attr.ib(init=False, repr=False, factory=set) + # mask -> Ban + _bans: MutableMapping[str, Ban] = attr.ib( + init=False, repr=False, factory=dict) + _unsetbans: MutableMapping[str, Ban] = attr.ib( + init=False, repr=False, factory=dict) + + def __attrs_post_init__(self): + self.actions = _ActionsWrangler(f'channel {self.name}') + + def __contains__(self, nickname: str) -> None: + return nickname in self._members + + def getMembers(self) -> Set[str]: + return frozenset(self._members) + + def addNick(self, nickname: str) -> None: + self._members.add(nickname) + + def removeNick(self, nickname: str) -> None: + with contextlib.suppress(KeyError): + self._members.remove(nickname) + + def addBan(self, mask: str, setter: str) -> None: + ban = Ban(mask=mask, setBy=setter) + self._bans[ban.mask] = ban + + def removeBan(self, mask: str, unsetter: str) -> None: + ban = self._bans.pop(mask, None) + if ban is None: + return + ban = attr.evolve(ban, unsetBy=unsetter) + self._unsetbans[ban.mask] = ban + + def getCurrentBans(self) -> Sequence[Ban]: + return list(self._bans.values()) + + def getUnsetBans(self) -> Sequence[Ban]: + return list(self._unsetbans.values()) + + def addMessage(self, nickname: str, message: str) -> None: + self.addNick(nickname) + msg = Message.now(sender=nickname, text=message) + self._messages.append(msg) + + def getMessages(self) -> Sequence[Message]: + return list(self._messages) + + +def _now() -> datetime.datetime: + return datetime.datetime.utcnow() + + +@attr.s +class Message: + sender: str = attr.ib() + text: str = attr.ib() + when: datetime.datetime = attr.ib() + + @classmethod + def now(cls, *, sender: str, text: str) -> Message: + return cls(sender=sender, text=text, when=_now()) + + +@attr.s +class Ban: + mask: str = attr.ib() + setBy: str = attr.ib() + unsetBy: Optional[str] = attr.ib(default=None) + + +_A = TypeVar('_A', bound=enum.Enum) +_C = TypeVar('_C', bound=Hashable) + +class _ActionsWrangler(Generic[_A, _C]): + def __init__(self, name: str): + self.name = name + self._inflight = {} + + def begin(self, actionType: _A, context: _C) -> defer.Deferred: + key = (actionType, context) + if key in self._inflight: + raise _ActionAlreadyInFlight.build(self.name, key) + dfd = self._inflight[key] = defer.Deferred() + return dfd + + def complete(self, actionType: _A, context: _C) -> None: + key = (actionType, context) + dfd = self._inflight.pop(key, None) + if dfd is None: + raise _NoActionInFlight.build(self.name, key) + dfd.callback(key) + + def error(self, actionType: _A, context: _C, err: Exception) -> None: + key = (actionType, context) + dfd = self._inflight.pop(key, None) + if dfd is None: + raise _NoActionInFlight.build(self.name, key) + dfd.errback(err) + + def __repr__(self): + return ( + f'<{type(self).__name__}(name={self.name}),' + f' {len(self._inflight)} outstanding>' + ) + + +class _ActionAlreadyInFlight(Exception): + @classmethod + def build(cls, name, key): + return cls(f'{name} for {key!r} already in flight') + + +class _NoActionInFlight(Exception): + @classmethod + def build(cls, name, key): + return cls(f'No {name} in flight for {key!r}') + + +class _IRCClientAction(enum.Enum): + joinChannel = enum.auto() + + def __repr__(self) -> str: + return f'<{self.name}>' + + +@attr.s +class _IRCClientState: + actions: _ActionsWrangler[_IRCClientAction, str] = attr.ib( + factory=lambda: _ActionsWrangler('_IRCClientAction')) + channels: _ChannelCollection = attr.ib(factory=_ChannelCollection) + _privmsgs: Deque[Message] = attr.ib( + init=False, + repr=False, + factory=lambda: collections.deque([], _MAX_MESSAGES), + ) + + def addPrivateMessage(self, nickname: str, message: str) -> None: + msg = Message.now(sender=nickname, text=message) + self._privmsgs.append(msg) + + def getPrivateMessages(self) -> Sequence[Message]: + return list(self._privmsgs) + + +class FailedToJoin(Exception): + pass + + +def _joinErrorMethod( + errorName: str +) -> Callable[[_ComposedIRCClient, str, Sequence[str]], None]: + assert errorName.upper() == errorName and errorName.startswith('ERR_') + methodName = f'irc_{errorName}' + + def method(self, prefix: str, params: Sequence[str]) -> None: # pylint: disable=unused-argument + channel, *rest = params + self._log.warn( # pylint: disable=protected-access + 'Failed to join {channel!r}: {code} {params}', + channel=channel, code=errorName, params=rest, + ) + err = FailedToJoin(errorName, channel, rest) + self.state.actions.error(_IRCClientAction.joinChannel, channel, err) + + method.__name__ = methodName + # TODO: Uh, what about __qualname__? + return method + + +def _prefixNicknameThenForward(event): + # XXX: Ugly. I think. + log_format = '(nick:{log_source.nickname}) ' + event['log_format'] + tweaked = {**event, 'log_format': log_format} + logger.globalLogPublisher(tweaked) + + +class _ComposedIRCClient(irc.IRCClient): # pylint: disable=abstract-method + """ + Goal: provide separations of concerns by dispatching events to + other objects, instead of stuffing even more in the already-bloated + IRCClient. + """ + _log = logger.Logger(observer=_prefixNicknameThenForward) + + def __init__(self, nickname: str, password: str): + self.nickname = nickname + self.password = password + self.state = _IRCClientState() + self.signOnComplete = defer.Deferred() + self.disconnected = defer.Deferred() + + def connectionMade(self) -> None: + self._log.info('Connection established') + super().connectionMade() + + def signedOn(self) -> None: + self._log.info('Sign-on complete') + self.signOnComplete.callback(None) + + def connectionLost(self, reason): + try: + super().connectionLost(reason) + finally: + self.disconnected.callback(None) + + def privmsg(self, user: str, channel: str, message: str) -> None: + sender = user.split('!', 1)[0] + if channel == self.nickname: + self._log.info( + 'privmsg from {sender}: {message!r}', + sender=sender, message=message, + ) + self.state.addPrivateMessage(sender, message) + else: + self._log.info( + 'message in {channel} from {sender}: {message!r}', + channel=channel, sender=sender, message=message, + ) + self.state.channels.get(channel).addMessage(sender, message) + + def joined(self, channel: str) -> None: + self._log.info('I joined channel {channel}', channel=channel) + self.state.channels.add(channel) + self.state.actions.complete(_IRCClientAction.joinChannel, channel) + + def left(self, channel: str) -> None: + self._log.info('I left {channel}', channel=channel) + self.state.channels.remove(channel) + + def modeChanged( + self, + user: str, + channel: str, + set: bool, + modes: Sequence[str], + args: Sequence[str], + ) -> None: + modePfx = '-+'[set] + if channel == self.nickname: + # Server-level user mode change, ignore for now. + self._log.info( + 'Mode change [{pfx}{modes}] for user {target}', + pfx=modePfx, modes=modes, target=self.nickname, + ) + return + chan = self.state.channels.get(channel) + for mode, arg in zip(modes, args): + self._log.info( + 'Mode change for {target}: [{pfx}{mode}{maybearg}] by {user}', + target=channel, pfx=modePfx, mode=mode, user=user, + maybearg='' if arg is None else f' {arg}', + ) + if mode == 'o': + if set: + chan.operators.add(arg) + else: + with contextlib.suppress(KeyError): + chan.operators.remove(arg) + if arg == self.nickname: + with contextlib.suppress(_NoActionInFlight): + chan.actions.complete( + _ChannelAction.becomeOperator, None) + if arg == self.nickname: + # Channel user mode change affecting me. + continue + if mode == 'b': + setterMask = user + banMask = arg + if set: + chan.addBan(mask=banMask, setter=setterMask) + else: + chan.removeBan(mask=banMask, unsetter=setterMask) + + def kickedFrom(self, channel: str, kicker: str, message: str) -> None: + self._log.info( + 'I was kicked from {channel} by {kicker}: {message}', + channel=channel, kicker=kicker, message=message, + ) + self.state.channels.remove(channel) + + def userJoined(self, user: str, channel: str) -> None: + self._log.info( + 'User {user} joined {channel}', + user=user, channel=channel, + ) + self.state.channels.get(channel).addNick(user) + + def userLeft(self, user: str, channel: str) -> None: + self._log.info( + 'User {user} left {channel}', + user=user, channel=channel, + ) + self.state.channels.get(channel).removeNick(user) + + def userQuit(self, user: str, quitMessage: str) -> None: + self._log.info( + 'User {user} quit: {message!r}', + user=user, message=quitMessage, + ) + self.state.channels.userQuit(user) + + def userKicked( + self, kickee: str, channel: str, kicker: str, message: str + ) -> None: + self._log.info( + 'User {kickee} was kicked from {channel} by {kicker}: {message!r}', + kickee=kickee, channel=channel, kicker=kicker, message=message, + ) + self.state.channels.get(channel).removeNick(kickee) + + def userRenamed(self, oldname: str, newname: str) -> None: + self._log.info( + 'User {oldname} is now known as {newname}', + oldname=oldname, newname=newname, + ) + self.state.channels.userRenamed(oldname, newname) + + ### Low-level protocol events + def irc_unknown(self, prefix, command, params): + self._log.warn( + "received command we aren't prepared to handle: " + "{pfx} {cmd} {pms}", + cmd=command, pfx=prefix, pms=params, + ) + + def lineReceived(self, line): + self._log.debug('lineReceived({line!r})', line=line) + super().lineReceived(line) + + def sendLine(self, line): + self._log.debug('sendLine({line!r})', line=line) + super().sendLine(line) + + ### JOIN replies: + # The server itself replies with a JOIN, this is handled by twisted: + # it calls either `joined` or `userJoined`, depending. + # RPL_TOPIC is handled by twisted: it calls `topicUpdated`. + + # These error replies aren't sent for anything but JOIN: + irc_ERR_BADCHANNELKEY = _joinErrorMethod('ERR_BADCHANNELKEY') + irc_ERR_BANNEDFROMCHAN = _joinErrorMethod('ERR_BANNEDFROMCHAN') + irc_ERR_CHANNELISFULL = _joinErrorMethod('ERR_CHANNELISFULL') + irc_ERR_INVITEONLYCHAN = _joinErrorMethod('ERR_INVITEONLYCHAN') + irc_ERR_TOOMANYCHANNELS = _joinErrorMethod('ERR_TOOMANYCHANNELS') + + ### NAMES + # Ignore until otherwise necessary to handle, to avoid noisy logging + # from `irc_unknown`. + def irc_RPL_NAMREPLY(self, prefix, params): pass + def irc_RPL_ENDOFNAMES(self, prefix, params): pass + + ### + def irc_RPL_BANLIST(self, prefix, params): + _, channelName, banMask, setterMask, when = params + chan = self.state.channels.get(channelName) + chan.addBan(mask=banMask, setter=setterMask) + + def irc_RPL_ENDOFBANLIST(self, prefix, params): + channelName = params[1] + chan = self.state.channels.get(channelName) + with contextlib.suppress(_NoActionInFlight): + chan.actions.complete(_ChannelAction.receiveBanlist, None) + + ### Ignore generic info replies + def irc_RPL_LUSERUNKNOWN(self, prefix, params): pass + def irc_RPL_STATSDLINE(self, prefix, params): pass + def irc_RPL_LOCALUSERS(self, prefix, params): pass + def irc_RPL_GLOBALUSERS(self, prefix, params): pass + + # XXX: Maybe try to handle these more ambiguous ones? + # There doesn't appear to be a nice way to correlate one to its cause, + # without some IRCv3 stuff, but we really shouldn't receive any of them + # unless we do something wrong. If we store recently-sent commands, we + # could maybe guess a little easier, but it's almost certainly not worth + # the effort. + # ERR_BADCHANMASK - to: JOIN or KICK + # ERR_NOSUCHCHANNEL - to: JOIN, PART, or KICK + # ERR_TOOMANYTARGETS - to: JOIN or PRIVMSG + # ERR_UNAVAILRESOURCE - to: JOIN or NICK + # ERR_TOOMANYMATCHES - to: NAMES or LIST + # XXX: These could just be fatal, maybe. + # ERR_NEEDMOREPARAMS - to: numerous commands + # ERR_NOSUCHSERVER - to: numerous commands + + +def _add_numerics() -> None: + """ + Update the IRC numerics registry in + :mod:`twisted.words.protocols.irc`. + """ + numeric_addendum = dict( + RPL_WHOISACCOUNT='330', + RPL_QUIETLIST='728', + RPL_ENDOFQUIETLIST='729', + # 250 is "reserved": https://tools.ietf.org/html/rfc2812#section-5.3 + RPL_STATSDLINE='250', + RPL_LOCALUSERS='265', # aka RPL_CURRENT_LOCAL + RPL_GLOBALUSERS='266', # aka RPL_CURRENT_GLOBAL + ) + for name, numeric in numeric_addendum.items(): + irc.numeric_to_symbolic[numeric] = name + irc.symbolic_to_numeric[name] = numeric + +_add_numerics() diff --git a/functional-tests/config.py b/functional-tests/config.py new file mode 100644 index 0000000..ae2b3ea --- /dev/null +++ b/functional-tests/config.py @@ -0,0 +1,111 @@ +import os +import pathlib +from typing import TypeVar, Callable + +import attr + + +HERE = pathlib.Path(__name__).parent.resolve() + +SCHEMA_PATH = HERE.parent.joinpath('db.schema') + +@attr.s +class IRCCredentials: + nickname: str = attr.ib() + password: str = attr.ib() + +# Don't change these unless you're prepared to do a lot of work to +# make the dockerized ircd and services match. +# Registered channels: +PROJECT_CHAN = '#project' +OFFTOPIC_CHAN = '##offtopic' +ALL_CHANS = (PROJECT_CHAN, OFFTOPIC_CHAN) +# Static credentials for nickserv-registered accounts: +INFOTEST = IRCCredentials('infotest', 'infotestpass') +MONITOR = IRCCredentials('monitor', 'monitorpass') +CHANOP = IRCCredentials('chanop', 'chanoppass') +GENERICS = tuple(IRCCredentials(*cred) for cred in [ + ('agonzales', 'agonzalespass'), + ('amcdowell', 'amcdowellpass'), + ('bbutler', 'bbutlerpass'), + ('cody67', 'cody67pass'), + ('daniel18', 'daniel18pass'), + ('imcdaniel', 'imcdanielpass'), + ('james74', 'james74pass'), + ('kevin69', 'kevin69pass'), + ('marissa05', 'marissa05pass'), + ('mary19', 'mary19pass'), + ('michael81', 'michael81pass'), + ('paul51', 'paul51pass'), + ('paula67', 'paula67pass'), + ('pward', 'pwardpass'), + ('rmiller', 'rmillerpass'), + ('tateroger', 'taterogerpass'), + ('tinasmith', 'tinasmithpass'), + ('tking', 'tkingpass'), + ('wendybell', 'wendybellpass'), + ('zchase', 'zchasepass') +]) +ALL_USERS = (INFOTEST, MONITOR, CHANOP, *GENERICS) + + +def _passthrough(o: str) -> str: + return o + +_T = TypeVar('_T') + + +def _maybeEnv( + suffix: str, + default: _T, + normalize: Callable[[str], _T] = _passthrough, +) -> _T: + key = f'INFOBOB_FUNCTEST_{suffix}' + fromEnv = os.environ.get(key) + if fromEnv is None: + return default + return normalize(fromEnv) + + +# Required env var +INFOBOB_PYTHON = pathlib.Path(os.environ['INFOBOB_PYTHON']).resolve() + +# Configurable defaults, change by setting as enviroment variables prefixed +# with `INFOBOB_FUNCTEST_`. +WEBUI_PORT = _maybeEnv('WEBUI_PORT', 8888, int) + +IRCD_HOST = _maybeEnv('IRCD_HOST', 'localhost') +IRCD_PORT = _maybeEnv('IRCD_PORT', 6667, int) +SERVICES_XMLRPC_URL = _maybeEnv( + 'SERVICES_XMLRPC_URL', 'http://localhost:8080/xmlrpc' +) + + +def buildConfig(channelsconf, autojoin, dbpath=None): + conf = { + 'irc': { + 'server': IRCD_HOST, + 'port': IRCD_PORT, + 'ssl': False, + 'nickname': INFOTEST.nickname, + 'password': INFOTEST.password, + 'nickserv_pw': None, + 'autojoin': autojoin, + }, + 'channels': { + 'defaults': { + 'commands': [ + ['allow', 'all'], + ], + }, + **channelsconf, + }, + 'web': { + 'port': WEBUI_PORT, + 'url': f'http://localhost:{WEBUI_PORT}', + }, + 'misc': {'manhole': {'socket': None}}, + } + if dbpath: + conf['database'] = {'sqlite': {'db_file': str(dbpath)}} + return conf diff --git a/functional-tests/conftest.py b/functional-tests/conftest.py new file mode 100644 index 0000000..ffc9db2 --- /dev/null +++ b/functional-tests/conftest.py @@ -0,0 +1,130 @@ +import sys + +import pytest +import pytest_twisted as pytest_tw +from twisted.internet import endpoints +from twisted.internet import defer +from twisted import logger + +from config import ( + INFOBOB_PYTHON, + buildConfig, + IRCD_HOST, + IRCD_PORT, + MONITOR, + CHANOP, + ALL_CHANS, +) +import clients +from runner import InfobobRunner + + +@pytest.fixture(scope='session', autouse=True) +def fixture_start_logging(): + """ + Start up twisted.logger machinery. + """ + stderrObserver = logger.textFileLogObserver(sys.stderr) + levelPredicate = logger.LogLevelFilterPredicate( + defaultLogLevel=logger.LogLevel.info) + filterer = logger.FilteringLogObserver(stderrObserver, [levelPredicate]) + observers = [filterer] + logger.globalLogBeginner.beginLoggingTo( + observers, redirectStandardIO=False) + + +@pytest.fixture(name='start_infobob') +def fixture_start_infobob(tmp_path): + called = False + controller = None + + def start_infobob(channelsconf=None, autojoin=None) -> defer.Deferred: + nonlocal called + nonlocal controller + + if channelsconf is None: + channelsconf = {cname: {'have_ops': True} for cname in ALL_CHANS} + if autojoin is None: + autojoin = ALL_CHANS + if called: + raise RuntimeError('already called') + called = True + conf = buildConfig(channelsconf, autojoin) + controller = InfobobRunner( + python=INFOBOB_PYTHON, + server=IRCD_HOST, + server_port=IRCD_PORT, + working_dir=tmp_path, + conf=conf, + ) + + return controller.spawn() + + yield start_infobob + + if controller is not None: + return pytest_tw.blockon(controller.stop()) + + +@pytest.fixture(name='ircd_endpoint') +def fixture_ircd_endpoint(): + from twisted.internet import reactor + + ircd_endpoint = endpoints.TCP4ClientEndpoint( + reactor, IRCD_HOST, IRCD_PORT, timeout=5) + return ircd_endpoint + + +# XXX: This is broken in pytest_twisted, see +# https://github.com/pytest-dev/pytest-twisted/pull/90 +# @pytest_tw.async_yield_fixture(name='joinfake') +# async def fixture_joinfake(ircd_endpoint): +# controllers = [] +# async def joinfake(creds, autojoin=ALL_CHANS): +# controller = await clients.joinFakeUser( +# ircd_endpoint, +# creds.nickname, +# creds.password, +# autojoin=autojoin, +# ) +# controllers.append(controller) +# return controller +# +# yield joinfake +# await defer.gatherResults([ +# controller.disconnect() for controller in controllers +# ]) +@pytest.fixture(name='joinfake') +def fixture_joinfake(ircd_endpoint): + controllers = [] + + @defer.inlineCallbacks + def joinfake(creds, autojoin=ALL_CHANS): + controller = yield defer.ensureDeferred(clients.joinFakeUser( + ircd_endpoint, + creds.nickname, + creds.password, + autojoin=autojoin, + )) + controllers.append(controller) + return controller + + yield joinfake + if controllers: + pytest_tw.blockon(defer.gatherResults([ + controller.disconnect() for controller in controllers + ])) + + +@pytest_tw.async_fixture(name='monitor') +async def fixture_monitor(joinfake): + monitor = await joinfake(MONITOR, autojoin=ALL_CHANS) + return monitor + + +@pytest_tw.async_fixture(name='chanop') +async def fixture_chanop(joinfake): + chanop = await joinfake(CHANOP, autojoin=ALL_CHANS) + for channelName in ALL_CHANS: + await chanop.channel(channelName).becomeOperator() + return chanop diff --git a/functional-tests/docker-compose.yml b/functional-tests/docker-compose.yml new file mode 100644 index 0000000..351eb4e --- /dev/null +++ b/functional-tests/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.7" +services: + + charybdis: + build: ./apps/charybdis + volumes: + - charybdis-logs:/var/log/charybdis + ports: + - "6667:6667" + + atheme: + build: ./apps/atheme + volumes: + - atheme-db:/var/lib/atheme + ports: + - "8080:8080" + #command: ["-d"] + +volumes: + charybdis-logs: + atheme-db: diff --git a/functional-tests/features/bans/setting-bans.feature b/functional-tests/features/bans/setting-bans.feature new file mode 100644 index 0000000..53627cd --- /dev/null +++ b/functional-tests/features/bans/setting-bans.feature @@ -0,0 +1,26 @@ +Feature: The bot assists with setting bans + + Scenario: Recording an account ban + + Given a user is in the channel + And a chanop is in the channel + + When the chanop sets an account ban for the user + + Then the bot will send the chanop a link to update the ban details + And the ban will show as active in the webui. + + + Scenario: Recording a mask ban and flipping it + + Given a user is in the channel + And a chanop is in the channel + + When the chanop sets a mask ban for the user + + Then the bot asks the chanop if they want the ban to be flipped + And the chanop says yes + Then the bot will unset the mask ban + And set a corresponding account ban + Then the bot will send the chanop a link to update the ban details + And the ban will show as active in the webui. diff --git a/functional-tests/features/bans/unsetting-bans.feature b/functional-tests/features/bans/unsetting-bans.feature new file mode 100644 index 0000000..0a8fb7d --- /dev/null +++ b/functional-tests/features/bans/unsetting-bans.feature @@ -0,0 +1,11 @@ +Feature: The bot assists with unsetting bans + + Scenario: Automatically unsetting an expired ban + + Given a ban is set on the channel + And the ban has an expiration set + + When the expiration time passes + + Then the bot will unset the ban + And the ban will show as expired in the webui. diff --git a/functional-tests/features/rehost-bad-pastebin-links.feature b/functional-tests/features/rehost-bad-pastebin-links.feature new file mode 100644 index 0000000..d5ddca1 --- /dev/null +++ b/functional-tests/features/rehost-bad-pastebin-links.feature @@ -0,0 +1,24 @@ +Feature: The bot rehosts annoying pastebin links on good pastebins + + Scenario: Rehosting annoying pastebin links + + Given a user is in the channel + + When the user sends a message with an annoying pastebin link + + Then the bot downloads the content + And the bot uploads the content to a good pastebin + Then the bot posts the rehosted link in the channel + And the rehosted link contains the same content as the original + + + Scenario: Rehosting multiple pastebin links on a single good pastebin + + Given a user is in the channel + + When the user sends a message with two annoying pastebin links + + Then the bot downloads the content from all links + And the bot uploads the content to a good pastebin + Then the bot posts the rehosted link in the channel + And the rehosted link contains all the content from the originals diff --git a/functional-tests/movie_lines.py b/functional-tests/movie_lines.py new file mode 100755 index 0000000..f3a4d65 --- /dev/null +++ b/functional-tests/movie_lines.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +List movies, lines, and conversations from the +"Cornel Movie-Dialogs Corpus" data set +(http://www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html). + +Useful for generating fake chat messages and such, without it being +too fake. + +:: + + usage: movie_lines.py COMMAND ... + + commands: + initdb Initialize the database from the raw corpus + (downloading if necessary) + movies List the available movies (and their IDs) + lines Show all the lines from a given movie + convos Show the conversations from a given movie + +:: + + usage: movie_lines.py lines [-n] movie_id + + optional arguments: + -n, --nonames Print just the line, without the character's name + +:: + + usage: movie_lines.py convos movie_id + +""" +import sys +import io +import pathlib +import sqlite3 +import zipfile +import urllib.request +import argparse +from operator import itemgetter +from itertools import groupby + + + +CORPUS_URL = ( + 'http://www.cs.cornell.edu/~cristian/data/' + 'cornell_movie_dialogs_corpus.zip' +) +CORPUS_ZIP = pathlib.Path('cornell_movie_dialogs_corpus.zip') +DBFILE = pathlib.Path('cornell_movie_dialogs_corpus.sqlite') + + +def main(): + parser = argparse.ArgumentParser() + parser.set_defaults(func=None) + subparsers = parser.add_subparsers() + initdb = subparsers.add_parser('initdb', help=( + 'Initialize the database from the raw corpus ' + '(downloading if necessary).' + )) + initdb.set_defaults( + func=lambda _: create_database(CORPUS_ZIP, CORPUS_URL, DBFILE)) + movies = subparsers.add_parser('movies', + help='List the available movies (and their IDs)') + movies.set_defaults(func=lambda _: list_movies(DBFILE)) + lines = subparsers.add_parser('lines', + help='Show all the lines from a given movie') + lines.add_argument('-n', '--nonames', action='store_false', dest='donames', + help="Print just the line, without the character's name") + lines.add_argument('movie_id') + lines.set_defaults( + func=lambda args: movie_lines(DBFILE, args.movie_id, args.donames)) + convos = subparsers.add_parser('convos', + help='Show the conversations from a given movie') + convos.add_argument('movie_id') + convos.set_defaults(func=lambda args: conversations(DBFILE, args.movie_id)) + + args = parser.parse_args() + if args.func is None: + parser.print_help() + else: + args.func(args) + + +def conversations(dbfile, movie_id): + conn = sqlite3.connect(dbfile) + sql = ''' + SELECT conversations.id, lines.name, lines.line + FROM conversations + JOIN conversation_lines + ON conversations.id = conversation_lines.conversation_id + JOIN lines + ON lines.id = conversation_lines.line_id + WHERE conversations.movie_id = ? + ORDER BY conversations.id, lines.id + ''' + rows = conn.execute(sql, (movie_id,)) + first = True + sep = '-' * 40 + #for row in rows: print(row) + for _, convo_lines in groupby(rows, key=itemgetter(0)): + if first: + first = False + else: + print(sep) + for cid, character_name, line in convo_lines: + print(f'{cid} -- {character_name}: {line}') + + +def movie_lines(dbfile, movie_id, donames): + conn = sqlite3.connect(dbfile) + sql = 'SELECT name, line FROM lines WHERE movie_id = ?' + for character_name, line in conn.execute(sql, (movie_id,)): + print((character_name + ': ' if donames else '') + line) + + +def list_movies(dbfile): + conn = sqlite3.connect(dbfile) + sql = 'SELECT id, title FROM movies' + for mid, title in conn.execute(sql): + print(f'{mid}: {title}') + + + +def create_database(corpus_zip, corpus_url, dbfile): + if not corpus_zip.exists(): + log(f'{corpus_zip} does not appear to exist') + download(corpus_url, corpus_zip) + populate_from_zip(corpus_zip, dbfile) + + +def download(url, outpath): + log(f'Downloading {url} to {outpath}...') + with urllib.request.urlopen(url) as response: + headers = response.info() + ctype = headers.get_content_type() + if ctype != 'application/zip': + log( + "Error: expected 'Content-Type: application/zip' " + f"but got {ctype}" + ) + sys.exit(1) + with outpath.open('wb') as outfile: + for chunk in iter(lambda: response.read(4096), b''): + outfile.write(chunk) + log('Download complete') + + +def populate_from_zip(corpus_zip, dbfile): + prefix = 'cornell movie-dialogs corpus/' + log(f'Loading from {corpus_zip}') + with zipfile.ZipFile(corpus_zip) as archive: + if dbfile.exists(): + log(f'Removing extant database {dbfile}') + dbfile.unlink() + conn = sqlite3.connect(dbfile) + log(f'Initializing database') + initialize(conn) + for filename, load_rows in FILE_HANDLERS: + log(f'Loading {filename}') + with archive.open(prefix + filename) as fp: + rawlines = io.TextIOWrapper(fp, encoding='cp1252') + with conn: + cur = conn.cursor() + load_rows(cur, map(split_rawline, rawlines)) + log('Done!') + + +def log(message): + print(message, file=sys.stderr) + +MOVIES_SCHEMA = ''' + CREATE TABLE movies ( + id text PRIMARY KEY, + title text NOT NULL, + year integer NOT NULL, + rating real NOT NULL, + votes integer NOT NULL, + genres text NOT NULL + ); +''' +def load_titles(cursor, rawrows): + sql = ( + 'INSERT INTO movies ' + '(id, title, year, rating, votes, genres) ' + 'VALUES (?, ?, ?, ?, ?, ?)' + ) + rows = ( + (mid, title, int(year.rstrip('/I')), float(rating), int(votes), + '|'.join(parse_corpus_list(genres))) + for mid, title, year, rating, votes, genres in rawrows + ) + cursor.executemany(sql, rows) + +def parse_corpus_list(rawlist): + return [item.strip("' ") for item in rawlist.strip('[]').split(',')] + + +CHARACTERS_SCHEMA = ''' + CREATE TABLE characters ( + id text PRIMARY KEY, + movie_id text NOT NULL REFERENCES movies(id), + name text NOT NULL, + gender text NOT NULL, + credits_position text NOT NULL + ); +''' +def load_characters(cursor, rawrows): + sql = ( + 'INSERT INTO characters ' + '(id, movie_id, name, gender, credits_position) ' + 'VALUES (?, ?, ?, ?, ?)' + ) + rows = ( + (cid, mid, name, gender, credpos) + for cid, name, mid, _, gender, credpos in rawrows + ) + cursor.executemany(sql, rows) + + +LINES_SCHEMA = ''' + CREATE TABLE lines ( + id text PRIMARY KEY, + character_id text NOT NULL REFERENCES characters(id), + movie_id text NOT NULL REFERENCES movies(id), + name text NOT NULL, + line text NOT NULL + ); +''' +def load_lines(cursor, rawrows): + sql = ( + 'INSERT INTO lines ' + '(id, character_id, movie_id, name, line) ' + 'VALUES (?, ?, ?, ?, ?)' + ) + rows = ( + (lid, cid, mid, character_name, line) + for lid, cid, mid, character_name, line in rawrows + if line # Filter out blank lines + ) + cursor.executemany(sql, rows) + + +CONVERSATIONS_SCHEMA = ''' + CREATE TABLE conversations ( + id INTEGER PRIMARY KEY, + character1_id text NOT NULL REFERENCES characters(id), + character2_id text NOT NULL REFERENCES characters(id), + movie_id text NOT NULL REFERENCES movies(id) + ); +''' +CONVERSATION_LINES_SCHEMA = ''' + CREATE TABLE conversation_lines ( + conversation_id integer NOT NULL REFERENCES conversations(id), + line_id text NOT NULL REFERENCES lines(id) + ); +''' +def load_conversations(cursor, rawrows): + convos_sql = ( + 'INSERT INTO conversations ' + '(character1_id, character2_id, movie_id) ' + 'VALUES (?, ?, ?)' + ) + convoline_sql = ( + 'INSERT INTO conversation_lines ' + '(conversation_id, line_id) ' + 'VALUES (?, ?)' + ) + for cid1, cid2, mid, raw_lineids in rawrows: + cursor.execute(convos_sql, (cid1, cid2, mid)) + convo_id = cursor.lastrowid + line_ids = parse_corpus_list(raw_lineids) + cursor.executemany(convoline_sql, [(convo_id, lid) for lid in line_ids]) + + +FILE_HANDLERS = ( + ('movie_titles_metadata.txt', load_titles), + ('movie_characters_metadata.txt', load_characters), + ('movie_lines.txt', load_lines), + ('movie_conversations.txt', load_conversations), +) + + +def split_rawline(rawline): + return [word.strip() for word in rawline.split(' +++$+++ ')] + + +def initialize(conn): + schemas = [ + MOVIES_SCHEMA, + CHARACTERS_SCHEMA, + LINES_SCHEMA, + CONVERSATIONS_SCHEMA, + CONVERSATION_LINES_SCHEMA, + ] + with conn: + for schema in schemas: + conn.executescript(schema) + + +if __name__ == '__main__': + main() diff --git a/functional-tests/requirements.txt b/functional-tests/requirements.txt new file mode 100644 index 0000000..ce5a998 --- /dev/null +++ b/functional-tests/requirements.txt @@ -0,0 +1,4 @@ +twisted[tls] +pytest +pytest-twisted +treq diff --git a/functional-tests/runner.py b/functional-tests/runner.py new file mode 100644 index 0000000..85db9c8 --- /dev/null +++ b/functional-tests/runner.py @@ -0,0 +1,227 @@ +import contextlib +import json +import os +import pathlib +import sqlite3 +import urllib.parse +from typing import Sequence, Tuple, Optional + +import attr +import hyperlink +import treq +from twisted.internet import defer +from twisted.internet import protocol +from twisted.internet.error import ProcessDone +from twisted.web.http_headers import Headers +from twisted import logger + +from config import SCHEMA_PATH + + + +JAN_15_1970 = '1970-01-15T12:00:00.000000Z' + + +def _getReactor(): + from twisted.internet import reactor + return reactor + + +@attr.s +class InfobobRunner: + python: pathlib.Path = attr.ib() + server: str = attr.ib() + server_port: int = attr.ib() + working_dir: pathlib.Path = attr.ib() + conf: dict = attr.ib() + _reactor = attr.ib(factory=_getReactor) + _procproto: Optional[defer.Deferred] = attr.ib( + init=False, repr=False, default=None) + _log = logger.Logger() + + def spawn(self) -> defer.Deferred: + if self._procproto is not None: + raise RuntimeError('process protocol not clear') + self._ensure_db() + confpath = self._write_conf() + executable, args = self._build_args(confpath) + childFDs = { + 0: open(os.devnull, 'rb').fileno(), + 1: 'r', + 2: 'r', + } + procproto = InfobobProcessProtocol() + dfd = defer.execute( + self._reactor.spawnProcess, + procproto, + executable, + args=args, + env=None, + path=str(self.working_dir), + childFDs=childFDs, + ) + + def cbUpdateStateReturnSelf(_): + self._procproto = procproto + return self + + return dfd.addCallback(cbUpdateStateReturnSelf) + + def respawn(self) -> defer.Deferred: + if self._procproto is None: + raise RuntimeError('No process protocol') + dfd = self.stop() + dfd.addCallback(lambda _: self.spawn()) + + def stop(self) -> defer.Deferred: + if self._procproto is None: + return defer.succeed(None) + if self._procproto.transport.pid is not None: + self._procproto.transport.signalProcess('INT') + dfd = self._procproto.ended + + def cbNullifyProcProto(passthrough): + self._procproto = None + return passthrough + + dfd.addBoth(cbNullifyProcProto) + + def ebLogAndRaise(f): + self._log.failure('Error in process', f) + return f + + dfd.addErrback(ebLogAndRaise) + return dfd + + def webui(self) -> 'InfobobWebUIClient': + port = self.conf['web']['port'] + uiclient = InfobobWebUIClient.new('localhost', port) + return uiclient + + def database(self) -> 'InfobobDBClient': + db = self.conf.get('database', {}).get('sqlite', {}).get('db_file') + if db is None: + dbpath = self.working_dir.joinpath('infobob.db') + self.conf\ + .setdefault('database', {})\ + .setdefault('sqlite', {})['db_file'] = str(dbpath) + else: + dbpath = pathlib.Path(db) + return InfobobDBClient(dbpath) + + def _ensure_db(self): + self.database().init() + + def _write_conf(self) -> pathlib.Path: + confpath = self.working_dir.joinpath('infobob.conf.json') + confpath.write_text(json.dumps(self.conf)) + return confpath + + def _build_args( + self, confpath: pathlib.Path) -> Tuple[str, Sequence[str]]: + twistd = str(self.python.parent.joinpath('twistd')) + args = [twistd, '-n', 'infobob', str(confpath)] + return twistd, args + + +@attr.s +class InfobobDBClient: + dbpath: os.PathLike = attr.ib() + + def init(self) -> None: + if not self.dbpath.exists(): + conn = self._connect() + with contextlib.closing(conn): + with conn: + conn.executescript(SCHEMA_PATH.read_text()) + + def _connect(self): + conn = sqlite3.connect(str(self.dbpath)) + return conn + + def getBanAuths(self): + conn = self._connect() + with contextlib.closing(conn): + rows = conn.execute('SELECT ban, code FROM ban_authorizations') + return [(str(banid), banauth) for (banid, banauth) in rows] + + def dumpBanRows(self): + conn = self._connect() + with contextlib.closing(conn): + for row in conn.execute('SELECT * FROM bans'): + print(row) + + +@attr.s +class InfobobWebUIClient: + root: hyperlink.URL = attr.ib() + _client = attr.ib() + + @classmethod + def new(cls, host: str, port: int): + root = hyperlink.URL(scheme='http', host=host, port=port) + return cls(root=root, client=treq) + + def _get(self, url): + headers = Headers() + headers.addRawHeader('Accept', 'application/json') + return self._client.get(str(url), headers=headers) + + def _post(self, url, data): + headers = Headers() + headers.addRawHeader('Accept', 'application/json') + headers.addRawHeader( + 'Content-Type', 'application/x-www-form-urlencoded') + payload = urllib.parse.urlencode(data).encode('ascii') + return self._client.post(str(url), headers=headers, data=payload) + + async def getCurrentBans(self, channelName: str): + chanBans = await self._bansFromChannel(('bans',), channelName) + return chanBans + + async def getExpiredBans(self, channelName: str): + chanBans = await self._bansFromChannel(('bans', 'expired'), channelName) + return chanBans + + async def getAllBans(self, channelName: str): + chanBans = await self._bansFromChannel(('bans', 'all'), channelName) + return chanBans + + async def _bansFromChannel(self, endpoint: Sequence[str], channelName: str): + url = self.root.child(*endpoint) + resp = await self._get(url) + assert resp.code == 200 + byChannel = await resp.json() + return byChannel.get(channelName, []) + + async def setBanExpired(self, banId: str, authToken: str): + url = self.root.child('bans', 'edit', banId, authToken) + resp = await self._post(url, data=dict(expire_at=JAN_15_1970)) + assert resp.code == 200 + body = await resp.json() + return body + + +class InfobobProcessProtocol(protocol.ProcessProtocol): + log = logger.Logger() + + def __init__(self): + self.ended = defer.Deferred() + + def connectionMade(self): + self.log.info('Infobob started') + self.transport.closeStdin() + + def outReceived(self, data: bytes): + self.log.info("stdout: " + data.decode('utf-8').rstrip('\r\n')) + + def errReceived(self, data: bytes): + self.log.info("stderr: " + data.decode('utf-8').rstrip('\r\n')) + + def processEnded(self, reason): + if reason.check(ProcessDone) is None: + self.log.warn('Infobob exited: {reason}', reason=reason) + self.ended.errback(reason) + else: + self.log.info('Infobob exited cleanly') + self.ended.callback(None) diff --git a/functional-tests/simulate.py b/functional-tests/simulate.py new file mode 100755 index 0000000..02571ee --- /dev/null +++ b/functional-tests/simulate.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +import pathlib +import tempfile +import sys +import random +from functools import partial +from typing import Sequence + +from twisted.internet import defer +from twisted.internet import task +from twisted.internet import endpoints +from twisted import logger + +from config import ( + INFOBOB_PYTHON, + MONITOR, + GENERICS, + IRCD_HOST, + IRCD_PORT, + buildConfig, +) +import clients +import runner + + +LOG = logger.Logger() + + +def main(): + args = sys.argv[1:] + if args: + with open(args[0]) as fp: + phrases = [line.strip() for line in fp] + else: + phrases = [ + "You're using coconuts!", + "Where did you get the coconuts?", + "Found them? In Mercea. The coconut's tropical!", + "This new learning amazes me, Sir Bedevere.", + "Explain again how sheep's bladders may be employed " + "to prevent earthquakes.", + "Oh, let me go and have a bit of peril?", + ] + with tempfile.TemporaryDirectory() as tdir: + infobob_working_dir = pathlib.Path(tdir) + task.react(setupAndRun, (infobob_working_dir, phrases)) + + +def setupAndRun(reactor, infobob_working_dir: pathlib.Path, phrases: Sequence[str]): + startLogging() + conf = buildConfig( + channelsconf={ + '#project': {'have_ops': True}, + '##offtopic': {'have_ops': True}, + }, + autojoin=['#project', '##offtopic'], + ) + bot = runner.InfobobRunner( + python=INFOBOB_PYTHON, + server=IRCD_HOST, + server_port=IRCD_PORT, + working_dir=infobob_working_dir, + conf=conf, + ) + endpoint = endpoints.TCP4ClientEndpoint( + reactor, IRCD_HOST, IRCD_PORT, timeout=5) + creds = [MONITOR, *GENERICS[:10]] + taskRunners = [ + partial( + runChatter, endpoint, reactor, + cred.nickname, cred.password, + '##offtopic', phrases, + ) + for cred in creds + ] + return run(reactor, bot=bot, taskRunners=taskRunners) + + +def startLogging(): + stderrObserver = logger.textFileLogObserver(sys.stderr) + levelPredicate = logger.LogLevelFilterPredicate( + defaultLogLevel=logger.LogLevel.info) + filterer = logger.FilteringLogObserver(stderrObserver, [levelPredicate]) + observers = [filterer] + logger.globalLogBeginner.beginLoggingTo( + observers, redirectStandardIO=False) + + +@defer.inlineCallbacks +def run(reactor, *, bot, taskRunners): + LOG.info('Starting infobob') + botproto = yield bot.spawn(reactor) + try: + yield task.deferLater(reactor, 5) + if botproto.transport.pid is None: + LOG.error('infobob quit') + return + yield defer.gatherResults([run() for run in taskRunners]) + except Exception: # pylint: disable=broad-except + LOG.failure('Unhandled exception in simulate.run') + finally: + if botproto.transport.pid is not None: + botproto.transport.signalProcess('INT') + yield botproto.ended + + +def runChatter(endpoint, reactor, nickname, password, channel, phrases): + dfd = defer.ensureDeferred( + clients.joinFakeUser(endpoint, nickname, password, [channel])) + dfd.addCallback(chat, reactor, channel, phrases) + return dfd + + +@defer.inlineCallbacks +def chat(controller: clients.ComposedIRCController, reactor, channel, phrases): + while True: + initdelay = random.randint(5, 20) + yield task.deferLater(reactor, initdelay) + for _ in range(random.randint(30, 180)): + msgdelay = random.randint(4, 50) + yield task.deferLater(reactor, msgdelay) + message = random.choice(phrases) + controller.say(channel, message) + burstdelay = random.randint(30, 180) + yield task.deferLater(reactor, burstdelay) + + +if __name__ == '__main__': + main() diff --git a/functional-tests/test_atheme_services.py b/functional-tests/test_atheme_services.py new file mode 100644 index 0000000..003a067 --- /dev/null +++ b/functional-tests/test_atheme_services.py @@ -0,0 +1,87 @@ +import pytest +from twisted.internet import defer +from twisted.web import xmlrpc + +from config import ( + CHANOP, + ALL_USERS, + ALL_CHANS, + SERVICES_XMLRPC_URL, +) + + +@pytest.fixture(name='proxy') +def fixture_proxy(): + from twisted.internet import reactor + proxy = xmlrpc.Proxy( + SERVICES_XMLRPC_URL.encode('ascii'), + connectTimeout=5.0, + reactor=reactor, + ) + return proxy + + +def test_registrations(proxy): + dfd = defer.succeed(None) + def cbLoginRPC(_, proxy, creds): + return rpcLogin(proxy, creds.nickname, creds.password) + for creds in ALL_USERS: + dfd.addCallback(cbLoginRPC, proxy, creds) + return dfd + + +def rpcLogin(proxy, nickname, password): + """ + Login via XMLRPC, return a Deferred that fires with the Atheme + auth token. + """ + # https://github.com/atheme/atheme/blob/v7.2.9/doc/XMLRPC + def ebExplain(failure): + failure.trap(xmlrpc.Fault) + fault = failure.value + raise AthemeLoginFailed( + f'While logging in as {nickname}, got {fault}' + ) from fault + + loginDfd = proxy.callRemote('atheme.login', nickname, password) + return loginDfd.addErrback(ebExplain) + + +class AthemeLoginFailed(Exception): + pass + + +def test_registered_channels(proxy): + def cbCheckChannelRegistered(_, proxy, chan, creds): + return checkChannelRegistered( + proxy, chan, creds.nickname, creds.password) + channels = ALL_CHANS + dfd = defer.succeed(None) + for chan in channels: + dfd.addCallback(cbCheckChannelRegistered, proxy, chan, CHANOP) + return dfd + + +def checkChannelRegistered(proxy, channel, nickname, password): + def cbLookupChannel(authtoken): + host = 'xxx' # Atheme wants a "source ip" parameter, no clue why. + return proxy.callRemote( + 'atheme.command', authtoken, nickname, host, + 'chanserv', 'info', channel, + ) + + def ebExplain(failure): + failure.trap(xmlrpc.Fault) + fault = failure.value + raise AthemeChannelRegistrationLookupFailed( + f'Could not look up status of channel {channel}, got {fault}' + ) from fault + + dfd = rpcLogin(proxy, nickname, password) + dfd.addCallback(cbLookupChannel) + dfd.addErrback(ebExplain) + return dfd + + +class AthemeChannelRegistrationLookupFailed(Exception): + pass diff --git a/functional-tests/test_ban_workflows.py b/functional-tests/test_ban_workflows.py new file mode 100644 index 0000000..21f472d --- /dev/null +++ b/functional-tests/test_ban_workflows.py @@ -0,0 +1,132 @@ +import re +import contextlib + +import pytest_twisted as pytest_tw +from twisted import logger + +import clients +import utils +from config import PROJECT_CHAN, INFOTEST + + +LOG = logger.Logger() + + + +@contextlib.asynccontextmanager +async def clean_channel(channel: clients.ChannelController): + for extantBan in await channel.retrieveBans(): + channel.unsetBan(extantBan.mask) + assert (await channel.retrieveBans()) == [] + yield + for extantBan in await channel.retrieveBans(): + channel.unsetBan(extantBan.mask) + await utils.sleep(1) + assert (await channel.retrieveBans()) == [] + + +@pytest_tw.ensureDeferred +async def test_ban_discovery(start_infobob, chanop): + """ + Infobob discovers bans that it did not see happen, by populating + its database when it joins a channel. + """ + async with clean_channel(chanop.channel(PROJECT_CHAN)): + # Given the bot is not in the channel, + # And a new ban is set on the channel, + mask = 'naughty!user@client.example.org' + chanop.channel(PROJECT_CHAN).setBan(mask) + # When the bot joins the channel, + botctrl = await start_infobob() + await utils.sleep(2) + # Then the ban shows as active in the webui, + recorded = await botctrl.webui().getCurrentBans(PROJECT_CHAN) + thatBan = next((ban for ban in recorded if ban['mask'] == mask), None) + assert thatBan is not None, f'{mask} not found in {recorded!r}' + # And the reason says the ban was pulled from the channel, + assert thatBan['reason'].startswith('ban pulled from banlist') + # ...and who it was set by. + assert thatBan['setBy'] == chanop.nickname + + +@pytest_tw.ensureDeferred +async def test_record_ban_unset(start_infobob, chanop): + """ + Infobob notices when an operator unsets a ban. + + It will notify the chanop in a PM when the ban was set and by + whom, and update its database to record the ban as unset. + """ + # Given a chanop is in the channel, + project = chanop.channel(PROJECT_CHAN) + async with clean_channel(project): + # And a ban is set on the channel, + mask = 'naughty!user@client.example.org' + project.setBan(mask) + botctrl = await start_infobob() + await utils.sleep(2) + # When the chanop unsets the ban, + project.unsetBan(mask) + await utils.sleep(2) + + # Then the bot notifies the chanop in a PM, + messages = chanop.getPrivateMessages(sender=INFOTEST.nickname) + assert len(messages) == 1 + [pm] = messages + # TODO: And the message says when the ban was set, + # And the message says by whom the ban was set, + pattern = ' '.join([ + re.escape(rf'fyi: {chanop.nickname} set'), + r'"\+b naughty\S+"', + re.escape(f'on {PROJECT_CHAN}'), + '.*', + ]) + assert re.fullmatch(pattern, pm.text) + + # And the ban will show as expired in the webui. + recorded = await botctrl.webui().getExpiredBans(PROJECT_CHAN) + thatBan = next((ban for ban in recorded if ban['mask'] == mask), None) + assert thatBan is not None, f'{mask} not found in {recorded!r}' + assert ( + thatBan['setBy'] + == thatBan['unset']['unsetBy'] + == chanop.nickname + ) + + +@pytest_tw.ensureDeferred +async def test_auto_unset_expired_ban(start_infobob, chanop): + """ + Infobob automatically unsets bans after they expire. + """ + project = chanop.channel(PROJECT_CHAN) + async with clean_channel(project): + botctrl = await start_infobob() + await utils.sleep(2) + # Given a ban is set on the channel, + mask = 'naughty!user@client.example.org' + project.setBan(mask) + await utils.sleep(2) + assert [mask] == [b.mask for b in (await project.retrieveBans())] + + # And the ban has an expiration set, + banAuths = botctrl.database().getBanAuths() + assert len(banAuths) == 1 + [(banId, authToken)] = banAuths + print(await botctrl.webui().setBanExpired(banId, authToken)) + await botctrl.stop() + assert [mask] == [b.mask for b in (await project.retrieveBans())] + + # When the expiration time passes, + # Then the bot will unset the ban, + await botctrl.spawn() + await utils.sleep(4) + assert [] == (await project.retrieveBans()) + + # And the ban will show as unset in the webui. + recorded = await botctrl.webui().getAllBans(PROJECT_CHAN) + thatBan = next((ban for ban in recorded if ban['mask'] == mask), None) + assert thatBan is not None, f'{mask} not found in {recorded!r}' + assert thatBan['setBy'] == chanop.nickname + assert thatBan['unset']['unsetBy'] == INFOTEST.nickname + assert thatBan['expiry']['when'].startswith('1970-01-') diff --git a/functional-tests/test_basic.py b/functional-tests/test_basic.py new file mode 100644 index 0000000..44c5b74 --- /dev/null +++ b/functional-tests/test_basic.py @@ -0,0 +1,13 @@ +import pytest_twisted as pytest_tw + +import utils +from config import ALL_CHANS, INFOTEST + + +@pytest_tw.inlineCallbacks +def test_infobob_basic(monitor, start_infobob): + yield start_infobob() + yield utils.sleep(3) + for channelName in ALL_CHANS: + chan = monitor.channel(channelName) + assert INFOTEST.nickname in chan.getMembers() diff --git a/functional-tests/test_redent_command.py b/functional-tests/test_redent_command.py new file mode 100644 index 0000000..9124931 --- /dev/null +++ b/functional-tests/test_redent_command.py @@ -0,0 +1,39 @@ +import re + +import pytest +import pytest_twisted as pytest_tw +import utils +from config import PROJECT_CHAN, GENERICS, INFOTEST + + +class IncompletelyImplemented(NotImplementedError): + pass + + +@pytest.mark.xfail(raises=IncompletelyImplemented, strict=True) +@pytest_tw.ensureDeferred +async def test_redent(monitor, start_infobob, joinfake): + """ + Infobob reformats oneliner code with indentation when requested. + """ + # Given two users are in the channel, + sender, target = GENERICS[:2] + await start_infobob() + senderCtrl = await joinfake(sender, [PROJECT_CHAN]) + targetCtrl = await joinfake(target, [PROJECT_CHAN]) + # When user A tells the bot to redent some code for user B, + code = 'try: dostuff();; except ItBroke as e: failAnnoyingly()' + message = f'{INFOTEST.nickname}, redent {target.nickname} {code}' + senderCtrl.channel(PROJECT_CHAN).say(message) + await utils.sleep(3) + # Then the bot parses the code, uploads it to a pastebin, + # And the bot mentions user B with a link containing the code, + chan = monitor.channel(PROJECT_CHAN) + botmessages = chan.getMessages(sender=INFOTEST.nickname) + assert len(botmessages) == 1 + [msg] = botmessages + assert msg.sender == INFOTEST.nickname + message_pattern = re.escape(target.nickname) + r', (https?://\S+)\s*' + assert re.fullmatch(message_pattern, msg.text) + # TODO: And the code is reformatted with indentation. + raise IncompletelyImplemented diff --git a/functional-tests/utils.py b/functional-tests/utils.py new file mode 100644 index 0000000..890cf61 --- /dev/null +++ b/functional-tests/utils.py @@ -0,0 +1,11 @@ +from twisted.internet import defer +from twisted.internet import task + + +def sleep(secs: float) -> defer.Deferred: + from twisted.internet import reactor + return task.deferLater(reactor, secs, _noop) + + +def _noop(): + return None diff --git a/infobob/http.py b/infobob/http.py index 6a8c53a..407e5f3 100644 --- a/infobob/http.py +++ b/infobob/http.py @@ -1,7 +1,10 @@ import os.path +import datetime import itertools import operator +import json +import dateutil.tz from twisted.internet.defer import inlineCallbacks from twisted.web import server from twisted import logger @@ -12,6 +15,7 @@ from infobob.util import parse_time_string +UTC = dateutil.tz.tzutc() DEFAULT_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates') @@ -22,6 +26,64 @@ def renderTemplate(request, tmpl, **kwargs): .render('html', doctype='html5', encoding='utf-8')) request.finish() + +def _banAsJSONable(bantuple, show_unset, is_expired=None): + # XXX Badness: + # If set to None, `is_expired` will be computed from the ban expiry + # and current time, otherwise will use the provided boolean value. + # This just papers over the lack of real model objects in + # anticipation of a sweeping refactor, and the JSON stuff in general + # is really just a hack to make functional tests easier to write. + ( + _, mask, mode, set_at, set_by, expire_at, reason, unset_at, unset_by + ) = bantuple + if is_expired is None: + is_expired = ( + expire_at < datetime.datetime.now(tz=UTC) + if expire_at + else False + ) + jsonable = dict( + mask=mask, + mode=mode, + setBy=set_by.partition('!')[0], + setAt=set_at.isoformat(), + reason=reason, + expiry=dict( + when=expire_at.isoformat() if expire_at else None, + expired=is_expired, + ), + ) + if show_unset: + jsonable['unset'] = dict( + unsetBy=unset_by.partition('!')[0] if unset_by else None, + unsetAt=unset_at.isoformat() if unset_by else None, + ) + return jsonable + + +def renderJSONBans(request, bans, show_unset, show_recent_expiration): + byChannel = {} + for channel, channelBans in bans: + renderedBans = [] + for ban in channelBans: + jsonable = _banAsJSONable( + ban, + show_unset=show_unset, + is_expired=show_recent_expiration, + ) + renderedBans.append(jsonable) + byChannel[channel] = renderedBans + + _renderJSON(request, byChannel) + + +def _renderJSON(request, payload): + request.setHeader('Content-type', 'application/json; charset=utf-8') + request.write(json.dumps(payload, ensure_ascii=True)) + request.finish() + + class InfobobWebUI(object): app = klein.Klein() @@ -29,13 +91,34 @@ def __init__(self, loader, dbpool): self.loader = loader self.dbpool = dbpool + def renderBans(self, request, bans, show_unset, show_recent_expiration): + variables = dict( + bans=bans, + show_unset=show_unset, + show_recent_expiration=show_recent_expiration, + ) + if request.getHeader('accept') == 'application/json': + renderJSONBans(request, **variables) + else: + renderTemplate(request, self.loader.load('bans.html'), **variables) + + def renderEditBan(self, request, ban, message=None, jsonBadRequest=False): + if request.getHeader('accept') == 'application/json': + if jsonBadRequest: + request.setResponseCode(400) + jsonable = _banAsJSONable(ban, show_unset=True) + _renderJSON(request, dict(ban=jsonable, message=message)) + else: + renderTemplate(request, self.loader.load('edit_ban.html'), + ban=ban, message=message) + @app.route('/bans') @inlineCallbacks def bans(self, request): bans = yield self.dbpool.get_active_bans() bans = itertools.groupby(bans, operator.itemgetter(0)) - renderTemplate(request, self.loader.load('bans.html'), - bans=bans, show_unset=False, show_recent_expiration=False) + self.renderBans( + request, bans=bans, show_unset=False, show_recent_expiration=False) @app.route('/bans/expired') @app.route('/bans/expired/') @@ -44,23 +127,22 @@ def expiredBans(self, request, count=10): bans = yield self.dbpool.get_recently_expired_bans(count) bans.sort(key=operator.itemgetter(0, 7)) bans = itertools.groupby(bans, operator.itemgetter(0)) - renderTemplate(request, self.loader.load('bans.html'), - bans=bans, show_unset=True, show_recent_expiration=True) + self.renderBans( + request, bans=bans, show_unset=True, show_recent_expiration=True) @app.route('/bans/all') @inlineCallbacks def allBans(self, request): bans = yield self.dbpool.get_all_bans() bans = itertools.groupby(bans, operator.itemgetter(0)) - renderTemplate(request, self.loader.load('bans.html'), - bans=bans, show_unset=True, show_recent_expiration=False) + self.renderBans( + request, bans=bans, show_unset=True, show_recent_expiration=False) @app.route('/bans/edit//', methods=['GET', 'HEAD']) @inlineCallbacks def editBan(self, request, rowid, auth): ban = yield self.dbpool.get_ban_with_auth(rowid, auth) - renderTemplate(request, self.loader.load('edit_ban.html'), - ban=ban, message=None) + self.renderEditBan(request, ban=ban, message=None) @app.route('/bans/edit//', methods=['POST']) @inlineCallbacks @@ -81,15 +163,15 @@ def postEditBan(self, request, rowid, auth): message = ( 'Invalid expiration timestamp or relative date {0!r}' ).format(raw_expire_at) - renderTemplate(request, self.loader.load('edit_ban.html'), - ban=ban, message=message) + self.renderEditBan( + request, ban=ban, message=message, jsonBadRequest=True) return if 'reason' in request.args: reason = request.args['reason'][0] yield self.dbpool.update_ban_by_rowid(rowid, expire_at, reason) ban = ban[:5] + (expire_at, reason) + ban[7:] - renderTemplate(request, self.loader.load('edit_ban.html'), - ban=ban, message='ban details updated') + self.renderEditBan(request, ban=ban, message='ban details updated') + def makeSite(templates_dir, dbpool): loader = TemplateLoader(templates_dir, auto_reload=True) diff --git a/infobob/irc.py b/infobob/irc.py index 576b134..c5ffe0c 100644 --- a/infobob/irc.py +++ b/infobob/irc.py @@ -19,9 +19,6 @@ from infobob.pastebin import make_paster, make_repaster -log = logger.Logger() - - numeric_addendum = dict( RPL_WHOISACCOUNT='330', RPL_QUIETLIST='728', @@ -51,6 +48,7 @@ class Infobob(irc.IRCClient): versionEnv = 'twisted' db = dbpool = manhole_service = None + log = logger.Logger() def __init__(self, conf, paster=None, repaster=None): self._conf = conf @@ -84,7 +82,7 @@ def startTimer(self, name, interval, method, *a, **kw): def wrap(): d = defer.maybeDeferred(method, *a, **kw) d.addErrback( - lambda f: log.failure( + lambda f: self.log.failure( u'error in looper {name}', f, name=name, @@ -151,6 +149,12 @@ def whois(self, nickname, server=None): finally: self._whois_queue.release() + def irc_unknown(self, command, prefix, params): + self.log.warn( + u"received command we aren't prepared to handle: {cmd} {pfx} {pms}", + cmd=command, pfx=prefix, pms=params, + ) + def irc_RPL_WHOISUSER(self, prefix, params): c = self._whois_collation c['nick'], c['user'], c['host'] = params[1:4] @@ -296,7 +300,7 @@ def privmsg(self, user, channel, message): if d is not None: d.callback(message) return - log.info( + self.log.info( u'privmsg from {user}: {message}', user=user, message=message ) target = user @@ -528,12 +532,15 @@ def _deopSelf(self): @defer.inlineCallbacks def _expireBans(self): + self.log.info('Checking for expired bans') expired = yield self.dbpool.get_expired_bans() for channel, it in itertools.groupby(expired, operator.itemgetter(0)): if not self._conf.channel(channel).have_ops: continue yield self.ensureOps(channel) for _, mask, mode in it: + self.log.info('Unsetting expired +{mode} {mask} on {channel}', + mode=mode, mask=mask, channel=channel) self.mode(channel, False, mode, mask=mask) @defer.inlineCallbacks