diff --git a/.github/workflows/test-devel.yml b/.github/workflows/test-devel.yml
index f3c4d38..640e680 100644
--- a/.github/workflows/test-devel.yml
+++ b/.github/workflows/test-devel.yml
@@ -66,7 +66,7 @@ jobs:
     - name: Build Bahamut
       run: |
         cd $GITHUB_WORKSPACE/Bahamut/
-        patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch
+        patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch
         echo "#undef THROTTLE_ENABLE" >> include/config.h
         libtoolize --force
         aclocal
@@ -144,7 +144,7 @@ jobs:
     - name: Build InspIRCd
       run: |
         cd $GITHUB_WORKSPACE/inspircd/
-        patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch
+        patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
         ./configure --prefix=$HOME/.local/inspircd --development
         make -j 4
         make install
@@ -184,6 +184,7 @@ jobs:
     - name: Build ngircd
       run: |
         cd $GITHUB_WORKSPACE/ngircd
+        patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch
         ./autogen.sh
         ./configure --prefix=$HOME/.local/
         make -j 4
diff --git a/.github/workflows/test-devel_release.yml b/.github/workflows/test-devel_release.yml
index 92fc3cc..0b25abb 100644
--- a/.github/workflows/test-devel_release.yml
+++ b/.github/workflows/test-devel_release.yml
@@ -57,7 +57,7 @@ jobs:
     - name: Build InspIRCd
       run: |
         cd $GITHUB_WORKSPACE/inspircd/
-        patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch
+        patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
         ./configure --prefix=$HOME/.local/inspircd --development
         make -j 4
         make install
diff --git a/.github/workflows/test-stable.yml b/.github/workflows/test-stable.yml
index 1df34a6..c908907 100644
--- a/.github/workflows/test-stable.yml
+++ b/.github/workflows/test-stable.yml
@@ -66,7 +66,7 @@ jobs:
     - name: Build Bahamut
       run: |
         cd $GITHUB_WORKSPACE/Bahamut/
-        patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch
+        patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch
         echo "#undef THROTTLE_ENABLE" >> include/config.h
         libtoolize --force
         aclocal
@@ -184,7 +184,7 @@ jobs:
     - name: Build InspIRCd
       run: |
         cd $GITHUB_WORKSPACE/inspircd/
-        patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch
+        patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
         ./configure --prefix=$HOME/.local/inspircd --development
         make -j 4
         make install
@@ -224,6 +224,7 @@ jobs:
     - name: Build ngircd
       run: |
         cd $GITHUB_WORKSPACE/ngircd
+        patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch
         ./autogen.sh
         ./configure --prefix=$HOME/.local/
         make -j 4
diff --git a/Makefile b/Makefile
index 4b76e4d..0f572bf 100644
--- a/Makefile
+++ b/Makefile
@@ -55,6 +55,7 @@ HYBRID_SELECTORS := \
 # testNoticeNonexistentChannel fails because of https://github.com/inspircd/inspircd/issues/1849
 # testBotPrivateMessage and testBotChannelMessage fail because https://github.com/inspircd/inspircd/pull/1910 is not released yet
 # testNamesInvalidChannel and testNamesNonexistingChannel fail because https://github.com/inspircd/inspircd/pull/1922 is not released yet.
+# WHOWAS tests fail because https://github.com/inspircd/inspircd/pull/1967 and https://github.com/inspircd/inspircd/pull/1968 are not released yet
 INSPIRCD_SELECTORS := \
 	not Ergo \
 	and not deprecated \
@@ -62,6 +63,7 @@ INSPIRCD_SELECTORS := \
 	and not testNoticeNonexistentChannel \
 	and not testBotPrivateMessage and not testBotChannelMessage \
 	and not testNamesInvalidChannel and not testNamesNonexistingChannel \
+	and not whowas \
 	$(EXTRA_SELECTORS)
 
 # buffering tests fail because ircu2 discards the whole buffer on long lines (TODO: refine how we exclude these tests)
@@ -72,6 +74,7 @@ INSPIRCD_SELECTORS := \
 # testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
 # testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS.
 # HelpTestCase fails because it returns NOTICEs instead of numerics
+# testWhowasCountZero fails: https://github.com/UndernetIRC/ircu2/pull/19
 IRCU2_SELECTORS := \
 	not Ergo \
 	and not deprecated \
@@ -84,6 +87,7 @@ IRCU2_SELECTORS := \
 	and not testKickDefaultComment \
 	and not testEmptyRealname \
 	and not HelpTestCase \
+	and not testWhowasCountZero \
 	$(EXTRA_SELECTORS)
 
 # same justification as ircu2
diff --git a/README.md b/README.md
index c5503af..7aac195 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ git clone https://github.com/inspircd/inspircd.git
 cd inspircd
 
 # optional, makes tests run considerably faster
-patch src/inspircd.cpp < ~/irctest/inspircd_mainloop.patch
+patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
 
 ./configure --prefix=$HOME/.local/ --development
 make -j 4
diff --git a/irctest/controllers/ngircd.py b/irctest/controllers/ngircd.py
index 5d0d845..17b3540 100644
--- a/irctest/controllers/ngircd.py
+++ b/irctest/controllers/ngircd.py
@@ -26,6 +26,9 @@ TEMPLATE_CONFIG = """
     Passive = yes  # don't connect to it
     ServiceMask = *Serv
 
+[Options]
+    MorePrivacy = no  # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
+
 [Operator]
     Name = operuser
     Password = operpassword
diff --git a/irctest/server_tests/whowas.py b/irctest/server_tests/whowas.py
new file mode 100644
index 0000000..c3de2c9
--- /dev/null
+++ b/irctest/server_tests/whowas.py
@@ -0,0 +1,204 @@
+from irctest import cases
+from irctest.exceptions import ConnectionClosed
+from irctest.numerics import (
+    RPL_ENDOFWHOWAS,
+    RPL_WHOISACTUALLY,
+    RPL_WHOISSERVER,
+    RPL_WHOWASUSER,
+)
+from irctest.patma import ANYSTR, StrRe
+
+
+class WhowasTestCase(cases.BaseServerTestCase):
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasNumerics(self):
+        """
+        https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self.connectClient("nick1")
+
+        self.connectClient("nick2")
+        self.sendLine(2, "QUIT :bye")
+        try:
+            self.getMessages(2)
+        except ConnectionClosed:
+            pass
+
+        self.sendLine(1, "WHOWAS nick2")
+
+        messages = []
+        for _ in range(10):
+            messages.extend(self.getMessages(1))
+            if RPL_ENDOFWHOWAS in (m.command for m in messages):
+                break
+
+        last_message = messages.pop()
+
+        self.assertMessageMatch(
+            last_message,
+            command=RPL_ENDOFWHOWAS,
+            params=["nick1", "nick2", ANYSTR],
+            fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})",
+        )
+
+        unexpected_messages = []
+
+        # Straight from the RFCs
+        for m in messages:
+            if m.command == RPL_WHOWASUSER:
+                host_re = "[0-9A-Za-z_:.-]+"
+                self.assertMessageMatch(
+                    m,
+                    params=[
+                        "nick1",
+                        "nick2",
+                        StrRe("~?username"),
+                        StrRe(host_re),
+                        "*",
+                        "Realname",
+                    ],
+                )
+            elif m.command == RPL_WHOISSERVER:
+                self.assertMessageMatch(
+                    m, params=["nick1", "nick2", "My.Little.Server", ANYSTR]
+                )
+            elif m.command == RPL_WHOISACTUALLY:
+                # Technically not allowed by the RFCs, but Solanum uses it.
+                # Not checking the syntax here; WhoisTestCase does it.
+                pass
+            else:
+                unexpected_messages.append(m)
+
+        self.assertEqual(
+            unexpected_messages, [], fail_msg="Unexpected numeric messages: {got}"
+        )
+
+    def _testWhowasMultiple(self, second_result, whowas_command):
+        """
+        "The history is searched backward, returning the most recent entry first."
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        # TODO: this test assumes the order is always: RPL_WHOWASUSER, then
+        # optional RPL_WHOISACTUALLY, then RPL_WHOISSERVER; but the RFCs
+        # don't specify the order.
+        self.connectClient("nick1")
+
+        self.connectClient("nick2", ident="ident2")
+        self.sendLine(2, "QUIT :bye")
+        try:
+            self.getMessages(2)
+        except ConnectionClosed:
+            pass
+
+        self.connectClient("nick2", ident="ident3")
+        self.sendLine(3, "QUIT :bye")
+        try:
+            self.getMessages(3)
+        except ConnectionClosed:
+            pass
+
+        self.sendLine(1, whowas_command)
+
+        messages = self.getMessages(1)
+
+        # nick2 with ident3
+        self.assertMessageMatch(
+            messages.pop(0),
+            command=RPL_WHOWASUSER,
+            params=[
+                "nick1",
+                "nick2",
+                StrRe("~?ident3"),
+                ANYSTR,
+                "*",
+                "Realname",
+            ],
+        )
+        while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER):
+            # don't care
+            messages.pop(0)
+
+        if second_result:
+            # nick2 with ident2
+            self.assertMessageMatch(
+                messages.pop(0),
+                command=RPL_WHOWASUSER,
+                params=[
+                    "nick1",
+                    "nick2",
+                    StrRe("~?ident2"),
+                    ANYSTR,
+                    "*",
+                    "Realname",
+                ],
+            )
+            if messages[0].command == RPL_WHOISACTUALLY:
+                # don't care
+                messages.pop(0)
+            while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER):
+                # don't care
+                messages.pop(0)
+
+        self.assertMessageMatch(
+            messages.pop(0),
+            command=RPL_ENDOFWHOWAS,
+            params=["nick1", "nick2", ANYSTR],
+            fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})",
+        )
+
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasMultiple(self):
+        """
+        "The history is searched backward, returning the most recent entry first."
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2")
+
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasCount1(self):
+        """
+        "If there are multiple entries, up to <count> replies will be returned"
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=False, whowas_command="WHOWAS nick2 1")
+
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasCount2(self):
+        """
+        "If there are multiple entries, up to <count> replies will be returned"
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 2")
+
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasCountNegative(self):
+        """
+        "If a non-positive number is passed as being <count>, then a full search
+        is done."
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 -1")
+
+    @cases.mark_specifications("RFC1459", "RFC2812")
+    def testWhowasCountZero(self):
+        """
+        "If a non-positive number is passed as being <count>, then a full search
+        is done."
+        -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 0")
+
+    @cases.mark_specifications("RFC2812", deprecated=True)
+    def testWhowasWildcard(self):
+        """
+        "Wildcards are allowed in the <target> parameter."
+        -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3
+        """
+        self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS *ck2")
diff --git a/bahamut_localhost.patch b/patches/bahamut_localhost.patch
similarity index 100%
rename from bahamut_localhost.patch
rename to patches/bahamut_localhost.patch
diff --git a/inspircd_mainloop.patch b/patches/inspircd_mainloop.patch
similarity index 100%
rename from inspircd_mainloop.patch
rename to patches/inspircd_mainloop.patch
diff --git a/patches/ngircd_whowas_delay.patch b/patches/ngircd_whowas_delay.patch
new file mode 100644
index 0000000..80e322e
--- /dev/null
+++ b/patches/ngircd_whowas_delay.patch
@@ -0,0 +1,19 @@
+ngIRCd skips WHOWAS entries for users that were connected for less
+than 30 seconds.
+
+To avoid waiting 30s in every WHOWAS test, we need to remove this.
+
+diff --git a/src/ngircd/client.c b/src/ngircd/client.c
+index 67c02604..66e8e540 100644
+--- a/src/ngircd/client.c
++++ b/src/ngircd/client.c
+@@ -1490,9 +1490,6 @@ Client_RegisterWhowas( CLIENT *Client )
+		return;
+ 
+	now = time(NULL);
+-	/* Don't register clients that were connected less than 30 seconds. */
+-	if( now - Client->starttime < 30 )
+-		return;
+ 
+	slot = Last_Whowas + 1;
+	if( slot >= MAX_WHOWAS || slot < 0 ) slot = 0;
diff --git a/workflows.yml b/workflows.yml
index 4909a0d..3ce954b 100644
--- a/workflows.yml
+++ b/workflows.yml
@@ -104,7 +104,7 @@ software:
         separate_build_job: true
         build_script: |
             cd $GITHUB_WORKSPACE/Bahamut/
-            patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch
+            patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch
             echo "#undef THROTTLE_ENABLE" >> include/config.h
             libtoolize --force
             aclocal
@@ -152,7 +152,7 @@ software:
         separate_build_job: true
         build_script: &inspircd_build_script |
             cd $GITHUB_WORKSPACE/inspircd/
-            patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch
+            patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
             ./configure --prefix=$HOME/.local/inspircd --development
             make -j 4
             make install
@@ -217,6 +217,7 @@ software:
         separate_build_job: true
         build_script: |
             cd $GITHUB_WORKSPACE/ngircd
+            patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch
             ./autogen.sh
             ./configure --prefix=$HOME/.local/
             make -j 4