Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
98cf83d
feat: wireguard & provider networking
RichardAnderson Jul 21, 2026
53c6fd3
fixes
RichardAnderson Jul 21, 2026
c49a387
Merge remote-tracking branch 'origin/4.x' into feat/networks
RichardAnderson Jul 21, 2026
edd84e4
Merge remote-tracking branch 'origin/4.x' into feat/networks
RichardAnderson Jul 21, 2026
a1d20e8
fix: ordering of network servers
RichardAnderson Jul 21, 2026
d2bea42
Merge remote-tracking branch 'origin/4.x' into feat/networks
RichardAnderson Jul 21, 2026
6ffc2a4
feat: server network firewall rules
RichardAnderson Jul 22, 2026
25c473b
feat: peers work
RichardAnderson Jul 22, 2026
2794080
feat: user interface and logs
RichardAnderson Jul 23, 2026
ff299c5
merge
RichardAnderson Jul 24, 2026
6c26b18
feat: ui updates and various fixes
RichardAnderson Jul 24, 2026
6cc0d5d
feat: provider private network sync
RichardAnderson Jul 24, 2026
3e59592
feat: various fixes + docs
RichardAnderson Jul 24, 2026
7d01090
fix: peer dialog persistant config
RichardAnderson Jul 24, 2026
b36ca70
fix: ensure tests pass + copilot fixes
RichardAnderson Jul 24, 2026
22df400
fix: various fixes
RichardAnderson Jul 24, 2026
65b627f
fix: styling
RichardAnderson Jul 24, 2026
09b1bc9
fix: docblock update
RichardAnderson Jul 24, 2026
5b4793d
fix: coderabbit findings
RichardAnderson Jul 25, 2026
71f42cf
fix: review fixes
RichardAnderson Jul 25, 2026
c76b75a
fix: coderabbit findings
RichardAnderson Jul 25, 2026
ac1a724
fix: additional findings
RichardAnderson Jul 25, 2026
13d3b12
fix: linting
RichardAnderson Jul 25, 2026
a9f5917
fix: out of change fixes, but worthwhile
RichardAnderson Jul 25, 2026
2432c05
Merge branch '4.x' into pr/1209/feat/networks
saeedvaziry Jul 25, 2026
611c8e1
feat: support ipv6 fully
RichardAnderson Jul 25, 2026
f866cd6
fix: fixes
RichardAnderson Jul 25, 2026
3c20cae
fixes: code review
RichardAnderson Jul 25, 2026
c246ff9
fix: linting
RichardAnderson Jul 25, 2026
e1f8334
fix: code-review fixes round 2
RichardAnderson Jul 25, 2026
c018414
fix: refactors and reviews, updated unit tests
RichardAnderson Jul 25, 2026
edd807c
fix: linting
RichardAnderson Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
/vendor
/build
/storage/database.sqlite
/storage/database.sqlite.bak*
/storage/database-test.sqlite
/storage/database.sqlite-journal
/storage/database-test.sqlite-journal
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ sail
!*.blade.php
!*.sh
resources/views/ssh/
resources/views/wireguard/
resources/views/scribe/
resources/js/ziggy.js
resources/views/mail/*
8 changes: 6 additions & 2 deletions app/Actions/FirewallRule/ManageRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Jobs\FirewallRule\ApplyRulesJob;
use App\Models\FirewallRule;
use App\Models\Server;
use App\Support\Cidr;
use App\ValidationRules\PortOrPortRangeRule;
use Illuminate\Support\Facades\Validator;

Expand Down Expand Up @@ -55,6 +56,9 @@ public function delete(FirewallRule $rule): void

private function validate(array $input): void
{
$source = $input['source'] ?? null;
$maxMask = is_string($source) && Cidr::isValidAddress($source) ? Cidr::bits($source) : 32;

$rules = [
'name' => [
'required',
Expand All @@ -81,13 +85,13 @@ private function validate(array $input): void
'nullable',
'numeric',
'min:1',
'max:32',
'max:'.$maxMask,
],
];

if (isset($input['source_any']) && $input['source_any'] === false) {
$rules['source'] = ['required', 'ip'];
$rules['mask'] = ['required', 'numeric', 'min:1', 'max:32'];
$rules['mask'] = ['required', 'numeric', 'min:1', 'max:'.$maxMask];
}

Validator::make($input, $rules)->validate();
Expand Down
188 changes: 188 additions & 0 deletions app/Actions/Network/AddServersToNetwork.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace App\Actions\Network;

use App\Enums\IpAddressType;
use App\Enums\NetworkServerStatus;
use App\Enums\NetworkType;
use App\Models\Network;
use App\Models\Project;
use App\Models\Server;
use App\ValidationRules\WithinCidrRule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;

class AddServersToNetwork
{
public function __construct(
private AllocateWireGuardPort $ports,
private CreateWireGuardMembers $members,
private DispatchNetworkServerSync $sync,
private RecomputeNetworkStatus $recompute,
private ApplyNetworkFirewall $firewall,
) {}

/**
* @param array<string, mixed> $input
* @return ?int the port the network moved to, when an incoming server forced it off its own
*/
public function add(Network $network, array $input): ?int
{
if ($network->type === NetworkType::PROVIDER) {
throw ValidationException::withMessages([
'servers' => __('Members of a provider-managed network are synced from the provider.'),
]);
}

$this->validate($network, $input);

$portBefore = $network->port;

$newMemberIds = DB::transaction(function () use ($network, $input): array {
return $network->type === NetworkType::WIREGUARD
? $this->addWireGuard($network, $input)
: $this->addCustom($network, $input);
});

if ($network->type === NetworkType::WIREGUARD) {
$network->load('servers.server');
foreach ($network->servers as $member) {
if (in_array($member->id, $newMemberIds, true)
|| in_array($member->status, [NetworkServerStatus::ACTIVE, NetworkServerStatus::UPDATING], true)) {
$this->sync->toPresent($member);
}
}
} else {
$this->firewall->handle($network);
}

$this->recompute->handle($network);

return $network->port !== $portBefore ? $network->port : null;
}

