@@ -135,31 +136,74 @@ export default function NetworkSettings() {
-
-
- Delete network
- Tear this network down on all of its servers and remove it. This action cannot be undone.
-
-
-
-
-
-
-
+ {network.is_managed && !network.is_orphaned && (
+
+
+ Provider managed
+
+ This network mirrors a private network at your cloud provider. Its members are synced automatically, and it is removed
+ from Vito when the network no longer exists at the provider.
+
+
+
+
+
+
+ )}
+
+ {network.is_orphaned && (
+
+
+ Provider connection removed
+
+ The cloud provider connection this network came from no longer exists, so it can no longer be synced or removed
+ automatically. Deleting it here is the only way to clear it.
+
+
+ )}
+
+ {(!network.is_managed || network.is_orphaned) && (
+
+
+ Delete network
+ Tear this network down on all of its servers and remove it. This action cannot be undone.
+
+
+
diff --git a/resources/js/types/network.d.ts b/resources/js/types/network.d.ts
index 0e1d2e710..348963c7d 100644
--- a/resources/js/types/network.d.ts
+++ b/resources/js/types/network.d.ts
@@ -10,6 +10,11 @@ export interface Network {
addressing_pool: string;
cidr: string | null;
port: number | null;
+ region: string | null;
+ is_managed: boolean;
+ is_orphaned: boolean;
+ provider?: string | null;
+ last_synced_at: string | null;
status: string;
status_color: StatusColor;
servers_count?: number;
diff --git a/tests/Feature/NetworkFirewallTest.php b/tests/Feature/NetworkFirewallTest.php
index 47fadd61f..fef00b3b0 100644
--- a/tests/Feature/NetworkFirewallTest.php
+++ b/tests/Feature/NetworkFirewallTest.php
@@ -8,6 +8,7 @@
use App\Enums\IpAddressType;
use App\Enums\NetworkServerStatus;
use App\Enums\NetworkStatus;
+use App\Enums\NetworkType;
use App\Enums\ServerNetworkRuleKind;
use App\Enums\ServerStatus;
use App\Facades\SSH;
@@ -249,7 +250,7 @@ public function test_provider_network_firewall_uses_cidr_source(): void
$network = app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov-net',
- 'type' => 'provider',
+ 'type' => 'custom',
'cidr' => '10.0.0.0/24',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip->id],
@@ -288,7 +289,7 @@ 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',
+ 'type' => 'custom',
'servers' => [$this->server->id, $peer->id],
'ip_addresses' => [$this->server->id => $ip1->id, $peer->id => $ip2->id],
]);
@@ -310,7 +311,7 @@ public function test_provider_network_applies_catch_all_on_create(): void
app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov-net',
- 'type' => 'provider',
+ 'type' => 'custom',
'cidr' => '10.0.0.0/24',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip->id],
@@ -319,6 +320,61 @@ 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_provider_network_never_uses_the_vpc_cidr_as_a_rule_source(): 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,
+ ]);
+
+ $network = \App\Models\Network::factory()->create([
+ 'project_id' => $this->server->project_id,
+ 'type' => NetworkType::PROVIDER,
+ 'status' => NetworkStatus::ACTIVE,
+ 'cidr' => '172.31.0.0/16',
+ 'cidr_canonical' => '172.31.0.0/16',
+ ]);
+
+ $network->servers()->create([
+ 'server_id' => $this->server->id,
+ 'ip' => '172.31.0.5',
+ 'status' => NetworkServerStatus::ACTIVE,
+ ]);
+ $network->servers()->create([
+ 'server_id' => $peer->id,
+ 'ip' => '172.31.0.6',
+ 'status' => NetworkServerStatus::ACTIVE,
+ ]);
+
+ app(ManageNetworkFirewallRule::class)->create($network, [
+ 'name' => 'mysql',
+ 'protocol' => 'tcp',
+ 'port' => '3306',
+ ]);
+
+ $sources = ServerNetworkRule::query()
+ ->where('server_id', $this->server->id)
+ ->where('network_id', $network->id)
+ ->pluck('source')
+ ->all();
+
+ $this->assertContains('172.31.0.6', $sources);
+ $this->assertNotContains('172.31.0.0', $sources, 'Provider networks must never widen the firewall to the whole VPC CIDR.');
+
+ $this->assertSame(
+ 0,
+ ServerNetworkRule::query()
+ ->where('network_id', $network->id)
+ ->where('mask', 16)
+ ->count(),
+ 'Provider network rules must be per-member /32s.'
+ );
+ }
+
public function test_server_with_zero_networks_has_no_materialized_rules(): void
{
$this->assertSame(0, ServerNetworkRule::query()->where('server_id', $this->server->id)->count());
diff --git a/tests/Feature/NetworkPeerTest.php b/tests/Feature/NetworkPeerTest.php
index 9c6a7dec2..a802a206a 100644
--- a/tests/Feature/NetworkPeerTest.php
+++ b/tests/Feature/NetworkPeerTest.php
@@ -17,6 +17,7 @@
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class NetworkPeerTest extends TestCase
@@ -79,11 +80,23 @@ public function test_server_add_skips_peer_ip(): void
]);
}
- public function test_peer_routes_404_on_provider_network(): void
+ /**
+ * @return array
+ */
+ public static function nonWireGuardTypes(): array
+ {
+ return [
+ 'custom' => [NetworkType::CUSTOM],
+ 'provider' => [NetworkType::PROVIDER],
+ ];
+ }
+
+ #[DataProvider('nonWireGuardTypes')]
+ public function test_peer_routes_404_on_non_wireguard_network(NetworkType $type): void
{
$network = Network::factory()->create([
'project_id' => $this->server->project_id,
- 'type' => NetworkType::PROVIDER,
+ 'type' => $type,
]);
$this->actingAs($this->user);
diff --git a/tests/Feature/NetworkProviderSyncTest.php b/tests/Feature/NetworkProviderSyncTest.php
new file mode 100644
index 000000000..0bb471f60
--- /dev/null
+++ b/tests/Feature/NetworkProviderSyncTest.php
@@ -0,0 +1,530 @@
+> */
+ private array $networksPayload = [];
+
+ /** @var array> */
+ private array $serversPayload = [];
+
+ private ?int $failStatus = null;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ SSH::fake();
+
+ // `Http::fake()` merges stubs rather than replacing them, so a per-call fake would let
+ // the first registration win for every later sync. One closure reading mutable state
+ // is the only way to vary the response across runs within a test.
+ Http::fake(function (Request $request): PromiseInterface {
+ if ($this->failStatus !== null) {
+ return Http::response(['error' => ['message' => 'nope']], $this->failStatus);
+ }
+
+ if (str_contains($request->url(), '/networks')) {
+ return Http::response([
+ 'networks' => $this->networksPayload,
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]);
+ }
+
+ return Http::response([
+ 'servers' => $this->serversPayload,
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]);
+ });
+
+ $this->connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Hetzner::id(),
+ 'profile' => 'hetzner-main',
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ $this->server->update([
+ 'status' => ServerStatus::READY,
+ 'provider_id' => $this->connection->id,
+ 'provider_data' => ['hetzner_id' => 101, 'region' => 'nbg1'],
+ ]);
+ }
+
+ private function otherServer(int $hetznerId = 102): Server
+ {
+ return Server::factory()->create([
+ 'project_id' => $this->server->project_id,
+ 'user_id' => $this->user->id,
+ 'status' => ServerStatus::READY,
+ 'provider_id' => $this->connection->id,
+ 'provider_data' => ['hetzner_id' => $hetznerId, 'region' => 'nbg1'],
+ ]);
+ }
+
+ /**
+ * @param array> $networks
+ * @param array> $servers
+ */
+ private function fakeProvider(array $networks, array $servers): void
+ {
+ $this->networksPayload = $networks;
+ $this->serversPayload = $servers;
+ $this->failStatus = null;
+ }
+
+ private function sync(?Network $only = null): void
+ {
+ app(SyncProviderNetworks::class)->forProject($this->server->project, $only);
+ }
+
+ public function test_creates_network_and_members_from_provider(): void
+ {
+ $peer = $this->otherServer();
+
+ $this->fakeProvider(
+ [[
+ 'id' => 4711,
+ 'name' => 'prod',
+ 'ip_range' => '10.0.0.0/16',
+ 'subnets' => [['network_zone' => 'eu-central']],
+ 'servers' => [101, 102],
+ ]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ],
+ );
+
+ $this->sync();
+
+ $this->assertDatabaseHas('networks', [
+ 'project_id' => $this->server->project_id,
+ 'server_provider_id' => $this->connection->id,
+ 'external_id' => '4711',
+ 'name' => 'prod',
+ 'type' => NetworkType::PROVIDER->value,
+ 'cidr' => '10.0.0.0/16',
+ 'region' => 'eu-central',
+ ]);
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+
+ $this->assertDatabaseHas('network_servers', [
+ 'network_id' => $network->id,
+ 'server_id' => $this->server->id,
+ 'ip' => '10.0.0.2',
+ ]);
+ $this->assertDatabaseHas('network_servers', [
+ 'network_id' => $network->id,
+ 'server_id' => $peer->id,
+ 'ip' => '10.0.0.3',
+ ]);
+ }
+
+ public function test_second_run_creates_no_duplicates(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+
+ $this->sync();
+ $this->sync();
+
+ $this->assertSame(1, Network::query()->where('external_id', '4711')->count());
+ $this->assertSame(1, Network::query()->where('type', NetworkType::PROVIDER)->count());
+ }
+
+ public function test_adds_and_removes_members_as_provider_changes(): void
+ {
+ $peer = $this->otherServer();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101, 102]]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+ $this->assertSame(2, $network->servers()->count());
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $this->assertSame(
+ 0,
+ $network->servers()->where('server_id', $peer->id)->whereNot('status', NetworkServerStatus::LEAVING)->count(),
+ 'A detached server must no longer be a live member.'
+ );
+ }
+
+ public function test_reattached_server_revives_a_leaving_member_instead_of_throwing(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+ $member = $network->servers()->firstOrFail();
+ $member->update(['status' => NetworkServerStatus::LEAVING, 'sync_attempts' => 3]);
+
+ $this->sync();
+
+ $member->refresh();
+ $this->assertSame(NetworkServerStatus::ACTIVE, $member->status);
+ $this->assertSame(0, $member->sync_attempts);
+ $this->assertSame(1, $network->servers()->count(), 'Reviving must not insert a second member row.');
+ }
+
+ public function test_already_leaving_member_is_not_torn_down_again(): void
+ {
+ $peer = $this->otherServer();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101, 102]]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+ $member = $network->servers()->where('server_id', $this->server->id)->firstOrFail();
+ $member->update(['status' => NetworkServerStatus::LEAVING, 'sync_attempts' => 3]);
+
+ // The peer keeps the network alive so this exercises member removal, not network prune.
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [102]]],
+ [['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]]],
+ );
+ $this->sync();
+
+ $member->refresh();
+ $this->assertSame(
+ 3,
+ $member->sync_attempts,
+ 'Re-driving teardown would reset sync_attempts and defeat forceConverge escalation.'
+ );
+ $this->assertNotNull($peer->id);
+ }
+
+ public function test_resurrects_a_deleting_network_that_reappears(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+ $network->update(['status' => NetworkStatus::DELETING]);
+ $network->servers()->update(['status' => NetworkServerStatus::LEAVING]);
+
+ $this->sync();
+
+ $network->refresh();
+ $this->assertNotSame(NetworkStatus::DELETING, $network->status);
+ $this->assertSame(
+ 0,
+ $network->servers()->where('status', NetworkServerStatus::LEAVING)->count(),
+ 'Resurrected networks must not keep LEAVING members that never converge.'
+ );
+ }
+
+ public function test_deletes_network_when_vpc_disappears(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+
+ $this->fakeProvider([], []);
+ $this->sync();
+
+ $this->assertNotSame(
+ NetworkStatus::ACTIVE,
+ $network->fresh()?->status,
+ 'A removed VPC must start teardown.'
+ );
+ }
+
+ public function test_provider_failure_never_prunes(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+
+ $this->failStatus = 500;
+ $this->sync();
+
+ $network->refresh();
+ $this->assertSame(NetworkStatus::ACTIVE, $network->status);
+ $this->assertSame(1, $network->servers()->count());
+ }
+
+ public function test_vpc_without_managed_servers_is_not_imported(): void
+ {
+ $this->fakeProvider(
+ [['id' => 9999, 'name' => 'someone-elses', 'ip_range' => '10.9.0.0/16', 'servers' => [777]]],
+ [['id' => 777, 'private_net' => [['network' => 9999, 'ip' => '10.9.0.2']]]],
+ );
+
+ $this->sync();
+
+ $this->assertSame(0, Network::query()->where('type', NetworkType::PROVIDER)->count());
+ }
+
+ public function test_imported_network_firewall_uses_member_ips_not_vpc_cidr(): void
+ {
+ $peer = $this->otherServer();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '172.31.0.0/16', 'servers' => [101, 102]]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '172.31.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '172.31.0.3']]],
+ ],
+ );
+
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+
+ $this->assertSame(
+ 0,
+ ServerNetworkRule::query()->where('network_id', $network->id)->where('mask', 16)->count(),
+ 'Sync must never widen the firewall to the whole VPC.'
+ );
+
+ $sources = ServerNetworkRule::query()
+ ->where('network_id', $network->id)
+ ->where('server_id', $this->server->id)
+ ->pluck('source')
+ ->all();
+
+ $this->assertContains('172.31.0.3', $sources);
+ $this->assertNotContains('172.31.0.0', $sources);
+ $this->assertNotNull($peer->id);
+ }
+
+ public function test_name_collision_is_uniqued(): void
+ {
+ Network::factory()->create([
+ 'project_id' => $this->server->project_id,
+ 'name' => 'prod',
+ 'type' => NetworkType::CUSTOM,
+ ]);
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+
+ $this->sync();
+
+ $this->assertDatabaseHas('networks', ['external_id' => '4711', 'name' => 'prod-2']);
+ }
+
+ public function test_provider_name_change_does_not_rename_the_local_network(): void
+ {
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'renamed-at-provider', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ [['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]]],
+ );
+ $this->sync();
+
+ $this->assertDatabaseHas('networks', ['external_id' => '4711', 'name' => 'prod']);
+ }
+
+ public function test_recycled_ip_between_members_does_not_violate_unique_index(): void
+ {
+ $peer = $this->otherServer();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101, 102]]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ],
+ );
+ $this->sync();
+
+ $this->fakeProvider(
+ [['id' => 4711, 'name' => 'prod', 'ip_range' => '10.0.0.0/16', 'servers' => [101, 102]]],
+ [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ],
+ );
+ $this->sync();
+
+ $network = Network::query()->where('external_id', '4711')->firstOrFail();
+
+ $this->assertSame('10.0.0.3', $network->servers()->where('server_id', $this->server->id)->value('ip'));
+ $this->assertSame('10.0.0.2', $network->servers()->where('server_id', $peer->id)->value('ip'));
+ }
+
+ private function providerNetwork(?int $connectionId = null): Network
+ {
+ return Network::factory()->create([
+ 'project_id' => $this->server->project_id,
+ 'type' => NetworkType::PROVIDER,
+ 'server_provider_id' => $connectionId,
+ 'external_id' => '4711',
+ ]);
+ }
+
+ public function test_provider_network_cannot_be_deleted(): void
+ {
+ $network = $this->providerNetwork($this->connection->id);
+
+ $this->actingAs($this->user)
+ ->delete(route('networks.destroy', $network))
+ ->assertForbidden();
+
+ $this->assertDatabaseHas('networks', ['id' => $network->id]);
+ }
+
+ public function test_orphaned_provider_network_can_be_deleted(): void
+ {
+ $network = $this->providerNetwork(null);
+
+ $this->actingAs($this->user)
+ ->delete(route('networks.destroy', $network))
+ ->assertRedirect();
+
+ $this->assertNotSame(NetworkStatus::ACTIVE, $network->fresh()?->status);
+ }
+
+ public function test_manual_member_routes_are_rejected_on_a_provider_network(): void
+ {
+ $network = $this->providerNetwork($this->connection->id);
+ $member = $network->servers()->create([
+ 'server_id' => $this->server->id,
+ 'ip' => '10.0.0.2',
+ 'status' => NetworkServerStatus::ACTIVE,
+ ]);
+
+ $this->actingAs($this->user);
+
+ $this->post(route('networks.servers.store', $network), ['servers' => [$this->server->id]])
+ ->assertNotFound();
+
+ $this->put(route('networks.servers.update', ['network' => $network, 'networkServer' => $member]), [])
+ ->assertNotFound();
+
+ $this->post(route('networks.servers.sync', ['network' => $network, 'networkServer' => $member]))
+ ->assertNotFound();
+
+ $this->delete(route('networks.servers.destroy', ['network' => $network, 'networkServer' => $member]))
+ ->assertNotFound();
+
+ $this->assertDatabaseHas('network_servers', ['id' => $member->id, 'status' => NetworkServerStatus::ACTIVE->value]);
+ }
+
+ public function test_global_sync_route_dispatches_the_job(): void
+ {
+ Queue::fake();
+
+ $this->actingAs($this->user)
+ ->post(route('networks.sync-providers'))
+ ->assertRedirect();
+
+ Queue::assertPushed(SyncProviderNetworksJob::class);
+ }
+
+ public function test_repeated_sync_clicks_are_debounced(): void
+ {
+ Queue::fake();
+
+ $this->actingAs($this->user);
+
+ $this->post(route('networks.sync-providers'))->assertRedirect();
+ $this->post(route('networks.sync-providers'))->assertRedirect();
+
+ Queue::assertPushed(SyncProviderNetworksJob::class, 1);
+ }
+
+ public function test_viewer_cannot_trigger_a_provider_sync(): void
+ {
+ Queue::fake();
+
+ $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)
+ ->post(route('networks.sync-providers'))
+ ->assertForbidden();
+
+ Queue::assertNothingPushed();
+ }
+
+ public function test_sync_ignores_networks_of_another_project(): void
+ {
+ $other = Network::factory()->create([
+ 'project_id' => $this->server->project_id,
+ 'type' => NetworkType::PROVIDER,
+ 'server_provider_id' => $this->connection->id,
+ 'external_id' => '4711',
+ ]);
+
+ $otherUser = User::factory()->create();
+ $otherUser->ensureHasDefaultProject();
+
+ app(SyncProviderNetworks::class)->forProject($otherUser->currentProject, $other);
+
+ $this->assertDatabaseHas('networks', ['id' => $other->id]);
+ }
+}
diff --git a/tests/Feature/NetworkSyncTest.php b/tests/Feature/NetworkSyncTest.php
index 09cc55540..fdca24210 100644
--- a/tests/Feature/NetworkSyncTest.php
+++ b/tests/Feature/NetworkSyncTest.php
@@ -116,7 +116,7 @@ public function test_create_provider_network_members_active_without_wireguard():
$network = app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov-net',
- 'type' => 'provider',
+ 'type' => 'custom',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip->id],
]);
@@ -152,7 +152,7 @@ public function test_create_provider_rejects_public_ip(): void
app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov-net',
- 'type' => 'provider',
+ 'type' => 'custom',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip->id],
]);
@@ -482,7 +482,7 @@ public function test_provider_add_server_success(): void
$network = app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov',
- 'type' => 'provider',
+ 'type' => 'custom',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip1->id],
]);
diff --git a/tests/Feature/NetworkTest.php b/tests/Feature/NetworkTest.php
index 428e78a76..62da30746 100644
--- a/tests/Feature/NetworkTest.php
+++ b/tests/Feature/NetworkTest.php
@@ -306,7 +306,7 @@ public function test_update_provider_network_server_ip_via_http(): void
$network = app(CreateNetwork::class)->create($this->server->project, [
'name' => 'prov-net',
- 'type' => 'provider',
+ 'type' => 'custom',
'cidr' => '10.0.0.0/24',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip1->id],
@@ -403,7 +403,7 @@ 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,
+ 'type' => NetworkType::CUSTOM,
'cidr' => '10.0.0.0/24',
'cidr_canonical' => '10.0.0.0/24',
]);
@@ -412,7 +412,7 @@ public function test_provider_duplicate_cidr_returns_422(): void
$this->post(route('networks.store'), [
'name' => 'dup',
- 'type' => 'provider',
+ 'type' => 'custom',
'cidr' => '10.0.0.5/24',
'servers' => [$this->server->id],
'ip_addresses' => [$this->server->id => $ip->id],
diff --git a/tests/Feature/ProviderPrivateNetworkTest.php b/tests/Feature/ProviderPrivateNetworkTest.php
new file mode 100644
index 000000000..04c7c1c46
--- /dev/null
+++ b/tests/Feature/ProviderPrivateNetworkTest.php
@@ -0,0 +1,493 @@
+create([
+ 'user_id' => $this->user->id,
+ 'provider' => Hetzner::id(),
+ 'profile' => 'hetzner-main',
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+ }
+
+ public function test_hetzner_maps_networks_and_member_ips(): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/networks*' => Http::response([
+ 'networks' => [
+ [
+ 'id' => 4711,
+ 'name' => 'prod-net',
+ 'ip_range' => '10.0.0.0/16',
+ 'subnets' => [['network_zone' => 'eu-central']],
+ 'servers' => [101, 102, 999],
+ ],
+ ],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [
+ ['id' => 101, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.2']]],
+ ['id' => 102, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.3']]],
+ ['id' => 999, 'private_net' => [['network' => 4711, 'ip' => '10.0.0.9']]],
+ ],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $networks = $provider->privateNetworks(['101', '102'], []);
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('4711', $networks[0]->externalId);
+ $this->assertSame('prod-net', $networks[0]->name);
+ $this->assertSame('10.0.0.0/16', $networks[0]->cidr);
+ $this->assertSame('eu-central', $networks[0]->region);
+
+ $this->assertCount(2, $networks[0]->members, 'Instances Vito does not manage must be ignored.');
+ $this->assertSame(['101', '102'], array_map(fn ($m): string => $m->instanceId, $networks[0]->members));
+ $this->assertSame(['10.0.0.2', '10.0.0.3'], array_map(fn ($m): ?string => $m->ip, $networks[0]->members));
+ }
+
+ public function test_hetzner_skips_networks_with_no_managed_members(): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/networks*' => Http::response([
+ 'networks' => [
+ ['id' => 1, 'name' => 'unrelated', 'ip_range' => '10.1.0.0/16', 'servers' => [777]],
+ ],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $this->assertSame([], $provider->privateNetworks(['101'], []));
+ }
+
+ public function test_hetzner_follows_pagination(): void
+ {
+ Http::fakeSequence('api.hetzner.cloud/v1/networks*')
+ ->push([
+ 'networks' => [['id' => 1, 'name' => 'a', 'ip_range' => '10.1.0.0/16', 'servers' => []]],
+ 'meta' => ['pagination' => ['next_page' => 2]],
+ ])
+ ->push([
+ 'networks' => [['id' => 2, 'name' => 'b', 'ip_range' => '10.2.0.0/16', 'servers' => [101]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]);
+
+ Http::fake([
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [['id' => 101, 'private_net' => [['network' => 2, 'ip' => '10.2.0.4']]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $networks = $provider->privateNetworks(['101'], []);
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('2', $networks[0]->externalId);
+ $this->assertSame('10.2.0.4', $networks[0]->members[0]->ip);
+ }
+
+ public function test_hetzner_error_does_not_leak_credentials(): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/*' => Http::response(['error' => ['message' => 'forbidden']], 403),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ try {
+ $provider->privateNetworks(['101'], []);
+ $this->fail('Expected PrivateNetworkSyncError.');
+ } catch (PrivateNetworkSyncError $e) {
+ $this->assertTrue($e->isPermissionError());
+ $this->assertStringNotContainsString('secret-token', $e->getMessage());
+ $this->assertStringNotContainsString('secret-token', (string) $e);
+ $this->assertSame('hetzner', $e->provider);
+ $this->assertSame('hetzner-main', $e->profile);
+ }
+ }
+
+ public function test_digitalocean_uses_droplet_vpc_uuid_and_private_address(): void
+ {
+ Http::fake([
+ 'api.digitalocean.com/v2/vpcs*' => Http::response([
+ 'vpcs' => [
+ ['id' => 'vpc-abc', 'name' => 'default-lon1', 'ip_range' => '10.106.0.0/20', 'region' => 'lon1'],
+ ],
+ ]),
+ 'api.digitalocean.com/v2/droplets*' => Http::response([
+ 'droplets' => [
+ [
+ 'id' => 3164444,
+ 'vpc_uuid' => 'vpc-abc',
+ 'networks' => ['v4' => [
+ ['type' => 'public', 'ip_address' => '203.0.113.5'],
+ ['type' => 'private', 'ip_address' => '10.106.0.4'],
+ ]],
+ ],
+ ['id' => 999, 'vpc_uuid' => 'vpc-abc', 'networks' => ['v4' => []]],
+ ],
+ ]),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => DigitalOcean::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var DigitalOcean $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->privateNetworks(['3164444'], []);
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('vpc-abc', $networks[0]->externalId);
+ $this->assertSame('10.106.0.0/20', $networks[0]->cidr);
+ $this->assertCount(1, $networks[0]->members, 'Unmanaged droplets must be excluded.');
+ $this->assertSame('10.106.0.4', $networks[0]->members[0]->ip);
+ }
+
+ public function test_linode_reads_membership_from_subnets_and_ips_from_account_list(): void
+ {
+ Http::fake([
+ 'api.linode.com/v4/vpcs/ips*' => Http::response([
+ 'data' => [
+ ['linode_id' => 555, 'vpc_id' => 77, 'address' => '10.0.1.4', 'active' => true],
+ ['linode_id' => 555, 'vpc_id' => 77, 'address' => null, 'address_range' => '10.0.9.0/28'],
+ ],
+ 'page' => 1,
+ 'pages' => 1,
+ ]),
+ 'api.linode.com/v4/vpcs*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 77,
+ 'label' => 'prod-vpc',
+ 'region' => 'eu-west',
+ 'subnets' => [
+ ['ipv4' => '10.0.1.0/24', 'linodes' => [['id' => 555], ['id' => 888]]],
+ ],
+ ],
+ ],
+ 'page' => 1,
+ 'pages' => 1,
+ ]),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Linode::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var Linode $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->privateNetworks(['555'], []);
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('prod-vpc', $networks[0]->name);
+ $this->assertSame('10.0.1.0/24', $networks[0]->cidr);
+ $this->assertCount(1, $networks[0]->members);
+ $this->assertSame('10.0.1.4', $networks[0]->members[0]->ip);
+ }
+
+ public function test_linode_multi_subnet_vpc_reports_no_single_cidr(): void
+ {
+ Http::fake([
+ 'api.linode.com/v4/vpcs/ips*' => Http::response(['data' => [], 'page' => 1, 'pages' => 1]),
+ 'api.linode.com/v4/vpcs*' => Http::response([
+ 'data' => [[
+ 'id' => 77,
+ 'label' => 'multi',
+ 'subnets' => [
+ ['ipv4' => '10.0.1.0/24', 'linodes' => [['id' => 555]]],
+ ['ipv4' => '10.0.2.0/24', 'linodes' => []],
+ ],
+ ]],
+ 'page' => 1,
+ 'pages' => 1,
+ ]),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Linode::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var Linode $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->privateNetworks(['555'], []);
+
+ $this->assertNull(
+ $networks[0]->cidr,
+ 'A VPC with several subnets has no single CIDR; firewall rules fall back to member /32s.'
+ );
+ }
+
+ public function test_vultr_builds_cidr_from_v4_subnet_and_mask(): void
+ {
+ Http::fake([
+ 'api.vultr.com/v2/instances/*/vpcs*' => Http::response([
+ 'vpcs' => [['id' => 'vpc-1', 'ip_address' => '10.20.0.4', 'mac_address' => 'aa:bb']],
+ ]),
+ 'api.vultr.com/v2/vpcs*' => Http::response([
+ 'vpcs' => [[
+ 'id' => 'vpc-1',
+ 'description' => 'prod-vpc',
+ 'v4_subnet' => '10.20.0.0',
+ 'v4_subnet_mask' => 24,
+ 'region' => 'lhr',
+ ]],
+ ]),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Vultr::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var Vultr $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->privateNetworks(['inst-1'], []);
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('prod-vpc', $networks[0]->name);
+ $this->assertSame('10.20.0.0/24', $networks[0]->cidr);
+ $this->assertSame('10.20.0.4', $networks[0]->members[0]->ip);
+ }
+
+ public function test_vultr_stale_instance_id_does_not_abort_the_connection(): void
+ {
+ Http::fake([
+ 'api.vultr.com/v2/instances/gone/vpcs*' => Http::response(['error' => 'not found'], 404),
+ 'api.vultr.com/v2/instances/*/vpcs*' => Http::response([
+ 'vpcs' => [['id' => 'vpc-1', 'ip_address' => '10.20.0.4']],
+ ]),
+ 'api.vultr.com/v2/vpcs*' => Http::response([
+ 'vpcs' => [['id' => 'vpc-1', 'description' => 'prod', 'v4_subnet' => '10.20.0.0', 'v4_subnet_mask' => 24]],
+ ]),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Vultr::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var Vultr $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->privateNetworks(['gone', 'live'], []);
+
+ $this->assertCount(1, $networks, 'A deleted instance must not abort the whole connection.');
+ $this->assertSame('10.20.0.4', $networks[0]->members[0]->ip);
+ $this->assertSame('live', $networks[0]->members[0]->instanceId);
+ }
+
+ public function test_vultr_non_404_error_still_fails_the_connection(): void
+ {
+ Http::fake([
+ 'api.vultr.com/v2/*' => Http::response(['error' => 'boom'], 500),
+ ]);
+
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => Vultr::id(),
+ 'credentials' => ['token' => 'secret-token'],
+ ]);
+
+ /** @var Vultr $provider */
+ $provider = $connection->provider();
+
+ $this->expectException(PrivateNetworkSyncError::class);
+ $provider->privateNetworks(['live'], []);
+ }
+
+ public function test_aws_mapper_prefers_network_interface_and_name_tag(): void
+ {
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => AWS::id(),
+ 'credentials' => ['key' => 'k', 'secret' => 's'],
+ ]);
+
+ /** @var AWS $provider */
+ $provider = $connection->provider();
+
+ $reservations = [[
+ 'Instances' => [
+ [
+ 'InstanceId' => 'i-123',
+ 'VpcId' => 'vpc-top',
+ 'PrivateIpAddress' => '172.31.9.9',
+ 'NetworkInterfaces' => [
+ ['VpcId' => 'vpc-eni', 'PrivateIpAddress' => '172.31.0.5'],
+ ],
+ ],
+ ['InstanceId' => 'i-unmanaged', 'VpcId' => 'vpc-eni', 'PrivateIpAddress' => '172.31.0.6'],
+ ],
+ ]];
+
+ $vpcs = [[
+ 'VpcId' => 'vpc-eni',
+ 'CidrBlock' => '172.31.0.0/16',
+ 'Tags' => [['Key' => 'Name', 'Value' => 'production']],
+ ]];
+
+ $networks = $provider->mapPrivateNetworks($reservations, $vpcs, ['i-123'], 'eu-west-2');
+
+ $this->assertCount(1, $networks);
+ $this->assertSame('vpc-eni', $networks[0]->externalId);
+ $this->assertSame('production', $networks[0]->name);
+ $this->assertSame('172.31.0.0/16', $networks[0]->cidr);
+ $this->assertSame('eu-west-2', $networks[0]->region);
+ $this->assertCount(1, $networks[0]->members);
+ $this->assertSame('172.31.0.5', $networks[0]->members[0]->ip);
+ }
+
+ public function test_aws_mapper_falls_back_to_vpc_id_when_untagged(): void
+ {
+ $connection = ServerProvider::factory()->create([
+ 'user_id' => $this->user->id,
+ 'provider' => AWS::id(),
+ 'credentials' => ['key' => 'k', 'secret' => 's'],
+ ]);
+
+ /** @var AWS $provider */
+ $provider = $connection->provider();
+
+ $networks = $provider->mapPrivateNetworks(
+ [['Instances' => [['InstanceId' => 'i-1', 'VpcId' => 'vpc-9', 'PrivateIpAddress' => '10.0.0.2']]]],
+ [['VpcId' => 'vpc-9', 'CidrBlock' => '10.0.0.0/16']],
+ ['i-1'],
+ 'us-east-1',
+ );
+
+ $this->assertSame('vpc-9', $networks[0]->name);
+ }
+
+ /**
+ * @return array
+ */
+ public static function untrustedAddresses(): array
+ {
+ return [
+ 'command substitution' => ['10.0.0.2 $(id)'],
+ 'command chaining' => ['10.0.0.2; rm -rf /'],
+ 'backticks' => ['`whoami`'],
+ 'newline injection' => ["10.0.0.2\nsudo ufw disable"],
+ 'ipv6' => ['fd00::1'],
+ 'not an address' => ['not-an-ip'],
+ 'empty' => [''],
+ ];
+ }
+
+ #[DataProvider('untrustedAddresses')]
+ public function test_member_addresses_that_are_not_plain_ipv4_are_dropped(?string $address): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/networks*' => Http::response([
+ 'networks' => [['id' => 1, 'name' => 'n', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [['id' => 101, 'private_net' => [['network' => 1, 'ip' => $address]]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $networks = $provider->privateNetworks(['101'], []);
+
+ $this->assertNull(
+ $networks[0]->members[0]->ip,
+ 'Member addresses reach an unquoted shell interpolation in the ufw template.'
+ );
+ }
+
+ public function test_valid_member_address_survives_normalisation(): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/networks*' => Http::response([
+ 'networks' => [['id' => 1, 'name' => 'n', 'ip_range' => '10.0.0.0/16', 'servers' => [101]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [['id' => 101, 'private_net' => [['network' => 1, 'ip' => '10.0.0.42']]]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $this->assertSame('10.0.0.42', $provider->privateNetworks(['101'], [])[0]->members[0]->ip);
+ }
+
+ public function test_ipv6_and_malformed_cidrs_are_dropped_rather_than_stored_as_zero(): void
+ {
+ Http::fake([
+ 'api.hetzner.cloud/v1/networks*' => Http::response([
+ 'networks' => [
+ ['id' => 1, 'name' => 'v6', 'ip_range' => 'fd00::/64', 'servers' => [101]],
+ ['id' => 2, 'name' => 'junk', 'ip_range' => 'not-a-cidr', 'servers' => [101]],
+ ['id' => 3, 'name' => 'noprefix', 'ip_range' => '10.0.0.0', 'servers' => [101]],
+ ['id' => 4, 'name' => 'ok', 'ip_range' => '10.5.0.0/16', 'servers' => [101]],
+ ],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ 'api.hetzner.cloud/v1/servers*' => Http::response([
+ 'servers' => [['id' => 101, 'private_net' => []]],
+ 'meta' => ['pagination' => ['next_page' => null]],
+ ]),
+ ]);
+
+ /** @var Hetzner $provider */
+ $provider = $this->hetznerConnection()->provider();
+
+ $cidrs = array_map(fn ($n): ?string => $n->cidr, $provider->privateNetworks(['101'], []));
+
+ $this->assertSame([null, null, null, '10.5.0.0/16'], $cidrs);
+ }
+}
From 3e5959204264be86bc7dcb0c8307dc8e4ecd05a1 Mon Sep 17 00:00:00 2001
From: Richard Anderson
Date: Fri, 24 Jul 2026 21:12:30 +0100
Subject: [PATCH 09/27] feat: various fixes + docs
---
app/Actions/Network/CreateNetwork.php | 3 +-
.../Network/RemoveServerFromNetwork.php | 1 -
app/Http/Controllers/NetworkController.php | 32 ++++-
app/Jobs/Network/PollPeerHandshakesJob.php | 1 -
app/Models/Network.php | 3 +-
app/Models/ServerNetworkRule.php | 2 +-
app/ServerProviders/AWS.php | 1 -
docs/4.x/README.md | 11 ++
docs/4.x/automation.md | 5 +
docs/4.x/networks/create.md | 73 +++++++++++
docs/4.x/networks/firewall.md | 54 ++++++++
docs/4.x/networks/overview.md | 72 +++++++++++
docs/4.x/networks/peers.md | 122 ++++++++++++++++++
docs/4.x/networks/provider-networks.md | 95 ++++++++++++++
docs/4.x/networks/servers.md | 59 +++++++++
docs/4.x/servers/firewall.md | 4 +
docs/4.x/servers/network.md | 4 +
docs/4.x/settings/server-providers.md | 2 +
resources/js/components/app-sidebar.tsx | 4 +-
resources/js/layouts/database/layout.tsx | 4 +-
resources/js/layouts/network/layout.tsx | 7 +-
resources/js/lib/utils.ts | 4 +
resources/js/pages/backups/files.tsx | 4 +-
resources/js/pages/backups/index.tsx | 5 +-
.../networks/components/create-network.tsx | 5 +-
.../components/edit-network-server.tsx | 3 +-
.../pages/networks/components/log-columns.tsx | 23 ++++
resources/js/pages/networks/firewall.tsx | 18 ++-
resources/js/pages/networks/index.tsx | 8 +-
resources/js/pages/networks/logs.tsx | 39 ++++++
resources/js/pages/networks/peers.tsx | 25 ++--
resources/js/pages/networks/servers.tsx | 8 +-
resources/js/pages/networks/settings.tsx | 20 +--
resources/js/pages/networks/show.tsx | 31 ++---
.../redirects/components/edit-redirect.tsx | 10 +-
.../components/redirect-form-fields.tsx | 7 +-
resources/js/pages/server-network/index.tsx | 2 +-
.../js/pages/services/components/install.tsx | 26 ++--
resources/js/pages/site-tooling/index.tsx | 2 +-
resources/views/wireguard/peer-conf.blade.php | 12 +-
tests/Feature/NetworkFirewallTest.php | 5 +-
tests/Feature/NetworkPeerTest.php | 33 ++++-
tests/Feature/NetworkSyncTest.php | 1 -
tests/Feature/NetworkTest.php | 44 ++++++-
44 files changed, 779 insertions(+), 115 deletions(-)
create mode 100644 docs/4.x/networks/create.md
create mode 100644 docs/4.x/networks/firewall.md
create mode 100644 docs/4.x/networks/overview.md
create mode 100644 docs/4.x/networks/peers.md
create mode 100644 docs/4.x/networks/provider-networks.md
create mode 100644 docs/4.x/networks/servers.md
create mode 100644 resources/js/pages/networks/components/log-columns.tsx
create mode 100644 resources/js/pages/networks/logs.tsx
diff --git a/app/Actions/Network/CreateNetwork.php b/app/Actions/Network/CreateNetwork.php
index 49ca4630a..c76a4438a 100644
--- a/app/Actions/Network/CreateNetwork.php
+++ b/app/Actions/Network/CreateNetwork.php
@@ -12,6 +12,7 @@
use App\Models\Project;
use App\Models\Server;
use App\Support\Cidr;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -70,7 +71,7 @@ private function buildWireGuard(Project $project, array $input): Network
Project::query()->whereKey($project->id)->lockForUpdate()->first();
- /** @var \Illuminate\Support\Collection $members */
+ /** @var Collection $members */
$members = Server::query()
->where('project_id', $project->id)
->whereIn('id', $input['servers'])
diff --git a/app/Actions/Network/RemoveServerFromNetwork.php b/app/Actions/Network/RemoveServerFromNetwork.php
index a9d25f17e..1f0abef48 100644
--- a/app/Actions/Network/RemoveServerFromNetwork.php
+++ b/app/Actions/Network/RemoveServerFromNetwork.php
@@ -4,7 +4,6 @@
use App\Enums\NetworkServerStatus;
use App\Enums\NetworkType;
-use App\Models\Network;
use App\Models\NetworkServer;
class RemoveServerFromNetwork
diff --git a/app/Http/Controllers/NetworkController.php b/app/Http/Controllers/NetworkController.php
index 9a989473b..a061f788b 100644
--- a/app/Http/Controllers/NetworkController.php
+++ b/app/Http/Controllers/NetworkController.php
@@ -17,6 +17,8 @@
use App\Models\Server;
use App\Tables\Networks\NetworkServerTable;
use App\Tables\NetworkTable;
+use Illuminate\Contracts\Pagination\Paginator;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -65,22 +67,28 @@ public function show(Network $network): Response
$network->loadCount(['servers', 'peers', 'firewallRules']);
- $logs = QueryBuilder::for($network->serverLogs()->with('server'))
- ->searchableFields(['name'])
- ->sortable('created_at', 'desc')
- ->query()
- ->simplePaginate(config('web.pagination_size'));
-
return Inertia::render('networks/show', [
'stats' => [
'servers' => $network->servers_count,
'peers' => $network->peers_count,
'firewall_rules' => $network->firewall_rules_count,
],
- 'logs' => ServerLogResource::collection($logs),
+ 'logs' => ServerLogResource::collection($this->logsQuery($network)),
]);
}
+ /**
+ * @return Paginator
+ */
+ private function logsQuery(Network $network): Paginator
+ {
+ return QueryBuilder::for($network->serverLogs()->with('server'))
+ ->searchableFields(['name'])
+ ->sortable('created_at', 'desc')
+ ->query()
+ ->simplePaginate(config('web.pagination_size'));
+ }
+
#[Get('/{network}/servers', name: 'networks.servers')]
public function servers(Network $network): Response
{
@@ -117,6 +125,16 @@ private function memberIpsPayload(Network $network): array
->all();
}
+ #[Get('/{network}/logs', name: 'networks.logs')]
+ public function logs(Network $network): Response
+ {
+ $this->authorize('view', $network);
+
+ return Inertia::render('networks/logs', [
+ 'logs' => ServerLogResource::collection($this->logsQuery($network)),
+ ]);
+ }
+
#[Get('/{network}/settings', name: 'networks.settings')]
public function settings(Network $network): Response
{
diff --git a/app/Jobs/Network/PollPeerHandshakesJob.php b/app/Jobs/Network/PollPeerHandshakesJob.php
index e2f735794..5731fec19 100644
--- a/app/Jobs/Network/PollPeerHandshakesJob.php
+++ b/app/Jobs/Network/PollPeerHandshakesJob.php
@@ -9,7 +9,6 @@
use App\Events\SocketEvent;
use App\Http\Resources\NetworkPeerResource;
use App\Models\Network;
-use App\Models\NetworkPeer;
use App\Models\NetworkServer;
use App\Models\ServerLog;
use App\Models\Service;
diff --git a/app/Models/Network.php b/app/Models/Network.php
index 544c11212..fc5a36ace 100644
--- a/app/Models/Network.php
+++ b/app/Models/Network.php
@@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Carbon;
/**
* @property int $id
@@ -24,7 +25,7 @@
* @property ?int $server_provider_id
* @property ?string $external_id
* @property ?string $region
- * @property ?\Illuminate\Support\Carbon $last_synced_at
+ * @property ?Carbon $last_synced_at
* @property Project $project
* @property ?ServerProvider $serverProvider
* @property Collection $servers
diff --git a/app/Models/ServerNetworkRule.php b/app/Models/ServerNetworkRule.php
index 51d57927c..24396c95c 100644
--- a/app/Models/ServerNetworkRule.php
+++ b/app/Models/ServerNetworkRule.php
@@ -61,7 +61,7 @@ class ServerNetworkRule extends AbstractModel
public function scopeOrdered(Builder $query): void
{
$query
- ->orderByRaw("case when kind = ? then 0 else 1 end", [ServerNetworkRuleKind::HANDSHAKE->value])
+ ->orderByRaw('case when kind = ? then 0 else 1 end', [ServerNetworkRuleKind::HANDSHAKE->value])
->orderBy('network_id')
->orderBy('id');
}
diff --git a/app/ServerProviders/AWS.php b/app/ServerProviders/AWS.php
index 29fc874c6..30e2d2c14 100755
--- a/app/ServerProviders/AWS.php
+++ b/app/ServerProviders/AWS.php
@@ -6,7 +6,6 @@
use App\DTOs\PrivateNetworkMemberDTO;
use App\Enums\OperatingSystem;
use App\Exceptions\CouldNotConnectToProvider;
-use App\Exceptions\PrivateNetworkSyncError;
use App\Facades\Notifier;
use App\Notifications\FailedToDeleteServerFromProvider;
use Aws\Ec2\Ec2Client;
diff --git a/docs/4.x/README.md b/docs/4.x/README.md
index c56f73256..ace8e8539 100644
--- a/docs/4.x/README.md
+++ b/docs/4.x/README.md
@@ -24,6 +24,17 @@ databases, deploy sites, and more — all from a clean dashboard.
| [Configuration](getting-started/configuration.md) | Configure your Vito instance |
| [Update](getting-started/update.md) | Keep your instance up to date |
+## Networks
+
+| Page | What's in it |
+| --- | --- |
+| [Overview](networks/overview.md) | Private networking between your servers |
+| [Creating a Network](networks/create.md) | Create a WireGuard or custom network |
+| [Provider Networks](networks/provider-networks.md) | Discover and sync VPCs from your cloud provider |
+| [Network Servers](networks/servers.md) | Manage the servers in a network |
+| [Peers](networks/peers.md) | Connect laptops and other devices |
+| [Network Firewall](networks/firewall.md) | Control traffic between members |
+
## Servers
| Page | What's in it |
diff --git a/docs/4.x/automation.md b/docs/4.x/automation.md
index 6418e05d7..1501ce51a 100644
--- a/docs/4.x/automation.md
+++ b/docs/4.x/automation.md
@@ -20,8 +20,13 @@ These tasks are driven by Vito's own scheduler, which runs on your Vito instance
| Wildcard SSL renewal | Daily | Renews wildcard Let's Encrypt [certificates](./servers/ssl.md) within 30 days of expiry. |
| SSL expiry check | Daily | Flags certificates that are expiring soon and notifies you. |
| GitHub App sync | Every 4 hours | Syncs [GitHub App](./admin/github-app.md) installations as a fallback to webhooks. |
+| Network reconciliation | Every 3 minutes | Re-applies [network](./networks/overview.md) configuration to servers that are pending or failed, and checks peer connectivity. |
| Database maintenance | Daily | Vacuums Vito's own database to keep it fast. |
+:::info
+[Provider networks](./networks/provider-networks.md) are **not** on this list. Vito only queries your cloud provider for private networks when you click **Sync**, so it never calls provider APIs in the background.
+:::
+
## Per-Server Automatic Updates
Automatic OS package updates are opt-in per server. Enable them from the server's [Security](./servers/security.md#automatic-updates) page and set the schedule. You'll get a notification when an update completes.
diff --git a/docs/4.x/networks/create.md b/docs/4.x/networks/create.md
new file mode 100644
index 000000000..73792eb67
--- /dev/null
+++ b/docs/4.x/networks/create.md
@@ -0,0 +1,73 @@
+# Creating a Network
+
+## Introduction
+
+Click **Create** on the Networks page to create a network. You can create **WireGuard** and **Custom** networks by hand.
+
+**Provider** networks cannot be created here — they appear automatically when Vito discovers them at your cloud provider. See [Provider Networks](provider-networks.md).
+
+## Creating a WireGuard Network
+
+Choose **WireGuard** as the type and fill in the following fields.
+
+### Name
+
+A name for the network, unique within the project.
+
+### Address Pool
+
+The range Vito allocates the network's address block from:
+
+- **CGNAT (100.64.0.0/10)** — the default. This range is reserved for carrier-grade NAT and is almost never used on a server's own interfaces, so it very rarely collides with anything.
+- **Private (RFC1918)** — allocates from `10.0.0.0/8` or `192.168.0.0/16`.
+
+:::info
+The RFC1918 option deliberately avoids `172.16.0.0/12`, because Docker and several cloud providers use it by default. Vito also skips ranges already used by your servers and a blocklist of common provider ranges.
+:::
+
+### Block Size
+
+The prefix length of the block to allocate, between `/16` and `/28`. The default `/24` gives you 253 usable addresses for servers and peers combined.
+
+### Primary Server
+
+The first server to join the network. You can add more from the [Servers](servers.md) tab once the network exists.
+
+### Listen Port
+
+The UDP port WireGuard listens on, `51820` by default. If a selected server is already in another WireGuard network using that port, Vito picks the next free one.
+
+Vito opens this port in the server's firewall automatically, restricted to the other members of the network.
+
+## Creating a Custom Network
+
+Choose **Custom** as the type. Custom networks describe connectivity that already exists, so Vito does not configure anything on the servers.
+
+### CIDR
+
+Optional. The address range of the existing private network, for example `10.0.0.0/24`.
+
+If you provide it, [firewall rules](firewall.md) are scoped to the whole range. If you leave it empty, rules are scoped to each member's individual address instead, which is tighter.
+
+### Primary Server and Private IP
+
+Select the first server, then choose which of its private IP addresses belongs to this network.
+
+The list contains the private addresses Vito has discovered for that server. If the address you need is missing, click the refresh action to re-detect the server's addresses, or add it from the server's [Network](../servers/network.md) page.
+
+:::warning
+Each private IP address can belong to only one network member across your whole instance. If an address is already used by another network, pick a different one.
+:::
+
+## After Creation
+
+Vito creates the network and starts configuring it:
+
+- **WireGuard networks** — WireGuard is installed on each server if needed, keys are generated, and the tunnel is brought up. The network moves to `active` once every server is configured.
+- **Custom networks** — members are marked active immediately, and the network's firewall rules are applied.
+
+Every new network is seeded with a single **Allow all** firewall rule so members can reach each other on any port. Tighten this from the [Firewall](firewall.md) tab.
+
+:::info
+If a server is offline when you create the network, its member stays `pending` and Vito configures it automatically once the server is reachable again.
+:::
diff --git a/docs/4.x/networks/firewall.md b/docs/4.x/networks/firewall.md
new file mode 100644
index 000000000..70a4039a5
--- /dev/null
+++ b/docs/4.x/networks/firewall.md
@@ -0,0 +1,54 @@
+# Network Firewall
+
+## Introduction
+
+A network's **Firewall** tab controls what traffic is allowed between its members. Vito turns each rule into real firewall rules on every server in the network, using the server's own [UFW firewall](../servers/firewall.md).
+
+This is the main reason to define a network even when the private connectivity already exists: you describe the rule once, for the network, and Vito keeps it applied on every member.
+
+## The Default Rule
+
+Every new network starts with a single **Allow all** rule, which lets members reach each other on any port and protocol. Delete it and add narrower rules when you want to lock things down.
+
+## Creating a Rule
+
+Click **Create** and provide:
+
+### Name
+
+A label for the rule, for example `mysql` or `redis`.
+
+### Protocol
+
+The protocol to allow — TCP or UDP. Leave it empty to allow any protocol.
+
+### Port
+
+The port to allow, for example `3306`. Leave it empty to allow any port.
+
+## How Rules Are Applied
+
+Vito translates each network rule into a rule on every member's firewall, scoped to the other members of that network:
+
+- On **WireGuard** networks, and on **custom** networks that have a CIDR, rules are scoped to the network's address range.
+- On **custom** networks without a CIDR, and on all **provider** networks, rules are scoped to each member's individual address instead. This is tighter — the port is opened only to the specific servers in the network, not to everything sharing the range.
+
+WireGuard networks also get an automatic rule opening the tunnel's listen port to the other members, so the tunnel can be established. This rule is managed by Vito and is not affected when you delete the **Allow all** rule.
+
+:::info
+Network rules appear on each server's own [Firewall](../servers/firewall.md) page as managed rules. They're shown there for visibility but are edited from the network, so they stay consistent across every member.
+:::
+
+## Servers Without a Firewall
+
+A server with no firewall service installed still shows in the network, but nothing is enforced on it — the Servers tab shows whether each member has a firewall.
+
+If you install UFW on a server later, Vito applies the network's rules as part of the installation.
+
+## Deleting a Rule
+
+Deleting a rule removes it from every member's firewall.
+
+:::warning
+Deleting the **Allow all** rule locks the network down to only the ports you've explicitly allowed. Make sure the services your servers rely on — a database port, for example — have their own rules first.
+:::
diff --git a/docs/4.x/networks/overview.md b/docs/4.x/networks/overview.md
new file mode 100644
index 000000000..be50410d8
--- /dev/null
+++ b/docs/4.x/networks/overview.md
@@ -0,0 +1,72 @@
+# Networks
+
+## Introduction
+
+**Networks** let your servers talk to each other over private addresses instead of the public internet. A network belongs to a project, and every server in it can reach the others on an address that is never exposed publicly.
+
+This is useful when you want a web server to reach a database server, or a group of application servers to share a cache, without opening those services to the world.
+
+Networks live at the top of the sidebar, above **Servers**.
+
+## Network Types
+
+Vito supports three kinds of network. The type is chosen when the network is created and cannot be changed afterwards.
+
+| Type | Membership | Addresses | Best for |
+| --- | --- | --- | --- |
+| **WireGuard** | You choose the servers | Allocated by Vito | Servers spread across different providers or regions |
+| **Custom** | You choose the servers and their IPs | You pick an existing private IP per server | A private network you set up yourself |
+| **Provider** | Synced from your cloud provider | Reported by the provider | Mirroring a VPC you already run at a cloud provider |
+
+### WireGuard
+
+Vito builds an encrypted [WireGuard](https://www.wireguard.com/) tunnel between the servers you select. It installs WireGuard, generates a key pair per server, allocates an address block, and writes the configuration to each machine.
+
+Because the tunnel is encrypted and routed over the public internet, a WireGuard network works even when the servers are at different providers, in different regions, or behind NAT. It is the only type that supports [Peers](peers.md).
+
+See [Creating a Network](create.md) to set one up.
+
+### Custom
+
+A custom network describes private connectivity that already exists — for example two servers at the same provider that can already reach each other on a private interface. Vito does not configure anything on the machines; you tell it which servers are in the network and which of their private IP addresses to use.
+
+Vito still manages the [firewall rules](firewall.md) for the network, which is the main reason to define one.
+
+### Provider
+
+A provider network mirrors a private network (a VPC) that exists at your cloud provider. Vito discovers it by querying the provider's API, and keeps the membership in step with reality.
+
+Provider networks are **read-only**: you cannot add, edit, or remove servers by hand, and you cannot delete the network while its provider connection still exists. See [Provider Networks](provider-networks.md).
+
+## Statuses
+
+A network shows one of the following statuses, derived from the state of its servers:
+
+| Status | Meaning |
+| --- | --- |
+| `creating` | The network has just been created and is being set up. |
+| `syncing` | At least one server is still being configured. |
+| `active` | Every server is configured and in sync. |
+| `failed` | At least one server failed to configure. Vito retries automatically. |
+| `deleting` | The network is being torn down. |
+
+Each server in the network carries its own status — `pending`, `updating`, `active`, `failed`, or `leaving` — which you can see on the [Servers](servers.md) tab.
+
+:::info
+Vito re-checks networks every three minutes and retries any server that is pending or failed, so a server that was offline when a change was made will pick it up once it is reachable again. See [Automation & Scheduled Tasks](../automation.md).
+:::
+
+## Network Pages
+
+Once you open a network you'll find these tabs:
+
+| Tab | What's in it |
+| --- | --- |
+| **Overview** | Status, address range, and counts for servers, peers, and firewall rules |
+| **Servers** | The servers in the network and their addresses |
+| **Peers** | Devices such as laptops or CI runners (WireGuard only) |
+| **Firewall** | Rules controlling what traffic is allowed inside the network |
+| **Logs** | Activity recorded against the network and the servers it runs on |
+| **Settings** | Rename the network, review its details, and delete it |
+
+The **Peers** tab is only available on WireGuard networks. On custom and provider networks the peer count on the Overview shows `N/A`.
diff --git a/docs/4.x/networks/peers.md b/docs/4.x/networks/peers.md
new file mode 100644
index 000000000..e430385e1
--- /dev/null
+++ b/docs/4.x/networks/peers.md
@@ -0,0 +1,122 @@
+# Peers
+
+## Introduction
+
+**Peers** are devices that join a network without being a server Vito manages — a laptop, a CI runner, or an office router. A peer gets an address inside the network and can reach its servers privately.
+
+Peers are only available on **WireGuard** networks. On custom and provider networks the tab is disabled, since Vito doesn't control the tunnel.
+
+## Adding a Peer
+
+Click **Add peer** and give it a name. Vito allocates the next free address from the network's block and adds the peer to every member's configuration.
+
+You have two options for keys:
+
+- **Let Vito generate the keys.** The simplest option. Vito creates the key pair and can show you a ready-to-use configuration file.
+- **Bring your own public key.** Provide a public key you generated yourself. Vito never sees the private key, which means the device's key material never leaves it.
+
+## Getting the Configuration
+
+Use **Show config** to display the WireGuard configuration for a peer. Drop it into the WireGuard client on the device.
+
+For peers where you supplied your own public key, Vito cannot know the private key, so the configuration contains `REPLACE_WITH_YOUR_PRIVATE_KEY` on the `PrivateKey` line. Substitute the key that stays on the device.
+
+:::warning
+A peer's configuration contains the key material that grants access to your private network. Treat it like a password.
+:::
+
+## Connecting from macOS
+
+Here's the full path from adding a peer to reaching your servers from a Mac.
+
+### 1. Install the WireGuard App
+
+Install **WireGuard** from the [Mac App Store](https://apps.apple.com/us/app/wireguard/id1451685025). It's the official client and is free.
+
+If you prefer the command line, `brew install wireguard-tools` gives you `wg` and `wg-quick` instead. The steps below use the app.
+
+### 2. Add the Peer in Vito
+
+On the network's **Peers** tab, click **Add peer** and give it a name such as `macbook`. Let Vito generate the keys — this is the simplest route and means the configuration you get is complete and ready to use.
+
+### 3. Copy the Configuration
+
+Use **Show config** on the peer you just created. You'll get a WireGuard configuration that looks roughly like this:
+
+```ini
+[Interface]
+Address = 100.64.0.5/32
+PrivateKey =
+
+[Peer]
+PublicKey =
+AllowedIPs = 100.64.0.0/24
+Endpoint = 203.0.113.10:51820
+PersistentKeepalive = 25
+
+[Peer]
+PublicKey =
+AllowedIPs = 100.64.0.3/32
+Endpoint = 203.0.113.11:51820
+PersistentKeepalive = 25
+```
+
+There is one `[Peer]` block per server in the network. The first one carries the whole network range in `AllowedIPs` so traffic for the network is routed through the tunnel; the rest are scoped to their own address.
+
+### 4. Create the Tunnel
+
+In the WireGuard app, click the **+** button in the bottom-left and choose **Add empty tunnel…**, then paste the configuration over the placeholder contents. Give the tunnel a name and click **Save**.
+
+macOS will ask for permission to add VPN configurations the first time — approve it.
+
+:::info
+You can also save the configuration as a file ending in `.conf` and use **Import tunnel(s) from file…** instead. The file name becomes the tunnel name.
+:::
+
+### 5. Activate and Test
+
+Select the tunnel and click **Activate**. The status changes to *Active* once the handshake succeeds.
+
+To confirm it's working, ping a server on its network address — you'll find it on the network's [Servers](servers.md) tab:
+
+```shell
+ping 100.64.0.2
+```
+
+Back in Vito, the peer's row on the **Peers** tab shows its last handshake once it has connected.
+
+### Bringing Your Own Key
+
+If you'd rather your private key never leave your Mac, generate a key pair first:
+
+```shell
+wg genkey | tee privatekey | wg pubkey > publickey
+```
+
+Give Vito the contents of `publickey` when adding the peer, then paste the contents of `privatekey` into the `PrivateKey` line of the configuration Vito shows you.
+
+### Troubleshooting
+
+- **The tunnel activates but nothing is reachable.** Check that the servers you want to reach are `active` on the network's [Servers](servers.md) tab, and that the network's [firewall rules](firewall.md) allow the port you're using.
+- **No handshake at all.** The peer needs to reach the server's `Endpoint` address and UDP port from wherever you are. Some restrictive networks block outbound UDP.
+- **It worked and then stopped.** If the peer's keys were regenerated in Vito, the old configuration is dead — copy the new one.
+
+For anything deeper — split tunnelling, routing all traffic through the network, or running WireGuard from the command line — see the [official WireGuard documentation](https://www.wireguard.com/quickstart/).
+
+## Regenerating Keys
+
+**Regenerate keys** issues a new key pair for a peer. The old configuration stops working immediately, so the device must be reconfigured with the new one. Use this if a device is lost or its key may have been exposed.
+
+## Disabling a Peer
+
+**Disable** removes the peer from every member's configuration without deleting it. Its address stays reserved, so enabling it again restores access on the same address.
+
+This is a quick way to cut off a device temporarily without having to reissue its configuration afterwards.
+
+## Removing a Peer
+
+**Remove** deletes the peer and takes it out of every member's configuration. Its address is released back into the pool.
+
+## Connection Status
+
+Vito periodically checks each member for the last handshake with every peer, so the peers list shows whether a device is currently connected and when it was last seen.
diff --git a/docs/4.x/networks/provider-networks.md b/docs/4.x/networks/provider-networks.md
new file mode 100644
index 000000000..63b58f326
--- /dev/null
+++ b/docs/4.x/networks/provider-networks.md
@@ -0,0 +1,95 @@
+# Provider Networks
+
+## Introduction
+
+If your servers already sit inside a private network at your cloud provider — a VPC — Vito can discover it and mirror it as a **provider network**. You get the same [firewall management](firewall.md) and visibility as any other network, without describing the topology by hand.
+
+Provider networks are created, updated, and removed entirely by syncing. They are read-only in the dashboard.
+
+## Supported Providers
+
+Vito reads private networks from the following [server provider](../settings/server-providers.md) connections:
+
+| Provider | Product |
+| --- | --- |
+| AWS | VPC |
+| DigitalOcean | VPC |
+| Hetzner | Cloud Networks |
+| Linode | VPC |
+| Vultr | VPC |
+
+Custom servers — those you added by IP address rather than through a provider connection — have no API to query and are skipped.
+
+:::info
+Vito only reads. It never creates, changes, or deletes a network at your provider.
+:::
+
+## Syncing
+
+### Syncing Every Network
+
+Click **Sync** on the Networks page. Vito looks at every server in the current project, groups them by the provider connection that created them, and asks each provider which private networks those servers belong to.
+
+For every network it finds, Vito will:
+
+- **Create** a provider network if it isn't in Vito yet.
+- **Add or remove servers** so membership matches the provider.
+- **Update addresses** when a server's private IP has changed.
+- **Delete** the network if it no longer exists at the provider.
+
+Syncing runs in the background. The networks list updates as it progresses.
+
+### Syncing One Network
+
+Open a provider network and click **Sync** on its **Servers** tab to reconcile just that network. If the network no longer exists at the provider, it is removed from Vito.
+
+### Sync Is Manual
+
+Vito does not sync provider networks on a schedule — it only happens when you click **Sync**. Changes you make directly in your provider's console will not appear in Vito until you do.
+
+## What Gets Imported
+
+Vito is deliberately conservative about what it pulls in.
+
+- **Only networks containing your servers.** A VPC is imported only if at least one of its instances is a server Vito manages in the current project. Unrelated VPCs in your cloud account are ignored.
+- **Only servers Vito manages.** Instances that Vito doesn't know about are ignored — they get no entry on the Servers tab and are not covered by the network's firewall rules.
+- **Only the current project.** Syncing affects the project you're viewing, using the provider connections its servers were created with.
+
+:::warning
+Because instances Vito doesn't manage are ignored, a provider network's firewall rules only cover the members you can see. Other machines in the same VPC are unaffected by them.
+:::
+
+## What You Can and Can't Change
+
+Membership belongs to the provider, so Vito won't let you edit it:
+
+| Action | Provider network |
+| --- | --- |
+| Add a server | Not available — attach the instance at your provider, then sync |
+| Change a server's IP | Not available — the provider reports it |
+| Remove a server | Not available — detach the instance at your provider, then sync |
+| Manage firewall rules | **Available** |
+| Rename the network | **Available** |
+| Delete the network | Not available while its provider connection exists |
+
+Renaming is safe: Vito sets the name once, when the network is first imported, and never overwrites it afterwards. If you rename the VPC at your provider, your name in Vito stays.
+
+## Deleting
+
+You cannot delete a provider network while the connection it came from still exists — it would simply be re-imported on the next sync. Delete the network at your provider and sync instead.
+
+The one exception is an **orphaned** network: if you delete the [server provider connection](../settings/server-providers.md) it came from, Vito can no longer sync it, so the network becomes deletable. Its Settings page shows a notice explaining this.
+
+## Addresses and Firewall Rules
+
+Provider networks use the private addresses reported by the provider. Their [firewall rules](firewall.md) are scoped to each member's individual address rather than the whole VPC range, so a rule only ever opens a port to the specific servers Vito manages.
+
+## Troubleshooting
+
+**Nothing was imported.** Check that your servers were created through a provider connection, and that at least one of them is attached to a network at the provider.
+
+**A network didn't sync.** If Vito can't reach a provider — an invalid token, a permissions error, or a rate limit — it skips that connection and leaves its networks untouched rather than risk deleting them. Try again, and check the connection under [Server Providers](../settings/server-providers.md).
+
+**Permission errors.** Reading private networks needs a token with permission to list networks and instances. Some providers scope this separately from the permissions Vito needed when the connection was first made — for example DigitalOcean requires the `vpc:read` scope. A connection that works for creating servers may still need its token updated.
+
+**A duplicate-looking network appeared.** If a discovered network's name clashes with an existing one in the project, Vito appends a suffix (for example `default-2`) rather than failing.
diff --git a/docs/4.x/networks/servers.md b/docs/4.x/networks/servers.md
new file mode 100644
index 000000000..89bb461ea
--- /dev/null
+++ b/docs/4.x/networks/servers.md
@@ -0,0 +1,59 @@
+# Network Servers
+
+## Introduction
+
+The **Servers** tab lists the servers that belong to a network, the address each one uses inside it, and whether the server has a firewall installed.
+
+## Adding a Server
+
+Click **Add server** and choose a server from the project.
+
+On a **WireGuard** network, Vito allocates the next free address from the network's block, generates a key pair, installs WireGuard if needed, and updates every other member so they know about the new one.
+
+On a **Custom** network, you also choose which of the server's private IP addresses to use.
+
+Adding a server is not available on [provider networks](provider-networks.md) — attach the instance at your cloud provider and sync instead.
+
+:::info
+A server can belong to several networks at once. On WireGuard networks, each one gets its own interface and its own listen port.
+:::
+
+## Server Status
+
+Each member shows its own status:
+
+| Status | Meaning |
+| --- | --- |
+| `pending` | Waiting to be configured — usually because the server is offline |
+| `updating` | Vito is applying the configuration now |
+| `active` | Configured and in sync |
+| `failed` | Configuration failed; Vito retries automatically |
+| `leaving` | Being removed from the network |
+
+Vito retries pending and failed members every three minutes, so a server that was unreachable is picked up once it comes back.
+
+## Changing a Server's IP
+
+On a **Custom** network, use **Edit** to change which private IP address the server uses in this network. Vito re-applies the network's firewall rules with the new address.
+
+This is not available on WireGuard networks, where Vito owns the addressing, or on provider networks, where the provider does.
+
+## Regenerating Configuration
+
+On a **WireGuard** network, **Regenerate** re-applies the network configuration to a single server. Use it if a server's tunnel has drifted or you want to force a rewrite.
+
+## Syncing the Whole Network
+
+**Sync** re-applies the configuration to every server in the network. On a [provider network](provider-networks.md) it does something different: it queries your cloud provider and reconciles membership.
+
+## Removing a Server
+
+**Remove** takes the server out of the network. Vito tears the configuration down on that machine, removes the network's firewall rules from it, and updates the remaining members so they stop routing to it.
+
+If the server is unreachable — for example it has already been destroyed — Vito retries for a few minutes and then removes the membership anyway so the network can settle.
+
+Removing a server is not available on provider networks.
+
+:::warning
+Removing a server drops its private connectivity immediately. Make sure nothing is relying on that address first.
+:::
diff --git a/docs/4.x/servers/firewall.md b/docs/4.x/servers/firewall.md
index 0194a9176..2f70cb274 100644
--- a/docs/4.x/servers/firewall.md
+++ b/docs/4.x/servers/firewall.md
@@ -23,6 +23,10 @@ These rules are applied in the server level, Your server provider might have add
Do not remove the SSH rule, This will cause Vito to lose connection to your server.
:::
+## Network Rules
+
+If the server belongs to a [network](../networks/overview.md), the rules for that network also appear on this page as managed rules. They are listed here for visibility, but you edit them from the network's [Firewall](../networks/firewall.md) tab so they stay consistent across every server in the network.
+
## Create new Rule
You can easily create firewall rules and deploy them to your server.
diff --git a/docs/4.x/servers/network.md b/docs/4.x/servers/network.md
index ddbf53cb2..2b44c3910 100644
--- a/docs/4.x/servers/network.md
+++ b/docs/4.x/servers/network.md
@@ -4,6 +4,10 @@
The **Network** section of a server lets you view and manage the server's IP addresses. Vito detects the addresses configured on the server and lets you add or remove additional ones.
+:::info
+This page is about the addresses on a single server. To connect several servers together privately, see [Networks](../networks/overview.md). A [custom network](../networks/create.md) is built from the private addresses listed here.
+:::
+
## Viewing IP Addresses
The Network page lists every IP address Vito knows about for the server, including:
diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md
index 714a0510e..2692a627c 100644
--- a/docs/4.x/settings/server-providers.md
+++ b/docs/4.x/settings/server-providers.md
@@ -6,6 +6,8 @@ Vito provides integration with multiple server providers to deploy your servers
When creating new servers, You can select the provider you've connected and deploy your servers with a few clicks.
+A connected provider is also used to discover the private networks your servers already belong to — see [Provider Networks](../networks/provider-networks.md).
+
## Supported Providers
- AWS
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index c331cba59..836df7cf0 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -227,9 +227,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?
{childItem.external ? (
diff --git a/resources/js/layouts/database/layout.tsx b/resources/js/layouts/database/layout.tsx
index c6ff53acd..aa8c2a80b 100644
--- a/resources/js/layouts/database/layout.tsx
+++ b/resources/js/layouts/database/layout.tsx
@@ -37,9 +37,7 @@ export default function DatabaseLayout({ server, children }: { server: Server; c