diff --git a/app/Actions/Helpers/GetExternalIpAddress.php b/app/Actions/Helpers/GetExternalIpAddress.php new file mode 100644 index 000000000..f43a53b15 --- /dev/null +++ b/app/Actions/Helpers/GetExternalIpAddress.php @@ -0,0 +1,29 @@ +get('https://icanhazip.com/'); + + if ($response->failed()) { + $message = sprintf('Failed to fetch public IP address, %d', $response->status()); + + Log::warning($message); + + return false; + } + + return Str::trim($response->body()); + } +} diff --git a/app/Enums/ResultStatus.php b/app/Enums/ResultStatus.php index 9445e03b8..bad34ff44 100644 --- a/app/Enums/ResultStatus.php +++ b/app/Enums/ResultStatus.php @@ -9,7 +9,8 @@ enum ResultStatus: string implements HasColor, HasLabel { case Completed = 'completed'; // a speedtest that ran successfully. case Failed = 'failed'; // a speedtest that failed to run successfully. - case Started = 'started'; // a speedtest that has been started by a worker but has not finish running. + case Started = 'started'; // a speedtest that has been started by a worker but has not finished running. + case Skipped = 'skipped'; // a speedtest that was skipped. public function getColor(): ?string { @@ -17,6 +18,7 @@ public function getColor(): ?string self::Completed => 'success', self::Failed => 'danger', self::Started => 'warning', + self::Skipped => 'info', // Adding Skipped state with a color }; } @@ -26,6 +28,7 @@ public function getLabel(): ?string self::Completed => 'Completed', self::Failed => 'Failed', self::Started => 'Started', + self::Skipped => 'Skipped', }; } } diff --git a/app/Events/SpeedtestSkipped.php b/app/Events/SpeedtestSkipped.php new file mode 100644 index 000000000..550efa380 --- /dev/null +++ b/app/Events/SpeedtestSkipped.php @@ -0,0 +1,20 @@ +label('Error Message') + ->label('Message') ->hint(new HtmlString('🔗Error Messages')) - ->hidden(fn (Result $record): bool => $record->status !== ResultStatus::Failed) ->columnSpanFull(), ]) ->columnSpan(2), diff --git a/app/Helpers/Network.php b/app/Helpers/Network.php new file mode 100644 index 000000000..66e7bbd60 --- /dev/null +++ b/app/Helpers/Network.php @@ -0,0 +1,22 @@ + '32']; + + $rangeDecimal = ip2long($range); + + $ipDecimal = ip2long($ip); + + $maskDecimal = ~((1 << (32 - (int) $mask)) - 1); + + return ($rangeDecimal & $maskDecimal) === ($ipDecimal & $maskDecimal); + } +} diff --git a/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php b/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php index 4c160de15..10a4e361e 100644 --- a/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php +++ b/app/Jobs/Speedtests/ExecuteOoklaSpeedtest.php @@ -2,9 +2,12 @@ namespace App\Jobs\Speedtests; +use App\Actions\Helpers\GetExternalIpAddress; use App\Enums\ResultStatus; use App\Events\SpeedtestCompleted; use App\Events\SpeedtestFailed; +use App\Events\SpeedtestSkipped; +use App\Helpers\Network; use App\Models\Result; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -13,7 +16,6 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\URL; use JJG\Ping; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; @@ -46,6 +48,20 @@ public function handle(): void return; } + $externalIp = GetExternalIpAddress::run(); + + $shouldSkip = $this->shouldSkip($externalIp); + + if ($shouldSkip !== false) { + $this->markAsSkipped( + message: $shouldSkip, + externalIp: $externalIp, + ); + + return; + } + + // Execute Speedtest $options = array_filter([ 'speedtest', '--accept-license', @@ -110,6 +126,26 @@ public function handle(): void SpeedtestCompleted::dispatch($this->result); } + /** + * Mark the test as skipped with a specific message. + */ + protected function markAsSkipped(string $message, string $externalIp): void + { + $this->result->update([ + 'data' => [ + 'type' => 'log', + 'level' => 'warning', + 'message' => $message, + 'interface' => [ + 'externalIp' => $externalIp, + ], + ], + 'status' => ResultStatus::Skipped, + ]); + + SpeedtestSkipped::dispatch($this->result); + } + /** * Check for internet connection. * @@ -120,6 +156,7 @@ protected function checkForInternetConnection(): bool $url = config('speedtest.ping_url'); // Skip checking for internet connection if ping url isn't set (disabled) + if (blank($url)) { return true; } @@ -139,7 +176,6 @@ protected function checkForInternetConnection(): bool return false; } - // Remove http:// or https:// from the URL if present $url = preg_replace('/^https?:\/\//', '', $url); $ping = new Ping( @@ -167,6 +203,8 @@ protected function checkForInternetConnection(): bool /** * Check if the given URL is a valid ping URL. + * + * TODO: move to Network helper */ public function isValidPingUrl(string $url): bool { @@ -180,4 +218,30 @@ public function isValidPingUrl(string $url): bool || (filter_var('https://'.$url, FILTER_VALIDATE_URL) && $hasTLD($url)) || filter_var($url, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 || FILTER_FLAG_IPV6) !== false; } + + /** + * Check if the speedtest should be skipped based on the skip ips list. + */ + public function shouldSkip(string $externalIp): bool|string + { + if (blank(config('speedtest.skip_ips'))) { + return false; + } + + $skipIPs = array_map('trim', explode(',', config('speedtest.skip_ips'))); + + foreach ($skipIPs as $ip) { + // Check for exact IP match + if (filter_var($ip, FILTER_VALIDATE_IP) && $externalIp === $ip) { + return sprintf('"%s" was found in public IP address skip list.', $externalIp); + } + + // Check for IP range match + if (strpos($ip, '/') !== false && Network::ipInRange($externalIp, $ip)) { + return sprintf('"%s" was found in public IP address skip list within range "%s".', $externalIp, $ip); + } + } + + return false; + } } diff --git a/config/speedtest.php b/config/speedtest.php index f45c484a8..7ebed9940 100644 --- a/config/speedtest.php +++ b/config/speedtest.php @@ -35,4 +35,9 @@ 'servers' => env('SPEEDTEST_SERVERS', ''), + /** + * IP filtering settings. + */ + 'skip_ips' => env('SPEEDTEST_SKIP_IPS'), + ];