From 98cf83d4c36ae16b53cbe265161b3ec008a51b57 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Tue, 21 Jul 2026 18:02:52 +0100 Subject: [PATCH 01/27] feat: wireguard & provider networking --- app/Actions/Network/AddServersToNetwork.php | 160 ++++++ app/Actions/Network/AllocateNetworkBlock.php | 132 +++++ app/Actions/Network/ApplyNetworkFirewall.php | 48 ++ .../Network/CompileServerFirewallRules.php | 159 ++++++ app/Actions/Network/CreateNetwork.php | 238 +++++++++ app/Actions/Network/DeleteNetwork.php | 27 + .../Network/DispatchNetworkServerSync.php | 33 ++ app/Actions/Network/GenerateWireGuardKeys.php | 27 + .../Network/ManageNetworkFirewallRule.php | 108 ++++ .../Network/RecomputeNetworkStatus.php | 59 +++ .../Network/RemoveServerFromNetwork.php | 38 ++ app/Actions/Network/SyncNetwork.php | 41 ++ app/Actions/Network/UpdateNetwork.php | 56 ++ app/Actions/Service/Install.php | 7 +- .../Commands/ReconcileNetworksCommand.php | 161 ++++++ app/Console/Kernel.php | 1 + app/Enums/NetworkAddressingPool.php | 28 + app/Enums/NetworkServerStatus.php | 31 ++ app/Enums/NetworkStatus.php | 31 ++ app/Enums/NetworkType.php | 28 + app/Http/Controllers/FirewallController.php | 10 + app/Http/Controllers/NetworkController.php | 125 +++++ .../NetworkFirewallRuleController.php | 56 ++ .../Controllers/NetworkServerController.php | 57 ++ .../Resources/NetworkFirewallRuleResource.php | 31 ++ app/Http/Resources/NetworkResource.php | 35 ++ app/Http/Resources/NetworkServerResource.php | 31 ++ app/Jobs/Network/ApplyNetworkFirewallJob.php | 48 ++ app/Jobs/Network/SyncNetworkServerJob.php | 180 +++++++ app/Models/Network.php | 78 +++ app/Models/NetworkFirewallRule.php | 49 ++ app/Models/NetworkServer.php | 72 +++ app/Models/Project.php | 9 + app/Models/Server.php | 38 ++ app/Policies/NetworkPolicy.php | 40 ++ app/Providers/ServiceTypeServiceProvider.php | 11 + app/Services/Firewall/Ufw.php | 10 +- app/Services/VPN/VPN.php | 14 + app/Services/VPN/WireGuard.php | 151 ++++++ app/Support/Cidr.php | 96 ++++ app/Tables/NetworkTable.php | 39 ++ .../Networks/NetworkFirewallRuleTable.php | 34 ++ app/Tables/Networks/NetworkServerTable.php | 32 ++ database/factories/NetworkFactory.php | 35 ++ .../factories/NetworkFirewallRuleFactory.php | 29 + database/factories/NetworkServerFactory.php | 31 ++ ...026_07_21_131503_create_networks_table.php | 35 ++ ...21_131548_create_network_servers_table.php | 35 ++ ...21_create_network_firewall_rules_table.php | 31 ++ package-lock.json | 47 +- resources/js/components/app-sidebar.tsx | 6 + resources/js/components/dialogs/registry.ts | 6 + resources/js/pages/firewall/index.tsx | 28 +- .../pages/networks/components/add-server.tsx | 127 +++++ .../networks/components/create-network.tsx | 213 ++++++++ .../components/firewall-rule-form.tsx | 134 +++++ resources/js/pages/networks/index.tsx | 88 ++++ resources/js/pages/networks/show.tsx | 200 +++++++ .../js/pages/services/components/install.tsx | 4 +- resources/js/types/network.d.ts | 54 ++ .../firewall/ufw/apply-rules.blade.php | 7 + resources/views/ssh/wireguard/conf.blade.php | 12 + .../views/ssh/wireguard/configure.blade.php | 13 + .../views/ssh/wireguard/install.blade.php | 9 + .../ssh/wireguard/remove-network.blade.php | 4 + .../views/ssh/wireguard/uninstall.blade.php | 4 + tests/Feature/NetworkFirewallTest.php | 373 +++++++++++++ tests/Feature/NetworkSyncTest.php | 497 ++++++++++++++++++ tests/Feature/NetworkTest.php | 375 +++++++++++++ 69 files changed, 5005 insertions(+), 51 deletions(-) create mode 100644 app/Actions/Network/AddServersToNetwork.php create mode 100644 app/Actions/Network/AllocateNetworkBlock.php create mode 100644 app/Actions/Network/ApplyNetworkFirewall.php create mode 100644 app/Actions/Network/CompileServerFirewallRules.php create mode 100644 app/Actions/Network/CreateNetwork.php create mode 100644 app/Actions/Network/DeleteNetwork.php create mode 100644 app/Actions/Network/DispatchNetworkServerSync.php create mode 100644 app/Actions/Network/GenerateWireGuardKeys.php create mode 100644 app/Actions/Network/ManageNetworkFirewallRule.php create mode 100644 app/Actions/Network/RecomputeNetworkStatus.php create mode 100644 app/Actions/Network/RemoveServerFromNetwork.php create mode 100644 app/Actions/Network/SyncNetwork.php create mode 100644 app/Actions/Network/UpdateNetwork.php create mode 100644 app/Console/Commands/ReconcileNetworksCommand.php create mode 100644 app/Enums/NetworkAddressingPool.php create mode 100644 app/Enums/NetworkServerStatus.php create mode 100644 app/Enums/NetworkStatus.php create mode 100644 app/Enums/NetworkType.php create mode 100644 app/Http/Controllers/NetworkController.php create mode 100644 app/Http/Controllers/NetworkFirewallRuleController.php create mode 100644 app/Http/Controllers/NetworkServerController.php create mode 100644 app/Http/Resources/NetworkFirewallRuleResource.php create mode 100644 app/Http/Resources/NetworkResource.php create mode 100644 app/Http/Resources/NetworkServerResource.php create mode 100644 app/Jobs/Network/ApplyNetworkFirewallJob.php create mode 100644 app/Jobs/Network/SyncNetworkServerJob.php create mode 100644 app/Models/Network.php create mode 100644 app/Models/NetworkFirewallRule.php create mode 100644 app/Models/NetworkServer.php create mode 100644 app/Policies/NetworkPolicy.php create mode 100644 app/Services/VPN/VPN.php create mode 100644 app/Services/VPN/WireGuard.php create mode 100644 app/Support/Cidr.php create mode 100644 app/Tables/NetworkTable.php create mode 100644 app/Tables/Networks/NetworkFirewallRuleTable.php create mode 100644 app/Tables/Networks/NetworkServerTable.php create mode 100644 database/factories/NetworkFactory.php create mode 100644 database/factories/NetworkFirewallRuleFactory.php create mode 100644 database/factories/NetworkServerFactory.php create mode 100644 database/migrations/2026_07_21_131503_create_networks_table.php create mode 100644 database/migrations/2026_07_21_131548_create_network_servers_table.php create mode 100644 database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php create mode 100644 resources/js/pages/networks/components/add-server.tsx create mode 100644 resources/js/pages/networks/components/create-network.tsx create mode 100644 resources/js/pages/networks/components/firewall-rule-form.tsx create mode 100644 resources/js/pages/networks/index.tsx create mode 100644 resources/js/pages/networks/show.tsx create mode 100644 resources/js/types/network.d.ts create mode 100644 resources/views/ssh/wireguard/conf.blade.php create mode 100644 resources/views/ssh/wireguard/configure.blade.php create mode 100644 resources/views/ssh/wireguard/install.blade.php create mode 100644 resources/views/ssh/wireguard/remove-network.blade.php create mode 100644 resources/views/ssh/wireguard/uninstall.blade.php create mode 100644 tests/Feature/NetworkFirewallTest.php create mode 100644 tests/Feature/NetworkSyncTest.php create mode 100644 tests/Feature/NetworkTest.php diff --git a/app/Actions/Network/AddServersToNetwork.php b/app/Actions/Network/AddServersToNetwork.php new file mode 100644 index 000000000..0eec986c5 --- /dev/null +++ b/app/Actions/Network/AddServersToNetwork.php @@ -0,0 +1,160 @@ + $input + */ + public function add(Network $network, array $input): void + { + $this->validate($network, $input); + + $newMemberIds = DB::transaction(function () use ($network, $input): array { + return $network->type === NetworkType::WIREGUARD + ? $this->addWireGuard($network, $input) + : $this->addProvider($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); + } + } + } elseif ($network->firewall_enabled) { + $this->firewall->handle($network); + } + + $this->recompute->handle($network); + } + + /** + * @param array $input + * @return array + */ + private function addWireGuard(Network $network, array $input): array + { + $used = $network->servers()->lockForUpdate()->pluck('ip')->filter()->values()->all(); + + $servers = Server::query() + ->where('project_id', $network->project_id) + ->whereIn('id', $input['servers']) + ->get(); + + $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; + } + + /** + * @param array $input + * @return array + */ + private function addProvider(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.*' => [ + 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::PROVIDER) { + $rules['ip_addresses'] = ['required', 'array']; + 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'), + ]; + } + } + + Validator::make($input, $rules)->validate(); + + if ($network->type === NetworkType::WIREGUARD) { + $this->validateNoPortConflict($network, $input['servers'] ?? []); + } + } + + /** + * @param array $serverIds + */ + private function validateNoPortConflict(Network $network, array $serverIds): void + { + $conflict = Network::query() + ->where('type', NetworkType::WIREGUARD) + ->whereKeyNot($network->id) + ->where('port', $network->port) + ->whereHas('servers', fn ($query) => $query->whereIn('server_id', $serverIds)) + ->exists(); + + if ($conflict) { + throw ValidationException::withMessages([ + 'servers' => __('A selected server already belongs to another WireGuard network using port :port.', ['port' => $network->port]), + ]); + } + } +} diff --git a/app/Actions/Network/AllocateNetworkBlock.php b/app/Actions/Network/AllocateNetworkBlock.php new file mode 100644 index 000000000..a933eb5fa --- /dev/null +++ b/app/Actions/Network/AllocateNetworkBlock.php @@ -0,0 +1,132 @@ + + */ + 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::base($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 $memberServers + ->flatMap(fn (Server $server): Collection => $server->ipAddresses()->get()) + ->filter(fn ($address): bool => filter_var($address->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) + ->map(fn ($address): string => Cidr::canonical($address->ip.'/'.$address->prefix_length)) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Actions/Network/ApplyNetworkFirewall.php b/app/Actions/Network/ApplyNetworkFirewall.php new file mode 100644 index 000000000..9bea161e6 --- /dev/null +++ b/app/Actions/Network/ApplyNetworkFirewall.php @@ -0,0 +1,48 @@ +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) { + 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/CompileServerFirewallRules.php b/app/Actions/Network/CompileServerFirewallRules.php new file mode 100644 index 000000000..4ed859285 --- /dev/null +++ b/app/Actions/Network/CompileServerFirewallRules.php @@ -0,0 +1,159 @@ +` catch-all (network is allow-all by default). + * + * @return array + */ + public function forServer(Server $server): array + { + $memberships = NetworkServer::query() + ->where('server_id', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('network') + ->get(); + + $handshakes = []; + $ruleSpecs = []; + $catchAlls = []; + + foreach ($memberships as $membership) { + $network = $membership->network; + $wireguard = $network->type === NetworkType::WIREGUARD; + + if ($wireguard) { + $handshakes = array_merge($handshakes, $this->handshakeSpecs($network, $server)); + } + + if (! $wireguard && ! $network->firewall_enabled) { + continue; + } + + $sources = $this->sources($network, $server); + + if ($network->firewall_enabled) { + $ruleSpecs = array_merge($ruleSpecs, $this->ruleSpecs($network, $sources)); + } + + foreach ($sources as $source) { + $catchAlls[] = $this->spec('allow', $source['ip'], $source['mask'], null, null); + } + } + + return array_merge($handshakes, $ruleSpecs, $catchAlls); + } + + /** + * @return array + */ + private function handshakeSpecs(Network $network, Server $server): array + { + return $this->peers($network, $server) + ->filter(fn (NetworkServer $peer): bool => filled($peer->server->ip)) + ->map(fn (NetworkServer $peer): stdClass => $this->spec( + 'allow', + $peer->server->ip, + 32, + 'udp', + (string) $network->port, + )) + ->values() + ->all(); + } + + /** + * @param array $sources + * @return array + */ + private function ruleSpecs(Network $network, array $sources): array + { + $specs = []; + + $rules = $network->firewallRules() + ->where('status', '!=', FirewallRuleStatus::DELETING) + ->orderBy('position') + ->orderBy('id') + ->get(); + + foreach ($rules as $rule) { + foreach ($sources as $source) { + $specs[] = $this->spec( + $rule->type, + $source['ip'], + $source['mask'], + $rule->protocol, + $rule->port, + ); + } + } + + return $specs; + } + + /** + * The network source(s) for per-port/catch-all rules: the network CIDR when + * set, otherwise (provider networks without a CIDR) each other member's + * private IP. + * + * @return array + */ + private function sources(Network $network, Server $server): array + { + if ($network->cidr !== null && $network->cidr !== '') { + return [[ + 'ip' => long2ip(Cidr::base($network->cidr)), + 'mask' => Cidr::prefix($network->cidr), + ]]; + } + + return $this->peers($network, $server) + ->filter(fn (NetworkServer $peer): bool => $peer->serverIpAddress !== null) + ->map(fn (NetworkServer $peer): array => [ + 'ip' => (string) $peer->serverIpAddress->ip, + 'mask' => 32, + ]) + ->values() + ->all(); + } + + /** + * @return \Illuminate\Support\Collection + */ + private function peers(Network $network, Server $server): \Illuminate\Support\Collection + { + return NetworkServer::query() + ->where('network_id', $network->id) + ->where('server_id', '!=', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server', 'serverIpAddress') + ->get(); + } + + private function spec(string $type, ?string $source, ?int $mask, ?string $protocol, ?string $port): stdClass + { + return (object) [ + 'type' => $type, + 'source' => $source, + 'mask' => $mask, + 'protocol' => $protocol, + 'port' => $port, + ]; + } +} diff --git a/app/Actions/Network/CreateNetwork.php b/app/Actions/Network/CreateNetwork.php new file mode 100644 index 000000000..81e7745a6 --- /dev/null +++ b/app/Actions/Network/CreateNetwork.php @@ -0,0 +1,238 @@ + $input + */ + public function create(Project $project, array $input): Network + { + $this->validate($project, $input); + + $type = NetworkType::from($input['type']); + + $network = DB::transaction(fn (): Network => $type === NetworkType::WIREGUARD + ? $this->buildWireGuard($project, $input) + : $this->buildProvider($project, $input)); + + if ($network->type === NetworkType::WIREGUARD) { + $network->load('servers.server'); + foreach ($network->servers as $member) { + $this->sync->toPresent($member); + } + } elseif ($network->firewall_enabled) { + $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 \Illuminate\Support\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->allocatePort($project, $members->pluck('id')->all(), (int) ($input['port'] ?? 51820)), + 'firewall_enabled' => (bool) ($input['firewall_enabled'] ?? false), + ]); + + $used = []; + foreach ($members as $server) { + $ip = Cidr::nextHost($cidr, $used); + if ($ip === null) { + throw ValidationException::withMessages([ + 'servers' => __('The network address block is full.'), + ]); + } + $used[] = $ip; + + $keys = $this->keys->generate(); + $network->servers()->create([ + 'server_id' => $server->id, + 'ip' => $ip, + 'public_key' => $keys['public_key'], + 'private_key' => $keys['private_key'], + 'status' => NetworkServerStatus::PENDING, + ]); + } + + return $network; + } + + /** + * @param array $input + */ + private function buildProvider(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()->where('cidr_canonical', $canonical)->exists()) { + throw ValidationException::withMessages([ + 'cidr' => __('This CIDR is already used by another network in this project.'), + ]); + } + } + + $network = Network::create([ + 'project_id' => $project->id, + 'name' => $input['name'], + 'type' => NetworkType::PROVIDER, + 'status' => NetworkStatus::ACTIVE, + 'cidr' => $input['cidr'] ?? null, + 'cidr_canonical' => isset($input['cidr']) ? Cidr::canonical($input['cidr']) : null, + 'firewall_enabled' => (bool) ($input['firewall_enabled'] ?? false), + ]); + + foreach ($input['servers'] as $serverId) { + $network->servers()->create([ + 'server_id' => $serverId, + 'server_ip_address_id' => $input['ip_addresses'][$serverId], + 'status' => NetworkServerStatus::ACTIVE, + ]); + } + + return $network; + } + + /** + * @param array $serverIds + */ + private function allocatePort(Project $project, array $serverIds, int $requested): int + { + $used = Network::query() + ->where('type', NetworkType::WIREGUARD) + ->where('project_id', $project->id) + ->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; + } + + /** + * @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::PROVIDER->value, NetworkType::WIREGUARD->value]), + ], + 'servers' => ['required', 'array', 'min:1'], + 'servers.*' => [ + 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::PROVIDER->value) { + $rules['cidr'] = ['nullable', 'string', $this->ipv4CidrRule()]; + $rules['ip_addresses'] = ['required', 'array']; + 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'), + ]; + } + } + + Validator::make($input, $rules)->validate(); + } + + private function ipv4CidrRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + $parts = explode('/', (string) $value); + $validPrefix = isset($parts[1]) && ctype_digit($parts[1]) && (int) $parts[1] >= 0 && (int) $parts[1] <= 32; + + if (count($parts) !== 2 || ! $validPrefix + || filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + $fail(__('The :attribute must be a valid IPv4 CIDR.')); + } + }; + } +} 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/DispatchNetworkServerSync.php b/app/Actions/Network/DispatchNetworkServerSync.php new file mode 100644 index 000000000..8a8650ccc --- /dev/null +++ b/app/Actions/Network/DispatchNetworkServerSync.php @@ -0,0 +1,33 @@ +server->isReady()) { + $member->status = NetworkServerStatus::UPDATING; + $member->save(); + 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(); + + dispatch(new SyncNetworkServerJob($member, true))->onQueue('ssh'); + } +} 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/ManageNetworkFirewallRule.php b/app/Actions/Network/ManageNetworkFirewallRule.php new file mode 100644 index 000000000..d8ed31295 --- /dev/null +++ b/app/Actions/Network/ManageNetworkFirewallRule.php @@ -0,0 +1,108 @@ + $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->applyIfEnabled($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->applyIfEnabled($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->applyIfEnabled($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, + )); + } + + private function applyIfEnabled(Network $network): void + { + if ($network->firewall_enabled) { + $this->apply->handle($network); + } + } + + /** + * @param array $input + * @return array + */ + private function attributes(array $input): array + { + $port = $input['port'] ?? null; + + return [ + 'name' => $input['name'], + 'type' => $input['type'], + 'protocol' => $input['protocol'] ?? null, + 'port' => ($port === null || $port === '') ? null : (string) $port, + 'position' => (int) ($input['position'] ?? 0), + 'status' => FirewallRuleStatus::READY, + ]; + } + + /** + * @param array $input + */ + private function validate(array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:allow,deny'], + 'position' => ['nullable', 'integer', 'min:0'], + 'protocol' => ['nullable', 'in:tcp,udp', 'required_with:port'], + 'port' => ['nullable', 'required_with:protocol', new PortOrPortRangeRule], + ])->validate(); + } +} diff --git a/app/Actions/Network/RecomputeNetworkStatus.php b/app/Actions/Network/RecomputeNetworkStatus.php new file mode 100644 index 000000000..e7f4f7332 --- /dev/null +++ b/app/Actions/Network/RecomputeNetworkStatus.php @@ -0,0 +1,59 @@ +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->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(); + } + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $network->project_id, + type: 'network.updated', + data: new NetworkResource($network->loadCount('servers')), + )); + } +} diff --git a/app/Actions/Network/RemoveServerFromNetwork.php b/app/Actions/Network/RemoveServerFromNetwork.php new file mode 100644 index 000000000..4f0eb1662 --- /dev/null +++ b/app/Actions/Network/RemoveServerFromNetwork.php @@ -0,0 +1,38 @@ +network; + + $this->sync->teardown($member); + + if ($network->type === NetworkType::WIREGUARD) { + $network->load('servers.server'); + foreach ($network->servers as $sibling) { + if ($sibling->id !== $member->id + && in_array($sibling->status, [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING], true)) { + $this->sync->toPresent($sibling); + } + } + } elseif ($network->firewall_enabled) { + $this->firewall->handle($network); + } + + $this->recompute->handle($network); + } +} diff --git a/app/Actions/Network/SyncNetwork.php b/app/Actions/Network/SyncNetwork.php new file mode 100644 index 000000000..637bb083a --- /dev/null +++ b/app/Actions/Network/SyncNetwork.php @@ -0,0 +1,41 @@ +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/UpdateNetwork.php b/app/Actions/Network/UpdateNetwork.php new file mode 100644 index 000000000..b79946660 --- /dev/null +++ b/app/Actions/Network/UpdateNetwork.php @@ -0,0 +1,56 @@ + $input + */ + public function update(Network $network, array $input): Network + { + $this->validate($network, $input); + + $firewallEnabled = array_key_exists('firewall_enabled', $input) + ? (bool) $input['firewall_enabled'] + : $network->firewall_enabled; + + $firewallChanged = $firewallEnabled !== $network->firewall_enabled; + + $network->update([ + 'name' => $input['name'] ?? $network->name, + 'firewall_enabled' => $firewallEnabled, + ]); + + if ($firewallChanged) { + $this->apply->handle($network); + } + + 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), + ], + 'firewall_enabled' => ['sometimes', 'boolean'], + ])->validate(); + } +} 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..0ba02fb54 --- /dev/null +++ b/app/Console/Commands/ReconcileNetworksCommand.php @@ -0,0 +1,161 @@ +reconcilePending(); + $this->reconcileFailed(); + $this->reconcileStaleUpdating(); + $this->reconcileLeaving(); + } + + 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); + } + }); + } + + private function reconcileFailed(): void + { + NetworkServer::query() + ->where('status', NetworkServerStatus::FAILED) + ->where('sync_attempts', '<', self::MAX_ATTEMPTS) + ->whereHas('server', $this->reachable()) + ->pluck('id') + ->each(function (int $id): void { + $claimed = NetworkServer::query() + ->whereKey($id) + ->where('status', NetworkServerStatus::FAILED) + ->where('sync_attempts', '<', self::MAX_ATTEMPTS) + ->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); + } + }); + } + + private function reconcileLeaving(): void + { + $threshold = now()->subMinutes(2); + + NetworkServer::query() + ->where('status', NetworkServerStatus::LEAVING) + ->where('updated_at', '<', $threshold) + ->with('network', 'server') + ->get() + ->each(function (NetworkServer $member) use ($threshold): void { + if ($member->sync_attempts >= self::MAX_ATTEMPTS) { + $this->forceConverge($member); + + 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::log( + $member->server, + 'network-leave-incomplete', + 'On-server cleanup could not complete after repeated attempts; membership force-removed.' + ); + + $member->delete(); + + SocketEvent::dispatch(new SocketEventDTO( + projectId: $projectId, + type: 'network-server.deleted', + data: ['id' => $memberId], + )); + + 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 10021e8f2..a6eb276ef 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(); $schedule->command('ssl:renew-wildcards')->daily(); $schedule->command('ssl:check-expiry')->daily(); $schedule->command('github-app:sync')->cron('0 */4 * * *'); 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/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..e35e45341 --- /dev/null +++ b/app/Enums/NetworkType.php @@ -0,0 +1,28 @@ + 'info', + self::WIREGUARD => 'success', + }; + } + + public function getText(): string + { + return match ($this) { + self::PROVIDER => 'Provider Managed', + self::WIREGUARD => 'WireGuard', + }; + } +} diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php index b67e1ac96..d701d2a7c 100644 --- a/app/Http/Controllers/FirewallController.php +++ b/app/Http/Controllers/FirewallController.php @@ -4,6 +4,7 @@ use App\Actions\FirewallRule\ManageRule; use App\Models\FirewallRule; +use App\Models\Network; use App\Models\Server; use App\Tables\Servers\FirewallRuleTable; use Illuminate\Http\RedirectResponse; @@ -28,6 +29,15 @@ public function index(Server $server): Response return Inertia::render('firewall/index', [ 'rules' => FirewallRuleTable::make($server->firewallRules())->simplePaginate(), + 'managedNetworks' => Network::query() + ->where('firewall_enabled', true) + ->whereHas('servers', fn ($query) => $query->where('server_id', $server->id)) + ->get() + ->map(fn (Network $network): array => [ + 'id' => $network->id, + 'name' => $network->name, + ]) + ->values(), ]); } diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php new file mode 100644 index 000000000..94614f7e1 --- /dev/null +++ b/app/Http/Controllers/NetworkController.php @@ -0,0 +1,125 @@ +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); + + $memberServerIds = $network->servers()->pluck('server_id')->all(); + + return Inertia::render('networks/show', [ + 'network' => new NetworkResource($network->loadCount('servers')), + 'members' => NetworkServerTable::make($network->servers())->identifier('members')->simplePaginate(), + 'rules' => NetworkFirewallRuleTable::make($network->firewallRules())->identifier('rules')->simplePaginate(), + 'servers' => $this->serversPayload($network->project, $memberServerIds), + ]); + } + + #[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('/{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 + * @return array> + */ + private function serversPayload(Project $project, array $excludeServerIds = []): array + { + return $project->servers() + ->when($excludeServerIds !== [], fn ($query) => $query->whereNotIn('id', $excludeServerIds)) + ->with(['ipAddresses' => fn ($query) => $query->where('type', IpAddressType::PRIVATE)]) + ->get() + ->map(fn (Server $server): array => [ + 'id' => $server->id, + 'name' => $server->name, + 'is_ready' => $server->isReady(), + 'private_ips' => $server->ipAddresses->map(fn ($ip): array => [ + 'id' => $ip->id, + 'ip' => $ip->ip, + 'is_primary' => $ip->is_primary, + ])->values(), + ]) + ->all(); + } +} diff --git a/app/Http/Controllers/NetworkFirewallRuleController.php b/app/Http/Controllers/NetworkFirewallRuleController.php new file mode 100644 index 000000000..abf9a4a7c --- /dev/null +++ b/app/Http/Controllers/NetworkFirewallRuleController.php @@ -0,0 +1,56 @@ +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/NetworkServerController.php b/app/Http/Controllers/NetworkServerController.php new file mode 100644 index 000000000..7460ae038 --- /dev/null +++ b/app/Http/Controllers/NetworkServerController.php @@ -0,0 +1,57 @@ +authorize('update', $network); + + app(AddServersToNetwork::class)->add($network, $request->all()); + + 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); + + app(SyncNetwork::class)->member($networkServer); + + return back()->with('info', 'Server configuration is being regenerated.'); + } + + #[Delete('/{networkServer}', name: 'networks.servers.destroy')] + public function destroy(Network $network, NetworkServer $networkServer): RedirectResponse + { + $this->authorize('update', $network); + $this->ensureBelongsToNetwork($network, $networkServer); + + 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); + } +} diff --git a/app/Http/Resources/NetworkFirewallRuleResource.php b/app/Http/Resources/NetworkFirewallRuleResource.php new file mode 100644 index 000000000..15ba56675 --- /dev/null +++ b/app/Http/Resources/NetworkFirewallRuleResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'network_id' => $this->network_id, + 'name' => $this->name, + 'type' => $this->type, + 'protocol' => $this->protocol, + 'port' => $this->port, + 'position' => $this->position, + '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/NetworkResource.php b/app/Http/Resources/NetworkResource.php new file mode 100644 index 000000000..ba6780203 --- /dev/null +++ b/app/Http/Resources/NetworkResource.php @@ -0,0 +1,35 @@ + + */ + 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, + 'firewall_enabled' => $this->firewall_enabled, + '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/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/Jobs/Network/ApplyNetworkFirewallJob.php b/app/Jobs/Network/ApplyNetworkFirewallJob.php new file mode 100644 index 000000000..bc40ec772 --- /dev/null +++ b/app/Jobs/Network/ApplyNetworkFirewallJob.php @@ -0,0 +1,48 @@ +run("server-{$this->member->server_id}", function (): void { + $service = $this->member->server->firewall(); + if (! $service instanceof Service) { + return; + } + + /** @var Firewall $handler */ + $handler = $service->handler(); + $handler->applyRules(); + }); + } + + public function failed(Exception $e): void + { + NetworkServer::query() + ->whereKey($this->member->id) + ->where('status', NetworkServerStatus::ACTIVE) + ->update(['status' => NetworkServerStatus::FAILED]); + + ServerLog::log($this->member->server, 'apply-network-firewall-failed', $e->getMessage()); + + app(RecomputeNetworkStatus::class)->handle($this->member->network); + } +} diff --git a/app/Jobs/Network/SyncNetworkServerJob.php b/app/Jobs/Network/SyncNetworkServerJob.php new file mode 100644 index 000000000..ef56b66a7 --- /dev/null +++ b/app/Jobs/Network/SyncNetworkServerJob.php @@ -0,0 +1,180 @@ +run("server-{$this->member->server_id}", function (): void { + if ($this->teardown) { + $this->tearDown(); + + return; + } + + $this->syncToPresent(); + }); + } + + public function failed(Exception $e): void + { + if ($this->teardown) { + ServerLog::log($this->member->server, 'network-teardown-failed', $e->getMessage()); + + return; + } + + $this->member->status = NetworkServerStatus::FAILED; + $this->member->save(); + $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 + { + $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/Models/Network.php b/app/Models/Network.php new file mode 100644 index 000000000..20ff71f94 --- /dev/null +++ b/app/Models/Network.php @@ -0,0 +1,78 @@ + $servers + * @property Collection $firewallRules + */ +class Network extends AbstractModel +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'project_id', + 'name', + 'type', + 'status', + 'addressing_pool', + 'cidr', + 'cidr_canonical', + 'port', + 'firewall_enabled', + ]; + + protected $casts = [ + 'project_id' => 'integer', + 'port' => 'integer', + 'firewall_enabled' => 'boolean', + 'type' => NetworkType::class, + 'status' => NetworkStatus::class, + 'addressing_pool' => NetworkAddressingPool::class, + ]; + + /** + * @return BelongsTo + */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** + * @return HasMany + */ + public function servers(): HasMany + { + return $this->hasMany(NetworkServer::class); + } + + /** + * @return HasMany + */ + public function firewallRules(): HasMany + { + return $this->hasMany(NetworkFirewallRule::class); + } +} diff --git a/app/Models/NetworkFirewallRule.php b/app/Models/NetworkFirewallRule.php new file mode 100644 index 000000000..7919474e7 --- /dev/null +++ b/app/Models/NetworkFirewallRule.php @@ -0,0 +1,49 @@ + */ + use HasFactory; + + protected $fillable = [ + 'network_id', + 'name', + 'type', + 'protocol', + 'port', + 'position', + 'status', + ]; + + protected $casts = [ + 'network_id' => 'integer', + 'position' => 'integer', + 'status' => FirewallRuleStatus::class, + ]; + + /** + * @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..94eeb3c05 --- /dev/null +++ b/app/Models/NetworkServer.php @@ -0,0 +1,72 @@ + */ + 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', + ]; + + /** + * @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); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index eec85217f..ec9996a81 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -26,6 +26,7 @@ * @property Collection $registeredUsers * @property Collection $workflows * @property Collection $domains + * @property Collection $networks */ class Project extends Model { @@ -117,4 +118,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 dd384153b..dd81904a4 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,7 +2,10 @@ namespace App\Models; +use App\Actions\Network\DispatchNetworkServerSync; use App\Actions\Server\CheckConnection; +use App\Enums\NetworkServerStatus; +use App\Enums\NetworkType; use App\Enums\OperatingSystem; use App\Enums\SecurityControlStatus; use App\Enums\ServerStatus; @@ -124,10 +127,45 @@ class Server extends AbstractModel public bool $deleteFromProvider = true; + /** @var array> */ + protected static array $networkSiblingsToResync = []; + public static function boot(): void { parent::boot(); + static::deleting(function (Server $server): void { + $siblings = []; + NetworkServer::query() + ->where('server_id', $server->id) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->pluck('network_id') + ->each(function (int $networkId) use ($server, &$siblings): void { + NetworkServer::query() + ->where('network_id', $networkId) + ->where('server_id', '!=', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->pluck('id') + ->each(function (int $id) use (&$siblings): void { + $siblings[$id] = true; + }); + }); + static::$networkSiblingsToResync[$server->id] = array_keys($siblings); + }); + + static::deleted(function (Server $server): void { + $ids = static::$networkSiblingsToResync[$server->id] ?? []; + unset(static::$networkSiblingsToResync[$server->id]); + + NetworkServer::query() + ->whereIn('id', $ids) + ->with('server', 'network') + ->get() + ->each(function (NetworkServer $member): void { + app(DispatchNetworkServerSync::class)->toPresent($member); + }); + }); + static::deleting(function (Server $server): void { DB::beginTransaction(); try { diff --git a/app/Policies/NetworkPolicy.php b/app/Policies/NetworkPolicy.php new file mode 100644 index 000000000..7b79145b7 --- /dev/null +++ b/app/Policies/NetworkPolicy.php @@ -0,0 +1,40 @@ +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); + } + + public function delete(User $user, Network $network): bool + { + 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/Services/Firewall/Ufw.php b/app/Services/Firewall/Ufw.php index adc2125f5..982971a16 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\CompileServerFirewallRules; 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,11 +57,15 @@ public function uninstall(): void */ public function applyRules(): void { - $rules = $this->service->server + $serverRules = $this->service->server ->firewallRules() ->where('status', '!=', FirewallRuleStatus::DELETING) ->get(); + $networkRules = collect(app(CompileServerFirewallRules::class)->forServer($this->service->server)); + + $rules = $networkRules->merge($serverRules); + $this->service->server->ssh()->exec( view('ssh.services.firewall.ufw.apply-rules', ['rules' => $rules]), 'apply-rules' 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 @@ +service->server->ssh() + ->setLog($this->service->log) + ->exec( + view('ssh.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.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.wireguard.conf', [ + 'address' => $membership->ip, + 'prefix' => $this->prefix($network), + 'listenPort' => $network->port, + 'privateKey' => $membership->private_key, + 'peers' => $this->peers($membership), + ])->render(); + + $ssh = $this->service->server->ssh()->setLog($this->service->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.wireguard.configure', ['networkId' => $network->id]), + 'configure-wireguard' + ); + } + + /** + * @throws SSHError + */ + public function removeNetwork(Network $network): void + { + $this->service->server->ssh()->exec( + view('ssh.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 (int) (explode('/', (string) $network->cidr)[1] ?? 32); + } + + /** + * @return array + */ + private function peers(NetworkServer $membership): array + { + return $membership->network->servers() + ->where('id', '!=', $membership->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereNotNull('public_key') + ->whereNotNull('ip') + ->with('server') + ->get() + ->filter(fn (NetworkServer $peer): bool => filled($peer->server->ip)) + ->map(fn (NetworkServer $peer): array => [ + 'public_key' => (string) $peer->public_key, + 'allowed_ips' => $peer->ip.'/32', + 'endpoint' => $peer->server->ip.':'.$membership->network->port, + ]) + ->values() + ->all(); + } + + private function uploadConf(SSH|SSHFake $ssh, string $remote, string $content): void + { + $tmpName = 'wg-'.Str::random(20); + $disk = Storage::disk('local'); + $disk->put($tmpName, $content); + + try { + $ssh->upload($disk->path($tmpName), $remote, 'root'); + } finally { + $disk->delete($tmpName); + } + } +} diff --git a/app/Support/Cidr.php b/app/Support/Cidr.php new file mode 100644 index 000000000..dbfb63e7c --- /dev/null +++ b/app/Support/Cidr.php @@ -0,0 +1,96 @@ + $used + */ + public static function nextHost(string $cidr, array $used): ?string + { + $prefix = self::prefix($cidr); + $size = self::size($prefix); + $used = array_flip($used); + + for ($index = 2; $index < $size - 1; $index++) { + $host = self::nthHost($cidr, $index); + if (! isset($used[$host])) { + return $host; + } + } + + return null; + } +} diff --git a/app/Tables/NetworkTable.php b/app/Tables/NetworkTable.php new file mode 100644 index 000000000..428cd4c3e --- /dev/null +++ b/app/Tables/NetworkTable.php @@ -0,0 +1,39 @@ + 'network']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->withCount('servers')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + EnumColumn::make('type', 'Type'), + TextColumn::make('servers_count', 'Servers'), + BooleanColumn::make('firewall_enabled', 'Firewall'), + 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..ed1547a19 --- /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('position')->orderBy('id'); + } + + protected function columns(): array + { + return [ + TextColumn::make('name', 'Name')->sortable(), + TextColumn::make('type', 'Type'), + TextColumn::make('protocol', 'Protocol'), + TextColumn::make('port', 'Port'), + TextColumn::make('position', 'Position')->sortable(), + EnumColumn::make('status', 'Status'), + Column::data('id'), + ActionsColumn::make(), + ]; + } +} diff --git a/app/Tables/Networks/NetworkServerTable.php b/app/Tables/Networks/NetworkServerTable.php new file mode 100644 index 000000000..11eb7b693 --- /dev/null +++ b/app/Tables/Networks/NetworkServerTable.php @@ -0,0 +1,32 @@ + 'network-server']; + + protected function query(): void + { + $this->perPage = config('web.pagination_size'); + $this->query->with('server', 'serverIpAddress')->latest(); + } + + protected function columns(): array + { + return [ + TextColumn::make('server.name', 'Server')->sortable(), + TextColumn::make('ip', 'Tunnel IP')->fallback('—'), + EnumColumn::make('status', 'Status'), + Column::data('id'), + Column::data('server_id'), + ActionsColumn::make(), + ]; + } +} diff --git a/database/factories/NetworkFactory.php b/database/factories/NetworkFactory.php new file mode 100644 index 000000000..984fba400 --- /dev/null +++ b/database/factories/NetworkFactory.php @@ -0,0 +1,35 @@ + + */ +class NetworkFactory extends Factory +{ + protected $model = Network::class; + + public function definition(): array + { + $block = $this->faker->unique()->numberBetween(0, 255); + + return [ + 'project_id' => Project::factory(), + 'name' => $this->faker->unique()->word(), + 'type' => NetworkType::WIREGUARD, + 'status' => NetworkStatus::ACTIVE, + 'addressing_pool' => NetworkAddressingPool::CGNAT, + 'cidr' => "100.64.{$block}.0/24", + 'cidr_canonical' => "100.64.{$block}.0/24", + 'port' => 51820, + 'firewall_enabled' => false, + ]; + } +} diff --git a/database/factories/NetworkFirewallRuleFactory.php b/database/factories/NetworkFirewallRuleFactory.php new file mode 100644 index 000000000..09e1cd4e3 --- /dev/null +++ b/database/factories/NetworkFirewallRuleFactory.php @@ -0,0 +1,29 @@ + + */ +class NetworkFirewallRuleFactory extends Factory +{ + protected $model = NetworkFirewallRule::class; + + public function definition(): array + { + return [ + 'network_id' => Network::factory(), + 'name' => $this->faker->word(), + 'type' => 'allow', + 'protocol' => 'tcp', + 'port' => (string) $this->faker->numberBetween(1, 65535), + 'position' => 0, + 'status' => FirewallRuleStatus::READY, + ]; + } +} diff --git a/database/factories/NetworkServerFactory.php b/database/factories/NetworkServerFactory.php new file mode 100644 index 000000000..57c1eaf53 --- /dev/null +++ b/database/factories/NetworkServerFactory.php @@ -0,0 +1,31 @@ + + */ +class NetworkServerFactory extends Factory +{ + protected $model = NetworkServer::class; + + public function definition(): array + { + return [ + 'network_id' => Network::factory(), + 'server_id' => Server::factory(), + 'server_ip_address_id' => null, + 'ip' => $this->faker->unique()->numerify('100.64.0.##'), + 'public_key' => base64_encode(random_bytes(32)), + 'private_key' => base64_encode(random_bytes(32)), + 'status' => NetworkServerStatus::ACTIVE, + 'sync_attempts' => 0, + ]; + } +} diff --git a/database/migrations/2026_07_21_131503_create_networks_table.php b/database/migrations/2026_07_21_131503_create_networks_table.php new file mode 100644 index 000000000..3e423c450 --- /dev/null +++ b/database/migrations/2026_07_21_131503_create_networks_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $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->boolean('firewall_enabled')->default(false); + $table->timestamps(); + + $table->unique(['project_id', 'name']); + $table->unique(['project_id', 'cidr_canonical'], 'networks_project_cidr_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('networks'); + } +}; diff --git a/database/migrations/2026_07_21_131548_create_network_servers_table.php b/database/migrations/2026_07_21_131548_create_network_servers_table.php new file mode 100644 index 000000000..0f192a14c --- /dev/null +++ b/database/migrations/2026_07_21_131548_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_21_131721_create_network_firewall_rules_table.php b/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php new file mode 100644 index 000000000..4acfd45e6 --- /dev/null +++ b/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('network_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type'); + $table->string('protocol')->nullable(); + $table->string('port')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default(FirewallRuleStatus::CREATING->value); + $table->timestamps(); + + $table->index(['network_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('network_firewall_rules'); + } +}; diff --git a/package-lock.json b/package-lock.json index 9c44199a4..0c7929005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "vito", + "name": "feat-networks", "lockfileVersion": 3, "requires": true, "packages": { @@ -8059,51 +8059,6 @@ "optional": true } } - }, - "vendor/forjedio/inertia-table/react": { - "name": "@forjedio/inertia-table-react", - "version": "0.0.0-dev", - "extraneous": true, - "license": "MIT", - "devDependencies": { - "@inertiajs/react": "^2.0 || ^3.0", - "@tanstack/react-table": "^8.0", - "@testing-library/jest-dom": "^6.0", - "@testing-library/react": "^16.0", - "@types/react": "^18.0 || ^19.0", - "@types/react-dom": "^18.0 || ^19.0", - "@vitest/coverage-v8": "^3.0", - "jsdom": "^25.0", - "react": "^18.0 || ^19.0", - "react-dom": "^18.0 || ^19.0", - "typescript": "^5.5", - "vite": "^6.0", - "vite-plugin-dts": "^4.0", - "vitest": "^3.0", - "ziggy-js": "^2.0" - }, - "peerDependencies": { - "@inertiajs/react": "^2.0 || ^3.0", - "@tanstack/react-table": "^8.0", - "@tanstack/table-core": "^8.0", - "@types/react": "^18.0 || ^19.0", - "@types/react-dom": "^18.0 || ^19.0", - "react": "^18.0 || ^19.0", - "react-dom": "^18.0 || ^19.0", - "tailwindcss": "^3.4 || ^4.0", - "ziggy-js": "^2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "ziggy-js": { - "optional": true - } - } } } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a1363a038..e9ccff5c5 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -23,6 +23,7 @@ import { ListEndIcon, LogsIcon, MousePointerClickIcon, + NetworkIcon, ServerIcon, Settings2Icon, WorkflowIcon, @@ -57,6 +58,11 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? href: route('workflows'), icon: WorkflowIcon, }, + { + title: 'Networks', + href: route('networks'), + icon: NetworkIcon, + }, { title: 'Domains', href: route('domains'), diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index 8b55c67d8..e89c41201 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -33,6 +33,9 @@ import SiteFeatureAction from '@/pages/site-features/components/feature-action'; import ServerFeatureAction from '@/pages/server-features/components/feature-action'; import Fail2banForm from '@/pages/security/components/fail2ban-form'; import PhpSettingsDialog from '@/pages/site-settings/components/php-settings-dialog'; +import CreateNetwork from '@/pages/networks/components/create-network'; +import AddNetworkServer from '@/pages/networks/components/add-server'; +import NetworkFirewallRuleForm from '@/pages/networks/components/firewall-rule-form'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -83,6 +86,9 @@ export const dialogs = { serverFeatureAction: ServerFeatureAction, fail2banForm: Fail2banForm, phpSettings: PhpSettingsDialog, + networkCreate: CreateNetwork, + networkAddServer: AddNetworkServer, + networkFirewallForm: NetworkFirewallRuleForm, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/pages/firewall/index.tsx b/resources/js/pages/firewall/index.tsx index 5064542a2..4bac5b91b 100644 --- a/resources/js/pages/firewall/index.tsx +++ b/resources/js/pages/firewall/index.tsx @@ -1,15 +1,16 @@ -import { Head, usePage } from '@inertiajs/react'; +import { Head, Link, usePage } from '@inertiajs/react'; import { Server } from '@/types/server'; import { FirewallRule } from '@/types/firewall'; import ServerLayout from '@/layouts/server/layout'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { BookOpenIcon, MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { BookOpenIcon, MoreVerticalIcon, NetworkIcon, PlusIcon } from 'lucide-react'; import Container from '@/components/container'; import { VitoTable } from '@/components/vito-table'; import Delete from '@/pages/firewall/components/delete'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import type { CellRenderProps, InertiaTableData, Row } from '@forjedio/inertia-table-react'; import { asRow } from '@/lib/inertia-table'; import { useDialog } from '@/hooks/use-dialog'; @@ -20,8 +21,10 @@ export default function Firewall() { const page = usePage<{ server: Server; rules: InertiaTableData; + managedNetworks: { id: number; name: string }[]; }>(); const dialog = useDialog(); + const managedNetworks = page.props.managedNetworks ?? []; return ( @@ -44,6 +47,27 @@ export default function Firewall() { + {managedNetworks.length > 0 && ( + + + Private network rules are active on this server + +

+ Firewall rules for{' '} + {managedNetworks.map((network, index) => ( + + {index > 0 && ', '} + + {network.name} + + + ))}{' '} + are managed from the network and are not editable here. +

+
+
+ )} + ; +}; + +export default function AddServer({ + open, + onOpenChange, + networkId, + isProvider, + servers, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + networkId: number; + isProvider: boolean; + servers: NetworkServerOption[]; +}) { + const form = useForm({ + servers: [], + ip_addresses: {}, + }); + + const toggleServer = (id: number) => { + const selected = form.data.servers.includes(id); + form.setData('servers', selected ? form.data.servers.filter((s) => s !== id) : [...form.data.servers, id]); + }; + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('networks.servers.store', { network: networkId }), { + onSuccess: () => { + onOpenChange(false); + form.reset(); + }, + }); + }; + + return ( + + e.preventDefault()}> + + Add servers + Add servers to the network + +
+ + + +
+ {servers.length === 0 &&

No eligible servers available.

} + {servers.map((server) => { + const selected = form.data.servers.includes(server.id); + const selectedIp = form.data.ip_addresses[server.id]; + const primaryChosen = server.private_ips.find((ip) => ip.id === selectedIp)?.is_primary; + return ( +
+
+ toggleServer(server.id)} /> + + {!server.is_ready && not ready} +
+ {selected && isProvider && ( +
+ + {primaryChosen && ( + + + This is the server's primary IP. Make sure you intend to use it for this network. + + )} + +
+ )} +
+ ); + })} +
+ +
+
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/networks/components/create-network.tsx b/resources/js/pages/networks/components/create-network.tsx new file mode 100644 index 000000000..c688b4cb5 --- /dev/null +++ b/resources/js/pages/networks/components/create-network.tsx @@ -0,0 +1,213 @@ +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { FormEvent } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon, TriangleAlertIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { NetworkServerOption } from '@/types/network'; + +type CreateNetworkForm = { + name: string; + type: string; + servers: number[]; + addressing_pool: string; + prefix: string; + port: string; + cidr: string; + firewall_enabled: boolean; + ip_addresses: Record; +}; + +export default function CreateNetwork({ + open, + onOpenChange, + servers, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + servers: NetworkServerOption[]; +}) { + const form = useForm({ + name: '', + type: 'wireguard', + servers: [], + addressing_pool: 'cgnat', + prefix: '24', + port: '51820', + cidr: '', + firewall_enabled: false, + ip_addresses: {}, + }); + + const toggleServer = (id: number) => { + const selected = form.data.servers.includes(id); + form.setData('servers', selected ? form.data.servers.filter((s) => s !== id) : [...form.data.servers, id]); + }; + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('networks.store'), { + onSuccess: () => { + onOpenChange(false); + form.reset(); + }, + }); + }; + + const isProvider = form.data.type === 'provider'; + + return ( + + e.preventDefault()}> + + Create network + Create a new private network + +
+ + + + form.setData('name', e.target.value)} /> + + + + + + + + + + {!isProvider && ( + <> + + + + + + +
+ + + form.setData('prefix', e.target.value)} /> + + + + + form.setData('port', e.target.value)} /> + + +
+ + )} + + {isProvider && ( + + + form.setData('cidr', e.target.value)} /> + + + )} + + + +
+ {servers.map((server) => { + const selected = form.data.servers.includes(server.id); + const selectedIp = form.data.ip_addresses[server.id]; + const primaryChosen = server.private_ips.find((ip) => ip.id === selectedIp)?.is_primary; + return ( +
+
+ toggleServer(server.id)} /> + + {!server.is_ready && not ready} +
+ {selected && isProvider && ( +
+ + {primaryChosen && ( + + + This is the server's primary IP. Make sure you intend to use it for this network. + + )} + +
+ )} +
+ ); + })} +
+ +
+ + +
+ form.setData('firewall_enabled', !form.data.firewall_enabled)} + /> + +
+
+
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/networks/components/firewall-rule-form.tsx b/resources/js/pages/networks/components/firewall-rule-form.tsx new file mode 100644 index 000000000..51162b592 --- /dev/null +++ b/resources/js/pages/networks/components/firewall-rule-form.tsx @@ -0,0 +1,134 @@ +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { FormEvent } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { NetworkFirewallRule } from '@/types/network'; + +export default function NetworkFirewallRuleForm({ + open, + onOpenChange, + networkId, + rule, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + networkId: number; + rule?: NetworkFirewallRule; +}) { + const form = useForm<{ + name: string; + type: string; + protocol: string; + port: string; + position: string; + }>({ + name: rule?.name || '', + type: rule?.type || 'deny', + protocol: rule?.protocol || '', + port: rule?.port || '', + position: rule?.position?.toString() || '0', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (rule) { + form.put(route('networks.firewall.update', { network: networkId, networkFirewallRule: rule.id }), { + onSuccess: () => onOpenChange(false), + }); + return; + } + form.post(route('networks.firewall.store', { network: networkId }), { + onSuccess: () => onOpenChange(false), + }); + }; + + return ( + + e.preventDefault()}> + + {rule ? 'Edit' : 'Create'} firewall rule + {rule ? 'Edit' : 'Create new'} network firewall rule + +
+ + + + form.setData('name', e.target.value)} /> + + + + + + + + + + + + + + + + + + form.setData('port', e.target.value)} + /> +

Leave protocol and port empty to match all traffic from the network.

+ +
+ + + + form.setData('position', e.target.value)} /> +

Lower positions are evaluated first. Put denies above allows.

+ +
+
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/networks/index.tsx b/resources/js/pages/networks/index.tsx new file mode 100644 index 000000000..566484f0e --- /dev/null +++ b/resources/js/pages/networks/index.tsx @@ -0,0 +1,88 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import Layout from '@/layouts/app/layout'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; +import { asRow } from '@/lib/inertia-table'; +import { useDialog } from '@/hooks/use-dialog'; +import { Network, NetworkServerOption } from '@/types/network'; + +export default function Networks() { + const page = usePage<{ + networks: InertiaTableData; + servers: NetworkServerOption[]; + }>(); + const dialog = useDialog(); + + return ( + + + + + + + + + + { + const network = asRow(row, ['id', 'name']); + return ( +
+ + + + + + router.visit(route('networks.show', { network: network.id }))}>View + + dialog.confirm.open({ + title: `Sync network [${network.name}]`, + description: 'Re-apply configuration to every server in this network.', + confirmLabel: 'Sync', + method: 'post', + url: route('networks.sync', { network: network.id }), + }) + } + > + Sync + + + + dialog.confirm.open({ + title: `Delete network [${network.name}]`, + description: `Are you sure you want to delete ${network.name}? This tears the network down on all of its servers.`, + variant: 'destructive', + confirmLabel: 'Delete', + method: 'delete', + url: route('networks.destroy', { network: network.id }), + }) + } + > + Delete + + + +
+ ); + }} + /> +
+
+ ); +} diff --git a/resources/js/pages/networks/show.tsx b/resources/js/pages/networks/show.tsx new file mode 100644 index 000000000..fcfbc76e3 --- /dev/null +++ b/resources/js/pages/networks/show.tsx @@ -0,0 +1,200 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import Layout from '@/layouts/app/layout'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; +import { asRow } from '@/lib/inertia-table'; +import { useDialog } from '@/hooks/use-dialog'; +import { Network, NetworkFirewallRule, NetworkServer, NetworkServerOption } from '@/types/network'; + +export default function ShowNetwork() { + const page = usePage<{ + network: { data: Network }; + members: InertiaTableData; + rules: InertiaTableData; + servers: NetworkServerOption[]; + }>(); + const dialog = useDialog(); + const network = page.props.network.data; + const isProvider = network.type_value === 'provider'; + + const toggleFirewall = () => { + router.put(route('networks.update', { network: network.id }), { firewall_enabled: !network.firewall_enabled }, { preserveScroll: true }); + }; + + return ( + + + + + + +
+ + +
+
+ +
+
+
+

Servers

+ +
+ { + const member = asRow(row, ['id', 'server_id']); + return ( +
+ + + + + + {!isProvider && ( + + dialog.confirm.open({ + title: 'Regenerate configuration', + description: 'Re-apply the network configuration to this server.', + confirmLabel: 'Regenerate', + method: 'post', + url: route('networks.servers.sync', { network: network.id, networkServer: member.id }), + }) + } + > + Regenerate + + )} + + + dialog.confirm.open({ + title: 'Remove server', + description: 'Remove this server from the network and tear down its configuration.', + variant: 'destructive', + confirmLabel: 'Remove', + method: 'delete', + url: route('networks.servers.destroy', { network: network.id, networkServer: member.id }), + }) + } + > + Remove + + + +
+ ); + }} + /> +
+ +
+
+
+

Firewall

+

Traffic from the network is allowed by default. Add deny rules to restrict it.

+
+
+
+ + +
+ +
+
+ { + const rule = asRow(row, ['id', 'name', 'type', 'protocol', 'port', 'position']); + return ( +
+ + + + + + dialog.networkFirewallForm.open({ networkId: network.id, rule })}>Edit + + + dialog.confirm.open({ + title: `Delete rule [${rule.name}]`, + description: 'Are you sure you want to delete this firewall rule?', + variant: 'destructive', + confirmLabel: 'Delete', + method: 'delete', + url: route('networks.firewall.destroy', { network: network.id, networkFirewallRule: rule.id }), + }) + } + > + Delete + + + +
+ ); + }} + /> +
+
+
+
+ ); +} diff --git a/resources/js/pages/services/components/install.tsx b/resources/js/pages/services/components/install.tsx index 97c03e727..600e1aec7 100644 --- a/resources/js/pages/services/components/install.tsx +++ b/resources/js/pages/services/components/install.tsx @@ -74,7 +74,9 @@ export default function InstallService({ name, children }: { name?: string; chil - {Object.entries(configs.service.services).map(([key, service]) => ( + {Object.entries(configs.service.services) + .filter(([, service]) => service.type !== 'vpn') + .map(([key, service]) => ( port) && $rule->port !== null && $rule->port !== '') if ! sudo ufw {{ $rule->type }} from {{ $source }} to any proto {{ $rule->protocol }} port {{ $rule->port }}; then @include('ssh.services.firewall.ufw.restore-rules') echo 'VITO_SSH_ERROR' && exit 1 fi + @else + if ! sudo ufw {{ $rule->type }} from {{ $source }} to any; then + @include('ssh.services.firewall.ufw.restore-rules') + echo 'VITO_SSH_ERROR' && exit 1 + fi + @endif @endforeach if ! sudo ufw --force enable; then diff --git a/resources/views/ssh/wireguard/conf.blade.php b/resources/views/ssh/wireguard/conf.blade.php new file mode 100644 index 000000000..3b00fe402 --- /dev/null +++ b/resources/views/ssh/wireguard/conf.blade.php @@ -0,0 +1,12 @@ +[Interface] +Address = {{ $address }}/{{ $prefix }} +ListenPort = {{ $listenPort }} +PrivateKey = {{ $privateKey }} +@foreach ($peers as $peer) + +[Peer] +PublicKey = {{ $peer['public_key'] }} +AllowedIPs = {{ $peer['allowed_ips'] }} +Endpoint = {{ $peer['endpoint'] }} +PersistentKeepalive = 25 +@endforeach diff --git a/resources/views/ssh/wireguard/configure.blade.php b/resources/views/ssh/wireguard/configure.blade.php new file mode 100644 index 000000000..d20bf6c75 --- /dev/null +++ b/resources/views/ssh/wireguard/configure.blade.php @@ -0,0 +1,13 @@ +sudo mkdir -p /etc/wireguard +sudo chmod 700 /etc/wireguard + +sudo install -m 600 -o root -g root /etc/wireguard/wg-vito-{{ $networkId }}.conf.tmp /etc/wireguard/wg-vito-{{ $networkId }}.conf +sudo rm -f /etc/wireguard/wg-vito-{{ $networkId }}.conf.tmp + +sudo systemctl enable wg-quick@wg-vito-{{ $networkId }} +sudo systemctl restart wg-quick@wg-vito-{{ $networkId }} + +if ! sudo wg show wg-vito-{{ $networkId }} >/dev/null 2>&1; then + echo "VITO_SSH_ERROR: wireguard interface wg-vito-{{ $networkId }} failed to start" + exit 1 +fi diff --git a/resources/views/ssh/wireguard/install.blade.php b/resources/views/ssh/wireguard/install.blade.php new file mode 100644 index 000000000..b28c63de8 --- /dev/null +++ b/resources/views/ssh/wireguard/install.blade.php @@ -0,0 +1,9 @@ +export DEBIAN_FRONTEND=noninteractive + +sudo apt-get update -y +sudo apt-get install -y wireguard wireguard-tools + +if ! command -v wg >/dev/null 2>&1; then + echo "VITO_SSH_ERROR: wireguard installation failed" + exit 1 +fi diff --git a/resources/views/ssh/wireguard/remove-network.blade.php b/resources/views/ssh/wireguard/remove-network.blade.php new file mode 100644 index 000000000..0a9368b06 --- /dev/null +++ b/resources/views/ssh/wireguard/remove-network.blade.php @@ -0,0 +1,4 @@ +sudo systemctl stop wg-quick@wg-vito-{{ $networkId }} 2>/dev/null || true +sudo systemctl disable wg-quick@wg-vito-{{ $networkId }} 2>/dev/null || true +sudo wg-quick down wg-vito-{{ $networkId }} 2>/dev/null || true +sudo rm -f /etc/wireguard/wg-vito-{{ $networkId }}.conf diff --git a/resources/views/ssh/wireguard/uninstall.blade.php b/resources/views/ssh/wireguard/uninstall.blade.php new file mode 100644 index 000000000..630cf8b27 --- /dev/null +++ b/resources/views/ssh/wireguard/uninstall.blade.php @@ -0,0 +1,4 @@ +export DEBIAN_FRONTEND=noninteractive + +sudo apt-get remove -y wireguard wireguard-tools || true +sudo apt-get autoremove -y || true diff --git a/tests/Feature/NetworkFirewallTest.php b/tests/Feature/NetworkFirewallTest.php new file mode 100644 index 000000000..16975747d --- /dev/null +++ b/tests/Feature/NetworkFirewallTest.php @@ -0,0 +1,373 @@ +create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'firewall_enabled' => $firewall, + 'servers' => $servers, + ]); + } + + public function test_wireguard_handshake_port_is_opened_even_when_firewall_disabled(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + + $this->wireguardNetwork([$this->server->id, $peer->id], false); + + SSH::assertExecutedContains('proto udp port 51820'); + } + + public function test_firewall_enabled_emits_default_allow_catch_all(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $this->wireguardNetwork([$this->server->id], true); + + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + } + + public function test_deny_rule_is_emitted_scoped_to_the_network_cidr(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + SSH::assertExecutedContains('deny from 100.64.0.0/24 to any proto tcp port 3306'); + $this->assertDatabaseHas('network_firewall_rules', [ + 'network_id' => $network->id, + 'type' => 'deny', + 'port' => '3306', + ]); + } + + public function test_network_rules_are_not_stored_as_server_firewall_rules(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + $this->assertSame(0, $this->server->firewallRules()->count()); + } + + public function test_server_firewall_page_exposes_managed_networks(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + + $this->actingAs($this->user); + + $this->get(route('firewall', ['server' => $this->server])) + ->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('firewall/index') + ->where('managedNetworks.0.name', $network->name) + ->where('managedNetworks.0.id', $network->id)); + } + + public function test_server_level_firewall_change_reapplies_network_rules(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $this->wireguardNetwork([$this->server->id], true); + + app(ManageRule::class)->create($this->server, [ + 'name' => 'ssh', + 'type' => 'allow', + 'protocol' => 'tcp', + 'port' => '22', + ]); + + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + } + + public function test_toggling_firewall_enabled_applies_network_rules(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], false); + + app(UpdateNetwork::class)->update($network, ['firewall_enabled' => true]); + + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + } + + public function test_installing_ufw_opens_wireguard_handshake_for_existing_member(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + + $this->wireguardNetwork([$this->server->id, $peer->id], false); + + SSH::fake(); + $this->server->firewall()->handler()->install(); + + SSH::assertExecutedContains('from '.$peer->ip.'/32 to any proto udp port 51820'); + } + + public function test_failed_firewall_apply_marks_member_failed(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + $member = $network->servers()->firstOrFail(); + + (new ApplyNetworkFirewallJob($member))->failed(new \RuntimeException('boom')); + + $this->assertSame(NetworkServerStatus::FAILED, $member->fresh()->status); + } + + public function test_deny_rule_is_ordered_before_the_catch_all(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + $specs = app(CompileServerFirewallRules::class)->forServer($this->server); + + $denyIndex = $this->indexOf($specs, fn ($s) => $s->type === 'deny' && $s->port === '3306'); + $catchAllIndex = $this->indexOf($specs, fn ($s) => $s->type === 'allow' && $s->port === null && $s->source === '100.64.0.0'); + + $this->assertNotNull($denyIndex); + $this->assertNotNull($catchAllIndex); + $this->assertLessThan($catchAllIndex, $denyIndex); + } + + public function test_portless_deny_all_rule_is_emitted(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'lockdown', + 'type' => 'deny', + ]); + + SSH::assertExecutedContains('deny from 100.64.0.0/24 to any'); + } + + public function test_disabling_firewall_removes_deny_rules_but_keeps_tunnel_allowed(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + SSH::fake(); + app(UpdateNetwork::class)->update($network, ['firewall_enabled' => false]); + + SSH::assertNotExecutedContains('deny from 100.64.0.0/24 to any proto tcp port 3306'); + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + } + + public function test_firewall_change_on_offline_server_marks_member_pending(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id], true); + + $this->server->update(['status' => ServerStatus::DISCONNECTED]); + + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + $this->assertSame(NetworkServerStatus::PENDING, $network->servers()->firstOrFail()->status); + $this->assertSame(NetworkStatus::SYNCING, $network->fresh()->status); + } + + public function test_provider_network_firewall_uses_cidr_source(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $ip = ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => '10.0.0.5', + 'type' => IpAddressType::PRIVATE, + ]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'cidr' => '10.0.0.0/24', + 'firewall_enabled' => true, + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip->id], + ]); + + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + SSH::assertExecutedContains('deny from 10.0.0.0/24 to any proto tcp port 3306'); + } + + public function test_provider_network_without_cidr_uses_member_private_ip(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $ip1 = ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => '10.0.0.5', + 'type' => IpAddressType::PRIVATE, + ]); + + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + $ip2 = ServerIpAddress::factory()->create([ + 'server_id' => $peer->id, + 'ip' => '10.0.0.6', + 'type' => IpAddressType::PRIVATE, + ]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'firewall_enabled' => true, + 'servers' => [$this->server->id, $peer->id], + 'ip_addresses' => [$this->server->id => $ip1->id, $peer->id => $ip2->id], + ]); + + app(ManageNetworkFirewallRule::class)->create($network, [ + 'name' => 'no-mysql', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '3306', + ]); + + SSH::assertExecutedContains('from 10.0.0.6/32 to any'); + } + + public function test_provider_network_applies_catch_all_on_create(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + $ip = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.5', 'type' => IpAddressType::PRIVATE]); + + app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'cidr' => '10.0.0.0/24', + 'firewall_enabled' => true, + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip->id], + ]); + + SSH::assertExecutedContains('allow from 10.0.0.0/24 to any'); + } + + public function test_wireguard_tunnel_is_allowed_when_firewall_disabled(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $this->wireguardNetwork([$this->server->id], false); + + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + } + + public function test_server_with_zero_networks_emits_no_network_rules(): void + { + $specs = app(CompileServerFirewallRules::class)->forServer($this->server); + + $this->assertSame([], $specs); + } + + /** + * @param array $specs + */ + private function indexOf(array $specs, callable $matcher): ?int + { + foreach ($specs as $index => $spec) { + if ($matcher($spec)) { + return $index; + } + } + + return null; + } +} diff --git a/tests/Feature/NetworkSyncTest.php b/tests/Feature/NetworkSyncTest.php new file mode 100644 index 000000000..d8019dd68 --- /dev/null +++ b/tests/Feature/NetworkSyncTest.php @@ -0,0 +1,497 @@ +assertSame('100.64.0.0/24', Cidr::canonical('100.64.0.5/24')); + $this->assertTrue(Cidr::overlaps('10.0.0.0/16', '10.0.5.0/24')); + $this->assertFalse(Cidr::overlaps('10.0.0.0/24', '10.1.0.0/24')); + $this->assertSame('100.64.0.2', Cidr::nextHost('100.64.0.0/24', [])); + $this->assertSame('100.64.0.3', Cidr::nextHost('100.64.0.0/24', ['100.64.0.2'])); + } + + public function test_block_allocator_skips_member_subnet(): void + { + ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => '100.64.0.10', + 'prefix_length' => 24, + 'type' => IpAddressType::PUBLIC, + ]); + + $cidr = app(AllocateNetworkBlock::class)->allocate( + NetworkAddressingPool::CGNAT, + 24, + collect(), + collect([$this->server]) + ); + + $this->assertSame('100.64.1.0/24', $cidr); + } + + public function test_create_wireguard_network_allocates_block_and_activates_ready_member(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $this->assertStringStartsWith('100.64.', (string) $network->cidr); + + $member = $network->servers()->firstOrFail(); + $this->assertSame('100.64.0.2', $member->ip); + $this->assertNotNull($member->public_key); + $this->assertSame(NetworkServerStatus::ACTIVE, $member->fresh()->status); + $this->assertSame(NetworkStatus::ACTIVE, $network->fresh()->status); + $this->assertNotNull($this->server->fresh()->service('vpn')); + SSH::assertExecutedContains('wg-quick@wg-vito-'.$network->id); + } + + public function test_create_wireguard_member_pending_when_server_not_ready_then_reconciler_activates(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::DISCONNECTED]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $member = $network->servers()->firstOrFail(); + $this->assertSame(NetworkServerStatus::PENDING, $member->status); + $this->assertSame(NetworkStatus::SYNCING, $network->fresh()->status); + + $this->server->update(['status' => ServerStatus::READY]); + Artisan::call('networks:reconcile'); + + $this->assertSame(NetworkServerStatus::ACTIVE, $member->fresh()->status); + $this->assertSame(NetworkStatus::ACTIVE, $network->fresh()->status); + } + + public function test_create_provider_network_members_active_without_wireguard(): void + { + SSH::fake(); + + $ip = ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => '10.0.0.5', + 'type' => IpAddressType::PRIVATE, + ]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip->id], + ]); + + $member = $network->servers()->firstOrFail(); + $this->assertSame(NetworkServerStatus::ACTIVE, $member->status); + $this->assertSame($ip->id, $member->server_ip_address_id); + $this->assertSame(NetworkStatus::ACTIVE, $network->fresh()->status); + $this->assertNull($this->server->fresh()->service('vpn')); + SSH::assertNotExecutedContains('wg-quick'); + } + + public function test_create_rejects_zero_servers(): void + { + $this->expectException(ValidationException::class); + + app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'empty', + 'type' => 'wireguard', + 'servers' => [], + ]); + } + + public function test_create_provider_rejects_public_ip(): void + { + $ip = ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => '1.2.3.4', + 'type' => IpAddressType::PUBLIC, + ]); + + $this->expectException(ValidationException::class); + + app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip->id], + ]); + } + + public function test_remove_last_wireguard_member_tears_down_and_uninstalls(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + app(RemoveServerFromNetwork::class)->remove($member->fresh()); + + $this->assertDatabaseMissing('network_servers', ['id' => $member->id]); + $this->assertNull($this->server->fresh()->service('vpn')); + SSH::assertExecutedContains('wg-quick down wg-vito-'.$network->id); + SSH::assertExecutedContains('apt-get remove -y wireguard'); + } + + public function test_delete_network_removes_members_and_network(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + app(DeleteNetwork::class)->delete($network); + + $this->assertDatabaseMissing('networks', ['id' => $network->id]); + $this->assertDatabaseMissing('network_servers', ['network_id' => $network->id]); + } + + public function test_reconciler_force_converges_stuck_leaving_member(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + NetworkServer::query()->whereKey($member->id)->update([ + 'status' => NetworkServerStatus::LEAVING, + 'sync_attempts' => 5, + 'updated_at' => now()->subMinutes(3), + ]); + + Artisan::call('networks:reconcile'); + + $this->assertDatabaseMissing('network_servers', ['id' => $member->id]); + } + + public function test_create_wireguard_rejects_when_block_is_full(): void + { + SSH::fake(); + + $servers = [$this->server->id]; + for ($i = 0; $i < 13; $i++) { + $servers[] = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::DISCONNECTED, + ])->id; + } + + try { + app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-full', + 'type' => 'wireguard', + 'prefix' => 28, + 'servers' => $servers, + ]); + $this->fail('Expected a full-block ValidationException.'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('servers', $e->errors()); + } + + $this->assertDatabaseMissing('networks', ['name' => 'wg-full']); + } + + public function test_add_server_to_wireguard_network_assigns_distinct_ip(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $server2 = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + + app(AddServersToNetwork::class)->add($network, ['servers' => [$server2->id]]); + + $ips = $network->servers()->pluck('ip'); + $this->assertSame(2, $ips->count()); + $this->assertSame($ips->count(), $ips->unique()->count()); + $this->assertContains('100.64.0.3', $ips->all()); + } + + public function test_add_server_rejects_cross_project_server(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $otherUser = User::factory()->create(); + $otherUser->ensureHasDefaultProject(); + $foreign = Server::factory()->create([ + 'project_id' => $otherUser->current_project_id, + 'user_id' => $otherUser->id, + 'status' => ServerStatus::READY, + ]); + + $this->expectException(ValidationException::class); + + app(AddServersToNetwork::class)->add($network, ['servers' => [$foreign->id]]); + } + + public function test_sync_reinstalls_wireguard_when_service_not_ready(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $this->server->services()->create([ + 'type' => 'vpn', + 'name' => 'wireguard', + 'version' => 'latest', + 'status' => ServiceStatus::INSTALLATION_FAILED, + ]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + SSH::assertExecutedContains('apt-get install -y wireguard'); + $this->assertSame(ServiceStatus::READY, $this->server->fresh()->service('vpn')->status); + $this->assertSame(NetworkServerStatus::ACTIVE, $network->servers()->firstOrFail()->status); + } + + public function test_reconciler_retries_failed_member_when_server_ready(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + NetworkServer::query()->whereKey($member->id)->update([ + 'status' => NetworkServerStatus::FAILED, + 'sync_attempts' => 1, + ]); + + Artisan::call('networks:reconcile'); + + $fresh = $member->fresh(); + $this->assertSame(NetworkServerStatus::ACTIVE, $fresh->status); + $this->assertSame(0, $fresh->sync_attempts); + } + + public function test_deleting_server_resyncs_wireguard_siblings(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $server2 = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id, $server2->id], + ]); + $sibling = $network->servers()->where('server_id', $server2->id)->firstOrFail(); + + $this->server->deleteFromProvider = false; + $this->server->delete(); + + $this->assertDatabaseMissing('network_servers', ['server_id' => $this->server->id]); + $this->assertSame(1, $network->servers()->count()); + $this->assertSame(NetworkServerStatus::ACTIVE, $sibling->fresh()->status); + } + + public function test_leaving_one_of_two_wireguard_networks_keeps_service_installed(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $networkA = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-a', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-b', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $memberA = $networkA->servers()->firstOrFail(); + app(RemoveServerFromNetwork::class)->remove($memberA->fresh()); + + $this->assertDatabaseMissing('network_servers', ['id' => $memberA->id]); + $this->assertNotNull($this->server->fresh()->service('vpn')); + } + + public function test_reconciler_recovers_stale_updating_member(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + NetworkServer::query()->whereKey($member->id)->update([ + 'status' => NetworkServerStatus::UPDATING, + 'updated_at' => now()->subMinutes(91), + ]); + + Artisan::call('networks:reconcile'); + + $this->assertSame(NetworkServerStatus::ACTIVE, $member->fresh()->status); + } + + public function test_allocator_ignores_ipv6_member_address(): void + { + ServerIpAddress::factory()->create([ + 'server_id' => $this->server->id, + 'ip' => 'fe80::1', + 'prefix_length' => 64, + 'family' => IpAddressFamily::V6, + 'type' => IpAddressType::PUBLIC, + ]); + + $cidr = app(AllocateNetworkBlock::class)->allocate( + NetworkAddressingPool::CGNAT, + 24, + collect(), + collect([$this->server]) + ); + + $this->assertSame('100.64.0.0/24', $cidr); + } + + public function test_two_wireguard_networks_sharing_a_server_get_distinct_ports(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $a = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-a', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $b = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-b', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $this->assertSame(51820, $a->port); + $this->assertSame(51821, $b->port); + } + + public function test_provider_add_server_success(): void + { + SSH::fake(); + $ip1 = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.5', 'type' => IpAddressType::PRIVATE]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov', + 'type' => 'provider', + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip1->id], + ]); + + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); + $ip2 = ServerIpAddress::factory()->create(['server_id' => $peer->id, 'ip' => '10.0.0.6', 'type' => IpAddressType::PRIVATE]); + + app(AddServersToNetwork::class)->add($network, ['servers' => [$peer->id], 'ip_addresses' => [$peer->id => $ip2->id]]); + + $this->assertSame(2, $network->servers()->count()); + $this->assertSame(NetworkServerStatus::ACTIVE, $network->servers()->where('server_id', $peer->id)->firstOrFail()->status); + } + + public function test_sync_network_member_regenerates_config(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + app(SyncNetwork::class)->member($member->fresh()); + + $this->assertSame(NetworkServerStatus::ACTIVE, $member->fresh()->status); + SSH::assertExecutedContains('wg-quick@wg-vito-'.$network->id); + } +} diff --git a/tests/Feature/NetworkTest.php b/tests/Feature/NetworkTest.php new file mode 100644 index 000000000..ac2952fe7 --- /dev/null +++ b/tests/Feature/NetworkTest.php @@ -0,0 +1,375 @@ +create([ + 'project_id' => $this->server->project_id, + ]); + + $this->assertDatabaseHas('networks', [ + 'id' => $network->id, + 'project_id' => $this->server->project_id, + 'type' => NetworkType::WIREGUARD->value, + 'status' => NetworkStatus::ACTIVE->value, + ]); + + $network->refresh(); + $this->assertInstanceOf(NetworkType::class, $network->type); + $this->assertInstanceOf(NetworkStatus::class, $network->status); + $this->assertTrue($network->port === 51820); + } + + public function test_network_relations(): void + { + $network = Network::factory()->create([ + 'project_id' => $this->server->project_id, + ]); + + $member = NetworkServer::factory()->create([ + 'network_id' => $network->id, + 'server_id' => $this->server->id, + 'ip' => '100.64.0.2', + ]); + + $rule = NetworkFirewallRule::factory()->create([ + 'network_id' => $network->id, + ]); + + $this->assertTrue($network->servers->contains($member)); + $this->assertTrue($network->firewallRules->contains($rule)); + $this->assertTrue($this->server->project->networks->contains($network)); + $this->assertTrue($member->network->is($network)); + $this->assertTrue($member->server->is($this->server)); + } + + public function test_network_server_private_key_is_encrypted_at_rest(): void + { + $member = NetworkServer::factory()->create([ + 'network_id' => Network::factory()->create([ + 'project_id' => $this->server->project_id, + ])->id, + 'server_id' => $this->server->id, + 'ip' => '100.64.0.3', + 'private_key' => 'super-secret-private-key', + 'status' => NetworkServerStatus::PENDING, + ]); + + $raw = DB::table('network_servers')->where('id', $member->id)->value('private_key'); + $this->assertNotSame('super-secret-private-key', $raw); + $this->assertSame('super-secret-private-key', $member->fresh()->private_key); + $this->assertInstanceOf(NetworkServerStatus::class, $member->fresh()->status); + } + + public function test_generate_wireguard_keys_produce_valid_curve25519_format(): void + { + $keys = app(GenerateWireGuardKeys::class)->generate(); + + foreach (['private_key', 'public_key'] as $key) { + $decoded = base64_decode($keys[$key], true); + $this->assertNotFalse($decoded); + $this->assertSame(32, strlen((string) $decoded)); + $this->assertSame(44, strlen($keys[$key])); + } + + $private = base64_decode($keys['private_key']); + $this->assertSame(0, ord($private[0]) & 7); + $this->assertSame(64, ord($private[31]) & 192); + } + + public function test_wireguard_service_installs_and_uninstalls_via_ssh(): void + { + SSH::fake('v1.0.20210914'); + + $service = $this->server->services()->create([ + 'type' => WireGuard::type(), + 'name' => WireGuard::id(), + 'version' => 'latest', + 'status' => ServiceStatus::INSTALLING, + ]); + + $handler = $service->handler(); + $handler->install(); + SSH::assertExecutedContains('apt-get install -y wireguard'); + $this->assertSame('v1.0.20210914', $handler->version()); + + $handler->uninstall(); + SSH::assertExecutedContains('apt-get remove -y wireguard'); + } + + public function test_wireguard_cannot_be_installed_via_services_store(): void + { + SSH::fake(); + + $this->actingAs($this->user); + + $this->post(route('services.store', ['server' => $this->server]), [ + 'name' => WireGuard::id(), + 'version' => 'latest', + ])->assertSessionHasErrors('name'); + + $this->assertDatabaseMissing('services', [ + 'server_id' => $this->server->id, + 'name' => WireGuard::id(), + ]); + } + + public function test_wireguard_configure_network_uploads_conf_and_starts_interface(): void + { + SSH::fake(); + + $network = Network::factory()->create([ + 'project_id' => $this->server->project_id, + 'cidr' => '100.64.0.0/24', + 'cidr_canonical' => '100.64.0.0/24', + 'port' => 51820, + ]); + + $peerServer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + ]); + + NetworkServer::factory()->create([ + 'network_id' => $network->id, + 'server_id' => $peerServer->id, + 'ip' => '100.64.0.3', + 'public_key' => 'PEERPUBLICKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + 'status' => NetworkServerStatus::ACTIVE, + ]); + + $membership = NetworkServer::factory()->create([ + 'network_id' => $network->id, + 'server_id' => $this->server->id, + 'ip' => '100.64.0.2', + 'private_key' => 'MYPRIVATEKEYBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', + 'public_key' => 'MYPUBLICKEYCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=', + 'status' => NetworkServerStatus::UPDATING, + ]); + + $service = $this->server->services()->create([ + 'type' => WireGuard::type(), + 'name' => WireGuard::id(), + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + + /** @var WireGuard $handler */ + $handler = $service->handler(); + $handler->configureNetwork($membership); + + SSH::assertFileUploaded("/etc/wireguard/wg-vito-{$network->id}.conf.tmp"); + SSH::assertExecutedContains('install -m 600 -o root -g root /etc/wireguard/wg-vito-'.$network->id.'.conf.tmp'); + + $content = SSH::getUploadedContent(); + $this->assertStringContainsString('[Interface]', $content); + $this->assertStringContainsString('Address = 100.64.0.2/24', $content); + $this->assertStringContainsString('ListenPort = 51820', $content); + $this->assertStringContainsString('[Peer]', $content); + $this->assertStringContainsString('PEERPUBLICKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', $content); + $this->assertStringContainsString('AllowedIPs = 100.64.0.3/32', $content); + $this->assertStringContainsString('Endpoint = '.$peerServer->ip.':51820', $content); + + SSH::assertExecutedContains("wg-quick@wg-vito-{$network->id}"); + } + + public function test_store_creates_wireguard_network_and_redirects(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + $this->actingAs($this->user); + + $response = $this->post(route('networks.store'), [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('networks', [ + 'name' => 'wg-net', + 'type' => NetworkType::WIREGUARD->value, + 'project_id' => $this->server->project_id, + ]); + } + + public function test_add_server_via_http_rejects_cross_project_server(): void + { + SSH::fake(); + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + + $otherUser = User::factory()->create(); + $otherUser->ensureHasDefaultProject(); + $foreign = Server::factory()->create([ + 'project_id' => $otherUser->current_project_id, + 'user_id' => $otherUser->id, + ]); + + $this->actingAs($this->user); + + $this->post(route('networks.servers.store', $network), [ + 'servers' => [$foreign->id], + ])->assertSessionHasErrors('servers.0'); + + $this->assertDatabaseMissing('network_servers', ['server_id' => $foreign->id]); + } + + public function test_destroy_network_via_http(): void + { + SSH::fake(); + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + + $this->actingAs($this->user); + + $this->delete(route('networks.destroy', $network))->assertRedirect(route('networks')); + $this->assertDatabaseMissing('networks', ['id' => $network->id]); + } + + public function test_cannot_view_another_projects_network(): void + { + $otherUser = User::factory()->create(); + $otherUser->ensureHasDefaultProject(); + $network = Network::factory()->create(['project_id' => $otherUser->current_project_id]); + + $this->actingAs($this->user); + + $this->get(route('networks.show', $network))->assertForbidden(); + } + + public function test_network_server_resource_never_exposes_private_key(): void + { + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + $member = NetworkServer::factory()->create([ + 'network_id' => $network->id, + 'server_id' => $this->server->id, + 'ip' => '100.64.0.9', + 'private_key' => 'top-secret', + ]); + + $data = (new NetworkServerResource($member->load('server')))->toArray(request()); + + $this->assertArrayNotHasKey('private_key', $data); + $this->assertArrayHasKey('public_key', $data); + } + + public function test_firewall_rule_update_via_http(): void + { + SSH::fake(); + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + $rule = NetworkFirewallRule::factory()->create(['network_id' => $network->id, 'type' => 'allow', 'port' => '80']); + + $this->actingAs($this->user); + + $this->put(route('networks.firewall.update', ['network' => $network, 'networkFirewallRule' => $rule]), [ + 'name' => 'updated', + 'type' => 'deny', + 'protocol' => 'tcp', + 'port' => '443', + 'position' => 0, + ])->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('network_firewall_rules', ['id' => $rule->id, 'type' => 'deny', 'port' => '443']); + } + + public function test_sub_resource_404_when_not_belonging_to_network(): void + { + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + $other = Network::factory()->create(['project_id' => $this->server->project_id]); + $rule = NetworkFirewallRule::factory()->create(['network_id' => $other->id]); + + $this->actingAs($this->user); + + $this->delete(route('networks.firewall.destroy', ['network' => $network, 'networkFirewallRule' => $rule])) + ->assertNotFound(); + } + + public function test_update_network_rename_via_http(): void + { + $network = Network::factory()->create(['project_id' => $this->server->project_id, 'name' => 'old-name']); + + $this->actingAs($this->user); + + $this->put(route('networks.update', $network), ['name' => 'new-name'])->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('networks', ['id' => $network->id, 'name' => 'new-name']); + } + + public function test_viewer_role_cannot_create_network(): void + { + $viewer = User::factory()->create(); + $this->server->project->users()->create(['user_id' => $viewer->id, 'role' => UserRole::USER]); + $viewer->current_project_id = $this->server->project_id; + $viewer->save(); + + $this->actingAs($viewer); + + $this->post(route('networks.store'), [ + 'name' => 'denied', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ])->assertForbidden(); + } + + public function test_member_regenerate_via_http(): void + { + SSH::fake(); + $network = Network::factory()->create(['project_id' => $this->server->project_id]); + $member = NetworkServer::factory()->create([ + 'network_id' => $network->id, + 'server_id' => $this->server->id, + 'ip' => '100.64.0.5', + ]); + + $this->actingAs($this->user); + + $this->post(route('networks.servers.sync', ['network' => $network, 'networkServer' => $member])) + ->assertSessionDoesntHaveErrors(); + } + + public function test_provider_duplicate_cidr_returns_422(): void + { + $ip = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.5', 'type' => IpAddressType::PRIVATE]); + Network::factory()->create([ + 'project_id' => $this->server->project_id, + 'type' => NetworkType::PROVIDER, + 'cidr' => '10.0.0.0/24', + 'cidr_canonical' => '10.0.0.0/24', + ]); + + $this->actingAs($this->user); + + $this->post(route('networks.store'), [ + 'name' => 'dup', + 'type' => 'provider', + 'cidr' => '10.0.0.5/24', + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip->id], + ])->assertSessionHasErrors('cidr'); + } +} From 53c6fd3e451a5225b9a54492daf06c8c9d9bb4c1 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Tue, 21 Jul 2026 22:24:22 +0100 Subject: [PATCH 02/27] fixes --- app/Actions/Network/AddServersToNetwork.php | 2 +- .../Network/CompileServerFirewallRules.php | 34 +-- app/Actions/Network/CreateNetwork.php | 27 ++- .../Network/ManageNetworkFirewallRule.php | 17 +- .../Network/RemoveServerFromNetwork.php | 2 +- app/Actions/Network/UpdateNetwork.php | 14 -- app/Actions/Network/UpdateNetworkServerIp.php | 43 ++++ app/Enums/NetworkType.php | 4 +- app/Http/Controllers/FirewallController.php | 5 +- app/Http/Controllers/NetworkController.php | 47 +++- .../NetworkFirewallRuleController.php | 14 ++ .../Controllers/NetworkServerController.php | 15 ++ app/Http/Middleware/HandleInertiaRequests.php | 15 ++ .../Resources/NetworkFirewallRuleResource.php | 2 - app/Http/Resources/NetworkResource.php | 1 - app/Models/Network.php | 3 - app/Models/NetworkFirewallRule.php | 5 - app/Tables/NetworkTable.php | 2 - .../Networks/NetworkFirewallRuleTable.php | 4 +- app/Tables/Networks/NetworkServerTable.php | 17 +- database/factories/NetworkFactory.php | 1 - .../factories/NetworkFirewallRuleFactory.php | 2 - ...026_07_21_131503_create_networks_table.php | 1 - ...21_create_network_firewall_rules_table.php | 4 - resources/js/components/app-sidebar.tsx | 10 +- resources/js/components/dialogs/registry.ts | 2 + resources/js/components/ui/combobox.tsx | 3 +- resources/js/layouts/network/layout.tsx | 46 ++++ .../pages/networks/components/add-server.tsx | 106 ++++---- .../networks/components/create-network.tsx | 132 ++++------ .../components/edit-network-server.tsx | 75 ++++++ .../components/firewall-rule-form.tsx | 27 --- .../networks/components/private-ip-select.tsx | 105 ++++++++ resources/js/pages/networks/firewall.tsx | 79 ++++++ resources/js/pages/networks/index.tsx | 64 +---- resources/js/pages/networks/servers.tsx | 105 ++++++++ resources/js/pages/networks/settings.tsx | 134 ++++++++++ resources/js/pages/networks/show.tsx | 228 ++++-------------- resources/js/types/network.d.ts | 11 +- tests/Feature/NetworkFirewallTest.php | 165 ++++++------- tests/Feature/NetworkTest.php | 54 ++++- 41 files changed, 1020 insertions(+), 607 deletions(-) create mode 100644 app/Actions/Network/UpdateNetworkServerIp.php create mode 100644 resources/js/layouts/network/layout.tsx create mode 100644 resources/js/pages/networks/components/edit-network-server.tsx create mode 100644 resources/js/pages/networks/components/private-ip-select.tsx create mode 100644 resources/js/pages/networks/firewall.tsx create mode 100644 resources/js/pages/networks/servers.tsx create mode 100644 resources/js/pages/networks/settings.tsx diff --git a/app/Actions/Network/AddServersToNetwork.php b/app/Actions/Network/AddServersToNetwork.php index 0eec986c5..e5ebc1a63 100644 --- a/app/Actions/Network/AddServersToNetwork.php +++ b/app/Actions/Network/AddServersToNetwork.php @@ -43,7 +43,7 @@ public function add(Network $network, array $input): void $this->sync->toPresent($member); } } - } elseif ($network->firewall_enabled) { + } else { $this->firewall->handle($network); } diff --git a/app/Actions/Network/CompileServerFirewallRules.php b/app/Actions/Network/CompileServerFirewallRules.php index 4ed859285..dbdbc631e 100644 --- a/app/Actions/Network/CompileServerFirewallRules.php +++ b/app/Actions/Network/CompileServerFirewallRules.php @@ -14,11 +14,12 @@ class CompileServerFirewallRules { /** - * Ordered network firewall pseudo-rules for a server, emitted BEFORE the - * server's own rules. Order (ufw is first-match): WireGuard handshake - * (always, so the tunnel survives `default deny incoming`), then per-network - * position-ordered allow/deny rules scoped to the network source, then a - * default `allow from ` catch-all (network is allow-all by default). + * Network firewall allow-rules for a server, emitted BEFORE the server's own + * rules. Order (ufw is first-match): WireGuard handshake (always, so the + * tunnel survives `default deny incoming`), then the network's allow rules + * scoped to the network source. A network is seeded with an "Allow all" rule + * (null protocol/port → `allow from `) so it is permissive by + * default; deleting that rule locks the network down to its explicit allows. * * @return array */ @@ -32,32 +33,18 @@ public function forServer(Server $server): array $handshakes = []; $ruleSpecs = []; - $catchAlls = []; foreach ($memberships as $membership) { $network = $membership->network; - $wireguard = $network->type === NetworkType::WIREGUARD; - if ($wireguard) { + if ($network->type === NetworkType::WIREGUARD) { $handshakes = array_merge($handshakes, $this->handshakeSpecs($network, $server)); } - if (! $wireguard && ! $network->firewall_enabled) { - continue; - } - - $sources = $this->sources($network, $server); - - if ($network->firewall_enabled) { - $ruleSpecs = array_merge($ruleSpecs, $this->ruleSpecs($network, $sources)); - } - - foreach ($sources as $source) { - $catchAlls[] = $this->spec('allow', $source['ip'], $source['mask'], null, null); - } + $ruleSpecs = array_merge($ruleSpecs, $this->ruleSpecs($network, $this->sources($network, $server))); } - return array_merge($handshakes, $ruleSpecs, $catchAlls); + return array_merge($handshakes, $ruleSpecs); } /** @@ -88,14 +75,13 @@ private function ruleSpecs(Network $network, array $sources): array $rules = $network->firewallRules() ->where('status', '!=', FirewallRuleStatus::DELETING) - ->orderBy('position') ->orderBy('id') ->get(); foreach ($rules as $rule) { foreach ($sources as $source) { $specs[] = $this->spec( - $rule->type, + 'allow', $source['ip'], $source['mask'], $rule->protocol, diff --git a/app/Actions/Network/CreateNetwork.php b/app/Actions/Network/CreateNetwork.php index 81e7745a6..15f85dc80 100644 --- a/app/Actions/Network/CreateNetwork.php +++ b/app/Actions/Network/CreateNetwork.php @@ -2,6 +2,7 @@ namespace App\Actions\Network; +use App\Enums\FirewallRuleStatus; use App\Enums\IpAddressType; use App\Enums\NetworkAddressingPool; use App\Enums\NetworkServerStatus; @@ -35,16 +36,22 @@ public function create(Project $project, array $input): Network $type = NetworkType::from($input['type']); - $network = DB::transaction(fn (): Network => $type === NetworkType::WIREGUARD - ? $this->buildWireGuard($project, $input) - : $this->buildProvider($project, $input)); + $network = DB::transaction(function () use ($type, $project, $input): Network { + $network = $type === NetworkType::WIREGUARD + ? $this->buildWireGuard($project, $input) + : $this->buildProvider($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); } - } elseif ($network->firewall_enabled) { + } else { $this->firewall->handle($network); } @@ -82,7 +89,6 @@ private function buildWireGuard(Project $project, array $input): Network 'cidr' => $cidr, 'cidr_canonical' => $cidr, 'port' => $this->allocatePort($project, $members->pluck('id')->all(), (int) ($input['port'] ?? 51820)), - 'firewall_enabled' => (bool) ($input['firewall_enabled'] ?? false), ]); $used = []; @@ -131,7 +137,6 @@ private function buildProvider(Project $project, array $input): Network 'status' => NetworkStatus::ACTIVE, 'cidr' => $input['cidr'] ?? null, 'cidr_canonical' => isset($input['cidr']) ? Cidr::canonical($input['cidr']) : null, - 'firewall_enabled' => (bool) ($input['firewall_enabled'] ?? false), ]); foreach ($input['servers'] as $serverId) { @@ -145,6 +150,16 @@ private function buildProvider(Project $project, array $input): Network return $network; } + private function seedDefaultFirewallRule(Network $network): void + { + $network->firewallRules()->create([ + 'name' => 'Allow all', + 'protocol' => null, + 'port' => null, + 'status' => FirewallRuleStatus::READY, + ]); + } + /** * @param array $serverIds */ diff --git a/app/Actions/Network/ManageNetworkFirewallRule.php b/app/Actions/Network/ManageNetworkFirewallRule.php index d8ed31295..b5b1a9e1e 100644 --- a/app/Actions/Network/ManageNetworkFirewallRule.php +++ b/app/Actions/Network/ManageNetworkFirewallRule.php @@ -25,7 +25,7 @@ public function create(Network $network, array $input): NetworkFirewallRule $rule = $network->firewallRules()->create($this->attributes($input)); $this->broadcast($network, 'network-firewall-rule.updated', new NetworkFirewallRuleResource($rule)); - $this->applyIfEnabled($network); + $this->apply->handle($network); return $rule; } @@ -40,7 +40,7 @@ public function update(NetworkFirewallRule $rule, array $input): NetworkFirewall $rule->update($this->attributes($input)); $this->broadcast($rule->network, 'network-firewall-rule.updated', new NetworkFirewallRuleResource($rule)); - $this->applyIfEnabled($rule->network); + $this->apply->handle($rule->network); return $rule; } @@ -52,7 +52,7 @@ public function delete(NetworkFirewallRule $rule): void $rule->delete(); $this->broadcast($network, 'network-firewall-rule.deleted', ['id' => $ruleId]); - $this->applyIfEnabled($network); + $this->apply->handle($network); } /** @@ -67,13 +67,6 @@ private function broadcast(Network $network, string $type, array|NetworkFirewall )); } - private function applyIfEnabled(Network $network): void - { - if ($network->firewall_enabled) { - $this->apply->handle($network); - } - } - /** * @param array $input * @return array @@ -84,10 +77,8 @@ private function attributes(array $input): array return [ 'name' => $input['name'], - 'type' => $input['type'], 'protocol' => $input['protocol'] ?? null, 'port' => ($port === null || $port === '') ? null : (string) $port, - 'position' => (int) ($input['position'] ?? 0), 'status' => FirewallRuleStatus::READY, ]; } @@ -99,8 +90,6 @@ private function validate(array $input): void { Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], - 'type' => ['required', 'in:allow,deny'], - 'position' => ['nullable', 'integer', 'min:0'], 'protocol' => ['nullable', 'in:tcp,udp', 'required_with:port'], 'port' => ['nullable', 'required_with:protocol', new PortOrPortRangeRule], ])->validate(); diff --git a/app/Actions/Network/RemoveServerFromNetwork.php b/app/Actions/Network/RemoveServerFromNetwork.php index 4f0eb1662..a9d25f17e 100644 --- a/app/Actions/Network/RemoveServerFromNetwork.php +++ b/app/Actions/Network/RemoveServerFromNetwork.php @@ -29,7 +29,7 @@ public function remove(NetworkServer $member): void $this->sync->toPresent($sibling); } } - } elseif ($network->firewall_enabled) { + } else { $this->firewall->handle($network); } diff --git a/app/Actions/Network/UpdateNetwork.php b/app/Actions/Network/UpdateNetwork.php index b79946660..13c4899c3 100644 --- a/app/Actions/Network/UpdateNetwork.php +++ b/app/Actions/Network/UpdateNetwork.php @@ -8,8 +8,6 @@ class UpdateNetwork { - public function __construct(private ApplyNetworkFirewall $apply) {} - /** * @param array $input */ @@ -17,21 +15,10 @@ public function update(Network $network, array $input): Network { $this->validate($network, $input); - $firewallEnabled = array_key_exists('firewall_enabled', $input) - ? (bool) $input['firewall_enabled'] - : $network->firewall_enabled; - - $firewallChanged = $firewallEnabled !== $network->firewall_enabled; - $network->update([ 'name' => $input['name'] ?? $network->name, - 'firewall_enabled' => $firewallEnabled, ]); - if ($firewallChanged) { - $this->apply->handle($network); - } - return $network->refresh(); } @@ -50,7 +37,6 @@ private function validate(Network $network, array $input): void ->where('project_id', $network->project_id) ->ignore($network->id), ], - 'firewall_enabled' => ['sometimes', 'boolean'], ])->validate(); } } diff --git a/app/Actions/Network/UpdateNetworkServerIp.php b/app/Actions/Network/UpdateNetworkServerIp.php new file mode 100644 index 000000000..e82e89ff0 --- /dev/null +++ b/app/Actions/Network/UpdateNetworkServerIp.php @@ -0,0 +1,43 @@ + $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), + ], + ])->validate(); + } +} diff --git a/app/Enums/NetworkType.php b/app/Enums/NetworkType.php index e35e45341..0770e0c31 100644 --- a/app/Enums/NetworkType.php +++ b/app/Enums/NetworkType.php @@ -21,8 +21,8 @@ public function getColor(): string public function getText(): string { return match ($this) { - self::PROVIDER => 'Provider Managed', - self::WIREGUARD => 'WireGuard', + self::PROVIDER => 'provider managed', + self::WIREGUARD => 'wireguard', }; } } diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php index d701d2a7c..9e91c5cce 100644 --- a/app/Http/Controllers/FirewallController.php +++ b/app/Http/Controllers/FirewallController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Actions\FirewallRule\ManageRule; +use App\Enums\NetworkServerStatus; use App\Models\FirewallRule; use App\Models\Network; use App\Models\Server; @@ -30,8 +31,8 @@ public function index(Server $server): Response return Inertia::render('firewall/index', [ 'rules' => FirewallRuleTable::make($server->firewallRules())->simplePaginate(), 'managedNetworks' => Network::query() - ->where('firewall_enabled', true) - ->whereHas('servers', fn ($query) => $query->where('server_id', $server->id)) + ->whereHas('servers', fn ($query) => $query->where('server_id', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING)) ->get() ->map(fn (Network $network): array => [ 'id' => $network->id, diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php index 94614f7e1..851ce85f9 100644 --- a/app/Http/Controllers/NetworkController.php +++ b/app/Http/Controllers/NetworkController.php @@ -7,11 +7,11 @@ use App\Actions\Network\SyncNetwork; use App\Actions\Network\UpdateNetwork; use App\Enums\IpAddressType; -use App\Http\Resources\NetworkResource; +use App\Enums\NetworkType; use App\Models\Network; +use App\Models\NetworkServer; use App\Models\Project; use App\Models\Server; -use App\Tables\Networks\NetworkFirewallRuleTable; use App\Tables\Networks\NetworkServerTable; use App\Tables\NetworkTable; use Illuminate\Http\RedirectResponse; @@ -60,16 +60,53 @@ public function show(Network $network): Response { $this->authorize('view', $network); + return Inertia::render('networks/show'); + } + + #[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/show', [ - 'network' => new NetworkResource($network->loadCount('servers')), + return Inertia::render('networks/servers', [ 'members' => NetworkServerTable::make($network->servers())->identifier('members')->simplePaginate(), - 'rules' => NetworkFirewallRuleTable::make($network->firewallRules())->identifier('rules')->simplePaginate(), 'servers' => $this->serversPayload($network->project, $memberServerIds), + 'memberIps' => $network->type === NetworkType::PROVIDER ? $this->memberIpsPayload($network) : [], ]); } + /** + * @return array> + */ + private function memberIpsPayload(Network $network): array + { + return $network->servers() + ->with(['server', 'server.ipAddresses' => fn ($query) => $query->where('type', IpAddressType::PRIVATE)]) + ->get() + ->map(fn (NetworkServer $member): array => [ + 'id' => $member->id, + 'server_id' => $member->server_id, + 'server_name' => $member->server->name, + 'ip_address_id' => $member->server_ip_address_id, + 'private_ips' => $member->server->ipAddresses->map(fn ($ip): array => [ + 'id' => $ip->id, + 'ip' => $ip->ip, + 'is_primary' => $ip->is_primary, + ])->values(), + ]) + ->all(); + } + + #[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 { diff --git a/app/Http/Controllers/NetworkFirewallRuleController.php b/app/Http/Controllers/NetworkFirewallRuleController.php index abf9a4a7c..732f8f1ac 100644 --- a/app/Http/Controllers/NetworkFirewallRuleController.php +++ b/app/Http/Controllers/NetworkFirewallRuleController.php @@ -5,9 +5,13 @@ use App\Actions\Network\ManageNetworkFirewallRule; use App\Models\Network; use App\Models\NetworkFirewallRule; +use App\Tables\Networks\NetworkFirewallRuleTable; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response; use Spatie\RouteAttributes\Attributes\Delete; +use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; @@ -17,6 +21,16 @@ #[Middleware(['auth', 'has-project'])] class NetworkFirewallRuleController extends Controller { + #[Get('/', name: 'networks.firewall')] + public function index(Network $network): Response + { + $this->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 { diff --git a/app/Http/Controllers/NetworkServerController.php b/app/Http/Controllers/NetworkServerController.php index 7460ae038..9bc7a472a 100644 --- a/app/Http/Controllers/NetworkServerController.php +++ b/app/Http/Controllers/NetworkServerController.php @@ -5,6 +5,8 @@ use App\Actions\Network\AddServersToNetwork; use App\Actions\Network\RemoveServerFromNetwork; use App\Actions\Network\SyncNetwork; +use App\Actions\Network\UpdateNetworkServerIp; +use App\Enums\NetworkType; use App\Models\Network; use App\Models\NetworkServer; use Illuminate\Http\RedirectResponse; @@ -13,6 +15,7 @@ use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; +use Spatie\RouteAttributes\Attributes\Put; #[Prefix('networks/{network}/servers')] #[Middleware(['auth', 'has-project'])] @@ -39,6 +42,18 @@ public function sync(Network $network, NetworkServer $networkServer): RedirectRe 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::PROVIDER, 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 { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 85ded423c..029351714 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,19 @@ 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(); + } + + $data['network'] = fn () => NetworkResource::make($network->loadCount('servers')); + } + } + return [ ...parent::share($request), ...$data, diff --git a/app/Http/Resources/NetworkFirewallRuleResource.php b/app/Http/Resources/NetworkFirewallRuleResource.php index 15ba56675..586da6535 100644 --- a/app/Http/Resources/NetworkFirewallRuleResource.php +++ b/app/Http/Resources/NetworkFirewallRuleResource.php @@ -18,10 +18,8 @@ public function toArray(Request $request): array 'id' => $this->id, 'network_id' => $this->network_id, 'name' => $this->name, - 'type' => $this->type, 'protocol' => $this->protocol, 'port' => $this->port, - 'position' => $this->position, 'status' => $this->status->getText(), 'status_color' => $this->status->getColor(), 'created_at' => $this->created_at, diff --git a/app/Http/Resources/NetworkResource.php b/app/Http/Resources/NetworkResource.php index ba6780203..70b56e137 100644 --- a/app/Http/Resources/NetworkResource.php +++ b/app/Http/Resources/NetworkResource.php @@ -24,7 +24,6 @@ public function toArray(Request $request): array 'addressing_pool' => $this->addressing_pool->getText(), 'cidr' => $this->cidr, 'port' => $this->port, - 'firewall_enabled' => $this->firewall_enabled, 'status' => $this->status->getText(), 'status_color' => $this->status->getColor(), 'servers_count' => $this->whenCounted('servers'), diff --git a/app/Models/Network.php b/app/Models/Network.php index 20ff71f94..5cca8fbea 100644 --- a/app/Models/Network.php +++ b/app/Models/Network.php @@ -21,7 +21,6 @@ * @property ?string $cidr * @property ?string $cidr_canonical * @property ?int $port - * @property bool $firewall_enabled * @property Project $project * @property Collection $servers * @property Collection $firewallRules @@ -40,13 +39,11 @@ class Network extends AbstractModel 'cidr', 'cidr_canonical', 'port', - 'firewall_enabled', ]; protected $casts = [ 'project_id' => 'integer', 'port' => 'integer', - 'firewall_enabled' => 'boolean', 'type' => NetworkType::class, 'status' => NetworkStatus::class, 'addressing_pool' => NetworkAddressingPool::class, diff --git a/app/Models/NetworkFirewallRule.php b/app/Models/NetworkFirewallRule.php index 7919474e7..069f6b3cd 100644 --- a/app/Models/NetworkFirewallRule.php +++ b/app/Models/NetworkFirewallRule.php @@ -11,10 +11,8 @@ * @property int $id * @property int $network_id * @property string $name - * @property string $type * @property ?string $protocol * @property ?string $port - * @property int $position * @property FirewallRuleStatus $status * @property Network $network */ @@ -26,16 +24,13 @@ class NetworkFirewallRule extends AbstractModel protected $fillable = [ 'network_id', 'name', - 'type', 'protocol', 'port', - 'position', 'status', ]; protected $casts = [ 'network_id' => 'integer', - 'position' => 'integer', 'status' => FirewallRuleStatus::class, ]; diff --git a/app/Tables/NetworkTable.php b/app/Tables/NetworkTable.php index 428cd4c3e..dc3d4858e 100644 --- a/app/Tables/NetworkTable.php +++ b/app/Tables/NetworkTable.php @@ -4,7 +4,6 @@ use Forjed\InertiaTable\Column; use Forjed\InertiaTable\Columns\ActionsColumn; -use Forjed\InertiaTable\Columns\BooleanColumn; use Forjed\InertiaTable\Columns\EnumColumn; use Forjed\InertiaTable\Columns\TextColumn; use Forjed\InertiaTable\Table; @@ -25,7 +24,6 @@ protected function columns(): array TextColumn::make('name', 'Name')->sortable(), EnumColumn::make('type', 'Type'), TextColumn::make('servers_count', 'Servers'), - BooleanColumn::make('firewall_enabled', 'Firewall'), EnumColumn::make('status', 'Status')->sortable(), Column::data('id'), ActionsColumn::make(), diff --git a/app/Tables/Networks/NetworkFirewallRuleTable.php b/app/Tables/Networks/NetworkFirewallRuleTable.php index ed1547a19..343d05c17 100644 --- a/app/Tables/Networks/NetworkFirewallRuleTable.php +++ b/app/Tables/Networks/NetworkFirewallRuleTable.php @@ -15,17 +15,15 @@ class NetworkFirewallRuleTable extends Table protected function query(): void { $this->perPage = config('web.pagination_size'); - $this->query->orderBy('position')->orderBy('id'); + $this->query->orderBy('id'); } protected function columns(): array { return [ TextColumn::make('name', 'Name')->sortable(), - TextColumn::make('type', 'Type'), TextColumn::make('protocol', 'Protocol'), TextColumn::make('port', 'Port'), - TextColumn::make('position', 'Position')->sortable(), EnumColumn::make('status', 'Status'), Column::data('id'), ActionsColumn::make(), diff --git a/app/Tables/Networks/NetworkServerTable.php b/app/Tables/Networks/NetworkServerTable.php index 11eb7b693..176f41e77 100644 --- a/app/Tables/Networks/NetworkServerTable.php +++ b/app/Tables/Networks/NetworkServerTable.php @@ -2,6 +2,8 @@ namespace App\Tables\Networks; +use App\Models\NetworkServer; +use App\Models\Service; use Forjed\InertiaTable\Column; use Forjed\InertiaTable\Columns\ActionsColumn; use Forjed\InertiaTable\Columns\EnumColumn; @@ -15,18 +17,29 @@ class NetworkServerTable extends Table protected function query(): void { $this->perPage = config('web.pagination_size'); - $this->query->with('server', 'serverIpAddress')->latest(); + $this->query->with(['server.services', 'serverIpAddress'])->latest(); } protected function columns(): array { return [ TextColumn::make('server.name', 'Server')->sortable(), - TextColumn::make('ip', 'Tunnel IP')->fallback('—'), + 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/database/factories/NetworkFactory.php b/database/factories/NetworkFactory.php index 984fba400..f5fe20da4 100644 --- a/database/factories/NetworkFactory.php +++ b/database/factories/NetworkFactory.php @@ -29,7 +29,6 @@ public function definition(): array 'cidr' => "100.64.{$block}.0/24", 'cidr_canonical' => "100.64.{$block}.0/24", 'port' => 51820, - 'firewall_enabled' => false, ]; } } diff --git a/database/factories/NetworkFirewallRuleFactory.php b/database/factories/NetworkFirewallRuleFactory.php index 09e1cd4e3..4f4eddca3 100644 --- a/database/factories/NetworkFirewallRuleFactory.php +++ b/database/factories/NetworkFirewallRuleFactory.php @@ -19,10 +19,8 @@ public function definition(): array return [ 'network_id' => Network::factory(), 'name' => $this->faker->word(), - 'type' => 'allow', 'protocol' => 'tcp', 'port' => (string) $this->faker->numberBetween(1, 65535), - 'position' => 0, 'status' => FirewallRuleStatus::READY, ]; } diff --git a/database/migrations/2026_07_21_131503_create_networks_table.php b/database/migrations/2026_07_21_131503_create_networks_table.php index 3e423c450..f09f92bda 100644 --- a/database/migrations/2026_07_21_131503_create_networks_table.php +++ b/database/migrations/2026_07_21_131503_create_networks_table.php @@ -20,7 +20,6 @@ public function up(): void $table->string('cidr')->nullable(); $table->string('cidr_canonical')->nullable(); $table->unsignedInteger('port')->nullable(); - $table->boolean('firewall_enabled')->default(false); $table->timestamps(); $table->unique(['project_id', 'name']); diff --git a/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php b/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php index 4acfd45e6..1874f68b6 100644 --- a/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php +++ b/database/migrations/2026_07_21_131721_create_network_firewall_rules_table.php @@ -13,14 +13,10 @@ public function up(): void $table->id(); $table->foreignId('network_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('type'); $table->string('protocol')->nullable(); $table->string('port')->nullable(); - $table->unsignedInteger('position')->default(0); $table->string('status')->default(FirewallRuleStatus::CREATING->value); $table->timestamps(); - - $table->index(['network_id', 'position']); }); } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index e9ccff5c5..cc0fe128d 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -43,6 +43,11 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? href: route('servers'), icon: ServerIcon, }, + { + title: 'Networks', + href: route('networks'), + icon: NetworkIcon, + }, { title: 'Sites', href: route('sites.all'), @@ -58,11 +63,6 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? href: route('workflows'), icon: WorkflowIcon, }, - { - title: 'Networks', - href: route('networks'), - icon: NetworkIcon, - }, { title: 'Domains', href: route('domains'), diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index e89c41201..26f6e80f4 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -35,6 +35,7 @@ import Fail2banForm from '@/pages/security/components/fail2ban-form'; import PhpSettingsDialog from '@/pages/site-settings/components/php-settings-dialog'; import CreateNetwork from '@/pages/networks/components/create-network'; import AddNetworkServer from '@/pages/networks/components/add-server'; +import EditNetworkServer from '@/pages/networks/components/edit-network-server'; import NetworkFirewallRuleForm from '@/pages/networks/components/firewall-rule-form'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -88,6 +89,7 @@ export const dialogs = { phpSettings: PhpSettingsDialog, networkCreate: CreateNetwork, networkAddServer: AddNetworkServer, + networkEditServer: EditNetworkServer, networkFirewallForm: NetworkFirewallRuleForm, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/components/ui/combobox.tsx b/resources/js/components/ui/combobox.tsx index e6214ee77..946f30e4a 100644 --- a/resources/js/components/ui/combobox.tsx +++ b/resources/js/components/ui/combobox.tsx @@ -15,7 +15,7 @@ export function Combobox({ placeholder = '', onValueChange, }: { - items: { value: string; label: string }[]; + items: { value: string; label: string; keywords?: string[] }[]; value: string; id?: string; searchText?: string; @@ -45,6 +45,7 @@ export function Combobox({ { value = currentValue; onValueChange(value); diff --git a/resources/js/layouts/network/layout.tsx b/resources/js/layouts/network/layout.tsx new file mode 100644 index 000000000..9aa2ec23e --- /dev/null +++ b/resources/js/layouts/network/layout.tsx @@ -0,0 +1,46 @@ +import { type NavItem } from '@/types'; +import { FlameIcon, HomeIcon, ServerIcon, Settings2Icon } from 'lucide-react'; +import { ReactNode } from 'react'; +import { Network } from '@/types/network'; +import Layout from '@/layouts/app/layout'; +import { usePage } from '@inertiajs/react'; +import { useRealtimeRecord } from '@/hooks/use-socket-events'; + +export default function NetworkLayout({ children }: { children: ReactNode }) { + const page = usePage<{ network: Network }>(); + const network = useRealtimeRecord(page.props.network, 'network')!; + + if (typeof window === 'undefined') { + return null; + } + + const sidebarNavItems: NavItem[] = [ + { + title: 'Overview', + href: route('networks.show', { network: network.id }), + onlyActivePath: route('networks.show', { network: network.id }), + icon: HomeIcon, + }, + { + title: 'Servers', + href: route('networks.servers', { network: network.id }), + icon: ServerIcon, + }, + { + title: 'Firewall', + href: route('networks.firewall', { network: network.id }), + icon: FlameIcon, + }, + { + title: 'Settings', + href: route('networks.settings', { network: network.id }), + icon: Settings2Icon, + }, + ]; + + return ( + +
{children}
+
+ ); +} diff --git a/resources/js/pages/networks/components/add-server.tsx b/resources/js/pages/networks/components/add-server.tsx index 173266e9f..d8bb0193d 100644 --- a/resources/js/pages/networks/components/add-server.tsx +++ b/resources/js/pages/networks/components/add-server.tsx @@ -1,15 +1,14 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { FormEvent } from 'react'; +import { FormEvent, useState } from 'react'; import { Form, FormField, FormFields } from '@/components/ui/form'; import { Button } from '@/components/ui/button'; import { useForm } from '@inertiajs/react'; -import { LoaderCircleIcon, TriangleAlertIcon } from 'lucide-react'; +import { LoaderCircleIcon } from 'lucide-react'; import { Label } from '@/components/ui/label'; import InputError from '@/components/ui/input-error'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Combobox } from '@/components/ui/combobox'; import { NetworkServerOption } from '@/types/network'; +import PrivateIpSelect, { PrivateIp } from './private-ip-select'; type AddServerForm = { servers: number[]; @@ -21,7 +20,7 @@ export default function AddServer({ onOpenChange, networkId, isProvider, - servers, + servers: initialServers, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -29,14 +28,19 @@ export default function AddServer({ isProvider: boolean; servers: NetworkServerOption[]; }) { + const [servers, setServers] = useState(initialServers); + const form = useForm({ servers: [], ip_addresses: {}, }); - const toggleServer = (id: number) => { - const selected = form.data.servers.includes(id); - form.setData('servers', selected ? form.data.servers.filter((s) => s !== id) : [...form.data.servers, id]); + const selectServer = (id: number) => { + form.setData((prev) => ({ ...prev, servers: [id], ip_addresses: {} })); + }; + + const applyRefreshedIps = (serverId: number, ips: PrivateIp[]) => { + setServers((prev) => prev.map((s) => (s.id === serverId ? { ...s, private_ips: ips } : s))); }; const submit = (e: FormEvent) => { @@ -49,67 +53,49 @@ export default function AddServer({ }); }; + const selectedServerId = form.data.servers[0] ?? null; + const selectedServer = selectedServerId ? (servers.find((s) => s.id === selectedServerId) ?? null) : null; + return ( e.preventDefault()}> - Add servers - Add servers to the network + Add server + Add a server to the network
- -
- {servers.length === 0 &&

No eligible servers available.

} - {servers.map((server) => { - const selected = form.data.servers.includes(server.id); - const selectedIp = form.data.ip_addresses[server.id]; - const primaryChosen = server.private_ips.find((ip) => ip.id === selectedIp)?.is_primary; - return ( -
-
- toggleServer(server.id)} /> - - {!server.is_ready && not ready} -
- {selected && isProvider && ( -
- - {primaryChosen && ( - - - This is the server's primary IP. Make sure you intend to use it for this network. - - )} - -
- )} -
- ); - })} -
+ + ({ + value: String(server.id), + label: server.is_ready ? server.name : `${server.name} (not ready)`, + keywords: [server.name], + }))} + value={selectedServerId ? String(selectedServerId) : ''} + placeholder="Select a server" + searchText="Filter servers..." + noneFoundText="No eligible servers available." + onValueChange={(value) => selectServer(Number(value))} + />
+ + {isProvider && ( + + + form.setData('ip_addresses', selectedServerId ? { [selectedServerId]: ipId } : {})} + onRefreshed={applyRefreshedIps} + /> + + + )}
diff --git a/resources/js/pages/networks/components/create-network.tsx b/resources/js/pages/networks/components/create-network.tsx index c688b4cb5..eac0b2977 100644 --- a/resources/js/pages/networks/components/create-network.tsx +++ b/resources/js/pages/networks/components/create-network.tsx @@ -1,16 +1,16 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { FormEvent } from 'react'; +import { FormEvent, useState } from 'react'; import { Form, FormField, FormFields } from '@/components/ui/form'; import { Button } from '@/components/ui/button'; import { useForm } from '@inertiajs/react'; -import { LoaderCircleIcon, TriangleAlertIcon } from 'lucide-react'; +import { LoaderCircleIcon } from 'lucide-react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import InputError from '@/components/ui/input-error'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Combobox } from '@/components/ui/combobox'; import { NetworkServerOption } from '@/types/network'; +import PrivateIpSelect, { PrivateIp } from './private-ip-select'; type CreateNetworkForm = { name: string; @@ -20,19 +20,20 @@ type CreateNetworkForm = { prefix: string; port: string; cidr: string; - firewall_enabled: boolean; ip_addresses: Record; }; export default function CreateNetwork({ open, onOpenChange, - servers, + servers: initialServers, }: { open: boolean; onOpenChange: (open: boolean) => void; servers: NetworkServerOption[]; }) { + const [servers, setServers] = useState(initialServers); + const form = useForm({ name: '', type: 'wireguard', @@ -41,13 +42,17 @@ export default function CreateNetwork({ prefix: '24', port: '51820', cidr: '', - firewall_enabled: false, ip_addresses: {}, }); - const toggleServer = (id: number) => { - const selected = form.data.servers.includes(id); - form.setData('servers', selected ? form.data.servers.filter((s) => s !== id) : [...form.data.servers, id]); + const isProvider = form.data.type === 'provider'; + + const selectPrimaryServer = (id: number) => { + form.setData((prev) => ({ ...prev, servers: [id], ip_addresses: {} })); + }; + + const applyRefreshedIps = (serverId: number, ips: PrivateIp[]) => { + setServers((prev) => prev.map((s) => (s.id === serverId ? { ...s, private_ips: ips } : s))); }; const submit = (e: FormEvent) => { @@ -60,7 +65,8 @@ export default function CreateNetwork({ }); }; - const isProvider = form.data.type === 'provider'; + const primaryServerId = form.data.servers[0] ?? null; + const primaryServer = primaryServerId ? (servers.find((s) => s.id === primaryServerId) ?? null) : null; return ( @@ -79,13 +85,13 @@ export default function CreateNetwork({ - form.setData((prev) => ({ ...prev, type: value, servers: [], ip_addresses: {} }))}> - WireGuard (Vito-managed) + WireGuard (Custom) Provider Managed @@ -111,18 +117,11 @@ export default function CreateNetwork({ -
- - - form.setData('prefix', e.target.value)} /> - - - - - form.setData('port', e.target.value)} /> - - -
+ + + form.setData('prefix', e.target.value)} /> + + )} @@ -135,67 +134,36 @@ export default function CreateNetwork({ )} - -
- {servers.map((server) => { - const selected = form.data.servers.includes(server.id); - const selectedIp = form.data.ip_addresses[server.id]; - const primaryChosen = server.private_ips.find((ip) => ip.id === selectedIp)?.is_primary; - return ( -
-
- toggleServer(server.id)} /> - - {!server.is_ready && not ready} -
- {selected && isProvider && ( -
- - {primaryChosen && ( - - - This is the server's primary IP. Make sure you intend to use it for this network. - - )} - -
- )} -
- ); - })} -
+ + ({ + value: String(server.id), + label: server.is_ready ? server.name : `${server.name} (not ready)`, + keywords: [server.name], + }))} + value={primaryServerId ? String(primaryServerId) : ''} + placeholder="Select a server" + searchText="Filter servers..." + noneFoundText="No servers found." + onValueChange={(value) => selectPrimaryServer(Number(value))} + />
- -
- form.setData('firewall_enabled', !form.data.firewall_enabled)} + {isProvider && ( + + + form.setData('ip_addresses', primaryServerId ? { [primaryServerId]: ipId } : {})} + onRefreshed={applyRefreshedIps} /> - -
-
+ + + )} diff --git a/resources/js/pages/networks/components/edit-network-server.tsx b/resources/js/pages/networks/components/edit-network-server.tsx new file mode 100644 index 000000000..98d3118c4 --- /dev/null +++ b/resources/js/pages/networks/components/edit-network-server.tsx @@ -0,0 +1,75 @@ +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { FormEvent, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { NetworkMemberIp } from '@/types/network'; +import PrivateIpSelect, { PrivateIp } from './private-ip-select'; + +export default function EditNetworkServer({ + open, + onOpenChange, + networkId, + member, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + networkId: number; + member: NetworkMemberIp; +}) { + const [ips, setIps] = useState(member.private_ips); + + const form = useForm<{ server_ip_address_id: number | null }>({ + server_ip_address_id: member.ip_address_id, + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.put(route('networks.servers.update', { network: networkId, networkServer: member.id }), { + preserveScroll: true, + onSuccess: () => onOpenChange(false), + }); + }; + + return ( + + e.preventDefault()}> + + Edit {member.server_name} + Change the private IP this server uses on the network + +
+ + + + form.setData('server_ip_address_id', id)} + onRefreshed={(_serverId, refreshed) => setIps(refreshed)} + refreshSource={{ + only: ['memberIps', 'flash'], + resolve: (props, serverId) => (props.memberIps as NetworkMemberIp[] | undefined)?.find((m) => m.server_id === serverId)?.private_ips, + }} + /> + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/networks/components/firewall-rule-form.tsx b/resources/js/pages/networks/components/firewall-rule-form.tsx index 51162b592..bc63812a5 100644 --- a/resources/js/pages/networks/components/firewall-rule-form.tsx +++ b/resources/js/pages/networks/components/firewall-rule-form.tsx @@ -23,16 +23,12 @@ export default function NetworkFirewallRuleForm({ }) { const form = useForm<{ name: string; - type: string; protocol: string; port: string; - position: string; }>({ name: rule?.name || '', - type: rule?.type || 'deny', protocol: rule?.protocol || '', port: rule?.port || '', - position: rule?.position?.toString() || '0', }); const submit = (e: FormEvent) => { @@ -63,22 +59,6 @@ export default function NetworkFirewallRuleForm({ - - - - - - form.setData('position', e.target.value)} /> -

Lower positions are evaluated first. Put denies above allows.

- -
diff --git a/resources/js/pages/networks/components/private-ip-select.tsx b/resources/js/pages/networks/components/private-ip-select.tsx new file mode 100644 index 000000000..5336dc392 --- /dev/null +++ b/resources/js/pages/networks/components/private-ip-select.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { router } from '@inertiajs/react'; +import { RefreshCw, TriangleAlertIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { NetworkServerOption } from '@/types/network'; + +export type PrivateIp = NetworkServerOption['private_ips'][number]; + +type RefreshSource = { + only: string[]; + resolve: (props: Record, serverId: number) => PrivateIp[] | undefined; +}; + +const defaultRefreshSource: RefreshSource = { + only: ['servers', 'flash'], + resolve: (props, serverId) => { + const servers = props.servers; + if (!Array.isArray(servers)) { + return undefined; + } + return (servers as NetworkServerOption[]).find((s) => s.id === serverId)?.private_ips; + }, +}; + +export default function PrivateIpSelect({ + serverId, + ips, + value, + onValueChange, + onRefreshed, + disabled = false, + refreshSource = defaultRefreshSource, +}: { + serverId: number | null; + ips: PrivateIp[]; + value?: number; + onValueChange: (id: number) => void; + onRefreshed: (serverId: number, ips: PrivateIp[]) => void; + disabled?: boolean; + refreshSource?: RefreshSource; +}) { + const [refreshing, setRefreshing] = useState(false); + + const refresh = () => { + if (!serverId) { + return; + } + setRefreshing(true); + router.post( + route('servers.network.refresh', { server: serverId }), + {}, + { + preserveScroll: true, + preserveState: true, + only: refreshSource.only, + onSuccess: (page) => { + const refreshed = refreshSource.resolve(page.props as Record, serverId); + if (refreshed) { + onRefreshed(serverId, refreshed); + } + }, + onFinish: () => setRefreshing(false), + }, + ); + }; + + const primaryChosen = ips.find((ip) => ip.id === value)?.is_primary; + + return ( +
+
+ + +
+ {primaryChosen && ( + + + This is the server's primary IP. Make sure you intend to use it for this network. + + )} +
+ ); +} diff --git a/resources/js/pages/networks/firewall.tsx b/resources/js/pages/networks/firewall.tsx new file mode 100644 index 000000000..c47db5130 --- /dev/null +++ b/resources/js/pages/networks/firewall.tsx @@ -0,0 +1,79 @@ +import { Head, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import NetworkLayout from '@/layouts/network/layout'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; +import { asRow } from '@/lib/inertia-table'; +import { useDialog } from '@/hooks/use-dialog'; +import { Network, NetworkFirewallRule } from '@/types/network'; + +export default function NetworkFirewall() { + const page = usePage<{ + network: Network; + rules: InertiaTableData; + }>(); + const dialog = useDialog(); + const network = page.props.network; + + return ( + + + + + + + + + + { + const rule = asRow(row, ['id', 'name']); + return ( +
+ + + + + + dialog.networkFirewallForm.open({ networkId: network.id, rule })}>Edit + + + dialog.confirm.open({ + title: `Delete rule [${rule.name}]`, + description: 'Are you sure you want to delete this firewall rule?', + variant: 'destructive', + confirmLabel: 'Delete', + method: 'delete', + url: route('networks.firewall.destroy', { network: network.id, networkFirewallRule: rule.id }), + }) + } + > + Delete + + + +
+ ); + }} + /> +
+
+ ); +} diff --git a/resources/js/pages/networks/index.tsx b/resources/js/pages/networks/index.tsx index 566484f0e..6485e3e27 100644 --- a/resources/js/pages/networks/index.tsx +++ b/resources/js/pages/networks/index.tsx @@ -1,16 +1,14 @@ -import { Head, router, usePage } from '@inertiajs/react'; +import { Head, Link, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { EyeIcon, PlusIcon } from 'lucide-react'; import { VitoTable } from '@/components/vito-table'; import Layout from '@/layouts/app/layout'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; -import { asRow } from '@/lib/inertia-table'; import { useDialog } from '@/hooks/use-dialog'; -import { Network, NetworkServerOption } from '@/types/network'; +import { NetworkServerOption } from '@/types/network'; export default function Networks() { const page = usePage<{ @@ -34,53 +32,15 @@ export default function Networks() { { - const network = asRow(row, ['id', 'name']); - return ( -
- - - - - - router.visit(route('networks.show', { network: network.id }))}>View - - dialog.confirm.open({ - title: `Sync network [${network.name}]`, - description: 'Re-apply configuration to every server in this network.', - confirmLabel: 'Sync', - method: 'post', - url: route('networks.sync', { network: network.id }), - }) - } - > - Sync - - - - dialog.confirm.open({ - title: `Delete network [${network.name}]`, - description: `Are you sure you want to delete ${network.name}? This tears the network down on all of its servers.`, - variant: 'destructive', - confirmLabel: 'Delete', - method: 'delete', - url: route('networks.destroy', { network: network.id }), - }) - } - > - Delete - - - -
- ); - }} + actions={(row: Row) => ( +
+ + + +
+ )} /> diff --git a/resources/js/pages/networks/servers.tsx b/resources/js/pages/networks/servers.tsx new file mode 100644 index 000000000..82f8ddd44 --- /dev/null +++ b/resources/js/pages/networks/servers.tsx @@ -0,0 +1,105 @@ +import { Head, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; +import { VitoTable } from '@/components/vito-table'; +import NetworkLayout from '@/layouts/network/layout'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; +import { asRow } from '@/lib/inertia-table'; +import { useDialog } from '@/hooks/use-dialog'; +import { Network, NetworkMemberIp, NetworkServer, NetworkServerOption } from '@/types/network'; + +export default function NetworkServers() { + const page = usePage<{ + network: Network; + members: InertiaTableData; + servers: NetworkServerOption[]; + memberIps: NetworkMemberIp[]; + }>(); + const dialog = useDialog(); + const network = page.props.network; + const isProvider = network.type_value === 'provider'; + + return ( + + + + + + + + + + { + const member = asRow(row, ['id', 'server_id']); + return ( +
+ + + + + + {isProvider && ( + { + const memberIp = page.props.memberIps.find((m) => m.id === member.id); + if (memberIp) { + dialog.networkEditServer.open({ networkId: network.id, member: memberIp }); + } + }} + > + Edit IP + + )} + {!isProvider && ( + + dialog.confirm.open({ + title: 'Regenerate configuration', + description: 'Re-apply the network configuration to this server.', + confirmLabel: 'Regenerate', + method: 'post', + url: route('networks.servers.sync', { network: network.id, networkServer: member.id }), + }) + } + > + Regenerate + + )} + + + dialog.confirm.open({ + title: 'Remove server', + description: 'Remove this server from the network and tear down its configuration.', + variant: 'destructive', + confirmLabel: 'Remove', + method: 'delete', + url: route('networks.servers.destroy', { network: network.id, networkServer: member.id }), + }) + } + > + Remove + + + +
+ ); + }} + /> +
+
+ ); +} diff --git a/resources/js/pages/networks/settings.tsx b/resources/js/pages/networks/settings.tsx new file mode 100644 index 000000000..1ad515a67 --- /dev/null +++ b/resources/js/pages/networks/settings.tsx @@ -0,0 +1,134 @@ +import { Head, useForm, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Input } from '@/components/ui/input'; +import React, { useState } from 'react'; +import NetworkLayout from '@/layouts/network/layout'; +import { useDialog } from '@/hooks/use-dialog'; +import { Network } from '@/types/network'; + +export default function NetworkSettings() { + const page = usePage<{ network: Network }>(); + const network = page.props.network; + const dialog = useDialog(); + + const [editMode, setEditMode] = useState(); + + const form = useForm<{ name: string }>({ + name: network.name, + }); + + const submit = () => { + form.put(route('networks.update', { network: network.id }), { + preserveScroll: true, + onSuccess: () => setEditMode(undefined), + }); + }; + + const handleEnterKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }; + + return ( + + + + + + + + + + +
+ Network details + Update network details +
+
+ {form.isDirty && ( + + )} + {(editMode || form.isDirty) && ( + + )} +
+
+ +
+ ID + {network.id} +
+ +
+ Name + {editMode === 'name' ? ( + form.setData('name', e.target.value)} + onKeyDown={handleEnterKey} + autoFocus + /> + ) : ( + + )} +
+ +
+ Type + {network.type} +
+
+
+ + + + Delete network + Tear this network down on all of its servers and remove it. This action cannot be undone. + + +
+ +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/networks/show.tsx b/resources/js/pages/networks/show.tsx index fcfbc76e3..8a118eb5f 100644 --- a/resources/js/pages/networks/show.tsx +++ b/resources/js/pages/networks/show.tsx @@ -1,200 +1,66 @@ -import { Head, router, usePage } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { MoreVerticalIcon, PlusIcon } from 'lucide-react'; -import { VitoTable } from '@/components/vito-table'; -import Layout from '@/layouts/app/layout'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Label } from '@/components/ui/label'; -import type { InertiaTableData, Row } from '@forjedio/inertia-table-react'; -import { asRow } from '@/lib/inertia-table'; +import { Badge } from '@/components/ui/badge'; +import { ReactNode } from 'react'; +import NetworkLayout from '@/layouts/network/layout'; import { useDialog } from '@/hooks/use-dialog'; -import { Network, NetworkFirewallRule, NetworkServer, NetworkServerOption } from '@/types/network'; +import { useRealtimeRecord } from '@/hooks/use-socket-events'; +import { Network } from '@/types/network'; -export default function ShowNetwork() { - const page = usePage<{ - network: { data: Network }; - members: InertiaTableData; - rules: InertiaTableData; - servers: NetworkServerOption[]; - }>(); +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +export default function NetworkOverview() { + const page = usePage<{ network: Network }>(); + const network = useRealtimeRecord(page.props.network, 'network')!; const dialog = useDialog(); - const network = page.props.network.data; const isProvider = network.type_value === 'provider'; - const toggleFirewall = () => { - router.put(route('networks.update', { network: network.id }), { firewall_enabled: !network.firewall_enabled }, { preserveScroll: true }); - }; - return ( - - + + - -
- - -
+ +
-
-
-
-

Servers

- -
- { - const member = asRow(row, ['id', 'server_id']); - return ( -
- - - - - - {!isProvider && ( - - dialog.confirm.open({ - title: 'Regenerate configuration', - description: 'Re-apply the network configuration to this server.', - confirmLabel: 'Regenerate', - method: 'post', - url: route('networks.servers.sync', { network: network.id, networkServer: member.id }), - }) - } - > - Regenerate - - )} - - - dialog.confirm.open({ - title: 'Remove server', - description: 'Remove this server from the network and tear down its configuration.', - variant: 'destructive', - confirmLabel: 'Remove', - method: 'delete', - url: route('networks.servers.destroy', { network: network.id, networkServer: member.id }), - }) - } - > - Remove - - - -
- ); - }} - /> -
- -
-
-
-

Firewall

-

Traffic from the network is allowed by default. Add deny rules to restrict it.

-
-
-
- - -
- -
-
- { - const rule = asRow(row, ['id', 'name', 'type', 'protocol', 'port', 'position']); - return ( -
- - - - - - dialog.networkFirewallForm.open({ networkId: network.id, rule })}>Edit - - - dialog.confirm.open({ - title: `Delete rule [${rule.name}]`, - description: 'Are you sure you want to delete this firewall rule?', - variant: 'destructive', - confirmLabel: 'Delete', - method: 'delete', - url: route('networks.firewall.destroy', { network: network.id, networkFirewallRule: rule.id }), - }) - } - > - Delete - - - -
- ); - }} - /> -
+
+ + {network.type} + + + {network.status} + + {network.cidr && {network.cidr}} + {!isProvider && {network.addressing_pool}} + {network.port && {network.port}} + {network.servers_count ?? 0}
- + ); } diff --git a/resources/js/types/network.d.ts b/resources/js/types/network.d.ts index ace414b5b..90ca60c09 100644 --- a/resources/js/types/network.d.ts +++ b/resources/js/types/network.d.ts @@ -10,7 +10,6 @@ export interface Network { addressing_pool: string; cidr: string | null; port: number | null; - firewall_enabled: boolean; status: string; status_color: StatusColor; servers_count?: number; @@ -36,10 +35,8 @@ export interface NetworkFirewallRule { id: number; network_id: number; name: string; - type: string; protocol: string | null; port: string | null; - position: number; status: string; status_color: StatusColor; created_at: string; @@ -52,3 +49,11 @@ export interface NetworkServerOption { is_ready: boolean; private_ips: { id: number; ip: string; is_primary: boolean }[]; } + +export interface NetworkMemberIp { + id: number; + server_id: number; + server_name: string; + ip_address_id: number | null; + private_ips: { id: number; ip: string; is_primary: boolean }[]; +} diff --git a/tests/Feature/NetworkFirewallTest.php b/tests/Feature/NetworkFirewallTest.php index 16975747d..954b02901 100644 --- a/tests/Feature/NetworkFirewallTest.php +++ b/tests/Feature/NetworkFirewallTest.php @@ -6,7 +6,6 @@ use App\Actions\Network\CompileServerFirewallRules; use App\Actions\Network\CreateNetwork; use App\Actions\Network\ManageNetworkFirewallRule; -use App\Actions\Network\UpdateNetwork; use App\Enums\IpAddressType; use App\Enums\NetworkServerStatus; use App\Enums\NetworkStatus; @@ -23,17 +22,34 @@ class NetworkFirewallTest extends TestCase { use RefreshDatabase; - private function wireguardNetwork(array $servers, bool $firewall): \App\Models\Network + /** + * @param array $servers + */ + private function wireguardNetwork(array $servers): \App\Models\Network { return app(CreateNetwork::class)->create($this->server->project, [ 'name' => 'wg-net', 'type' => 'wireguard', - 'firewall_enabled' => $firewall, 'servers' => $servers, ]); } - public function test_wireguard_handshake_port_is_opened_even_when_firewall_disabled(): void + public function test_network_is_seeded_with_a_default_allow_all_rule(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $network = $this->wireguardNetwork([$this->server->id]); + + $this->assertDatabaseHas('network_firewall_rules', [ + 'network_id' => $network->id, + 'name' => 'Allow all', + 'protocol' => null, + 'port' => null, + ]); + } + + public function test_wireguard_handshake_port_is_opened(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); @@ -44,39 +60,38 @@ public function test_wireguard_handshake_port_is_opened_even_when_firewall_disab 'status' => ServerStatus::READY, ]); - $this->wireguardNetwork([$this->server->id, $peer->id], false); + $this->wireguardNetwork([$this->server->id, $peer->id]); SSH::assertExecutedContains('proto udp port 51820'); } - public function test_firewall_enabled_emits_default_allow_catch_all(): void + public function test_default_allow_all_rule_emits_catch_all(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $this->wireguardNetwork([$this->server->id], true); + $this->wireguardNetwork([$this->server->id]); SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); } - public function test_deny_rule_is_emitted_scoped_to_the_network_cidr(): void + public function test_allow_rule_is_emitted_scoped_to_the_network_cidr(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', + 'name' => 'mysql', 'protocol' => 'tcp', 'port' => '3306', ]); - SSH::assertExecutedContains('deny from 100.64.0.0/24 to any proto tcp port 3306'); + SSH::assertExecutedContains('allow from 100.64.0.0/24 to any proto tcp port 3306'); $this->assertDatabaseHas('network_firewall_rules', [ 'network_id' => $network->id, - 'type' => 'deny', + 'name' => 'mysql', 'port' => '3306', ]); } @@ -86,10 +101,9 @@ public function test_network_rules_are_not_stored_as_server_firewall_rules(): vo SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', + 'name' => 'mysql', 'protocol' => 'tcp', 'port' => '3306', ]); @@ -102,7 +116,7 @@ public function test_server_firewall_page_exposes_managed_networks(): void SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); $this->actingAs($this->user); @@ -114,31 +128,36 @@ public function test_server_firewall_page_exposes_managed_networks(): void ->where('managedNetworks.0.id', $network->id)); } - public function test_server_level_firewall_change_reapplies_network_rules(): void + public function test_leaving_member_network_is_not_listed_as_managed(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); + $network->servers()->update(['status' => NetworkServerStatus::LEAVING]); - app(ManageRule::class)->create($this->server, [ - 'name' => 'ssh', - 'type' => 'allow', - 'protocol' => 'tcp', - 'port' => '22', - ]); + $this->actingAs($this->user); - SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + $this->get(route('firewall', ['server' => $this->server])) + ->assertSuccessful() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('firewall/index') + ->count('managedNetworks', 0)); } - public function test_toggling_firewall_enabled_applies_network_rules(): void + public function test_server_level_firewall_change_reapplies_network_rules(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], false); + $this->wireguardNetwork([$this->server->id]); - app(UpdateNetwork::class)->update($network, ['firewall_enabled' => true]); + app(ManageRule::class)->create($this->server, [ + 'name' => 'ssh', + 'type' => 'allow', + 'protocol' => 'tcp', + 'port' => '22', + ]); SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); } @@ -154,7 +173,7 @@ public function test_installing_ufw_opens_wireguard_handshake_for_existing_membe 'status' => ServerStatus::READY, ]); - $this->wireguardNetwork([$this->server->id, $peer->id], false); + $this->wireguardNetwork([$this->server->id, $peer->id]); SSH::fake(); $this->server->firewall()->handler()->install(); @@ -167,7 +186,7 @@ public function test_failed_firewall_apply_marks_member_failed(): void SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); $member = $network->servers()->firstOrFail(); (new ApplyNetworkFirewallJob($member))->failed(new \RuntimeException('boom')); @@ -175,61 +194,29 @@ public function test_failed_firewall_apply_marks_member_failed(): void $this->assertSame(NetworkServerStatus::FAILED, $member->fresh()->status); } - public function test_deny_rule_is_ordered_before_the_catch_all(): void + public function test_deleting_allow_all_locks_down_but_keeps_tunnel_handshake(): void { SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); - app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', - 'protocol' => 'tcp', - 'port' => '3306', - ]); - - $specs = app(CompileServerFirewallRules::class)->forServer($this->server); - - $denyIndex = $this->indexOf($specs, fn ($s) => $s->type === 'deny' && $s->port === '3306'); - $catchAllIndex = $this->indexOf($specs, fn ($s) => $s->type === 'allow' && $s->port === null && $s->source === '100.64.0.0'); - - $this->assertNotNull($denyIndex); - $this->assertNotNull($catchAllIndex); - $this->assertLessThan($catchAllIndex, $denyIndex); - } - - public function test_portless_deny_all_rule_is_emitted(): void - { - SSH::fake(); - $this->server->update(['status' => ServerStatus::READY]); - - $network = $this->wireguardNetwork([$this->server->id], true); - app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'lockdown', - 'type' => 'deny', + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, ]); - SSH::assertExecutedContains('deny from 100.64.0.0/24 to any'); - } + $network = $this->wireguardNetwork([$this->server->id, $peer->id]); - public function test_disabling_firewall_removes_deny_rules_but_keeps_tunnel_allowed(): void - { - SSH::fake(); - $this->server->update(['status' => ServerStatus::READY]); + $allowAll = $network->firewallRules()->whereNull('protocol')->whereNull('port')->firstOrFail(); + app(ManageNetworkFirewallRule::class)->delete($allowAll); - $network = $this->wireguardNetwork([$this->server->id], true); - app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', - 'protocol' => 'tcp', - 'port' => '3306', - ]); + $specs = app(CompileServerFirewallRules::class)->forServer($this->server->fresh()); - SSH::fake(); - app(UpdateNetwork::class)->update($network, ['firewall_enabled' => false]); + $catchAll = $this->indexOf($specs, fn ($s) => $s->port === null && $s->protocol === null && $s->source === '100.64.0.0'); + $handshake = $this->indexOf($specs, fn ($s) => $s->protocol === 'udp' && $s->port === '51820'); - SSH::assertNotExecutedContains('deny from 100.64.0.0/24 to any proto tcp port 3306'); - SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); + $this->assertNull($catchAll); + $this->assertNotNull($handshake); } public function test_firewall_change_on_offline_server_marks_member_pending(): void @@ -237,13 +224,12 @@ public function test_firewall_change_on_offline_server_marks_member_pending(): v SSH::fake(); $this->server->update(['status' => ServerStatus::READY]); - $network = $this->wireguardNetwork([$this->server->id], true); + $network = $this->wireguardNetwork([$this->server->id]); $this->server->update(['status' => ServerStatus::DISCONNECTED]); app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', + 'name' => 'mysql', 'protocol' => 'tcp', 'port' => '3306', ]); @@ -267,19 +253,17 @@ public function test_provider_network_firewall_uses_cidr_source(): void 'name' => 'prov-net', 'type' => 'provider', 'cidr' => '10.0.0.0/24', - 'firewall_enabled' => true, 'servers' => [$this->server->id], 'ip_addresses' => [$this->server->id => $ip->id], ]); app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', + 'name' => 'mysql', 'protocol' => 'tcp', 'port' => '3306', ]); - SSH::assertExecutedContains('deny from 10.0.0.0/24 to any proto tcp port 3306'); + SSH::assertExecutedContains('allow from 10.0.0.0/24 to any proto tcp port 3306'); } public function test_provider_network_without_cidr_uses_member_private_ip(): void @@ -307,14 +291,12 @@ public function test_provider_network_without_cidr_uses_member_private_ip(): voi $network = app(CreateNetwork::class)->create($this->server->project, [ 'name' => 'prov-net', 'type' => 'provider', - 'firewall_enabled' => true, 'servers' => [$this->server->id, $peer->id], 'ip_addresses' => [$this->server->id => $ip1->id, $peer->id => $ip2->id], ]); app(ManageNetworkFirewallRule::class)->create($network, [ - 'name' => 'no-mysql', - 'type' => 'deny', + 'name' => 'mysql', 'protocol' => 'tcp', 'port' => '3306', ]); @@ -332,7 +314,6 @@ public function test_provider_network_applies_catch_all_on_create(): void 'name' => 'prov-net', 'type' => 'provider', 'cidr' => '10.0.0.0/24', - 'firewall_enabled' => true, 'servers' => [$this->server->id], 'ip_addresses' => [$this->server->id => $ip->id], ]); @@ -340,16 +321,6 @@ public function test_provider_network_applies_catch_all_on_create(): void SSH::assertExecutedContains('allow from 10.0.0.0/24 to any'); } - public function test_wireguard_tunnel_is_allowed_when_firewall_disabled(): void - { - SSH::fake(); - $this->server->update(['status' => ServerStatus::READY]); - - $this->wireguardNetwork([$this->server->id], false); - - SSH::assertExecutedContains('allow from 100.64.0.0/24 to any'); - } - public function test_server_with_zero_networks_emits_no_network_rules(): void { $specs = app(CompileServerFirewallRules::class)->forServer($this->server); diff --git a/tests/Feature/NetworkTest.php b/tests/Feature/NetworkTest.php index ac2952fe7..430158c98 100644 --- a/tests/Feature/NetworkTest.php +++ b/tests/Feature/NetworkTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Actions\Network\CreateNetwork; use App\Actions\Network\GenerateWireGuardKeys; use App\Enums\IpAddressType; use App\Enums\NetworkServerStatus; @@ -282,19 +283,64 @@ public function test_firewall_rule_update_via_http(): void { SSH::fake(); $network = Network::factory()->create(['project_id' => $this->server->project_id]); - $rule = NetworkFirewallRule::factory()->create(['network_id' => $network->id, 'type' => 'allow', 'port' => '80']); + $rule = NetworkFirewallRule::factory()->create(['network_id' => $network->id, 'port' => '80']); $this->actingAs($this->user); $this->put(route('networks.firewall.update', ['network' => $network, 'networkFirewallRule' => $rule]), [ 'name' => 'updated', - 'type' => 'deny', 'protocol' => 'tcp', 'port' => '443', - 'position' => 0, ])->assertSessionDoesntHaveErrors(); - $this->assertDatabaseHas('network_firewall_rules', ['id' => $rule->id, 'type' => 'deny', 'port' => '443']); + $this->assertDatabaseHas('network_firewall_rules', ['id' => $rule->id, 'name' => 'updated', 'port' => '443']); + } + + public function test_update_provider_network_server_ip_via_http(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $ip1 = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.5', 'type' => IpAddressType::PRIVATE]); + $ip2 = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.6', 'type' => IpAddressType::PRIVATE]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'prov-net', + 'type' => 'provider', + 'cidr' => '10.0.0.0/24', + 'servers' => [$this->server->id], + 'ip_addresses' => [$this->server->id => $ip1->id], + ]); + $member = $network->servers()->firstOrFail(); + + $this->actingAs($this->user); + + $this->put(route('networks.servers.update', ['network' => $network, 'networkServer' => $member]), [ + 'server_ip_address_id' => $ip2->id, + ])->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('network_servers', ['id' => $member->id, 'server_ip_address_id' => $ip2->id]); + } + + public function test_cannot_update_ip_on_wireguard_network_server(): void + { + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $ip = ServerIpAddress::factory()->create(['server_id' => $this->server->id, 'ip' => '10.0.0.6', 'type' => IpAddressType::PRIVATE]); + + $network = app(CreateNetwork::class)->create($this->server->project, [ + 'name' => 'wg-net', + 'type' => 'wireguard', + 'servers' => [$this->server->id], + ]); + $member = $network->servers()->firstOrFail(); + + $this->actingAs($this->user); + + $this->put(route('networks.servers.update', ['network' => $network, 'networkServer' => $member]), [ + 'server_ip_address_id' => $ip->id, + ])->assertNotFound(); } public function test_sub_resource_404_when_not_belonging_to_network(): void From a1d20e8ada246a3e9c1cec6c9caba14b3da750aa Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Tue, 21 Jul 2026 22:59:13 +0100 Subject: [PATCH 03/27] fix: ordering of network servers --- app/Tables/Networks/NetworkServerTable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Tables/Networks/NetworkServerTable.php b/app/Tables/Networks/NetworkServerTable.php index 176f41e77..7e8c33a63 100644 --- a/app/Tables/Networks/NetworkServerTable.php +++ b/app/Tables/Networks/NetworkServerTable.php @@ -17,7 +17,7 @@ class NetworkServerTable extends Table protected function query(): void { $this->perPage = config('web.pagination_size'); - $this->query->with(['server.services', 'serverIpAddress'])->latest(); + $this->query->with(['server.services', 'serverIpAddress'])->orderBy('id'); } protected function columns(): array From 6ffc2a4e5dbd646e97d3b1e7f151a28ef81c81bf Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Wed, 22 Jul 2026 10:30:20 +0100 Subject: [PATCH 04/27] feat: server network firewall rules --- app/Actions/Network/ApplyNetworkFirewall.php | 7 +- .../Network/CompileServerFirewallRules.php | 145 ------------ .../Network/FinalizeServerNetworkRules.php | 59 +++++ .../Network/MaterializeServerNetworkRules.php | 222 ++++++++++++++++++ app/Actions/Server/EditServer.php | 33 +++ app/Enums/ServerNetworkRuleKind.php | 9 + app/Http/Controllers/FirewallController.php | 15 +- app/Jobs/Network/SyncNetworkServerJob.php | 3 + app/Models/Network.php | 8 + app/Models/NetworkFirewallRule.php | 9 + app/Models/NetworkServer.php | 9 + app/Models/Server.php | 8 + app/Models/ServerIpAddress.php | 25 ++ app/Models/ServerNetworkRule.php | 100 ++++++++ app/Services/Firewall/Ufw.php | 35 ++- .../Networks/NetworkFirewallRuleTable.php | 6 +- app/Tables/Networks/NetworkServerTable.php | 3 +- app/Tables/Servers/ServerNetworkRuleTable.php | 53 +++++ ...1800_create_server_network_rules_table.php | 39 +++ resources/js/pages/firewall/index.tsx | 42 ++-- resources/js/pages/networks/servers.tsx | 2 +- tests/Feature/NetworkFirewallTest.php | 65 +++-- 22 files changed, 677 insertions(+), 220 deletions(-) delete mode 100644 app/Actions/Network/CompileServerFirewallRules.php create mode 100644 app/Actions/Network/FinalizeServerNetworkRules.php create mode 100644 app/Actions/Network/MaterializeServerNetworkRules.php create mode 100644 app/Enums/ServerNetworkRuleKind.php create mode 100644 app/Models/ServerNetworkRule.php create mode 100644 app/Tables/Servers/ServerNetworkRuleTable.php create mode 100644 database/migrations/2026_07_21_131800_create_server_network_rules_table.php diff --git a/app/Actions/Network/ApplyNetworkFirewall.php b/app/Actions/Network/ApplyNetworkFirewall.php index 9bea161e6..5fa19e3bf 100644 --- a/app/Actions/Network/ApplyNetworkFirewall.php +++ b/app/Actions/Network/ApplyNetworkFirewall.php @@ -10,7 +10,10 @@ class ApplyNetworkFirewall { - public function __construct(private RecomputeNetworkStatus $recompute) {} + public function __construct( + private RecomputeNetworkStatus $recompute, + private MaterializeServerNetworkRules $materialize, + ) {} /** * Re-apply the network's firewall on each member. Reachable servers apply @@ -19,6 +22,8 @@ public function __construct(private RecomputeNetworkStatus $recompute) {} */ public function handle(Network $network): void { + $this->materialize->forNetwork($network); + $deferred = false; $network->servers() diff --git a/app/Actions/Network/CompileServerFirewallRules.php b/app/Actions/Network/CompileServerFirewallRules.php deleted file mode 100644 index dbdbc631e..000000000 --- a/app/Actions/Network/CompileServerFirewallRules.php +++ /dev/null @@ -1,145 +0,0 @@ -`) so it is permissive by - * default; deleting that rule locks the network down to its explicit allows. - * - * @return array - */ - public function forServer(Server $server): array - { - $memberships = NetworkServer::query() - ->where('server_id', $server->id) - ->where('status', '!=', NetworkServerStatus::LEAVING) - ->with('network') - ->get(); - - $handshakes = []; - $ruleSpecs = []; - - foreach ($memberships as $membership) { - $network = $membership->network; - - if ($network->type === NetworkType::WIREGUARD) { - $handshakes = array_merge($handshakes, $this->handshakeSpecs($network, $server)); - } - - $ruleSpecs = array_merge($ruleSpecs, $this->ruleSpecs($network, $this->sources($network, $server))); - } - - return array_merge($handshakes, $ruleSpecs); - } - - /** - * @return array - */ - private function handshakeSpecs(Network $network, Server $server): array - { - return $this->peers($network, $server) - ->filter(fn (NetworkServer $peer): bool => filled($peer->server->ip)) - ->map(fn (NetworkServer $peer): stdClass => $this->spec( - 'allow', - $peer->server->ip, - 32, - 'udp', - (string) $network->port, - )) - ->values() - ->all(); - } - - /** - * @param array $sources - * @return array - */ - private function ruleSpecs(Network $network, array $sources): array - { - $specs = []; - - $rules = $network->firewallRules() - ->where('status', '!=', FirewallRuleStatus::DELETING) - ->orderBy('id') - ->get(); - - foreach ($rules as $rule) { - foreach ($sources as $source) { - $specs[] = $this->spec( - 'allow', - $source['ip'], - $source['mask'], - $rule->protocol, - $rule->port, - ); - } - } - - return $specs; - } - - /** - * The network source(s) for per-port/catch-all rules: the network CIDR when - * set, otherwise (provider networks without a CIDR) each other member's - * private IP. - * - * @return array - */ - private function sources(Network $network, Server $server): array - { - if ($network->cidr !== null && $network->cidr !== '') { - return [[ - 'ip' => long2ip(Cidr::base($network->cidr)), - 'mask' => Cidr::prefix($network->cidr), - ]]; - } - - return $this->peers($network, $server) - ->filter(fn (NetworkServer $peer): bool => $peer->serverIpAddress !== null) - ->map(fn (NetworkServer $peer): array => [ - 'ip' => (string) $peer->serverIpAddress->ip, - 'mask' => 32, - ]) - ->values() - ->all(); - } - - /** - * @return \Illuminate\Support\Collection - */ - private function peers(Network $network, Server $server): \Illuminate\Support\Collection - { - return NetworkServer::query() - ->where('network_id', $network->id) - ->where('server_id', '!=', $server->id) - ->where('status', '!=', NetworkServerStatus::LEAVING) - ->with('server', 'serverIpAddress') - ->get(); - } - - private function spec(string $type, ?string $source, ?int $mask, ?string $protocol, ?string $port): stdClass - { - return (object) [ - 'type' => $type, - 'source' => $source, - 'mask' => $mask, - 'protocol' => $protocol, - 'port' => $port, - ]; - } -} diff --git a/app/Actions/Network/FinalizeServerNetworkRules.php b/app/Actions/Network/FinalizeServerNetworkRules.php new file mode 100644 index 000000000..a016a12da --- /dev/null +++ b/app/Actions/Network/FinalizeServerNetworkRules.php @@ -0,0 +1,59 @@ + $emittedIds + * @param array $deletingIds + */ + public function success(Server $server, array $emittedIds, array $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/MaterializeServerNetworkRules.php b/app/Actions/Network/MaterializeServerNetworkRules.php new file mode 100644 index 000000000..37629cc79 --- /dev/null +++ b/app/Actions/Network/MaterializeServerNetworkRules.php @@ -0,0 +1,222 @@ +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; + + DB::transaction(function () use ($server, $desired, $applied): void { + $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; + } + } + + 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; + } + + 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(); + + $desired = []; + + foreach ($memberships as $membership) { + $network = $membership->network; + + if ($network->type === NetworkType::WIREGUARD) { + foreach ($this->handshakes($network, $server) as $handshake) { + $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' => 32, + ]; + $desired[$this->identity($membership->id, ServerNetworkRuleKind::HANDSHAKE, null, $handshake['ip'], 32)] = $spec; + } + } + + $sources = $this->sources($network, $server); + + foreach ($network->firewallRules()->where('status', '!=', FirewallRuleStatus::DELETING)->orderBy('id')->get() 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; + } + + /** + * @return array + */ + private function handshakes(Network $network, Server $server): array + { + return $this->peers($network, $server) + ->filter(fn (NetworkServer $peer): bool => filled($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->cidr !== null && $network->cidr !== '') { + return [[ + 'ip' => long2ip(Cidr::base($network->cidr)), + 'mask' => Cidr::prefix($network->cidr), + ]]; + } + + return $this->peers($network, $server) + ->filter(fn (NetworkServer $peer): bool => $peer->serverIpAddress !== null) + ->map(fn (NetworkServer $peer): array => [ + 'ip' => (string) $peer->serverIpAddress->ip, + 'mask' => 32, + ]) + ->values() + ->all(); + } + + /** + * @return Collection + */ + private function peers(Network $network, Server $server): Collection + { + return NetworkServer::query() + ->where('network_id', $network->id) + ->where('server_id', '!=', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->with('server', 'serverIpAddress') + ->get(); + } + + 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/Server/EditServer.php b/app/Actions/Server/EditServer.php index 83f2450ce..ecdacc3f2 100755 --- a/app/Actions/Server/EditServer.php +++ b/app/Actions/Server/EditServer.php @@ -2,6 +2,10 @@ namespace App\Actions\Server; +use App\Actions\Network\DispatchNetworkServerSync; +use App\Enums\NetworkServerStatus; +use App\Enums\NetworkType; +use App\Models\NetworkServer; use App\Models\Server; use App\ValidationRules\RestrictedIPAddressesRule; use Illuminate\Support\Facades\Validator; @@ -10,6 +14,8 @@ class EditServer { + public function __construct(private DispatchNetworkServerSync $sync) {} + /** * @param array $input * @return Server $server @@ -21,12 +27,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 +49,10 @@ public function edit(Server $server, array $input): Server } $server->save(); + if ($ipChanged) { + $this->resyncWireGuardPeers($server); + } + if ($checkConnection) { return $server->checkConnection(); } @@ -48,6 +60,27 @@ public function edit(Server $server, array $input): Server return $server; } + /** + * The server's public IP feeds every peer's WireGuard endpoint and handshake + * firewall rule, so re-sync the peers in each of its WireGuard networks. + */ + private function resyncWireGuardPeers(Server $server): void + { + NetworkServer::query() + ->where('server_id', $server->id) + ->where('status', '!=', NetworkServerStatus::LEAVING) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::WIREGUARD)) + ->with('network') + ->get() + ->each(function (NetworkServer $membership) use ($server): void { + $membership->network->servers() + ->where('server_id', '!=', $server->id) + ->whereIn('status', [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING]) + ->get() + ->each(fn (NetworkServer $peer) => $this->sync->toPresent($peer)); + }); + } + private function validate(Server $server, array $input): void { $rules = [ diff --git a/app/Enums/ServerNetworkRuleKind.php b/app/Enums/ServerNetworkRuleKind.php new file mode 100644 index 000000000..c35ae65fa --- /dev/null +++ b/app/Enums/ServerNetworkRuleKind.php @@ -0,0 +1,9 @@ + FirewallRuleTable::make($server->firewallRules())->simplePaginate(), - 'managedNetworks' => Network::query() - ->whereHas('servers', fn ($query) => $query->where('server_id', $server->id) - ->where('status', '!=', NetworkServerStatus::LEAVING)) - ->get() - ->map(fn (Network $network): array => [ - 'id' => $network->id, - 'name' => $network->name, - ]) - ->values(), + 'networkRules' => ServerNetworkRuleTable::make($server->networkRules()) + ->identifier('networkRules') + ->simplePaginate(), ]); } diff --git a/app/Jobs/Network/SyncNetworkServerJob.php b/app/Jobs/Network/SyncNetworkServerJob.php index ef56b66a7..45a245ef6 100644 --- a/app/Jobs/Network/SyncNetworkServerJob.php +++ b/app/Jobs/Network/SyncNetworkServerJob.php @@ -2,6 +2,7 @@ namespace App\Jobs\Network; +use App\Actions\Network\MaterializeServerNetworkRules; use App\Actions\Network\RecomputeNetworkStatus; use App\DTOs\SocketEventDTO; use App\Enums\NetworkServerStatus; @@ -110,6 +111,8 @@ private function tearDown(): void private function applyFirewall(): void { + app(MaterializeServerNetworkRules::class)->forServer($this->member->server); + $service = $this->member->server->firewall(); if (! $service instanceof Service) { return; diff --git a/app/Models/Network.php b/app/Models/Network.php index 5cca8fbea..c440d706f 100644 --- a/app/Models/Network.php +++ b/app/Models/Network.php @@ -72,4 +72,12 @@ public function firewallRules(): HasMany { return $this->hasMany(NetworkFirewallRule::class); } + + /** + * @return HasMany + */ + public function serverRules(): HasMany + { + return $this->hasMany(ServerNetworkRule::class); + } } diff --git a/app/Models/NetworkFirewallRule.php b/app/Models/NetworkFirewallRule.php index 069f6b3cd..756d84c74 100644 --- a/app/Models/NetworkFirewallRule.php +++ b/app/Models/NetworkFirewallRule.php @@ -6,6 +6,7 @@ use Database\Factories\NetworkFirewallRuleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -41,4 +42,12 @@ 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/NetworkServer.php b/app/Models/NetworkServer.php index 94eeb3c05..4ffa02d1a 100644 --- a/app/Models/NetworkServer.php +++ b/app/Models/NetworkServer.php @@ -6,6 +6,7 @@ use Database\Factories\NetworkServerFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; /** * @property int $id @@ -69,4 +70,12 @@ 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/Server.php b/app/Models/Server.php index dd81904a4..9f7c264c1 100755 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -309,6 +309,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..a39a803d1 100644 --- a/app/Models/ServerIpAddress.php +++ b/app/Models/ServerIpAddress.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Actions\Network\ApplyNetworkFirewall; use App\Enums\IpAddressFamily; use App\Enums\IpAddressStatus; use App\Enums\IpAddressType; +use App\Enums\NetworkType; use Database\Factories\ServerIpAddressFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -45,6 +47,29 @@ class ServerIpAddress extends AbstractModel 'status' => IpAddressStatus::class, ]; + /** @var array */ + public array $reapplyNetworkIds = []; + + protected static function booted(): void + { + static::deleting(function (ServerIpAddress $address): void { + $address->reapplyNetworkIds = NetworkServer::query() + ->where('server_ip_address_id', $address->id) + ->whereHas('network', fn ($query) => $query->where('type', NetworkType::PROVIDER)) + ->pluck('network_id') + ->unique() + ->values() + ->all(); + }); + + static::deleted(function (ServerIpAddress $address): void { + Network::query() + ->whereIn('id', $address->reapplyNetworkIds) + ->get() + ->each(fn (Network $network) => app(ApplyNetworkFirewall::class)->handle($network)); + }); + } + /** * @return BelongsTo */ diff --git a/app/Models/ServerNetworkRule.php b/app/Models/ServerNetworkRule.php new file mode 100644 index 000000000..51d57927c --- /dev/null +++ b/app/Models/ServerNetworkRule.php @@ -0,0 +1,100 @@ + '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 $query + */ + public function scopeOrdered(Builder $query): void + { + $query + ->orderByRaw("case when kind = ? then 0 else 1 end", [ServerNetworkRuleKind::HANDSHAKE->value]) + ->orderBy('network_id') + ->orderBy('id'); + } + + /** + * @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/Services/Firewall/Ufw.php b/app/Services/Firewall/Ufw.php index 982971a16..32c6940c3 100755 --- a/app/Services/Firewall/Ufw.php +++ b/app/Services/Firewall/Ufw.php @@ -2,7 +2,7 @@ namespace App\Services\Firewall; -use App\Actions\Network\CompileServerFirewallRules; +use App\Actions\Network\FinalizeServerNetworkRules; use App\DTOs\ServiceLog; use App\Enums\FirewallRuleStatus; use App\Exceptions\SSHError; @@ -57,19 +57,36 @@ public function uninstall(): void */ public function applyRules(): void { - $serverRules = $this->service->server - ->firewallRules() + $server = $this->service->server; + + $networkRules = $server->networkRules() ->where('status', '!=', FirewallRuleStatus::DELETING) + ->ordered() ->get(); - $networkRules = collect(app(CompileServerFirewallRules::class)->forServer($this->service->server)); + $emittedIds = $networkRules->pluck('id')->all(); + $deletingIds = $server->networkRules()->where('status', FirewallRuleStatus::DELETING)->pluck('id')->all(); - $rules = $networkRules->merge($serverRules); + $serverRules = $server->firewallRules() + ->where('status', '!=', FirewallRuleStatus::DELETING) + ->get(); - $this->service->server->ssh()->exec( - view('ssh.services.firewall.ufw.apply-rules', ['rules' => $rules]), - 'apply-rules' - ); + $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/Tables/Networks/NetworkFirewallRuleTable.php b/app/Tables/Networks/NetworkFirewallRuleTable.php index 343d05c17..d9863d200 100644 --- a/app/Tables/Networks/NetworkFirewallRuleTable.php +++ b/app/Tables/Networks/NetworkFirewallRuleTable.php @@ -22,10 +22,12 @@ protected function columns(): array { return [ TextColumn::make('name', 'Name')->sortable(), - TextColumn::make('protocol', 'Protocol'), - TextColumn::make('port', 'Port'), + 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/NetworkServerTable.php b/app/Tables/Networks/NetworkServerTable.php index 7e8c33a63..f3d8e379e 100644 --- a/app/Tables/Networks/NetworkServerTable.php +++ b/app/Tables/Networks/NetworkServerTable.php @@ -7,6 +7,7 @@ use Forjed\InertiaTable\Column; use Forjed\InertiaTable\Columns\ActionsColumn; use Forjed\InertiaTable\Columns\EnumColumn; +use Forjed\InertiaTable\Columns\LinkColumn; use Forjed\InertiaTable\Columns\TextColumn; use Forjed\InertiaTable\Table; @@ -23,7 +24,7 @@ protected function query(): void protected function columns(): array { return [ - TextColumn::make('server.name', 'Server')->sortable(), + 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('—'), diff --git a/app/Tables/Servers/ServerNetworkRuleTable.php b/app/Tables/Servers/ServerNetworkRuleTable.php new file mode 100644 index 000000000..cde3c5f2f --- /dev/null +++ b/app/Tables/Servers/ServerNetworkRuleTable.php @@ -0,0 +1,53 @@ + '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') + ->orderByRaw('case when kind = ? then 0 else 1 end', [ServerNetworkRuleKind::HANDSHAKE->value]) + ->orderBy('network_id') + ->orderBy('id'); + } + + 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/database/migrations/2026_07_21_131800_create_server_network_rules_table.php b/database/migrations/2026_07_21_131800_create_server_network_rules_table.php new file mode 100644 index 000000000..04e69e412 --- /dev/null +++ b/database/migrations/2026_07_21_131800_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/resources/js/pages/firewall/index.tsx b/resources/js/pages/firewall/index.tsx index 4bac5b91b..662a01820 100644 --- a/resources/js/pages/firewall/index.tsx +++ b/resources/js/pages/firewall/index.tsx @@ -1,16 +1,15 @@ -import { Head, Link, usePage } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import { Server } from '@/types/server'; import { FirewallRule } from '@/types/firewall'; import ServerLayout from '@/layouts/server/layout'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { BookOpenIcon, MoreVerticalIcon, NetworkIcon, PlusIcon } from 'lucide-react'; +import { BookOpenIcon, MoreVerticalIcon, PlusIcon } from 'lucide-react'; import Container from '@/components/container'; import { VitoTable } from '@/components/vito-table'; import Delete from '@/pages/firewall/components/delete'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import type { CellRenderProps, InertiaTableData, Row } from '@forjedio/inertia-table-react'; import { asRow } from '@/lib/inertia-table'; import { useDialog } from '@/hooks/use-dialog'; @@ -21,10 +20,10 @@ export default function Firewall() { const page = usePage<{ server: Server; rules: InertiaTableData; - managedNetworks: { id: number; name: string }[]; + networkRules: InertiaTableData; }>(); const dialog = useDialog(); - const managedNetworks = page.props.managedNetworks ?? []; + const hasNetworkRules = (page.props.networkRules?.data?.length ?? 0) > 0; return ( @@ -47,27 +46,6 @@ export default function Firewall() {
- {managedNetworks.length > 0 && ( - - - Private network rules are active on this server - -

- Firewall rules for{' '} - {managedNetworks.map((network, index) => ( - - {index > 0 && ', '} - - {network.name} - - - ))}{' '} - are managed from the network and are not editable here. -

-
-
- )} - + + {hasNetworkRules && ( +
+
+

Private network rules

+

+ These rules are applied by the private networks this server belongs to. They are read-only here — edit them from the network. +

+
+ +
+ )}
); diff --git a/resources/js/pages/networks/servers.tsx b/resources/js/pages/networks/servers.tsx index 82f8ddd44..e7cbb486c 100644 --- a/resources/js/pages/networks/servers.tsx +++ b/resources/js/pages/networks/servers.tsx @@ -59,7 +59,7 @@ export default function NetworkServers() { } }} > - Edit IP + Edit )} {!isProvider && ( diff --git a/tests/Feature/NetworkFirewallTest.php b/tests/Feature/NetworkFirewallTest.php index 954b02901..47fadd61f 100644 --- a/tests/Feature/NetworkFirewallTest.php +++ b/tests/Feature/NetworkFirewallTest.php @@ -3,17 +3,18 @@ namespace Tests\Feature; use App\Actions\FirewallRule\ManageRule; -use App\Actions\Network\CompileServerFirewallRules; use App\Actions\Network\CreateNetwork; use App\Actions\Network\ManageNetworkFirewallRule; use App\Enums\IpAddressType; use App\Enums\NetworkServerStatus; use App\Enums\NetworkStatus; +use App\Enums\ServerNetworkRuleKind; use App\Enums\ServerStatus; use App\Facades\SSH; use App\Jobs\Network\ApplyNetworkFirewallJob; use App\Models\Server; use App\Models\ServerIpAddress; +use App\Models\ServerNetworkRule; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia; use Tests\TestCase; @@ -124,8 +125,8 @@ public function test_server_firewall_page_exposes_managed_networks(): void ->assertSuccessful() ->assertInertia(fn (AssertableInertia $page) => $page ->component('firewall/index') - ->where('managedNetworks.0.name', $network->name) - ->where('managedNetworks.0.id', $network->id)); + ->where('networkRules.data.0.network_id', $network->id) + ->where('networkRules.data.0.name', 'Allow all')); } public function test_leaving_member_network_is_not_listed_as_managed(): void @@ -142,7 +143,7 @@ public function test_leaving_member_network_is_not_listed_as_managed(): void ->assertSuccessful() ->assertInertia(fn (AssertableInertia $page) => $page ->component('firewall/index') - ->count('managedNetworks', 0)); + ->count('networkRules.data', 0)); } public function test_server_level_firewall_change_reapplies_network_rules(): void @@ -210,13 +211,10 @@ public function test_deleting_allow_all_locks_down_but_keeps_tunnel_handshake(): $allowAll = $network->firewallRules()->whereNull('protocol')->whereNull('port')->firstOrFail(); app(ManageNetworkFirewallRule::class)->delete($allowAll); - $specs = app(CompileServerFirewallRules::class)->forServer($this->server->fresh()); + $rules = ServerNetworkRule::query()->where('server_id', $this->server->id); - $catchAll = $this->indexOf($specs, fn ($s) => $s->port === null && $s->protocol === null && $s->source === '100.64.0.0'); - $handshake = $this->indexOf($specs, fn ($s) => $s->protocol === 'udp' && $s->port === '51820'); - - $this->assertNull($catchAll); - $this->assertNotNull($handshake); + $this->assertFalse((clone $rules)->where('kind', ServerNetworkRuleKind::RULE)->exists()); + $this->assertTrue((clone $rules)->where('kind', ServerNetworkRuleKind::HANDSHAKE)->exists()); } public function test_firewall_change_on_offline_server_marks_member_pending(): void @@ -321,24 +319,43 @@ public function test_provider_network_applies_catch_all_on_create(): void SSH::assertExecutedContains('allow from 10.0.0.0/24 to any'); } - public function test_server_with_zero_networks_emits_no_network_rules(): void + public function test_server_with_zero_networks_has_no_materialized_rules(): void { - $specs = app(CompileServerFirewallRules::class)->forServer($this->server); - - $this->assertSame([], $specs); + $this->assertSame(0, ServerNetworkRule::query()->where('server_id', $this->server->id)->count()); } - /** - * @param array $specs - */ - private function indexOf(array $specs, callable $matcher): ?int + public function test_network_create_materializes_handshake_and_catch_all_rows(): void { - foreach ($specs as $index => $spec) { - if ($matcher($spec)) { - return $index; - } - } + SSH::fake(); + $this->server->update(['status' => ServerStatus::READY]); + + $peer = Server::factory()->create([ + 'project_id' => $this->server->project_id, + 'user_id' => $this->user->id, + 'status' => ServerStatus::READY, + ]); - return null; + $network = $this->wireguardNetwork([$this->server->id, $peer->id]); + $allowAll = $network->firewallRules()->whereNull('protocol')->whereNull('port')->firstOrFail(); + + $this->assertDatabaseHas('server_network_rules', [ + 'server_id' => $this->server->id, + 'network_id' => $network->id, + 'kind' => ServerNetworkRuleKind::HANDSHAKE->value, + 'protocol' => 'udp', + 'port' => (string) $network->port, + 'source' => $peer->ip, + 'network_firewall_rule_id' => null, + ]); + + $this->assertDatabaseHas('server_network_rules', [ + 'server_id' => $this->server->id, + 'network_id' => $network->id, + 'kind' => ServerNetworkRuleKind::RULE->value, + 'protocol' => null, + 'port' => null, + 'source' => '100.64.0.0', + 'network_firewall_rule_id' => $allowAll->id, + ]); } } From 25c473b1daa2e3300943e1fd7d62b2f0206ff99f Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Wed, 22 Jul 2026 19:41:20 +0100 Subject: [PATCH 05/27] feat: peers work --- app/Actions/Network/AddServersToNetwork.php | 8 +- app/Actions/Network/ConcealNetworkPeerKey.php | 30 ++ app/Actions/Network/CreateNetworkPeer.php | 116 +++++++ app/Actions/Network/DeleteNetworkPeer.php | 32 ++ .../Network/DispatchNetworkServerSync.php | 17 + app/Actions/Network/GetNetworkPeerConfig.php | 55 ++++ .../Network/MaterializeServerNetworkRules.php | 16 + .../Network/RecomputeNetworkStatus.php | 22 ++ .../Network/RegenerateNetworkPeerKeys.php | 54 +++ app/Actions/Network/UpdateNetworkPeer.php | 58 ++++ .../Commands/ReconcileNetworksCommand.php | 16 + app/Enums/NetworkPeerStatus.php | 27 ++ .../Controllers/NetworkPeerController.php | 110 +++++++ app/Http/Resources/NetworkPeerResource.php | 32 ++ app/Jobs/Network/PollPeerHandshakesJob.php | 94 ++++++ app/Models/Network.php | 9 + app/Models/NetworkPeer.php | 61 ++++ app/Services/VPN/WireGuard.php | 52 ++- app/Tables/Networks/NetworkPeerTable.php | 45 +++ .../WireGuardPublicKeyRule.php | 34 ++ database/factories/NetworkPeerFactory.php | 31 ++ ...7_22_122807_create_network_peers_table.php | 36 ++ resources/js/components/dialogs/registry.ts | 4 + resources/js/layouts/network/layout.tsx | 11 +- .../js/pages/networks/components/add-peer.tsx | 89 +++++ .../pages/networks/components/peer-config.tsx | 144 ++++++++ resources/js/pages/networks/peers.tsx | 114 +++++++ resources/js/types/network.d.ts | 16 + resources/views/ssh/wireguard/conf.blade.php | 2 + .../ssh/wireguard/latest-handshakes.blade.php | 1 + resources/views/wireguard/peer-conf.blade.php | 11 + tests/Feature/NetworkPeerTest.php | 311 ++++++++++++++++++ 32 files changed, 1650 insertions(+), 8 deletions(-) create mode 100644 app/Actions/Network/ConcealNetworkPeerKey.php create mode 100644 app/Actions/Network/CreateNetworkPeer.php create mode 100644 app/Actions/Network/DeleteNetworkPeer.php create mode 100644 app/Actions/Network/GetNetworkPeerConfig.php create mode 100644 app/Actions/Network/RegenerateNetworkPeerKeys.php create mode 100644 app/Actions/Network/UpdateNetworkPeer.php create mode 100644 app/Enums/NetworkPeerStatus.php create mode 100644 app/Http/Controllers/NetworkPeerController.php create mode 100644 app/Http/Resources/NetworkPeerResource.php create mode 100644 app/Jobs/Network/PollPeerHandshakesJob.php create mode 100644 app/Models/NetworkPeer.php create mode 100644 app/Tables/Networks/NetworkPeerTable.php create mode 100644 app/ValidationRules/WireGuardPublicKeyRule.php create mode 100644 database/factories/NetworkPeerFactory.php create mode 100644 database/migrations/2026_07_22_122807_create_network_peers_table.php create mode 100644 resources/js/pages/networks/components/add-peer.tsx create mode 100644 resources/js/pages/networks/components/peer-config.tsx create mode 100644 resources/js/pages/networks/peers.tsx create mode 100644 resources/views/ssh/wireguard/latest-handshakes.blade.php create mode 100644 resources/views/wireguard/peer-conf.blade.php create mode 100644 tests/Feature/NetworkPeerTest.php diff --git a/app/Actions/Network/AddServersToNetwork.php b/app/Actions/Network/AddServersToNetwork.php index e5ebc1a63..8283f91b8 100644 --- a/app/Actions/Network/AddServersToNetwork.php +++ b/app/Actions/Network/AddServersToNetwork.php @@ -56,7 +56,13 @@ public function add(Network $network, array $input): void */ private function addWireGuard(Network $network, array $input): array { - $used = $network->servers()->lockForUpdate()->pluck('ip')->filter()->values()->all(); + Network::query()->whereKey($network->id)->lockForUpdate()->first(); + + $used = $network->servers()->lockForUpdate()->pluck('ip') + ->concat($network->peers()->lockForUpdate()->pluck('ip')) + ->filter() + ->values() + ->all(); $servers = Server::query() ->where('project_id', $network->project_id) 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/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/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 index 8a8650ccc..6f0a87e21 100644 --- a/app/Actions/Network/DispatchNetworkServerSync.php +++ b/app/Actions/Network/DispatchNetworkServerSync.php @@ -4,10 +4,27 @@ use App\Enums\NetworkServerStatus; use App\Jobs\Network\SyncNetworkServerJob; +use App\Models\Network; use App\Models\NetworkServer; class DispatchNetworkServerSync { + /** + * Re-push the current configuration to every already-provisioned member. + * Used when peer/topology data changes and existing members must rewrite + * their WireGuard config and firewall rules. + */ + public function resyncMembers(Network $network): void + { + $network->load('servers.server'); + + foreach ($network->servers as $member) { + if (in_array($member->status, [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING], true)) { + $this->toPresent($member); + } + } + } + public function toPresent(NetworkServer $member): void { if ($member->server->isReady()) { diff --git a/app/Actions/Network/GetNetworkPeerConfig.php b/app/Actions/Network/GetNetworkPeerConfig.php new file mode 100644 index 000000000..177de2831 --- /dev/null +++ b/app/Actions/Network/GetNetworkPeerConfig.php @@ -0,0 +1,55 @@ +canShowConfig(), 410); + + $network = $peer->network; + + $config = view('wireguard.peer-conf', [ + 'address' => $peer->ip, + 'privateKey' => $peer->byo ? self::PRIVATE_KEY_PLACEHOLDER : $peer->private_key, + 'peers' => $this->peers($peer), + ])->render(); + + return ['config' => $config]; + } + + /** + * @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') + ->get() + ->filter(fn (NetworkServer $member): bool => filled($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.'/32', + 'endpoint' => $member->server->ip.':'.$network->port, + ]) + ->all(); + } +} diff --git a/app/Actions/Network/MaterializeServerNetworkRules.php b/app/Actions/Network/MaterializeServerNetworkRules.php index 37629cc79..d01ccb673 100644 --- a/app/Actions/Network/MaterializeServerNetworkRules.php +++ b/app/Actions/Network/MaterializeServerNetworkRules.php @@ -4,6 +4,7 @@ use App\DTOs\SocketEventDTO; use App\Enums\FirewallRuleStatus; +use App\Enums\NetworkPeerStatus; use App\Enums\NetworkServerStatus; use App\Enums\NetworkType; use App\Enums\ServerNetworkRuleKind; @@ -133,6 +134,21 @@ private function desiredFor(Server $server): array ]; $desired[$this->identity($membership->id, ServerNetworkRuleKind::HANDSHAKE, null, $handshake['ip'], 32)] = $spec; } + + if ($network->peers()->where('status', '!=', NetworkPeerStatus::DISABLED)->exists()) { + $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); diff --git a/app/Actions/Network/RecomputeNetworkStatus.php b/app/Actions/Network/RecomputeNetworkStatus.php index e7f4f7332..853a33011 100644 --- a/app/Actions/Network/RecomputeNetworkStatus.php +++ b/app/Actions/Network/RecomputeNetworkStatus.php @@ -3,9 +3,11 @@ namespace App\Actions\Network; use App\DTOs\SocketEventDTO; +use App\Enums\NetworkPeerStatus; use App\Enums\NetworkServerStatus; use App\Enums\NetworkStatus; use App\Events\SocketEvent; +use App\Http\Resources\NetworkPeerResource; use App\Http\Resources\NetworkResource; use App\Models\Network; @@ -50,10 +52,30 @@ public function handle(Network $network): void $network->save(); } + if ($computed === NetworkStatus::ACTIVE) { + $this->activatePendingPeers($network); + } + SocketEvent::dispatch(new SocketEventDTO( projectId: $network->project_id, type: 'network.updated', data: new NetworkResource($network->loadCount('servers')), )); } + + 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/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/Console/Commands/ReconcileNetworksCommand.php b/app/Console/Commands/ReconcileNetworksCommand.php index 0ba02fb54..285a703e1 100644 --- a/app/Console/Commands/ReconcileNetworksCommand.php +++ b/app/Console/Commands/ReconcileNetworksCommand.php @@ -4,10 +4,15 @@ use App\Actions\Network\RecomputeNetworkStatus; use App\DTOs\SocketEventDTO; +use App\Enums\NetworkPeerStatus; use App\Enums\NetworkServerStatus; +use App\Enums\NetworkStatus; +use App\Enums\NetworkType; use App\Enums\ServerStatus; use App\Events\SocketEvent; +use App\Jobs\Network\PollPeerHandshakesJob; use App\Jobs\Network\SyncNetworkServerJob; +use App\Models\Network; use App\Models\NetworkServer; use App\Models\ServerLog; use Illuminate\Console\Command; @@ -27,6 +32,17 @@ public function handle(): void $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 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/Http/Controllers/NetworkPeerController.php b/app/Http/Controllers/NetworkPeerController.php new file mode 100644 index 000000000..3f2c2791c --- /dev/null +++ b/app/Http/Controllers/NetworkPeerController.php @@ -0,0 +1,110 @@ +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)); + } + + #[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/Resources/NetworkPeerResource.php b/app/Http/Resources/NetworkPeerResource.php new file mode 100644 index 000000000..85f17c6f2 --- /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, + 'can_show_config' => $this->canShowConfig(), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Jobs/Network/PollPeerHandshakesJob.php b/app/Jobs/Network/PollPeerHandshakesJob.php new file mode 100644 index 000000000..05730e001 --- /dev/null +++ b/app/Jobs/Network/PollPeerHandshakesJob.php @@ -0,0 +1,94 @@ +run("network-{$this->network->id}-handshakes", function (): void { + $member = $this->reachableMember(); + if (! $member instanceof NetworkServer) { + return; + } + + $service = $member->server->service(WireGuard::type()); + if (! $service instanceof Service || $service->status !== ServiceStatus::READY) { + return; + } + + /** @var WireGuard $handler */ + $handler = $service->handler(); + + $this->apply($handler->latestHandshakes($this->network)); + }); + } + + public function failed(Exception $e): void + { + $member = $this->reachableMember(); + if ($member instanceof NetworkServer) { + 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), + )); + } + } + + private function reachableMember(): ?NetworkServer + { + return $this->network->servers() + ->where('status', NetworkServerStatus::ACTIVE) + ->whereHas('server', fn ($query) => $query->whereIn('status', [ServerStatus::READY, ServerStatus::UPDATING])) + ->with('server') + ->orderBy('id') + ->first(); + } +} diff --git a/app/Models/Network.php b/app/Models/Network.php index c440d706f..dae78ffc6 100644 --- a/app/Models/Network.php +++ b/app/Models/Network.php @@ -24,6 +24,7 @@ * @property Project $project * @property Collection $servers * @property Collection $firewallRules + * @property Collection $peers */ class Network extends AbstractModel { @@ -80,4 +81,12 @@ public function serverRules(): HasMany { return $this->hasMany(ServerNetworkRule::class); } + + /** + * @return HasMany + */ + public function peers(): HasMany + { + return $this->hasMany(NetworkPeer::class); + } } diff --git a/app/Models/NetworkPeer.php b/app/Models/NetworkPeer.php new file mode 100644 index 000000000..4102e108e --- /dev/null +++ b/app/Models/NetworkPeer.php @@ -0,0 +1,61 @@ + */ + 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', + ]; + + public function canShowConfig(): bool + { + return $this->byo || $this->private_key !== null; + } + + /** + * @return BelongsTo + */ + public function network(): BelongsTo + { + return $this->belongsTo(Network::class); + } +} diff --git a/app/Services/VPN/WireGuard.php b/app/Services/VPN/WireGuard.php index 5df3363c0..43556650f 100644 --- a/app/Services/VPN/WireGuard.php +++ b/app/Services/VPN/WireGuard.php @@ -2,10 +2,12 @@ namespace App\Services\VPN; +use App\Enums\NetworkPeerStatus; use App\Enums\NetworkServerStatus; use App\Exceptions\SSHError; use App\Helpers\SSH; use App\Models\Network; +use App\Models\NetworkPeer; use App\Models\NetworkServer; use App\Services\AbstractService; use App\Support\Testing\SSHFake; @@ -115,11 +117,13 @@ private function prefix(Network $network): int } /** - * @return array + * @return array */ private function peers(NetworkServer $membership): array { - return $membership->network->servers() + $network = $membership->network; + + $servers = $network->servers() ->where('id', '!=', $membership->id) ->where('status', '!=', NetworkServerStatus::LEAVING) ->whereNotNull('public_key') @@ -130,10 +134,46 @@ private function peers(NetworkServer $membership): array ->map(fn (NetworkServer $peer): array => [ 'public_key' => (string) $peer->public_key, 'allowed_ips' => $peer->ip.'/32', - 'endpoint' => $peer->server->ip.':'.$membership->network->port, - ]) - ->values() - ->all(); + 'endpoint' => $peer->server->ip.':'.$network->port, + ]); + + $devices = $network->peers() + ->where('status', '!=', NetworkPeerStatus::DISABLED) + ->get() + ->map(fn (NetworkPeer $peer): array => [ + 'public_key' => $peer->public_key, + 'allowed_ips' => $peer->ip.'/32', + '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.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 diff --git a/app/Tables/Networks/NetworkPeerTable.php b/app/Tables/Networks/NetworkPeerTable.php new file mode 100644 index 000000000..1bccb722c --- /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('can_show_config', fn (NetworkPeer $peer): bool => $peer->canShowConfig()), + 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/ValidationRules/WireGuardPublicKeyRule.php b/app/ValidationRules/WireGuardPublicKeyRule.php new file mode 100644 index 000000000..51ad6e0a8 --- /dev/null +++ b/app/ValidationRules/WireGuardPublicKeyRule.php @@ -0,0 +1,34 @@ +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.'); + } + } +} 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/migrations/2026_07_22_122807_create_network_peers_table.php b/database/migrations/2026_07_22_122807_create_network_peers_table.php new file mode 100644 index 000000000..6a53ef964 --- /dev/null +++ b/database/migrations/2026_07_22_122807_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/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index 26f6e80f4..17df21db9 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -37,6 +37,8 @@ import CreateNetwork from '@/pages/networks/components/create-network'; import AddNetworkServer from '@/pages/networks/components/add-server'; import EditNetworkServer from '@/pages/networks/components/edit-network-server'; import NetworkFirewallRuleForm from '@/pages/networks/components/firewall-rule-form'; +import AddNetworkPeer from '@/pages/networks/components/add-peer'; +import PeerConfigDialog from '@/pages/networks/components/peer-config'; export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) => void }; @@ -91,6 +93,8 @@ export const dialogs = { networkAddServer: AddNetworkServer, networkEditServer: EditNetworkServer, networkFirewallForm: NetworkFirewallRuleForm, + networkAddPeer: AddNetworkPeer, + networkPeerConfig: PeerConfigDialog, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as const satisfies Record>; diff --git a/resources/js/layouts/network/layout.tsx b/resources/js/layouts/network/layout.tsx index 9aa2ec23e..8affe5842 100644 --- a/resources/js/layouts/network/layout.tsx +++ b/resources/js/layouts/network/layout.tsx @@ -1,5 +1,5 @@ import { type NavItem } from '@/types'; -import { FlameIcon, HomeIcon, ServerIcon, Settings2Icon } from 'lucide-react'; +import { FlameIcon, HomeIcon, LaptopIcon, ServerIcon, Settings2Icon } from 'lucide-react'; import { ReactNode } from 'react'; import { Network } from '@/types/network'; import Layout from '@/layouts/app/layout'; @@ -26,6 +26,15 @@ export default function NetworkLayout({ children }: { children: ReactNode }) { href: route('networks.servers', { network: network.id }), icon: ServerIcon, }, + ...(network.type_value !== 'provider' + ? [ + { + title: 'Peers', + href: route('networks.peers', { network: network.id }), + icon: LaptopIcon, + }, + ] + : []), { title: 'Firewall', href: route('networks.firewall', { network: network.id }), diff --git a/resources/js/pages/networks/components/add-peer.tsx b/resources/js/pages/networks/components/add-peer.tsx new file mode 100644 index 000000000..63e859a2f --- /dev/null +++ b/resources/js/pages/networks/components/add-peer.tsx @@ -0,0 +1,89 @@ +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { FormEvent, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import InputError from '@/components/ui/input-error'; + +export default function AddNetworkPeer({ + open, + onOpenChange, + networkId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + networkId: number; +}) { + const [byo, setByo] = useState(false); + const form = useForm<{ name: string; public_key: string }>({ + name: '', + public_key: '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.transform((data) => ({ name: data.name, public_key: byo ? data.public_key : '' })); + form.post(route('networks.peers.store', { network: networkId }), { + onSuccess: () => { + form.reset(); + setByo(false); + onOpenChange(false); + }, + }); + }; + + return ( + + e.preventDefault()}> + + Add peer + Add a device to this network + +
+ + + + form.setData('name', e.target.value)} placeholder="e.g. my-laptop" /> + + + + +
+ setByo(checked === true)} /> + +
+
+ + {byo && ( + + +