diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f455df..911269d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ CHANGE LOG ========== +## 5.1.2 (UPCOMING) + +* Add support for firewall rule action (`allow` or `deny`) and protocol `all` + + ## 5.1.1 (06/05/2026) * Add sensitive parameter annotations for authentication tokens diff --git a/README.md b/README.md index 3bdb809..4027bb4 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,22 @@ $firewall->addRules($firewallId, $rules); // remove above rule $firewall->removeRules($firewallId, $rules); +// Add inbound rule denying all traffic from a specific IP address +$denyRules = [ + 'inbound_rules' => [ + [ + 'protocol' => 'all', + 'ports' => '0', + 'sources' => ['addresses' => ['203.0.113.5']], + 'action' => 'deny', + ], + ], +]; +$firewall->addRules($firewallId, $denyRules); + +// remove above deny rule +$firewall->removeRules($firewallId, $denyRules); + // remove firewall id 123-abc $firewall->remove('123-abc'); ``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5a36fb6..c96eb2b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -282,12 +282,6 @@ parameters: count: 1 path: src/Entity/Firewall.php - - - message: '#^Loose comparison via "\!\=" is not allowed\.$#' - identifier: notEqual.notAllowed - count: 1 - path: src/Entity/FirewallRule.php - - message: '#^Variable property access on \$this\(DigitalOceanV2\\Entity\\FirewallRuleInbound\)\.$#' identifier: property.dynamicName diff --git a/src/Entity/FirewallRule.php b/src/Entity/FirewallRule.php index c0901aa..43b9794 100644 --- a/src/Entity/FirewallRule.php +++ b/src/Entity/FirewallRule.php @@ -24,14 +24,25 @@ abstract class FirewallRule extends AbstractEntity public string $ports; + public ?string $action; + + /** + * @psalm-suppress RedundantPropertyInitializationCheck + */ public function toArray(): array { $data = [ 'protocol' => $this->protocol, ]; - if ('icmp' != $this->protocol) { + if ('icmp' !== $this->protocol && 'all' !== $this->protocol) { $data['ports'] = ('0' === $this->ports) ? 'all' : $this->ports; + } elseif ('all' === $this->protocol && isset($this->ports)) { + $data['ports'] = $this->ports; + } + + if (isset($this->action)) { + $data['action'] = $this->action; } return $data; diff --git a/tests/Entity/FirewallRuleTest.php b/tests/Entity/FirewallRuleTest.php new file mode 100644 index 0000000..06f975c --- /dev/null +++ b/tests/Entity/FirewallRuleTest.php @@ -0,0 +1,138 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace DigitalOceanV2\Tests\Entity; + +use DigitalOceanV2\Entity\AbstractEntity; +use DigitalOceanV2\Entity\FirewallRule; +use DigitalOceanV2\Entity\FirewallRuleInbound; +use DigitalOceanV2\Entity\FirewallRuleOutbound; +use PHPUnit\Framework\TestCase; + +/** + * @author Graham Campbell + */ +class FirewallRuleTest extends TestCase +{ + public function testInboundAllowRule(): void + { + $data = [ + 'protocol' => 'tcp', + 'ports' => '80', + 'sources' => [ + 'addresses' => ['0.0.0.0/0', '::/0'], + ], + ]; + + $entity = new FirewallRuleInbound($data); + + self::assertInstanceOf(AbstractEntity::class, $entity); + self::assertInstanceOf(FirewallRule::class, $entity); + self::assertSame('tcp', $entity->protocol); + self::assertSame('80', $entity->ports); + self::assertFalse(isset($entity->action)); + + $array = $entity->toArray(); + self::assertSame('tcp', $array['protocol']); + self::assertSame('80', $array['ports']); + self::assertArrayNotHasKey('action', $array); + self::assertSame(['addresses' => ['0.0.0.0/0', '::/0']], $array['sources']); + } + + public function testInboundDenyRuleAllProtocolWithZeroPort(): void + { + $data = [ + 'protocol' => 'all', + 'ports' => '0', + 'sources' => [ + 'addresses' => ['203.0.113.5'], + ], + 'action' => 'deny', + ]; + + $entity = new FirewallRuleInbound($data); + + self::assertSame('all', $entity->protocol); + self::assertSame('0', $entity->ports); + self::assertSame('deny', $entity->action); + + $array = $entity->toArray(); + self::assertSame('all', $array['protocol']); + self::assertSame('0', $array['ports']); + self::assertSame('deny', $array['action']); + self::assertSame(['addresses' => ['203.0.113.5']], $array['sources']); + } + + public function testInboundDenyRuleAllProtocolWithOmittedPort(): void + { + $data = [ + 'protocol' => 'all', + 'sources' => [ + 'addresses' => ['203.0.113.5'], + ], + 'action' => 'deny', + ]; + + $entity = new FirewallRuleInbound($data); + + self::assertSame('all', $entity->protocol); + self::assertFalse(isset($entity->ports)); + self::assertSame('deny', $entity->action); + + $array = $entity->toArray(); + self::assertSame('all', $array['protocol']); + self::assertArrayNotHasKey('ports', $array); + self::assertSame('deny', $array['action']); + } + + public function testInboundIcmpRuleOmitsPorts(): void + { + $data = [ + 'protocol' => 'icmp', + 'sources' => [ + 'addresses' => ['0.0.0.0/0'], + ], + ]; + + $entity = new FirewallRuleInbound($data); + + $array = $entity->toArray(); + self::assertSame('icmp', $array['protocol']); + self::assertArrayNotHasKey('ports', $array); + } + + public function testOutboundDenyRule(): void + { + $data = [ + 'protocol' => 'all', + 'ports' => '0', + 'destinations' => [ + 'addresses' => ['198.51.100.0/24'], + ], + 'action' => 'deny', + ]; + + $entity = new FirewallRuleOutbound($data); + + self::assertSame('all', $entity->protocol); + self::assertSame('0', $entity->ports); + self::assertSame('deny', $entity->action); + + $array = $entity->toArray(); + self::assertSame('all', $array['protocol']); + self::assertSame('0', $array['ports']); + self::assertSame('deny', $array['action']); + self::assertSame(['addresses' => ['198.51.100.0/24']], $array['destinations']); + } +} diff --git a/tests/Entity/FirewallTest.php b/tests/Entity/FirewallTest.php new file mode 100644 index 0000000..4291624 --- /dev/null +++ b/tests/Entity/FirewallTest.php @@ -0,0 +1,103 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace DigitalOceanV2\Tests\Entity; + +use DigitalOceanV2\Entity\AbstractEntity; +use DigitalOceanV2\Entity\Firewall as FirewallEntity; +use DigitalOceanV2\Entity\FirewallRuleInbound; +use DigitalOceanV2\Entity\FirewallRuleOutbound; +use PHPUnit\Framework\TestCase; + +/** + * @author Graham Campbell + */ +class FirewallTest extends TestCase +{ + public function testConstructorAndToArray(): void + { + $values = [ + 'id' => 'bb4b2611-3d72-467b-8602-280330ecd65c', + 'name' => 'web-firewall', + 'status' => 'succeeded', + 'created_at' => '2026-09-09T00:00:00Z', + 'pending_changes' => [], + 'inbound_rules' => [ + [ + 'protocol' => 'tcp', + 'ports' => '80', + 'sources' => ['addresses' => ['0.0.0.0/0']], + ], + [ + 'protocol' => 'all', + 'ports' => '0', + 'sources' => ['addresses' => ['203.0.113.5']], + 'action' => 'deny', + ], + ], + 'outbound_rules' => [ + [ + 'protocol' => 'tcp', + 'ports' => '443', + 'destinations' => ['addresses' => ['0.0.0.0/0']], + ], + [ + 'protocol' => 'all', + 'ports' => '0', + 'destinations' => ['addresses' => ['198.51.100.0/24']], + 'action' => 'deny', + ], + ], + 'droplet_ids' => [12345], + 'tags' => ['frontend'], + ]; + + $entity = new FirewallEntity($values); + + self::assertInstanceOf(AbstractEntity::class, $entity); + self::assertInstanceOf(FirewallEntity::class, $entity); + self::assertSame('bb4b2611-3d72-467b-8602-280330ecd65c', $entity->id); + self::assertSame('web-firewall', $entity->name); + self::assertSame('succeeded', $entity->status); + self::assertCount(2, $entity->inboundRules); + self::assertCount(2, $entity->outboundRules); + self::assertInstanceOf(FirewallRuleInbound::class, $entity->inboundRules[0]); + self::assertInstanceOf(FirewallRuleInbound::class, $entity->inboundRules[1]); + self::assertInstanceOf(FirewallRuleOutbound::class, $entity->outboundRules[0]); + self::assertInstanceOf(FirewallRuleOutbound::class, $entity->outboundRules[1]); + self::assertFalse(isset($entity->inboundRules[0]->action)); + self::assertSame('deny', $entity->inboundRules[1]->action); + self::assertFalse(isset($entity->outboundRules[0]->action)); + self::assertSame('deny', $entity->outboundRules[1]->action); + + $array = $entity->toArray(); + self::assertSame('web-firewall', $array['name']); + self::assertSame([12345], $array['droplet_ids']); + self::assertSame(['frontend'], $array['tags']); + self::assertCount(2, $array['inbound_rules']); + self::assertCount(2, $array['outbound_rules']); + self::assertSame('tcp', $array['inbound_rules'][0]['protocol']); + self::assertSame('80', $array['inbound_rules'][0]['ports']); + self::assertArrayNotHasKey('action', $array['inbound_rules'][0]); + self::assertSame('all', $array['inbound_rules'][1]['protocol']); + self::assertSame('0', $array['inbound_rules'][1]['ports']); + self::assertSame('deny', $array['inbound_rules'][1]['action']); + self::assertSame('tcp', $array['outbound_rules'][0]['protocol']); + self::assertSame('443', $array['outbound_rules'][0]['ports']); + self::assertArrayNotHasKey('action', $array['outbound_rules'][0]); + self::assertSame('all', $array['outbound_rules'][1]['protocol']); + self::assertSame('0', $array['outbound_rules'][1]['ports']); + self::assertSame('deny', $array['outbound_rules'][1]['action']); + } +}