Back to Login
diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php
index b745259fe..5c60b30d6 100644
--- a/tests/Feature/TrustHostsMiddlewareTest.php
+++ b/tests/Feature/TrustHostsMiddlewareTest.php
@@ -286,6 +286,56 @@
expect($response->status())->not->toBe(400);
});
+it('trusts localhost when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('localhost');
+});
+
+it('trusts 127.0.0.1 when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('127.0.0.1');
+});
+
+it('trusts IPv6 loopback when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('[::1]');
+});
+
+it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $response = $this->get('/', [
+ 'Host' => 'localhost',
+ ]);
+
+ // Should NOT be rejected as untrusted host (would be 400)
+ expect($response->status())->not->toBe(400);
+});
+
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
From 4f39cf6dc8a5605a16d2098ead02a7cb8534cc54 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 16:43:29 +0100
Subject: [PATCH 48/54] chore: prepare for PR
---
app/Livewire/Settings/Advanced.php | 12 +-
app/Rules/ValidIpOrCidr.php | 5 +-
bootstrap/helpers/shared.php | 107 +++++++++++++--
tests/Feature/IpAllowlistTest.php | 208 ++++++++++++++++++++++++++++-
4 files changed, 311 insertions(+), 21 deletions(-)
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 16361ce79..ad478273f 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -95,7 +95,9 @@ public function submit()
// Check if it's valid CIDR notation
if (str_contains($entry, '/')) {
[$ip, $mask] = explode('/', $entry);
- if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) {
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6 ? 128 : 32;
+ if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) {
return $entry;
}
$invalidEntries[] = $entry;
@@ -111,7 +113,7 @@ public function submit()
$invalidEntries[] = $entry;
return null;
- })->filter()->unique();
+ })->filter()->values()->all();
if (! empty($invalidEntries)) {
$this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries));
@@ -119,13 +121,15 @@ public function submit()
return;
}
- if ($validEntries->isEmpty()) {
+ if (empty($validEntries)) {
$this->dispatch('error', 'No valid IP addresses or subnets provided');
return;
}
- $this->allowed_ips = $validEntries->implode(',');
+ $validEntries = deduplicateAllowlist($validEntries);
+
+ $this->allowed_ips = implode(',', $validEntries);
}
$this->instantSave();
diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php
index e172ffd1a..bd0bd2296 100644
--- a/app/Rules/ValidIpOrCidr.php
+++ b/app/Rules/ValidIpOrCidr.php
@@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
[$ip, $mask] = $parts;
- if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) {
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6 ? 128 : 32;
+
+ if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) {
$invalidEntries[] = $entry;
}
} else {
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index a1cecc879..3e993dbf3 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1416,24 +1416,48 @@ function checkIPAgainstAllowlist($ip, $allowlist)
}
$mask = (int) $mask;
+ $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6Subnet ? 128 : 32;
- // Validate mask
- if ($mask < 0 || $mask > 32) {
+ // Validate mask for address family
+ if ($mask < 0 || $mask > $maxMask) {
continue;
}
- // Calculate network addresses
- $ip_long = ip2long($ip);
- $subnet_long = ip2long($subnet);
+ if ($isIpv6Subnet) {
+ // IPv6 CIDR matching using binary string comparison
+ $ipBin = inet_pton($ip);
+ $subnetBin = inet_pton($subnet);
- if ($ip_long === false || $subnet_long === false) {
- continue;
- }
+ if ($ipBin === false || $subnetBin === false) {
+ continue;
+ }
- $mask_long = ~((1 << (32 - $mask)) - 1);
+ // Build a 128-bit mask from $mask prefix bits
+ $maskBin = str_repeat("\xff", (int) ($mask / 8));
+ $remainder = $mask % 8;
+ if ($remainder > 0) {
+ $maskBin .= chr(0xFF & (0xFF << (8 - $remainder)));
+ }
+ $maskBin = str_pad($maskBin, 16, "\x00");
- if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
- return true;
+ if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) {
+ return true;
+ }
+ } else {
+ // IPv4 CIDR matching
+ $ip_long = ip2long($ip);
+ $subnet_long = ip2long($subnet);
+
+ if ($ip_long === false || $subnet_long === false) {
+ continue;
+ }
+
+ $mask_long = ~((1 << (32 - $mask)) - 1);
+
+ if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
+ return true;
+ }
}
} else {
// Special case: 0.0.0.0 means allow all
@@ -1451,6 +1475,67 @@ function checkIPAgainstAllowlist($ip, $allowlist)
return false;
}
+function deduplicateAllowlist(array $entries): array
+{
+ if (count($entries) <= 1) {
+ return array_values($entries);
+ }
+
+ // Normalize each entry into [original, ip, mask]
+ $parsed = [];
+ foreach ($entries as $entry) {
+ $entry = trim($entry);
+ if (empty($entry)) {
+ continue;
+ }
+
+ if ($entry === '0.0.0.0') {
+ // Special case: bare 0.0.0.0 means "allow all" — treat as /0
+ $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0];
+ } elseif (str_contains($entry, '/')) {
+ [$ip, $mask] = explode('/', $entry);
+ $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask];
+ } else {
+ $ip = $entry;
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32];
+ }
+ }
+
+ $count = count($parsed);
+ $redundant = array_fill(0, $count, false);
+
+ for ($i = 0; $i < $count; $i++) {
+ if ($redundant[$i]) {
+ continue;
+ }
+
+ for ($j = 0; $j < $count; $j++) {
+ if ($i === $j || $redundant[$j]) {
+ continue;
+ }
+
+ // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask
+ // AND $j's network IP falls within $i's CIDR range
+ if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) {
+ $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask'];
+ if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) {
+ $redundant[$j] = true;
+ }
+ }
+ }
+ }
+
+ $result = [];
+ for ($i = 0; $i < $count; $i++) {
+ if (! $redundant[$i]) {
+ $result[] = $parsed[$i]['original'];
+ }
+ }
+
+ return $result;
+}
+
function get_public_ips()
{
try {
diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php
index 959dc757d..1b14b79e8 100644
--- a/tests/Feature/IpAllowlistTest.php
+++ b/tests/Feature/IpAllowlistTest.php
@@ -86,7 +86,7 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
});
-test('IP allowlist with various subnet sizes', function () {
+test('IP allowlist with various IPv4 subnet sizes', function () {
// /32 - single host
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
@@ -96,16 +96,98 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
- // /16 - class B
+ // /25 - half a /24
+ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
+
+ // /16
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
+ // /12
+ expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
+
+ // /8
+ expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
+
// /0 - all addresses
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
});
+test('IP allowlist with various IPv6 subnet sizes', function () {
+ // /128 - single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /127 - point-to-point link
+ expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
+
+ // /64 - standard subnet
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
+
+ // /48 - site prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+
+ // /32 - ISP allocation
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
+
+ // /16
+ expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
+});
+
+test('IP allowlist with bare IPv6 addresses', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
+ expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
+});
+
+test('IP allowlist with IPv6 CIDR notation', function () {
+ // /64 prefix — issue #8729 exact case
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
+
+ // /128 — single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /48 prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+});
+
+test('IP allowlist with mixed IPv4 and IPv6', function () {
+ $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
+
+ expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
+ expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
+});
+
+test('IP allowlist handles invalid IPv6 masks', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
+});
+
test('IP allowlist comma-separated string input', function () {
// Test with comma-separated string (as it would come from the settings)
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
@@ -134,14 +216,21 @@
// Valid cases - should pass
expect($validate(''))->toBeTrue(); // Empty is allowed
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
- expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
- expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
- expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
+ expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
+ expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
+ expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
+ // IPv6 valid cases — issue #8729
+ expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
+ expect($validate('::1'))->toBeTrue(); // Loopback IPv6
+ expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
+ expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
+ expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
+ expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
// Invalid cases - should fail
expect($validate('1'))->toBeFalse(); // Single digit
@@ -155,6 +244,7 @@
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
+ expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
});
test('ValidIpOrCidr validation rule error messages', function () {
@@ -181,3 +271,111 @@
expect($error)->toContain('10.0.0.256');
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
});
+
+test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
+ // /24
+ expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /16
+ expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
+ // /8
+ expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /32 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
+ // /31 — point-to-point
+ expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
+ // IP outside subnet — both preserved
+ expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
+ // /32 inside /24
+ expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /25 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /24 inside /16
+ expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
+ // /16 inside /12
+ expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
+ // /16 inside /8
+ expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /24 inside /8
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /12 inside /8
+ expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
+ // /31 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // Non-overlapping CIDRs — both preserved
+ expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
+ expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
+});
+
+test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
+ // /64 — issue #8729 exact scenario
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
+ ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
+ // /48
+ expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /128 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
+ // IP outside prefix — both preserved
+ expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
+ ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
+});
+
+test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
+ // /128 inside /64
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
+ // /127 inside /64
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
+ // /64 inside /48
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /48 inside /32
+ expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // /32 inside /16
+ expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
+ // /64 inside /32
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // Non-overlapping IPv6 — both preserved
+ expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
+ expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
+});
+
+test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
+ $result = deduplicateAllowlist([
+ '192.168.1.5', // covered by 192.168.0.0/16
+ '192.168.0.0/16',
+ '2a01:e0a:21d:8230::1', // covered by ::/64
+ '2a01:e0a:21d:8230::/64',
+ '10.0.0.1', // not covered by anything
+ '::1', // not covered by anything
+ ]);
+ expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
+});
+
+test('deduplicateAllowlist preserves non-overlapping entries', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+ expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist handles exact duplicates', function () {
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
+ expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
+});
+
+test('deduplicateAllowlist handles single entry and empty array', function () {
+ expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
+ expect(deduplicateAllowlist([]))->toBe([]);
+});
+
+test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
+ expect($result)->toBe(['0.0.0.0']);
+});
+
+test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
+ // IPv4: three levels of nesting
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // IPv6: three levels of nesting
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+});
From 91f538e171d2e6ebb85f87a627dfd5e06e7ae084 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:03:46 +0100
Subject: [PATCH 49/54] fix(server): handle limit edge case and IPv6 allowlist
dedupe
Update server limit enforcement to re-enable force-disabled servers when the
team is at or under its limit (`<= 0` condition).
Improve allowlist validation and matching by:
- supporting IPv6 CIDR mask ranges up to `/128`
- adding IPv6-aware CIDR matching in `checkIPAgainstAllowlist`
- normalizing/deduplicating redundant allowlist entries before saving
Add feature tests for `ServerLimitCheckJob` covering under-limit, at-limit,
over-limit, and no-op scenarios.
---
app/Jobs/ServerLimitCheckJob.php | 2 +-
app/Livewire/Settings/Advanced.php | 12 ++-
app/Rules/ValidIpOrCidr.php | 5 +-
bootstrap/helpers/shared.php | 107 +++++++++++++++++++---
tests/Feature/ServerLimitCheckJobTest.php | 83 +++++++++++++++++
5 files changed, 192 insertions(+), 17 deletions(-)
create mode 100644 tests/Feature/ServerLimitCheckJobTest.php
diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php
index aa82c6dad..06e94fc93 100644
--- a/app/Jobs/ServerLimitCheckJob.php
+++ b/app/Jobs/ServerLimitCheckJob.php
@@ -38,7 +38,7 @@ public function handle()
$server->forceDisableServer();
$this->team->notify(new ForceDisabled($server));
});
- } elseif ($number_of_servers_to_disable === 0) {
+ } elseif ($number_of_servers_to_disable <= 0) {
$servers->each(function ($server) {
if ($server->isForceDisabled()) {
$server->forceEnableServer();
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 16361ce79..ad478273f 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -95,7 +95,9 @@ public function submit()
// Check if it's valid CIDR notation
if (str_contains($entry, '/')) {
[$ip, $mask] = explode('/', $entry);
- if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) {
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6 ? 128 : 32;
+ if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= $maxMask) {
return $entry;
}
$invalidEntries[] = $entry;
@@ -111,7 +113,7 @@ public function submit()
$invalidEntries[] = $entry;
return null;
- })->filter()->unique();
+ })->filter()->values()->all();
if (! empty($invalidEntries)) {
$this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries));
@@ -119,13 +121,15 @@ public function submit()
return;
}
- if ($validEntries->isEmpty()) {
+ if (empty($validEntries)) {
$this->dispatch('error', 'No valid IP addresses or subnets provided');
return;
}
- $this->allowed_ips = $validEntries->implode(',');
+ $validEntries = deduplicateAllowlist($validEntries);
+
+ $this->allowed_ips = implode(',', $validEntries);
}
$this->instantSave();
diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php
index e172ffd1a..bd0bd2296 100644
--- a/app/Rules/ValidIpOrCidr.php
+++ b/app/Rules/ValidIpOrCidr.php
@@ -45,7 +45,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
[$ip, $mask] = $parts;
- if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > 32) {
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6 ? 128 : 32;
+
+ if (! filter_var($ip, FILTER_VALIDATE_IP) || ! is_numeric($mask) || $mask < 0 || $mask > $maxMask) {
$invalidEntries[] = $entry;
}
} else {
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index a1cecc879..3e993dbf3 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1416,24 +1416,48 @@ function checkIPAgainstAllowlist($ip, $allowlist)
}
$mask = (int) $mask;
+ $isIpv6Subnet = filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $maxMask = $isIpv6Subnet ? 128 : 32;
- // Validate mask
- if ($mask < 0 || $mask > 32) {
+ // Validate mask for address family
+ if ($mask < 0 || $mask > $maxMask) {
continue;
}
- // Calculate network addresses
- $ip_long = ip2long($ip);
- $subnet_long = ip2long($subnet);
+ if ($isIpv6Subnet) {
+ // IPv6 CIDR matching using binary string comparison
+ $ipBin = inet_pton($ip);
+ $subnetBin = inet_pton($subnet);
- if ($ip_long === false || $subnet_long === false) {
- continue;
- }
+ if ($ipBin === false || $subnetBin === false) {
+ continue;
+ }
- $mask_long = ~((1 << (32 - $mask)) - 1);
+ // Build a 128-bit mask from $mask prefix bits
+ $maskBin = str_repeat("\xff", (int) ($mask / 8));
+ $remainder = $mask % 8;
+ if ($remainder > 0) {
+ $maskBin .= chr(0xFF & (0xFF << (8 - $remainder)));
+ }
+ $maskBin = str_pad($maskBin, 16, "\x00");
- if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
- return true;
+ if (($ipBin & $maskBin) === ($subnetBin & $maskBin)) {
+ return true;
+ }
+ } else {
+ // IPv4 CIDR matching
+ $ip_long = ip2long($ip);
+ $subnet_long = ip2long($subnet);
+
+ if ($ip_long === false || $subnet_long === false) {
+ continue;
+ }
+
+ $mask_long = ~((1 << (32 - $mask)) - 1);
+
+ if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
+ return true;
+ }
}
} else {
// Special case: 0.0.0.0 means allow all
@@ -1451,6 +1475,67 @@ function checkIPAgainstAllowlist($ip, $allowlist)
return false;
}
+function deduplicateAllowlist(array $entries): array
+{
+ if (count($entries) <= 1) {
+ return array_values($entries);
+ }
+
+ // Normalize each entry into [original, ip, mask]
+ $parsed = [];
+ foreach ($entries as $entry) {
+ $entry = trim($entry);
+ if (empty($entry)) {
+ continue;
+ }
+
+ if ($entry === '0.0.0.0') {
+ // Special case: bare 0.0.0.0 means "allow all" — treat as /0
+ $parsed[] = ['original' => $entry, 'ip' => '0.0.0.0', 'mask' => 0];
+ } elseif (str_contains($entry, '/')) {
+ [$ip, $mask] = explode('/', $entry);
+ $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => (int) $mask];
+ } else {
+ $ip = $entry;
+ $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
+ $parsed[] = ['original' => $entry, 'ip' => $ip, 'mask' => $isIpv6 ? 128 : 32];
+ }
+ }
+
+ $count = count($parsed);
+ $redundant = array_fill(0, $count, false);
+
+ for ($i = 0; $i < $count; $i++) {
+ if ($redundant[$i]) {
+ continue;
+ }
+
+ for ($j = 0; $j < $count; $j++) {
+ if ($i === $j || $redundant[$j]) {
+ continue;
+ }
+
+ // Entry $j is redundant if its mask is narrower/equal (>=) than $i's mask
+ // AND $j's network IP falls within $i's CIDR range
+ if ($parsed[$j]['mask'] >= $parsed[$i]['mask']) {
+ $cidr = $parsed[$i]['ip'].'/'.$parsed[$i]['mask'];
+ if (checkIPAgainstAllowlist($parsed[$j]['ip'], [$cidr])) {
+ $redundant[$j] = true;
+ }
+ }
+ }
+ }
+
+ $result = [];
+ for ($i = 0; $i < $count; $i++) {
+ if (! $redundant[$i]) {
+ $result[] = $parsed[$i]['original'];
+ }
+ }
+
+ return $result;
+}
+
function get_public_ips()
{
try {
diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php
new file mode 100644
index 000000000..6b2c074be
--- /dev/null
+++ b/tests/Feature/ServerLimitCheckJobTest.php
@@ -0,0 +1,83 @@
+set('constants.coolify.self_hosted', false);
+
+ Notification::fake();
+
+ $this->team = Team::factory()->create(['custom_server_limit' => 5]);
+});
+
+function createServerForTeam(Team $team, bool $forceDisabled = false): Server
+{
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ if ($forceDisabled) {
+ $server->settings()->update(['force_disabled' => true]);
+ }
+
+ return $server->fresh(['settings']);
+}
+
+it('re-enables force-disabled servers when under the limit', function () {
+ createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team, forceDisabled: true);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ expect($server2->settings->force_disabled)->toBeTruthy();
+ expect($server3->settings->force_disabled)->toBeTruthy();
+
+ // 3 servers, limit 5 → all should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('re-enables force-disabled servers when exactly at the limit', function () {
+ $this->team->update(['custom_server_limit' => 3]);
+
+ createServerForTeam($this->team);
+ createServerForTeam($this->team);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ // 3 servers, limit 3 → disabled one should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('disables newest servers when over the limit', function () {
+ $this->team->update(['custom_server_limit' => 2]);
+
+ $oldest = createServerForTeam($this->team);
+ sleep(1);
+ $middle = createServerForTeam($this->team);
+ sleep(1);
+ $newest = createServerForTeam($this->team);
+
+ // 3 servers, limit 2 → newest 1 should be disabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
+});
+
+it('does not change servers when under limit and none are force-disabled', function () {
+ $server1 = createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team);
+
+ // 2 servers, limit 5 → nothing to do
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+});
From 0ca5596b1f78550bf8cacc96a5ea7c4a427befb7 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:03:59 +0100
Subject: [PATCH 50/54] fix(server-limit): re-enable force-disabled servers at
limit
Handle non-positive disable counts with `<= 0` so teams at or under the
server limit correctly re-enable force-disabled servers. Add a feature test
suite for ServerLimitCheckJob covering under-limit, at-limit, over-limit,
and no-op behavior.
---
app/Jobs/ServerLimitCheckJob.php | 2 +-
tests/Feature/ServerLimitCheckJobTest.php | 83 +++++++++++++++++++++++
2 files changed, 84 insertions(+), 1 deletion(-)
create mode 100644 tests/Feature/ServerLimitCheckJobTest.php
diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php
index aa82c6dad..06e94fc93 100644
--- a/app/Jobs/ServerLimitCheckJob.php
+++ b/app/Jobs/ServerLimitCheckJob.php
@@ -38,7 +38,7 @@ public function handle()
$server->forceDisableServer();
$this->team->notify(new ForceDisabled($server));
});
- } elseif ($number_of_servers_to_disable === 0) {
+ } elseif ($number_of_servers_to_disable <= 0) {
$servers->each(function ($server) {
if ($server->isForceDisabled()) {
$server->forceEnableServer();
diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php
new file mode 100644
index 000000000..6b2c074be
--- /dev/null
+++ b/tests/Feature/ServerLimitCheckJobTest.php
@@ -0,0 +1,83 @@
+set('constants.coolify.self_hosted', false);
+
+ Notification::fake();
+
+ $this->team = Team::factory()->create(['custom_server_limit' => 5]);
+});
+
+function createServerForTeam(Team $team, bool $forceDisabled = false): Server
+{
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ if ($forceDisabled) {
+ $server->settings()->update(['force_disabled' => true]);
+ }
+
+ return $server->fresh(['settings']);
+}
+
+it('re-enables force-disabled servers when under the limit', function () {
+ createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team, forceDisabled: true);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ expect($server2->settings->force_disabled)->toBeTruthy();
+ expect($server3->settings->force_disabled)->toBeTruthy();
+
+ // 3 servers, limit 5 → all should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('re-enables force-disabled servers when exactly at the limit', function () {
+ $this->team->update(['custom_server_limit' => 3]);
+
+ createServerForTeam($this->team);
+ createServerForTeam($this->team);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ // 3 servers, limit 3 → disabled one should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('disables newest servers when over the limit', function () {
+ $this->team->update(['custom_server_limit' => 2]);
+
+ $oldest = createServerForTeam($this->team);
+ sleep(1);
+ $middle = createServerForTeam($this->team);
+ sleep(1);
+ $newest = createServerForTeam($this->team);
+
+ // 3 servers, limit 2 → newest 1 should be disabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
+});
+
+it('does not change servers when under limit and none are force-disabled', function () {
+ $server1 = createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team);
+
+ // 2 servers, limit 5 → nothing to do
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+});
From 80be2628d06ec2c941e2d5c1e2379bd77085fea1 Mon Sep 17 00:00:00 2001
From: Cinzya
Date: Tue, 3 Mar 2026 20:57:03 +0100
Subject: [PATCH 51/54] chore(ui): add labels header
---
resources/views/livewire/project/application/general.blade.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index f576a4a12..aada339cc 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -527,6 +527,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blu
@endif
@if ($application->settings->is_container_label_readonly_enabled)
From 86cbd8299163e444e0e4dc3ea431dc6b006b6bfa Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 3 Mar 2026 22:07:36 +0100
Subject: [PATCH 52/54] docs(readme): add VPSDime to Big Sponsors list
Include VPSDime with its referral link and hosting description in README.
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index c78e47997..901536208 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,7 @@ ### Big Sponsors
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
+* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions
### Small Sponsors
From d0929a58836cb683b730918cb51870c1073b9755 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 4 Mar 2026 09:02:22 +0100
Subject: [PATCH 53/54] docs(readme): move MVPS to Huge Sponsors section
Promote MVPS from the Big Sponsors list to Huge Sponsors to reflect its updated sponsorship tier.
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 901536208..e9ea0e7d4 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,9 @@ ## Donations
### Huge Sponsors
+* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
+*
### Big Sponsors
@@ -85,7 +87,6 @@ ### Big Sponsors
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
-* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
From 4015e0315388cea07114ecd66cda96056e614e39 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Wed, 4 Mar 2026 11:36:52 +0100
Subject: [PATCH 54/54] fix(proxy): remove ipv6 cidr network remediation
stop explicitly re-creating networks while ensuring them since the previous IPv6 CIDR gateway workaround is no longer needed and was duplicating effort.
---
bootstrap/helpers/proxy.php | 56 ++++++-------------------------------
1 file changed, 8 insertions(+), 48 deletions(-)
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index 27637cc6f..ac52c0af8 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -127,44 +127,10 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
-/**
- * Generate shell commands to fix a Docker network that has an IPv6 gateway with CIDR notation.
- *
- * Docker 25+ may store IPv6 gateways with CIDR (e.g. fd7d:f7d2:7e77::1/64), which causes
- * ParseAddr errors in Docker Compose. This detects the issue and recreates the network.
- *
- * @see https://github.com/coollabsio/coolify/issues/8649
- *
- * @param string $network Network name to check and fix
- * @return array Shell commands to execute on the remote server
- */
-function fixNetworkIpv6CidrGateway(string $network): array
-{
- return [
- "if docker network inspect {$network} >/dev/null 2>&1; then",
- " IPV6_GW=\$(docker network inspect {$network} --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>/dev/null | tr ' ' '\n' | grep '/' || true)",
- ' if [ -n "$IPV6_GW" ]; then',
- " echo \"Fixing network {$network}: IPv6 gateway has CIDR notation (\$IPV6_GW)\"",
- " CONTAINERS=\$(docker network inspect {$network} --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null)",
- ' for c in $CONTAINERS; do',
- " [ -n \"\$c\" ] && docker network disconnect {$network} \"\$c\" 2>/dev/null || true",
- ' done',
- " docker network rm {$network} 2>/dev/null || true",
- " docker network create --attachable {$network} 2>/dev/null || true",
- ' for c in $CONTAINERS; do',
- " [ -n \"\$c\" ] && [ \"\$c\" != \"coolify-proxy\" ] && docker network connect {$network} \"\$c\" 2>/dev/null || true",
- ' done',
- ' fi',
- 'fi',
- ];
-}
-
/**
* Ensures all required networks exist before docker compose up.
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
- * Also detects and fixes networks with IPv6 CIDR gateway notation that causes ParseAddr errors.
- *
* @param Server $server The server to ensure networks on
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
*/
@@ -174,23 +140,17 @@ function ensureProxyNetworksExist(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
- return array_merge(
- fixNetworkIpv6CidrGateway($network),
- [
- "echo 'Ensuring network $network exists...'",
- "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
- ]
- );
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
+ ];
});
} else {
$commands = $networks->map(function ($network) {
- return array_merge(
- fixNetworkIpv6CidrGateway($network),
- [
- "echo 'Ensuring network $network exists...'",
- "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
- ]
- );
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
+ ];
});
}