/**
* @param array<string, mixed> $input
* @return array<int, int>
*/
private function addWireGuard(Network $network, array $input): array
{
Project::query()->whereKey($network->project_id)->lockForUpdate()->first();
Network::query()->whereKey($network->id)->lockForUpdate()->first();

$this->resolvePortConflict($network, $input['servers']);

$used = $network->servers()->lockForUpdate()->pluck('ip')
->concat($network->peers()->lockForUpdate()->pluck('ip'))
->filter()
->values()
->all();

$servers = Server::query()
->where('project_id', $network->project_id)
->whereIn('id', $input['servers'])
->get();

return $this->members->create($network, $servers, $used);
}

/**
* @param array<string, mixed> $input
* @return array<int, int>
*/
private function addCustom(Network $network, array $input): array
{
$ids = [];
foreach ($input['servers'] as $serverId) {
$member = $network->servers()->create([
'server_id' => $serverId,
'server_ip_address_id' => $input['ip_addresses'][$serverId],
'status' => NetworkServerStatus::ACTIVE,
]);
$ids[] = $member->id;
}

return $ids;
}

/**
* @param array<string, mixed> $input
*/
private function validate(Network $network, array $input): void
{
$rules = [
'servers' => ['required', 'array', 'min:1'],
'servers.*' => [
'integer',
'distinct',
Rule::exists('servers', 'id')->where('project_id', $network->project_id),
Rule::unique('network_servers', 'server_id')->where('network_id', $network->id),
],
Comment thread
RichardAnderson marked this conversation as resolved.
];

if ($network->type === NetworkType::CUSTOM) {
$rules['ip_addresses'] = ['required', 'array'];
}

Validator::make($input, $rules)->validate();

if ($network->type === NetworkType::CUSTOM) {
$this->validateMemberIps($network, $input);
}
}

