diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b961cf5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +**/*.o +**/*.lo +**/.libs +**/.deps +**/__pycache__ +**/.pytest_cache +bin/*.log +bin/debug.log +bin/socket.log +bin/gnuworld.pid +lib/*.a +ddd +.vscode diff --git a/include/iServer.h b/include/iServer.h index c9f88c0e..872cc3c9 100644 --- a/include/iServer.h +++ b/include/iServer.h @@ -61,13 +61,15 @@ class iServer : public NetworkTarget { * Flags that iServers may have. */ /* Set if this iServer is juped, false otherwise */ - static const flagType FLAG_JUPE; + static constexpr flagType FLAG_JUPE = 0x01; /* Set if this iServer is a hub (+h) */ - static const flagType FLAG_HUB; + static constexpr flagType FLAG_HUB = 0x02; /* Set if this iServer is a service (+s) */ - static const flagType FLAG_SERVICE; + static constexpr flagType FLAG_SERVICE = 0x04; /* Set if this iServer is IPv6-compatible (+6) */ - static const flagType FLAG_IPV6; + static constexpr flagType FLAG_IPV6 = 0x08; + /* Set if this iServer is connected over TLS (+z) */ + static constexpr flagType FLAG_TLS = 0x10; /** * Construct an iServer given its vital state variables @@ -91,19 +93,17 @@ class iServer : public NetworkTarget { /** * Return true if a particular flag is set, false otherwise. */ - inline bool getFlag(const flagType& whichFlag) const { - return ((flags & whichFlag) == whichFlag); - } + inline bool getFlag(flagType whichFlag) const { return ((flags & whichFlag) == whichFlag); } /** * Set a particular flags. */ - inline void setFlag(const flagType& whichFlag) { flags |= whichFlag; } + inline void setFlag(flagType whichFlag) { flags |= whichFlag; } /** * Remove a particular flag. */ - inline void removeFlag(const flagType& whichFlag) { flags &= ~whichFlag; } + inline void removeFlag(flagType whichFlag) { flags &= ~whichFlag; } /** * Return true if this server is a jupe. @@ -145,6 +145,16 @@ class iServer : public NetworkTarget { */ inline void setIPv6() { setFlag(FLAG_IPV6); } + /** + * Return true if this server is connected over TLS + */ + inline bool isTLS() const { return getFlag(FLAG_TLS); } + + /** + * Set this iServer as connected over TLS + */ + inline void setTLS() { setFlag(FLAG_TLS); } + /** * Return the server numeric of this server's uplink. */ diff --git a/include/server.h b/include/server.h index be1f8479..b666c429 100644 --- a/include/server.h +++ b/include/server.h @@ -788,8 +788,15 @@ class xServer : public ConnectionManager, public ConnectionHandler, public Netwo * Set this server's uplink. * This method should ONLY be called by the server command * handlers. + * When our link to the uplink is TLS, mark the uplink iServer + * as TLS regardless of whether its SERVER line advertised +z. */ - inline void setUplink(iServer* newUplink) { Uplink = newUplink; } + inline void setUplink(iServer* newUplink) { + Uplink = newUplink; + if (Uplink != nullptr && serverConnection != nullptr && serverConnection->isTLS()) { + Uplink->setTLS(); + } + } /** * Enable or disable the burstBuffer. diff --git a/libgnuworld/StringTokenizer.cc b/libgnuworld/StringTokenizer.cc index 4b91e3c1..54901f87 100644 --- a/libgnuworld/StringTokenizer.cc +++ b/libgnuworld/StringTokenizer.cc @@ -116,7 +116,8 @@ void StringTokenizer::Tokenize(const string& buf) { if (respectQuotes && inQuotes) { // \" inside a quoted run collapses to a literal ", staying // in quote mode. Any other backslash is kept as-is. - if (('\\' == *currentPtr) && ((currentPtr + 1) != endPtr) && ('"' == *(currentPtr + 1))) { + if (('\\' == *currentPtr) && ((currentPtr + 1) != endPtr) && + ('"' == *(currentPtr + 1))) { *addMePtr = '"'; ++addMePtr; ++currentPtr; @@ -211,6 +212,22 @@ string StringTokenizer::assemble(const size_type& start, int end) const { return retMe; } +/** + * trailing() + * Return the IRC trailing parameter: everything from the first + * token that begins with ':', with that leading ':' stripped. + * Returns std::nullopt if no such token exists. + */ +std::optional StringTokenizer::trailing() const { + for (size_type i = 0; i < size(); ++i) { + if (!array[i].empty() && array[i][0] == ':') { + string t = assemble(i); + return t.substr(1); + } + } + return std::nullopt; +} + StringTokenizer::size_type StringTokenizer::totalChars() const { size_type numChars = 0; diff --git a/libgnuworld/StringTokenizer.h b/libgnuworld/StringTokenizer.h index 92b38474..a003c6cf 100644 --- a/libgnuworld/StringTokenizer.h +++ b/libgnuworld/StringTokenizer.h @@ -26,6 +26,7 @@ #include #include +#include #include namespace gnuworld { @@ -128,6 +129,13 @@ class StringTokenizer { */ std::string assemble(const size_type& = 0, int = -1) const; + /** + * Return the IRC trailing parameter: everything from the first + * token that begins with ':', with that leading ':' stripped. + * Returns std::nullopt if no such token exists. + */ + std::optional trailing() const; + /** * The immutable iterator type to use for walking through * this object's tokens. diff --git a/mod.ccontrol/ccontrol.cc b/mod.ccontrol/ccontrol.cc index 34a36d6a..d3d274a3 100644 --- a/mod.ccontrol/ccontrol.cc +++ b/mod.ccontrol/ccontrol.cc @@ -6165,8 +6165,8 @@ bool ccontrol::glineChannelUsers(iClient* theClient, Channel* theChan, const str // omitted - legacy senders; treat as no account (backwards compatible) // * - explicitly no account // - logged-in account name - // Realname is located by the first ':'-prefixed token at index >= 5, - // so the optional account token does not shift a fixed fullname index. + // Realname is the IRC trailing parameter (st.trailing()), so the + // optional account token does not shift a fixed fullname index. StringTokenizer st(Message); if (st.size() < 6) { return 0; @@ -6188,16 +6188,7 @@ bool ccontrol::glineChannelUsers(iClient* theClient, Channel* theChan, const str ipmask_parse(IP.c_str(), &theIP, NULL); string base64IP = string(xIP(theIP).GetBase64IP()); - size_t fullnameIdx = 5; - for (size_t i = 5; i < st.size(); ++i) { - if (!st[i].empty() && st[i][0] == ':') { - fullnameIdx = i; - break; - } - } - string fullname = st.assemble(fullnameIdx); - if (fullname.substr(0, 1) == ":") - fullname = fullname.substr(1); + string fullname = st.trailing().value_or(st.assemble(5)); fullname = theServer->getCharYY() + " " + fullname; iClient* newClient = new (std::nothrow) iClient( diff --git a/mod.debug/SERVERINFOCommand.cc b/mod.debug/SERVERINFOCommand.cc index e531f980..8592d038 100644 --- a/mod.debug/SERVERINFOCommand.cc +++ b/mod.debug/SERVERINFOCommand.cc @@ -47,10 +47,11 @@ void dumpServerInfo(debug* bot, const iClient* theClient, iServer* theServer) { prettyDuration(theServer->getStartTime())); bot->Notice(theClient, "Lag: {} seconds (last update: {})", theServer->getLag(), theServer->getLastLagTS()); - bot->Notice(theClient, "Flags: {:#x} hub={} service={} ipv6={} jupe={} bursting={}", + bot->Notice(theClient, "Flags: {:#x} hub={} service={} ipv6={} tls={} jupe={} bursting={}", theServer->getFlags(), theServer->isHub() ? "yes" : "no", theServer->isService() ? "yes" : "no", theServer->isIPv6() ? "yes" : "no", - theServer->isJupe() ? "yes" : "no", theServer->isBursting() ? "yes" : "no"); + theServer->isTLS() ? "yes" : "no", theServer->isJupe() ? "yes" : "no", + theServer->isBursting() ? "yes" : "no"); } } // namespace diff --git a/src/iServer.cc b/src/iServer.cc index 49bdcffa..dbb57f2f 100644 --- a/src/iServer.cc +++ b/src/iServer.cc @@ -34,11 +34,6 @@ namespace gnuworld { using std::string; -const iServer::flagType iServer::FLAG_JUPE = 0x01; -const iServer::flagType iServer::FLAG_HUB = 0x02; -const iServer::flagType iServer::FLAG_SERVICE = 0x04; -const iServer::flagType iServer::FLAG_IPV6 = 0x08; - iServer::iServer(const unsigned int& _uplink, const string& _yyxxx, const string& _name, const time_t& _connectTime, const string& _description) : NetworkTarget(_yyxxx), uplinkIntYY(_uplink), name(_name), connectTime(_connectTime), @@ -64,6 +59,9 @@ void iServer::setFlags(const string& newFlags) { case '6': setIPv6(); break; + case 'z': + setTLS(); + break; case '+': break; default: diff --git a/test/.gitignore b/test/.gitignore index 96869c3e..cc060d3a 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,4 +1,5 @@ test_stringtokenizer +test_stringtokenizer_trailing test_signal test_mtrie_perf_summary test_mtrie_perf @@ -10,3 +11,8 @@ test_gThread test_econfig test_burst test_bot +test_xparameters_tags +harness/.venv/ +harness/.pytest_cache/ +harness/run/ +harness/**/__pycache__/ diff --git a/test/Makefile.am b/test/Makefile.am index ce684399..99cb308d 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -13,7 +13,8 @@ noinst_PROGRAMS = test_burst \ test_match \ test_bot \ test_kick_transaction \ - test_xparameters_tags + test_xparameters_tags \ + test_stringtokenizer_trailing if COND_PCRE noinst_PROGRAMS += test_regex @@ -67,6 +68,11 @@ test_xparameters_tags_CXXFLAGS = -I${top_srcdir}/include \ -I${top_srcdir}/libgnuworld test_xparameters_tags_LDADD = libgnuworld.la +test_stringtokenizer_trailing_SOURCES = test/stringtokenizer_trailing.cc +test_stringtokenizer_trailing_CXXFLAGS = -I${top_srcdir}/include \ + -I${top_srcdir}/libgnuworld +test_stringtokenizer_trailing_LDADD = libgnuworld.la + test_regex_SOURCES = test/test_regex.cc test_regex_LDADD = libgnuworld.la -lpcre diff --git a/test/harness/.gitignore b/test/harness/.gitignore new file mode 100644 index 00000000..2e8f3958 --- /dev/null +++ b/test/harness/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +.coverage +htmlcov/ +run/ diff --git a/test/harness/README.md b/test/harness/README.md new file mode 100644 index 00000000..134b4dbe --- /dev/null +++ b/test/harness/README.md @@ -0,0 +1,65 @@ +# GNUWorld integration tests (`test/harness`) + +Stand-alone pytest harness that: + +1. Listens as a fake ircu P10 hub **on the host** +2. Runs **Postgres + gnuworld in Docker Compose** +3. Lets tests assert on S2S lines the hub receives and on **gnuworld container stdout** + +Gnuworld is started with `docker compose run --rm -T` so its `-c` verbose +output is piped into the test process (`wait_for_stdout`). + +C++ unit/load tools remain in `test/` (automake `test_*` binaries). This +directory is the Python integration suite only. + +## Prerequisites + +- Docker + Docker Compose +- A host build of gnuworld (`bin/gnuworld`, `lib/`, `share/gnuworld/`) — the + image copies these artifacts (it does not compile inside Docker) + +## Setup + +```bash +cd test/harness +python3 -m venv .venv +source .venv/bin/activate +pip install pytest pytest-asyncio pytest-timeout +``` + +## Run + +```bash +cd test/harness +pytest -v +``` + +The session fixture builds the image, starts Postgres, and tears the stack +down at the end. First run is slower due to `docker compose build`. + +## Writing tests + +```python +async def test_example(linked): + hub, proc = linked + await proc.wait_for_stdout("Connected") + line = await hub.wait_for_token("EB") + +async def test_check(ccontrol_linked): + hub, proc = ccontrol_linked + await hub.send_xquery(routing="iauth:1", message="CHECK ...") +``` + +## Layout + +| Path | Role | +|------|------| +| `docker-compose.yml` | Postgres + gnuworld image | +| `docker/Dockerfile` | Runtime image from host-built binaries | +| `docker/initdb/` | ccontrol schema for Postgres | +| `fake_hub.py` | Listening fake P10 hub | +| `gnuworld_proc.py` | `compose run` + stdout capture | +| `data/*.conf.in` | Config templates | + +Gnuworld reaches FakeHub at `127.0.0.1:` (container uses host networking). +ccontrol reaches Postgres at `127.0.0.1:5433` (published compose port). diff --git a/test/harness/conftest.py b/test/harness/conftest.py new file mode 100644 index 00000000..dd82a151 --- /dev/null +++ b/test/harness/conftest.py @@ -0,0 +1,163 @@ +"""Pytest fixtures for the GNUWorld fake-hub harness (Dockerized GW + Postgres).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import pytest_asyncio + +from fake_hub import FakeHub +from gnuworld_proc import ( + CONTAINER_CONF_DIR, + CONTAINER_UPLINK, + DockerStack, + GnuworldProc, +) + + +@pytest.fixture(scope="session") +def docker_stack(): + """Build the gnuworld image and start Postgres for the test session.""" + stack = DockerStack() + stack.up() + try: + yield stack + finally: + stack.down() + + +@pytest_asyncio.fixture +async def fake_hub(): + """Listen on loopback; gnuworld uses host networking to reach it.""" + hub = FakeHub(host="127.0.0.1") + await hub.start() + try: + yield hub + finally: + await hub.close() + + +def _prepare_conf_dir(tmp_path: Path) -> Path: + """Host dir bind-mounted at /etc/gnuworld inside the container.""" + conf_dir = tmp_path / "etc-gnuworld" + conf_dir.mkdir() + return conf_dir + + +@pytest_asyncio.fixture +async def gnuworld(docker_stack, fake_hub, tmp_path): + """Start Dockerized gnuworld (no modules) linked to FakeHub.""" + hub = fake_hub + conf_dir = _prepare_conf_dir(tmp_path) + GnuworldProc.write_config( + conf_dir / "GNUWorld.conf", + uplink=CONTAINER_UPLINK, + port=hub.port, + password=hub.password, + ) + + proc = GnuworldProc(conf_dir=conf_dir) + await proc.start() + try: + await hub.accept_and_handshake(timeout=45.0) + await proc.wait_for_stdout("Connected", timeout=30.0) + yield hub, proc + finally: + await proc.terminate() + + +@pytest_asyncio.fixture +async def linked(gnuworld): + yield gnuworld + + +@pytest_asyncio.fixture +async def ccontrol_linked(docker_stack, fake_hub, tmp_path): + """Dockerized gnuworld with libccontrol against compose Postgres.""" + hub = fake_hub + conf_dir = _prepare_conf_dir(tmp_path) + GnuworldProc.write_ccontrol_config(conf_dir / "ccontrol.conf") + GnuworldProc.write_config( + conf_dir / "GNUWorld.conf", + uplink=CONTAINER_UPLINK, + port=hub.port, + password=hub.password, + module_lines=f"module = libccontrol.la {CONTAINER_CONF_DIR}/ccontrol.conf", + ) + + proc = GnuworldProc(conf_dir=conf_dir) + await proc.start() + try: + await hub.accept_and_handshake(timeout=90.0) + await proc.wait_for_stdout("Connected", timeout=60.0) + assert hub.get_user_numnick("euworld") or any( + " N euworld " in line for line in hub.received + ), "ccontrol did not burst euworld (module/DB load failed?)" + yield hub, proc + finally: + await proc.terminate() + + +@pytest_asyncio.fixture +async def debug_linked(docker_stack, fake_hub, tmp_path): + """Dockerized gnuworld with stealth mod.debug (no DB required).""" + hub = fake_hub + conf_dir = _prepare_conf_dir(tmp_path) + GnuworldProc.write_debug_config(conf_dir / "debug.conf") + GnuworldProc.write_config( + conf_dir / "GNUWorld.conf", + uplink=CONTAINER_UPLINK, + port=hub.port, + password=hub.password, + module_lines=f"module = libdebug.la {CONTAINER_CONF_DIR}/debug.conf", + ) + + proc = GnuworldProc(conf_dir=conf_dir) + await proc.start() + try: + await hub.accept_and_handshake(timeout=90.0) + await proc.wait_for_stdout("Connected", timeout=60.0) + await proc.wait_for_stdout("Loaded stealth client, nickname: debug", timeout=30.0) + yield hub, proc + finally: + await proc.terminate() + + +@pytest_asyncio.fixture +async def debug_linked_tls(docker_stack, tmp_path): + """Stealth mod.debug over a TLS uplink (hub SERVER flags omit +z).""" + conf_dir = _prepare_conf_dir(tmp_path) + hub_crt, hub_key, _gw_crt, _gw_key = GnuworldProc.install_tls_certs(conf_dir) + + hub = FakeHub( + host="127.0.0.1", + tls=True, + tls_certfile=hub_crt, + tls_keyfile=hub_key, + # Intentionally no 'z' — tls=yes must come from the transport. + server_flags="hs", + ) + await hub.start() + + GnuworldProc.write_debug_config(conf_dir / "debug.conf") + GnuworldProc.write_config( + conf_dir / "GNUWorld.conf", + uplink=CONTAINER_UPLINK, + port=hub.port, + password=hub.password, + module_lines=f"module = libdebug.la {CONTAINER_CONF_DIR}/debug.conf", + tls=True, + ) + + proc = GnuworldProc(conf_dir=conf_dir) + await proc.start() + try: + await hub.accept_and_handshake(timeout=90.0) + await proc.wait_for_stdout("Connected", timeout=60.0) + await proc.wait_for_stdout("TLS handshake completed successfully", timeout=30.0) + await proc.wait_for_stdout("Loaded stealth client, nickname: debug", timeout=30.0) + yield hub, proc + finally: + await proc.terminate() + await hub.close() diff --git a/test/harness/data/ccontrol.harness.conf.in b/test/harness/data/ccontrol.harness.conf.in new file mode 100644 index 00000000..1b606cbd --- /dev/null +++ b/test/harness/data/ccontrol.harness.conf.in @@ -0,0 +1,40 @@ +# Minimal ccontrol config for the pytest harness (Docker Postgres). + +sql_host = @SQL_HOST@ +sql_port = @SQL_PORT@ +sql_db = @SQL_DB@ +sql_user = @SQL_USER@ +sql_pass = @SQL_PASS@ + +username = euworld +nickname = euworld +hostname = undernet.org +userdescription = UWorld + +operchanmodes = +isn +mode = +iodkw +operchan = #valhalla +msgchan = #gnuworld.message +limitschan = '' + +glength = 3600 +operchanreason = Oper channel +abuse_mail = abuse@testnet +ccemail = uworld@testnet +SendMail = /bin/true +sendmail = /bin/true +mail_report = 0 +gline_interval = 3600 +max_connection = 5 +max_GLen = 86400 +max_threads = 100 +check_gates = 0 +check_clones = 0 +Expired_interval = 60 +dbinterval = 60 +excess_conn_report_interval = 28800 +showCGIpsInLogs = 0 +AnnounceNick = A +iauthTimeout = 30 +url_excessive_conn = '' +url_install_identd = '' diff --git a/test/harness/data/debug.harness.conf.in b/test/harness/data/debug.harness.conf.in new file mode 100644 index 00000000..669da0d4 --- /dev/null +++ b/test/harness/data/debug.harness.conf.in @@ -0,0 +1,10 @@ +# Generated by the GNUWorld pytest harness — do not edit by hand. +username = debug +nickname = debug +hostname = undernet.org +userdescription = GNUWorld Debug Services +stealth = yes +mode = +ik + +# Accounts allowed to run debug commands (case-insensitive). +permit_user = @PERMIT_USER@ diff --git a/test/harness/data/gnuworld.harness.conf.in b/test/harness/data/gnuworld.harness.conf.in new file mode 100644 index 00000000..a4f15dcf --- /dev/null +++ b/test/harness/data/gnuworld.harness.conf.in @@ -0,0 +1,19 @@ +# Generated by the GNUWorld pytest harness — do not edit by hand. +uplink = @UPLINK@ +port = @PORT@ +password = @PASSWORD@ +name = @NAME@ +description = GNUWorld Test Harness +numeric = @NUMERIC@ +tls = @TLS@ +@TLS_FILES@ +auto_reconnect = no +maxclients = 1023 +hidden_host_suffix = .users.testnet +command_map = @COMMAND_MAP@ +libdir = @LIBDIR@ +glineUpdateInterval = 15 +pingUpdateInterval = 60 +controlnick = control +allowcontrol = tester +@MODULE_LINES@ diff --git a/test/harness/data/tls/gnuworld.crt b/test/harness/data/tls/gnuworld.crt new file mode 100644 index 00000000..4d66be97 --- /dev/null +++ b/test/harness/data/tls/gnuworld.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUTId4YVdMIdfreXvpeYWnXMsoEIcwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQc2VydmljZXMudGVzdG5ldDAeFw0yNjA3MjUyMzIyNDFa +Fw0zNjA3MjIyMzIyNDFaMBsxGTAXBgNVBAMMEHNlcnZpY2VzLnRlc3RuZXQwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCS4cavKwgI+bCiZCVSHkBmagkO +SnOzkwLYZi4MeNehTTlarSfKi3GvO+xvsDvhy0dhro6Cibjv5snxTNkAfOJkMfYT +nZRoYotwDIX1xTpkgalJwRIoEBqlxtiwT9mWZ8OS+SpOsOTftI2dDuXPpk7HBOtL +DEX860H32TCuTlqFDLiVLvdngp4d0ACj1/ZqeKytqhA3DX8Osv8nGIfyL08vRDqs +9xSOh10Gi6w3O9+F5HDcTTX25tuzhlQ97eRSdf2/IPaXRl+b60ht84n3cq5108iO +lOOUxC6Y4QrCBsuhQMT/6SAYiT+A1lUG2dzWLwknAZ1kPbG6b4o2TwzGLW4rAgMB +AAGjUzBRMB0GA1UdDgQWBBQUzK8qQrpGmUSENCs7gtiy4BYSkDAfBgNVHSMEGDAW +gBQUzK8qQrpGmUSENCs7gtiy4BYSkDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBCxXJ7B5BNu6pA28LuLNd6PRndOv4leQgxIZxxXryehWa6sPI1 +U8DdkNg0EcvX1t2SoV6/U2vxKc1jVMRi6vhz9EMIBFbAGozOpWBRACCeo4XAIjny ++Wii/8HiegZ9zoNFWcZZZoSA8K21y5Lqp012HQpx39+Qcdquxwz49wqwmXVmAXzg +8zJ7r5w536HK3qWFnl4bRxLF51EYWn0USQR1LSbE5XC90GOAkat67d8KaXvHk1X9 +sr08hdYWPgy0v6/OcPVxqUPTDB8OhLo+FUNaUswmdPom8CcM4HKFQJZzIRMompK5 +3EW2fsykNRzU9KTeBudEkZEEAFlWVULKxS3E +-----END CERTIFICATE----- diff --git a/test/harness/data/tls/gnuworld.key b/test/harness/data/tls/gnuworld.key new file mode 100644 index 00000000..140ee2e5 --- /dev/null +++ b/test/harness/data/tls/gnuworld.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCS4cavKwgI+bCi +ZCVSHkBmagkOSnOzkwLYZi4MeNehTTlarSfKi3GvO+xvsDvhy0dhro6Cibjv5snx +TNkAfOJkMfYTnZRoYotwDIX1xTpkgalJwRIoEBqlxtiwT9mWZ8OS+SpOsOTftI2d +DuXPpk7HBOtLDEX860H32TCuTlqFDLiVLvdngp4d0ACj1/ZqeKytqhA3DX8Osv8n +GIfyL08vRDqs9xSOh10Gi6w3O9+F5HDcTTX25tuzhlQ97eRSdf2/IPaXRl+b60ht +84n3cq5108iOlOOUxC6Y4QrCBsuhQMT/6SAYiT+A1lUG2dzWLwknAZ1kPbG6b4o2 +TwzGLW4rAgMBAAECggEACtDpapjGnLKWWT76qJNltqT0ScMdxgl7WigcF/sGFfCz +2oOJvJaMJpVJEehuYHAxr3XFNMOjhLcAA+7ew/RuT2aGcmdWOGGZVwT1EGYXqLML +nkzDY4PcEn0UE9etdC/r7PTAMs8/62FGVFx2e9YJUwp/sSUUOk2wq+modg17CPUM +S4sq6mFHxpvMLQRBaRWPuK3vTHPbaXtereNbTYorLX9E0G/Prty74X4n2nLWBQ0w +ZHtWs9sqU3cRqItP0WFsAaA1Xag9D+9YzlPE+SC4uNyU9Up1dcy47VUOL472Kvk/ +vf+1lMBffA1lkDxCbzu78pGinTVQmsKD5RMjigcqYQKBgQDCC0uG+/Os90K61D0f +E8QTMJ6xZo4wrZw44LytgHlQge9oGnYJCwgDeujWhJ3kbbf2M13JNJhKh0AvkrXh +UcEdsem1uD6O0HIpmUoUOvW+VFv1rg5oBGNZTwqTFkrA3TCkyOABPOtoZqU+CxkB +PgUPav9yGOSb+w51KM0X6Q+q+QKBgQDBx45vYhsUzatceLFPXSAiVZivotEBEsBo +Nemil50hmVzDT4bfqXTOqLQObMWiaFCvngWkLocavabyFV2MkT6CURpzKnuhQaNO +RWhAHLrMMAoiWMIsMYW28XbMTLipYHk5nGHxG5espJhWcilSqBGeKTMDyIpSUngs +8fmYgHXnQwKBgBFqZhr1Xgd2Ib0W821onr6CLJwLclOYIV9RfF2uHDVHlC8pwNJK +9Ssqyt8GBA3OcyZbsd0vJUP7I52hc0WHyudZYnp20NaMitKE+YsbR5cPhzljp9Na +IXiQiYhuBcONlqITjVdPGmnCXK5W3KWp5VZe6hJZfZsqSz/kq5OrzUYxAoGBAJ51 +6Wv79dlVNkQwDg6wQI7TIEDAC9ms09pj++IRyVSobMrqRYiwsews2NDS8eqVEyYJ +OuO/iIu9er+L6SwBufQnDlIO83oyirB+4XlMBRTkU+UyX9ZzyLyJSHRYaMlZMsiB +sTXRMn7jOg+220PUXFPRrP3zB+m5trxKQ6kJo2CxAoGAAw0nR539pKnmJa7dQ9+c +ttuwOKrA3txN8tU2V6sum0KD6siBDCmmiplf0cSbovZdsZe2MicvkcwbjdLeMxlf +Wde816xS0rhUgS2siIYa8Gi14D9yheNLGU18cuHEXV+8Y+xlnH2B8ktMjhN96jK7 +FNMCjxnHuVrNudKsqGBG4Y4= +-----END PRIVATE KEY----- diff --git a/test/harness/data/tls/hub.crt b/test/harness/data/tls/hub.crt new file mode 100644 index 00000000..18a88c4c --- /dev/null +++ b/test/harness/data/tls/hub.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIUe9435KK26Gsg38w5prRLyApn0rswDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLaHViLnRlc3RuZXQwHhcNMjYwNzI1MjMyMjQxWhcNMzYw +NzIyMjMyMjQxWjAWMRQwEgYDVQQDDAtodWIudGVzdG5ldDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAMRsKHA2J8GjpBeT/tSgnec1M4pLmbx4IeQlnE56 +5BTY8hwvN0TTwOM35qYDAksXdrq6HmV+LEo2VoAN5sO7K/Q+y48mXAiaqa17RJAC +QmEjJo6QrszAgMbQI1kFS/rurB6r2Z4wWY1xpAd4v1zpQTOnqWAkd4elJyujVYNd +yVUprByidIHkG1ujc4AcJfPbZIjfmkzhYY7/Hl16rCls326C2EA2dPF4BarreykX +/5zgm3zFR+RaGt7AtkbvvtYMx+LaLwhQo662DbseyMikGOnL4/oGlgbbU3xEvO3T +1cPT48oQ3Udl3ChGGYcZk4kDZ12Bk4KOQDZCXZzoTZOoQTcCAwEAAaNTMFEwHQYD +VR0OBBYEFAaAjSQdDWu5vaL19uXFl1ZmJAAgMB8GA1UdIwQYMBaAFAaAjSQdDWu5 +vaL19uXFl1ZmJAAgMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +ADnWX9rpkbvim4bnwjW0lsjs9tMMyo7hTpoT8xMb+jDipLXj556yyT/J83vsOjr9 +Tbb8rPuOd8BUFai9q4a2HCmoT/f7FebE6ptpk+C0IDxU43TZ5KWoYt60AOQzAfGq +umD0hDAohIdZ3YLKwTtvfLLtqfLkqGvX+IB7Uk4ZDE0G2g92WW1b6WYq23EX56i5 +Qy/+ysFxDaxLRwJhx6bFplAxTN67u2s+wFUM6Y2KI81s+F8RicF+yKfYEFFfmKUk +gune78hSKAPJhqH1IXif/sUgyKEJO4cU3uIqDzw35TrmuUR3ot2YJbTnTZFrx8NY +omPVSuiGWiWEwNifxVZ+oo8= +-----END CERTIFICATE----- diff --git a/test/harness/data/tls/hub.key b/test/harness/data/tls/hub.key new file mode 100644 index 00000000..06b207b5 --- /dev/null +++ b/test/harness/data/tls/hub.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDEbChwNifBo6QX +k/7UoJ3nNTOKS5m8eCHkJZxOeuQU2PIcLzdE08DjN+amAwJLF3a6uh5lfixKNlaA +DebDuyv0PsuPJlwImqmte0SQAkJhIyaOkK7MwIDG0CNZBUv67qweq9meMFmNcaQH +eL9c6UEzp6lgJHeHpScro1WDXclVKawconSB5Btbo3OAHCXz22SI35pM4WGO/x5d +eqwpbN9ugthANnTxeAWq63spF/+c4Jt8xUfkWhrewLZG777WDMfi2i8IUKOutg27 +HsjIpBjpy+P6BpYG21N8RLzt09XD0+PKEN1HZdwoRhmHGZOJA2ddgZOCjkA2Ql2c +6E2TqEE3AgMBAAECggEAQ8Q2/WPJupZtDg9qu0wAvlyOABgOHcZqlu5c63ydhQ3G +FfA6Rr4xzZKjOkJOf77EIS8GPqjVufLeTAa/x2ajhvxFOYmX9gX6JVaidHa7FQ1O +B6CmFhESPMVhdJyNtrCyZFCQ33E57EE5QLSpfPIioyIknv1l2cAib/1FivGH/R+s +g19tPLnjxdkVR1XUggzpHvwAEb8HBf0w4D5GsZ/6yy7NxvPdP2ivz3qRD1UTzUx5 +zHzhKTCTwrt4s1UNlmm5bTozK25hYFKeeHyE+7hhA8RhPdobOti9qsFOzV6FXN65 ++IVXNVguvAypAMia3SpvXF6muGKUJARu5Byj9nqUZQKBgQD3CRmR/EzIwkRZ41ED +YX57DTFgvWjR6scFfKcgtgXe+LLMyg48y/ux7X55rQyyzh+Ac+obtDxWiv9sP1p9 +aHnyEXkrVPUwSEfBtQtBES4x9fTe65kD8lobnNWcOWoxq4hJPK8Uh1+vXJZIbeeB +GjX4fyZo8Eq/bm4zHQg6XXwhiwKBgQDLjOAHxpWohqs8DL70Q/DZ7Xp1kiFsL5e4 +Wg6KJOpCu5K5bfPdAtuG8U4uRUKf0r4Dd8h6X1gSLMgPg2B2ELBHxTwIIEgZA04m +ZiI7eeAKCct4lc8X3nysGE4/XPk4c1jkYC4p7o86n4DRXuwGlMMobJojotd0i78v +42tjTDL8hQKBgQDWc+rjxa/utF7b3a+NjMxdDGXqQmPFn9foVn9LsVjFaQbnKx1T +AA3fN3oNLQISE0hbncUFCeE7i+0SzqjCp7j9QNf8mwNDR+wrJ/y+HqkIrClIgCRU +vcYlpG/38AvVVMC6O1kOLDsPpAO+mtJXTCbAM7lnbfql1rsJ2lEzQcap4wKBgQCx ++Szw34t/XLBfwu07eiQfB+so3Wpnw30u7V8FHp3NV2BEzYSJ27PCWz52aoyEXalS +MuuRQ8gnrAwItGAlGxZmymdg24juhdtQ68BGrJtda48CkkoOnrP3bRENiedGmmRA +2m/CrhmBsnDZn9tTLcMtlzd2rS0hdAbogjolCj0SWQKBgBEX2F+N1W6Aj/eDEghO +Gfkkp/T5OmaqCBJAHaQftPatmvumsTGvfhOaA17ipfwD2wbF/COI5zKL8/Cj/BaL +QI8ma402x0PXaR4Ss52pKYFSNoAObro2uTx86ubWvccSn8UhW+5y+k1Yn0FJhhAm +C0UlhH3tBGTd7hpP1U98uMpJ +-----END PRIVATE KEY----- diff --git a/test/harness/docker-compose.yml b/test/harness/docker-compose.yml new file mode 100644 index 00000000..b8dd4669 --- /dev/null +++ b/test/harness/docker-compose.yml @@ -0,0 +1,30 @@ +# GNUWorld integration-test stack: Postgres + gnuworld runtime image. +# +# Gnuworld uses host networking so it can reach FakeHub on 127.0.0.1 +# (pytest on the host) and Postgres on published port 5433. +# Stdout is still captured via `docker compose run --rm -T`. + +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: gnuworld + POSTGRES_PASSWORD: gnuworld + POSTGRES_DB: ccontrol + volumes: + - ./docker/initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gnuworld -d ccontrol"] + interval: 2s + timeout: 5s + retries: 30 + ports: + - "5433:5432" + + gnuworld: + build: + context: ../.. + dockerfile: test/harness/docker/Dockerfile + network_mode: host + # Conf is bind-mounted per test via `compose run -v ...` + profiles: ["run"] diff --git a/test/harness/docker/Dockerfile b/test/harness/docker/Dockerfile new file mode 100644 index 00000000..00edd374 --- /dev/null +++ b/test/harness/docker/Dockerfile @@ -0,0 +1,25 @@ +# Runtime image for integration tests: host-built binaries + libs. +FROM debian:trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + libltdl7 \ + libpq5 \ + libssl3 \ + libstdc++6 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/gnuworld + +# Prebuilt artifacts from the repo (build on the host first). +COPY bin/gnuworld bin/gnuworld +COPY lib/ lib/ +COPY share/gnuworld/ share/gnuworld/ +COPY mod.ccontrol/migrations/ migrations/ccontrol/ + +ENV LD_LIBRARY_PATH=/opt/gnuworld/lib +WORKDIR /opt/gnuworld/bin + +ENTRYPOINT ["./gnuworld"] +CMD ["-c", "-f", "/etc/gnuworld/GNUWorld.conf", "-L", "-D"] diff --git a/test/harness/docker/initdb/01_ccontrol_schema.sql b/test/harness/docker/initdb/01_ccontrol_schema.sql new file mode 100755 index 00000000..3e60ea00 --- /dev/null +++ b/test/harness/docker/initdb/01_ccontrol_schema.sql @@ -0,0 +1,197 @@ +-- "$Id: ccontrol.sql,v 1.34 2009/07/25 18:12:33 hidden1 Exp $" + +-- 2022-12-27: Hidden +-- Removed the two shell tables (ShellCompanies and ShellNetblocks) + +-- 2019-06-29: Hidden +-- Modified ipLISPs table for unidented glines + +-- 2016-04-17 : Hidden +-- Added Single Sign On + /oper anywhere support + +-- 2009-01-16 : Spike +-- Merged ShellCompanies and ShellNetblocks tables in. + +-- 2002-25-02 : |MrBean| +-- Added the Misc table + +-- 2001-10-14 : nighty +-- corrected fieldname typo in "suspendReason" -> "suspend_reason" + +-- 2001-13-02 : |MrBean| +-- Added level patch for ccontrol module + +-- 2001-22-02 : |MrBean| +-- Added help table for a new help system being developed + +-- 2001-13-03 : |MrBean| +-- Added Glines table + +-- 2001-30-04 : |MrBean| +-- Added servers table + + +CREATE TABLE opers ( + user_id SERIAL, + user_name TEXT NOT NULL UNIQUE, + password VARCHAR (40) NOT NULL, + access INT4 NOT NULL DEFAULT '0', + saccess INT4 NOT NULL DEFAULT '0', +-- For a full list of access mask see CControlCommands.h + server VARCHAR (128) NOT NULL DEFAULT 'undernet.org', -- the server the oper is assosiated to + flags INT4 NOT NULL DEFAULT '0', + isSuspended BOOLEAN NOT NULL DEFAULT 'n', + suspend_expires INT4, + suspend_level INT4, + suspended_by VARCHAR(128), + suspend_Reason VARCHAR(256), + isUHS BOOLEAN NOT NULL DEFAULT 'n', + isOPER BOOLEAN NOT NULL DEFAULT 'n', + isADMIN BOOLEAN NOT NULL DEFAULT 'n', + isSMT BOOLEAN NOT NULL DEFAULT 'n', + isCODER BOOLEAN NOT NULL DEFAULT 'n', + getLOGS BOOLEAN NOT NULL DEFAULT 'n', + GetLAG BOOLEAN NOT NULL DEFAULT 'n', + needOP BOOLEAN NOT NULL DEFAULT 'n', + autoop BOOLEAN NOT NULL DEFAULT 'n', + sso BOOLEAN NOT NULL DEFAULT 't', + ssooo BOOLEAN NOT NULL DEFAULT 't', + email VARCHAR(128), + account VARCHAR(128), + accountts INT4 NOT NULL DEFAULT '0', + last_updated_by VARCHAR (128), -- nick!user@host + last_updated INT4 NOT NULL, + LastPassChangeTS INT4 NOT NULL DEFAULT '0', + notice BOOLEAN NOT NULL DEFAULT 't', + is_deleted BOOLEAN NOT NULL DEFAULT 'n', + PRIMARY KEY( user_id ) +); + +CREATE TABLE hosts ( + user_id INT4 NOT NULL, + host VARCHAR(128) NOT NULL + ); + +CREATE TABLE help ( + command VARCHAR(40) NOT NULL, + subcommand VARCHAR(40), + line INT4 NOT NULL DEFAULT '1', + help VARCHAR(255) +); + +CREATE TABLE glines ( + Id SERIAL, + Host VARCHAR(128) UNIQUE NOT NULL, + AddedBy VARCHAR(128) NOT NULL, + AddedOn INT4 NOT NULL, + ExpiresAt INT4 NOT NULL, + LastUpdated INT4 NOT NULL DEFAULT date_part('epoch', CURRENT_TIMESTAMP)::int, + Reason VARCHAR(255), + TrackingId VARCHAR(32) + ); + +CREATE TABLE servers ( + Name VARCHAR(100) NOT NULL, + LastUplink VARCHAR(100), + LastConnected INT4 NOT NULL DEFAULT '0', + SplitedOn INT4 NOT NULL DEFAULT '0', + LastNumeric VARCHAR(4), + SplitReason VARCHAR(512), + Version VARCHAR(256), + AddedOn INT4 NOT NULL, + LastUpdated INT4 NOT NULL, + ReportMissing BOOLEAN NOT NULL DEFAULT 't' + ); + +CREATE TABLE comlog ( + ts INT4 NOT NULL, + oper text, + command VARCHAR(512) + ); + +CREATE TABLE opernews ( + MessageId SERIAL, + MessageFlags INT4 NOT NULL DEFAULT '0', + PostTime INT4 NOT NULL, + PostedBy VARCHAR(128) NOT NULL, + Message VARCHAR(512) NOT NULL, + ExpiresOn INT4 + ); + +CREATE TABLE exceptions ( + Host VARCHAR(128) NOT NULL, + Connections INT4 NOT NULL, + AddedBy VARCHAR(128) NOT NULL, + AddedOn INT4 NOT NULL DEFAULT date_part('epoch', CURRENT_TIMESTAMP)::int, + Reason VARCHAR(450) + ); + +CREATE TABLE commands ( + RealName VARCHAR(128) NOT NULL UNIQUE, + Name VARCHAR(128) NOT NULL UNIQUE, + Flags INT4 NOT NULL, + IsDisabled BOOLEAN NOT NULL DEFAULT 'n', + NeedOp BOOLEAN NOT NULL DEFAULT 'n', + NoLog BOOLEAN NOT NULL DEFAULT 'n', + MinLevel INT4 NOT NULL DEFAULT '1', + SAccess BOOLEAN NOT NULL DEFAULT 'n' + ); + +CREATE TABLE notes ( + user_id INT4 NOT NULL REFERENCES opers (user_id), + sentby TEXT NOT NULL, + postedOn INT4 NOT NULL, + IsNew BOOLEAN NOT NULL DEFAULT 'n', + note VARCHAR (512) + ); + +CREATE TABLE Misc ( + VarName VARCHAR(30) NOT NULL, + Value1 INT4, + Value2 INT4, + Value3 INT4, + Value4 VARCHAR(40), + Value5 VARCHAR(128) + ); + +CREATE TABLE BadChannels ( + Name VARCHAR(400) NOT NULL, + Reason VARCHAR(512) NOT NULL, + AddedBy VARCHAR(200) NOT NULL + ); + +CREATE TABLE ipLISPs ( + id SERIAL, + name VARCHAR(200) UNIQUE NOT NULL, + email VARCHAR(200) NOT NULL, + clonecidr int4 NOT NULL DEFAULT 0, + maxlimit int4 NOT NULL DEFAULT 0, + maxidentlimit int4 NOT NULL DEFAULT 0, + forcecount int4 NOT NULL DEFAULT 0, + glunidented int4 NOT NULL DEFAULT 0, + active int4 NOT NULL DEFAULT 1, + nogline int4 NOT NULL DEFAULT 0, + isgroup int4 NOT NULL DEFAULT 0, + addedby VARCHAR(200) NOT NULL, + addedon int4 NOT NULL, + lastmodby VARCHAR(200) NOT NULL, + lastmodon int4 NOT NULL + ); + +CREATE TABLE ipLNetblocks ( + ispid int4 NOT NULL, + cidr VARCHAR(50) NOT NULL, + addedby VARCHAR(200) NOT NULL, + addedon int4 NOT NULL + ); + +INSERT INTO public.iplisps (id, name, email, clonecidr, maxlimit, forcecount, active, isgroup, addedby, addedon, lastmodby, lastmodon, maxidentlimit, glunidented, nogline) VALUES (1, 'default32', 'email@not.available', 32, 5, 1, 1, 0, 'euworld!euworld@undernet.org', 0, 'euworld!euworld@undernet.org', 0, 5, 0, 0); +INSERT INTO public.iplisps (id, name, email, clonecidr, maxlimit, forcecount, active, isgroup, addedby, addedon, lastmodby, lastmodon, maxidentlimit, glunidented, nogline) VALUES (2, 'default24', 'email@not.available', 24, 50, 0, 1, 0, 'euworld!euworld@undernet.org', 0, 'euworld!euworld@undernet.org', 0, 5, 0, 0); +INSERT INTO public.iplisps (id, name, email, clonecidr, maxlimit, forcecount, active, isgroup, addedby, addedon, lastmodby, lastmodon, maxidentlimit, glunidented, nogline) VALUES (3, 'default48', 'email@not.available', 48, 25, 0, 1, 0, 'euworld!euworld@undernet.org', 0, 'euworld!euworld@undernet.org', 0, 5, 0, 0); +INSERT INTO public.iplisps (id, name, email, clonecidr, maxlimit, forcecount, active, isgroup, addedby, addedon, lastmodby, lastmodon, maxidentlimit, glunidented, nogline) VALUES (4, 'default64', 'email@not.available', 64, 4, 0, 1, 0, 'euworld!euworld@undernet.org', 0, 'euworld!euworld@undernet.org', 0, 5, 0, 0); + +INSERT INTO public.iplnetblocks (ispid, cidr, addedby, addedon) VALUES (1, '0.0.0.0/0', 'euworld!euworld@undernet.org', 0); +INSERT INTO public.iplnetblocks (ispid, cidr, addedby, addedon) VALUES (2, '0.0.0.0/0', 'euworld!euworld@undernet.org', 0); +INSERT INTO public.iplnetblocks (ispid, cidr, addedby, addedon) VALUES (3, '0::/0', 'euworld!euworld@undernet.org', 0); +INSERT INTO public.iplnetblocks (ispid, cidr, addedby, addedon) VALUES (4, '0::/0', 'euworld!euworld@undernet.org', 0); + diff --git a/test/harness/docker/initdb/02_gline_tracking_id.sql b/test/harness/docker/initdb/02_gline_tracking_id.sql new file mode 100644 index 00000000..70edd712 --- /dev/null +++ b/test/harness/docker/initdb/02_gline_tracking_id.sql @@ -0,0 +1 @@ +ALTER TABLE glines ADD COLUMN IF NOT EXISTS TrackingId VARCHAR(32); diff --git a/test/harness/fake_hub.py b/test/harness/fake_hub.py new file mode 100644 index 00000000..cb19f19e --- /dev/null +++ b/test/harness/fake_hub.py @@ -0,0 +1,575 @@ +"""Listening fake ircu P10 hub for GNUWorld integration tests.""" + +from __future__ import annotations + +import asyncio +import logging +import ssl +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from p10 import ( + client_numnick, + int_to_b64, + ipv4_to_b64, + p10_token, + server_numeric, + strip_msg_tags, +) + +logger = logging.getLogger("fake_hub") + +Predicate = Callable[[str], bool] + + +class FakeHub: + """A fake IRC hub that listens and speaks P10 to an inbound GNUWorld. + + Lifecycle: + await hub.start() + # start gnuworld pointed at hub.host:hub.port + await hub.accept_and_handshake() + ... + await hub.close() + """ + + def __init__( + self, + name: str = "hub.testnet", + numeric: int = 1, + password: str = "testpass", + max_clients: int = 262143, + description: str = "Fake Hub", + server_flags: str = "hs", + host: str = "0.0.0.0", + *, + tls: bool = False, + tls_certfile: str | Path | None = None, + tls_keyfile: str | Path | None = None, + ): + self.name = name + self.numeric = numeric + self.password = password + self.max_clients = max_clients + self.description = description + self.server_flags = server_flags + self.host = host + self.tls = tls + self.tls_certfile = Path(tls_certfile) if tls_certfile else None + self.tls_keyfile = Path(tls_keyfile) if tls_keyfile else None + + self._num = server_numeric(numeric) + self._numnick_mask = self._num + int_to_b64(max_clients, 3) + + self._server: asyncio.AbstractServer | None = None + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self.port: int | None = None + + self.connected = False + self.burst_complete = False + self.peer_name: str | None = None + self.peer_numeric: str | None = None # 2-char YY from peer SERVER line + self.peer_server_line: str | None = None + + self.users: dict[str, dict[str, Any]] = {} + self.received: list[str] = [] + self.sent: list[str] = [] + self._next_client_num = 0 + + @property + def server_numnick(self) -> str: + """This hub's 2-char P10 numeric.""" + return self._num + + def _ssl_context(self) -> ssl.SSLContext: + if not self.tls_certfile or not self.tls_keyfile: + raise ValueError("tls=True requires tls_certfile and tls_keyfile") + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 + ctx.load_cert_chain(str(self.tls_certfile), str(self.tls_keyfile)) + # Gnuworld uses SSL_VERIFY_NONE; do not require a client cert. + ctx.verify_mode = ssl.CERT_NONE + return ctx + + async def start(self) -> None: + """Bind an ephemeral port and begin listening (plain or TLS).""" + ssl_ctx = self._ssl_context() if self.tls else None + self._server = await asyncio.start_server( + self._on_connect, self.host, 0, ssl=ssl_ctx + ) + sockets = self._server.sockets + assert sockets + self.port = sockets[0].getsockname()[1] + logger.debug( + "FakeHub listening on %s:%d tls=%s", + self.host, + self.port, + self.tls, + ) + + async def _on_connect( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + if self._reader is not None: + # Only accept the first connection. + writer.close() + await writer.wait_closed() + return + self._reader = reader + self._writer = writer + self.connected = True + peer = writer.get_extra_info("peername") + logger.debug("Accepted connection from %s", peer) + + async def accept_and_handshake(self, timeout: float = 30.0) -> None: + """Wait for GNUWorld to connect, then complete the P10 handshake. + + Expected order: + << PASS / SERVER from gnuworld + >> PASS / SERVER / EB from hub + << EB / EA from gnuworld (after its empty or module burst) + >> EA from hub + """ + deadline = asyncio.get_event_loop().time() + timeout + + # Wait until accept callback fires + while self._reader is None: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + raise TimeoutError("Timed out waiting for gnuworld to connect") + await asyncio.sleep(0.05) + + # Read PASS + line = await self._recv(timeout=max(0.1, deadline - asyncio.get_event_loop().time())) + if p10_token(line) != "PASS": + raise RuntimeError(f"Expected PASS from gnuworld, got: {line!r}") + + # Read SERVER + line = await self._recv(timeout=max(0.1, deadline - asyncio.get_event_loop().time())) + if p10_token(line) != "SERVER": + raise RuntimeError(f"Expected SERVER from gnuworld, got: {line!r}") + self.peer_server_line = line + self._parse_peer_server(line) + + now = int(time.time()) + await self._send(f"PASS :{self.password}") + flag_field = f"+{self.server_flags}" if self.server_flags else "+" + await self._send( + f"SERVER {self.name} 1 {now} {now} J10 {self._numnick_mask} " + f"{flag_field} :{self.description}" + ) + # Empty network burst from the hub + await self._send(f"{self._num} EB") + + # Wait for peer EB then EA + saw_eb = False + while True: + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + raise TimeoutError("Timed out waiting for peer EB/EA") + line = await self._recv(timeout=remaining) + tok = p10_token(line) + if tok == "EB": + saw_eb = True + continue + if tok == "EA" and saw_eb: + break + + await self._send(f"{self._num} EA") + self.burst_complete = True + logger.debug( + "Handshake complete; peer=%s numeric=%s", + self.peer_name, + self.peer_numeric, + ) + + def _parse_peer_server(self, line: str) -> None: + # SERVER J10 +flags :desc + parts = strip_msg_tags(line).split() + # Unprefixed: SERVER name ... + if parts[0] != "SERVER": + raise RuntimeError(f"Not a SERVER line: {line!r}") + self.peer_name = parts[1] + # Find J10 token then the following numnick mask + try: + j10_idx = next(i for i, p in enumerate(parts) if p.startswith("J")) + mask = parts[j10_idx + 1] + self.peer_numeric = mask[:2] + except (StopIteration, IndexError) as exc: + raise RuntimeError(f"Could not parse numeric from SERVER: {line!r}") from exc + + async def close(self) -> None: + """Close the client connection and stop listening.""" + if self._writer is not None: + try: + if self.burst_complete and self.peer_name: + await self._send(f"{self._num} SQ {self.peer_name} 0 :Test done") + except Exception: + pass + try: + self._writer.close() + await self._writer.wait_closed() + except (OSError, ConnectionError): + pass + self._writer = None + self._reader = None + self.connected = False + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _send(self, line: str) -> None: + if not self._writer: + raise ConnectionError("Not connected") + logger.debug(">> %s", line) + self.sent.append(line) + self._writer.write((line + "\r\n").encode("utf-8")) + await self._writer.drain() + + async def send_raw(self, line: str) -> None: + """Send an arbitrary P10 line to gnuworld.""" + await self._send(line) + + async def send_tagged(self, tags: str | dict[str, str | None], line: str) -> None: + """Send ``line`` with an IRCv3 ``@tag`` prefix. + + ``tags`` is either a raw tag-section (without leading ``@``), e.g. + ``time=2026-07-25T12:00:00.000Z;+ex/foo=1``, or a dict of key→value + (``None`` / empty value → key-only tag). + """ + if isinstance(tags, dict): + parts = [] + for key, value in tags.items(): + if value is None or value == "": + parts.append(key) + else: + parts.append(f"{key}={value}") + tag_section = ";".join(parts) + else: + tag_section = tags.lstrip("@") + await self._send(f"@{tag_section} {line}") + + async def _recv_raw(self, timeout: float = 10.0) -> str: + if not self._reader: + raise ConnectionError("Not connected") + raw = await asyncio.wait_for(self._reader.readline(), timeout=timeout) + if not raw: + raise ConnectionError("Connection closed by peer") + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + logger.debug("<< %s", line) + self.received.append(line) + return line + + async def _recv(self, timeout: float = 10.0) -> str: + """Read one line, auto-answering PINGs; track peer N lines.""" + while True: + line = await self._recv_raw(timeout=timeout) + payload = strip_msg_tags(line) + tokens = payload.split() + + if tokens and tokens[0] == "PING": + origin = tokens[1].lstrip(":") if len(tokens) > 1 else self.name + await self._send(f"{self._num} Z {self._num} :{origin}") + continue + if len(tokens) >= 2 and tokens[1] == "G": + # Old: Az G :reason → AB Z Az :reason + # New: Az G !cookie ... + if len(tokens) == 3 and tokens[2].startswith(":"): + await self._send(f"{self._num} Z {tokens[0]} {tokens[2]}") + else: + cookie = tokens[2].lstrip("!") if len(tokens) > 2 else "0" + await self._send(f"{self._num} Z {tokens[0]} :{cookie}") + continue + + if len(tokens) >= 2 and tokens[1] == "N": + self._parse_nick(payload) + + return line + + def _parse_nick(self, payload: str) -> None: + # N <+modes> [..] : + parts = payload.split() + if len(parts) < 10: + return + nick = parts[2] + numnick = parts[-2] if not parts[-1].startswith(":") else parts[-2] + # Realname is trailing; numnick is the token before it + for i, p in enumerate(parts): + if p.startswith(":") and i > 8: + numnick = parts[i - 1] + break + self.users[nick.lower()] = { + "nick": nick, + "numnick": numnick, + "raw": payload, + } + + def get_user_numnick(self, nick: str) -> str | None: + info = self.users.get(nick.lower()) + return info["numnick"] if info else None + + async def wait_for( + self, + match: str | Predicate, + timeout: float = 10.0, + *, + after: int = 0, + ) -> str: + """Wait for a line matching a token string or predicate. + + ``after`` skips ``received[:after]`` so callers can wait for a + reply that arrives after a specific send. + """ + if isinstance(match, str): + token = match + + def pred(line: str) -> bool: + return p10_token(line) == token + + else: + pred = match + + deadline = asyncio.get_event_loop().time() + timeout + start_idx = max(0, after) + while True: + for line in self.received[start_idx:]: + if pred(line): + return line + start_idx = len(self.received) + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + raise TimeoutError(f"Timed out waiting for {match!r}") + try: + await self._recv(timeout=min(remaining, 1.0)) + except asyncio.TimeoutError: + continue + + async def wait_for_token( + self, token: str, timeout: float = 10.0, *, after: int = 0 + ) -> str: + """Wait until a line whose P10 token matches.""" + return await self.wait_for(token, timeout=timeout, after=after) + + async def assert_no_message(self, token: str, timeout: float = 1.0) -> None: + """Fail if a line with the given token arrives within timeout.""" + try: + line = await self.wait_for_token(token, timeout=timeout) + except TimeoutError: + return + raise AssertionError(f"Unexpected message with token {token!r}: {line!r}") + + async def introduce_nick( + self, + nick: str, + username: str = "fakeuser", + host: str = "fake.testnet", + modes: str = "+i", + realname: str = "Fake User", + ip: str = "127.0.0.1", + hops: int = 1, + tags: str | dict[str, str | None] | None = None, + ) -> str: + """Introduce a nick originating from this hub via a P10 N message. + + Returns the allocated numnick. Optional ``tags`` prefixes the N line + with an IRCv3 message-tag section (e.g. ``@time=…;+client/tag=…``). + """ + client_num = self._next_client_num + self._next_client_num += 1 + numnick = client_numnick(self.numeric, client_num) + ts = int(time.time()) + ip64 = ipv4_to_b64(ip) + line = ( + f"{self._num} N {nick} {hops} {ts} {username} {host} {modes} " + f"{ip64} {numnick} :{realname}" + ) + if tags is not None: + await self.send_tagged(tags, line) + else: + await self._send(line) + self.users[nick.lower()] = { + "nick": nick, + "numnick": numnick, + "user": username, + "host": host, + "modes": modes, + "realname": realname, + "ip": ip, + } + return numnick + + async def burst_channel( + self, + channel: str, + members: list[str] | None = None, + modes: str = "+tn", + ts: int | None = None, + ) -> None: + """Burst a channel via a P10 B message. + + ``members`` is a list of numnicks, optionally with modes like ``ABAAA:o``. + If omitted, uses all currently introduced hub users as plain members. + """ + if ts is None: + ts = int(time.time()) + if members is None: + members = [u["numnick"] for u in self.users.values()] + member_field = ",".join(members) if members else "" + # Format: B +modes [modeargs] members + if member_field: + await self._send(f"{self._num} B {channel} {ts} {modes} {member_field}") + else: + await self._send(f"{self._num} B {channel} {ts} {modes}") + + async def send_xquery( + self, + routing: str, + message: str, + target: str | None = None, + tags: str | dict[str, str | None] | None = None, + ) -> None: + """Send an XQUERY to gnuworld. + + Wire: ``[@tags ] XQ :`` + ``target`` defaults to the peer's server numeric from handshake. + """ + if target is None: + if not self.peer_numeric: + raise RuntimeError("No peer numeric; handshake not complete") + target = self.peer_numeric + line = f"{self._num} XQ {target} {routing} :{message}" + if tags is not None: + await self.send_tagged(tags, line) + else: + await self._send(line) + + async def send_privmsg( + self, + from_numnick: str, + target: str, + text: str, + tags: str | dict[str, str | None] | None = None, + ) -> None: + line = f"{from_numnick} P {target} :{text}" + if tags is not None: + await self.send_tagged(tags, line) + else: + await self._send(line) + + async def send_xreply( + self, + routing: str, + reply: str, + target: str | None = None, + ) -> None: + """Send an XREPLY. + + Wire: `` XR :`` + """ + if target is None: + if not self.peer_numeric: + raise RuntimeError("No peer numeric; handshake not complete") + target = self.peer_numeric + await self._send(f"{self._num} XR {target} {routing} :{reply}") + + async def send_notice(self, from_numnick: str, target: str, text: str) -> None: + await self._send(f"{from_numnick} O {target} :{text}") + + async def introduce_leaf( + self, + name: str, + numeric: int, + *, + hop: int = 2, + flags: str = "", + description: str = "Downstream test server", + timestamp: int | None = None, + protocol: str = "J10", + ) -> str: + """Introduce a remote leaf behind this hub (P10 ``S``). + + ``flags`` are letters without ``+`` (e.g. ``\"z\"`` for TLS, ``\"hz\"`` + for hub+TLS). Returns the leaf's 2-char P10 numeric. + """ + ts = timestamp if timestamp is not None else int(time.time()) + down_num = server_numeric(numeric) + down_mask = down_num + int_to_b64(self.max_clients, 3) + flag_field = f"+{flags}" if flags else "+" + await self._send( + f"{self._num} S {name} {hop} 0 {ts} {protocol} {down_mask} " + f"{flag_field} :{description}" + ) + return down_num + + async def end_burst(self, server_yy: str | None = None) -> None: + """Send End of Burst for ``server_yy`` (defaults to this hub).""" + yy = server_yy or self._num + await self._send(f"{yy} EB") + + async def introduce_leaf_nick( + self, + leaf_yy: str, + leaf_numeric: int, + nick: str, + *, + client_num: int = 1, + username: str | None = None, + host: str = "leaf.testnet", + modes: str = "+i", + realname: str = "Leaf User", + ip: str = "127.0.0.1", + hops: int = 1, + ) -> str: + """Introduce a nick homed on a previously announced leaf server.""" + ts = int(time.time()) + user = username or nick.lower() + numnick = client_numnick(leaf_numeric, client_num) + ip64 = ipv4_to_b64(ip) + await self._send( + f"{leaf_yy} N {nick} {hops} {ts} {user} {host} {modes} " + f"{ip64} {numnick} :{realname}" + ) + self.users[nick.lower()] = { + "nick": nick, + "numnick": numnick, + "user": user, + "host": host, + "modes": modes, + "realname": realname, + "ip": ip, + "server": leaf_yy, + } + return numnick + + async def send_account( + self, + target_numnick: str, + account: str, + *, + acc_id: int | None = None, + acc_flags: int | None = None, + from_yy: str | None = None, + ) -> None: + """Set a client's account via P10 ``AC``. + + Wire: `` AC [ []]`` + """ + yy = from_yy or self._num + parts = f"{yy} AC {target_numnick} {account}" + if acc_id is not None: + parts += f" {acc_id}" + if acc_flags is not None: + parts += f" {acc_flags}" + await self._send(parts) + + async def drain_messages(self, timeout: float = 0.5) -> None: + """Read and process pending messages until quiet.""" + while True: + try: + await self._recv(timeout=timeout) + except (asyncio.TimeoutError, TimeoutError): + break diff --git a/test/harness/gnuworld_proc.py b/test/harness/gnuworld_proc.py new file mode 100644 index 00000000..d7bf38d6 --- /dev/null +++ b/test/harness/gnuworld_proc.py @@ -0,0 +1,320 @@ +"""Spawn and supervise GNUWorld (Docker) for integration tests. + +Postgres and the gnuworld image run under docker compose. The FakeHub +listens on the host; gnuworld uses host networking to reach it at +``127.0.0.1``. + +Stdout from ``docker compose run -T gnuworld`` (gnuworld ``-c``) is +piped into this process so tests can call ``wait_for_stdout`` exactly +as they would with a local binary. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import signal +import subprocess +import time +import uuid +from pathlib import Path + +logger = logging.getLogger("gnuworld_proc") + +HARNESS_DIR = Path(__file__).resolve().parent +REPO_ROOT = HARNESS_DIR.parent.parent +COMPOSE_FILE = HARNESS_DIR / "docker-compose.yml" +CONF_TEMPLATE = HARNESS_DIR / "data" / "gnuworld.harness.conf.in" +CCONTROL_CONF_TEMPLATE = HARNESS_DIR / "data" / "ccontrol.harness.conf.in" +DEBUG_CONF_TEMPLATE = HARNESS_DIR / "data" / "debug.harness.conf.in" +TLS_DIR = HARNESS_DIR / "data" / "tls" +RUN_DIR = HARNESS_DIR / "run" + +# Paths inside the gnuworld container / host-network process +CONTAINER_CONF_DIR = "/etc/gnuworld" +CONTAINER_COMMAND_MAP = "/opt/gnuworld/share/gnuworld/server_command_map" +CONTAINER_LIBDIR = "/opt/gnuworld/lib" +# host networking: FakeHub and published Postgres are on the host loopback +CONTAINER_UPLINK = "127.0.0.1" + +# Postgres via published host port (gnuworld uses network_mode: host) +DEFAULT_SQL_HOST = "127.0.0.1" +DEFAULT_SQL_PORT = "5433" +DEFAULT_SQL_DB = "ccontrol" +DEFAULT_SQL_USER = "gnuworld" +DEFAULT_SQL_PASS = "gnuworld" + + +def _compose_cmd(*args: str) -> list[str]: + return ["docker", "compose", "-f", str(COMPOSE_FILE), *args] + + +class DockerStack: + """Session-scoped helper: build image, start Postgres, tear down.""" + + def __init__(self) -> None: + self.started = False + + def up(self) -> None: + RUN_DIR.mkdir(parents=True, exist_ok=True) + logger.info("Building gnuworld image and starting Postgres...") + subprocess.run( + _compose_cmd("build", "gnuworld"), + cwd=str(HARNESS_DIR), + check=True, + ) + subprocess.run( + _compose_cmd("up", "-d", "postgres"), + cwd=str(HARNESS_DIR), + check=True, + ) + self._wait_healthy("postgres", timeout=60.0) + self.started = True + + def down(self) -> None: + subprocess.run( + _compose_cmd("down", "-v", "--remove-orphans"), + cwd=str(HARNESS_DIR), + check=False, + ) + self.started = False + + def _wait_healthy(self, service: str, timeout: float) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + ready = subprocess.run( + _compose_cmd( + "exec", + "-T", + "postgres", + "pg_isready", + "-U", + DEFAULT_SQL_USER, + "-d", + DEFAULT_SQL_DB, + ), + cwd=str(HARNESS_DIR), + capture_output=True, + check=False, + ) + if ready.returncode == 0: + logger.info("Postgres is ready") + return + time.sleep(1.0) + raise TimeoutError(f"Timed out waiting for {service} to become ready") + + +class GnuworldProc: + """Runs gnuworld via ``docker compose run`` and captures stdout.""" + + def __init__(self, conf_dir: Path, container_name: str | None = None): + """``conf_dir`` is a host directory bind-mounted at /etc/gnuworld. + + It must contain ``GNUWorld.conf`` (and optionally module confs). + """ + self.conf_dir = Path(conf_dir) + self.container_name = container_name or f"gw-test-{uuid.uuid4().hex[:10]}" + self.proc: asyncio.subprocess.Process | None = None + self.stdout_lines: list[str] = [] + self._reader_task: asyncio.Task | None = None + self._line_event = asyncio.Event() + + @staticmethod + def write_config( + path: Path, + *, + uplink: str = CONTAINER_UPLINK, + port: int, + password: str = "testpass", + name: str = "services.testnet", + numeric: int = 51, + command_map: str = CONTAINER_COMMAND_MAP, + libdir: str = CONTAINER_LIBDIR, + module_lines: str = "", + tls: bool = False, + tls_key_file: str | None = None, + tls_cert_file: str | None = None, + ) -> Path: + text = CONF_TEMPLATE.read_text(encoding="utf-8") + if tls: + key = tls_key_file or f"{CONTAINER_CONF_DIR}/gnuworld.key" + cert = tls_cert_file or f"{CONTAINER_CONF_DIR}/gnuworld.crt" + tls_files = f"tlsKeyFile = {key}\ntlsCertFile = {cert}\n" + tls_val = "yes" + else: + tls_files = "" + tls_val = "no" + replacements = { + "@UPLINK@": uplink, + "@PORT@": str(port), + "@PASSWORD@": password, + "@NAME@": name, + "@NUMERIC@": str(numeric), + "@COMMAND_MAP@": command_map, + "@LIBDIR@": libdir, + "@MODULE_LINES@": module_lines, + "@TLS@": tls_val, + "@TLS_FILES@": tls_files, + } + for key, value in replacements.items(): + text = text.replace(key, value) + path.write_text(text, encoding="utf-8") + return path + + @staticmethod + def install_tls_certs(conf_dir: Path) -> tuple[Path, Path, Path, Path]: + """Copy harness TLS material into the bind-mounted conf dir. + + Returns (hub_crt, hub_key, gw_crt, gw_key) host paths. + """ + import shutil + + for name in ("hub.crt", "hub.key", "gnuworld.crt", "gnuworld.key"): + shutil.copy2(TLS_DIR / name, conf_dir / name) + return ( + conf_dir / "hub.crt", + conf_dir / "hub.key", + conf_dir / "gnuworld.crt", + conf_dir / "gnuworld.key", + ) + + @staticmethod + def write_ccontrol_config( + path: Path, + *, + sql_host: str = DEFAULT_SQL_HOST, + sql_port: str = DEFAULT_SQL_PORT, + sql_db: str = DEFAULT_SQL_DB, + sql_user: str = DEFAULT_SQL_USER, + sql_pass: str = DEFAULT_SQL_PASS, + ) -> Path: + text = CCONTROL_CONF_TEMPLATE.read_text(encoding="utf-8") + replacements = { + "@SQL_HOST@": os.environ.get("CCONTROL_SQL_HOST", sql_host), + "@SQL_PORT@": os.environ.get("CCONTROL_SQL_PORT", sql_port), + "@SQL_DB@": os.environ.get("CCONTROL_SQL_DB", sql_db), + "@SQL_USER@": os.environ.get("CCONTROL_SQL_USER", sql_user), + "@SQL_PASS@": os.environ.get("CCONTROL_SQL_PASS", sql_pass), + } + for key, value in replacements.items(): + text = text.replace(key, value) + path.write_text(text, encoding="utf-8") + return path + + @staticmethod + def write_debug_config( + path: Path, + *, + permit_user: str = "MrIron", + ) -> Path: + text = DEBUG_CONF_TEMPLATE.read_text(encoding="utf-8") + path.write_text( + text.replace("@PERMIT_USER@", permit_user), + encoding="utf-8", + ) + return path + + async def start(self) -> None: + if not (self.conf_dir / "GNUWorld.conf").is_file(): + raise FileNotFoundError(f"Missing {self.conf_dir / 'GNUWorld.conf'}") + + # Bind-mount this test's conf dir over /etc/gnuworld for the one-off run. + cmd = _compose_cmd( + "run", + "--rm", + "-T", + "--name", + self.container_name, + "-v", + f"{self.conf_dir.resolve()}:{CONTAINER_CONF_DIR}:ro", + "gnuworld", + ) + logger.debug("Starting: %s", " ".join(cmd)) + self.proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=str(HARNESS_DIR), + start_new_session=True, + ) + self._reader_task = asyncio.create_task(self._read_stdout()) + + async def _read_stdout(self) -> None: + assert self.proc and self.proc.stdout + while True: + raw = await self.proc.stdout.readline() + if not raw: + break + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + logger.debug("GW| %s", line) + self.stdout_lines.append(line) + self._line_event.set() + + async def wait_for_stdout( + self, + pattern: str | re.Pattern[str], + timeout: float = 30.0, + ) -> str: + if isinstance(pattern, str): + regex = re.compile(re.escape(pattern)) + else: + regex = pattern + + deadline = asyncio.get_event_loop().time() + timeout + start_idx = 0 + while True: + for line in self.stdout_lines[start_idx:]: + if regex.search(line): + return line + start_idx = len(self.stdout_lines) + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + recent = "\n".join(self.stdout_lines[-40:]) + raise TimeoutError( + f"Timed out waiting for stdout matching {pattern!r}. " + f"Recent output:\n{recent}" + ) + self._line_event.clear() + try: + await asyncio.wait_for(self._line_event.wait(), timeout=min(remaining, 1.0)) + except asyncio.TimeoutError: + if self.proc and self.proc.returncode is not None: + recent = "\n".join(self.stdout_lines[-40:]) + raise RuntimeError( + f"gnuworld exited with {self.proc.returncode} while waiting " + f"for {pattern!r}. Recent output:\n{recent}" + ) + continue + + async def terminate(self, grace: float = 5.0) -> None: + if self.proc is None: + return + if self.proc.returncode is not None: + if self._reader_task: + await self._reader_task + return + + # Prefer docker stop so gnuworld can shut down cleanly + await asyncio.to_thread( + subprocess.run, + ["docker", "stop", "-t", "3", self.container_name], + capture_output=True, + check=False, + ) + + try: + await asyncio.wait_for(self.proc.wait(), timeout=grace) + except asyncio.TimeoutError: + try: + os.killpg(self.proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + await self.proc.wait() + + if self._reader_task: + try: + await asyncio.wait_for(self._reader_task, timeout=2.0) + except asyncio.TimeoutError: + self._reader_task.cancel() diff --git a/test/harness/p10.py b/test/harness/p10.py new file mode 100644 index 00000000..f1445886 --- /dev/null +++ b/test/harness/p10.py @@ -0,0 +1,64 @@ +"""P10 base64 and numnick helpers for the fake hub.""" + +_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789[]" +_B64_VAL = {c: i for i, c in enumerate(_B64)} + + +def int_to_b64(value: int, width: int) -> str: + """Encode an integer as a P10 base64 string of fixed width.""" + chars = [] + for _ in range(width): + chars.append(_B64[value & 63]) + value >>= 6 + return "".join(reversed(chars)) + + +def b64_to_int(s: str) -> int: + """Decode a P10 base64 string to an integer.""" + result = 0 + for c in s: + result = (result << 6) | _B64_VAL[c] + return result + + +def server_numeric(num: int) -> str: + """Encode a server numeric as 2-char P10 base64.""" + return int_to_b64(num, 2) + + +def client_numnick(server_num: int, client_num: int) -> str: + """Encode a full SSCCC numnick (2-char server + 3-char client).""" + return server_numeric(server_num) + int_to_b64(client_num, 3) + + +def ipv4_to_b64(ip: str = "127.0.0.1") -> str: + """Encode an IPv4 address as a 6-character P10 base64 string.""" + parts = [int(x) for x in ip.split(".")] + value = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3] + return int_to_b64(value, 6) + + +def parse_numnick(numnick: str) -> tuple[int, int]: + """Parse a 5-char numnick into (server_numeric, client_numeric).""" + return b64_to_int(numnick[:2]), b64_to_int(numnick[2:]) + + +def strip_msg_tags(line: str) -> str: + """Remove a leading IRCv3 @tag-section from an S2S line if present.""" + if not line.startswith("@"): + return line + sp = line.find(" ") + return line[sp + 1 :] if sp != -1 else line + + +def p10_token(line: str) -> str | None: + """Extract the P10 command token (second word), or first word if unprefixed.""" + parts = strip_msg_tags(line).split() + if not parts: + return None + # Unprefixed commands (PASS, SERVER, EB, EA during handshake) + if parts[0] in {"PASS", "SERVER", "ERROR", "EB", "EA"}: + return parts[0] + if len(parts) >= 2: + return parts[1] + return None diff --git a/test/harness/pyproject.toml b/test/harness/pyproject.toml new file mode 100644 index 00000000..a1c28c68 --- /dev/null +++ b/test/harness/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "gnuworld-harness" +version = "0.1.0" +description = "Integration test harness for GNUWorld (fake P10 hub)" +requires-python = ">=3.10" +dependencies = [ + "pytest", + "pytest-asyncio", + "pytest-timeout", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +timeout = 60 +timeout_func_only = true +testpaths = ["."] +pythonpath = ["."] diff --git a/test/harness/test_ccontrol_check.py b/test/harness/test_ccontrol_check.py new file mode 100644 index 00000000..2d9a4c00 --- /dev/null +++ b/test/harness/test_ccontrol_check.py @@ -0,0 +1,60 @@ +"""ccontrol iauth CHECK XQ — legacy and account-aware syntax.""" + +from __future__ import annotations + +import pytest + +from p10 import p10_token + + +async def _check_and_expect_ok(hub, routing: str, check_body: str) -> str: + """Send CHECK via XQ and wait for a matching XR :OK.""" + after = len(hub.received) + await hub.send_xquery(routing=routing, message=check_body) + + def is_our_ok(line: str) -> bool: + if p10_token(line) != "XR": + return False + return routing in line and ":OK" in line + + return await hub.wait_for(is_our_ok, timeout=10.0, after=after) + + +@pytest.mark.asyncio +async def test_ccontrol_check_legacy_syntax(ccontrol_linked): + """Old iauth CHECK: nick user ip host :fullname (no account field).""" + hub, _proc = ccontrol_linked + + xr = await _check_and_expect_ok( + hub, + routing="iauth:legacy1", + check_body="CHECK alice ali 192.0.2.10 alice.testnet :Alice Example", + ) + assert "iauth:legacy1" in xr + assert xr.endswith(":OK") or " :OK" in xr + + +@pytest.mark.asyncio +async def test_ccontrol_check_with_account(ccontrol_linked): + """New iauth CHECK: nick user ip host account :fullname.""" + hub, _proc = ccontrol_linked + + xr = await _check_and_expect_ok( + hub, + routing="iauth:acct1", + check_body="CHECK bob bobby 192.0.2.11 bob.testnet bobaccount :Bob Example", + ) + assert "iauth:acct1" in xr + + +@pytest.mark.asyncio +async def test_ccontrol_check_with_star_account(ccontrol_linked): + """New iauth CHECK with explicit no-account marker '*'.""" + hub, _proc = ccontrol_linked + + xr = await _check_and_expect_ok( + hub, + routing="iauth:star1", + check_body="CHECK carol car 192.0.2.12 carol.testnet * :Carol Example", + ) + assert "iauth:star1" in xr diff --git a/test/harness/test_debug_serverinfo.py b/test/harness/test_debug_serverinfo.py new file mode 100644 index 00000000..05d6bb6b --- /dev/null +++ b/test/harness/test_debug_serverinfo.py @@ -0,0 +1,104 @@ +"""mod.debug SERVERINFO — TLS flags on leaf (+z) and TLS uplink transport.""" + +from __future__ import annotations + +import pytest + +from p10 import p10_token + +PERMIT_ACCOUNT = "MrIron" +LEAF_NAME = "leaf-tls.testnet" +LEAF_NUMERIC = 2 + + +async def _serverinfo_flags( + hub, + *, + target: str, + expect_tls: bool, +) -> str: + """Introduce an authed nick, PRIVMSG SERVERINFO, return the Flags Notice.""" + assert hub.peer_name, "gnuworld peer name missing after handshake" + numnick = await hub.introduce_nick("authed", username="authed") + await hub.send_account(numnick, PERMIT_ACCOUNT) + + after = len(hub.received) + await hub.send_privmsg( + numnick, + f"debug@{hub.peer_name}", + f"SERVERINFO {target}", + ) + + want = "tls=yes" if expect_tls else "tls=no" + + def is_flags_notice(line: str) -> bool: + if p10_token(line) != "O": + return False + return numnick in line and want in line and "Flags:" in line + + return await hub.wait_for(is_flags_notice, timeout=10.0, after=after) + + +@pytest.mark.asyncio +async def test_serverinfo_leaf_with_tls_flag(debug_linked): + """Spawn a +z leaf, PRIVMSG stealth debug as an authed nick, expect tls=yes.""" + hub, proc = debug_linked + + leaf_yy = await hub.introduce_leaf(LEAF_NAME, LEAF_NUMERIC, flags="z") + await hub.end_burst(leaf_yy) + + numnick = await hub.introduce_leaf_nick( + leaf_yy, + LEAF_NUMERIC, + "authed", + username="authed", + host="user.leaf.testnet", + ) + await hub.send_account(numnick, PERMIT_ACCOUNT) + + after = len(hub.received) + await hub.send_privmsg( + numnick, + f"debug@{hub.peer_name}", + f"SERVERINFO {LEAF_NAME}", + ) + + def is_flags_notice(line: str) -> bool: + if p10_token(line) != "O": + return False + return numnick in line and "tls=yes" in line and "Flags:" in line + + flags_line = await hub.wait_for(is_flags_notice, timeout=10.0, after=after) + assert "tls=yes" in flags_line + assert any( + p10_token(line) == "O" and LEAF_NAME in line for line in hub.received[after:] + ), "expected a SERVERINFO Notice naming the leaf" + + assert proc.proc is not None + assert proc.proc.returncode is None + + +@pytest.mark.asyncio +async def test_serverinfo_uplink_tls_from_transport(debug_linked_tls): + """TLS uplink (no +z on SERVER) must still report tls=yes via transport.""" + hub, proc = debug_linked_tls + assert hub.tls + + flags_line = await _serverinfo_flags(hub, target=hub.name, expect_tls=True) + assert "tls=yes" in flags_line + + assert proc.proc is not None + assert proc.proc.returncode is None + + +@pytest.mark.asyncio +async def test_serverinfo_uplink_plain_is_not_tls(debug_linked): + """Plain uplink without +z must report tls=no.""" + hub, proc = debug_linked + assert not hub.tls + + flags_line = await _serverinfo_flags(hub, target=hub.name, expect_tls=False) + assert "tls=no" in flags_line + + assert proc.proc is not None + assert proc.proc.returncode is None diff --git a/test/harness/test_link.py b/test/harness/test_link.py new file mode 100644 index 00000000..8d2dc4db --- /dev/null +++ b/test/harness/test_link.py @@ -0,0 +1,61 @@ +"""Smoke tests: P10 link-up and basic FakeHub helpers.""" + +from __future__ import annotations + +import pytest + +from p10 import p10_token + + +@pytest.mark.asyncio +async def test_link_handshake(linked): + hub, proc = linked + + assert hub.burst_complete + assert hub.peer_name == "services.testnet" + assert hub.peer_numeric is not None + assert hub.peer_server_line is not None + assert hub.peer_numeric in hub.peer_server_line + + # GNUWorld must have completed its burst toward the hub + assert any(p10_token(line) == "EB" for line in hub.received) + assert any(p10_token(line) == "EA" for line in hub.received) + + await proc.wait_for_stdout("Connected") + + +@pytest.mark.asyncio +async def test_introduce_nick_and_burst_channel(linked): + hub, proc = linked + + numnick = await hub.introduce_nick( + "alice", + username="ali", + host="alice.testnet", + realname="Alice Example", + ) + assert len(numnick) == 5 + assert hub.get_user_numnick("alice") == numnick + + await hub.burst_channel("#test", members=[f"{numnick}:o"]) + assert any( + p10_token(line) == "B" and "#test" in line for line in hub.sent + ) + + # GNUWorld should still be alive after accepting N/B + assert proc.proc is not None + assert proc.proc.returncode is None + + +@pytest.mark.asyncio +async def test_send_xquery_helper(linked): + hub, _proc = linked + + await hub.send_xquery( + routing="iauth:1", + message="CHECK alice ali 127.0.0.1 alice.testnet :Alice Example", + ) + assert any(p10_token(line) == "XQ" for line in hub.sent) + xq = next(line for line in hub.sent if p10_token(line) == "XQ") + assert hub.peer_numeric in xq + assert "CHECK alice" in xq diff --git a/test/harness/test_msgtags.py b/test/harness/test_msgtags.py new file mode 100644 index 00000000..65d04c9d --- /dev/null +++ b/test/harness/test_msgtags.py @@ -0,0 +1,98 @@ +"""IRCv3 message-tags on S2S (@time and @+client-tags). + +Gnuworld strips a leading @tag-section before dispatching P10 handlers, so +tagged client events and tagged XQUERY must still work. Outbound service +RPC (XR) must remain untagged — positional parsers on the hub/iauth side +break if @time= is prepended (see ircu msgtags compat suite). +""" + +from __future__ import annotations + +import pytest + +from p10 import p10_token + +SERVER_TIME = "2026-07-25T12:00:00.000Z" +# Client-only tag key (IRCv3 +prefix); gnuworld stores it as an ordinary key. +CLIENT_TAG = {"+example.com/foo": "bar"} + + +def _time_and_client_tags() -> dict[str, str | None]: + return {"time": SERVER_TIME, **CLIENT_TAG} + + +async def _still_alive(hub, proc) -> None: + await hub.drain_messages(timeout=0.3) + assert proc.proc is not None + assert proc.proc.returncode is None + + +@pytest.mark.asyncio +async def test_tagged_nick_with_time_and_client_tag(linked): + """@time + @+client-tag on N must be stripped; nick still introduced.""" + hub, proc = linked + numnick = await hub.introduce_nick( + "tagnick", + tags=_time_and_client_tags(), + ) + assert numnick + assert any(line.lstrip().startswith("@") and " N tagnick " in line for line in hub.sent) + await _still_alive(hub, proc) + + +@pytest.mark.asyncio +async def test_tagged_privmsg_with_time_and_client_tag(linked): + """Tagged channel PRIVMSG must not crash or break the link.""" + hub, proc = linked + numnick = await hub.introduce_nick("tagger") + await hub.burst_channel("#msgtags", members=[numnick]) + await hub.send_privmsg( + numnick, + "#msgtags", + "hello tagged", + tags=_time_and_client_tags(), + ) + assert any( + line.lstrip().startswith("@") and " P #msgtags :" in line for line in hub.sent + ) + await _still_alive(hub, proc) + + +@pytest.mark.asyncio +async def test_tags_only_line_ignored(linked): + """A lone @tag-section with no command is dropped silently.""" + hub, proc = linked + await hub.send_raw(f"@time={SERVER_TIME}") + await _still_alive(hub, proc) + + +@pytest.mark.asyncio +async def test_tagged_xq_check_still_ok_and_xr_untagged(ccontrol_linked): + """Tagged XQ CHECK is handled; gnuworld's XR must not carry @tags.""" + hub, _proc = ccontrol_linked + routing = "iauth:tagged1" + after = len(hub.received) + + await hub.send_xquery( + routing=routing, + message="CHECK taggeduser tu 192.0.2.50 tagged.testnet :Tagged User", + tags=_time_and_client_tags(), + ) + assert any( + line.lstrip().startswith("@") and " XQ " in line and routing in line + for line in hub.sent + ) + + def is_our_ok(line: str) -> bool: + if p10_token(line) != "XR": + return False + return routing in line and ":OK" in line + + xr = await hub.wait_for(is_our_ok, timeout=10.0, after=after) + + # Raw wire: service RPC must stay untagged (positional S2S parsers). + assert not xr.lstrip().startswith("@"), f"XR unexpectedly tagged: {xr!r}" + parts = xr.split() + assert parts[1] == "XR" + assert routing in xr + assert ":OK" in xr diff --git a/test/stringtokenizer_trailing.cc b/test/stringtokenizer_trailing.cc new file mode 100644 index 00000000..5e586719 --- /dev/null +++ b/test/stringtokenizer_trailing.cc @@ -0,0 +1,110 @@ +/** + * stringtokenizer_trailing.cc + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + * + * Exercises StringTokenizer::trailing() — the IRC trailing parameter + * (first token beginning with ':', leading ':' stripped). + */ + +#include +#include +#include + +#include "StringTokenizer.h" + +using namespace std; +using namespace gnuworld; + +static int failures = 0; + +static void check(bool condition, const string& description) { + if (!condition) { + cout << "FAILED: " << description << endl; + ++failures; + } +} + +static void testTrailingBasic() { + StringTokenizer st("CMD arg :hello"); + auto t = st.trailing(); + check(t.has_value() && *t == "hello", "trailing returns text after the leading ':'"); +} + +static void testTrailingMultiWord() { + StringTokenizer st("CMD arg :hello world there"); + auto t = st.trailing(); + check(t.has_value() && *t == "hello world there", + "trailing reassembles multi-word realname after the colon"); +} + +static void testTrailingColonOnly() { + StringTokenizer st("CMD arg :"); + auto t = st.trailing(); + // Tokenize drops empty tokens, so a lone trailing ':' may not appear. + // If present as ":", substr(1) is empty; if absent, nullopt. + // Space-delimited ":" alone is a token ":". + check(t.has_value() && t->empty(), "trailing of a lone ':' token is an empty string"); +} + +static void testTrailingAbsent() { + StringTokenizer st("CMD nick user ip host"); + check(!st.trailing().has_value(), "trailing returns nullopt when no ':'-prefixed token exists"); + + StringTokenizer empty; + check(!empty.trailing().has_value(), "trailing returns nullopt for an empty tokenizer"); +} + +static void testTrailingCheckXqShapes() { + // Legacy iauth CHECK (no account field) + StringTokenizer legacy("CHECK nick user 1.2.3.4 host :Some Real Name"); + check(legacy.trailing().has_value() && *legacy.trailing() == "Some Real Name", + "CHECK without account: trailing is the realname"); + + // Optional account before trailing + StringTokenizer withAcct("CHECK nick user 1.2.3.4 host myaccount :Some Real Name"); + check(withAcct.trailing().has_value() && *withAcct.trailing() == "Some Real Name", + "CHECK with account: trailing is still the realname"); + + // Explicit no-account marker + StringTokenizer starAcct("CHECK nick user 1.2.3.4 host * :Some Real Name"); + check(starAcct.trailing().has_value() && *starAcct.trailing() == "Some Real Name", + "CHECK with '*': trailing is still the realname"); + + // Same pattern used by iauthXQCheck callers + string fullname = withAcct.trailing().value_or(withAcct.assemble(5)); + check(fullname == "Some Real Name", "value_or(assemble(5)) yields trailing when present"); + + StringTokenizer noColon("CHECK nick user 1.2.3.4 host leftover"); + fullname = noColon.trailing().value_or(noColon.assemble(5)); + check(fullname == "leftover", "value_or(assemble(5)) falls back when trailing is absent"); +} + +int main() { + testTrailingBasic(); + testTrailingMultiWord(); + testTrailingColonOnly(); + testTrailingAbsent(); + testTrailingCheckXqShapes(); + + if (0 == failures) { + cout << "All StringTokenizer trailing tests passed." << endl; + return 0; + } + + cout << failures << " test(s) failed." << endl; + return 1; +}