mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2443 lines
83 KiB
PHP
2443 lines
83 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands\Admin;
|
|
|
|
use App\Models\ConfigCache;
|
|
use App\Models\Instance;
|
|
use App\Models\InstanceActor;
|
|
use App\Models\Profile;
|
|
use App\Models\User;
|
|
use App\Services\ConfigCacheService;
|
|
use App\Services\DeliveryHostService;
|
|
use App\Services\InstanceService;
|
|
use App\Util\ActivityPub\Helpers;
|
|
use App\Util\ActivityPub\HttpSignature;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use League\Uri\BaseUri;
|
|
use Symfony\Component\Console\Formatter\OutputFormatter;
|
|
use Throwable;
|
|
|
|
class AppFederation extends Command
|
|
{
|
|
/**
|
|
* The name and signature of the console command.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $signature = 'app:federation
|
|
{--remote= : Probe a remote server or account with signed GET requests (example.com, @user@example.com or an actor URL)}
|
|
{--user= : Local username used for the actor, WebFinger, signing and audience checks (defaults to the first active admin)}
|
|
{--only= : Comma separated groups to run: config,actor,endpoints,signing,queues,delivery,env,remote}
|
|
{--skip-http : Skip every check that makes an HTTP request}
|
|
{--json : Output the results as JSON}';
|
|
|
|
/**
|
|
* The console command description.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $description = 'Diagnose ActivityPub federation: config, instance actor, endpoints, signatures, queues, delivery health and an optional remote probe';
|
|
|
|
private const array LOCAL_GROUPS = [
|
|
'config',
|
|
'actor',
|
|
'endpoints',
|
|
'signing',
|
|
'queues',
|
|
'delivery',
|
|
'env',
|
|
];
|
|
|
|
private const array TITLES = [
|
|
'config' => '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<int, array{group: string, status: string, label: string, detail: ?string, hint: ?string}> */
|
|
protected array $results = [];
|
|
|
|
/** @var array<string, array<string, mixed>> */
|
|
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<int, string>|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<string, mixed> $res
|
|
* @return array<int, string>
|
|
*/
|
|
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<string, mixed> $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 <username>.'
|
|
);
|
|
}
|
|
|
|
$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<int, string>|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<int|string, array<string, mixed>> $supervisors
|
|
* @return array<int, string>
|
|
*/
|
|
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<string, int>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, string>
|
|
*/
|
|
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<int, string> $curlHeaders
|
|
* @return array<string, string>
|
|
*/
|
|
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 ? ' <fg=gray>'.OutputFormatter::escape($detail).'</>' : '')
|
|
);
|
|
|
|
if ($hint !== null) {
|
|
$this->line(' <fg=gray>↳ '.OutputFormatter::escape($hint).'</>');
|
|
}
|
|
}
|
|
|
|
protected function icon(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'ok' => '<fg=green>✓</>',
|
|
'warn' => '<fg=yellow>!</>',
|
|
'fail' => '<fg=red>✗</>',
|
|
'skip' => '<fg=gray>-</>',
|
|
default => '<fg=gray>·</>',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $headers
|
|
* @param array<int, array<int, mixed>> $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;
|
|
}
|
|
}
|