diff --git a/app/Console/Commands/Admin/AppFederation.php b/app/Console/Commands/Admin/AppFederation.php new file mode 100644 index 000000000..f3e075644 --- /dev/null +++ b/app/Console/Commands/Admin/AppFederation.php @@ -0,0 +1,2442 @@ + 'CONFIG', + 'actor' => 'INSTANCE ACTOR', + 'endpoints' => 'LOCAL ENDPOINTS (outside-in)', + 'signing' => 'HTTP SIGNATURES', + 'queues' => 'QUEUES', + 'delivery' => 'DELIVERY HEALTH', + 'env' => 'ENVIRONMENT', + 'remote' => 'REMOTE PROBE', + ]; + + /** + * Queues that carry federation work on this branch, and what rides on + * them. A queue without a worker means that traffic silently stalls. + */ + private const array FEDERATION_QUEUES = [ + 'default' => 'outbound status delivery', + 'high' => 'user inbox activities', + 'shared' => 'shared inbox activities', + 'follow' => 'inbound and outbound follows', + 'inbox' => 'inbound actor deletes', + 'delete' => 'inbound deletes', + 'story' => 'story deletes', + 'feed' => 'likes, home feed inserts after ingest', + 'low' => 'shares, signing actor discovery, remote avatars', + 'move' => 'account migration', + ]; + + private const array FEDERATION_JOB_NEEDLES = [ + 'InboxPipeline', + 'FollowPipeline', + 'StatusPipeline', + 'SharePipeline', + 'LikePipeline', + 'DeletePipeline', + 'ProfilePipeline', + 'MovePipeline', + 'CommentPipeline', + 'StoryPipeline', + 'FeaturedCollectionPipeline', + 'Federation', + ]; + + private const array AP_ACCEPT_TYPES = [ + 'application/activity+json', + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + ]; + + private const string CLOCK_REFERENCE = 'https://pixelfed.org'; + + private const int CLOCK_SKEW_WARN = 30; + + private const int CLOCK_SKEW_FAIL = 300; + + private const int QUEUE_DEPTH_WARN = 1000; + + private const int QUEUE_DEPTH_FAIL = 10000; + + private const int QUEUE_WAIT_WARN = 120; + + private const int FAILED_JOB_SCAN_LIMIT = 5000; + + private const int MAX_REDIRECTS = 2; + + /** @var array */ + protected array $results = []; + + /** @var array> */ + protected array $data = []; + + protected string $currentGroup = 'config'; + + protected bool $asJson = false; + + protected bool $skipHttp = false; + + protected bool $horizonRunning = false; + + protected bool $testProfileResolved = false; + + protected ?Profile $testProfile = null; + + public function handle(): int + { + $this->asJson = (bool) $this->option('json'); + $this->skipHttp = (bool) $this->option('skip-http'); + + $groups = $this->resolveGroups(); + + if ($groups === null) { + return self::INVALID; + } + + foreach ($groups as $group) { + $this->currentGroup = $group; + $this->section(self::TITLES[$group]); + + try { + match ($group) { + 'config' => $this->checkConfig(), + 'actor' => $this->checkInstanceActor(), + 'endpoints' => $this->checkEndpoints(), + 'signing' => $this->checkSigning(), + 'queues' => $this->checkQueues(), + 'delivery' => $this->checkDelivery(), + 'env' => $this->checkEnvironment(), + 'remote' => $this->checkRemote((string) $this->option('remote')), + }; + } catch (Throwable $e) { + $this->problem( + 'Check group crashed', + class_basename($e).': '.Str::limit($e->getMessage(), 200) + ); + } + + $this->blank(); + } + + return $this->finish(); + } + + /** + * @return array|null + */ + protected function resolveGroups(): ?array + { + $known = [...self::LOCAL_GROUPS, 'remote']; + $remote = trim((string) $this->option('remote')); + $only = trim((string) $this->option('only')); + + if ($only === '') { + $groups = self::LOCAL_GROUPS; + } else { + $groups = array_values(array_unique(array_filter(array_map( + fn (string $group): string => strtolower(trim($group)), + explode(',', $only) + )))); + + $unknown = array_diff($groups, $known); + + if ($unknown !== []) { + $this->error('Unknown group(s): '.implode(', ', $unknown).'. Valid groups: '.implode(', ', $known)); + + return null; + } + + if (in_array('remote', $groups, true) && $remote === '') { + $this->error('The remote group needs a target, for example --remote=@user@example.com'); + + return null; + } + } + + if ($remote !== '' && ! in_array('remote', $groups, true)) { + $groups[] = 'remote'; + } + + return array_values(array_intersect($known, $groups)); + } + + protected function checkConfig(): void + { + $env = app()->environment(); + + if ($env === 'production') { + $this->pass('APP_ENV is production'); + } else { + $this->problem( + 'APP_ENV is "'.$env.'"', + 'outbound delivery is skipped and instance bans are not enforced', + 'ActivityPubDeliveryService only delivers when APP_ENV=production. Expected on a dev box, fatal on a live instance.' + ); + } + + $this->checkToggle('federation.activitypub.enabled', 'ActivityPub', 'ACTIVITY_PUB', true); + + foreach ( + [ + 'federation.activitypub.inbox' => 'Inbox (AP_INBOX)', + 'federation.activitypub.outbox' => 'Outbox (AP_OUTBOX)', + 'federation.activitypub.sharedInbox' => 'Shared inbox (AP_SHAREDINBOX)', + 'federation.activitypub.remoteFollow' => 'Remote follow (AP_REMOTE_FOLLOW)', + ] as $key => $label + ) { + if (config($key)) { + $this->pass($label.' enabled'); + } else { + $this->caution($label.' is disabled'); + } + } + + if (config('federation.webfinger.enabled')) { + $this->pass('WebFinger enabled'); + } else { + $this->problem( + 'WebFinger is disabled (WEBFINGER=false)', + 'remote servers cannot discover local accounts' + ); + } + + if (config('federation.nodeinfo.enabled')) { + $this->pass('NodeInfo enabled'); + } else { + $this->caution( + 'NodeInfo is disabled (NODEINFO=false)', + 'other servers and crawlers cannot identify this instance' + ); + } + + $this->checkToggle('federation.activitypub.authorized_fetch', 'Authorized fetch', 'AUTHORIZED_FETCH', false); + + $this->note( + 'Followers sync (FEP-8fcf)', + config('federation.activitypub.followers_sync.enabled') ? 'enabled' : 'disabled' + ); + + $this->note('Delivery tuning', sprintf( + 'timeout=%ss concurrency=%s failure_threshold=%s max_backoff=%ss', + config('federation.activitypub.delivery.timeout'), + config('federation.activitypub.delivery.concurrency'), + config('federation.activitypub.delivery.failure_threshold'), + config('federation.activitypub.delivery.max_backoff') + )); + + $this->checkAppUrl(); + $this->checkConfigCacheFreshness(); + $this->checkCacheStore(); + $this->checkSchema(); + } + + /** + * Report a boolean config key that the admin dashboard can override + * through the config_cache table, without calling config_cache() itself + * (it inserts a row when none exists). + */ + protected function checkToggle(string $key, string $label, string $envName, bool $required): void + { + $envValue = (bool) config($key); + $effective = $envValue; + $parts = ['env='.$this->bool($envValue)]; + + if (config('instance.enable_cc')) { + try { + $row = ConfigCache::where('k', $key)->first(); + $cached = Cache::get(ConfigCacheService::CACHE_KEY.$key); + + if ($row) { + $dbValue = $this->truthy($row->v); + $effective = $dbValue; + $parts[] = 'admin setting='.$this->bool($dbValue); + + if ($cached !== null && $this->truthy($cached) !== $dbValue) { + $effective = $this->truthy($cached); + + $this->caution( + $label.' setting cache is stale', + 'cached='.$this->bool($effective).' database='.$this->bool($dbValue), + 'php artisan cache:forget "'.ConfigCacheService::CACHE_KEY.$key.'"' + ); + } + } elseif ($cached !== null) { + $effective = $this->truthy($cached); + $parts[] = 'cached='.$this->bool($effective); + } + } catch (Throwable $e) { + $this->caution( + 'Could not read the admin override for '.$key, + class_basename($e) + ); + } + } + + $detail = implode(' ', $parts); + + if (! $required) { + $this->note($label, ($effective ? 'enabled' : 'disabled').' ('.$detail.')'); + + return; + } + + if (! $effective) { + $this->problem( + $label.' is disabled', + $detail, + $envValue + ? 'The admin settings override is turning it off. Re-enable it in the admin dashboard.' + : 'Set '.$envName.'=true, then run php artisan config:cache.' + ); + + return; + } + + if ($effective !== $envValue) { + $this->caution($label.' enabled by admin setting only', $detail, 'The .env value disagrees with the admin setting. The admin setting wins while ENABLE_CONFIG_CACHE is on.'); + + return; + } + + $this->pass($label.' enabled', $detail); + } + + protected function checkAppUrl(): void + { + $appUrl = (string) config('app.url'); + $domain = strtolower((string) config('pixelfed.domain.app')); + $parts = parse_url($appUrl) ?: []; + $host = strtolower((string) ($parts['host'] ?? '')); + + if (($parts['scheme'] ?? '') !== 'https') { + $this->problem( + 'APP_URL is not https', + $appUrl, + 'Actor ids are built from APP_URL and Pixelfed only accepts https ActivityPub URLs.' + ); + } else { + $this->pass('APP_URL uses https', $appUrl); + } + + if ($domain === '') { + $this->problem('APP_DOMAIN is empty', null, 'Set APP_DOMAIN to the bare hostname of this instance.'); + } elseif ($host !== $domain) { + $this->problem( + 'APP_URL host does not match APP_DOMAIN', + 'APP_URL host='.$host.' APP_DOMAIN='.$domain, + 'WebFinger answers for APP_DOMAIN while actor ids use APP_URL. They have to be the same host.' + ); + } else { + $this->pass('APP_URL host matches APP_DOMAIN', $domain); + } + + $path = trim((string) ($parts['path'] ?? ''), '/'); + + if ($path !== '' || isset($parts['query'])) { + $this->caution('APP_URL contains a path or query', $appUrl, 'Use the bare origin, for example https://'.$domain); + } + } + + protected function checkConfigCacheFreshness(): void + { + if (! app()->configurationIsCached()) { + $this->note('Config cache', 'not cached, .env is read live'); + + return; + } + + $cachedAt = @filemtime(app()->getCachedConfigPath()); + $envAt = @filemtime(app()->environmentFilePath()); + + if ($cachedAt && $envAt && $envAt > $cachedAt) { + $this->caution( + '.env is newer than the cached config', + '.env changed '.Carbon::createFromTimestamp($envAt)->diffForHumans().', config cached '.Carbon::createFromTimestamp($cachedAt)->diffForHumans(), + 'php artisan config:cache (and restart Horizon so workers pick it up)' + ); + + return; + } + + $this->pass('Cached config is newer than .env'); + } + + protected function checkCacheStore(): void + { + $store = (string) config('cache.default'); + + if (in_array($store, ['array', 'null'], true)) { + $this->problem( + 'Cache store is "'.$store.'"', + 'nothing is shared between web requests and queue workers', + 'Set CACHE_STORE=redis.' + ); + + return; + } + + $this->note('Cache store', $store); + } + + protected function checkSchema(): void + { + $expected = [ + ['instance_actors', null, true], + ['instances', 'delivery_failures', true], + ['profiles', 'followers_url', (bool) config('federation.activitypub.followers_sync.enabled')], + ]; + + foreach ($expected as [$table, $column, $required]) { + try { + $exists = $column === null + ? Schema::hasTable($table) + : Schema::hasColumn($table, $column); + } catch (Throwable) { + $exists = false; + } + + if ($exists || ! $required) { + continue; + } + + $this->problem( + 'Missing '.($column === null ? 'table '.$table : 'column '.$table.'.'.$column), + null, + 'php artisan migrate' + ); + } + + try { + $migrator = app('migrator'); + + if (! $migrator->repositoryExists()) { + $this->caution('Migration table not found', null, 'php artisan migrate'); + + return; + } + + $files = array_keys($migrator->getMigrationFiles([database_path('migrations'), ...$migrator->paths()])); + $pending = array_values(array_diff($files, $migrator->getRepository()->getRan())); + + if ($pending === []) { + $this->pass('No pending migrations'); + + return; + } + + $this->caution( + count($pending).' pending migration(s)', + implode(', ', array_slice($pending, 0, 3)).(count($pending) > 3 ? ', ...' : ''), + 'php artisan migrate' + ); + } catch (Throwable $e) { + $this->skipped('Pending migrations', class_basename($e)); + } + } + + protected function checkInstanceActor(): void + { + if (! Schema::hasTable('instance_actors')) { + $this->problem('Missing instance_actors table', null, 'php artisan migrate'); + + return; + } + + $count = InstanceActor::count(); + $actor = InstanceActor::first(); + + if (! $actor) { + $this->problem( + 'No instance actor', + 'signed fetches cannot work without it', + 'php artisan instance:actor' + ); + + return; + } + + if ($count > 1) { + $this->caution('Multiple instance actor rows', $count.' rows, the first one (id '.$actor->id.') is used'); + } else { + $this->pass('Instance actor exists', $actor->permalink()); + } + + $pair = $this->inspectKeyPair($actor->private_key, $actor->public_key); + + if ($pair['error']) { + $this->problem( + 'Instance actor keypair is broken', + $pair['error'], + 'Remote servers verify with the published public key, so every signed fetch fails. Delete the row and run php artisan instance:actor, then clear the three caches below.' + ); + } else { + $this->pass('Instance actor keypair is valid', $pair['bits'].' bit RSA, public key derives from private key'); + + if ($pair['bits'] < 2048) { + $this->caution('Instance actor key is shorter than 2048 bits', $pair['bits'].' bits'); + } + } + + $this->compareCachedKey(InstanceActor::PKI_PRIVATE, $actor->private_key, 'private'); + $this->compareCachedKey(InstanceActor::PKI_PUBLIC, $actor->public_key, 'public'); + + $cachedProfile = Cache::get(InstanceActor::PROFILE_KEY); + + if (! is_string($cachedProfile) || $cachedProfile === '') { + $this->note('Cached actor document', 'not cached yet'); + + return; + } + + $doc = json_decode($cachedProfile, true); + $stale = []; + + if (! is_array($doc)) { + $stale[] = 'not valid JSON'; + } else { + if (($doc['id'] ?? null) !== $actor->permalink()) { + $stale[] = 'id is '.($doc['id'] ?? 'missing').', expected '.$actor->permalink(); + } + + if (! $this->samePem($doc['publicKey']['publicKeyPem'] ?? null, $actor->public_key)) { + $stale[] = 'publicKeyPem differs from the database'; + } + } + + if ($stale !== []) { + $this->problem( + 'Cached actor document is stale', + implode('; ', $stale), + 'php artisan cache:forget "'.InstanceActor::PROFILE_KEY.'"' + ); + + return; + } + + $this->pass('Cached actor document matches the database'); + } + + protected function compareCachedKey(string $cacheKey, ?string $dbValue, string $kind): void + { + $cached = Cache::get($cacheKey); + + if ($cached === null) { + $this->note('Cached '.$kind.' key', 'not cached yet'); + + return; + } + + if ($this->samePem($cached, $dbValue)) { + $this->pass('Cached '.$kind.' key matches the database'); + + return; + } + + $this->problem( + 'Cached '.$kind.' key differs from the database', + 'requests are signed with a key that is not the published one', + 'php artisan cache:forget "'.$cacheKey.'" (then restart Horizon)' + ); + } + + protected function checkEndpoints(): void + { + if ($this->skipHttp) { + $this->skipped('Local endpoint checks', '--skip-http'); + + return; + } + + $domain = (string) config('pixelfed.domain.app'); + $profile = $this->testProfile(); + + $probe = $this->localGet('/.well-known/host-meta', 'application/xrd+xml'); + + if ($probe['error']) { + $this->caution( + 'Could not reach '.config('app.url').' from this server', + $probe['error'], + 'No hairpin route or internal DNS for your own domain? Run this group from a box that can reach the public URL, the remaining endpoint checks are skipped.' + ); + + return; + } + + if ($this->expectStatus($probe, 'host-meta', 200)) { + if (str_contains($probe['body'], 'lrdd') && str_contains($probe['body'], '/.well-known/webfinger')) { + $this->pass('host-meta', 'points at /.well-known/webfinger'); + } else { + $this->problem('host-meta has no lrdd webfinger template'); + } + } + + if ($profile) { + $acct = 'acct:'.$profile->username.'@'.$domain; + $res = $this->localGet('/.well-known/webfinger?resource='.rawurlencode($acct), 'application/jrd+json, application/json'); + + if ($this->expectStatus($res, 'WebFinger '.$acct, 200)) { + $this->assertWebfinger($res, $acct, $profile->permalink()); + } + } else { + $this->skipped('WebFinger for a local user', 'no usable local profile, pass --user='); + } + + if (config('federation.activitypub.sharedInbox')) { + $acct = 'acct:'.$domain.'@'.$domain; + $res = $this->localGet('/.well-known/webfinger?resource='.rawurlencode($acct), 'application/jrd+json, application/json'); + + if ($this->expectStatus($res, 'WebFinger instance actor', 200)) { + $this->assertWebfinger($res, $acct, url('/i/actor')); + } + } + + $this->checkNodeinfoEndpoints(); + $this->checkInstanceActorEndpoint(); + + if ($profile) { + foreach (self::AP_ACCEPT_TYPES as $accept) { + $this->checkUserActorEndpoint($profile, $accept); + } + } + + $inbox = $this->localGet('/f/inbox', 'application/activity+json'); + + if ($inbox['error']) { + $this->caution('Shared inbox route', $inbox['error']); + } elseif ($inbox['status'] === 405) { + $this->pass('Shared inbox route reaches Laravel', 'GET /f/inbox returned 405 as expected'); + } else { + $this->problem( + 'Shared inbox route answered '.$inbox['status'].' to a GET', + 'expected 405 Method Not Allowed', + $this->statusHint($inbox) + ); + } + } + + protected function checkNodeinfoEndpoints(): void + { + if (! config('federation.nodeinfo.enabled')) { + $this->skipped('NodeInfo endpoints', 'disabled'); + + return; + } + + $res = $this->localGet('/.well-known/nodeinfo', 'application/json'); + + if (! $this->expectStatus($res, 'NodeInfo discovery', 200)) { + return; + } + + $href = collect($res['json']['links'] ?? [])->pluck('href')->filter()->first(); + + if (! is_string($href) || parse_url($href, PHP_URL_HOST) !== parse_url((string) config('app.url'), PHP_URL_HOST)) { + $this->problem('NodeInfo discovery link is missing or on another host', is_string($href) ? $href : null); + + return; + } + + $this->pass('NodeInfo discovery', $href); + + $doc = $this->localGet((string) parse_url($href, PHP_URL_PATH), 'application/json'); + + if (! $this->expectStatus($doc, 'NodeInfo document', 200)) { + return; + } + + $software = $doc['json']['software']['name'] ?? null; + $protocols = $doc['json']['protocols'] ?? []; + + if ($software === 'pixelfed' && in_array('activitypub', (array) $protocols, true)) { + $this->pass('NodeInfo document', 'pixelfed '.($doc['json']['software']['version'] ?? '?').', protocols: '.implode(',', (array) $protocols)); + + return; + } + + $this->problem( + 'NodeInfo document looks wrong', + 'software='.json_encode($software).' protocols='.json_encode($protocols) + ); + } + + protected function checkInstanceActorEndpoint(): void + { + $res = $this->localGet('/i/actor', 'application/activity+json'); + + if (! $this->expectStatus($res, 'Instance actor document', 200)) { + return; + } + + $issues = $this->actorDocumentIssues($res, url('/i/actor'), InstanceActor::first()?->public_key); + + if ($issues !== []) { + $this->problem( + 'Instance actor document', + implode('; ', $issues), + 'The document is cached forever. If the id or key is stale: php artisan cache:forget "'.InstanceActor::PROFILE_KEY.'"' + ); + + return; + } + + $this->pass('Instance actor document', 'id, inbox and publicKeyPem all match'); + } + + protected function checkUserActorEndpoint(Profile $profile, string $accept): void + { + $label = 'Actor @'.$profile->username.' as '.Str::before($accept, ';'); + $path = (string) parse_url($profile->permalink(), PHP_URL_PATH); + $res = $this->localGet($path, $accept); + + if (! $this->expectStatus($res, $label, 200)) { + return; + } + + $issues = $this->actorDocumentIssues($res, $profile->permalink(), $profile->public_key); + + if (($res['json']['publicKey']['id'] ?? null) !== $profile->keyId()) { + $issues[] = 'publicKey.id is '.($res['json']['publicKey']['id'] ?? 'missing').', expected '.$profile->keyId(); + } + + if ( + config('federation.activitypub.sharedInbox') + && ($res['json']['endpoints']['sharedInbox'] ?? null) !== url('/f/inbox') + ) { + $issues[] = 'endpoints.sharedInbox is '.($res['json']['endpoints']['sharedInbox'] ?? 'missing'); + } + + if ($issues !== []) { + $this->problem( + $label, + implode('; ', $issues), + 'The actor document is cached for 30 minutes: php artisan cache:forget "pf:activitypub:user-object:by-id:'.$profile->id.'"' + ); + + return; + } + + $this->pass($label, 'id, inbox, keyId and publicKeyPem all match'); + + $vary = strtolower((string) $res['response']->header('Vary')); + $cacheHeader = collect(['cf-cache-status', 'x-cache', 'age', 'x-served-by']) + ->first(fn (string $name): bool => (string) $res['response']->header($name) !== ''); + + if ($cacheHeader && ! str_contains($vary, 'accept')) { + $this->caution( + 'Actor response is behind a cache without Vary: Accept', + 'saw a '.$cacheHeader.' header, Vary='.($vary ?: 'none'), + 'A CDN can end up serving the HTML profile page to ActivityPub fetches. Bypass the cache for /users/* or add Vary: Accept.' + ); + } + } + + /** + * @param array $res + * @return array + */ + protected function actorDocumentIssues(array $res, string $expectedId, ?string $expectedPem): array + { + $issues = []; + $doc = $res['json']; + + if (! is_array($doc)) { + return ['response body is not JSON']; + } + + if (! $this->isActivityPubContentType($res['content_type'])) { + $issues[] = 'Content-Type is '.($res['content_type'] ?: 'missing'); + } + + if (($doc['id'] ?? null) !== $expectedId) { + $issues[] = 'id is '.($doc['id'] ?? 'missing').', expected '.$expectedId; + } + + if (! is_string($doc['inbox'] ?? null) || ! str_starts_with($doc['inbox'], $expectedId)) { + $issues[] = 'inbox is '.json_encode($doc['inbox'] ?? null, JSON_UNESCAPED_SLASHES); + } + + if (! $this->samePem($doc['publicKey']['publicKeyPem'] ?? null, $expectedPem)) { + $issues[] = 'publicKeyPem differs from the database'; + } + + return $issues; + } + + /** + * @param array $res + */ + protected function assertWebfinger(array $res, string $acct, string $expectedSelf): void + { + $doc = $res['json']; + + if (! is_array($doc)) { + $this->problem('WebFinger '.$acct, 'response body is not JSON', $this->statusHint($res)); + + return; + } + + $self = collect($doc['links'] ?? []) + ->first(fn ($link): bool => is_array($link) + && ($link['rel'] ?? null) === 'self' + && $this->isActivityPubContentType($link['type'] ?? null)); + + $issues = []; + + if (($doc['subject'] ?? null) !== $acct) { + $issues[] = 'subject is '.json_encode($doc['subject'] ?? null, JSON_UNESCAPED_SLASHES); + } + + if (! $self) { + $issues[] = 'no rel=self ActivityPub link'; + } elseif (($self['href'] ?? null) !== $expectedSelf) { + $issues[] = 'self link is '.($self['href'] ?? 'missing').', expected '.$expectedSelf; + } + + if ($issues !== []) { + $this->problem('WebFinger '.$acct, implode('; ', $issues)); + + return; + } + + $this->pass('WebFinger '.$acct, 'self link is '.$expectedSelf); + } + + protected function checkSigning(): void + { + $active = Profile::whereNull('domain')->whereNull('status'); + $total = (clone $active)->count(); + + $missingPrivate = (clone $active)->where(function ($query) { + $query->whereNull('private_key')->orWhere('private_key', ''); + })->count(); + + $missingPublic = (clone $active)->where(function ($query) { + $query->whereNull('public_key')->orWhere('public_key', ''); + })->count(); + + if ($missingPrivate === 0 && $missingPublic === 0) { + $this->pass('All active local profiles have a keypair', $total.' profiles'); + } else { + $this->caution( + 'Active local profiles without keys', + $missingPrivate.' missing private_key, '.$missingPublic.' missing public_key (of '.$total.')', + 'Those accounts cannot deliver anything (validateSender throws). Inspect one with php artisan status:profile .' + ); + } + + $profile = $this->testProfile(); + + if (! $profile) { + $this->skipped('Profile signature round trip', 'no usable local profile, pass --user='); + } else { + $this->profileRoundTrip($profile); + } + + $this->instanceActorRoundTrip(); + } + + protected function profileRoundTrip(Profile $profile): void + { + $label = 'POST signature round trip as @'.$profile->username; + + $pair = $this->inspectKeyPair($profile->private_key, $profile->public_key); + + if ($pair['error']) { + $this->problem($label, $pair['error'], 'Remote servers verify against the published public_key, so everything this account sends is rejected.'); + + return; + } + + $url = 'https://federation-check.invalid/inbox'; + + $payload = json_encode([ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $profile->permalink('#status-federation-'.Str::random(8)), + 'type' => 'Like', + 'actor' => $profile->permalink(), + 'object' => 'https://federation-check.invalid/objects/1', + ], JSON_UNESCAPED_SLASHES); + + $signed = $this->parseCurlHeaders(HttpSignature::sign($profile, $url, $payload, [ + 'Content-Type' => self::AP_ACCEPT_TYPES[1], + 'User-Agent' => $this->userAgent(), + ])); + + if ($signed === []) { + $this->problem($label, 'HttpSignature::sign() returned no headers'); + + return; + } + + $issues = []; + $lower = array_change_key_case($signed, CASE_LOWER); + + foreach (['host', 'date', 'digest', 'content-type', 'signature'] as $required) { + if (empty($lower[$required])) { + $issues[] = 'missing '.$required.' header'; + } + } + + $signature = $this->parseSignature($lower['signature'] ?? ''); + $signedNames = preg_split('/\s+/', strtolower($signature['headers'] ?? '')) ?: []; + + foreach (['(request-target)', 'host', 'date', 'digest'] as $required) { + if (! in_array($required, $signedNames, true)) { + $issues[] = $required.' is not part of the signed header list'; + } + } + + if (($signature['keyId'] ?? null) !== $profile->keyId()) { + $issues[] = 'keyId is '.($signature['keyId'] ?? 'missing').', expected '.$profile->keyId(); + } + + if (($lower['digest'] ?? null) !== 'SHA-256='.base64_encode(hash('sha256', $payload, true))) { + $issues[] = 'Digest does not match the payload bytes'; + } + + if ($issues === []) { + [$verified] = HttpSignature::verify( + openssl_pkey_get_public($profile->public_key), + $signature, + $signed, + '/inbox', + $payload, + 'post' + ); + + if ($verified !== 1) { + $issues[] = 'signature does not verify against the published public key'; + } + } + + if ($issues !== []) { + $this->problem($label, implode('; ', $issues)); + + return; + } + + $this->pass($label, 'signed headers: '.$signature['headers']); + } + + protected function instanceActorRoundTrip(): void + { + $label = 'GET signature round trip as the instance actor'; + $actor = Schema::hasTable('instance_actors') ? InstanceActor::first() : null; + + if (! $actor || empty($actor->public_key)) { + $this->skipped($label, 'no instance actor'); + + return; + } + + $accept = self::AP_ACCEPT_TYPES[0]; + + $signed = HttpSignature::instanceActorSign( + 'https://federation-check.invalid/users/check?page=1', + false, + ['Accept' => $accept], + 'get' + ); + + $signature = $this->parseSignature($signed['Signature'] ?? ''); + $issues = []; + + if (($signature['keyId'] ?? null) !== $actor->permalink('#main-key')) { + $issues[] = 'keyId is '.($signature['keyId'] ?? 'missing').', the actor document publishes '.$actor->permalink('#main-key'); + } + + $publicKey = openssl_pkey_get_public($actor->public_key); + + if (! $publicKey) { + $issues[] = 'published public key does not parse'; + } else { + [$verified] = HttpSignature::verify( + $publicKey, + $signature, + $signed, + '/users/check?page=1', + '', + 'get' + ); + + if ($verified !== 1) { + $issues[] = 'signature does not verify against the published public key'; + } + } + + if ($issues !== []) { + $this->problem( + $label, + implode('; ', $issues), + 'Usually a stale cached private key: php artisan cache:forget "'.InstanceActor::PKI_PRIVATE.'"' + ); + + return; + } + + $this->pass($label, 'signed headers: '.$signature['headers']); + } + + protected function checkQueues(): void + { + $connection = (string) config('queue.default'); + $driver = (string) config('queue.connections.'.$connection.'.driver'); + + if ($driver === 'sync') { + $this->caution( + 'Queue driver is sync', + 'inbox processing and delivery run inside web requests', + 'Set QUEUE_CONNECTION=redis and run Horizon.' + ); + } else { + $this->note('Queue connection', $connection.' ('.$driver.' driver)'); + } + + $covered = $driver === 'redis' ? $this->horizonCoverage() : null; + + if ($covered === null && $driver !== 'sync') { + $this->note( + 'Workers cannot be detected for this driver', + 'make sure something works these queues: '.implode(',', array_keys(self::FEDERATION_QUEUES)) + ); + } + + $waits = $this->horizonWaits(); + $rows = []; + + foreach (self::FEDERATION_QUEUES as $queue => $carries) { + $pending = null; + + try { + $pending = (int) Queue::connection($connection)->size($queue); + } catch (Throwable) { + } + + $wait = $waits[$connection.':'.$queue] ?? null; + $hasWorker = $covered === null ? null : in_array($queue, $covered, true); + + $rows[] = [ + $queue, + match (true) { + $hasWorker === null => '?', + $hasWorker === false => 'NO', + $this->horizonRunning => 'yes', + default => 'configured', + }, + $pending ?? '?', + $wait === null ? '-' : $wait.'s', + $carries, + ]; + + if ($hasWorker === false) { + $this->problem( + 'No worker for the "'.$queue.'" queue', + 'carries '.$carries, + 'Add it to a supervisor in config/horizon.php, then php artisan horizon:terminate.' + ); + } + + if ($pending !== null && $pending >= self::QUEUE_DEPTH_FAIL) { + $this->problem('Queue "'.$queue.'" is badly backed up', number_format($pending).' pending jobs, carries '.$carries); + } elseif ($pending !== null && $pending >= self::QUEUE_DEPTH_WARN) { + $this->caution('Queue "'.$queue.'" is backing up', number_format($pending).' pending jobs, carries '.$carries); + } + + if ($wait !== null && $wait >= self::QUEUE_WAIT_WARN) { + $this->caution('Queue "'.$queue.'" wait time is '.$wait.'s', 'carries '.$carries, 'Raise maxProcesses for its supervisor.'); + } + } + + $this->renderTable('federation_queues', ['Queue', 'Worker', 'Pending', 'Wait', 'Carries'], $rows); + + $this->checkFailedJobs(); + } + + /** + * Queues that have a Horizon worker, or null when Horizon is not in use. + * + * Live supervisors are preferred. When Horizon is down the configured + * plan is used instead, so a single "not running" failure is reported + * rather than one per queue. + * + * @return array|null + */ + protected function horizonCoverage(): ?array + { + $masters = 'Laravel\Horizon\Contracts\MasterSupervisorRepository'; + $supervisors = 'Laravel\Horizon\Contracts\SupervisorRepository'; + + if (! interface_exists($masters) || ! app()->bound($masters)) { + return null; + } + + $horizonEnv = (string) (config('horizon.env') ?? config('app.env')); + $environments = (array) config('horizon.environments', []); + + $plan = collect($environments) + ->first(fn ($supervisors, $pattern): bool => Str::is((string) $pattern, $horizonEnv)); + + if ($plan === null) { + $this->problem( + 'Horizon has no supervisors for the "'.$horizonEnv.'" environment', + 'config/horizon.php defines: '.implode(', ', array_keys($environments)), + 'Horizon boots without workers unless the environment key exists. Add it or set HORIZON_ENV.' + ); + } + + $configured = $plan === null ? [] : $this->queuesFrom( + array_replace_recursive((array) config('horizon.defaults', []), (array) $plan) + ); + + try { + $running = app($masters)->all(); + } catch (Throwable $e) { + $this->problem('Could not read Horizon state from Redis', class_basename($e).': '.Str::limit($e->getMessage(), 120)); + + return $configured; + } + + if ($running === []) { + $this->problem( + 'Horizon is not running', + 'no master supervisor found, nothing is being processed', + 'php artisan horizon (check your supervisor or systemd unit)' + ); + + return $configured; + } + + $paused = collect($running)->first(fn ($master): bool => ($master->status ?? '') !== 'running'); + + $this->horizonRunning = true; + + if ($paused) { + $this->caution('Horizon is '.$paused->status, null, 'php artisan horizon:continue'); + } else { + $this->pass('Horizon is running', count($running).' master supervisor(s)'); + } + + return $this->queuesFrom( + collect(app($supervisors)->all()) + ->map(fn ($supervisor): array => (array) ($supervisor->options ?? [])) + ->all() + ); + } + + /** + * @param array> $supervisors + * @return array + */ + protected function queuesFrom(array $supervisors): array + { + $queues = []; + + foreach ($supervisors as $options) { + $list = $options['queue'] ?? []; + $list = is_array($list) ? $list : explode(',', (string) $list); + + foreach ($list as $queue) { + $queues[] = trim((string) $queue); + } + } + + return array_values(array_unique(array_filter($queues))); + } + + /** + * @return array + */ + protected function horizonWaits(): array + { + $calculator = 'Laravel\Horizon\WaitTimeCalculator'; + + if (! class_exists($calculator)) { + return []; + } + + try { + return array_map( + fn ($seconds): int => (int) round((float) $seconds), + (array) app($calculator)->calculate() + ); + } catch (Throwable) { + return []; + } + } + + protected function checkFailedJobs(): void + { + $table = (string) config('queue.failed.table', 'failed_jobs'); + $connection = config('queue.failed.database'); + + try { + if (! Schema::connection($connection)->hasTable($table)) { + $this->skipped('Failed jobs', 'no '.$table.' table'); + + return; + } + + $rows = DB::connection($connection) + ->table($table) + ->where('failed_at', '>=', now()->subDay()) + ->orderByDesc('id') + ->limit(self::FAILED_JOB_SCAN_LIMIT) + ->get([ + 'queue', + 'failed_at', + DB::raw('SUBSTR(payload, 1, 600) as head'), + DB::raw('SUBSTR(exception, 1, 400) as reason'), + ]); + } catch (Throwable $e) { + $this->skipped('Failed jobs', class_basename($e)); + + return; + } + + $groups = []; + + foreach ($rows as $row) { + $class = $this->jobClassFromPayloadHead((string) $row->head); + + if (! $class || ! Str::contains($class, self::FEDERATION_JOB_NEEDLES)) { + continue; + } + + $groups[$class] ??= [ + 'count' => 0, + 'queue' => (string) $row->queue, + 'latest' => (string) $row->failed_at, + 'reason' => Str::limit(trim(Str::before((string) $row->reason, "\n")), 110), + ]; + + $groups[$class]['count']++; + } + + $scanned = $rows->count().($rows->count() >= self::FAILED_JOB_SCAN_LIMIT ? '+' : ''); + + if ($groups === []) { + $this->pass('No failed federation jobs in the last 24h', $scanned.' failed jobs scanned'); + + return; + } + + uasort($groups, fn (array $a, array $b): int => $b['count'] <=> $a['count']); + + $this->caution( + array_sum(array_column($groups, 'count')).' failed federation jobs in the last 24h', + count($groups).' job classes, '.$scanned.' failed jobs scanned', + 'Full traces: Horizon dashboard or php artisan queue:failed' + ); + + $this->renderTable( + 'failed_federation_jobs', + ['Job', 'Count', 'Queue', 'Latest', 'Latest exception'], + collect($groups) + ->map(fn (array $group, string $class): array => [ + Str::after($class, 'App\\Jobs\\'), + $group['count'], + $group['queue'], + $group['latest'], + $group['reason'], + ]) + ->values() + ->all() + ); + } + + protected function checkDelivery(): void + { + if (! Schema::hasColumn('instances', 'delivery_failures')) { + $this->problem( + 'instances.delivery_failures column is missing', + 'DeliveryHostService cannot track dead hosts, every fanout retries them', + 'php artisan migrate' + ); + + return; + } + + $total = Instance::count(); + $banned = Instance::whereBanned(true)->count(); + $failing = Instance::where('delivery_failures', '>', 0)->count(); + + $backedOff = Instance::where('delivery_timeout', true) + ->where('delivery_next_after', '>', now()) + ->count(); + + $this->note('Known instances', number_format($total).' total, '.number_format($banned).' banned'); + + if ($failing === 0) { + $this->pass('No hosts with delivery failures'); + } else { + $this->note( + 'Hosts with delivery failures', + number_format($failing).' failing, '.number_format($backedOff).' currently skipped (backoff)' + ); + } + + if ($total > 0 && $backedOff / $total >= 0.25 && $backedOff >= 20) { + $this->caution( + round($backedOff / $total * 100).'% of known hosts are in delivery backoff', + null, + 'That many dead hosts at once usually means the problem is on this side (DNS, egress firewall, TLS, clock, signatures). Check the env and signing groups.' + ); + } + + $this->compareBannedCache(); + + $worst = Instance::where('delivery_failures', '>', 0) + ->orderByDesc('delivery_failures') + ->limit(15) + ->get(['domain', 'software', 'delivery_failures', 'delivery_timeout', 'delivery_next_after']); + + if ($worst->isNotEmpty()) { + $profiles = Profile::whereIn('domain', $worst->pluck('domain')) + ->selectRaw('domain, count(*) as total') + ->groupBy('domain') + ->pluck('total', 'domain'); + + $this->renderTable( + 'failing_hosts', + ['Host', 'Software', 'Failures', 'Skipped until', 'Known profiles'], + $worst->map(fn (Instance $instance): array => [ + $instance->domain, + $instance->software ?? '-', + $instance->delivery_failures, + $instance->delivery_timeout && $instance->delivery_next_after?->isFuture() + ? $instance->delivery_next_after->toDateTimeString().' ('.$instance->delivery_next_after->diffForHumans().')' + : '-', + $profiles[$instance->domain] ?? 0, + ])->all() + ); + + $this->note('Clear a host manually', 'App\Services\DeliveryHostService::reset("example.com") in tinker'); + } + + $this->checkAudience(); + } + + protected function compareBannedCache(): void + { + $cached = Cache::get(InstanceService::CACHE_KEY_BANNED_DOMAINS); + + if (! is_array($cached)) { + $this->note('Banned domain cache', 'not cached yet'); + + return; + } + + $live = Instance::whereBanned(true)->pluck('domain')->all(); + $stillBlocked = array_values(array_diff($cached, $live)); + $notYetBlocked = array_values(array_diff($live, $cached)); + + if ($stillBlocked === [] && $notYetBlocked === []) { + $this->pass('Banned domain cache matches the database', count($live).' domains'); + + return; + } + + $this->caution( + 'Banned domain cache is stale', + count($stillBlocked).' unbanned host(s) still blocked, '.count($notYetBlocked).' banned host(s) not blocked yet' + .($stillBlocked !== [] ? ' (e.g. '.$stillBlocked[0].')' : ''), + 'The list is cached for 14 days: php artisan cache:forget "'.InstanceService::CACHE_KEY_BANNED_DOMAINS.'"' + ); + } + + /** + * Compare the 5 day audience cache used for fanout with the followers + * table, for the test user only. + */ + protected function checkAudience(): void + { + $profile = $this->testProfile(); + + if (! $profile) { + $this->skipped('Delivery audience', 'no usable local profile, pass --user='); + + return; + } + + $live = DB::table('followers') + ->join('profiles', 'followers.profile_id', '=', 'profiles.id') + ->where('followers.following_id', $profile->id) + ->whereNotNull('profiles.inbox_url') + ->whereNull('profiles.deleted_at') + ->distinct() + ->get(['profiles.sharedInbox', 'profiles.inbox_url']) + ->map(fn ($row): ?string => $row->sharedInbox ?? $row->inbox_url) + ->filter() + ->unique() + ->values(); + + $hosts = $live->map(fn (string $inbox): ?string => DeliveryHostService::domain($inbox))->filter()->unique(); + $skipped = $hosts->filter(fn (string $host): bool => DeliveryHostService::isUnavailable($host))->count(); + + $this->note( + 'Audience of @'.$profile->username, + $live->count().' inboxes on '.$hosts->count().' hosts, '.$skipped.' host(s) currently skipped' + ); + + $cached = Cache::get('pf:services:follower:audience:'.$profile->id); + + if ($cached === null) { + return; + } + + $banned = Instance::whereBanned(true)->pluck('domain')->all(); + + $missing = $live + ->reject(fn (string $inbox): bool => in_array(DeliveryHostService::domain($inbox), $banned, true)) + ->diff(collect($cached)) + ->values(); + + if ($missing->isEmpty()) { + $this->pass('Cached audience covers every follower inbox'); + + return; + } + + $this->caution( + 'Cached audience is missing '.$missing->count().' inbox(es)', + 'e.g. '.$missing->first(), + 'Followers on those inboxes do not get new posts until the cache expires: php artisan cache:forget "pf:services:follower:audience:'.$profile->id.'"' + ); + } + + protected function checkEnvironment(): void + { + $this->note('PHP', PHP_VERSION.' ('.PHP_SAPI.')'); + + foreach (['curl', 'openssl', 'json', 'mbstring'] as $extension) { + if (! extension_loaded($extension)) { + $this->problem('PHP extension "'.$extension.'" is not loaded'); + } + } + + if (! function_exists('idn_to_ascii')) { + $this->caution('PHP intl extension is not loaded', 'internationalized (IDN) hosts are rejected by Helpers::normalizeHost()'); + } + + $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); + + if (! function_exists('dns_get_record') || in_array('dns_get_record', $disabled, true)) { + $this->problem( + 'dns_get_record() is unavailable', + 'Helpers::validateUrl() rejects every remote URL without it', + 'Remove it from disable_functions in the CLI and FPM php.ini.' + ); + + return; + } + + $this->pass('Required PHP extensions and dns_get_record() are available'); + + $this->checkOwnDomainResolution(); + + if ($this->skipHttp) { + $this->skipped('Outbound HTTPS and clock skew', '--skip-http'); + + return; + } + + $this->checkClock(self::CLOCK_REFERENCE); + } + + /** + * The inbox handlers run local object URLs (follow targets, inReplyTo, + * undo objects) through Helpers::validateLocalUrl(), which requires this + * instance's own domain to resolve to public IPs only. + */ + protected function checkOwnDomainResolution(): void + { + $domain = (string) config('pixelfed.domain.app'); + + if ($domain === '') { + return; + } + + $ips = Helpers::resolvePublicIps($domain); + + if ($ips !== []) { + $this->pass('Own domain resolves to public IPs', $domain.' -> '.implode(', ', $ips)); + } else { + $raw = collect(@dns_get_record($domain.'.', DNS_A | DNS_AAAA) ?: []) + ->map(fn (array $record): ?string => $record['ip'] ?? $record['ipv6'] ?? null) + ->filter() + ->implode(', '); + + $this->problem( + 'Own domain does not pass the public IP check from this server', + $domain.' -> '.($raw !== '' ? $raw : 'no A/AAAA records'), + 'Helpers::validateLocalUrl() fails here, so inbound Follows, replies (inReplyTo) and Undos that reference local URLs are dropped. One private or reserved record (split-horizon DNS, internal resolver, ULA AAAA) is enough. Negative results are cached for 5 minutes.' + ); + + return; + } + + $profile = $this->testProfile(); + + if (! $profile) { + return; + } + + if (Helpers::validateLocalUrl($profile->permalink())) { + $this->pass('Helpers::validateLocalUrl() accepts a local actor URL'); + + return; + } + + $this->problem( + 'Helpers::validateLocalUrl() rejects '.$profile->permalink(), + null, + 'Inbound activities that reference local URLs are dropped. Check APP_URL, APP_DOMAIN and the instance ban list.' + ); + } + + protected function checkClock(string $url): void + { + try { + $before = microtime(true); + + $response = Http::withHeaders(['User-Agent' => $this->userAgent()]) + ->timeout(10) + ->connectTimeout(5) + ->head($url); + + $elapsed = microtime(true) - $before; + } catch (Throwable $e) { + $this->problem( + 'Outbound HTTPS to '.$url.' failed', + class_basename($e).': '.Str::limit($e->getMessage(), 140), + 'If every outbound request fails, look at the egress firewall, DNS and the CA bundle (curl.cainfo).' + ); + + return; + } + + $this->pass('Outbound HTTPS works', $url.' answered '.$response->status().' in '.round($elapsed * 1000).'ms'); + + $date = (string) $response->header('Date'); + + if ($date === '') { + $this->skipped('Clock skew', 'no Date header from '.$url); + + return; + } + + try { + $remote = Carbon::parse($date)->getTimestamp(); + } catch (Throwable) { + $this->skipped('Clock skew', 'unparseable Date header: '.$date); + + return; + } + + $skew = (int) round(abs(($before + $elapsed / 2) - $remote)); + + if ($skew >= self::CLOCK_SKEW_FAIL) { + $this->problem( + 'Server clock is off by '.$skew.'s', + 'compared with the Date header of '.$url, + 'Signed requests carry a Date header and remote servers reject stale ones. Fix NTP (timedatectl set-ntp true).' + ); + } elseif ($skew >= self::CLOCK_SKEW_WARN) { + $this->caution('Server clock is off by '.$skew.'s', 'compared with the Date header of '.$url, 'Check NTP.'); + } else { + $this->pass('Server clock is in sync', 'skew '.$skew.'s against '.parse_url($url, PHP_URL_HOST)); + } + } + + protected function checkRemote(string $input): void + { + if ($this->skipHttp) { + $this->skipped('Remote probe', '--skip-http'); + + return; + } + + $target = $this->parseRemoteTarget($input); + + if (! $target) { + $this->problem('Could not parse "'.$input.'"', null, 'Use example.com, @user@example.com or an https actor URL.'); + + return; + } + + $host = $target['host']; + + if (Helpers::isLocalDomain($host)) { + $this->problem($host.' is this instance', null, 'The remote probe needs another server.'); + + return; + } + + $this->note('Target', $host.($target['user'] ? ' (account '.$target['user'].')' : '').($target['actor'] ? ' (actor '.$target['actor'].')' : '')); + + if (! $this->checkRemoteHost($host)) { + return; + } + + $this->checkRemoteNodeinfo($host); + + $actorUrl = $target['actor']; + + if (! $actorUrl) { + $account = $target['user'] ?? $host; + $actorUrl = $this->remoteWebfinger($host, $account, $target['user'] !== null); + } + + if (! $actorUrl) { + if (! $target['user']) { + $this->skipped('Signed actor fetch', 'no instance actor advertised over WebFinger, pass --remote=@user@'.$host.' to test one'); + } + + return; + } + + $this->checkRemoteActor($actorUrl); + } + + protected function checkRemoteHost(string $host): bool + { + $instance = Instance::whereDomain($host)->first(); + + if (! $instance) { + $this->note('Instance row', 'none yet, this server has never been seen'); + } else { + $this->note('Instance row', sprintf( + 'id=%s software=%s banned=%s unlisted=%s delivery_failures=%s', + $instance->id, + $instance->software ?? 'null', + $this->bool((bool) $instance->banned), + $this->bool((bool) $instance->unlisted), + $instance->delivery_failures ?? 0 + )); + + if ($instance->banned) { + $this->caution( + $host.' is banned on this instance', + 'fetches and deliveries are refused by design', + app()->environment('production') ? null : 'Bans are only enforced in production, so the probe below still runs.' + ); + } + } + + if (DeliveryHostService::isUnavailable($host)) { + $this->caution( + 'Deliveries to '.$host.' are currently skipped', + 'in backoff until '.($instance?->delivery_next_after?->toDateTimeString() ?? 'unknown'), + 'Clear it with App\Services\DeliveryHostService::reset("'.$host.'") once the host is healthy.' + ); + } + + $ips = Helpers::resolvePublicIps($host); + + if ($ips === []) { + $this->problem( + $host.' does not pass the public IP check', + 'no records, or at least one private/reserved record', + 'Helpers::validateUrl() rejects every URL on this host, so nothing is fetched or delivered. Negative results are cached for 5 minutes.' + ); + + return false; + } + + $this->pass('DNS', $host.' -> '.implode(', ', $ips)); + + if (! Helpers::validateUrl('https://'.$host.'/')) { + $this->problem( + 'Helpers::validateUrl() rejects https://'.$host.'/', + $instance?->banned ? 'the host is banned' : null + ); + + return false; + } + + $this->pass('Helpers::validateUrl() accepts the host'); + + return true; + } + + protected function checkRemoteNodeinfo(string $host): void + { + $discovery = $this->remoteGet('https://'.$host.'/.well-known/nodeinfo', false, 'application/json'); + + if ($discovery['error'] || $discovery['status'] !== 200) { + $this->caution( + 'NodeInfo discovery', + $discovery['error'] ?? 'HTTP '.$discovery['status'], + $discovery['error'] ? 'If this is a connection error the server is down or unreachable from here.' : null + ); + + return; + } + + $href = collect($discovery['json']['links'] ?? [])->pluck('href')->filter()->last(); + $doc = is_string($href) ? $this->remoteGet($href, false, 'application/json') : null; + + if (! $doc || $doc['error'] || $doc['status'] !== 200 || ! is_array($doc['json'])) { + $this->caution('NodeInfo document', $doc['error'] ?? ($doc ? 'HTTP '.$doc['status'] : 'no link in discovery document')); + + return; + } + + $this->pass('Reachable over HTTPS', sprintf( + '%s %s', + $doc['json']['software']['name'] ?? 'unknown software', + $doc['json']['software']['version'] ?? '' + )); + } + + protected function remoteWebfinger(string $host, string $account, bool $required): ?string + { + $acct = 'acct:'.$account.'@'.$host; + + $res = $this->remoteGet( + 'https://'.$host.'/.well-known/webfinger?resource='.rawurlencode($acct), + false, + 'application/jrd+json, application/json' + ); + + $self = collect($res['json']['links'] ?? []) + ->first(fn ($link): bool => is_array($link) + && ($link['rel'] ?? null) === 'self' + && $this->isActivityPubContentType($link['type'] ?? null)); + + $href = is_array($self) ? ($self['href'] ?? null) : null; + + if (is_string($href) && $href !== '') { + $this->pass('WebFinger '.$acct, $href); + + return $href; + } + + if ($required) { + $this->problem( + 'WebFinger '.$acct, + $res['error'] ?? 'HTTP '.$res['status'].', no rel=self ActivityPub link', + $this->statusHint($res, false) + ); + } + + return null; + } + + protected function checkRemoteActor(string $actorUrl): void + { + $signed = $this->remoteGet($actorUrl, true); + $unsigned = $this->remoteGet($actorUrl, false); + + $signedOk = ! $signed['error'] && $signed['status'] === 200; + $unsignedOk = ! $unsigned['error'] && $unsigned['status'] === 200; + $describe = fn (array $res): string => $res['error'] ?? 'HTTP '.$res['status']; + + if ($signedOk) { + $this->pass( + 'Signed actor fetch', + $unsignedOk + ? 'HTTP 200 (unsigned also 200)' + : 'HTTP 200 (unsigned: '.$describe($unsigned).', the remote enforces authorized fetch)' + ); + } elseif ($unsignedOk) { + $this->problem( + 'Signed actor fetch failed but the unsigned one works', + 'signed: '.$describe($signed).', unsigned: HTTP 200', + 'The remote is rejecting this instance\'s signature specifically. Check the actor and signing groups, the clock, and that the remote can fetch '.url('/i/actor').' (run the endpoints group).' + ); + } else { + $this->problem( + 'Actor fetch failed signed and unsigned', + 'signed: '.$describe($signed).', unsigned: '.$describe($unsigned), + in_array($signed['status'], [401, 403], true) + ? 'Refused either way: this instance may be blocked (defederated) there, or a WAF is rejecting the PixelFedBot user agent.' + : $this->statusHint($signed, false) + ); + } + + $res = $signedOk ? $signed : ($unsignedOk ? $unsigned : null); + + if (! $res) { + return; + } + + if (! $this->isActivityPubContentType($res['content_type'])) { + $this->problem( + 'Remote actor Content-Type is '.($res['content_type'] ?: 'missing'), + null, + 'ActivityPubFetchService only accepts application/activity+json or application/ld+json with the ActivityStreams profile, so this actor cannot be imported.' + ); + } + + $doc = $res['json']; + + if (! is_array($doc) || ! isset($doc['id'], $doc['inbox'], $doc['publicKey']['publicKeyPem'])) { + $this->problem('Remote actor document is missing id, inbox or publicKey'); + + return; + } + + $this->note('Remote actor', sprintf( + 'type=%s inbox=%s sharedInbox=%s', + is_string($doc['type'] ?? null) ? $doc['type'] : json_encode($doc['type'] ?? null), + $doc['inbox'], + $doc['endpoints']['sharedInbox'] ?? 'none' + )); + + if (parse_url((string) ($doc['publicKey']['id'] ?? ''), PHP_URL_HOST) !== parse_url((string) $doc['id'], PHP_URL_HOST)) { + $this->problem('publicKey.id is on a different host than the actor id', (string) ($doc['publicKey']['id'] ?? 'missing'), 'Helpers::isValidProfileData() rejects this actor.'); + } + + $this->compareLocalCopy($doc); + } + + /** + * @param array $doc + */ + protected function compareLocalCopy(array $doc): void + { + $local = Profile::withTrashed()->where('remote_url', $doc['id'])->first(); + + if (! $local) { + $this->note('Local copy', 'none, this actor has not been imported yet'); + + return; + } + + $issues = []; + + if ($local->deleted_at) { + $issues[] = 'soft-deleted locally'; + } + + if ($local->status !== null) { + $issues[] = 'status='.$local->status; + } + + if (! $this->samePem($local->public_key, $doc['publicKey']['publicKeyPem'])) { + $issues[] = 'stored public_key differs from the live key (key rotated, so their signatures fail verification here)'; + } + + if ($local->key_id !== ($doc['publicKey']['id'] ?? null)) { + $issues[] = 'stored key_id is '.($local->key_id ?? 'null'); + } + + if ($local->inbox_url !== $doc['inbox']) { + $issues[] = 'stored inbox_url is '.($local->inbox_url ?? 'null'); + } + + if (($local->getAttributes()['sharedInbox'] ?? null) !== ($doc['endpoints']['sharedInbox'] ?? null)) { + $issues[] = 'stored sharedInbox is '.($local->getAttributes()['sharedInbox'] ?? 'null'); + } + + $fetched = $local->last_fetched_at ? Carbon::parse($local->last_fetched_at)->diffForHumans() : 'never'; + + if ($issues !== []) { + $this->problem( + 'Local copy (profile '.$local->id.') is out of date', + implode('; ', $issues).'; last fetched '.$fetched, + 'php artisan status:profile '.$local->id.' for the full row. It refreshes on the next fetch once Helpers::needsFetch() is true.' + ); + + return; + } + + $this->pass('Local copy (profile '.$local->id.') matches the live actor', 'last fetched '.$fetched); + } + + /** + * @return array{host: string, user: ?string, actor: ?string}|null + */ + protected function parseRemoteTarget(string $input): ?array + { + $input = trim($input); + $user = null; + $actor = null; + + if (Str::startsWith($input, ['https://', 'http://'])) { + $host = parse_url($input, PHP_URL_HOST); + $path = trim((string) parse_url($input, PHP_URL_PATH), '/'); + $actor = $path !== '' ? $input : null; + } else { + $input = ltrim($input, '@'); + + if (str_contains($input, '@')) { + [$user, $host] = explode('@', $input, 2); + $user = $user !== '' ? $user : null; + } else { + $host = explode('/', $input)[0]; + } + } + + $host = Helpers::normalizeHost(is_string($host) ? $host : null); + + if (! $host) { + return null; + } + + return ['host' => $host, 'user' => $user, 'actor' => $actor]; + } + + /** + * GET one of this instance's own public URLs the way a remote server + * would, without following redirects. + * + * @return array{error: ?string, status: ?int, content_type: string, body: string, json: mixed, response: ?Response} + */ + protected function localGet(string $path, string $accept): array + { + $url = rtrim((string) config('app.url'), '/').'/'.ltrim($path, '/'); + + try { + $response = Http::withHeaders([ + 'Accept' => $accept, + 'User-Agent' => $this->userAgent(), + ]) + ->withOptions(['allow_redirects' => false]) + ->timeout(10) + ->connectTimeout(5) + ->get($url); + } catch (Throwable $e) { + return $this->httpResult(null, class_basename($e).': '.Str::limit($e->getMessage(), 140)); + } + + return $this->httpResult($response); + } + + /** + * Read-only GET against a remote server, mirroring ActivityPubFetchService: + * every hop is validated, DNS is pinned to the validated public IPs and, + * when $signed, the request is signed by the instance actor. + * + * @return array{error: ?string, status: ?int, content_type: string, body: string, json: mixed, response: ?Response} + */ + protected function remoteGet(string $url, bool $signed, string $accept = 'application/activity+json'): array + { + $current = $url; + + for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) { + $valid = Helpers::validateUrl($current); + + if (! is_string($valid)) { + return $this->httpResult(null, 'rejected by Helpers::validateUrl(): '.$current); + } + + $host = (string) parse_url($valid, PHP_URL_HOST); + $port = parse_url($valid, PHP_URL_PORT) ?: 443; + $ips = Helpers::resolvePublicIps($host); + + if ($ips === []) { + return $this->httpResult(null, 'no public IPs for '.$host); + } + + try { + $headers = $signed + ? HttpSignature::instanceActorSign($valid, false, ['Accept' => $accept], 'get') + : []; + + $headers['Accept'] = $accept; + $headers['User-Agent'] = $this->userAgent(); + + $pinned = implode(',', array_map( + fn (string $ip): string => str_contains($ip, ':') ? '['.$ip.']' : $ip, + $ips + )); + + $response = Http::withOptions([ + 'allow_redirects' => false, + 'curl' => [ + CURLOPT_RESOLVE => [$host.':'.$port.':'.$pinned], + ], + ]) + ->withHeaders($headers) + ->timeout(15) + ->connectTimeout(5) + ->get($valid); + } catch (Throwable $e) { + return $this->httpResult(null, class_basename($e).': '.Str::limit($e->getMessage(), 140)); + } + + if (! in_array($response->status(), [301, 302, 303, 307, 308], true)) { + return $this->httpResult($response); + } + + $location = trim((string) $response->header('Location')); + + if ($location === '') { + return $this->httpResult($response); + } + + try { + $current = (string) BaseUri::from($valid)->resolve($location); + } catch (Throwable) { + return $this->httpResult(null, 'unresolvable redirect to '.$location); + } + } + + return $this->httpResult(null, 'more than '.self::MAX_REDIRECTS.' redirects'); + } + + /** + * @return array{error: ?string, status: ?int, content_type: string, body: string, json: mixed, response: ?Response} + */ + protected function httpResult(?Response $response, ?string $error = null): array + { + $body = $response ? substr($response->body(), 0, 2 * 1024 * 1024) : ''; + + return [ + 'error' => $error, + 'status' => $response?->status(), + 'content_type' => strtolower((string) $response?->header('Content-Type')), + 'body' => $body, + 'json' => $body !== '' ? json_decode($body, true) : null, + 'response' => $response, + ]; + } + + /** + * @param array $res + */ + protected function expectStatus(array $res, string $label, int $expected): bool + { + if ($res['error']) { + $this->problem($label, $res['error']); + + return false; + } + + if ($res['status'] === $expected) { + return true; + } + + $this->problem($label, 'HTTP '.$res['status'].', expected '.$expected, $this->statusHint($res)); + + return false; + } + + /** + * @param array $res + */ + protected function statusHint(array $res, bool $local = true): ?string + { + $response = $res['response'] ?? null; + + if (! $response instanceof Response) { + return null; + } + + $status = $response->status(); + + if ((string) $response->header('cf-mitigated') !== '') { + return $local + ? 'Cloudflare is challenging this request (cf-mitigated: '.$response->header('cf-mitigated').'). Add a WAF skip rule for /.well-known/*, /users/*, /i/actor, /f/inbox and /api/nodeinfo/*. Bot fight mode breaks federation.' + : 'Cloudflare on the remote side is challenging requests from this server.'; + } + + if (in_array($status, [301, 302, 303, 307, 308], true)) { + return $local + ? 'Redirects to '.$response->header('Location').'. Remote servers do not reliably follow redirects here, APP_URL should be the canonical origin.' + : 'Redirects to '.$response->header('Location').'.'; + } + + if (str_contains($res['content_type'], 'text/html')) { + return $local + ? 'Got HTML back: a WAF or bot challenge, a maintenance page, or the web server is not passing this path to Laravel.' + : 'Got HTML back: the remote is not doing content negotiation for this URL, or a WAF challenged the request.'; + } + + if (! $local) { + return match (true) { + $status === 404, $status === 410 => 'The remote says this resource does not exist (deleted, moved or suspended).', + $status === 429 => 'This server is being rate limited by the remote.', + $status >= 500 => 'The remote server is erroring, nothing to fix on this side.', + default => null, + }; + } + + return match (true) { + $status === 401, $status === 403 => 'Blocked before or inside the app: check WAF rules, basic auth and IP allow lists.', + $status === 404 => 'The route is not reaching Laravel, or the feature is disabled in config.', + $status === 429 => 'Rate limited by the proxy or the app.', + $status >= 500 => 'Server error: check storage/logs/laravel.log and the web server error log.', + default => null, + }; + } + + protected function userAgent(): string + { + return 'PixelFedBot/1.0.0 (Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')'; + } + + /** + * @return array{error: ?string, bits: int} + */ + protected function inspectKeyPair(?string $privatePem, ?string $publicPem): array + { + if (empty($privatePem)) { + return ['error' => 'private key is empty', 'bits' => 0]; + } + + if (empty($publicPem)) { + return ['error' => 'public key is empty', 'bits' => 0]; + } + + $private = openssl_pkey_get_private($privatePem); + + if (! $private) { + return ['error' => 'private key does not parse', 'bits' => 0]; + } + + if (! openssl_pkey_get_public($publicPem)) { + return ['error' => 'public key does not parse', 'bits' => 0]; + } + + $details = openssl_pkey_get_details($private) ?: []; + + if (($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA) { + return ['error' => 'private key is not RSA', 'bits' => 0]; + } + + if (! $this->samePem($details['key'] ?? null, $publicPem)) { + return ['error' => 'public key does not belong to the private key', 'bits' => (int) $details['bits']]; + } + + return ['error' => null, 'bits' => (int) $details['bits']]; + } + + protected function samePem(mixed $a, mixed $b): bool + { + if (! is_string($a) || ! is_string($b) || $a === '' || $b === '') { + return false; + } + + return preg_replace('/\s+/', '', $a) === preg_replace('/\s+/', '', $b); + } + + /** + * Parse a draft-cavage Signature header. HttpSignature::parseSignatureHeader() + * is avoided on purpose: it runs keyId through Helpers::validateUrl(), which + * would make the local round trip depend on DNS. + * + * @return array + */ + protected function parseSignature(string $header): array + { + preg_match_all( + '/(?:^|,)\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"/', + $header, + $matches, + PREG_SET_ORDER + ); + + $parsed = []; + + foreach ($matches as $match) { + $parsed[$match[1]] = $match[2]; + } + + return $parsed; + } + + /** + * @param array $curlHeaders + * @return array + */ + protected function parseCurlHeaders(array $curlHeaders): array + { + $headers = []; + + foreach ($curlHeaders as $header) { + $position = is_string($header) ? strpos($header, ':') : false; + + if ($position === false) { + continue; + } + + $headers[trim(substr($header, 0, $position))] = trim(substr($header, $position + 1)); + } + + return $headers; + } + + protected function jobClassFromPayloadHead(string $head): ?string + { + if (! preg_match('/"displayName":"((?:[^"\\\\]|\\\\.)+)"/', $head, $match)) { + return null; + } + + $decoded = json_decode('"'.$match[1].'"'); + + return is_string($decoded) ? $decoded : null; + } + + protected function isActivityPubContentType(mixed $type): bool + { + if (! is_string($type)) { + return false; + } + + $type = strtolower($type); + + if (str_starts_with($type, 'application/activity+json')) { + return true; + } + + return str_starts_with($type, 'application/ld+json') + && str_contains($type, 'https://www.w3.org/ns/activitystreams'); + } + + protected function testProfile(): ?Profile + { + if ($this->testProfileResolved) { + return $this->testProfile; + } + + $username = ltrim(trim((string) $this->option('user')), '@'); + + if ($username !== '') { + $profile = Profile::whereNull('domain')->whereUsername($username)->first(); + + if (! $profile) { + $this->caution('Local user "'.$username.'" not found', 'user dependent checks are skipped'); + } + } else { + $admin = User::whereIsAdmin(true)->whereNull('status')->orderBy('id')->first(); + + $profile = $admin?->profile + ?? Profile::whereNull('domain') + ->whereNull('status') + ->whereNotNull('private_key') + ->orderBy('id') + ->first(); + } + + $this->testProfileResolved = true; + + return $this->testProfile = $profile; + } + + protected function truthy(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + protected function bool(bool $value): string + { + return $value ? 'true' : 'false'; + } + + protected function pass(string $label, ?string $detail = null): void + { + $this->record('ok', $label, $detail); + } + + protected function caution(string $label, ?string $detail = null, ?string $hint = null): void + { + $this->record('warn', $label, $detail, $hint); + } + + protected function problem(string $label, ?string $detail = null, ?string $hint = null): void + { + $this->record('fail', $label, $detail, $hint); + } + + protected function note(string $label, ?string $detail = null): void + { + $this->record('info', $label, $detail); + } + + protected function skipped(string $label, ?string $detail = null): void + { + $this->record('skip', $label, $detail); + } + + protected function record(string $status, string $label, ?string $detail = null, ?string $hint = null): void + { + $this->results[] = [ + 'group' => $this->currentGroup, + 'status' => $status, + 'label' => $label, + 'detail' => $detail, + 'hint' => $hint, + ]; + + if ($this->asJson) { + return; + } + + $this->line( + ' '.$this->icon($status).' '.OutputFormatter::escape($label) + .($detail !== null ? ' '.OutputFormatter::escape($detail).'' : '') + ); + + if ($hint !== null) { + $this->line(' ↳ '.OutputFormatter::escape($hint).''); + } + } + + protected function icon(string $status): string + { + return match ($status) { + 'ok' => '✓', + 'warn' => '!', + 'fail' => '✗', + 'skip' => '-', + default => '·', + }; + } + + /** + * @param array $headers + * @param array> $rows + */ + protected function renderTable(string $name, array $headers, array $rows): void + { + $this->data[$this->currentGroup][$name] = array_map( + fn (array $row): array => array_combine($headers, $row), + $rows + ); + + if ($this->asJson || $rows === []) { + return; + } + + $this->newLine(); + $this->table($headers, $rows); + } + + protected function section(string $title): void + { + if ($this->asJson) { + return; + } + + $this->line(str_repeat('=', 64)); + $this->info($title); + $this->line(str_repeat('=', 64)); + } + + protected function blank(): void + { + if (! $this->asJson) { + $this->newLine(); + } + } + + protected function finish(): int + { + $counts = array_count_values(array_column($this->results, 'status')) + [ + 'ok' => 0, + 'warn' => 0, + 'fail' => 0, + 'info' => 0, + 'skip' => 0, + ]; + + if ($this->asJson) { + $this->line(json_encode([ + 'domain' => config('pixelfed.domain.app'), + 'version' => config('pixelfed.version'), + 'generated_at' => now()->toIso8601String(), + 'healthy' => $counts['fail'] === 0, + 'summary' => $counts, + 'results' => $this->results, + 'data' => $this->data, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return $counts['fail'] === 0 ? self::SUCCESS : self::FAILURE; + } + + $this->section('SUMMARY'); + $this->line(sprintf( + ' %d ok, %d warning(s), %d failure(s), %d skipped', + $counts['ok'], + $counts['warn'], + $counts['fail'], + $counts['skip'] + )); + + foreach (['fail' => 'FAILURES', 'warn' => 'WARNINGS'] as $status => $heading) { + $matching = array_filter($this->results, fn (array $result): bool => $result['status'] === $status); + + if ($matching === []) { + continue; + } + + $this->newLine(); + + if ($status === 'fail') { + $this->error($heading.':'); + } else { + $this->warn($heading.':'); + } + + foreach ($matching as $result) { + $this->line(' '.$this->icon($status).' ['.$result['group'].'] '.OutputFormatter::escape($result['label'])); + } + } + + if ($counts['fail'] === 0 && $counts['warn'] === 0) { + $this->newLine(); + $this->info('No federation issues detected.'); + } + + return $counts['fail'] === 0 ? self::SUCCESS : self::FAILURE; + } +}