/**
* Runs only once `servers` is known to be a list of integers — building these rules from
* unvalidated input would interpolate an array into a rule key and fail with a 500.
*
* @param array<string, mixed> $input
*/
private function validateMemberIps(Network $network, array $input): void
{
$rules = [];

foreach ($input['servers'] as $serverId) {
$rules["ip_addresses.$serverId"] = [
'required',
Rule::exists('server_ip_addresses', 'id')
->where('server_id', $serverId)
->where('type', IpAddressType::PRIVATE->value),
Rule::unique('network_servers', 'server_ip_address_id'),
new WithinCidrRule($network->cidr),
];
}

Validator::make($input, $rules)->validate();
}

/**
* An incoming server may already run this network's port for a different network, which the
* two would then fight over on that host. The network moves to a free port instead of
* refusing the server — every healthy member is resynced by the caller, so they follow it.
*
* Peers do not follow: their endpoint port is baked into the config at download time, so an
* already-imported config keeps the old port. The caller warns when peers exist.
*
* @param array<int, int> $serverIds
*/
private function resolvePortConflict(Network $network, array $serverIds): void
{
$serverIds = array_merge($network->servers()->pluck('server_id')->all(), $serverIds);

$port = $this->ports->allocate(
$network->project_id,
$serverIds,
$network->port ?? 51820,
$network->id,
);

if ($port === $network->port) {
return;
}

$network->port = $port;
$network->save();
}
}
134 changes: 134 additions & 0 deletions app/Actions/Network/AllocateNetworkBlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace App\Actions\Network;

use App\Enums\NetworkAddressingPool;
use App\Models\Server;
use App\Models\ServerIpAddress;
use App\Support\Cidr;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;

class AllocateNetworkBlock
{
/**
* Provider/Docker/AWS/Linode ranges to avoid on the RFC1918 opt-in path.
* The whole 172.16.0.0/12 (Docker/AWS default) is excluded by not listing
* it as an RFC1918 supernet below.
*
* @var array<int, string>
*/
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<int, string>
*/
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<int, string> $existingCidrs
* @param Collection<int, Server> $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<int, string> $existing
* @param array<int, string> $memberSubnets
* @param array<int, string> $blocklist
*/
private function scan(
string $supernet,
int $blockPrefix,
array $existing,
array $memberSubnets,
array $blocklist
): ?string {
$supernetPrefix = Cidr::prefix($supernet);
if ($blockPrefix < $supernetPrefix) {
return null;
}

$base = Cidr::toLong(Cidr::network($supernet));
$blockSize = Cidr::size($blockPrefix);
$count = 2 ** ($blockPrefix - $supernetPrefix);

for ($i = 0; $i < $count; $i++) {
$candidate = long2ip($base + ($i * $blockSize)).'/'.$blockPrefix;

if ($this->conflicts($candidate, $existing) || $this->conflicts($candidate, $blocklist)
|| $this->conflicts($candidate, $memberSubnets)) {
continue;
}

return $candidate;
}

return null;
}

/**
* @param array<int, string> $others
*/
private function conflicts(string $candidate, array $others): bool
{
foreach ($others as $other) {
if (Cidr::overlaps($candidate, $other)) {
return true;
}
}

return false;
}

/**
* @param Collection<int, Server> $memberServers
* @return array<int, string>
*/
private function memberSubnets(Collection $memberServers): array
{
return ServerIpAddress::query()
->whereIn('server_id', $memberServers->pluck('id')->all())
->get()
->filter(fn (ServerIpAddress $address): bool => filter_var($address->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false)
->map(fn (ServerIpAddress $address): string => Cidr::canonical($address->ip.'/'.$address->prefix_length))
->unique()
->values()
->all();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading