diff --git a/app/Exceptions/Utils/Whois/WhoisException.php b/app/Exceptions/Utils/Whois/WhoisException.php
new file mode 100644
index 000000000..8a182aa71
--- /dev/null
+++ b/app/Exceptions/Utils/Whois/WhoisException.php
@@ -0,0 +1,26 @@
+getNetworkByAsn( $asn ) ) {
@@ -65,7 +70,6 @@ public function asn( PeeringDb $pdb, Request $r, string $asn ): Response
$response = "Querying PeeringDB failed:\n\nError:{$pdb->error}\n\nTrying " . config( 'ixp_api.whois.asn2.host' ) . ":\n\n";
}
- $whois = new Whois( config( 'ixp_api.whois.asn2.host' ), config( 'ixp_api.whois.asn2.port' ) );
$response .= $whois->whois( 'AS' . (int)$asn );
return $response;
@@ -77,17 +81,18 @@ public function asn( PeeringDb $pdb, Request $r, string $asn ): Response
/**
* API call to do a Whois looking on a prefix
*
+ * @param Whois $whois A whois instance
* @param string $prefix The IP address element of the prefix
* @param string|null $mask The mask length
*
* @return Response
*/
- public function prefix( string $prefix, string $mask = null ): Response
+ public function prefix( #[WhoisHost('prefix')] Whois $whois, string $prefix, ?string $mask = null ): Response
{
- $response = Cache::remember( 'api-v4-whois-prefix-' . $prefix . '-' . $mask, config('ixp_api.whois.cache_ttl'), function () use ( $prefix, $mask ) {
- $whois = new Whois( config('ixp_api.whois.prefix.host'), config('ixp_api.whois.prefix.port') );
- return $whois->whois( $prefix .'/' . $mask );
- });
+ // Don't append slash unless we're sending the mask also
+ $response = Cache::remember( 'api-v4-whois-prefix-' . $prefix . '-' . $mask, config('ixp_api.whois.cache_ttl'),
+ fn() => $whois->whois( $prefix . ($mask ? "/$mask" : ""))
+ );
return response( $response, 200 )->header('Content-Type', 'text/plain');
}
diff --git a/app/Utils/Whois.php b/app/Utils/Whois/Whois.php
similarity index 93%
rename from app/Utils/Whois.php
rename to app/Utils/Whois/Whois.php
index c4bfd9346..a0f31f0d3 100644
--- a/app/Utils/Whois.php
+++ b/app/Utils/Whois/Whois.php
@@ -1,9 +1,9 @@
port = $port;
}
-
- /**
- *
- */
-
/**
* Do a whois lookup
*
diff --git a/app/Utils/Whois/WhoisHost.php b/app/Utils/Whois/WhoisHost.php
new file mode 100644
index 000000000..5d1f6ebeb
--- /dev/null
+++ b/app/Utils/Whois/WhoisHost.php
@@ -0,0 +1,60 @@
+
+ * @category Whois
+ * @package IXP\Utils\Whois
+ * @copyright Copyright (C) 2009 - 2026 Internet Neutral Exchange Association Company Limited By Guarantee
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GNU GPL V2.0
+ */
+#[Attribute(Attribute::TARGET_PARAMETER)]
+readonly class WhoisHost implements ContextualAttribute
+{
+ /**
+ * @param string $name The name of the whois config to load
+ */
+ public function __construct(public string $name) {}
+
+ /**
+ * @param WhoisHost $attribute containing whois config name
+ * @param Container $container for dependency injection
+ * @return Whois
+ * @throws \IXP\Exceptions\Utils\Whois\WhoisException
+ */
+ public static function resolve(self $attribute, Container $container): Whois
+ {
+ return $container->make(WhoisResolver::class)->get($attribute->name);
+ }
+}
\ No newline at end of file
diff --git a/app/Utils/Whois/WhoisResolver.php b/app/Utils/Whois/WhoisResolver.php
new file mode 100644
index 000000000..871fe6ae1
--- /dev/null
+++ b/app/Utils/Whois/WhoisResolver.php
@@ -0,0 +1,53 @@
+has("ixp_api.whois.{$server}.host") && config()->has("ixp_api.whois.{$server}.port") ) ) {
+ throw new WhoisException( "Configuration not found for whois server '$server'" );
+ }
+
+ return new Whois( config( "ixp_api.whois.{$server}.host" ), (int) config( "ixp_api.whois.{$server}.port" ) );
+ }
+}
\ No newline at end of file
diff --git a/data/ci/known-good/peeringdb/getnetwork.inex.json b/data/ci/known-good/peeringdb/getnetwork.inex.json
new file mode 100644
index 000000000..06a95d973
--- /dev/null
+++ b/data/ci/known-good/peeringdb/getnetwork.inex.json
@@ -0,0 +1 @@
+{"data": [{"id": 502, "org_id": 679, "name": "INEX Route Collectors", "aka": "Internet Neutral Exchange Association Ltd", "name_long": "", "website": "http://www.inex.ie/", "social_media": [{"service": "website", "identifier": "http://www.inex.ie/"}], "asn": 2128, "looking_glass": "http://www.inex.ie/lg/", "route_server": "", "irr_as_set": "", "info_type": "Route Collector", "info_types": ["Route Collector"], "info_prefixes4": 0, "info_prefixes6": 0, "info_traffic": "", "info_ratio": "Balanced", "info_scope": "Regional", "info_unicast": true, "info_multicast": false, "info_ipv6": true, "info_never_via_route_servers": false, "ix_count": 2, "fac_count": 0, "notes": "INEX is an IXP in Dublin, Ireland.", "netixlan_updated": "2026-03-26T17:18:39Z", "netfac_updated": null, "poc_updated": "2025-10-13T09:07:32Z", "policy_url": "", "policy_general": "Open", "policy_locations": "Not Required", "policy_ratio": false, "policy_contracts": "Not Required", "netfac_set": [], "netixlan_set": [{"id": 1782, "ix_id": 48, "name": "INEX LAN1: INEX LAN1", "ixlan_id": 48, "notes": "", "speed": 1000, "asn": 2128, "ipaddr4": "185.6.36.126", "ipaddr6": "2001:7f8:18::f:0:1", "is_rs_peer": true, "bfd_support": false, "operational": true, "net_side_id": null, "ix_side_id": null, "created": "2010-07-29T00:00:00Z", "updated": "2024-05-28T10:07:37Z", "status": "ok"}, {"id": 34373, "ix_id": 1262, "name": "INEX Cork: Peering LAN", "ixlan_id": 1262, "notes": "", "speed": 1000, "asn": 2128, "ipaddr4": "185.1.69.126", "ipaddr6": "2001:7f8:18:210::126", "is_rs_peer": true, "bfd_support": false, "operational": true, "net_side_id": null, "ix_side_id": 620, "created": "2017-04-26T18:30:19Z", "updated": "2026-03-26T17:18:39Z", "status": "ok"}], "poc_set": [{"id": 1547, "role": "NOC", "visible": "Public", "name": "INEX Operations", "phone": "+35315313339", "email": "operations@inex.ie", "url": "", "created": "2010-07-29T00:00:00Z", "updated": "2025-10-13T09:07:32Z", "status": "ok"}], "allow_ixp_update": false, "status_dashboard": "", "rir_status": "ok", "rir_status_updated": "2024-06-26T04:47:55Z", "logo": "https://peeringdb-media-prod.s3.amazonaws.com/media/logos_user_supplied/network-502-9a7956f5.png", "created": "2005-06-07T21:13:52Z", "updated": "2025-10-13T09:05:37Z", "status": "ok"}], "meta": {}}
\ No newline at end of file
diff --git a/data/ci/known-good/peeringdb/netAsAscii.inex.txt b/data/ci/known-good/peeringdb/netAsAscii.inex.txt
new file mode 100644
index 000000000..4445fb6ea
--- /dev/null
+++ b/data/ci/known-good/peeringdb/netAsAscii.inex.txt
@@ -0,0 +1,16 @@
+PeeringDB Network Details of AS2128
+==================================================
+
+Name: INEX Route Collectors
+Internet Neutral Exchange Association Ltd
+
+Website: http://www.inex.ie/
+
+Peering Policy: Open
+
+Notes:
+
+
+
+==================================================
+
diff --git a/data/ci/known-good/whois-prefix.8.8.8.8.txt b/data/ci/known-good/whois-prefix.8.8.8.8.txt
new file mode 100644
index 000000000..35d9ff1d4
--- /dev/null
+++ b/data/ci/known-good/whois-prefix.8.8.8.8.txt
@@ -0,0 +1,11 @@
+route: 8.8.8.0/24
+origin: AS15169
+descr: Google
+notify: radb-contact@google.com
+mnt-by: MAINT-AS15169
+changed: radb-contact@google.com 20230208
+source: RADB
+last-modified: 2023-11-13T16:14:55Z
+rpki-ov-state: valid
+
+
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 38900c093..292e4f765 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -2416,6 +2416,11 @@
+
+
+
+
+
getBody()]]>
diff --git a/tests/API/WhoisControllerTest.php b/tests/API/WhoisControllerTest.php
new file mode 100644
index 000000000..c318e01d7
--- /dev/null
+++ b/tests/API/WhoisControllerTest.php
@@ -0,0 +1,145 @@
+mock(PeeringDb::class, function (MockInterface $mock) {
+ $mock->expects('getNetworkByAsn')->with('2128')
+ ->andReturn(json_decode(file_get_contents('data/ci/known-good/peeringdb/getnetwork.inex.json'), associative: true));
+ $mock
+ ->expects('netAsAscii')
+ ->andReturn(file_get_contents('data/ci/known-good/peeringdb/netAsAscii.inex.txt'));
+ });
+
+ $this->post( "login", [
+ "username" => "travis",
+ "password" => "travisci",
+ ] );
+
+ $response = $this->get( '/api/v4/aut-num/2128' );
+ $response->assertStatus( 200 );
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+
+ $this->assertEquals(file_get_contents('data/ci/known-good/peeringdb/netAsAscii.inex.txt'), $response->getContent());
+ }
+
+ public function testAsnInexWhois(): void
+ {
+ $this->mock(PeeringDb::class, function (MockInterface $mock) {
+ $mock->expects('getNetworkByAsn')->with('2128')
+ ->set('status', 404)
+ ->set('error', "No network with AS2128 found in PeeringDB")
+ ->andReturn(false);
+ });
+
+ $this->mock(WhoisResolver::class, function (MockInterface $mock) {
+ $mock->expects('get')->with('asn2')
+ ->andReturn(
+ Mockery::mock(Whois::class, function (MockInterface $mock) {
+ $mock->expects('whois')->with('AS2128')
+ ->andReturn("AS Name\nINEX Internet Neutral Exchange Association Company Limited By Guarantee, IE\n");
+ })
+ );
+ });
+
+ $this->post( "login", [
+ "username" => "travis",
+ "password" => "travisci",
+ ] );
+
+ $response = $this->get( '/api/v4/aut-num/2128' );
+ $response->assertStatus( 200 );
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+
+ $this->assertEquals("ASN not registered in PeeringDB. Trying ". config( 'ixp_api.whois.asn2.host' ) . ":\n\nAS Name\nINEX Internet Neutral Exchange Association Company Limited By Guarantee, IE\n", $response->getContent());
+ }
+
+ public function testAsnNotFound(): void
+ {
+ $this->mock(PeeringDb::class, function (MockInterface $mock) {
+ $mock->expects('getNetworkByAsn')->with('99999999')
+ ->set('status', 500)
+ ->set('error', "Server Error")
+ ->andReturn(false);
+ });
+
+ $this->mock(WhoisResolver::class, function (MockInterface $mock) {
+ $mock->expects('get')->with('asn2')
+ ->andReturn(
+ Mockery::mock(Whois::class, function (MockInterface $mock) {
+ $mock->expects('whois')->with('AS99999999')
+ ->andReturn("AS Name\nNO_NAME\n");
+ })
+ );
+ });
+
+ $this->post( "login", [
+ "username" => "travis",
+ "password" => "travisci",
+ ] );
+
+ $response = $this->get( '/api/v4/aut-num/99999999' );
+ $response->assertStatus( 200 );
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+ $this->assertEquals("Querying PeeringDB failed:\n\nError:Server Error\n\nTrying ". config( 'ixp_api.whois.asn2.host' ) . ":\n\nAS Name\nNO_NAME\n", $response->getContent());
+ }
+
+ public function testPrefix(): void
+ {
+ $this->mock(WhoisResolver::class, function (MockInterface $mock) {
+ $mock->expects('get')->with('prefix')
+ ->andReturn(
+ Mockery::mock(Whois::class, function (MockInterface $mock) {
+ $mock->expects('whois')->with('8.8.8.8/32')
+ ->andReturn(file_get_contents('data/ci/known-good/whois-prefix.8.8.8.8.txt'));
+ })
+ );
+ });
+
+ $this->post( "login", [
+ "username" => "travis",
+ "password" => "travisci",
+ ] );
+
+ $response = $this->get( '/api/v4/prefix-whois/8.8.8.8/32' );
+ $response->assertStatus( 200 );
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+
+ $this->assertEquals(file_get_contents('data/ci/known-good/whois-prefix.8.8.8.8.txt'), $response->getContent());
+ }
+
+ public function testPrefixWithoutMask(): void
+ {
+ $this->mock(WhoisResolver::class, function (MockInterface $mock) {
+ $mock->expects('get')->with('prefix')
+ ->andReturn(
+ Mockery::mock(Whois::class, function (MockInterface $mock) {
+ $mock->expects('whois')->with('8.8.8.8')
+ ->andReturn(file_get_contents('data/ci/known-good/whois-prefix.8.8.8.8.txt'));
+ })
+ );
+ });
+
+ $this->post( "login", [
+ "username" => "travis",
+ "password" => "travisci",
+ ] );
+
+ $response = $this->get( '/api/v4/prefix-whois/8.8.8.8' );
+ $response->assertStatus( 200 );
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+
+ $this->assertEquals(file_get_contents('data/ci/known-good/whois-prefix.8.8.8.8.txt'), $response->getContent());
+ }
+
+}
\ No newline at end of file
diff --git a/tests/Utils/Whois/WhoisHostTest.php b/tests/Utils/Whois/WhoisHostTest.php
new file mode 100644
index 000000000..ff30e9c9e
--- /dev/null
+++ b/tests/Utils/Whois/WhoisHostTest.php
@@ -0,0 +1,45 @@
+app);
+ $this->assertEquals(config("ixp_api.whois.asn2.host"), $whois->host());
+ }
+
+ public function testExceptionIfUnknown()
+ {
+ $this->expectException(WhoisException::class);
+ WhoisHost::resolve(new WhoisHost('unknown'), $this->app);
+ }
+}
\ No newline at end of file
diff --git a/tests/Utils/Whois/WhoisLiveTest.php b/tests/Utils/Whois/WhoisLiveTest.php
new file mode 100644
index 000000000..7992195b2
--- /dev/null
+++ b/tests/Utils/Whois/WhoisLiveTest.php
@@ -0,0 +1,66 @@
+get('prefix');
+ $result = $whois->whois('8.8.8.8/32');
+ $this->assertStringContainsString('route: 8.8.8.0/24', $result);
+ $this->assertStringContainsString('origin: AS15169', $result);
+ $this->assertStringContainsString('descr: Google', $result);
+ }
+
+ public function testPrefixWithoutMask()
+ {
+ $whois = app(WhoisResolver::class)->get('prefix');
+
+ $result = $whois->whois('8.8.8.8');
+ $this->assertStringContainsString('route: 8.8.8.0/24', $result);
+ $this->assertStringContainsString('origin: AS15169', $result);
+ $this->assertStringContainsString('descr: Google', $result);
+ }
+
+ public function testPrefixWithoutMaskTrailingSlash()
+ {
+ // This test shows what happens when a trailing slash is left on, when network mask is omitted.
+ $whois = app(WhoisResolver::class)->get('prefix');
+ $result = $whois->whois('8.8.8.8/');
+ $this->assertEquals("% No entries found for the selected source(s).\n\n\n", $result);
+ }
+
+ public function testAsn()
+ {
+ $whois = app(WhoisResolver::class)->get('asn2');
+ $result = $whois->whois('AS2128');
+ $this->assertEquals("AS Name\nINEX Internet Neutral Exchange Association Company Limited By Guarantee, IE\n", $result);
+ }
+}
\ No newline at end of file
diff --git a/tests/Utils/Whois/WhoisResolverTest.php b/tests/Utils/Whois/WhoisResolverTest.php
new file mode 100644
index 000000000..6846bd60b
--- /dev/null
+++ b/tests/Utils/Whois/WhoisResolverTest.php
@@ -0,0 +1,55 @@
+expectException(WhoisException::class);
+ $this->expectExceptionMessage("Configuration not found for whois server 'unknown'");
+ $resolver->get('unknown');
+ }
+
+ public function testGet()
+ {
+ $resolver = new WhoisResolver();
+ $whois = $resolver->get('asn');
+ $this->assertEquals(config("ixp_api.whois.asn.host"), $whois->host());
+
+ $whois = $resolver->get('asn2');
+ $this->assertEquals(config("ixp_api.whois.asn2.host"), $whois->host());
+
+ $whois = $resolver->get('prefix');
+ $this->assertEquals(config("ixp_api.whois.prefix.host"), $whois->host());
+ }
+}
\ No newline at end of file