diff --git a/.gitignore b/.gitignore index f11338e07..f2a70cedf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /vendor /build /storage/database.sqlite +/storage/database.sqlite.bak* /storage/database-test.sqlite /storage/database.sqlite-journal /storage/database-test.sqlite-journal diff --git a/.prettierignore b/.prettierignore index 47061dda0..db8957b31 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,6 +13,7 @@ sail !*.blade.php !*.sh resources/views/ssh/ +resources/views/wireguard/ resources/views/scribe/ resources/js/ziggy.js resources/views/mail/* diff --git a/app/Actions/FirewallRule/ManageRule.php b/app/Actions/FirewallRule/ManageRule.php index bf6f541ef..b9d9524d8 100755 --- a/app/Actions/FirewallRule/ManageRule.php +++ b/app/Actions/FirewallRule/ManageRule.php @@ -6,6 +6,7 @@ use App\Jobs\FirewallRule\ApplyRulesJob; use App\Models\FirewallRule; use App\Models\Server; +use App\Support\Cidr; use App\ValidationRules\PortOrPortRangeRule; use Illuminate\Support\Facades\Validator; @@ -55,6 +56,9 @@ public function delete(FirewallRule $rule): void private function validate(array $input): void { + $source = $input['source'] ?? null; + $maxMask = is_string($source) && Cidr::isValidAddress($source) ? Cidr::bits($source) : 32; + $rules = [ 'name' => [ 'required', @@ -81,13 +85,13 @@ private function validate(array $input): void 'nullable', 'numeric', 'min:1', - 'max:32', + 'max:'.$maxMask, ], ]; if (isset($input['source_any']) && $input['source_any'] === false) { $rules['source'] = ['required', 'ip']; - $rules['mask'] = ['required', 'numeric', 'min:1', 'max:32']; + $rules['mask'] = ['required', 'numeric', 'min:1', 'max:'.$maxMask]; } Validator::make($input, $rules)->validate(); diff --git a/app/Actions/Network/AddServersToNetwork.php b/app/Actions/Network/AddServersToNetwork.php new file mode 100644 index 000000000..5f3109796 --- /dev/null +++ b/app/Actions/Network/AddServersToNetwork.php @@ -0,0 +1,188 @@ + $input + * @return ?int the port the network moved to, when an incoming server forced it off its own + */ + public function add(Network $network, array $input): ?int + { + if ($network->type === NetworkType::PROVIDER) { + throw ValidationException::withMessages([ + 'servers' => __('Members of a provider-managed network are synced from the provider.'), + ]); + } + + $this->validate($network, $input); + + $portBefore = $network->port; + + $newMemberIds = DB::transaction(function () use ($network, $input): array { + return $network->type === NetworkType::WIREGUARD + ? $this->addWireGuard($network, $input) + : $this->addCustom($network, $input); + }); + + if ($network->type === NetworkType::WIREGUARD) { + $network->load('servers.server'); + foreach ($network->servers as $member) { + if (in_array($member->id, $newMemberIds, true) + || in_array($member->status, [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING], true)) { + $this->sync->toPresent($member); + } + } + } else { + $this->firewall->handle($network); + } + + $this->recompute->handle($network); + + return $network->port !== $portBefore ? $network->port : null; + } + + /** + * @param array $input + * @return array + */ + private function addWireGuard(Network $network, array $input): array + { + Project::query()->whereKey($network->project_id)->lockForUpdate()->first(); + Network::query()->whereKey($network->id)->lockForUpdate()->first(); + + $this->resolvePortConflict($network, $input['servers']); + + $used = $network->servers()->lockForUpdate()->pluck('ip') + ->concat($network->peers()->lockForUpdate()->pluck('ip')) + ->filter() + ->values() + ->all(); + + $servers = Server::query() + ->where('project_id', $network->project_id) + ->whereIn('id', $input['servers']) + ->get(); + + return $this->members->create($network, $servers, $used); + } + + /** + * @param array $input + * @return array + */ + private function addCustom(Network $network, array $input): array + { + $ids = []; + foreach ($input['servers'] as $serverId) { + $member = $network->servers()->create([ + 'server_id' => $serverId, + 'server_ip_address_id' => $input['ip_addresses'][$serverId], + 'status' => NetworkServerStatus::ACTIVE, + ]); + $ids[] = $member->id; + } + + return $ids; + } + + /** + * @param array $input + */ + private function validate(Network $network, array $input): void + { + $rules = [ + 'servers' => ['required', 'array', 'min:1'], + 'servers.*' => [ + 'integer', + 'distinct', + Rule::exists('servers', 'id')->where('project_id', $network->project_id), + Rule::unique('network_servers', 'server_id')->where('network_id', $network->id), + ], + ]; + + if ($network->type === NetworkType::CUSTOM) { + $rules['ip_addresses'] = ['required', 'array']; + } + + Validator::make($input, $rules)->validate(); + + if ($network->type === NetworkType::CUSTOM) { + $this->validateMemberIps($network, $input); + } + } + + /** + * Runs only once `servers` is known to be a list of integers — building these rules from + * unvalidated input would interpolate an array into a rule key and fail with a 500. + * + * @param array $input + */ + private function validateMemberIps(Network $network, array $input): void + { + $rules = []; + + foreach ($input['servers'] as $serverId) { + $rules["ip_addresses.$serverId"] = [ + 'required', + Rule::exists('server_ip_addresses', 'id') + ->where('server_id', $serverId) + ->where('type', IpAddressType::PRIVATE->value), + Rule::unique('network_servers', 'server_ip_address_id'), + new WithinCidrRule($network->cidr), + ]; + } + + Validator::make($input, $rules)->validate(); + } + + /** + * An incoming server may already run this network's port for a different network, which the + * two would then fight over on that host. The network moves to a free port instead of + * refusing the server — every healthy member is resynced by the caller, so they follow it. + * + * Peers do not follow: their endpoint port is baked into the config at download time, so an + * already-imported config keeps the old port. The caller warns when peers exist. + * + * @param array $serverIds + */ + private function resolvePortConflict(Network $network, array $serverIds): void + { + $serverIds = array_merge($network->servers()->pluck('server_id')->all(), $serverIds); + + $port = $this->ports->allocate( + $network->project_id, + $serverIds, + $network->port ?? 51820, + $network->id, + ); + + if ($port === $network->port) { + return; + } + + $network->port = $port; + $network->save(); + } +} diff --git a/app/Actions/Network/AllocateNetworkBlock.php b/app/Actions/Network/AllocateNetworkBlock.php new file mode 100644 index 000000000..321483cdf --- /dev/null +++ b/app/Actions/Network/AllocateNetworkBlock.php @@ -0,0 +1,134 @@ + + */ + private const BLOCKLIST = [ + '10.244.0.0/16', + '10.245.0.0/16', + '10.246.0.0/24', + '10.229.0.0/16', + '192.168.128.0/17', + ]; + + /** + * @return array + */ + private function supernets(NetworkAddressingPool $pool): array + { + return match ($pool) { + NetworkAddressingPool::CGNAT => ['100.64.0.0/10'], + NetworkAddressingPool::RFC1918 => ['10.0.0.0/8', '192.168.0.0/16'], + }; + } + + /** + * Carve the next free canonical block from the pool, avoiding overlap with + * existing project networks and with any member server's observed subnets + * (of any type — a CGNAT-WAN interface is stored PUBLIC). + * + * @param Collection $existingCidrs + * @param Collection $memberServers + */ + public function allocate( + NetworkAddressingPool $pool, + int $blockPrefix, + Collection $existingCidrs, + Collection $memberServers + ): string { + $existing = $existingCidrs->filter()->values()->all(); + $memberSubnets = $this->memberSubnets($memberServers); + $blocklist = $pool === NetworkAddressingPool::RFC1918 ? self::BLOCKLIST : []; + + foreach ($this->supernets($pool) as $supernet) { + $candidate = $this->scan($supernet, $blockPrefix, $existing, $memberSubnets, $blocklist); + if ($candidate !== null) { + return $candidate; + } + } + + throw ValidationException::withMessages([ + 'servers' => __('No free address block is available in the selected pool. Choose a smaller block size or the RFC1918 pool.'), + ]); + } + + /** + * @param array $existing + * @param array $memberSubnets + * @param array $blocklist + */ + private function scan( + string $supernet, + int $blockPrefix, + array $existing, + array $memberSubnets, + array $blocklist + ): ?string { + $supernetPrefix = Cidr::prefix($supernet); + if ($blockPrefix < $supernetPrefix) { + return null; + } + + $base = Cidr::toLong(Cidr::network($supernet)); + $blockSize = Cidr::size($blockPrefix); + $count = 2 ** ($blockPrefix - $supernetPrefix); + + for ($i = 0; $i < $count; $i++) { + $candidate = long2ip($base + ($i * $blockSize)).'/'.$blockPrefix; + + if ($this->conflicts($candidate, $existing) || $this->conflicts($candidate, $blocklist) + || $this->conflicts($candidate, $memberSubnets)) { + continue; + } + + return $candidate; + } + + return null; + } + + /** + * @param array $others + */ + private function conflicts(string $candidate, array $others): bool + { + foreach ($others as $other) { + if (Cidr::overlaps($candidate, $other)) { + return true; + } + } + + return false; + } + + /** + * @param Collection $memberServers + * @return array + */ + private function memberSubnets(Collection $memberServers): array + { + return ServerIpAddress::query() + ->whereIn('server_id', $memberServers->pluck('id')->all()) + ->get() + ->filter(fn (ServerIpAddress $address): bool => filter_var($address->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) + ->map(fn (ServerIpAddress $address): string => Cidr::canonical($address->ip.'/'.$address->prefix_length)) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Actions/Network/AllocateWireGuardPort.php b/app/Actions/Network/AllocateWireGuardPort.php new file mode 100644 index 000000000..bd1a6be1c --- /dev/null +++ b/app/Actions/Network/AllocateWireGuardPort.php @@ -0,0 +1,43 @@ + $serverIds + */ + public function allocate(int $projectId, array $serverIds, int $requested, ?int $excludeNetworkId = null): int + { + $used = Network::query() + ->where('type', NetworkType::WIREGUARD) + ->where('project_id', $projectId) + ->when($excludeNetworkId !== null, fn ($query) => $query->whereKeyNot($excludeNetworkId)) + ->whereHas('servers', fn ($query) => $query->whereIn('server_id', $serverIds)) + ->pluck('port') + ->filter() + ->all(); + + $port = $requested; + + while (in_array($port, $used, true)) { + $port++; + + if ($port > 65535) { + throw ValidationException::withMessages([ + 'port' => __('No free WireGuard port is available for the selected servers.'), + ]); + } + } + + return $port; + } +} diff --git a/app/Actions/Network/ApplyNetworkFirewall.php b/app/Actions/Network/ApplyNetworkFirewall.php new file mode 100644 index 000000000..f76f3325c --- /dev/null +++ b/app/Actions/Network/ApplyNetworkFirewall.php @@ -0,0 +1,54 @@ +materialize->forNetwork($network); + + $deferred = false; + + $network->servers() + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server') + ->get() + ->each(function (NetworkServer $member) use (&$deferred): void { + if ($member->server->isReady()) { + if ($member->server->firewall() instanceof Service) { + DB::afterCommit(fn () => dispatch(new ApplyNetworkFirewallJob($member))->onQueue('ssh')); + } + + return; + } + + if ($member->status === NetworkServerStatus::ACTIVE) { + $member->status = NetworkServerStatus::PENDING; + $member->save(); + $deferred = true; + } + }); + + if ($deferred) { + $this->recompute->handle($network); + } + } +} diff --git a/app/Actions/Network/CheckNetworkStranding.php b/app/Actions/Network/CheckNetworkStranding.php new file mode 100644 index 000000000..476583bbe --- /dev/null +++ b/app/Actions/Network/CheckNetworkStranding.php @@ -0,0 +1,44 @@ +type !== NetworkType::PROVIDER || $network->server_provider_id === null) { + return false; + } + + $provider = $network->serverProvider?->provider(); + + if (! $provider instanceof ProvidesPrivateNetworks) { + return true; + } + + $members = $network->servers()->with('server:id,provider_data')->get(); + + if ($members->isEmpty()) { + return false; + } + + $key = $provider->instanceIdKey(); + + return ! $members->contains( + fn (NetworkServer $member): bool => ($member->server->provider_data[$key] ?? '') !== '' + ); + } +} diff --git a/app/Actions/Network/ConcealNetworkPeerKey.php b/app/Actions/Network/ConcealNetworkPeerKey.php new file mode 100644 index 000000000..052196270 --- /dev/null +++ b/app/Actions/Network/ConcealNetworkPeerKey.php @@ -0,0 +1,30 @@ +byo) { + throw ValidationException::withMessages([ + 'peer' => __('This peer uses a user-provided key; there is nothing to conceal.'), + ]); + } + + $peer->private_key = null; + $peer->save(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $peer->network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } +} diff --git a/app/Actions/Network/CreateNetwork.php b/app/Actions/Network/CreateNetwork.php new file mode 100644 index 000000000..4cf4b3ce7 --- /dev/null +++ b/app/Actions/Network/CreateNetwork.php @@ -0,0 +1,223 @@ + $input + */ + public function create(Project $project, array $input): Network + { + $this->validate($project, $input); + + $type = NetworkType::from($input['type']); + + $network = DB::transaction(function () use ($type, $project, $input): Network { + $network = $type === NetworkType::WIREGUARD + ? $this->buildWireGuard($project, $input) + : $this->buildCustom($project, $input); + + $this->seedDefaultFirewallRule($network); + + return $network; + }); + + if ($network->type === NetworkType::WIREGUARD) { + $network->load('servers.server'); + foreach ($network->servers as $member) { + $this->sync->toPresent($member); + } + } else { + $this->firewall->handle($network); + } + + $this->recompute->handle($network); + + return $network->refresh(); + } + + /** + * @param array $input + */ + private function buildWireGuard(Project $project, array $input): Network + { + $pool = NetworkAddressingPool::from($input['addressing_pool'] ?? NetworkAddressingPool::CGNAT->value); + $blockPrefix = (int) ($input['prefix'] ?? 24); + + Project::query()->whereKey($project->id)->lockForUpdate()->first(); + + /** @var Collection $members */ + $members = Server::query() + ->where('project_id', $project->id) + ->whereIn('id', $input['servers']) + ->get(); + + $existing = $project->networks()->pluck('cidr_canonical'); + + $cidr = $this->allocator->allocate($pool, $blockPrefix, $existing, $members); + + $network = Network::create([ + 'project_id' => $project->id, + 'name' => $input['name'], + 'type' => NetworkType::WIREGUARD, + 'status' => NetworkStatus::CREATING, + 'addressing_pool' => $pool, + 'cidr' => $cidr, + 'cidr_canonical' => $cidr, + 'port' => $this->ports->allocate($project->id, $members->pluck('id')->all(), (int) ($input['port'] ?? 51820)), + ]); + + $this->members->create($network, $members); + + return $network; + } + + /** + * @param array $input + */ + private function buildCustom(Project $project, array $input): Network + { + Project::query()->whereKey($project->id)->lockForUpdate()->first(); + + if (isset($input['cidr']) && $input['cidr'] !== '') { + $canonical = Cidr::canonical($input['cidr']); + if ($project->networks() + ->whereIn('type', [NetworkType::CUSTOM, NetworkType::WIREGUARD]) + ->where('cidr_canonical', $canonical) + ->exists()) { + throw ValidationException::withMessages([ + 'cidr' => __('This CIDR is already used by another network in this project.'), + ]); + } + } + + $cidr = ($input['cidr'] ?? '') !== '' ? $input['cidr'] : null; + + $network = Network::create([ + 'project_id' => $project->id, + 'name' => $input['name'], + 'type' => NetworkType::CUSTOM, + 'status' => NetworkStatus::ACTIVE, + 'cidr' => $cidr, + 'cidr_canonical' => $cidr !== null ? Cidr::canonical($cidr) : null, + ]); + + foreach ($input['servers'] as $serverId) { + $network->servers()->create([ + 'server_id' => $serverId, + 'server_ip_address_id' => $input['ip_addresses'][$serverId], + 'status' => NetworkServerStatus::ACTIVE, + ]); + } + + return $network; + } + + private function seedDefaultFirewallRule(Network $network): void + { + $network->firewallRules()->create([ + 'name' => 'Allow all', + 'protocol' => null, + 'port' => null, + 'status' => FirewallRuleStatus::READY, + ]); + } + + /** + * @param array $input + */ + private function validate(Project $project, array $input): void + { + $rules = [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('networks', 'name')->where('project_id', $project->id), + ], + 'type' => [ + 'required', + Rule::in([NetworkType::CUSTOM->value, NetworkType::WIREGUARD->value]), + ], + 'servers' => ['required', 'array', 'min:1'], + 'servers.*' => [ + 'integer', + 'distinct', + Rule::exists('servers', 'id')->where('project_id', $project->id), + ], + ]; + + if (($input['type'] ?? null) === NetworkType::WIREGUARD->value) { + $rules['addressing_pool'] = [ + 'nullable', + Rule::in([NetworkAddressingPool::CGNAT->value, NetworkAddressingPool::RFC1918->value]), + ]; + $rules['prefix'] = ['nullable', 'integer', 'min:16', 'max:28']; + $rules['port'] = ['nullable', 'integer', 'min:1024', 'max:65535']; + } + + if (($input['type'] ?? null) === NetworkType::CUSTOM->value) { + $rules['cidr'] = ['nullable', 'string', new CidrRule, new PrivateRangeRule]; + $rules['ip_addresses'] = ['required', 'array']; + } + + Validator::make($input, $rules)->validate(); + + if (($input['type'] ?? null) === NetworkType::CUSTOM->value) { + $this->validateMemberIps($input); + } + } + + /** + * Runs only once `servers` is known to be a list of integers — building these rules from + * unvalidated input would interpolate an array into a rule key and fail with a 500. + * + * @param array $input + */ + private function validateMemberIps(array $input): void + { + $rules = []; + + foreach ($input['servers'] as $serverId) { + $rules["ip_addresses.$serverId"] = [ + 'required', + Rule::exists('server_ip_addresses', 'id') + ->where('server_id', $serverId) + ->where('type', IpAddressType::PRIVATE->value), + Rule::unique('network_servers', 'server_ip_address_id'), + new WithinCidrRule($input['cidr'] ?? null), + ]; + } + + Validator::make($input, $rules)->validate(); + } +} diff --git a/app/Actions/Network/CreateNetworkPeer.php b/app/Actions/Network/CreateNetworkPeer.php new file mode 100644 index 000000000..55cfdea56 --- /dev/null +++ b/app/Actions/Network/CreateNetworkPeer.php @@ -0,0 +1,116 @@ + $input + */ + public function create(Network $network, array $input): NetworkPeer + { + abort_unless($network->type === NetworkType::WIREGUARD, 404); + + $this->validate($network, $input); + + $peer = DB::transaction(function () use ($network, $input): NetworkPeer { + Network::query()->whereKey($network->id)->lockForUpdate()->first(); + + $used = $this->usedIps($network); + $ip = Cidr::nextHost((string) $network->cidr, $used); + if ($ip === null) { + throw ValidationException::withMessages([ + 'name' => __('The network address block is full.'), + ]); + } + + return $network->peers()->create([ + ...$this->keyAttributes($input), + 'name' => $input['name'], + 'ip' => $ip, + 'status' => NetworkPeerStatus::PENDING, + ]); + }); + + $this->sync->resyncMembers($network); + $this->recompute->handle($network); + + $this->broadcast($network, $peer); + + return $peer; + } + + /** + * @param array $input + * @return array{public_key: string, private_key: ?string, byo: bool} + */ + private function keyAttributes(array $input): array + { + $publicKey = $input['public_key'] ?? null; + + if (is_string($publicKey) && $publicKey !== '') { + return ['public_key' => $publicKey, 'private_key' => null, 'byo' => true]; + } + + $keys = $this->keys->generate(); + + return ['public_key' => $keys['public_key'], 'private_key' => $keys['private_key'], 'byo' => false]; + } + + /** + * @return array + */ + private function usedIps(Network $network): array + { + return $network->servers()->lockForUpdate()->pluck('ip') + ->concat($network->peers()->lockForUpdate()->pluck('ip')) + ->filter() + ->values() + ->all(); + } + + /** + * @param array $input + */ + private function validate(Network $network, array $input): void + { + Validator::make($input, [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('network_peers', 'name')->where('network_id', $network->id), + ], + 'public_key' => ['nullable', 'string', new WireGuardPublicKeyRule($network)], + ])->validate(); + } + + private function broadcast(Network $network, NetworkPeer $peer): void + { + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } +} diff --git a/app/Actions/Network/CreateWireGuardMembers.php b/app/Actions/Network/CreateWireGuardMembers.php new file mode 100644 index 000000000..50f7004bf --- /dev/null +++ b/app/Actions/Network/CreateWireGuardMembers.php @@ -0,0 +1,54 @@ + $servers + * @param array $used + * @return array + */ + public function create(Network $network, Collection $servers, array $used = []): array + { + $ids = []; + + foreach ($servers as $server) { + $ip = Cidr::nextHost((string) $network->cidr, $used); + + if ($ip === null) { + throw ValidationException::withMessages([ + 'servers' => __('The network address block is full.'), + ]); + } + + $used[] = $ip; + + $keys = $this->keys->generate(); + + $member = $network->servers()->create([ + 'server_id' => $server->id, + 'ip' => $ip, + 'public_key' => $keys['public_key'], + 'private_key' => $keys['private_key'], + 'status' => NetworkServerStatus::PENDING, + ]); + + $ids[] = $member->id; + } + + return $ids; + } +} diff --git a/app/Actions/Network/DeleteNetwork.php b/app/Actions/Network/DeleteNetwork.php new file mode 100644 index 000000000..7645b8493 --- /dev/null +++ b/app/Actions/Network/DeleteNetwork.php @@ -0,0 +1,27 @@ +status = NetworkStatus::DELETING; + $network->save(); + + $network->load('servers.server'); + foreach ($network->servers as $member) { + $this->sync->teardown($member); + } + + $this->recompute->handle($network); + } +} diff --git a/app/Actions/Network/DeleteNetworkPeer.php b/app/Actions/Network/DeleteNetworkPeer.php new file mode 100644 index 000000000..2af24b047 --- /dev/null +++ b/app/Actions/Network/DeleteNetworkPeer.php @@ -0,0 +1,32 @@ +network; + $peerId = $peer->id; + + $peer->delete(); + + $this->sync->resyncMembers($network); + $this->recompute->handle($network); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.deleted', + data: ['id' => $peerId], + )); + } +} diff --git a/app/Actions/Network/DispatchNetworkServerSync.php b/app/Actions/Network/DispatchNetworkServerSync.php new file mode 100644 index 000000000..44c7a2d69 --- /dev/null +++ b/app/Actions/Network/DispatchNetworkServerSync.php @@ -0,0 +1,52 @@ +load('servers.server'); + + foreach ($network->servers as $member) { + if ($member->id !== $excludeMemberId + && in_array($member->status, [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING], true)) { + $this->toPresent($member); + } + } + } + + public function toPresent(NetworkServer $member): void + { + if ($member->server->isReady()) { + $member->status = NetworkServerStatus::UPDATING; + $member->save(); + DB::afterCommit(fn () => dispatch(new SyncNetworkServerJob($member))->onQueue('ssh')); + + return; + } + + $member->status = NetworkServerStatus::PENDING; + $member->save(); + } + + public function teardown(NetworkServer $member): void + { + $member->status = NetworkServerStatus::LEAVING; + $member->sync_attempts = 0; + $member->save(); + + DB::afterCommit(fn () => dispatch(new SyncNetworkServerJob($member, true))->onQueue('ssh')); + } +} diff --git a/app/Actions/Network/FinalizeServerNetworkRules.php b/app/Actions/Network/FinalizeServerNetworkRules.php new file mode 100644 index 000000000..8994d5eed --- /dev/null +++ b/app/Actions/Network/FinalizeServerNetworkRules.php @@ -0,0 +1,62 @@ + $emittedIds + * @param array $deletingIds + */ + public function success(Server $server, array $emittedIds, array $deletingIds): void + { + DB::transaction(function () use ($emittedIds, $deletingIds): void { + if ($emittedIds !== []) { + ServerNetworkRule::query() + ->whereIn('id', $emittedIds) + ->where('status', '!=', FirewallRuleStatus::READY) + ->update(['status' => FirewallRuleStatus::READY]); + } + + if ($deletingIds !== []) { + ServerNetworkRule::query()->whereIn('id', $deletingIds)->delete(); + } + }); + + $this->broadcast($server); + } + + /** + * @param array $emittedIds + */ + public function failure(Server $server, array $emittedIds): void + { + if ($emittedIds !== []) { + ServerNetworkRule::query() + ->whereIn('id', $emittedIds) + ->whereIn('status', [FirewallRuleStatus::CREATING, FirewallRuleStatus::UPDATING]) + ->update(['status' => FirewallRuleStatus::FAILED]); + } + + $this->broadcast($server); + } + + private function broadcast(Server $server): void + { + SocketEvent::dispatch(new SocketEventDTO( + projectId: $server->project_id, + type: 'server-network-rule.updated', + data: ['server_id' => $server->id], + )); + } +} diff --git a/app/Actions/Network/GenerateWireGuardKeys.php b/app/Actions/Network/GenerateWireGuardKeys.php new file mode 100644 index 000000000..49688810d --- /dev/null +++ b/app/Actions/Network/GenerateWireGuardKeys.php @@ -0,0 +1,27 @@ + base64_encode($private), + 'public_key' => base64_encode($public), + ]; + } +} diff --git a/app/Actions/Network/GetNetworkPeerConfig.php b/app/Actions/Network/GetNetworkPeerConfig.php new file mode 100644 index 000000000..c5addc607 --- /dev/null +++ b/app/Actions/Network/GetNetworkPeerConfig.php @@ -0,0 +1,70 @@ +hasPrivateKey(); + + $config = view('wireguard.peer-conf', [ + 'address' => $peer->ip, + 'prefix' => Cidr::hostPrefix($peer->ip), + 'privateKey' => $hasKey ? $peer->private_key : self::PRIVATE_KEY_PLACEHOLDER, + 'peers' => $this->peers($peer), + ])->render(); + + return [ + 'config' => $config, + 'private_key' => $hasKey ? $peer->private_key : null, + ]; + } + + /** + * The first member carries the whole network range as its `AllowedIPs` so the device routes + * the subnet through it, and the rest carry only their own host address. That makes the + * order load-bearing: without a deterministic one, regenerating a config could silently + * move which server the device routes through. + * + * @return array + */ + private function peers(NetworkPeer $peer): array + { + $network = $peer->network; + + $members = $network->servers() + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereNotNull('public_key') + ->whereNotNull('ip') + ->with('server') + ->orderBy('id') + ->get() + ->filter(fn (NetworkServer $member): bool => Cidr::isValidAddress((string) $member->server->ip)) + ->values(); + + return $members + ->map(fn (NetworkServer $member, int $index): array => [ + 'public_key' => (string) $member->public_key, + 'allowed_ips' => $index === 0 + ? (string) $network->cidr + : $member->ip.'/'.Cidr::hostPrefix((string) $member->ip), + 'endpoint' => Cidr::endpoint((string) $member->server->ip, (int) $network->port), + ]) + ->all(); + } +} diff --git a/app/Actions/Network/ManageNetworkFirewallRule.php b/app/Actions/Network/ManageNetworkFirewallRule.php new file mode 100644 index 000000000..45fb6490a --- /dev/null +++ b/app/Actions/Network/ManageNetworkFirewallRule.php @@ -0,0 +1,102 @@ + $input + */ + public function create(Network $network, array $input): NetworkFirewallRule + { + $this->validate($input); + + $rule = $network->firewallRules()->create($this->attributes($input)); + + $this->broadcast($network, 'network-firewall-rule.updated', new NetworkFirewallRuleResource($rule)); + $this->apply->handle($network); + + return $rule; + } + + /** + * @param array $input + */ + public function update(NetworkFirewallRule $rule, array $input): NetworkFirewallRule + { + $this->validate($input); + + $rule->update($this->attributes($input)); + + $this->broadcast($rule->network, 'network-firewall-rule.updated', new NetworkFirewallRuleResource($rule)); + $this->apply->handle($rule->network); + + return $rule; + } + + public function delete(NetworkFirewallRule $rule): void + { + $network = $rule->network; + $ruleId = $rule->id; + $rule->delete(); + + $this->broadcast($network, 'network-firewall-rule.deleted', ['id' => $ruleId]); + $this->apply->handle($network); + } + + /** + * @param array|NetworkFirewallRuleResource $data + */ + private function broadcast(Network $network, string $type, array|NetworkFirewallRuleResource $data): void + { + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: $type, + data: $data, + )); + } + + /** + * @param array $input + * @return array + */ + private function attributes(array $input): array + { + $port = $input['port'] ?? null; + + return [ + 'name' => $input['name'], + 'protocol' => $input['protocol'] ?? null, + 'port' => ($port === null || $port === '') ? null : (string) $port, + 'status' => FirewallRuleStatus::READY, + ]; + } + + /** + * @param array $input + */ + private function validate(array $input): void + { + $port = $input['port'] ?? null; + $isRange = is_string($port) && str_contains($port, ':'); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'protocol' => [$isRange ? 'required' : 'nullable', 'in:tcp,udp'], + 'port' => ['nullable', new PortOrPortRangeRule], + ], [ + 'protocol.required' => __('A protocol is required when the port is a range, because ufw rejects a multi-port rule without one.'), + ])->validate(); + } +} diff --git a/app/Actions/Network/MaterializeServerNetworkRules.php b/app/Actions/Network/MaterializeServerNetworkRules.php new file mode 100644 index 000000000..3a6100b87 --- /dev/null +++ b/app/Actions/Network/MaterializeServerNetworkRules.php @@ -0,0 +1,294 @@ +> */ + private array $peerCache = []; + + /** @var array> */ + private array $ruleCache = []; + + /** @var array */ + private array $deviceCache = []; + + /** + * Re-materialise every non-LEAVING member's server. A topology change on the + * network (membership, IP, keys) affects the handshake/source rows on all peers. + */ + public function forNetwork(Network $network): void + { + $network->servers() + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server') + ->get() + ->each(fn (NetworkServer $member) => $this->forServer($member->server)); + } + + /** + * Reconcile the materialised network-rule rows for a single server against the + * desired set derived from its current memberships. + */ + public function forServer(Server $server): void + { + $desired = $this->desiredFor($server); + $applied = $server->firewall() instanceof Service; + + $changed = DB::transaction(function () use ($server, $desired, $applied): bool { + $existing = $server->networkRules()->get()->keyBy(fn (ServerNetworkRule $row): string => $this->identity( + $row->network_server_id, + $row->kind, + $row->network_firewall_rule_id, + $row->source, + $row->mask, + )); + + $changed = false; + + foreach ($desired as $key => $spec) { + /** @var ?ServerNetworkRule $row */ + $row = $existing->get($key); + + if (! $row instanceof ServerNetworkRule) { + $server->networkRules()->create([ + ...$spec, + 'status' => $applied ? FirewallRuleStatus::CREATING : FirewallRuleStatus::READY, + ]); + $changed = true; + + continue; + } + + if ($row->name !== $spec['name'] + || $row->type !== $spec['type'] + || $row->protocol !== $spec['protocol'] + || $row->port !== $spec['port']) { + $row->fill([ + 'name' => $spec['name'], + 'type' => $spec['type'], + 'protocol' => $spec['protocol'], + 'port' => $spec['port'], + 'status' => $applied ? FirewallRuleStatus::UPDATING : FirewallRuleStatus::READY, + ])->save(); + $changed = true; + + continue; + } + + if ($row->status === FirewallRuleStatus::DELETING) { + $row->status = $applied ? FirewallRuleStatus::CREATING : FirewallRuleStatus::READY; + $row->save(); + $changed = true; + } + } + + foreach ($existing as $key => $row) { + if (isset($desired[$key])) { + continue; + } + + if (! $applied || $row->status === FirewallRuleStatus::CREATING) { + $row->delete(); + } else { + $row->status = FirewallRuleStatus::DELETING; + $row->save(); + } + $changed = true; + } + + return $changed; + }); + + if ($changed) { + $this->broadcast($server); + } + } + + /** + * @return array> + */ + private function desiredFor(Server $server): array + { + $memberships = NetworkServer::query() + ->where('server_id', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('network') + ->get(); + + $this->peerCache = []; + $this->ruleCache = []; + $this->deviceCache = []; + + $desired = []; + + foreach ($memberships as $membership) { + $network = $membership->network; + + if ($network->type === NetworkType::WIREGUARD) { + foreach ($this->handshakes($network, $server) as $handshake) { + $mask = Cidr::hostPrefix($handshake['ip']); + $spec = [ + 'network_id' => $network->id, + 'network_server_id' => $membership->id, + 'network_firewall_rule_id' => null, + 'kind' => ServerNetworkRuleKind::HANDSHAKE, + 'name' => 'WireGuard handshake ('.$handshake['name'].')', + 'type' => 'allow', + 'protocol' => 'udp', + 'port' => (string) $network->port, + 'source' => $handshake['ip'], + 'mask' => $mask, + ]; + $desired[$this->identity($membership->id, ServerNetworkRuleKind::HANDSHAKE, null, $handshake['ip'], $mask)] = $spec; + } + + if ($this->hasDevices($network)) { + $desired[$this->identity($membership->id, ServerNetworkRuleKind::HANDSHAKE, null, null, null)] = [ + 'network_id' => $network->id, + 'network_server_id' => $membership->id, + 'network_firewall_rule_id' => null, + 'kind' => ServerNetworkRuleKind::HANDSHAKE, + 'name' => 'WireGuard handshake (devices)', + 'type' => 'allow', + 'protocol' => 'udp', + 'port' => (string) $network->port, + 'source' => null, + 'mask' => null, + ]; + } + } + + $sources = $this->sources($network, $server); + + foreach ($this->firewallRules($network) as $rule) { + foreach ($sources as $source) { + $spec = [ + 'network_id' => $network->id, + 'network_server_id' => $membership->id, + 'network_firewall_rule_id' => $rule->id, + 'kind' => ServerNetworkRuleKind::RULE, + 'name' => $rule->name, + 'type' => 'allow', + 'protocol' => $rule->protocol, + 'port' => $rule->port, + 'source' => $source['ip'], + 'mask' => $source['mask'], + ]; + $desired[$this->identity($membership->id, ServerNetworkRuleKind::RULE, $rule->id, $source['ip'], $source['mask'])] = $spec; + } + } + } + + return $desired; + } + + /** + * Sources are interpolated unquoted into the `ufw` blade template, which is a shell + * script — Blade escapes HTML, not shell metacharacters. Every address is re-checked + * here so nothing but a literal IP can reach it, whatever wrote the column. + * + * @return array + */ + private function handshakes(Network $network, Server $server): array + { + return $this->peers($network, $server) + ->filter(fn (NetworkServer $peer): bool => Cidr::isValidAddress((string) $peer->server->ip)) + ->map(fn (NetworkServer $peer): array => ['ip' => (string) $peer->server->ip, 'name' => $peer->server->name]) + ->values() + ->all(); + } + + /** + * @return array + */ + private function sources(Network $network, Server $server): array + { + if ($network->type !== NetworkType::PROVIDER && $network->cidr !== null && $network->cidr !== '') { + if (! Cidr::isValid($network->cidr)) { + return []; + } + + return [[ + 'ip' => Cidr::network($network->cidr), + 'mask' => Cidr::prefix($network->cidr), + ]]; + } + + $sources = []; + + foreach ($this->peers($network, $server) as $peer) { + $ip = $peer->server_ip_address_id !== null ? $peer->serverIpAddress?->ip : $peer->ip; + + if ($ip === null || ! Cidr::isValidAddress($ip)) { + continue; + } + + $sources[] = ['ip' => $ip, 'mask' => Cidr::hostPrefix($ip)]; + } + + return $sources; + } + + /** + * @return Collection + */ + private function peers(Network $network, Server $server): Collection + { + return $this->peerCache[$network->id] ??= NetworkServer::query() + ->where('network_id', $network->id) + ->where('server_id', '!=', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server', 'serverIpAddress') + ->get(); + } + + /** + * @return Collection + */ + private function firewallRules(Network $network): Collection + { + return $this->ruleCache[$network->id] ??= $network->firewallRules() + ->where('status', '!=', FirewallRuleStatus::DELETING) + ->orderBy('id') + ->get(); + } + + private function hasDevices(Network $network): bool + { + return $this->deviceCache[$network->id] ??= $network->peers() + ->where('status', '!=', NetworkPeerStatus::DISABLED) + ->exists(); + } + + private function identity(int $networkServerId, ServerNetworkRuleKind $kind, ?int $ruleId, ?string $source, ?int $mask): string + { + return implode('|', [$networkServerId, $kind->value, $ruleId ?? '', $source ?? '', $mask ?? '']); + } + + private function broadcast(Server $server): void + { + SocketEvent::dispatch(new SocketEventDTO( + projectId: $server->project_id, + type: 'server-network-rule.updated', + data: ['server_id' => $server->id], + )); + } +} diff --git a/app/Actions/Network/RecomputeNetworkStatus.php b/app/Actions/Network/RecomputeNetworkStatus.php new file mode 100644 index 000000000..21ad4d7e7 --- /dev/null +++ b/app/Actions/Network/RecomputeNetworkStatus.php @@ -0,0 +1,108 @@ +fresh(); + + if (! $network instanceof Network) { + return; + } + + if ($network->status === NetworkStatus::DELETING) { + if ($network->servers()->count() === 0) { + $projectId = $network->project_id; + $id = $network->id; + $network->delete(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $projectId, + type: 'network.deleted', + data: ['id' => $id], + )); + } + + return; + } + + $statuses = $network->servers()->pluck('status'); + + $computed = match (true) { + $statuses->isEmpty() => NetworkStatus::CREATING, + $statuses->contains(NetworkServerStatus::FAILED) => NetworkStatus::FAILED, + $statuses->contains(NetworkServerStatus::PENDING), + $statuses->contains(NetworkServerStatus::UPDATING), + $statuses->contains(NetworkServerStatus::LEAVING) => NetworkStatus::SYNCING, + default => NetworkStatus::ACTIVE, + }; + + if ($network->status !== $computed) { + $network->status = $computed; + $network->save(); + } + + if ($computed === NetworkStatus::ACTIVE) { + $this->activatePendingPeers($network); + } + + if ($computed === NetworkStatus::CREATING) { + $this->holdActivePeers($network); + } + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network.updated', + data: new NetworkResource($network->load('serverProvider')->loadCount('servers')), + )); + } + + /** + * No member is left to hold the peer's key, so it is not connected to anything. It waits + * for a server to join rather than reporting itself active against an empty network. + */ + private function holdActivePeers(Network $network): void + { + $network->peers() + ->where('status', NetworkPeerStatus::ACTIVE) + ->get() + ->each(function (NetworkPeer $peer) use ($network): void { + $peer->status = NetworkPeerStatus::PENDING; + $peer->save(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + }); + } + + private function activatePendingPeers(Network $network): void + { + $peers = $network->peers()->where('status', NetworkPeerStatus::PENDING)->get(); + + foreach ($peers as $peer) { + $peer->status = NetworkPeerStatus::ACTIVE; + $peer->save(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } + } +} diff --git a/app/Actions/Network/RegenerateNetworkPeerKeys.php b/app/Actions/Network/RegenerateNetworkPeerKeys.php new file mode 100644 index 000000000..130775e26 --- /dev/null +++ b/app/Actions/Network/RegenerateNetworkPeerKeys.php @@ -0,0 +1,54 @@ + $input + */ + public function regenerate(NetworkPeer $peer, array $input): void + { + $network = $peer->network; + + Validator::make($input, [ + 'public_key' => ['nullable', 'string', new WireGuardPublicKeyRule($network, $peer->id)], + ])->validate(); + + $publicKey = $input['public_key'] ?? null; + + if (is_string($publicKey) && $publicKey !== '') { + $peer->fill(['public_key' => $publicKey, 'private_key' => null, 'byo' => true]); + } else { + $keys = $this->keys->generate(); + $peer->fill(['public_key' => $keys['public_key'], 'private_key' => $keys['private_key'], 'byo' => false]); + } + + $peer->status = NetworkPeerStatus::PENDING; + $peer->last_handshake_at = null; + $peer->save(); + + $this->sync->resyncMembers($network); + $this->recompute->handle($network); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } +} diff --git a/app/Actions/Network/RemoveMembershipsForAddress.php b/app/Actions/Network/RemoveMembershipsForAddress.php new file mode 100644 index 000000000..998363d68 --- /dev/null +++ b/app/Actions/Network/RemoveMembershipsForAddress.php @@ -0,0 +1,52 @@ + + */ + public function capture(ServerIpAddress $address): array + { + return NetworkServer::query() + ->where('server_ip_address_id', $address->id) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::CUSTOM)) + ->pluck('network_id') + ->unique() + ->values() + ->all(); + } + + /** + * A custom membership is addressed solely by that row, so losing the address leaves it with + * nothing to announce — it is removed rather than re-applied with an incomplete source. + * + * @param array $networkIds + */ + public function handle(int $serverId, array $networkIds): void + { + if ($networkIds === []) { + return; + } + + DB::afterCommit(function () use ($serverId, $networkIds): void { + NetworkServer::query() + ->whereIn('network_id', $networkIds) + ->where('server_id', $serverId) + ->get() + ->each(fn (NetworkServer $member) => $this->remove->remove($member)); + }); + } +} diff --git a/app/Actions/Network/RemoveServerFromNetwork.php b/app/Actions/Network/RemoveServerFromNetwork.php new file mode 100644 index 000000000..99a5fbdd9 --- /dev/null +++ b/app/Actions/Network/RemoveServerFromNetwork.php @@ -0,0 +1,30 @@ +network; + + $this->sync->teardown($member); + + if ($network->type === NetworkType::WIREGUARD) { + $this->sync->resyncMembers($network, $member->id); + } else { + $this->firewall->handle($network); + } + + $this->recompute->handle($network); + } +} diff --git a/app/Actions/Network/ResyncNetworkSiblings.php b/app/Actions/Network/ResyncNetworkSiblings.php new file mode 100644 index 000000000..db06c48b9 --- /dev/null +++ b/app/Actions/Network/ResyncNetworkSiblings.php @@ -0,0 +1,75 @@ +, networks: array} + */ + public function capture(Server $server): array + { + $networkIds = NetworkServer::query() + ->where('server_id', $server->id) + ->pluck('network_id') + ->unique() + ->values() + ->all(); + + $memberIds = NetworkServer::query() + ->whereIn('network_id', $networkIds) + ->where('server_id', '!=', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->pluck('id') + ->all(); + + return ['members' => $memberIds, 'networks' => $networkIds]; + } + + /** + * Deferred to after commit so the members are rewritten, and the statuses computed, against + * the topology that survives the deletion rather than the one still inside the transaction. + * + * @param array{members: array, networks: array} $departure + */ + public function handle(array $departure): void + { + if ($departure['networks'] === []) { + return; + } + + DB::afterCommit(function () use ($departure): void { + NetworkServer::query() + ->whereIn('id', $departure['members']) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server', 'network') + ->get() + ->each(fn (NetworkServer $member) => $this->sync->toPresent($member)); + + Network::query() + ->whereIn('id', $departure['networks']) + ->get() + ->each(fn (Network $network) => $this->recompute->handle($network)); + }); + } +} diff --git a/app/Actions/Network/ResyncServerEndpoint.php b/app/Actions/Network/ResyncServerEndpoint.php new file mode 100644 index 000000000..1794067bb --- /dev/null +++ b/app/Actions/Network/ResyncServerEndpoint.php @@ -0,0 +1,29 @@ +where('server_id', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->with('network') + ->get() + ->each(fn (NetworkServer $membership) => $this->sync->resyncMembers($membership->network, $membership->id)); + } +} diff --git a/app/Actions/Network/SyncNetwork.php b/app/Actions/Network/SyncNetwork.php new file mode 100644 index 000000000..ca0f6fc8a --- /dev/null +++ b/app/Actions/Network/SyncNetwork.php @@ -0,0 +1,49 @@ +type === NetworkType::PROVIDER) { + SyncProviderNetworksJob::dispatchUnlessRecent($network->project, $network); + + return; + } + + $network->load('servers.server'); + foreach ($network->servers as $member) { + if ($member->status !== NetworkServerStatus::LEAVING) { + $member->sync_attempts = 0; + $member->save(); + $this->sync->toPresent($member); + } + } + + $this->recompute->handle($network); + } + + public function member(NetworkServer $member): void + { + if ($member->status === NetworkServerStatus::LEAVING) { + return; + } + + $member->sync_attempts = 0; + $member->save(); + $this->sync->toPresent($member); + $this->recompute->handle($member->network); + } +} diff --git a/app/Actions/Network/SyncProviderNetworks.php b/app/Actions/Network/SyncProviderNetworks.php new file mode 100644 index 000000000..ea44e0ec3 --- /dev/null +++ b/app/Actions/Network/SyncProviderNetworks.php @@ -0,0 +1,462 @@ +project_id !== $project->id) { + return; + } + + $failure = null; + $persistFailures = 0; + + foreach ($this->connections($project, $only) as $context) { + $connection = $context['connection']; + + $asked = $context['servers'] !== [] + && $context['provider']->canDiscoverPrivateNetworks($context['regions'], $context['serversWithoutRegion']); + + try { + $discovered = $asked + ? $context['provider']->privateNetworks( + array_map('strval', array_keys($context['servers'])), + $context['regions'], + ) + : []; + } catch (PrivateNetworkSyncError $e) { + $this->logFailure($e); + $failure ??= $e; + + continue; + } + + $seen = []; + + foreach ($discovered as $dto) { + if ($only instanceof Network && $dto->externalId !== $only->external_id) { + continue; + } + + $seen[] = $dto->externalId; + + if (! $this->reconcile($project, $connection, $dto, $context['servers'])) { + $persistFailures++; + } + } + + $this->prune($project, $connection, $seen, $only, $asked); + } + + if ($failure instanceof PrivateNetworkSyncError) { + throw $failure; + } + + if ($persistFailures > 0) { + throw new PrivateNetworkPersistError($project->id, $persistFailures); + } + } + + /** + * Discovered networks are upserted on (project, connection, external id). A per-network DB + * failure is logged and skipped rather than propagated, so it never counts as a connection + * failure — that would wrongly suppress pruning for every other network on the connection. + * + * @param array $servers + * @return bool whether the network was persisted; the caller fails the sweep at the end + */ + private function reconcile(Project $project, ServerProvider $connection, PrivateNetworkDTO $dto, array $servers): bool + { + try { + $network = DB::transaction(function () use ($project, $connection, $dto, $servers): Network { + $network = $this->upsert($project, $connection, $dto); + + $this->reconcileMembers($network, $dto, $servers); + + return $network; + }); + } catch (QueryException $e) { + Log::warning('Could not reconcile provider network.', [ + 'project_id' => $project->id, + 'server_provider_id' => $connection->id, + 'external_id' => $dto->externalId, + 'reason' => $e->getCode(), + ]); + + return false; + } + + $this->firewall->handle($network); + $this->recompute->handle($network); + + return true; + } + + private function upsert(Project $project, ServerProvider $connection, PrivateNetworkDTO $dto): Network + { + /** @var ?Network $network */ + $network = $project->networks() + ->where('server_provider_id', $connection->id) + ->where('external_id', $dto->externalId) + ->lockForUpdate() + ->first(); + + if (! $network instanceof Network) { + $network = new Network([ + 'project_id' => $project->id, + 'name' => $this->uniqueName($project, $dto->name), + 'type' => NetworkType::PROVIDER, + 'status' => NetworkStatus::SYNCING, + 'cidr' => $dto->cidr, + 'cidr_canonical' => $dto->cidr, + 'region' => $dto->region, + ]); + + $network->server_provider_id = $connection->id; + $network->external_id = $dto->externalId; + $network->last_synced_at = now(); + $network->save(); + + $network->firewallRules()->create([ + 'name' => 'Allow all', + 'protocol' => null, + 'port' => null, + 'status' => FirewallRuleStatus::READY, + ]); + + return $network; + } + + $this->resurrect($network); + + $network->cidr = $dto->cidr; + $network->cidr_canonical = $dto->cidr; + $network->region = $dto->region; + $network->last_synced_at = now(); + $network->save(); + + return $network; + } + + /** + * A network pruned on an earlier run can legitimately reappear — a transient omission + * from the provider, or a region that failed and then recovered. `RecomputeNetworkStatus` + * returns early for DELETING networks without recomputing, and only hard-deletes at zero + * members, so a resurrected network would otherwise stay DELETING forever with members + * that never converge, and D2 blocks the user from deleting it. + */ + private function resurrect(Network $network): void + { + if ($network->status !== NetworkStatus::DELETING) { + return; + } + + $network->status = NetworkStatus::SYNCING; + $network->save(); + + $network->servers() + ->where('status', NetworkServerStatus::LEAVING) + ->update(['status' => NetworkServerStatus::PENDING, 'sync_attempts' => 0]); + } + + /** + * Membership is a fact reported by the provider, so members start ACTIVE — there is no + * on-server provisioning beyond firewall rules. `ApplyNetworkFirewall` downgrades a member + * to PENDING when its server is unreachable, and the reconciler drives it back up. + * + * Departure is keyed on array_key_exists rather than isset, because a member the provider + * reports without an address is present with a null value. + * + * @param array $servers + */ + private function reconcileMembers(Network $network, PrivateNetworkDTO $dto, array $servers): void + { + /** @var Collection $existing */ + $existing = $network->servers()->lockForUpdate()->get()->keyBy('server_id'); + + $desired = []; + + foreach ($dto->members as $member) { + $server = $servers[$member->instanceId] ?? null; + + if ($server instanceof Server) { + $desired[$server->id] = $member->ip; + } + } + + $this->releaseChangedIps($existing, $desired); + + foreach ($desired as $serverId => $ip) { + /** @var ?NetworkServer $member */ + $member = $existing->get($serverId); + + if (! $member instanceof NetworkServer) { + $network->servers()->create([ + 'server_id' => $serverId, + 'ip' => $ip, + 'status' => NetworkServerStatus::ACTIVE, + ]); + + continue; + } + + $member->ip = $ip; + + if ($member->status === NetworkServerStatus::LEAVING) { + $member->status = NetworkServerStatus::ACTIVE; + $member->sync_attempts = 0; + } + + $member->save(); + } + + foreach ($existing as $member) { + if (array_key_exists($member->server_id, $desired)) { + continue; + } + + if ($member->status === NetworkServerStatus::LEAVING) { + continue; + } + + $this->remove->remove($member); + } + } + + /** + * `network_servers` carries unique(network_id, ip). When a provider recycles an address + * onto a different instance within one run, writing the new owner before clearing the old + * one violates it, so changed addresses are released first. + * + * @param Collection $existing + * @param array $desired + */ + private function releaseChangedIps(Collection $existing, array $desired): void + { + foreach ($existing as $member) { + $target = $desired[$member->server_id] ?? null; + + if ($member->ip !== null && $member->ip !== $target) { + $member->ip = null; + $member->save(); + } + } + } + + /** + * `$asked` is false when the connection had no instance ids to query, or when the provider + * could not be queried at all (an EC2 connection whose servers carry no region), so `$seen` + * being empty carries no information about what still exists at the provider. Only a network + * with no members left may be reaped in that case — anything else has to wait for a run + * that could actually ask. + * + * @param array $seen + */ + private function prune(Project $project, ServerProvider $connection, array $seen, ?Network $only, bool $asked): void + { + $project->networks() + ->where('type', NetworkType::PROVIDER) + ->where('server_provider_id', $connection->id) + ->where('status', '!=', NetworkStatus::DELETING) + ->when($only instanceof Network, fn ($query) => $query->whereKey($only?->id)) + ->get() + ->each(function (Network $network) use ($seen, $asked): void { + if (! $asked) { + if ($this->hasLiveMembers($network)) { + return; + } + + $this->delete->delete($network); + + return; + } + + $stillPresent = in_array($network->external_id, $seen, true); + + if ($stillPresent && $this->hasLiveMembers($network)) { + return; + } + + $this->delete->delete($network); + }); + } + + private function hasLiveMembers(Network $network): bool + { + return $network->servers() + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->exists(); + } + + private function uniqueName(Project $project, string $name): string + { + $name = trim($name) !== '' ? trim($name) : 'network'; + $candidate = $name; + $suffix = 2; + + while ($project->networks()->where('name', $candidate)->exists()) { + $candidate = $name.'-'.$suffix; + $suffix++; + } + + return $candidate; + } + + /** + * @return array, regions: array, serversWithoutRegion: int}> + */ + private function connections(Project $project, ?Network $only): array + { + /** @var Collection $servers */ + $servers = $project->servers() + ->whereNotNull('provider_id') + ->when( + $only instanceof Network, + fn ($query) => $query->where('provider_id', $only?->server_provider_id) + ) + ->with('serverProvider') + ->get(); + + $contexts = []; + + foreach ($servers as $server) { + $connection = $server->serverProvider; + + if (! isset($contexts[$connection->id])) { + $provider = $connection->provider(); + + if (! $provider instanceof ProvidesPrivateNetworks) { + continue; + } + + $contexts[$connection->id] = [ + 'connection' => $connection, + 'provider' => $provider, + 'key' => $provider->instanceIdKey(), + 'servers' => [], + 'regions' => [], + 'serversWithoutRegion' => 0, + ]; + } + + $instanceId = $server->provider_data[$contexts[$connection->id]['key']] ?? null; + + if ($instanceId === null || $instanceId === '') { + continue; + } + + $contexts[$connection->id]['servers'][(string) $instanceId] = $server; + + $region = $server->provider_data['region'] ?? null; + + if (! is_string($region) || $region === '') { + $contexts[$connection->id]['serversWithoutRegion']++; + + continue; + } + + if (! in_array($region, $contexts[$connection->id]['regions'], true)) { + $contexts[$connection->id]['regions'][] = $region; + } + } + + foreach ($this->orphanedConnections($project, $only, array_keys($contexts)) as $connection) { + $provider = $connection->provider(); + + if (! $provider instanceof ProvidesPrivateNetworks) { + continue; + } + + $contexts[$connection->id] = [ + 'connection' => $connection, + 'provider' => $provider, + 'key' => $provider->instanceIdKey(), + 'servers' => [], + 'regions' => [], + 'serversWithoutRegion' => 0, + ]; + } + + return array_values($contexts); + } + + /** + * A connection whose last managed server has been deleted still needs a pass. Its provider + * networks have no members left, so nothing can reconcile them, and because they are + * provider-managed the policy also refuses a manual delete — without this they would be + * stranded permanently. There is nothing to ask the provider in that case, so the context + * carries no servers and `forProject()` goes straight to pruning. + * + * @param array $known + * @return Collection + */ + private function orphanedConnections(Project $project, ?Network $only, array $known): Collection + { + $ids = $project->networks() + ->where('type', NetworkType::PROVIDER) + ->where('status', '!=', NetworkStatus::DELETING) + ->whereNotNull('server_provider_id') + ->when($only instanceof Network, fn ($query) => $query->whereKey($only?->id)) + ->pluck('server_provider_id') + ->unique() + ->reject(fn (int $id): bool => in_array($id, $known, true)) + ->values() + ->all(); + + if ($ids === []) { + return new Collection; + } + + return ServerProvider::query()->whereIn('id', $ids)->get(); + } + + private function logFailure(PrivateNetworkSyncError $e): void + { + Log::warning('Provider private network sync failed.', [ + 'server_provider_id' => $e->serverProviderId, + 'provider' => $e->provider, + 'profile' => $e->profile, + 'status' => $e->status, + 'region' => $e->region, + 'permission_error' => $e->isPermissionError(), + ]); + } +} diff --git a/app/Actions/Network/UpdateNetwork.php b/app/Actions/Network/UpdateNetwork.php new file mode 100644 index 000000000..13c4899c3 --- /dev/null +++ b/app/Actions/Network/UpdateNetwork.php @@ -0,0 +1,42 @@ + $input + */ + public function update(Network $network, array $input): Network + { + $this->validate($network, $input); + + $network->update([ + 'name' => $input['name'] ?? $network->name, + ]); + + return $network->refresh(); + } + + /** + * @param array $input + */ + private function validate(Network $network, array $input): void + { + Validator::make($input, [ + 'name' => [ + 'sometimes', + 'required', + 'string', + 'max:255', + Rule::unique('networks', 'name') + ->where('project_id', $network->project_id) + ->ignore($network->id), + ], + ])->validate(); + } +} diff --git a/app/Actions/Network/UpdateNetworkPeer.php b/app/Actions/Network/UpdateNetworkPeer.php new file mode 100644 index 000000000..9c59e5b9c --- /dev/null +++ b/app/Actions/Network/UpdateNetworkPeer.php @@ -0,0 +1,58 @@ + $input + */ + public function update(NetworkPeer $peer, array $input): void + { + $network = $peer->network; + + Validator::make($input, [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('network_peers', 'name')->where('network_id', $network->id)->ignore($peer->id), + ], + 'enabled' => ['required', 'boolean'], + ])->validate(); + + $target = $input['enabled'] + ? ($peer->status === NetworkPeerStatus::DISABLED ? NetworkPeerStatus::PENDING : $peer->status) + : NetworkPeerStatus::DISABLED; + + $statusChanged = $target !== $peer->status; + + $peer->name = $input['name']; + $peer->status = $target; + $peer->save(); + + if ($statusChanged) { + $this->sync->resyncMembers($network); + $this->recompute->handle($network); + } + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } +} diff --git a/app/Actions/Network/UpdateNetworkServerIp.php b/app/Actions/Network/UpdateNetworkServerIp.php new file mode 100644 index 000000000..5e3b046a7 --- /dev/null +++ b/app/Actions/Network/UpdateNetworkServerIp.php @@ -0,0 +1,45 @@ + $input + */ + public function update(NetworkServer $member, array $input): NetworkServer + { + $this->validate($member, $input); + + $member->update(['server_ip_address_id' => $input['server_ip_address_id']]); + + $this->firewall->handle($member->network); + + return $member->refresh(); + } + + /** + * @param array $input + */ + private function validate(NetworkServer $member, array $input): void + { + Validator::make($input, [ + 'server_ip_address_id' => [ + 'required', + Rule::exists('server_ip_addresses', 'id') + ->where('server_id', $member->server_id) + ->where('type', IpAddressType::PRIVATE->value), + Rule::unique('network_servers', 'server_ip_address_id')->ignore($member->id), + new WithinCidrRule($member->network->cidr), + ], + ])->validate(); + } +} diff --git a/app/Actions/Server/CreateServer.php b/app/Actions/Server/CreateServer.php index b7c23d9d7..84ca85e66 100755 --- a/app/Actions/Server/CreateServer.php +++ b/app/Actions/Server/CreateServer.php @@ -110,6 +110,7 @@ private function validate(Project $project, array $input): void 'ip' => [ Rule::when(fn (): bool => isset($input['provider']) && $input['provider'] == Custom::id(), [ 'required', + 'ip', new RestrictedIPAddressesRule, ]), ], diff --git a/app/Actions/Server/EditServer.php b/app/Actions/Server/EditServer.php index 83f2450ce..824f49e04 100755 --- a/app/Actions/Server/EditServer.php +++ b/app/Actions/Server/EditServer.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Actions\Network\ResyncServerEndpoint; use App\Models\Server; use App\ValidationRules\RestrictedIPAddressesRule; use Illuminate\Support\Facades\Validator; @@ -10,6 +11,8 @@ class EditServer { + public function __construct(private ResyncServerEndpoint $resync) {} + /** * @param array $input * @return Server $server @@ -21,12 +24,14 @@ public function edit(Server $server, array $input): Server $this->validate($server, $input); $checkConnection = false; + $ipChanged = false; if (isset($input['name'])) { $server->name = $input['name']; } if (isset($input['ip'])) { if ($server->ip !== $input['ip']) { $checkConnection = true; + $ipChanged = true; } $server->ip = $input['ip']; } @@ -41,6 +46,10 @@ public function edit(Server $server, array $input): Server } $server->save(); + if ($ipChanged) { + $this->resync->handle($server); + } + if ($checkConnection) { return $server->checkConnection(); } @@ -58,12 +67,14 @@ private function validate(Server $server, array $input): void ], 'ip' => [ 'string', + 'ip', new RestrictedIPAddressesRule, Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id), ], 'local_ip' => [ 'nullable', 'string', + 'ip', Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id), ], 'port' => [ diff --git a/app/Actions/ServerIp/ManageServerIp.php b/app/Actions/ServerIp/ManageServerIp.php index f640a2877..55d4c2ae6 100644 --- a/app/Actions/ServerIp/ManageServerIp.php +++ b/app/Actions/ServerIp/ManageServerIp.php @@ -2,6 +2,7 @@ namespace App\Actions\ServerIp; +use App\Actions\Network\ResyncServerEndpoint; use App\DTOs\SocketEventDTO; use App\Enums\IpAddressFamily; use App\Enums\IpAddressStatus; @@ -67,6 +68,7 @@ public function create(Server $server, array $input): void public function setPrimary(ServerIpAddress $address): void { $server = $address->server; + $endpointChanged = $address->type !== IpAddressType::PRIVATE && $server->ip !== $address->ip; DB::transaction(function () use ($server, $address): void { if ($address->type === IpAddressType::PRIVATE) { @@ -81,6 +83,10 @@ public function setPrimary(ServerIpAddress $address): void $server->ipAddresses()->whereIn('ip', $primaryIps)->update(['is_primary' => true]); }); + if ($endpointChanged) { + app(ResyncServerEndpoint::class)->handle($server); + } + SocketEvent::dispatch(new SocketEventDTO( projectId: $server->project_id, type: 'server-ip.updated', diff --git a/app/Actions/ServerIp/RefreshServerIps.php b/app/Actions/ServerIp/RefreshServerIps.php index c12155fe6..50449273f 100644 --- a/app/Actions/ServerIp/RefreshServerIps.php +++ b/app/Actions/ServerIp/RefreshServerIps.php @@ -185,7 +185,8 @@ private function reconcile(Server $server, array $discovered, array $managedIps) $server->ipAddresses() ->where('is_managed', false) ->whereNotIn('ip', $seen) - ->delete(); + ->get() + ->each(fn (ServerIpAddress $address) => $address->delete()); }); } } diff --git a/app/Actions/Service/Install.php b/app/Actions/Service/Install.php index f83c5d93c..ffb1d9e63 100644 --- a/app/Actions/Service/Install.php +++ b/app/Actions/Service/Install.php @@ -50,10 +50,15 @@ public function install(Server $server, array $input): Service private function validate(array $input): void { + $installable = collect(config('service.services')) + ->reject(fn (array $service): bool => ($service['type'] ?? null) === 'vpn') + ->keys() + ->all(); + $rules = [ 'name' => [ 'required', - Rule::in(array_keys(config('service.services'))), + Rule::in($installable), ], 'version' => [ 'required', diff --git a/app/Console/Commands/ReconcileNetworksCommand.php b/app/Console/Commands/ReconcileNetworksCommand.php new file mode 100644 index 000000000..21eba7b4c --- /dev/null +++ b/app/Console/Commands/ReconcileNetworksCommand.php @@ -0,0 +1,215 @@ +reconcilePending(); + $this->reconcileFailed(); + $this->reconcileStaleUpdating(); + $this->reconcileLeaving(); + $this->pollHandshakes(); + } + + private function pollHandshakes(): void + { + Network::query() + ->where('type', NetworkType::WIREGUARD) + ->where('status', NetworkStatus::ACTIVE) + ->whereHas('peers', fn ($query) => $query->where('status', '!=', NetworkPeerStatus::DISABLED)) + ->get() + ->each(fn (Network $network) => dispatch(new PollPeerHandshakesJob($network))->onQueue('ssh')); + } + + private function reconcilePending(): void + { + NetworkServer::query() + ->where('status', NetworkServerStatus::PENDING) + ->whereHas('server', $this->reachable()) + ->pluck('id') + ->each(function (int $id): void { + $claimed = NetworkServer::query() + ->whereKey($id) + ->where('status', NetworkServerStatus::PENDING) + ->update(['status' => NetworkServerStatus::UPDATING]); + + if ($claimed === 1) { + $this->dispatchSync($id); + } + }); + } + + /** + * The first attempts run at the command's own cadence; past that the member drops to an + * hourly retry rather than being abandoned. A member that gave up permanently would keep + * its network `failed` forever and would never pick up a later change — a moved WireGuard + * port, say — with nothing in the UI to say a manual sync is required. + */ + private function reconcileFailed(): void + { + $slowRetry = now()->subMinutes(self::SLOW_RETRY_MINUTES); + + $due = fn ($query) => $query->where(fn ($due) => $due + ->where('sync_attempts', '<', self::MAX_ATTEMPTS) + ->orWhere('updated_at', '<', $slowRetry)); + + NetworkServer::query() + ->where('status', NetworkServerStatus::FAILED) + ->tap($due) + ->whereHas('server', $this->reachable()) + ->pluck('id') + ->each(function (int $id) use ($due): void { + $claimed = NetworkServer::query() + ->whereKey($id) + ->where('status', NetworkServerStatus::FAILED) + ->tap($due) + ->update([ + 'status' => NetworkServerStatus::UPDATING, + 'sync_attempts' => DB::raw('sync_attempts + 1'), + ]); + + if ($claimed === 1) { + $this->dispatchSync($id); + } + }); + } + + private function reconcileStaleUpdating(): void + { + $threshold = now()->subMinutes(90); + + NetworkServer::query() + ->where('status', NetworkServerStatus::UPDATING) + ->where('updated_at', '<', $threshold) + ->whereHas('server', $this->reachable()) + ->pluck('id') + ->each(function (int $id) use ($threshold): void { + $claimed = NetworkServer::query() + ->whereKey($id) + ->where('status', NetworkServerStatus::UPDATING) + ->where('updated_at', '<', $threshold) + ->update(['updated_at' => now()]); + + if ($claimed === 1) { + $this->dispatchSync($id); + } + }); + } + + /** + * On-server cleanup only works while the server is reachable, so a teardown keeps retrying — + * quickly at first, then hourly — for about a day before the membership is force-removed. + * Giving up after the fast attempts alone would abandon the tunnel config on a server that + * was merely rebooting. + */ + private function reconcileLeaving(): void + { + $threshold = now()->subMinutes(2); + $slowRetry = now()->subMinutes(self::SLOW_RETRY_MINUTES); + + NetworkServer::query() + ->where('status', NetworkServerStatus::LEAVING) + ->where('updated_at', '<', $threshold) + ->with('network', 'server') + ->get() + ->each(function (NetworkServer $member) use ($threshold, $slowRetry): void { + if ($member->sync_attempts >= self::MAX_LEAVING_ATTEMPTS) { + $this->forceConverge($member); + + return; + } + + if ($member->sync_attempts >= self::MAX_ATTEMPTS + && $member->updated_at instanceof Carbon + && $member->updated_at->greaterThan($slowRetry)) { + return; + } + + $claimed = NetworkServer::query() + ->whereKey($member->id) + ->where('status', NetworkServerStatus::LEAVING) + ->where('updated_at', '<', $threshold) + ->update([ + 'sync_attempts' => DB::raw('sync_attempts + 1'), + 'updated_at' => now(), + ]); + + if ($claimed === 1) { + dispatch(new SyncNetworkServerJob($member, true))->onQueue('ssh'); + } + }); + } + + private function forceConverge(NetworkServer $member): void + { + $network = $member->network; + $projectId = $network->project_id; + $memberId = $member->id; + + ServerLog::withNetwork($network->id, fn () => ServerLog::log( + $member->server, + 'network-leave-incomplete', + 'On-server cleanup could not complete after repeated attempts; membership force-removed. ' + .'The tunnel configuration may still exist on the server; it is cleared the next time the server syncs a network.' + )); + + $member->delete(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $projectId, + type: 'network-server.deleted', + data: ['id' => $memberId], + )); + + if ($network->type === NetworkType::WIREGUARD) { + app(DispatchNetworkServerSync::class)->resyncMembers($network); + } + + app(ApplyNetworkFirewall::class)->handle($network); + app(RecomputeNetworkStatus::class)->handle($network); + } + + private function dispatchSync(int $id): void + { + $member = NetworkServer::find($id); + if ($member instanceof NetworkServer) { + dispatch(new SyncNetworkServerJob($member))->onQueue('ssh'); + } + } + + private function reachable(): callable + { + return fn ($query) => $query->whereIn('status', [ServerStatus::READY, ServerStatus::UPDATING]); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9a5e47e2f..d2fc79e57 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -21,6 +21,7 @@ protected function schedule(Schedule $schedule): void $schedule->command('servers:check-updates')->dailyAt('02:00'); $schedule->command('servers:auto-update')->everyMinute()->withoutOverlapping(); $schedule->command('domains:check-pending')->everyFiveMinutes(); + $schedule->command('networks:reconcile')->everyThreeMinutes()->withoutOverlapping(10); $schedule->command('ssl:renew-wildcards')->daily(); $schedule->command('ssl:check-expiry')->daily(); $schedule->command('github-app:sync')->cron('0 */4 * * *'); diff --git a/app/DTOs/PrivateNetworkDTO.php b/app/DTOs/PrivateNetworkDTO.php new file mode 100644 index 000000000..91cb37e0e --- /dev/null +++ b/app/DTOs/PrivateNetworkDTO.php @@ -0,0 +1,41 @@ + */ + public array $members; + + public ?string $cidr; + + /** + * @param array $members + */ + public function __construct( + public string $externalId, + public string $name, + ?string $cidr = null, + public ?string $region = null, + array $members = [], + ) { + $this->cidr = self::normalizeCidr($cidr); + $this->members = array_values($members); + } + + /** + * Ranges reach a shell template via the firewall rules derived from them, so a malformed + * value is dropped rather than stored. Both families are accepted; the prefix must be + * explicit and within range for the family it belongs to. + */ + private static function normalizeCidr(?string $cidr): ?string + { + if ($cidr === null || $cidr === '') { + return null; + } + + return Cidr::isValid($cidr) ? Cidr::canonical($cidr) : null; + } +} diff --git a/app/DTOs/PrivateNetworkMemberDTO.php b/app/DTOs/PrivateNetworkMemberDTO.php new file mode 100644 index 000000000..c57807eb0 --- /dev/null +++ b/app/DTOs/PrivateNetworkMemberDTO.php @@ -0,0 +1,32 @@ +ip = self::normalizeIp($ip); + } + + /** + * Member addresses reach `ServerNetworkRule.source` and are interpolated into the `ufw` + * blade template, which is a shell script — Blade's escaping is HTML escaping and offers + * no protection there. Both families are accepted, but only a literal address: anything + * carrying whitespace, a prefix or shell metacharacters is dropped. + */ + private static function normalizeIp(?string $ip): ?string + { + if ($ip === null || $ip === '') { + return null; + } + + return Cidr::isValidAddress($ip) ? $ip : null; + } +} diff --git a/app/Enums/NetworkAddressingPool.php b/app/Enums/NetworkAddressingPool.php new file mode 100644 index 000000000..ba7e2a46f --- /dev/null +++ b/app/Enums/NetworkAddressingPool.php @@ -0,0 +1,28 @@ + 'info', + self::RFC1918 => 'warning', + }; + } + + public function getText(): string + { + return match ($this) { + self::CGNAT => 'CGNAT (100.64.0.0/10)', + self::RFC1918 => 'Private (RFC1918)', + }; + } +} diff --git a/app/Enums/NetworkPeerStatus.php b/app/Enums/NetworkPeerStatus.php new file mode 100644 index 000000000..93b8ec623 --- /dev/null +++ b/app/Enums/NetworkPeerStatus.php @@ -0,0 +1,27 @@ + 'info', + self::ACTIVE => 'success', + self::DISABLED => 'gray', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Enums/NetworkServerStatus.php b/app/Enums/NetworkServerStatus.php new file mode 100644 index 000000000..039b96eb1 --- /dev/null +++ b/app/Enums/NetworkServerStatus.php @@ -0,0 +1,31 @@ + 'info', + self::UPDATING, + self::LEAVING => 'warning', + self::ACTIVE => 'success', + self::FAILED => 'danger', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Enums/NetworkStatus.php b/app/Enums/NetworkStatus.php new file mode 100644 index 000000000..3cc2ca8e0 --- /dev/null +++ b/app/Enums/NetworkStatus.php @@ -0,0 +1,31 @@ + 'info', + self::SYNCING => 'warning', + self::ACTIVE => 'success', + self::DELETING, + self::FAILED => 'danger', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Enums/NetworkType.php b/app/Enums/NetworkType.php new file mode 100644 index 000000000..195fe9e2c --- /dev/null +++ b/app/Enums/NetworkType.php @@ -0,0 +1,31 @@ + 'info', + self::CUSTOM => 'warning', + self::WIREGUARD => 'success', + }; + } + + public function getText(): string + { + return match ($this) { + self::PROVIDER => 'provider', + self::CUSTOM => 'custom', + self::WIREGUARD => 'wireguard', + }; + } +} diff --git a/app/Enums/ServerNetworkRuleKind.php b/app/Enums/ServerNetworkRuleKind.php new file mode 100644 index 000000000..dbc33b139 --- /dev/null +++ b/app/Enums/ServerNetworkRuleKind.php @@ -0,0 +1,24 @@ + 'info', + self::RULE => 'success', + }; + } + + public function getText(): string + { + return $this->value; + } +} diff --git a/app/Exceptions/PrivateNetworkPersistError.php b/app/Exceptions/PrivateNetworkPersistError.php new file mode 100644 index 000000000..6dfe79e09 --- /dev/null +++ b/app/Exceptions/PrivateNetworkPersistError.php @@ -0,0 +1,25 @@ +networks, + $this->projectId, + )); + } +} diff --git a/app/Exceptions/PrivateNetworkSyncError.php b/app/Exceptions/PrivateNetworkSyncError.php new file mode 100644 index 000000000..ad60b5605 --- /dev/null +++ b/app/Exceptions/PrivateNetworkSyncError.php @@ -0,0 +1,34 @@ +provider, + $this->profile, + $this->region !== null ? ' in region '.$this->region : '', + $this->status !== null ? ' (HTTP '.$this->status.')' : '', + )); + } + + public function isPermissionError(): bool + { + return in_array($this->status, [401, 403], true); + } +} diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php index b67e1ac96..24f8ae113 100644 --- a/app/Http/Controllers/FirewallController.php +++ b/app/Http/Controllers/FirewallController.php @@ -6,6 +6,7 @@ use App\Models\FirewallRule; use App\Models\Server; use App\Tables\Servers\FirewallRuleTable; +use App\Tables\Servers\ServerNetworkRuleTable; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -28,6 +29,9 @@ public function index(Server $server): Response return Inertia::render('firewall/index', [ 'rules' => FirewallRuleTable::make($server->firewallRules())->simplePaginate(), + 'networkRules' => ServerNetworkRuleTable::make($server->networkRules()) + ->identifier('networkRules') + ->simplePaginate(), ]); } diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php new file mode 100644 index 000000000..4866b95ca --- /dev/null +++ b/app/Http/Controllers/NetworkController.php @@ -0,0 +1,190 @@ +currentProject; + + $this->authorize('viewAny', [Network::class, $project]); + + return Inertia::render('networks/index', [ + 'networks' => NetworkTable::make($project->networks())->simplePaginate(), + 'servers' => $this->serversPayload($project), + ]); + } + + #[Post('/', name: 'networks.store')] + public function store(Request $request): RedirectResponse + { + $project = user()->currentProject; + + $this->authorize('create', [Network::class, $project]); + + $network = app(CreateNetwork::class)->create($project, $request->all()); + + return redirect()->route('networks.show', $network->id) + ->with('info', 'Network is being created.'); + } + + #[Get('/{network}', name: 'networks.show')] + public function show(Network $network): Response + { + $this->authorize('view', $network); + + $network->loadCount(['servers', 'peers', 'firewallRules']); + + return Inertia::render('networks/show', [ + 'stats' => [ + 'servers' => $network->servers_count, + 'peers' => $network->peers_count, + 'firewall_rules' => $network->firewall_rules_count, + ], + 'logs' => ServerLogResource::collection($this->logsQuery($network)), + ]); + } + + /** + * @return Paginator + */ + private function logsQuery(Network $network): Paginator + { + return QueryBuilder::for($network->serverLogs()->with('server')) + ->searchableFields(['name']) + ->sortable('created_at', 'desc') + ->query() + ->simplePaginate(config('web.pagination_size')); + } + + #[Get('/{network}/servers', name: 'networks.servers')] + public function servers(Network $network): Response + { + $this->authorize('view', $network); + + $memberServerIds = $network->servers()->pluck('server_id')->all(); + + return Inertia::render('networks/servers', [ + 'members' => NetworkServerTable::make($network->servers())->identifier('members')->simplePaginate(), + 'servers' => $this->serversPayload($network->project, $memberServerIds), + 'memberIps' => $network->type === NetworkType::CUSTOM ? $this->memberIpsPayload($network) : [], + ]); + } + + private function memberIpsPayload(Network $network): AnonymousResourceCollection + { + return NetworkMemberIpResource::collection( + $network->servers() + ->with(['server', 'server.ipAddresses' => fn ($query) => $query->where('type', IpAddressType::PRIVATE)]) + ->get() + ); + } + + #[Get('/{network}/logs', name: 'networks.logs')] + public function logs(Network $network): Response + { + $this->authorize('view', $network); + + return Inertia::render('networks/logs', [ + 'logs' => ServerLogResource::collection($this->logsQuery($network)), + ]); + } + + #[Get('/{network}/settings', name: 'networks.settings')] + public function settings(Network $network): Response + { + $this->authorize('view', $network); + + return Inertia::render('networks/settings'); + } + + #[Put('/{network}', name: 'networks.update')] + public function update(Request $request, Network $network): RedirectResponse + { + $this->authorize('update', $network); + + app(UpdateNetwork::class)->update($network, $request->all()); + + return back()->with('success', 'Changes saved!'); + } + + #[Delete('/{network}', name: 'networks.destroy')] + public function destroy(Network $network): RedirectResponse + { + $this->authorize('delete', $network); + + app(DeleteNetwork::class)->delete($network); + + return redirect()->route('networks')->with('success', 'Network deleted!'); + } + + #[Post('/sync', name: 'networks.sync-providers')] + public function syncProviders(): RedirectResponse + { + $project = user()->currentProject; + + $this->authorize('create', [Network::class, $project]); + + if (! SyncProviderNetworksJob::dispatchUnlessRecent($project)) { + return back()->with('info', 'A provider sync is already in progress.'); + } + + return back()->with('info', 'Syncing networks from your cloud providers.'); + } + + #[Post('/{network}/sync', name: 'networks.sync')] + public function sync(Network $network): RedirectResponse + { + $this->authorize('update', $network); + + app(SyncNetwork::class)->network($network); + + return back()->with('info', 'Network is being synced.'); + } + + /** + * @param array $excludeServerIds + */ + private function serversPayload(Project $project, array $excludeServerIds = []): AnonymousResourceCollection + { + return NetworkServerOptionResource::collection( + $project->servers() + ->when($excludeServerIds !== [], fn ($query) => $query->whereNotIn('id', $excludeServerIds)) + ->with(['ipAddresses' => fn ($query) => $query->where('type', IpAddressType::PRIVATE)]) + ->get() + ); + } +} diff --git a/app/Http/Controllers/NetworkFirewallRuleController.php b/app/Http/Controllers/NetworkFirewallRuleController.php new file mode 100644 index 000000000..732f8f1ac --- /dev/null +++ b/app/Http/Controllers/NetworkFirewallRuleController.php @@ -0,0 +1,70 @@ +authorize('view', $network); + + return Inertia::render('networks/firewall', [ + 'rules' => NetworkFirewallRuleTable::make($network->firewallRules())->identifier('rules')->simplePaginate(), + ]); + } + + #[Post('/', name: 'networks.firewall.store')] + public function store(Request $request, Network $network): RedirectResponse + { + $this->authorize('update', $network); + + app(ManageNetworkFirewallRule::class)->create($network, $request->all()); + + return back()->with('info', 'Firewall rule is being created.'); + } + + #[Put('/{networkFirewallRule}', name: 'networks.firewall.update')] + public function update(Request $request, Network $network, NetworkFirewallRule $networkFirewallRule): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkFirewallRule); + + app(ManageNetworkFirewallRule::class)->update($networkFirewallRule, $request->all()); + + return back()->with('info', 'Firewall rule is being updated.'); + } + + #[Delete('/{networkFirewallRule}', name: 'networks.firewall.destroy')] + public function destroy(Network $network, NetworkFirewallRule $networkFirewallRule): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkFirewallRule); + + app(ManageNetworkFirewallRule::class)->delete($networkFirewallRule); + + return back()->with('info', 'Firewall rule is being deleted.'); + } + + private function ensureBelongsToNetwork(Network $network, NetworkFirewallRule $networkFirewallRule): void + { + abort_unless($networkFirewallRule->network_id === $network->id, 404); + } +} diff --git a/app/Http/Controllers/NetworkPeerController.php b/app/Http/Controllers/NetworkPeerController.php new file mode 100644 index 000000000..0e87c3454 --- /dev/null +++ b/app/Http/Controllers/NetworkPeerController.php @@ -0,0 +1,111 @@ +authorize('view', $network); + abort_unless($network->type === NetworkType::WIREGUARD, 404); + + return Inertia::render('networks/peers', [ + 'peers' => NetworkPeerTable::make($network->peers())->identifier('peers')->simplePaginate(), + ]); + } + + #[Post('/', name: 'networks.peers.store')] + public function store(Request $request, Network $network): RedirectResponse + { + $this->authorize('update', $network); + abort_unless($network->type === NetworkType::WIREGUARD, 404); + + app(CreateNetworkPeer::class)->create($network, $request->all()); + + return back()->with('info', 'Peer is being added to the network.'); + } + + #[Get('/{networkPeer}/config', name: 'networks.peers.config')] + public function config(Network $network, NetworkPeer $networkPeer): JsonResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkPeer); + + return response()->json(app(GetNetworkPeerConfig::class)->config($networkPeer)) + ->header('Cache-Control', 'no-store'); + } + + #[Post('/{networkPeer}/conceal', name: 'networks.peers.conceal')] + public function conceal(Network $network, NetworkPeer $networkPeer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkPeer); + + app(ConcealNetworkPeerKey::class)->conceal($networkPeer); + + return back(); + } + + #[Post('/{networkPeer}/regenerate', name: 'networks.peers.regenerate')] + public function regenerate(Request $request, Network $network, NetworkPeer $networkPeer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkPeer); + + app(RegenerateNetworkPeerKeys::class)->regenerate($networkPeer, $request->all()); + + return back()->with('info', 'Peer keys are being regenerated.'); + } + + #[Put('/{networkPeer}', name: 'networks.peers.update')] + public function update(Request $request, Network $network, NetworkPeer $networkPeer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkPeer); + + app(UpdateNetworkPeer::class)->update($networkPeer, $request->all()); + + return back()->with('success', 'Peer updated.'); + } + + #[Delete('/{networkPeer}', name: 'networks.peers.destroy')] + public function destroy(Network $network, NetworkPeer $networkPeer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkPeer); + + app(DeleteNetworkPeer::class)->delete($networkPeer); + + return back()->with('info', 'Peer is being removed from the network.'); + } + + private function ensureBelongsToNetwork(Network $network, NetworkPeer $networkPeer): void + { + abort_unless($networkPeer->network_id === $network->id, 404); + } +} diff --git a/app/Http/Controllers/NetworkServerController.php b/app/Http/Controllers/NetworkServerController.php new file mode 100644 index 000000000..b6808186b --- /dev/null +++ b/app/Http/Controllers/NetworkServerController.php @@ -0,0 +1,84 @@ +authorize('update', $network); + $this->ensureNotProviderManaged($network); + + $movedPort = app(AddServersToNetwork::class)->add($network, $request->all()); + + if ($movedPort !== null && $network->peers()->exists()) { + return back()->with('warning', 'Servers are being added. The listen port moved to '.$movedPort.', so every peer must download its config again to reconnect.'); + } + + return back()->with('info', 'Servers are being added to the network.'); + } + + #[Post('/{networkServer}/sync', name: 'networks.servers.sync')] + public function sync(Network $network, NetworkServer $networkServer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkServer); + $this->ensureNotProviderManaged($network); + + app(SyncNetwork::class)->member($networkServer); + + return back()->with('info', 'Server configuration is being regenerated.'); + } + + #[Put('/{networkServer}', name: 'networks.servers.update')] + public function update(Request $request, Network $network, NetworkServer $networkServer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkServer); + abort_unless($network->type === NetworkType::CUSTOM, 404); + + app(UpdateNetworkServerIp::class)->update($networkServer, $request->only('server_ip_address_id')); + + return back()->with('success', 'IP address updated.'); + } + + #[Delete('/{networkServer}', name: 'networks.servers.destroy')] + public function destroy(Network $network, NetworkServer $networkServer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkServer); + $this->ensureNotProviderManaged($network); + + app(RemoveServerFromNetwork::class)->remove($networkServer); + + return back()->with('info', 'Server is being removed from the network.'); + } + + private function ensureBelongsToNetwork(Network $network, NetworkServer $networkServer): void + { + abort_unless($networkServer->network_id === $network->id, 404); + } + + private function ensureNotProviderManaged(Network $network): void + { + abort_if($network->type === NetworkType::PROVIDER, 404); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 85ded423c..795233003 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,10 +3,12 @@ namespace App\Http\Middleware; use App\Actions\Bootstrap\GetBootstrap; +use App\Http\Resources\NetworkResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\ServerResource; use App\Http\Resources\SiteResource; use App\Http\Resources\UserResource; +use App\Models\Network; use App\Models\Server; use App\Models\Site; use App\Models\User; @@ -78,6 +80,23 @@ public function share(Request $request): array } } + if ($request->route('network')) { + /** @var Network $network */ + $network = $request->route('network'); + if ($user && $user->can('view', $network)) { + if ($user->current_project_id !== $network->project_id) { + $user->current_project_id = $network->project_id; + $user->save(); + $user->unsetRelation('currentProject'); + $currentProject = $user->currentProject; + } + + $data['network'] = fn () => NetworkResource::make( + $network->load('serverProvider')->loadCount('servers') + ); + } + } + return [ ...parent::share($request), ...$data, diff --git a/app/Http/Resources/NetworkFirewallRuleResource.php b/app/Http/Resources/NetworkFirewallRuleResource.php new file mode 100644 index 000000000..586da6535 --- /dev/null +++ b/app/Http/Resources/NetworkFirewallRuleResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'network_id' => $this->network_id, + 'name' => $this->name, + 'protocol' => $this->protocol, + 'port' => $this->port, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/NetworkMemberIpResource.php b/app/Http/Resources/NetworkMemberIpResource.php new file mode 100644 index 000000000..567de8618 --- /dev/null +++ b/app/Http/Resources/NetworkMemberIpResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'server_id' => $this->server_id, + 'server_name' => $this->whenLoaded('server', fn (): string => $this->server->name), + 'ip_address_id' => $this->server_ip_address_id, + 'private_ips' => $this->whenLoaded( + 'server', + fn () => NetworkPrivateIpResource::collection($this->server->ipAddresses) + ), + ]; + } +} diff --git a/app/Http/Resources/NetworkPeerResource.php b/app/Http/Resources/NetworkPeerResource.php new file mode 100644 index 000000000..a2a98f827 --- /dev/null +++ b/app/Http/Resources/NetworkPeerResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'network_id' => $this->network_id, + 'name' => $this->name, + 'ip' => $this->ip, + 'public_key' => $this->public_key, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'last_handshake_at' => $this->last_handshake_at, + 'byo' => $this->byo, + 'has_private_key' => $this->hasPrivateKey(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/NetworkPrivateIpResource.php b/app/Http/Resources/NetworkPrivateIpResource.php new file mode 100644 index 000000000..0cd22f2ab --- /dev/null +++ b/app/Http/Resources/NetworkPrivateIpResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'ip' => $this->ip, + 'is_primary' => $this->is_primary, + ]; + } +} diff --git a/app/Http/Resources/NetworkResource.php b/app/Http/Resources/NetworkResource.php new file mode 100644 index 000000000..b11152297 --- /dev/null +++ b/app/Http/Resources/NetworkResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'project_id' => $this->project_id, + 'name' => $this->name, + 'type' => $this->type->getText(), + 'type_value' => $this->type->value, + 'type_color' => $this->type->getColor(), + 'addressing_pool' => $this->addressing_pool->getText(), + 'cidr' => $this->cidr, + 'port' => $this->port, + 'region' => $this->region, + 'is_managed' => $this->type === NetworkType::PROVIDER, + 'is_orphaned' => $this->type === NetworkType::PROVIDER && $this->server_provider_id === null, + 'is_stranded' => app(CheckNetworkStranding::class)->handle($this->resource), + 'provider' => $this->whenLoaded('serverProvider', fn (): ?string => $this->serverProvider?->provider), + 'last_synced_at' => $this->last_synced_at, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'servers_count' => $this->whenCounted('servers'), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/NetworkServerOptionResource.php b/app/Http/Resources/NetworkServerOptionResource.php new file mode 100644 index 000000000..47d44f2d2 --- /dev/null +++ b/app/Http/Resources/NetworkServerOptionResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'is_ready' => $this->isReady(), + 'private_ips' => NetworkPrivateIpResource::collection($this->whenLoaded('ipAddresses')), + ]; + } +} diff --git a/app/Http/Resources/NetworkServerResource.php b/app/Http/Resources/NetworkServerResource.php new file mode 100644 index 000000000..247d9ab64 --- /dev/null +++ b/app/Http/Resources/NetworkServerResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'network_id' => $this->network_id, + 'server_id' => $this->server_id, + 'server_name' => $this->whenLoaded('server', fn (): string => $this->server->name), + 'ip' => $this->ip, + 'private_ip' => $this->whenLoaded('serverIpAddress', fn (): ?string => $this->serverIpAddress?->ip), + 'public_key' => $this->public_key, + 'status' => $this->status->getText(), + 'status_color' => $this->status->getColor(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ServerLogResource.php b/app/Http/Resources/ServerLogResource.php index b813464be..616dcf72a 100644 --- a/app/Http/Resources/ServerLogResource.php +++ b/app/Http/Resources/ServerLogResource.php @@ -17,7 +17,9 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'server_id' => $this->server_id, + 'server_name' => $this->whenLoaded('server', fn (): string => $this->server->name), 'site_id' => $this->site_id, + 'network_id' => $this->network_id, 'type' => $this->type, 'name' => $this->name, 'disk' => $this->disk, diff --git a/app/Jobs/Network/ApplyNetworkFirewallJob.php b/app/Jobs/Network/ApplyNetworkFirewallJob.php new file mode 100644 index 000000000..c5e6c1347 --- /dev/null +++ b/app/Jobs/Network/ApplyNetworkFirewallJob.php @@ -0,0 +1,57 @@ +run("server-{$this->member->server_id}", function (): void { + ServerLog::withNetwork($this->member->network_id, function (): void { + $service = $this->member->server->firewall(); + if (! $service instanceof Service) { + return; + } + + /** @var Firewall $handler */ + $handler = $service->handler(); + $handler->applyRules(); + }); + }); + } + + public function failed(Throwable $e): void + { + NetworkServer::query() + ->whereKey($this->member->id) + ->whereIn('status', [ + NetworkServerStatus::ACTIVE, + NetworkServerStatus::PENDING, + NetworkServerStatus::UPDATING, + ]) + ->update(['status' => NetworkServerStatus::FAILED]); + + ServerLog::withNetwork( + $this->member->network_id, + fn () => ServerLog::log($this->member->server, 'apply-network-firewall-failed', $e->getMessage()), + ); + + app(RecomputeNetworkStatus::class)->handle($this->member->network); + } +} diff --git a/app/Jobs/Network/PollPeerHandshakesJob.php b/app/Jobs/Network/PollPeerHandshakesJob.php new file mode 100644 index 000000000..1989d040d --- /dev/null +++ b/app/Jobs/Network/PollPeerHandshakesJob.php @@ -0,0 +1,100 @@ +run("network-{$this->network->id}-handshakes", function (): void { + foreach ($this->reachableMembers() as $member) { + $service = $member->server->service(WireGuard::type()); + + if (! $service instanceof Service || $service->status !== ServiceStatus::READY) { + continue; + } + + /** @var WireGuard $handler */ + $handler = $service->handler(); + + $this->apply($handler->latestHandshakes($this->network)); + + return; + } + }); + } + + public function failed(Throwable $e): void + { + $member = $this->reachableMembers()->first(); + if ($member instanceof NetworkServer) { + ServerLog::withNetwork( + $this->network->id, + fn () => ServerLog::log($member->server, 'network-handshake-poll-failed', $e->getMessage()), + ); + } + } + + /** + * @param array $handshakes + */ + private function apply(array $handshakes): void + { + foreach ($this->network->peers()->get() as $peer) { + $epoch = $handshakes[$peer->public_key] ?? 0; + if ($epoch <= 0) { + continue; + } + + $seenAt = Carbon::createFromTimestamp($epoch); + if ($peer->last_handshake_at !== null && $peer->last_handshake_at->greaterThanOrEqualTo($seenAt)) { + continue; + } + + $peer->last_handshake_at = $seenAt; + $peer->save(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $this->network->project_id, + type: 'network-peer.updated', + data: new NetworkPeerResource($peer), + )); + } + } + + /** + * @return Collection + */ + private function reachableMembers(): Collection + { + return $this->network->servers() + ->where('status', NetworkServerStatus::ACTIVE) + ->whereHas('server', fn ($query) => $query->whereIn('status', [ServerStatus::READY, ServerStatus::UPDATING])) + ->with('server') + ->orderBy('id') + ->get(); + } +} diff --git a/app/Jobs/Network/SyncNetworkServerJob.php b/app/Jobs/Network/SyncNetworkServerJob.php new file mode 100644 index 000000000..ad1aada31 --- /dev/null +++ b/app/Jobs/Network/SyncNetworkServerJob.php @@ -0,0 +1,226 @@ +run("server-{$this->member->server_id}", function (): void { + if (! $this->claim()) { + return; + } + + ServerLog::withNetwork($this->member->network_id, function (): void { + if ($this->teardown) { + $this->tearDown(); + + return; + } + + $this->syncToPresent(); + }); + }); + } + + /** + * The membership is serialised at dispatch time, so a queued job can outlive the state + * it was queued for — a removal turns a pending sync stale, and a completed teardown + * leaves no row at all. Re-read under the per-server queue lock and only proceed while + * the membership still holds the status this job was dispatched to act on. + */ + private function claim(): bool + { + $fresh = NetworkServer::query()->whereKey($this->member->id)->first(); + + if (! $fresh instanceof NetworkServer) { + return false; + } + + $expected = $this->teardown ? NetworkServerStatus::LEAVING : NetworkServerStatus::UPDATING; + + if ($fresh->status !== $expected) { + return false; + } + + $this->member = $fresh; + + return true; + } + + public function failed(Throwable $e): void + { + ServerLog::withNetwork($this->member->network_id, function () use ($e): void { + if ($this->teardown) { + ServerLog::log($this->member->server, 'network-teardown-failed', $e->getMessage()); + app(RecomputeNetworkStatus::class)->handle($this->member->network); + + return; + } + + $failed = NetworkServer::query() + ->whereKey($this->member->id) + ->whereIn('status', [ + NetworkServerStatus::ACTIVE, + NetworkServerStatus::PENDING, + NetworkServerStatus::UPDATING, + ]) + ->update(['status' => NetworkServerStatus::FAILED]); + + if ($failed === 1) { + $this->broadcastMember(); + } + + ServerLog::log($this->member->server, 'network-sync-failed', $e->getMessage()); + + app(RecomputeNetworkStatus::class)->handle($this->member->network); + }); + } + + private function syncToPresent(): void + { + $network = $this->member->network; + + if ($network->type === NetworkType::WIREGUARD) { + /** @var WireGuard $handler */ + $handler = $this->wireGuardService()->handler(); + $handler->configureNetwork($this->member); + } + + $this->applyFirewall(); + + $this->member->status = NetworkServerStatus::ACTIVE; + $this->member->sync_attempts = 0; + $this->member->save(); + + $this->broadcastMember(); + app(RecomputeNetworkStatus::class)->handle($network); + } + + private function tearDown(): void + { + $network = $this->member->network; + $server = $this->member->server; + + if ($network->type === NetworkType::WIREGUARD) { + $service = $server->service(WireGuard::type()); + if ($service instanceof Service) { + /** @var WireGuard $handler */ + $handler = $service->handler(); + $handler->removeNetwork($network); + } + + $this->maybeUninstallWireGuard($server); + } + + $this->applyFirewall(); + + $projectId = $network->project_id; + $memberId = $this->member->id; + $this->member->delete(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $projectId, + type: 'network-server.deleted', + data: ['id' => $memberId], + )); + + app(RecomputeNetworkStatus::class)->handle($network); + } + + private function applyFirewall(): void + { + app(MaterializeServerNetworkRules::class)->forServer($this->member->server); + + $service = $this->member->server->firewall(); + if (! $service instanceof Service) { + return; + } + + /** @var Firewall $handler */ + $handler = $service->handler(); + $handler->applyRules(); + } + + private function maybeUninstallWireGuard(Server $server): void + { + $hasOther = NetworkServer::query() + ->where('server_id', $server->id) + ->where('id', '!=', $this->member->id) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->exists(); + + if ($hasOther) { + return; + } + + $service = $server->service(WireGuard::type()); + if ($service instanceof Service) { + $service->handler()->uninstall(); + $service->delete(); + } + } + + private function wireGuardService(): Service + { + $server = $this->member->server; + $service = $server->service(WireGuard::type()); + + if ($service instanceof Service && $service->status === ServiceStatus::READY) { + return $service; + } + + if (! $service instanceof Service) { + /** @var Service $service */ + $service = $server->services()->create([ + 'type' => WireGuard::type(), + 'name' => WireGuard::id(), + 'version' => 'latest', + 'status' => ServiceStatus::INSTALLING, + ]); + } + + $service->newLog(); + $service->handler()->install(); + $service->status = ServiceStatus::READY; + $service->installed_version = $service->handler()->version(); + $service->save(); + + return $service; + } + + private function broadcastMember(): void + { + $this->member->refresh()->load('server', 'serverIpAddress'); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $this->member->network->project_id, + type: 'network-server.updated', + data: new NetworkServerResource($this->member), + )); + } +} diff --git a/app/Jobs/Network/SyncProviderNetworksJob.php b/app/Jobs/Network/SyncProviderNetworksJob.php new file mode 100644 index 000000000..3c9c9d38b --- /dev/null +++ b/app/Jobs/Network/SyncProviderNetworksJob.php @@ -0,0 +1,98 @@ +id : 'all'; + $key = 'provider-networks:'.$project->id.':'.$scope; + + if (! Cache::add($key, true, 30)) { + return false; + } + + dispatch(new self($project, $network)); + + return true; + } + + protected function lockSeconds(): int + { + return $this->timeout + 60; + } + + public function handle(): void + { + $this->run("provider-networks-{$this->project->id}", function (): void { + app(SyncProviderNetworks::class)->forProject($this->project, $this->network); + }); + } + + /** + * The sweep is user-triggered from a button that only flashes "syncing", so a failure has + * to be surfaced somewhere the user will see it rather than only in the job log. + */ + public function failed(Throwable $e): void + { + Log::warning('Provider network sync job failed.', [ + 'project_id' => $this->project->id, + 'network_id' => $this->network?->id, + 'exception' => $e::class, + 'reason' => $this->safeReason($e), + ]); + + Notifier::send($this->project, new GenericNotification( + __('Could not sync private networks from your cloud providers. Check the provider connection and its permissions.') + )); + } + + /** + * Only the sweep's own exceptions are built to be credential-free. Anything else — a driver + * or HTTP client exception, say — can carry a token or a connection string in its message, + * and this log is written verbatim. + */ + private function safeReason(Throwable $e): ?string + { + return $e instanceof PrivateNetworkSyncError || $e instanceof PrivateNetworkPersistError + ? $e->getMessage() + : null; + } +} diff --git a/app/Models/Network.php b/app/Models/Network.php new file mode 100644 index 000000000..1c99e481b --- /dev/null +++ b/app/Models/Network.php @@ -0,0 +1,119 @@ + $servers + * @property Collection $firewallRules + * @property Collection $peers + * @property Collection $serverRules + * @property Collection $serverLogs + */ +class Network extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'project_id', + 'name', + 'type', + 'status', + 'addressing_pool', + 'cidr', + 'cidr_canonical', + 'port', + 'region', + ]; + + protected $casts = [ + 'project_id' => 'integer', + 'port' => 'integer', + 'server_provider_id' => 'integer', + 'last_synced_at' => 'datetime', + 'type' => NetworkType::class, + 'status' => NetworkStatus::class, + 'addressing_pool' => NetworkAddressingPool::class, + ]; + + /** + * @return BelongsTo + */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** + * @return BelongsTo + */ + public function serverProvider(): BelongsTo + { + return $this->belongsTo(ServerProvider::class); + } + + /** + * @return HasMany + */ + public function servers(): HasMany + { + return $this->hasMany(NetworkServer::class); + } + + /** + * @return HasMany + */ + public function firewallRules(): HasMany + { + return $this->hasMany(NetworkFirewallRule::class); + } + + /** + * @return HasMany + */ + public function serverRules(): HasMany + { + return $this->hasMany(ServerNetworkRule::class); + } + + /** + * @return HasMany + */ + public function peers(): HasMany + { + return $this->hasMany(NetworkPeer::class); + } + + /** + * @return HasMany + */ + public function serverLogs(): HasMany + { + return $this->hasMany(ServerLog::class); + } +} diff --git a/app/Models/NetworkFirewallRule.php b/app/Models/NetworkFirewallRule.php new file mode 100644 index 000000000..1b973053c --- /dev/null +++ b/app/Models/NetworkFirewallRule.php @@ -0,0 +1,55 @@ + $serverRules + */ +class NetworkFirewallRule extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'network_id', + 'name', + 'protocol', + 'port', + 'status', + ]; + + protected $casts = [ + 'network_id' => 'integer', + 'status' => FirewallRuleStatus::class, + ]; + + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } + + /** + * @return HasMany + */ + public function serverRules(): HasMany + { + return $this->hasMany(ServerNetworkRule::class); + } +} diff --git a/app/Models/NetworkPeer.php b/app/Models/NetworkPeer.php new file mode 100644 index 000000000..19aa91b4e --- /dev/null +++ b/app/Models/NetworkPeer.php @@ -0,0 +1,69 @@ + */ + use HasFactory; + + protected $fillable = [ + 'network_id', + 'name', + 'ip', + 'public_key', + 'private_key', + 'byo', + 'status', + 'last_handshake_at', + ]; + + protected $casts = [ + 'network_id' => 'integer', + 'status' => NetworkPeerStatus::class, + 'private_key' => 'encrypted', + 'byo' => 'boolean', + 'last_handshake_at' => 'datetime', + 'sync_attempts' => 'integer', + ]; + + protected $hidden = [ + 'private_key', + ]; + + /** + * Whether Vito still holds this peer's private key. False once the key has been revealed + * and concealed, and always false for peers that brought their own key. + */ + public function hasPrivateKey(): bool + { + return ! $this->byo && $this->private_key !== null; + } + + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } +} diff --git a/app/Models/NetworkServer.php b/app/Models/NetworkServer.php new file mode 100644 index 000000000..1a8fad7f9 --- /dev/null +++ b/app/Models/NetworkServer.php @@ -0,0 +1,85 @@ + */ + use HasFactory; + + protected $fillable = [ + 'network_id', + 'server_id', + 'server_ip_address_id', + 'ip', + 'public_key', + 'private_key', + 'status', + 'sync_attempts', + ]; + + protected $casts = [ + 'network_id' => 'integer', + 'server_id' => 'integer', + 'server_ip_address_id' => 'integer', + 'sync_attempts' => 'integer', + 'status' => NetworkServerStatus::class, + 'private_key' => 'encrypted', + ]; + + protected $hidden = [ + 'private_key', + ]; + + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } + + /** + * @return BelongsTo + */ + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + /** + * @return BelongsTo + */ + public function serverIpAddress(): BelongsTo + { + return $this->belongsTo(ServerIpAddress::class); + } + + /** + * @return HasMany + */ + public function rules(): HasMany + { + return $this->hasMany(ServerNetworkRule::class); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index e7058b6b3..ff623e363 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -27,6 +27,7 @@ * @property Collection $registeredUsers * @property Collection $workflows * @property Collection $domains + * @property Collection $networks */ class Project extends Model { @@ -126,4 +127,12 @@ public function domains(): HasMany { return $this->hasMany(Domain::class); } + + /** + * @return HasMany + */ + public function networks(): HasMany + { + return $this->hasMany(Network::class); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 7596fc5aa..c631bb0bc 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\Network\ResyncNetworkSiblings; use App\Actions\Server\CheckConnection; use App\Enums\OperatingSystem; use App\Enums\SecurityControlStatus; @@ -124,10 +125,25 @@ class Server extends AbstractModel public bool $deleteFromProvider = true; + /** + * Carried between the two events because the membership rows are gone by the second. + * + * @var array{members: array, networks: array} + */ + protected array $networkDeparture = ['members' => [], 'networks' => []]; + public static function boot(): void { parent::boot(); + static::deleting(function (Server $server): void { + $server->networkDeparture = app(ResyncNetworkSiblings::class)->capture($server); + }); + + static::deleted(function (Server $server): void { + app(ResyncNetworkSiblings::class)->handle($server->networkDeparture); + }); + static::deleting(function (Server $server): void { DB::beginTransaction(); try { @@ -275,6 +291,14 @@ public function firewallRules(): HasMany return $this->hasMany(FirewallRule::class); } + /** + * @return HasMany + */ + public function networkRules(): HasMany + { + return $this->hasMany(ServerNetworkRule::class); + } + /** * @return HasMany */ diff --git a/app/Models/ServerIpAddress.php b/app/Models/ServerIpAddress.php index 372510fc5..f9d8fdb00 100644 --- a/app/Models/ServerIpAddress.php +++ b/app/Models/ServerIpAddress.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\Network\RemoveMembershipsForAddress; use App\Enums\IpAddressFamily; use App\Enums\IpAddressStatus; use App\Enums\IpAddressType; @@ -45,6 +46,25 @@ class ServerIpAddress extends AbstractModel 'status' => IpAddressStatus::class, ]; + /** + * Carried between the two events because the foreign key is nullOnDelete, so by the second + * nothing records which memberships this address backed. + * + * @var array + */ + protected array $reapplyNetworkIds = []; + + protected static function booted(): void + { + static::deleting(function (ServerIpAddress $address): void { + $address->reapplyNetworkIds = app(RemoveMembershipsForAddress::class)->capture($address); + }); + + static::deleted(function (ServerIpAddress $address): void { + app(RemoveMembershipsForAddress::class)->handle($address->server_id, $address->reapplyNetworkIds); + }); + } + /** * @return BelongsTo */ diff --git a/app/Models/ServerLog.php b/app/Models/ServerLog.php index 3ac190b2d..fa21fa040 100755 --- a/app/Models/ServerLog.php +++ b/app/Models/ServerLog.php @@ -6,6 +6,7 @@ use App\Events\SocketEvent; use App\Http\Resources\ServerLogResource; use App\SSH\OS\OS; +use Closure; use Database\Factories\ServerLogFactory; use Exception; use Illuminate\Database\Eloquent\Builder; @@ -21,21 +22,26 @@ /** * @property int $server_id * @property ?int $site_id + * @property ?int $network_id * @property string $type * @property string $name * @property string $disk * @property bool $is_remote * @property Server $server * @property ?Site $site + * @property ?Network $network */ class ServerLog extends AbstractModel { /** @use HasFactory */ use HasFactory; + private static ?int $networkContext = null; + protected $fillable = [ 'server_id', 'site_id', + 'network_id', 'type', 'name', 'disk', @@ -45,9 +51,32 @@ class ServerLog extends AbstractModel protected $casts = [ 'server_id' => 'integer', 'site_id' => 'integer', + 'network_id' => 'integer', 'is_remote' => 'boolean', ]; + /** + * Associate every ServerLog created inside $callback with $networkId. Lets + * logs produced deep in the SSH/Service layers during a network sync be + * tagged without threading the network through every call site. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + public static function withNetwork(?int $networkId, Closure $callback): mixed + { + $previous = self::$networkContext; + self::$networkContext = $networkId; + + try { + return $callback(); + } finally { + self::$networkContext = $previous; + } + } + public static function boot(): void { parent::boot(); @@ -98,6 +127,14 @@ public function site(): BelongsTo return $this->belongsTo(Site::class); } + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } + /** * @throws Throwable */ @@ -193,6 +230,7 @@ public static function log(Server $server, string $type, string $content, ?Site $log = new self([ 'server_id' => $server->id, 'site_id' => $site?->id, + 'network_id' => self::$networkContext, 'name' => $server->id.'-'.strtotime('now').'-'.$type.'.log', 'type' => $type, 'disk' => config('core.logs_disk'), @@ -207,6 +245,7 @@ public static function newLog(Server $server, string $type): ServerLog { return new self([ 'server_id' => $server->id, + 'network_id' => self::$networkContext, 'name' => $server->id.'-'.strtotime('now').'-'.$type.'.log', 'type' => $type, 'disk' => config('core.logs_disk'), diff --git a/app/Models/ServerNetworkRule.php b/app/Models/ServerNetworkRule.php new file mode 100644 index 000000000..3c1b7f117 --- /dev/null +++ b/app/Models/ServerNetworkRule.php @@ -0,0 +1,110 @@ + 'integer', + 'network_id' => 'integer', + 'network_server_id' => 'integer', + 'network_firewall_rule_id' => 'integer', + 'mask' => 'integer', + 'kind' => ServerNetworkRuleKind::class, + 'status' => FirewallRuleStatus::class, + ]; + + /** + * Network rows first (handshakes, then rules by network), stable by id. + * + * @param Builder|QueryBuilder|Relation $query + */ + public static function applyOrder(Builder|QueryBuilder|Relation $query): void + { + $query + ->orderByRaw('case when kind = ? then 0 else 1 end', [ServerNetworkRuleKind::HANDSHAKE->value]) + ->orderBy('network_id') + ->orderBy('id'); + } + + /** + * @param Builder $query + */ + public function scopeOrdered(Builder $query): void + { + self::applyOrder($query); + } + + /** + * @return BelongsTo + */ + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } + + /** + * @return BelongsTo + */ + public function networkServer(): BelongsTo + { + return $this->belongsTo(NetworkServer::class); + } + + /** + * @return BelongsTo + */ + public function networkFirewallRule(): BelongsTo + { + return $this->belongsTo(NetworkFirewallRule::class); + } +} diff --git a/app/Policies/NetworkPolicy.php b/app/Policies/NetworkPolicy.php new file mode 100644 index 000000000..7bed0bd6b --- /dev/null +++ b/app/Policies/NetworkPolicy.php @@ -0,0 +1,53 @@ +hasReadAccess($user, $project); + } + + public function view(User $user, Network $network): bool + { + return $this->hasReadAccess($user, $network->project); + } + + public function create(User $user, Project $project): bool + { + return $this->hasWriteAccess($user, $project); + } + + public function update(User $user, Network $network): bool + { + return $this->hasWriteAccess($user, $network->project); + } + + /** + * Provider-managed networks mirror the cloud provider and are removed by sync when the + * VPC disappears. One that sync can never reach again — its connection deleted, or no + * member still identifiable at the provider — stays deletable to avoid stranding the row. + */ + public function delete(User $user, Network $network): bool + { + if ($network->type === NetworkType::PROVIDER + && $network->server_provider_id !== null + && ! app(CheckNetworkStranding::class)->handle($network)) { + return false; + } + + return $this->hasWriteAccess($user, $network->project); + } +} diff --git a/app/Providers/ServiceTypeServiceProvider.php b/app/Providers/ServiceTypeServiceProvider.php index c7b4ee96f..c7aefae5e 100644 --- a/app/Providers/ServiceTypeServiceProvider.php +++ b/app/Providers/ServiceTypeServiceProvider.php @@ -16,6 +16,7 @@ use App\Services\ProcessManager\Supervisor; use App\Services\Redis\Redis; use App\Services\Valkey\Valkey; +use App\Services\VPN\WireGuard; use App\Services\Webserver\Caddy; use App\Services\Webserver\Nginx; use Illuminate\Support\ServiceProvider; @@ -36,6 +37,7 @@ public function boot(): void $this->logAnalysis(); $this->php(); $this->node(); + $this->vpn(); } private function webservers(): void @@ -293,4 +295,13 @@ private function node(): void ]) ->register(); } + + private function vpn(): void + { + RegisterServiceType::make(WireGuard::id()) + ->type(WireGuard::type()) + ->label('WireGuard') + ->handler(WireGuard::class) + ->register(); + } } diff --git a/app/ServerProviders/AWS.php b/app/ServerProviders/AWS.php index 7c934138f..838920b6f 100755 --- a/app/ServerProviders/AWS.php +++ b/app/ServerProviders/AWS.php @@ -2,6 +2,8 @@ namespace App\ServerProviders; +use App\DTOs\PrivateNetworkDTO; +use App\DTOs\PrivateNetworkMemberDTO; use App\Enums\OperatingSystem; use App\Exceptions\CouldNotConnectToProvider; use App\Facades\Notifier; @@ -12,7 +14,7 @@ use Illuminate\Support\Facades\Storage; use Throwable; -class AWS extends AbstractProvider +class AWS extends AbstractProvider implements ProvidesPrivateNetworks { protected Ec2Client $ec2Client; @@ -21,6 +23,292 @@ public static function id(): string return 'aws'; } + public function instanceIdKey(): string + { + return 'instance_id'; + } + + /** + * EC2 is queried per region, so a region that was never collected is never asked about. With + * no regions at all, or with a server whose region is unknown, the result is incomplete for + * want of asking rather than because the VPCs are gone — saying so keeps sync from pruning + * a network whose members live in a region this run could not reach. + * + * @param array $regions + */ + public function canDiscoverPrivateNetworks(array $regions, int $serversWithoutRegion): bool + { + return $regions !== [] && $serversWithoutRegion === 0; + } + + /** + * EC2 is regional, so each region is queried with its own client. A region failure aborts + * the whole connection rather than returning partial results: the caller only skips + * pruning per connection, and returning a partial view would let it delete networks that + * live in the region that failed. + */ + public function privateNetworks(array $instanceIds, array $regions): array + { + $result = []; + + foreach ($regions as $region) { + try { + $client = $this->networkClient($region); + + $reservations = $this->paginate($client, 'DescribeInstances', [ + 'Filters' => [ + ['Name' => 'instance-id', 'Values' => array_values($instanceIds)], + ], + ], 'Reservations'); + + $vpcIds = $this->vpcIdsFrom($reservations); + + $vpcs = $vpcIds === [] ? [] : $this->paginate($client, 'DescribeVpcs', [ + 'Filters' => [ + ['Name' => 'vpc-id', 'Values' => $vpcIds], + ], + ], 'Vpcs'); + } catch (Throwable) { + throw $this->syncError(null, $region); + } + + foreach ($this->mapPrivateNetworks($reservations, $vpcs, $instanceIds, $region) as $network) { + $result[] = $network; + } + } + + return $result; + } + + /** + * Public so it can be exercised without an EC2 client — the SDK has no HTTP-level fake + * equivalent to `Http::fake()` in this codebase. Not part of the provider contract. + * + * @internal + * + * @param array> $reservations + * @param array> $vpcs + * @param array $instanceIds + * @return array + */ + public function mapPrivateNetworks(array $reservations, array $vpcs, array $instanceIds, ?string $region = null): array + { + $wanted = array_flip($instanceIds); + $members = []; + + foreach ($this->instancesFrom($reservations) as $instance) { + $instanceId = (string) ($instance['InstanceId'] ?? ''); + + if ($instanceId === '' || ! isset($wanted[$instanceId])) { + continue; + } + + [$vpcId, $ip] = $this->placementOf($instance); + + if ($vpcId === null) { + continue; + } + + $members[$vpcId][] = new PrivateNetworkMemberDTO(instanceId: $instanceId, ip: $ip); + } + + $result = []; + + foreach ($vpcs as $vpc) { + $vpcId = (string) ($vpc['VpcId'] ?? ''); + + if (! isset($members[$vpcId])) { + continue; + } + + $result[] = new PrivateNetworkDTO( + externalId: $vpcId, + name: $this->nameOf($vpc, $vpcId), + cidr: $this->cidrOf($vpc), + region: $region, + members: $members[$vpcId], + ); + } + + return $result; + } + + /** + * Reads the primary network interface (device index 0), falling back to the first usable + * one and then to the instance's top-level fields, both of which are optional. On a + * multi-ENI instance the interfaces are not returned in a guaranteed order, so taking the + * first would risk reporting a secondary interface's address. + * + * @param array $instance + * @return array{0: ?string, 1: ?string} + */ + private function placementOf(array $instance): array + { + $interfaces = $instance['NetworkInterfaces'] ?? []; + + $primary = null; + + foreach ($interfaces as $interface) { + if ((int) ($interface['Attachment']['DeviceIndex'] ?? -1) === 0) { + $primary = $interface; + + break; + } + } + + foreach ($primary !== null ? [$primary] : $interfaces as $interface) { + $vpcId = $interface['VpcId'] ?? null; + + if (is_string($vpcId) && $vpcId !== '') { + return [$vpcId, $this->addressOf($interface)]; + } + } + + $vpcId = $instance['VpcId'] ?? null; + + return [ + is_string($vpcId) && $vpcId !== '' ? $vpcId : null, + $this->addressOf($instance), + ]; + } + + /** + * A VPC always carries an IPv4 range unless it was created IPv6-only, in which case its + * range lives in the association set. A dual-stack VPC is recorded by its IPv4 range, + * since a network holds a single range. + * + * @param array $vpc + */ + private function cidrOf(array $vpc): ?string + { + $cidr = $vpc['CidrBlock'] ?? null; + + if (is_string($cidr) && $cidr !== '') { + return $cidr; + } + + foreach ($vpc['Ipv6CidrBlockAssociationSet'] ?? [] as $association) { + $cidr = $association['Ipv6CidrBlock'] ?? null; + + if (is_string($cidr) && $cidr !== '') { + return $cidr; + } + } + + return null; + } + + /** + * An IPv6-only instance has no `PrivateIpAddress`, so its address has to be read from the + * IPv6 fields — without this it would join its network with no address at all. + * + * @param array $source + */ + private function addressOf(array $source): ?string + { + $ip = $source['PrivateIpAddress'] ?? null; + + if (is_string($ip) && $ip !== '') { + return $ip; + } + + foreach ($source['Ipv6Addresses'] ?? [] as $address) { + $ipv6 = $address['Ipv6Address'] ?? null; + + if (is_string($ipv6) && $ipv6 !== '') { + return $ipv6; + } + } + + $ipv6 = $source['Ipv6Address'] ?? null; + + return is_string($ipv6) && $ipv6 !== '' ? $ipv6 : null; + } + + /** + * @param array> $reservations + * @return array> + */ + private function instancesFrom(array $reservations): array + { + $instances = []; + + foreach ($reservations as $reservation) { + /** @var array> $batch */ + $batch = $reservation['Instances'] ?? []; + $instances = array_merge($instances, $batch); + } + + return $instances; + } + + /** + * @param array> $reservations + * @return array + */ + private function vpcIdsFrom(array $reservations): array + { + $ids = []; + + foreach ($this->instancesFrom($reservations) as $instance) { + [$vpcId] = $this->placementOf($instance); + + if ($vpcId !== null && ! in_array($vpcId, $ids, true)) { + $ids[] = $vpcId; + } + } + + return $ids; + } + + /** + * @param array $vpc + */ + private function nameOf(array $vpc, string $fallback): string + { + foreach ($vpc['Tags'] ?? [] as $tag) { + if (($tag['Key'] ?? null) === 'Name' && is_string($tag['Value'] ?? null) && $tag['Value'] !== '') { + return $tag['Value']; + } + } + + return $fallback; + } + + /** + * EC2 list calls are paged. Reading only the first page would make instances beyond it look + * detached, and sync would then remove them from their network. + * + * @param array $args + * @return array> + */ + private function paginate(Ec2Client $client, string $operation, array $args, string $key): array + { + $items = []; + + foreach ($client->getPaginator($operation, $args) as $page) { + /** @var array> $batch */ + $batch = $page->get($key) ?? []; + $items = array_merge($items, $batch); + } + + return $items; + } + + private function networkClient(string $region): Ec2Client + { + $credentials = $this->serverProvider->getCredentials(); + + return new Ec2Client([ + 'region' => $region, + 'version' => '2016-11-15', + 'credentials' => [ + 'key' => $credentials['key'], + 'secret' => $credentials['secret'], + ], + ]); + } + public function createRules(array $input): array { return [ diff --git a/app/ServerProviders/AbstractProvider.php b/app/ServerProviders/AbstractProvider.php index 3bdedf684..a3a628441 100755 --- a/app/ServerProviders/AbstractProvider.php +++ b/app/ServerProviders/AbstractProvider.php @@ -2,6 +2,7 @@ namespace App\ServerProviders; +use App\Exceptions\PrivateNetworkSyncError; use App\Models\Server; use App\Models\ServerProvider as Provider; use Illuminate\Filesystem\FilesystemAdapter; @@ -22,6 +23,30 @@ public function availablePlans(?string $region): array ->all(); } + /** + * @param array $regions + */ + public function canDiscoverPrivateNetworks(array $regions, int $serversWithoutRegion): bool + { + return true; + } + + /** + * Convert any upstream failure into a credential-free exception. Upstream + * HTTP/SDK exceptions can carry tokens in their message or trace arguments, + * which the queue would persist into `failed_jobs.exception`. + */ + protected function syncError(?int $status = null, ?string $region = null): PrivateNetworkSyncError + { + return new PrivateNetworkSyncError( + serverProviderId: $this->serverProvider->id, + provider: static::id(), + profile: $this->serverProvider->profile, + status: $status, + region: $region, + ); + } + public function generateKeyPair(): void { /** @var FilesystemAdapter $storageDisk */ diff --git a/app/ServerProviders/Custom.php b/app/ServerProviders/Custom.php index 0aa085c3c..46698c3ac 100755 --- a/app/ServerProviders/Custom.php +++ b/app/ServerProviders/Custom.php @@ -22,6 +22,7 @@ public function createRules(array $input): array return [ 'ip' => [ 'required', + 'ip', Rule::unique('servers', 'ip'), new RestrictedIPAddressesRule, ], diff --git a/app/ServerProviders/DigitalOcean.php b/app/ServerProviders/DigitalOcean.php index ee1153895..aa96ca60a 100644 --- a/app/ServerProviders/DigitalOcean.php +++ b/app/ServerProviders/DigitalOcean.php @@ -2,8 +2,11 @@ namespace App\ServerProviders; +use App\DTOs\PrivateNetworkDTO; +use App\DTOs\PrivateNetworkMemberDTO; use App\Enums\OperatingSystem; use App\Exceptions\CouldNotConnectToProvider; +use App\Exceptions\PrivateNetworkSyncError; use App\Exceptions\ServerProviderError; use App\Facades\Notifier; use App\Notifications\FailedToDeleteServerFromProvider; @@ -11,15 +14,137 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -class DigitalOcean extends AbstractProvider +class DigitalOcean extends AbstractProvider implements ProvidesPrivateNetworks { protected string $apiUrl = 'https://api.digitalocean.com/v2'; + private const MAX_PAGES = 100; + public static function id(): string { return 'digitalocean'; } + public function instanceIdKey(): string + { + return 'droplet_id'; + } + + public function privateNetworks(array $instanceIds, array $regions): array + { + return $this->mapPrivateNetworks( + $this->fetchAll('/vpcs', 'vpcs'), + $this->fetchAll('/droplets', 'droplets'), + $instanceIds, + ); + } + + /** + * Droplet objects carry `vpc_uuid` and their private `networks.v4` entry, so membership + * and addresses come from one list call. `/vpcs/{id}/members` returns URNs with no IP and + * would need a fetch per droplet. + * + * @param array> $vpcs + * @param array> $droplets + * @param array $instanceIds + * @return array + */ + private function mapPrivateNetworks(array $vpcs, array $droplets, array $instanceIds): array + { + $wanted = array_flip($instanceIds); + $members = []; + + foreach ($droplets as $droplet) { + $dropletId = (string) ($droplet['id'] ?? ''); + $vpcId = $droplet['vpc_uuid'] ?? null; + + if (! isset($wanted[$dropletId]) || ! is_string($vpcId) || $vpcId === '') { + continue; + } + + $ip = null; + + foreach ($droplet['networks']['v4'] ?? [] as $address) { + if (($address['type'] ?? null) === 'private' && isset($address['ip_address'])) { + $ip = (string) $address['ip_address']; + + break; + } + } + + $members[$vpcId][] = new PrivateNetworkMemberDTO(instanceId: $dropletId, ip: $ip); + } + + $result = []; + + foreach ($vpcs as $vpc) { + $vpcId = (string) ($vpc['id'] ?? ''); + + if (! isset($members[$vpcId])) { + continue; + } + + $result[] = new PrivateNetworkDTO( + externalId: $vpcId, + name: (string) ($vpc['name'] ?? $vpcId), + cidr: isset($vpc['ip_range']) ? (string) $vpc['ip_range'] : null, + region: isset($vpc['region']) ? (string) $vpc['region'] : null, + members: $members[$vpcId], + ); + } + + return $result; + } + + /** + * @return array> + * + * @throws PrivateNetworkSyncError + */ + private function fetchAll(string $path, string $key): array + { + $token = $this->serverProvider->getCredentials()['token']; + $items = []; + $page = 1; + + do { + try { + $response = Http::withToken($token)->get($this->apiUrl.$path, [ + 'per_page' => 200, + 'page' => $page, + ]); + } catch (Exception) { + throw $this->syncError(); + } + + if (! $response->ok()) { + throw $this->syncError($response->status()); + } + + $body = $response->json(); + + if (! is_array($body) || ! is_array($body[$key] ?? null)) { + throw $this->syncError($response->status()); + } + + /** @var array> $batch */ + $batch = $body[$key]; + $items = array_merge($items, $batch); + + if (! isset($body['links']['pages']['next'])) { + break; + } + + if ($page >= self::MAX_PAGES) { + throw $this->syncError(); + } + + $page++; + } while (true); + + return $items; + } + public function createRules(array $input): array { return [ diff --git a/app/ServerProviders/Hetzner.php b/app/ServerProviders/Hetzner.php index d5fec572d..ebb98e135 100644 --- a/app/ServerProviders/Hetzner.php +++ b/app/ServerProviders/Hetzner.php @@ -2,7 +2,10 @@ namespace App\ServerProviders; +use App\DTOs\PrivateNetworkDTO; +use App\DTOs\PrivateNetworkMemberDTO; use App\Exceptions\CouldNotConnectToProvider; +use App\Exceptions\PrivateNetworkSyncError; use App\Exceptions\ServerProviderError; use App\Facades\Notifier; use App\Notifications\FailedToDeleteServerFromProvider; @@ -12,7 +15,7 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; -class Hetzner extends AbstractProvider +class Hetzner extends AbstractProvider implements ProvidesPrivateNetworks { protected string $apiUrl = 'https://api.hetzner.cloud/v1'; @@ -21,6 +24,132 @@ public static function id(): string return 'hetzner'; } + public function instanceIdKey(): string + { + return 'hetzner_id'; + } + + public function privateNetworks(array $instanceIds, array $regions): array + { + return $this->mapPrivateNetworks( + $this->fetchAll('/networks', 'networks'), + $this->fetchAll('/servers', 'servers'), + $instanceIds, + ); + } + + /** + * `/networks` already carries `servers[]` (attached ids), so membership needs no + * extra call; `/servers` is read only for the per-network private IPs. + * + * @param array> $networks + * @param array> $servers + * @param array $instanceIds + * @return array + */ + private function mapPrivateNetworks(array $networks, array $servers, array $instanceIds): array + { + $wanted = array_flip($instanceIds); + $ips = []; + + foreach ($servers as $server) { + $serverId = (string) ($server['id'] ?? ''); + + foreach ($server['private_net'] ?? [] as $attachment) { + if (isset($attachment['network'], $attachment['ip'])) { + $ips[(string) $attachment['network']][$serverId] = (string) $attachment['ip']; + } + } + } + + $result = []; + + foreach ($networks as $network) { + $networkId = (string) ($network['id'] ?? ''); + $members = []; + + foreach ($network['servers'] ?? [] as $attachedId) { + $attachedId = (string) $attachedId; + + if (! isset($wanted[$attachedId])) { + continue; + } + + $members[] = new PrivateNetworkMemberDTO( + instanceId: $attachedId, + ip: $ips[$networkId][$attachedId] ?? null, + ); + } + + if ($members === []) { + continue; + } + + $result[] = new PrivateNetworkDTO( + externalId: $networkId, + name: (string) ($network['name'] ?? $networkId), + cidr: isset($network['ip_range']) ? (string) $network['ip_range'] : null, + region: isset($network['subnets'][0]['network_zone']) + ? (string) $network['subnets'][0]['network_zone'] + : null, + members: $members, + ); + } + + return $result; + } + + /** + * @return array> + * + * @throws PrivateNetworkSyncError + */ + private function fetchAll(string $path, string $key): array + { + $token = $this->serverProvider->getCredentials()['token']; + $items = []; + $page = 1; + + do { + try { + $response = Http::withToken($token)->get($this->apiUrl.$path, [ + 'per_page' => 50, + 'page' => $page, + ]); + } catch (Exception) { + throw $this->syncError(); + } + + if (! $response->ok()) { + throw $this->syncError($response->status()); + } + + $body = $response->json(); + + if (! is_array($body) || ! is_array($body[$key] ?? null)) { + throw $this->syncError($response->status()); + } + + /** @var array> $batch */ + $batch = $body[$key]; + $items = array_merge($items, $batch); + + $next = $body['meta']['pagination']['next_page'] ?? null; + + if ($next === null) { + break; + } + + if (! is_numeric($next) || (int) $next <= $page) { + throw $this->syncError(); + } + + $page = (int) $next; + } while (true); + + return $items; + } + public function createRules(array $input): array { return [ diff --git a/app/ServerProviders/Linode.php b/app/ServerProviders/Linode.php index 4daf41469..51590bea1 100644 --- a/app/ServerProviders/Linode.php +++ b/app/ServerProviders/Linode.php @@ -2,7 +2,10 @@ namespace App\ServerProviders; +use App\DTOs\PrivateNetworkDTO; +use App\DTOs\PrivateNetworkMemberDTO; use App\Exceptions\CouldNotConnectToProvider; +use App\Exceptions\PrivateNetworkSyncError; use App\Exceptions\ServerProviderError; use App\Facades\Notifier; use App\Notifications\FailedToDeleteServerFromProvider; @@ -10,15 +13,153 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -class Linode extends AbstractProvider +class Linode extends AbstractProvider implements ProvidesPrivateNetworks { protected string $apiUrl = 'https://api.linode.com/v4'; + private const MAX_PAGES = 100; + public static function id(): string { return 'linode'; } + public function instanceIdKey(): string + { + return 'linode_id'; + } + + public function privateNetworks(array $instanceIds, array $regions): array + { + return $this->mapPrivateNetworks( + $this->fetchAll('/vpcs'), + $this->fetchAll('/vpcs/ips'), + $instanceIds, + ); + } + + /** + * `/vpcs` carries `subnets[].linodes[]`, so membership needs no extra call; `/vpcs/ips` + * is the account-wide address list. A linode can hold several VPC addresses, and an entry + * may carry only `address_range`, so the first active entry with a plain address wins. + * + * @param array> $vpcs + * @param array> $addresses + * @param array $instanceIds + * @return array + */ + private function mapPrivateNetworks(array $vpcs, array $addresses, array $instanceIds): array + { + $wanted = array_flip($instanceIds); + $ips = []; + + foreach ($addresses as $entry) { + $linodeId = (string) ($entry['linode_id'] ?? ''); + $vpcId = (string) ($entry['vpc_id'] ?? ''); + $address = $entry['address'] ?? null; + + if ($linodeId === '' || $vpcId === '' || ! is_string($address) || $address === '') { + continue; + } + + if (($entry['active'] ?? true) === false) { + continue; + } + + $ips[$vpcId][$linodeId] ??= $address; + } + + $result = []; + + foreach ($vpcs as $vpc) { + $vpcId = (string) ($vpc['id'] ?? ''); + $members = []; + $cidrs = []; + + foreach ($vpc['subnets'] ?? [] as $subnet) { + if (isset($subnet['ipv4'])) { + $cidrs[] = (string) $subnet['ipv4']; + } + + foreach ($subnet['linodes'] ?? [] as $linode) { + $linodeId = (string) ($linode['id'] ?? ''); + + if ($linodeId === '' || ! isset($wanted[$linodeId])) { + continue; + } + + $members[] = new PrivateNetworkMemberDTO( + instanceId: $linodeId, + ip: $ips[$vpcId][$linodeId] ?? null, + ); + } + } + + if ($members === []) { + continue; + } + + $result[] = new PrivateNetworkDTO( + externalId: $vpcId, + name: (string) ($vpc['label'] ?? $vpcId), + cidr: count($cidrs) === 1 ? $cidrs[0] : null, + region: isset($vpc['region']) ? (string) $vpc['region'] : null, + members: $members, + ); + } + + return $result; + } + + /** + * @return array> + * + * @throws PrivateNetworkSyncError + */ + private function fetchAll(string $path): array + { + $token = $this->serverProvider->getCredentials()['token']; + $items = []; + $page = 1; + + do { + try { + $response = Http::withToken($token)->get($this->apiUrl.$path, [ + 'page_size' => 500, + 'page' => $page, + ]); + } catch (Exception) { + throw $this->syncError(); + } + + if (! $response->ok()) { + throw $this->syncError($response->status()); + } + + $body = $response->json(); + + if (! is_array($body) || ! is_array($body['data'] ?? null)) { + throw $this->syncError($response->status()); + } + + /** @var array> $batch */ + $batch = $body['data']; + $items = array_merge($items, $batch); + + if ($page >= (int) ($body['pages'] ?? 1)) { + break; + } + + if ($page >= self::MAX_PAGES) { + throw $this->syncError(); + } + + $page++; + } while (true); + + return $items; + } + public function createRules(array $input): array { return [ diff --git a/app/ServerProviders/ProvidesPrivateNetworks.php b/app/ServerProviders/ProvidesPrivateNetworks.php new file mode 100644 index 000000000..d56b3b990 --- /dev/null +++ b/app/ServerProviders/ProvidesPrivateNetworks.php @@ -0,0 +1,45 @@ +serverProvider->getCredentials()` and never touch `$this->server`. + */ +interface ProvidesPrivateNetworks +{ + /** + * The `servers.provider_data` key holding this provider's instance identifier. + */ + public function instanceIdKey(): string; + + /** + * Whether a complete query is possible with the given regions. A provider that returns + * false cannot be asked, so an empty result must not be read as "these networks are gone". + * + * `$serversWithoutRegion` counts the connection's servers that carry an instance id but no + * region, whose networks a regional provider would therefore never see. + * + * @param array $regions + */ + public function canDiscoverPrivateNetworks(array $regions, int $serversWithoutRegion): bool; + + /** + * Private networks that at least one of $instanceIds is attached to. Members are + * restricted to $instanceIds — instances the caller did not ask about are omitted. + * + * @param array $instanceIds + * @param array $regions regions to query; ignored by global providers + * @return array + * + * @throws PrivateNetworkSyncError + */ + public function privateNetworks(array $instanceIds, array $regions): array; +} diff --git a/app/ServerProviders/Vultr.php b/app/ServerProviders/Vultr.php index 89084deb1..bc157a988 100644 --- a/app/ServerProviders/Vultr.php +++ b/app/ServerProviders/Vultr.php @@ -2,8 +2,11 @@ namespace App\ServerProviders; +use App\DTOs\PrivateNetworkDTO; +use App\DTOs\PrivateNetworkMemberDTO; use App\Enums\OperatingSystem; use App\Exceptions\CouldNotConnectToProvider; +use App\Exceptions\PrivateNetworkSyncError; use App\Exceptions\ServerProviderError; use App\Facades\Notifier; use App\Notifications\FailedToDeleteServerFromProvider; @@ -13,7 +16,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; -class Vultr extends AbstractProvider +class Vultr extends AbstractProvider implements ProvidesPrivateNetworks { protected string $apiUrl = 'https://api.vultr.com/v2'; @@ -22,6 +25,140 @@ public static function id(): string return 'vultr'; } + public function instanceIdKey(): string + { + return 'instance_id'; + } + + /** + * A server deleted upstream leaves a stale instance id behind, so a 404 for one instance is + * skipped rather than allowed to abort the connection's whole sync — which would also + * suppress pruning for every other network on it. + */ + public function privateNetworks(array $instanceIds, array $regions): array + { + $attachments = []; + + foreach ($instanceIds as $instanceId) { + try { + $attached = $this->fetchAll('/instances/'.$instanceId.'/vpcs', 'vpcs'); + } catch (PrivateNetworkSyncError $e) { + if ($e->status === 404) { + continue; + } + + throw $e; + } + + foreach ($attached as $attachment) { + $vpcId = (string) ($attachment['id'] ?? ''); + + if ($vpcId === '') { + continue; + } + + $attachments[$vpcId][] = new PrivateNetworkMemberDTO( + instanceId: (string) $instanceId, + ip: isset($attachment['ip_address']) ? (string) $attachment['ip_address'] : null, + ); + } + } + + if ($attachments === []) { + return []; + } + + return $this->mapPrivateNetworks($this->fetchAll('/vpcs', 'vpcs'), $attachments); + } + + /** + * Only `/v2/vpcs` is used. `/v2/vpc2` is deprecated, and its metadata shape differs + * (`ip_block`/`prefix_length` rather than `v4_subnet`/`v4_subnet_mask`). + * + * The per-instance attachment lookup is bounded by the servers Vito manages rather than + * by the size of the account. + * + * @param array> $vpcs + * @param array> $attachments + * @return array + */ + private function mapPrivateNetworks(array $vpcs, array $attachments): array + { + $result = []; + + foreach ($vpcs as $vpc) { + $vpcId = (string) ($vpc['id'] ?? ''); + + if (! isset($attachments[$vpcId])) { + continue; + } + + $subnet = $vpc['v4_subnet'] ?? null; + $mask = $vpc['v4_subnet_mask'] ?? null; + + $result[] = new PrivateNetworkDTO( + externalId: $vpcId, + name: (string) ($vpc['description'] ?? $vpcId), + cidr: is_string($subnet) && $subnet !== '' && is_numeric($mask) ? $subnet.'/'.(int) $mask : null, + region: isset($vpc['region']) ? (string) $vpc['region'] : null, + members: $attachments[$vpcId], + ); + } + + return $result; + } + + /** + * @return array> + * + * @throws PrivateNetworkSyncError + */ + private function fetchAll(string $path, string $key): array + { + $token = $this->serverProvider->getCredentials()['token']; + $items = []; + $cursor = null; + + do { + try { + $response = Http::withToken($token)->get($this->apiUrl.$path, array_filter([ + 'per_page' => 500, + 'cursor' => $cursor, + ])); + } catch (Exception) { + throw $this->syncError(); + } + + if (! $response->ok()) { + throw $this->syncError($response->status()); + } + + $body = $response->json(); + + if (! is_array($body) || ! is_array($body[$key] ?? null)) { + throw $this->syncError($response->status()); + } + + /** @var array> $batch */ + $batch = $body[$key]; + $items = array_merge($items, $batch); + + $next = $body['meta']['links']['next'] ?? null; + + if (! is_string($next) || $next === '') { + break; + } + + if ($next === $cursor) { + throw $this->syncError(); + } + + $cursor = $next; + } while (true); + + return $items; + } + public function createRules(array $input): array { return [ diff --git a/app/Services/Firewall/Ufw.php b/app/Services/Firewall/Ufw.php index adc2125f5..a8f64e1a4 100755 --- a/app/Services/Firewall/Ufw.php +++ b/app/Services/Firewall/Ufw.php @@ -2,6 +2,7 @@ namespace App\Services\Firewall; +use App\Actions\Network\FinalizeServerNetworkRules; use App\DTOs\ServiceLog; use App\Enums\FirewallRuleStatus; use App\Exceptions\SSHError; @@ -39,6 +40,9 @@ public function install(): void ]), 'install-ufw' ); + + $this->applyRules(); + event('service.installed', $this->service); $this->service->server->os()->cleanup(); } @@ -53,15 +57,37 @@ public function uninstall(): void */ public function applyRules(): void { - $rules = $this->service->server - ->firewallRules() + $server = $this->service->server; + + $networkRules = $server->networkRules() ->where('status', '!=', FirewallRuleStatus::DELETING) + ->ordered() ->get(); - $this->service->server->ssh()->exec( - view('ssh.services.firewall.ufw.apply-rules', ['rules' => $rules]), - 'apply-rules' - ); + $emittedIds = $networkRules->pluck('id')->all(); + $deletingIds = $server->networkRules()->where('status', FirewallRuleStatus::DELETING)->pluck('id')->all(); + + $serverRules = $server->firewallRules() + ->where('status', '!=', FirewallRuleStatus::DELETING) + ->orderBy('id') + ->get(); + + $rules = $networkRules->concat($serverRules); + + $finalize = app(FinalizeServerNetworkRules::class); + + try { + $server->ssh()->exec( + view('ssh.services.firewall.ufw.apply-rules', ['rules' => $rules]), + 'apply-rules' + ); + } catch (SSHError $e) { + $finalize->failure($server, $emittedIds); + + throw $e; + } + + $finalize->success($server, $emittedIds, $deletingIds); } public function version(): string diff --git a/app/Services/VPN/VPN.php b/app/Services/VPN/VPN.php new file mode 100644 index 000000000..c93f440f7 --- /dev/null +++ b/app/Services/VPN/VPN.php @@ -0,0 +1,14 @@ + + */ + public function deletionRules(): array + { + return [ + 'service' => [ + function (string $attribute, mixed $value, Closure $fail): void { + $isMember = NetworkServer::query() + ->where('server_id', $this->service->server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->exists(); + + if ($isMember) { + $fail(__('This server is a member of one or more WireGuard networks. Remove it from those networks before uninstalling WireGuard.')); + } + }, + ], + ]; + } + + public function unit(): string + { + return ''; + } + + /** + * @throws SSHError + */ + public function install(): void + { + $this->service->server->ssh() + ->setLog($this->service->log) + ->exec( + view('ssh.services.wireguard.install'), + 'install-wireguard' + ); + event('service.installed', $this->service); + $this->service->server->os()->cleanup(); + } + + /** + * @throws SSHError + */ + public function uninstall(): void + { + $this->service->server->ssh()->exec( + view('ssh.services.wireguard.uninstall'), + 'uninstall-wireguard' + ); + event('service.uninstalled', $this->service); + } + + /** + * @throws SSHError + */ + public function version(): string + { + $version = $this->service->server->ssh()->exec( + 'wg --version | grep -oE \'v[0-9]+\.[0-9]+\.[0-9]+\' | head -n1' + ); + + return trim($version); + } + + /** + * @throws SSHError + */ + public function configureNetwork(NetworkServer $membership): void + { + $network = $membership->network; + + $content = view('ssh.services.wireguard.conf', [ + 'address' => $membership->ip, + 'prefix' => $this->prefix($network), + 'listenPort' => $network->port, + 'privateKey' => $membership->private_key, + 'peers' => $this->peers($membership), + ])->render(); + + $log = ServerLog::newLog($this->service->server, "configure-wireguard-{$network->id}"); + $log->save(); + + $ssh = $this->service->server->ssh()->setLog($log); + $ssh->exec('sudo mkdir -p /etc/wireguard && sudo chmod 700 /etc/wireguard', 'configure-wireguard'); + $this->uploadConf($ssh, $this->confPath($network).'.tmp', $content); + + $ssh->exec( + view('ssh.services.wireguard.configure', [ + 'networkId' => $network->id, + 'keepNetworkIds' => $this->managedNetworkIds($membership), + ]), + 'configure-wireguard' + ); + } + + /** + * Every WireGuard network this server still belongs to. Any other wg-vito interface on the + * box is a leftover — a teardown that could not complete while the server was unreachable, + * for instance — and the configure pass removes it. + * + * @return array + */ + private function managedNetworkIds(NetworkServer $membership): array + { + return NetworkServer::query() + ->where('server_id', $membership->server_id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->pluck('network_id') + ->push($membership->network_id) + ->unique() + ->values() + ->all(); + } + + /** + * @throws SSHError + */ + public function removeNetwork(Network $network): void + { + $this->service->server->ssh()->exec( + view('ssh.services.wireguard.remove-network', ['networkId' => $network->id]), + 'remove-wireguard-network' + ); + } + + private function confPath(Network $network): string + { + return "/etc/wireguard/wg-vito-{$network->id}.conf"; + } + + private function prefix(Network $network): int + { + return Cidr::prefix((string) $network->cidr); + } + + /** + * @return array + */ + private function peers(NetworkServer $membership): array + { + $network = $membership->network; + + $servers = $network->servers() + ->where('id', '!=', $membership->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereNotNull('public_key') + ->whereNotNull('ip') + ->with('server') + ->get() + ->filter(fn (NetworkServer $peer): bool => Cidr::isValidAddress((string) $peer->server->ip)) + ->map(fn (NetworkServer $peer): array => [ + 'public_key' => (string) $peer->public_key, + 'allowed_ips' => $peer->ip.'/'.Cidr::hostPrefix((string) $peer->ip), + 'endpoint' => Cidr::endpoint((string) $peer->server->ip, (int) $network->port), + ]); + + $devices = $network->peers() + ->where('status', '!=', NetworkPeerStatus::DISABLED) + ->get() + ->map(fn (NetworkPeer $peer): array => [ + 'public_key' => $peer->public_key, + 'allowed_ips' => $peer->ip.'/'.Cidr::hostPrefix($peer->ip), + 'endpoint' => null, + ]); + + return $servers->concat($devices)->values()->all(); + } + + /** + * @return array + * + * @throws SSHError + */ + public function latestHandshakes(Network $network): array + { + $output = $this->service->server->ssh()->exec( + view('ssh.services.wireguard.latest-handshakes', ['networkId' => $network->id]), + 'wireguard-latest-handshakes' + ); + + $handshakes = []; + + foreach (preg_split('/\r?\n/', trim($output)) ?: [] as $line) { + $parts = preg_split('/\s+/', trim($line)); + + if ($parts === false || count($parts) < 2 || $parts[0] === '') { + continue; + } + + $handshakes[$parts[0]] = (int) $parts[1]; + } + + return $handshakes; + } + + private function uploadConf(SSH|SSHFake $ssh, string $remote, string $content): void + { + $tmpName = 'wg-'.Str::random(20); + $disk = Storage::disk('local'); + + try { + if (! $disk->put($tmpName, '')) { + throw new RuntimeException('Could not create the temporary WireGuard configuration file.'); + } + + $path = $disk->path($tmpName); + + if (! chmod($path, 0600) || ! $disk->put($tmpName, $content)) { + throw new RuntimeException('Could not write the temporary WireGuard configuration file.'); + } + + $ssh->upload($path, $remote, 'root'); + } finally { + $disk->delete($tmpName); + } + } +} diff --git a/app/Support/Cidr.php b/app/Support/Cidr.php new file mode 100644 index 000000000..ccabf6076 --- /dev/null +++ b/app/Support/Cidr.php @@ -0,0 +1,282 @@ += self::prefix($outer) + && self::contains($outer, self::network($inner)); + } + + public static function overlaps(string $a, string $b): bool + { + if (self::bits($a) !== self::bits($b)) { + return false; + } + + return self::contains($a, self::network($b)) || self::contains($b, self::network($a)); + } + + /** + * First usable host (index 2), skipping the network address and the reserved + * gateway (.1), avoiding any already-used address and — for IPv4 — the + * broadcast address. Returns null when the block is exhausted. + * + * @param array $used + */ + public static function nextHost(string $cidr, array $used): ?string + { + [$address, $prefix] = self::split($cidr); + $bytes = self::toBytes($address); + + if ($bytes === null) { + return null; + } + + $network = self::mask($bytes, $prefix); + $used = array_flip($used); + + for ($index = 2; ; $index++) { + $candidate = self::add($network, $index); + + if ($candidate === null || self::mask($candidate, $prefix) !== $network) { + return null; + } + + if (strlen($candidate) === 4 && self::isAllOnes($candidate, $prefix)) { + return null; + } + + $host = self::fromBytes($candidate); + + if (! isset($used[$host])) { + return $host; + } + } + } + + /** + * `host:port` for IPv4, `[host]:port` for IPv6 — WireGuard's `Endpoint` and most + * socket syntax require the brackets to disambiguate the port. + */ + public static function endpoint(string $ip, int|string $port): string + { + return self::isV6($ip) ? '['.$ip.']:'.$port : $ip.':'.$port; + } + + /** + * Number of addresses in an IPv4 block. IPv6 blocks are far too large to count, + * so callers that need to enumerate must use `nextHost()` instead. + */ + public static function size(int $prefix): int + { + return 2 ** (self::V4_BITS - $prefix); + } + + /** + * IPv4 address as an unsigned integer, for the overlay block allocator. + */ + public static function toLong(string $ip): int + { + return (int) sprintf('%u', ip2long($ip)); + } + + private static function toBytes(string $ip): ?string + { + $bytes = @inet_pton($ip); + + return $bytes === false ? null : $bytes; + } + + private static function fromBytes(string $bytes): string + { + $ip = @inet_ntop($bytes); + + return $ip === false ? '' : $ip; + } + + /** + * Zero every bit below the prefix. + */ + private static function mask(string $bytes, int $prefix): string + { + $length = strlen($bytes); + + for ($i = 0; $i < $length; $i++) { + $keep = $prefix - ($i * 8); + + if ($keep >= 8) { + continue; + } + + $bytes[$i] = $keep <= 0 + ? "\0" + : chr(ord($bytes[$i]) & ((0xFF << (8 - $keep)) & 0xFF)); + } + + return $bytes; + } + + /** + * Whether every host bit below the prefix is set — the IPv4 broadcast address. + */ + private static function isAllOnes(string $bytes, int $prefix): bool + { + $length = strlen($bytes); + + for ($i = 0; $i < $length; $i++) { + $keep = $prefix - ($i * 8); + + if ($keep >= 8) { + continue; + } + + $hostMask = $keep <= 0 ? 0xFF : (~(0xFF << (8 - $keep))) & 0xFF; + + if ((ord($bytes[$i]) & $hostMask) !== $hostMask) { + return false; + } + } + + return true; + } + + /** + * Add an offset to a big-endian byte string, returning null on overflow. + */ + private static function add(string $bytes, int $offset): ?string + { + for ($i = strlen($bytes) - 1; $i >= 0 && $offset > 0; $i--) { + $sum = ord($bytes[$i]) + ($offset & 0xFF); + $bytes[$i] = chr($sum & 0xFF); + $offset = ($offset >> 8) + ($sum >> 8); + } + + return $offset > 0 ? null : $bytes; + } +} diff --git a/app/Support/Testing/SSHFake.php b/app/Support/Testing/SSHFake.php index 7f9bc4384..cbe1a151e 100644 --- a/app/Support/Testing/SSHFake.php +++ b/app/Support/Testing/SSHFake.php @@ -56,14 +56,12 @@ public function connect(bool $sftp = false): void public function exec(string|View $command, string $log = '', ?int $siteId = null, ?bool $stream = false, ?callable $streamCallback = null, int $timeout = 0): string { if (! $this->log instanceof ServerLog && $log) { - /** @var ServerLog $log */ - $log = $this->server->logs()->create([ - 'site_id' => $siteId, - 'name' => $this->server->id.'-'.strtotime('now').'-'.$log.'.log', - 'type' => $log, - 'disk' => config('core.logs_disk'), - ]); - $this->log = $log; + $serverLog = ServerLog::newLog($this->server, $log); + if ($siteId !== null && $siteId !== 0) { + $serverLog->forSite($siteId); + } + $serverLog->save(); + $this->log = $serverLog; } $this->commands[] = $command; diff --git a/app/Tables/NetworkTable.php b/app/Tables/NetworkTable.php new file mode 100644 index 000000000..c41fbe968 --- /dev/null +++ b/app/Tables/NetworkTable.php @@ -0,0 +1,44 @@ + 'network']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->with('serverProvider')->withCount('servers')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + EnumColumn::make('type', 'Type'), + Column::make('provider', 'Provider') + ->value(fn (Network $network): string => $network->server_provider_id !== null + ? $network->serverProvider->provider + : '-'), + TextColumn::make('cidr', 'CIDR'), + TextColumn::make('port', 'Port')->fallback('-'), + TextColumn::make('servers_count', 'Servers'), + EnumColumn::make('status', 'Status')->sortable(), + Column::data('id'), + ActionsColumn::make(), + ]; + } + + protected function searchable(): array + { + return ['name']; + } +} diff --git a/app/Tables/Networks/NetworkFirewallRuleTable.php b/app/Tables/Networks/NetworkFirewallRuleTable.php new file mode 100644 index 000000000..d9863d200 --- /dev/null +++ b/app/Tables/Networks/NetworkFirewallRuleTable.php @@ -0,0 +1,34 @@ + 'network-firewall-rule']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->orderBy('id'); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + Column::make('_protocol', 'Protocol')->accessor('protocol')->text()->fallback('*'), + Column::make('_port', 'Port')->accessor('port')->text()->fallback('*'), + EnumColumn::make('status', 'Status'), + Column::data('id'), + Column::data('protocol'), + Column::data('port'), + ActionsColumn::make(), + ]; + } +} diff --git a/app/Tables/Networks/NetworkPeerTable.php b/app/Tables/Networks/NetworkPeerTable.php new file mode 100644 index 000000000..4d8210509 --- /dev/null +++ b/app/Tables/Networks/NetworkPeerTable.php @@ -0,0 +1,45 @@ + 'network-peer']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->orderBy('id'); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('ip', 'IP address'), + CopyableColumn::make('public_key', 'Public key'), + EnumColumn::make('status', 'Status'), + Column::make('last_handshake', 'Last handshake') + ->value(fn (NetworkPeer $peer): string => $peer->last_handshake_at?->diffForHumans() ?? 'Never') + ->badge(colorField: '_connected_color'), + Column::data('_connected_color', fn (NetworkPeer $peer): string => $this->connected($peer) ? 'success' : 'gray'), + Column::data('id'), + Column::data('byo'), + Column::data('has_private_key', fn (NetworkPeer $peer): bool => $peer->hasPrivateKey()), + ActionsColumn::make(), + ]; + } + + private function connected(NetworkPeer $peer): bool + { + return $peer->last_handshake_at !== null && $peer->last_handshake_at->greaterThan(now()->subMinutes(10)); + } +} diff --git a/app/Tables/Networks/NetworkServerTable.php b/app/Tables/Networks/NetworkServerTable.php new file mode 100644 index 000000000..f3b7fefb0 --- /dev/null +++ b/app/Tables/Networks/NetworkServerTable.php @@ -0,0 +1,46 @@ + 'network-server']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->with(['server.services', 'serverIpAddress'])->orderBy('id'); + } + + protected function columns(): array + { + return [ + LinkColumn::make('server.name', 'Server')->sortable()->route('servers.show', ['server' => ':server_id']), + TextColumn::make('ip', 'IP address') + ->value(fn (NetworkServer $member) => $member->ip ?? $member->serverIpAddress?->ip) + ->fallback('-'), + Column::make('firewall', 'Firewall') + ->value(fn (NetworkServer $member): string => $this->hasFirewall($member) ? 'Yes' : 'No') + ->badge(colorField: '_firewall_color'), + Column::data('_firewall_color', fn (NetworkServer $member): string => $this->hasFirewall($member) ? 'success' : 'danger'), + EnumColumn::make('status', 'Status'), + Column::data('id'), + Column::data('server_id'), + ActionsColumn::make(), + ]; + } + + private function hasFirewall(NetworkServer $member): bool + { + return $member->server->services->contains(fn (Service $service): bool => $service->type === 'firewall'); + } +} diff --git a/app/Tables/Servers/ServerNetworkRuleTable.php b/app/Tables/Servers/ServerNetworkRuleTable.php new file mode 100644 index 000000000..cb5878b0c --- /dev/null +++ b/app/Tables/Servers/ServerNetworkRuleTable.php @@ -0,0 +1,51 @@ + 'server-network-rule']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query + ->whereHas('networkServer', fn ($query) => $query->where('status', '!=', NetworkServerStatus::LEAVING)) + ->with('network'); + + ServerNetworkRule::applyOrder($this->query); + } + + protected function columns(): array + { + return [ + LinkColumn::make('network.name', 'Network')->route('networks.show', ['network' => ':network_id']), + TextColumn::make('name', 'Name'), + TextColumn::make('type', 'Type')->uppercase(), + Column::make('source', 'Source') + ->value(fn (ServerNetworkRule $rule): string => $this->sourceLabel($rule)) + ->text(), + TextColumn::make('protocol', 'Protocol')->fallback('*'), + TextColumn::make('port', 'Port')->fallback('*'), + EnumColumn::make('status', 'Status'), + Column::data('network_id'), + ]; + } + + private function sourceLabel(ServerNetworkRule $rule): string + { + if ($rule->source === null) { + return '*'; + } + + return $rule->mask !== null && $rule->mask !== 32 ? $rule->source.'/'.$rule->mask : $rule->source; + } +} diff --git a/app/Traits/UniqueQueue.php b/app/Traits/UniqueQueue.php index 891c2fb5e..456fa7085 100644 --- a/app/Traits/UniqueQueue.php +++ b/app/Traits/UniqueQueue.php @@ -3,11 +3,14 @@ namespace App\Traits; use Closure; +use Illuminate\Database\DetectsConcurrencyErrors; use Illuminate\Support\Facades\Cache; use Throwable; trait UniqueQueue { + use DetectsConcurrencyErrors; + public $tries = 120; public function retryUntil(): \DateTime @@ -30,7 +33,12 @@ public function run(string $key, Closure $callback): void try { $callback(); } catch (Throwable $e) { - $lock->release(); + if ($this->isTransientDatabaseError($e) && $this->attempts() < $this->tries) { + $this->release(min(30, $this->attempts() * 2)); + + return; + } + $this->fail($e); } finally { $lock->release(); @@ -39,4 +47,9 @@ public function run(string $key, Closure $callback): void $this->release(30); } } + + protected function isTransientDatabaseError(Throwable $e): bool + { + return $this->causedByConcurrencyError($e); + } } diff --git a/app/ValidationRules/CidrRule.php b/app/ValidationRules/CidrRule.php new file mode 100644 index 000000000..c090d2aec --- /dev/null +++ b/app/ValidationRules/CidrRule.php @@ -0,0 +1,21 @@ +translate(); + } + } +} diff --git a/app/ValidationRules/PortOrPortRangeRule.php b/app/ValidationRules/PortOrPortRangeRule.php index 7fb57efe0..e5d80ff98 100644 --- a/app/ValidationRules/PortOrPortRangeRule.php +++ b/app/ValidationRules/PortOrPortRangeRule.php @@ -10,7 +10,7 @@ class PortOrPortRangeRule implements ValidationRule public function validate(string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) || preg_match('/^[1-9]\d{0,4}(:[1-9]\d{0,4})?$/', $value) !== 1) { - $fail('The :attribute must be a port (e.g. 8080) or a range (e.g. 3000:3010).'); + $fail('The :attribute must be a port (e.g. 8080) or a range (e.g. 3000:3010).')->translate(); return; } @@ -18,14 +18,14 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $parts = array_map('intval', explode(':', $value)); foreach ($parts as $p) { if ($p < 1 || $p > 65535) { - $fail('The :attribute must be between 1 and 65535.'); + $fail('The :attribute must be between 1 and 65535.')->translate(); return; } } if (count($parts) === 2 && $parts[0] >= $parts[1]) { - $fail('The :attribute range start must be lower than the range end.'); + $fail('The :attribute range start must be lower than the range end.')->translate(); } } } diff --git a/app/ValidationRules/PrivateRangeRule.php b/app/ValidationRules/PrivateRangeRule.php new file mode 100644 index 000000000..436124281 --- /dev/null +++ b/app/ValidationRules/PrivateRangeRule.php @@ -0,0 +1,38 @@ +translate(); + } +} diff --git a/app/ValidationRules/WireGuardPublicKeyRule.php b/app/ValidationRules/WireGuardPublicKeyRule.php new file mode 100644 index 000000000..19abdae2d --- /dev/null +++ b/app/ValidationRules/WireGuardPublicKeyRule.php @@ -0,0 +1,41 @@ +translate(); + + return; + } + + $collidesWithServer = $this->network->servers() + ->where('public_key', $value) + ->exists(); + + $collidesWithPeer = $this->network->peers() + ->where('public_key', $value) + ->when($this->ignorePeerId !== null, fn ($query) => $query->whereKeyNot($this->ignorePeerId)) + ->exists(); + + if ($collidesWithServer || $collidesWithPeer) { + $fail('The :attribute is already in use on this network.')->translate(); + } + } +} diff --git a/app/ValidationRules/WithinCidrRule.php b/app/ValidationRules/WithinCidrRule.php new file mode 100644 index 000000000..4c2c289eb --- /dev/null +++ b/app/ValidationRules/WithinCidrRule.php @@ -0,0 +1,38 @@ +cidr === null || $this->cidr === '' || ! Cidr::isValid($this->cidr)) { + return; + } + + $ip = ServerIpAddress::query()->whereKey($value)->value('ip'); + + if (! is_string($ip)) { + return; + } + + if (! Cidr::contains($this->cidr, $ip)) { + $fail('The selected address :ip is outside the network range :cidr.')->translate([ + 'ip' => $ip, + 'cidr' => $this->cidr, + ]); + } + } +} diff --git a/database/factories/NetworkFactory.php b/database/factories/NetworkFactory.php new file mode 100644 index 000000000..b95acc34e --- /dev/null +++ b/database/factories/NetworkFactory.php @@ -0,0 +1,45 @@ + + */ +class NetworkFactory extends Factory +{ + protected $model = Network::class; + + public function definition(): array + { + $cidr = $this->block(); + + return [ + 'project_id' => Project::factory(), + 'name' => $this->faker->unique()->word(), + 'type' => NetworkType::WIREGUARD, + 'status' => NetworkStatus::ACTIVE, + 'addressing_pool' => NetworkAddressingPool::CGNAT, + 'cidr' => $cidr, + 'cidr_canonical' => $cidr, + 'port' => 51820, + ]; + } + + /** + * One unique number feeds both variable octets, so the pool spans every /24 in + * 100.64.0.0/10 rather than the 256 a single-octet unique() would allow. + */ + private function block(): string + { + $block = $this->faker->unique()->numberBetween(0, 16383); + + return '100.'.(64 + intdiv($block, 256)).'.'.($block % 256).'.0/24'; + } +} diff --git a/database/factories/NetworkFirewallRuleFactory.php b/database/factories/NetworkFirewallRuleFactory.php new file mode 100644 index 000000000..4f4eddca3 --- /dev/null +++ b/database/factories/NetworkFirewallRuleFactory.php @@ -0,0 +1,27 @@ + + */ +class NetworkFirewallRuleFactory extends Factory +{ + protected $model = NetworkFirewallRule::class; + + public function definition(): array + { + return [ + 'network_id' => Network::factory(), + 'name' => $this->faker->word(), + 'protocol' => 'tcp', + 'port' => (string) $this->faker->numberBetween(1, 65535), + 'status' => FirewallRuleStatus::READY, + ]; + } +} diff --git a/database/factories/NetworkPeerFactory.php b/database/factories/NetworkPeerFactory.php new file mode 100644 index 000000000..5a863b40f --- /dev/null +++ b/database/factories/NetworkPeerFactory.php @@ -0,0 +1,31 @@ + + */ +class NetworkPeerFactory extends Factory +{ + protected $model = NetworkPeer::class; + + public function definition(): array + { + return [ + 'network_id' => Network::factory(), + 'name' => $this->faker->unique()->word(), + 'ip' => $this->faker->unique()->numerify('100.64.0.##'), + 'public_key' => base64_encode(random_bytes(32)), + 'private_key' => base64_encode(random_bytes(32)), + 'byo' => false, + 'status' => NetworkPeerStatus::ACTIVE, + 'last_handshake_at' => null, + 'sync_attempts' => 0, + ]; + } +} diff --git a/database/factories/NetworkServerFactory.php b/database/factories/NetworkServerFactory.php new file mode 100644 index 000000000..aa5fc6eb0 --- /dev/null +++ b/database/factories/NetworkServerFactory.php @@ -0,0 +1,45 @@ + + */ +class NetworkServerFactory extends Factory +{ + protected $model = NetworkServer::class; + + public function definition(): array + { + $keys = app(GenerateWireGuardKeys::class)->generate(); + + return [ + 'network_id' => Network::factory(), + 'server_id' => Server::factory(), + 'server_ip_address_id' => null, + 'ip' => $this->hostAddress(), + 'public_key' => $keys['public_key'], + 'private_key' => $keys['private_key'], + 'status' => NetworkServerStatus::ACTIVE, + 'sync_attempts' => 0, + ]; + } + + /** + * A two-digit host pattern gives faker's unique() only 100 values, which it can exhaust + * across a long-running process and then throw. This spans the whole 100.64.0.0/16. + */ + private function hostAddress(): string + { + $host = $this->faker->unique()->numberBetween(1, 64516); + + return '100.64.'.intdiv($host - 1, 254).'.'.(($host - 1) % 254 + 1); + } +} diff --git a/database/migrations/2026_07_24_195720_create_networks_table.php b/database/migrations/2026_07_24_195720_create_networks_table.php new file mode 100644 index 000000000..8936dd447 --- /dev/null +++ b/database/migrations/2026_07_24_195720_create_networks_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_provider_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('type'); + $table->string('status')->default(NetworkStatus::CREATING->value); + $table->string('addressing_pool')->default(NetworkAddressingPool::CGNAT->value); + $table->string('cidr')->nullable(); + $table->string('cidr_canonical')->nullable(); + $table->unsignedInteger('port')->nullable(); + $table->string('external_id')->nullable(); + $table->string('region')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['project_id', 'name']); + $table->unique(['project_id', 'server_provider_id', 'external_id'], 'networks_project_external_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('networks'); + } +}; diff --git a/database/migrations/2026_07_24_195721_create_network_servers_table.php b/database/migrations/2026_07_24_195721_create_network_servers_table.php new file mode 100644 index 000000000..0f192a14c --- /dev/null +++ b/database/migrations/2026_07_24_195721_create_network_servers_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('network_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_ip_address_id')->nullable()->constrained()->nullOnDelete(); + $table->string('ip')->nullable(); + $table->text('public_key')->nullable(); + $table->text('private_key')->nullable(); + $table->string('status')->default(NetworkServerStatus::PENDING->value); + $table->unsignedInteger('sync_attempts')->default(0); + $table->timestamps(); + + $table->unique(['network_id', 'server_id']); + $table->unique(['network_id', 'ip']); + $table->unique('server_ip_address_id'); + $table->index(['status', 'updated_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_servers'); + } +}; diff --git a/database/migrations/2026_07_24_195722_create_network_firewall_rules_table.php b/database/migrations/2026_07_24_195722_create_network_firewall_rules_table.php new file mode 100644 index 000000000..1874f68b6 --- /dev/null +++ b/database/migrations/2026_07_24_195722_create_network_firewall_rules_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('network_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('protocol')->nullable(); + $table->string('port')->nullable(); + $table->string('status')->default(FirewallRuleStatus::CREATING->value); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_firewall_rules'); + } +}; diff --git a/database/migrations/2026_07_24_195723_create_server_network_rules_table.php b/database/migrations/2026_07_24_195723_create_server_network_rules_table.php new file mode 100644 index 000000000..04e69e412 --- /dev/null +++ b/database/migrations/2026_07_24_195723_create_server_network_rules_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->foreignId('network_id')->constrained()->cascadeOnDelete(); + $table->foreignId('network_server_id')->constrained()->cascadeOnDelete(); + $table->foreignId('network_firewall_rule_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('kind')->default(ServerNetworkRuleKind::RULE->value); + $table->string('name'); + $table->string('type')->default('allow'); + $table->string('protocol')->nullable(); + $table->string('port')->nullable(); + $table->string('source')->nullable(); + $table->unsignedTinyInteger('mask')->nullable(); + $table->string('status')->default(FirewallRuleStatus::CREATING->value); + $table->timestamps(); + + $table->index(['server_id', 'status']); + $table->index('network_server_id'); + $table->index('network_firewall_rule_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_network_rules'); + } +}; diff --git a/database/migrations/2026_07_24_195724_create_network_peers_table.php b/database/migrations/2026_07_24_195724_create_network_peers_table.php new file mode 100644 index 000000000..6a53ef964 --- /dev/null +++ b/database/migrations/2026_07_24_195724_create_network_peers_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('network_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('ip'); + $table->string('public_key'); + $table->text('private_key')->nullable(); + $table->boolean('byo')->default(false); + $table->string('status')->default(NetworkPeerStatus::PENDING->value); + $table->timestamp('last_handshake_at')->nullable(); + $table->unsignedInteger('sync_attempts')->default(0); + $table->timestamps(); + + $table->unique(['network_id', 'name']); + $table->unique(['network_id', 'ip']); + $table->unique(['network_id', 'public_key']); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_peers'); + } +}; diff --git a/database/migrations/2026_07_24_195725_add_network_id_to_server_logs.php b/database/migrations/2026_07_24_195725_add_network_id_to_server_logs.php new file mode 100644 index 000000000..40e191bae --- /dev/null +++ b/database/migrations/2026_07_24_195725_add_network_id_to_server_logs.php @@ -0,0 +1,23 @@ +foreignId('network_id')->nullable()->after('site_id')->constrained()->nullOnDelete(); + $table->index('network_id'); + }); + } + + public function down(): void + { + Schema::table('server_logs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('network_id'); + }); + } +}; diff --git a/docs/4.x/README.md b/docs/4.x/README.md index c56f73256..ace8e8539 100644 --- a/docs/4.x/README.md +++ b/docs/4.x/README.md @@ -24,6 +24,17 @@ databases, deploy sites, and more — all from a clean dashboard. | [Configuration](getting-started/configuration.md) | Configure your Vito instance | | [Update](getting-started/update.md) | Keep your instance up to date | +## Networks + +| Page | What's in it | +| --- | --- | +| [Overview](networks/overview.md) | Private networking between your servers | +| [Creating a Network](networks/create.md) | Create a WireGuard or custom network | +| [Provider Networks](networks/provider-networks.md) | Discover and sync VPCs from your cloud provider | +| [Network Servers](networks/servers.md) | Manage the servers in a network | +| [Peers](networks/peers.md) | Connect laptops and other devices | +| [Network Firewall](networks/firewall.md) | Control traffic between members | + ## Servers | Page | What's in it | diff --git a/docs/4.x/automation.md b/docs/4.x/automation.md index 6418e05d7..1501ce51a 100644 --- a/docs/4.x/automation.md +++ b/docs/4.x/automation.md @@ -20,8 +20,13 @@ These tasks are driven by Vito's own scheduler, which runs on your Vito instance | Wildcard SSL renewal | Daily | Renews wildcard Let's Encrypt [certificates](./servers/ssl.md) within 30 days of expiry. | | SSL expiry check | Daily | Flags certificates that are expiring soon and notifies you. | | GitHub App sync | Every 4 hours | Syncs [GitHub App](./admin/github-app.md) installations as a fallback to webhooks. | +| Network reconciliation | Every 3 minutes | Re-applies [network](./networks/overview.md) configuration to servers that are pending or failed, and checks peer connectivity. | | Database maintenance | Daily | Vacuums Vito's own database to keep it fast. | +:::info +[Provider networks](./networks/provider-networks.md) are **not** on this list. Vito only queries your cloud provider for private networks when you click **Sync**, so it never calls provider APIs in the background. +::: + ## Per-Server Automatic Updates Automatic OS package updates are opt-in per server. Enable them from the server's [Security](./servers/security.md#automatic-updates) page and set the schedule. You'll get a notification when an update completes. diff --git a/docs/4.x/networks/create.md b/docs/4.x/networks/create.md new file mode 100644 index 000000000..a3e815cf0 --- /dev/null +++ b/docs/4.x/networks/create.md @@ -0,0 +1,87 @@ +# Creating a Network + +## Introduction + +Click **Create** on the Networks page to create a network. You can create **WireGuard** and **Custom** networks by hand. + +**Provider** networks cannot be created here — they appear automatically when Vito discovers them at your cloud provider. See [Provider Networks](provider-networks.md). + +## Creating a WireGuard Network + +Choose **WireGuard** as the type and fill in the following fields. + +### Name + +A name for the network, unique within the project. + +### Address Pool + +The range Vito allocates the network's address block from: + +- **CGNAT (100.64.0.0/10)** — the default. This range is reserved for carrier-grade NAT and is almost never used on a server's own interfaces, so it very rarely collides with anything. +- **Private (RFC1918)** — allocates from `10.0.0.0/8` or `192.168.0.0/16`. + +:::info +The RFC1918 option deliberately avoids `172.16.0.0/12`, because Docker and several cloud providers use it by default. Vito also skips ranges already used by your servers and a blocklist of common provider ranges. +::: + +### Block Size + +The prefix length of the block to allocate, between `/16` and `/28`. The default `/24` gives you 253 usable addresses for servers and peers combined. + +### Primary Server + +The first server to join the network. You can add more from the [Servers](servers.md) tab once the network exists. + +### Listen Port + +The UDP port WireGuard listens on, `51820` by default. If a selected server is already in another WireGuard network using that port, Vito picks the next free one. This also applies later: adding such a server from the [Servers](servers.md) tab moves the network to a free port. Members follow automatically, but [peers](peers.md) must download their configuration again. + +Vito opens this port in the server's firewall automatically, restricted to the other members of the network. + +## Creating a Custom Network + +Choose **Custom** as the type. Custom networks describe connectivity that already exists, so Vito does not configure anything on the servers. + +### CIDR + +Optional. The address range of the existing private network — IPv4 or IPv6, for example `10.0.0.0/24` or `fd00:1::/64`. + +If you provide it, [firewall rules](firewall.md) are scoped to the whole range. If you leave it empty, rules are scoped to each member's individual address instead, which is tighter. + +:::warning +Every member's address must fall inside the range you declare. Vito rejects a member whose address sits outside it, because the rules derived from the range would not cover that member while still opening the whole range. +::: + +### Primary Server and Private IP + +Select the first server, then choose which of its private IP addresses belongs to this network. + +The list contains the private addresses Vito has discovered for that server. If the address you need is missing, click the refresh action to re-detect the server's addresses, or add it from the server's [Network](../servers/network.md) page. + +:::warning +Each private IP address can belong to only one network member across your whole instance. If an address is already used by another network, pick a different one. +::: + +## After Creation + +Vito creates the network and starts configuring it: + +- **WireGuard networks** — WireGuard is installed on each server if needed, keys are generated, and the tunnel is brought up. The network moves to `active` once every server is configured. +- **Custom networks** — members are marked active immediately, and the network's firewall rules are applied. + +Every new network is seeded with a single **Allow all** firewall rule so members can reach each other on any port. Tighten this from the [Firewall](firewall.md) tab. + +:::info +If a server is offline when you create the network, its member stays `pending` and Vito configures it automatically once the server is reachable again. +::: + +## IPv6 + +Vito handles IPv6 wherever an address is given to it: + +- A server reachable only over IPv6 works as a WireGuard endpoint — the address is bracketed as `[2001:db8::1]:51820` in every generated config, and the handshake firewall rule uses a `/128` host prefix. +- **Custom** networks can be built from IPv6 private addresses and can declare an IPv6 range. +- **Provider** networks with IPv6 ranges are discovered and synced like IPv4 ones, and a member whose only address is IPv6 joins with that address. A network holds a single range, so a dual-stack VPC is recorded by its IPv4 range. + +The address block a **WireGuard** network hands out to its own members and peers is always IPv4, from the CGNAT or RFC1918 pool above. That is the address space inside the tunnel; it is independent of whether the servers reach each other over IPv4 or IPv6. diff --git a/docs/4.x/networks/firewall.md b/docs/4.x/networks/firewall.md new file mode 100644 index 000000000..be0b617c1 --- /dev/null +++ b/docs/4.x/networks/firewall.md @@ -0,0 +1,69 @@ +# Network Firewall + +## Introduction + +A network's **Firewall** tab controls what traffic is allowed between its members. Vito turns each rule into real firewall rules on every server in the network, using the server's own [UFW firewall](../servers/firewall.md). + +This is the main reason to define a network even when the private connectivity already exists: you describe the rule once, for the network, and Vito keeps it applied on every member. + +## The Default Rule + +Every new network starts with a single **Allow all** rule, which lets members reach each other on any port and protocol. Delete it and add narrower rules when you want to lock things down. + +## Creating a Rule + +Click **Create** and provide: + +### Name + +A label for the rule, for example `mysql` or `redis`. + +### Protocol + +The protocol to allow — TCP or UDP. Leave it empty to allow any protocol. + +### Port + +The port to allow, for example `3306`, or a range such as `3000:3010`. Leave it empty to allow any port. + +Protocol and port are independent — all four combinations are valid: + +| Protocol | Port | Allows | +| --- | --- | --- | +| TCP | `3306` | TCP on port 3306 | +| empty | `3306` | any protocol on port 3306 | +| UDP | empty | UDP on any port | +| empty | empty | all traffic from the network | + +:::info +A port **range** must name a protocol — UFW rejects a multi-port rule that doesn't. A single port works with or without one. +::: + +## How Rules Are Applied + +Vito translates each network rule into a rule on every member's firewall, scoped to the other members of that network: + +- On **WireGuard** networks, and on **custom** networks that have a CIDR, rules are scoped to the network's address range. +- On **custom** networks without a CIDR, and on all **provider** networks, rules are scoped to each member's individual address instead. This is tighter — the port is opened only to the specific servers in the network, not to everything sharing the range. + +WireGuard networks also get an automatic rule opening the tunnel's listen port to the other members, so the tunnel can be established. This rule is managed by Vito and is not affected when you delete the **Allow all** rule. + +Adding a [peer](peers.md) widens that rule to any source, because a laptop or CI runner connects from an address Vito can't know in advance. Only the tunnel's UDP listen port is opened; traffic still has to authenticate with a known peer key before it reaches anything. Remove every peer and the rule narrows back to the other members. + +:::info +Network rules appear on each server's own [Firewall](../servers/firewall.md) page as managed rules. They're shown there for visibility but are edited from the network, so they stay consistent across every member. +::: + +## Servers Without a Firewall + +A server with no firewall service installed still shows in the network, but nothing is enforced on it — the Servers tab shows whether each member has a firewall. + +If you install UFW on a server later, Vito applies the network's rules as part of the installation. + +## Deleting a Rule + +Deleting a rule removes it from every member's firewall. + +:::warning +Deleting the **Allow all** rule locks the network down to only the ports you've explicitly allowed. Make sure the services your servers rely on — a database port, for example — have their own rules first. +::: diff --git a/docs/4.x/networks/overview.md b/docs/4.x/networks/overview.md new file mode 100644 index 000000000..2012fe06c --- /dev/null +++ b/docs/4.x/networks/overview.md @@ -0,0 +1,72 @@ +# Networks + +## Introduction + +**Networks** let your servers talk to each other over private addresses instead of the public internet. A network belongs to a project, and every server in it can reach the others on an address that is never exposed publicly. + +This is useful when you want a web server to reach a database server, or a group of application servers to share a cache, without opening those services to the world. + +Networks live at the top of the sidebar, above **Servers**. + +## Network Types + +Vito supports three kinds of network. The type is chosen when the network is created and cannot be changed afterwards. + +| Type | Membership | Addresses | Best for | +| --- | --- | --- | --- | +| **WireGuard** | You choose the servers | Allocated by Vito | Servers spread across different providers or regions | +| **Custom** | You choose the servers and their IPs | You pick an existing private IP per server | A private network you set up yourself | +| **Provider** | Synced from your cloud provider | Reported by the provider | Mirroring a VPC you already run at a cloud provider | + +### WireGuard + +Vito builds an encrypted [WireGuard](https://www.wireguard.com/) tunnel between the servers you select. It installs WireGuard, generates a key pair per server, allocates an address block, and writes the configuration to each machine. + +Because the tunnel is encrypted and routed over the public internet, a WireGuard network works even when the servers are at different providers, in different regions, or behind NAT. It is the only type that supports [Peers](peers.md). + +See [Creating a Network](create.md) to set one up. + +### Custom + +A custom network describes private connectivity that already exists — for example two servers at the same provider that can already reach each other on a private interface. Vito does not configure anything on the machines; you tell it which servers are in the network and which of their private IP addresses to use. + +Vito still manages the [firewall rules](firewall.md) for the network, which is the main reason to define one. + +### Provider + +A provider network mirrors a private network (a VPC) that exists at your cloud provider. Vito discovers it by querying the provider's API, and keeps the membership in step with reality. + +Provider networks are **read-only**: you cannot add, edit, or remove servers by hand, and you cannot delete the network while its provider connection still exists. See [Provider Networks](provider-networks.md). + +## Statuses + +A network shows one of the following statuses, derived from the state of its servers: + +| Status | Meaning | +| --- | --- | +| `creating` | The network has no servers. A network created with servers moves straight to `syncing`. | +| `syncing` | At least one server is still being configured. | +| `active` | Every server is configured and in sync. | +| `failed` | At least one server failed to configure. Vito retries automatically. | +| `deleting` | The network is being torn down. | + +Each server in the network carries its own status — `pending`, `updating`, `active`, `failed`, or `leaving` — which you can see on the [Servers](servers.md) tab. + +:::info +Vito re-checks networks every three minutes and retries any server that is pending or failed, so a server that was offline when a change was made will pick it up once it is reachable again. See [Automation & Scheduled Tasks](../automation.md). +::: + +## Network Pages + +Once you open a network you'll find these tabs: + +| Tab | What's in it | +| --- | --- | +| **Overview** | Status, address range, and counts for servers, peers, and firewall rules | +| **Servers** | The servers in the network and their addresses | +| **Peers** | Devices such as laptops or CI runners (WireGuard only) | +| **Firewall** | Rules controlling what traffic is allowed inside the network | +| **Logs** | Activity recorded against the network and the servers it runs on | +| **Settings** | Rename the network, review its details, and delete it | + +The **Peers** tab is only available on WireGuard networks. On custom and provider networks the peer count on the Overview shows `N/A`. diff --git a/docs/4.x/networks/peers.md b/docs/4.x/networks/peers.md new file mode 100644 index 000000000..0fc40e544 --- /dev/null +++ b/docs/4.x/networks/peers.md @@ -0,0 +1,122 @@ +# Peers + +## Introduction + +**Peers** are devices that join a network without being a server Vito manages — a laptop, a CI runner, or an office router. A peer gets an address inside the network and can reach its servers privately. + +Peers are only available on **WireGuard** networks. On custom and provider networks the tab is disabled, since Vito doesn't control the tunnel. + +## Adding a Peer + +Click **Add peer** and give it a name. Vito allocates the next free address from the network's block and adds the peer to every member's configuration. + +You have two options for keys: + +- **Let Vito generate the keys.** The simplest option. Vito creates the key pair and can show you a ready-to-use configuration file. +- **Bring your own public key.** Provide a public key you generated yourself. Vito never sees the private key, which means the device's key material never leaves it. + +## Getting the Configuration + +Use **Show config** to display the WireGuard configuration for a peer. Drop it into the WireGuard client on the device. + +For peers where you supplied your own public key, Vito cannot know the private key, so the configuration contains `REPLACE_WITH_YOUR_PRIVATE_KEY` on the `PrivateKey` line. Substitute the key that stays on the device. + +:::warning +A peer's configuration contains the key material that grants access to your private network. Treat it like a password. +::: + +## Connecting from macOS + +Here's the full path from adding a peer to reaching your servers from a Mac. + +### 1. Install the WireGuard App + +Install **WireGuard** from the [Mac App Store](https://apps.apple.com/us/app/wireguard/id1451685025). It's the official client and is free. + +If you prefer the command line, `brew install wireguard-tools` gives you `wg` and `wg-quick` instead. The steps below use the app. + +### 2. Add the Peer in Vito + +On the network's **Peers** tab, click **Add peer** and give it a name such as `macbook`. Let Vito generate the keys — this is the simplest route and means the configuration you get is complete and ready to use. + +### 3. Copy the Configuration + +Use **Show config** on the peer you just created. You'll get a WireGuard configuration that looks roughly like this: + +```ini +[Interface] +Address = 100.64.0.5/32 +PrivateKey = + +[Peer] +PublicKey = +AllowedIPs = 100.64.0.0/24 +Endpoint = 203.0.113.10:51820 +PersistentKeepalive = 25 + +[Peer] +PublicKey = +AllowedIPs = 100.64.0.3/32 +Endpoint = 203.0.113.11:51820 +PersistentKeepalive = 25 +``` + +There is one `[Peer]` block per server in the network. The first one carries the whole network range in `AllowedIPs` so traffic for the network is routed through the tunnel; the rest are scoped to their own address. + +### 4. Create the Tunnel + +In the WireGuard app, click the **+** button in the bottom-left and choose **Add empty tunnel…**, then paste the configuration over the placeholder contents. Give the tunnel a name and click **Save**. + +macOS will ask for permission to add VPN configurations the first time — approve it. + +:::info +You can also save the configuration as a file ending in `.conf` and use **Import tunnel(s) from file…** instead. The file name becomes the tunnel name. +::: + +### 5. Activate and Test + +Select the tunnel and click **Activate**. The status changes to *Active* once the handshake succeeds. + +To confirm it's working, ping a server on its network address — you'll find it on the network's [Servers](servers.md) tab: + +```shell +ping 100.64.0.2 +``` + +Back in Vito, the peer's row on the **Peers** tab shows its last handshake once it has connected. + +### Bringing Your Own Key + +If you'd rather your private key never leave your Mac, generate a key pair first: + +```shell +wg genkey | tee privatekey | wg pubkey > publickey +``` + +Give Vito the contents of `publickey` when adding the peer, then paste the contents of `privatekey` into the `PrivateKey` line of the configuration Vito shows you. + +### Troubleshooting + +- **The tunnel activates but nothing is reachable.** Check that the servers you want to reach are `active` on the network's [Servers](servers.md) tab, and that the network's [firewall rules](firewall.md) allow the port you're using. +- **No handshake at all.** The peer needs to reach the server's `Endpoint` address and UDP port from wherever you are. Some restrictive networks block outbound UDP. +- **It worked and then stopped.** If the peer's keys were regenerated in Vito, the old configuration is dead — copy the new one. The same applies if the network's listen port moved: adding a server that already uses the port for another network shifts this one to the next free port, and the `Endpoint` line in an already-imported configuration still points at the old one. Vito warns when this happens; download the configuration again. + +For anything deeper — split tunnelling, routing all traffic through the network, or running WireGuard from the command line — see the [official WireGuard documentation](https://www.wireguard.com/quickstart/). + +## Regenerating Keys + +**Regenerate keys** issues a new key pair for a peer. The old configuration stops working immediately, so the device must be reconfigured with the new one. Use this if a device is lost or its key may have been exposed. + +## Disabling a Peer + +**Disable** removes the peer from every member's configuration without deleting it. Its address stays reserved, so enabling it again restores access on the same address. + +This is a quick way to cut off a device temporarily without having to reissue its configuration afterwards. + +## Removing a Peer + +**Remove** deletes the peer and takes it out of every member's configuration. Its address is released back into the pool. + +## Connection Status + +Vito periodically asks one of the network's members for its last handshake with every peer, so the peers list shows whether a device is currently connected and when it was last seen. A peer keeps its tunnel alive with every member, so any one of them can answer for it. diff --git a/docs/4.x/networks/provider-networks.md b/docs/4.x/networks/provider-networks.md new file mode 100644 index 000000000..8bdf9fcde --- /dev/null +++ b/docs/4.x/networks/provider-networks.md @@ -0,0 +1,98 @@ +# Provider Networks + +## Introduction + +If your servers already sit inside a private network at your cloud provider — a VPC — Vito can discover it and mirror it as a **provider network**. You get the same [firewall management](firewall.md) and visibility as any other network, without describing the topology by hand. + +Provider networks are created, updated, and removed entirely by syncing. They are read-only in the dashboard. + +## Supported Providers + +Vito reads private networks from the following [server provider](../settings/server-providers.md) connections: + +| Provider | Product | +| --- | --- | +| AWS | VPC | +| DigitalOcean | VPC | +| Hetzner | Cloud Networks | +| Linode | VPC | +| Vultr | VPC | + +Custom servers — those you added by IP address rather than through a provider connection — have no API to query and are skipped. + +:::info +Vito only reads. It never creates, changes, or deletes a network at your provider. +::: + +## Syncing + +### Syncing Every Network + +Click **Sync** on the Networks page. Vito looks at every server in the current project, groups them by the provider connection that created them, and asks each provider which private networks those servers belong to. + +For every network it finds, Vito will: + +- **Create** a provider network if it isn't in Vito yet. +- **Add or remove servers** so membership matches the provider. +- **Update addresses** when a server's private IP has changed. +- **Delete** the network if it no longer exists at the provider. + +Syncing runs in the background. The networks list updates as it progresses. + +### Syncing One Network + +Open a provider network and click **Sync** on its **Servers** tab to reconcile just that network. If the network no longer exists at the provider, it is removed from Vito. + +### Sync Is Manual + +Vito does not sync provider networks on a schedule — it only happens when you click **Sync**. Changes you make directly in your provider's console will not appear in Vito until you do. + +## What Gets Imported + +Vito is deliberately conservative about what it pulls in. + +- **Only networks containing your servers.** A VPC is imported only if at least one of its instances is a server Vito manages in the current project. Unrelated VPCs in your cloud account are ignored. +- **Only servers Vito manages.** Instances that Vito doesn't know about are ignored — they get no entry on the Servers tab and are not covered by the network's firewall rules. +- **Only the current project.** Syncing affects the project you're viewing, using the provider connections its servers were created with. + +:::warning +Because instances Vito doesn't manage are ignored, a provider network's firewall rules only cover the members you can see. Other machines in the same VPC are unaffected by them. +::: + +## What You Can and Can't Change + +Membership belongs to the provider, so Vito won't let you edit it: + +| Action | Provider network | +| --- | --- | +| Add a server | Not available — attach the instance at your provider, then sync | +| Change a server's IP | Not available — the provider reports it | +| Remove a server | Not available — detach the instance at your provider, then sync | +| Manage firewall rules | **Available** | +| Rename the network | **Available** | +| Delete the network | Not available while Vito can still sync it | + +Renaming is safe: Vito sets the name once, when the network is first imported, and never overwrites it afterwards. If you rename the VPC at your provider, your name in Vito stays. + +## Deleting + +You cannot delete a provider network while the connection it came from still exists — it would simply be re-imported on the next sync. Delete the network at your provider and sync instead. + +The exception is a network Vito can no longer sync, which becomes deletable so it isn't stranded. Its Settings page shows a notice explaining which case applies: + +- **Orphaned** — you deleted the [server provider connection](../settings/server-providers.md) it came from. +- **Cannot be synced** — none of its servers still carries the identifier the provider knows them by, so Vito has no way to ask the provider about the network. This is rare; it usually means a server's provider details were changed outside Vito. + +## Addresses and Firewall Rules + +Provider networks use the private addresses reported by the provider. Their [firewall rules](firewall.md) are scoped to each member's individual address rather than the whole VPC range, so a rule only ever opens a port to the specific servers Vito manages. + +## Troubleshooting + +**Nothing was imported.** Check that your servers were created through a provider connection, and that at least one of them is attached to a network at the provider. + +**A network didn't sync.** If Vito can't reach a provider — an invalid token, a permissions error, or a rate limit — it skips that connection and leaves its networks untouched rather than risk deleting them. Try again, and check the connection under [Server Providers](../settings/server-providers.md). + +**Permission errors.** Reading private networks needs a token with permission to list networks and instances. Some providers scope this separately from the permissions Vito needed when the connection was first made — for example DigitalOcean requires the `vpc:read` scope. A connection that works for creating servers may still need its token updated. + +**A duplicate-looking network appeared.** If a discovered network's name clashes with an existing one in the project, Vito appends a suffix (for example `default-2`) rather than failing. diff --git a/docs/4.x/networks/servers.md b/docs/4.x/networks/servers.md new file mode 100644 index 000000000..7064547b2 --- /dev/null +++ b/docs/4.x/networks/servers.md @@ -0,0 +1,59 @@ +# Network Servers + +## Introduction + +The **Servers** tab lists the servers that belong to a network, the address each one uses inside it, and whether the server has a firewall installed. + +## Adding a Server + +Click **Add server** and choose a server from the project. + +On a **WireGuard** network, Vito allocates the next free address from the network's block, generates a key pair, installs WireGuard if needed, and updates every other member so they know about the new one. + +On a **Custom** network, you also choose which of the server's private IP addresses to use. + +Adding a server is not available on [provider networks](provider-networks.md) — attach the instance at your cloud provider and sync instead. + +:::info +A server can belong to several networks at once. On WireGuard networks, each one gets its own interface and its own listen port. +::: + +## Server Status + +Each member shows its own status: + +| Status | Meaning | +| --- | --- | +| `pending` | Waiting to be configured — usually because the server is offline | +| `updating` | Vito is applying the configuration now | +| `active` | Configured and in sync | +| `failed` | Configuration failed; Vito retries automatically | +| `leaving` | Being removed from the network | + +Vito re-checks pending and failed members every three minutes, so a server that was unreachable is picked up once it is back. That interval is how often a retry is attempted, not a deadline - a member that keeps failing is retried across several passes before it converges. After several consecutive failures the member drops to an hourly retry instead, so a server that stays broken for a long time no longer costs a sync attempt every three minutes, and still recovers on its own once it is fixed. **Sync** on the member forces an immediate attempt at any point. + +## Changing a Server's IP + +On a **Custom** network, use **Edit** to change which private IP address the server uses in this network. Vito re-applies the network's firewall rules with the new address. + +This is not available on WireGuard networks, where Vito owns the addressing, or on provider networks, where the provider does. + +## Regenerating Configuration + +On a **WireGuard** network, **Regenerate** re-applies the network configuration to a single server. Use it if a server's tunnel has drifted or you want to force a rewrite. + +## Syncing the Whole Network + +**Sync** re-applies the configuration to every server in the network. On a [provider network](provider-networks.md) it does something different: it queries your cloud provider and reconciles membership. + +## Removing a Server + +**Remove** takes the server out of the network. Vito tears the configuration down on that machine, removes the network's firewall rules from it, and updates the remaining members so they stop routing to it. + +If the server is unreachable — for example it has already been destroyed — Vito retries for a few minutes and then removes the membership anyway so the network can settle. + +Removing a server is not available on provider networks. + +:::warning +Removing a server drops its private connectivity immediately. Make sure nothing is relying on that address first. +::: diff --git a/docs/4.x/servers/firewall.md b/docs/4.x/servers/firewall.md index 0194a9176..2f70cb274 100644 --- a/docs/4.x/servers/firewall.md +++ b/docs/4.x/servers/firewall.md @@ -23,6 +23,10 @@ These rules are applied in the server level, Your server provider might have add Do not remove the SSH rule, This will cause Vito to lose connection to your server. ::: +## Network Rules + +If the server belongs to a [network](../networks/overview.md), the rules for that network also appear on this page as managed rules. They are listed here for visibility, but you edit them from the network's [Firewall](../networks/firewall.md) tab so they stay consistent across every server in the network. + ## Create new Rule You can easily create firewall rules and deploy them to your server. diff --git a/docs/4.x/servers/network.md b/docs/4.x/servers/network.md index ddbf53cb2..2b44c3910 100644 --- a/docs/4.x/servers/network.md +++ b/docs/4.x/servers/network.md @@ -4,6 +4,10 @@ The **Network** section of a server lets you view and manage the server's IP addresses. Vito detects the addresses configured on the server and lets you add or remove additional ones. +:::info +This page is about the addresses on a single server. To connect several servers together privately, see [Networks](../networks/overview.md). A [custom network](../networks/create.md) is built from the private addresses listed here. +::: + ## Viewing IP Addresses The Network page lists every IP address Vito knows about for the server, including: diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index 714a0510e..2692a627c 100644 --- a/docs/4.x/settings/server-providers.md +++ b/docs/4.x/settings/server-providers.md @@ -6,6 +6,8 @@ Vito provides integration with multiple server providers to deploy your servers When creating new servers, You can select the provider you've connected and deploy your servers with a few clicks. +A connected provider is also used to discover the private networks your servers already belong to — see [Provider Networks](../networks/provider-networks.md). + ## Supported Providers - AWS diff --git a/eslint.config.js b/eslint.config.js index e3d7b1934..7452ea534 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,7 +37,7 @@ export default [ }, }, { - ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + ignores: ['vendor', 'node_modules', 'public', 'storage', 'bootstrap/ssr', 'tailwind.config.js'], }, prettier, // Turn off all rules that might conflict with Prettier ]; diff --git a/resources/js/components/app-sidebar-nested.tsx b/resources/js/components/app-sidebar-nested.tsx index a20c92c1b..659598a35 100644 --- a/resources/js/components/app-sidebar-nested.tsx +++ b/resources/js/components/app-sidebar-nested.tsx @@ -1,4 +1,5 @@ import { NavUser } from '@/components/nav-user'; +import { currentPath } from '@/lib/utils'; import { Sidebar, SidebarContent, @@ -237,7 +238,7 @@ export function AppSidebar() { const getMenuItems = (items: NavItem[]) => { return items.map((item) => { - const isActive = item.onlyActivePath ? window.location.href === item.href : window.location.href.startsWith(item.href); + const isActive = item.onlyActivePath ? currentPath() === item.href : window.location.href.startsWith(item.href); if (item.children && item.children.length > 0) { return ( diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 1d5ad064e..d53cc84da 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,4 +1,5 @@ import { NavUser } from '@/components/nav-user'; +import { currentPath } from '@/lib/utils'; import { Sidebar, SidebarContent, @@ -24,6 +25,7 @@ import { ListEndIcon, LogsIcon, MousePointerClickIcon, + NetworkIcon, ServerIcon, Settings2Icon, WorkflowIcon, @@ -38,6 +40,11 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? const page = usePage(); const mainNavItems: NavItem[] = [ + { + title: 'Networks', + href: route('networks'), + icon: NetworkIcon, + }, { title: 'Servers', href: route('servers'), @@ -136,7 +143,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?