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