From 542d30dfa1b8171127bf9aa677a69160f249664e Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Wed, 10 Apr 2024 13:39:55 +0300 Subject: [PATCH 01/16] Move Firewalls from VM to Subnet model migration We made the decision to make Firewalls to be added to the whole subnet instead of individual VMs. This commit implements the migration file. --- migrate/20240409_move_firewall_to_subnet.rb | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 migrate/20240409_move_firewall_to_subnet.rb diff --git a/migrate/20240409_move_firewall_to_subnet.rb b/migrate/20240409_move_firewall_to_subnet.rb new file mode 100644 index 000000000..aa8dcbbde --- /dev/null +++ b/migrate/20240409_move_firewall_to_subnet.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + alter_table(:firewall) do + add_foreign_key :private_subnet_id, :private_subnet, type: :uuid + end + + run <<~SQL + UPDATE firewall f + SET private_subnet_id = ( + SELECT n.private_subnet_id + FROM nic n + WHERE n.vm_id = f.vm_id + ); + SQL + end + + down do + run <<~SQL + UPDATE firewall f + SET vm_id = ( + SELECT n.vm_id + FROM nic n + WHERE n.private_subnet_id = f.private_subnet_id + ); + SQL + + alter_table(:firewall) do + drop_column :private_subnet_id + end + end +end From 91d1f3c3efd225a6d17e01431a0d3981e128d9ad Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Wed, 10 Apr 2024 14:09:28 +0300 Subject: [PATCH 02/16] Create Firewalls and attach to Subnet instead of VM This commit implements the Firewalls move from VMs to Subnets. Therefore, there are multiple changes regarding model relationships, Vm::Nexus.assemble, Vnet::SubnetNexus.assemble and finally at the routes. The changes are not very interesting as they mostly involve semaphore increments being performed on subnets instead of individual VMs or entity creations referring to subnets intead of VMs. One additional small but interesting change is the cidr validation. It involves 2 changes; 1. Validate IPv6 as well 2. Return the parsed cidr and use its string representation while creating the record. This is necessary because when NetAddr is able to parse a cidr like "1.1.1.1/8" without an issue, db insert fails because the valid form of that cidr is actually "1.0.0.0/8". This used to cause 500 error in console. --- lib/validation.rb | 10 ++++++-- model/firewall.rb | 4 ++-- model/private_subnet.rb | 6 +++-- model/vm.rb | 7 ++++-- prog/vm/nexus.rb | 6 +---- prog/vnet/subnet_nexus.rb | 13 +++++++++-- routes/api/project/location/vm.rb | 4 ++-- routes/web/project/location/vm.rb | 3 ++- spec/lib/validation_spec.rb | 7 ++++++ spec/model/firewall_spec.rb | 6 ++--- spec/prog/vnet/subnet_nexus_spec.rb | 36 +++++++++++------------------ spec/routes/web/vm_spec.rb | 2 +- 12 files changed, 60 insertions(+), 44 deletions(-) diff --git a/lib/validation.rb b/lib/validation.rb index b137bdc4f..06be050ff 100644 --- a/lib/validation.rb +++ b/lib/validation.rb @@ -121,9 +121,15 @@ def self.validate_postgres_superuser_password(original_password, repeat_password end def self.validate_cidr(cidr) - NetAddr::IPv4Net.parse(cidr) + if cidr.include?(".") + NetAddr::IPv4Net.parse(cidr) + elsif cidr.include?(":") + NetAddr::IPv6Net.parse(cidr) + else + fail ValidationFailed.new({cidr: "Invalid CIDR"}) + end rescue NetAddr::ValidationError - fail ValidationFailed.new({CIDR: "Invalid CIDR"}) + fail ValidationFailed.new({cidr: "Invalid CIDR"}) end def self.validate_port_range(port_range) diff --git a/model/firewall.rb b/model/firewall.rb index 0034dffa5..d01fb2749 100644 --- a/model/firewall.rb +++ b/model/firewall.rb @@ -4,7 +4,7 @@ class Firewall < Sequel::Model one_to_many :firewall_rules, key: :firewall_id - many_to_one :vm, key: :vm_id + many_to_one :private_subnet, key: :private_subnet_id plugin :association_dependencies, firewall_rules: :destroy @@ -17,7 +17,7 @@ def insert_firewall_rule(cidr, port_range) port_range: port_range ) - vm&.incr_update_firewall_rules + private_subnet&.incr_update_firewall_rules fwr end end diff --git a/model/private_subnet.rb b/model/private_subnet.rb index 80c165577..187062c22 100644 --- a/model/private_subnet.rb +++ b/model/private_subnet.rb @@ -6,7 +6,7 @@ class PrivateSubnet < Sequel::Model many_to_many :vms, join_table: Nic.table_name, left_key: :private_subnet_id, right_key: :vm_id one_to_many :nics, key: :private_subnet_id one_to_one :strand, key: :id - one_to_many :firewall_rules + one_to_many :firewalls PRIVATE_SUBNET_RANGES = [ "10.0.0.0/8", @@ -14,6 +14,8 @@ class PrivateSubnet < Sequel::Model "192.168.0.0/16" ].freeze + plugin :association_dependencies, firewalls: :destroy + dataset_module Pagination dataset_module Authorization::Dataset include Authorization::HyperTagMethods @@ -42,7 +44,7 @@ def display_state end include SemaphoreMethods - semaphore :destroy, :refresh_keys, :add_new_nic + semaphore :destroy, :refresh_keys, :add_new_nic, :update_firewall_rules def self.random_subnet PRIVATE_SUBNET_RANGES.sample diff --git a/model/vm.rb b/model/vm.rb index 36535f0ff..cafe1db1e 100644 --- a/model/vm.rb +++ b/model/vm.rb @@ -11,9 +11,8 @@ class Vm < Sequel::Model one_to_one :assigned_vm_address, key: :dst_vm_id, class: :AssignedVmAddress one_to_many :vm_storage_volumes, key: :vm_id, order: Sequel.desc(:boot) one_to_one :active_billing_record, class: :BillingRecord, key: :resource_id do |ds| ds.active end - one_to_many :firewalls, key: :vm_id - plugin :association_dependencies, sshable: :destroy, assigned_vm_address: :destroy, vm_storage_volumes: :destroy, firewalls: :destroy + plugin :association_dependencies, sshable: :destroy, assigned_vm_address: :destroy, vm_storage_volumes: :destroy dataset_module Pagination dataset_module Authorization::Dataset @@ -31,6 +30,10 @@ def hyper_tag_name(project) include Authorization::TaggableMethods + def firewalls + private_subnets.flat_map(&:firewalls) + end + def display_location LocationNameConverter.to_display_name(location) end diff --git a/prog/vm/nexus.rb b/prog/vm/nexus.rb index 6b08b3c82..117727185 100644 --- a/prog/vm/nexus.rb +++ b/prog/vm/nexus.rb @@ -72,7 +72,7 @@ def self.assemble(public_key, project_id, name: nil, size: "standard-2", raise "Given subnet doesn't exist with the id #{private_subnet_id}" unless subnet raise "Given subnet is not available in the given project" unless project.private_subnets.any? { |ps| ps.id == subnet.id } else - subnet_s = Prog::Vnet::SubnetNexus.assemble(project_id, name: "#{name}-subnet", location: location) + subnet_s = Prog::Vnet::SubnetNexus.assemble(project_id, name: "#{name}-subnet", location: location, allow_only_ssh: allow_only_ssh) subnet = PrivateSubnet[subnet_s.id] end nic_s = Prog::Vnet::NicNexus.assemble(subnet.id, name: "#{name}-nic") @@ -90,10 +90,6 @@ def self.assemble(public_key, project_id, name: nil, size: "standard-2", boot_image: boot_image, ip4_enabled: enable_ip4, pool_id: pool_id, arch: arch) { _1.id = ubid.to_uuid } nic.update(vm_id: vm.id) - port_range = allow_only_ssh ? 22..22 : 0..65535 - fw = Firewall.create_with_id(vm_id: vm.id, name: "#{name}-default") - ["0.0.0.0/0", "::/0"].each { |cidr| FirewallRule.create_with_id(firewall_id: fw.id, cidr: cidr, port_range: Sequel.pg_range(port_range)) } - vm.associate_with_project(project) Strand.create( diff --git a/prog/vnet/subnet_nexus.rb b/prog/vnet/subnet_nexus.rb index e89642372..d66f84580 100644 --- a/prog/vnet/subnet_nexus.rb +++ b/prog/vnet/subnet_nexus.rb @@ -2,9 +2,9 @@ class Prog::Vnet::SubnetNexus < Prog::Base subject_is :private_subnet - semaphore :destroy, :refresh_keys, :add_new_nic + semaphore :destroy, :refresh_keys, :add_new_nic, :update_firewall_rules - def self.assemble(project_id, name: nil, location: "hetzner-hel1", ipv6_range: nil, ipv4_range: nil) + def self.assemble(project_id, name: nil, location: "hetzner-hel1", ipv6_range: nil, ipv4_range: nil, allow_only_ssh: false) unless (project = Project[project_id]) fail "No existing project" end @@ -20,6 +20,10 @@ def self.assemble(project_id, name: nil, location: "hetzner-hel1", ipv6_range: n DB.transaction do ps = PrivateSubnet.create(name: name, location: location, net6: ipv6_range, net4: ipv4_range, state: "waiting") { _1.id = ubid.to_uuid } ps.associate_with_project(project) + port_range = allow_only_ssh ? 22..22 : 0..65535 + fw = Firewall.create_with_id(private_subnet_id: ubid.to_uuid, name: "#{name}-default") + ["0.0.0.0/0", "::/0"].each { |cidr| FirewallRule.create_with_id(firewall_id: fw.id, cidr: cidr, port_range: Sequel.pg_range(port_range)) } + Strand.create(prog: "Vnet::SubnetNexus", label: "wait") { _1.id = ubid.to_uuid } end end @@ -44,6 +48,11 @@ def before_run hop_add_new_nic end + when_update_firewall_rules_set? do + private_subnet.vms.map(&:incr_update_firewall_rules) + decr_update_firewall_rules + end + if private_subnet.last_rekey_at < Time.now - 60 * 60 * 24 private_subnet.incr_refresh_keys end diff --git a/routes/api/project/location/vm.rb b/routes/api/project/location/vm.rb index a6b23c837..3a678cb7f 100644 --- a/routes/api/project/location/vm.rb +++ b/routes/api/project/location/vm.rb @@ -95,7 +95,7 @@ def handle_vm_requests(user, vm) request_body_params = Validation.validate_request_body(request.body.read, required_parameters, allowed_optional_parameters) - Validation.validate_cidr(request_body_params["cidr"]) + parsed_cidr = Validation.validate_cidr(request_body_params["cidr"]) port_range = if request_body_params["port_range"].nil? [0, 65535] else @@ -104,7 +104,7 @@ def handle_vm_requests(user, vm) pg_range = Sequel.pg_range(port_range.first..port_range.last) - vm.firewalls.first.insert_firewall_rule(request_body_params["cidr"], pg_range) + vm.firewalls.first.insert_firewall_rule(parsed_cidr.to_s, pg_range) serialize(vm, :detailed) end diff --git a/routes/web/project/location/vm.rb b/routes/web/project/location/vm.rb index 4a570925c..830f624a3 100644 --- a/routes/web/project/location/vm.rb +++ b/routes/web/project/location/vm.rb @@ -30,9 +30,10 @@ class CloverWeb Validation.validate_port_range(r.params["port_range"]) end + parsed_cidr = Validation.validate_cidr(r.params["cidr"]) pg_range = Sequel.pg_range(port_range.first..port_range.last) - vm.firewalls.first.insert_firewall_rule(r.params["cidr"], pg_range) + vm.firewalls.first.insert_firewall_rule(parsed_cidr.to_s, pg_range) flash["notice"] = "Firewall rule is created" r.redirect "#{@project.path}#{vm.path}" diff --git a/spec/lib/validation_spec.rb b/spec/lib/validation_spec.rb index e209dd209..09e227dfe 100644 --- a/spec/lib/validation_spec.rb +++ b/spec/lib/validation_spec.rb @@ -212,6 +212,10 @@ expect { described_class.validate_cidr("0.0.0.0/1") }.not_to raise_error expect { described_class.validate_cidr("192.168.1.0/24") }.not_to raise_error expect { described_class.validate_cidr("255.255.255.255/0") }.not_to raise_error + + expect { described_class.validate_cidr("::/0") }.not_to raise_error + expect { described_class.validate_cidr("::1/128") }.not_to raise_error + expect { described_class.validate_cidr("2001:db8::/32") }.not_to raise_error end it "invalid cidr" do @@ -219,6 +223,9 @@ expect { described_class.validate_cidr("10.256.0.0/8") }.to raise_error described_class::ValidationFailed expect { described_class.validate_cidr("172.16.0.0/33") }.to raise_error described_class::ValidationFailed expect { described_class.validate_cidr("not_a_cidr") }.to raise_error described_class::ValidationFailed + + expect { described_class.validate_cidr("::1/129") }.to raise_error described_class::ValidationFailed + expect { described_class.validate_cidr("::1/::1") }.to raise_error described_class::ValidationFailed end end diff --git a/spec/model/firewall_spec.rb b/spec/model/firewall_spec.rb index b22ee576c..524d776e3 100644 --- a/spec/model/firewall_spec.rb +++ b/spec/model/firewall_spec.rb @@ -18,9 +18,9 @@ end it "increments VMs update_firewall_rules if there is a VM" do - vm = instance_double(Vm) - expect(fw).to receive(:vm).and_return(vm) - expect(vm).to receive(:incr_update_firewall_rules) + private_subnet = instance_double(PrivateSubnet) + expect(fw).to receive(:private_subnet).and_return(private_subnet) + expect(private_subnet).to receive(:incr_update_firewall_rules) fw.insert_firewall_rule("0.0.0.0/0", nil) end end diff --git a/spec/prog/vnet/subnet_nexus_spec.rb b/spec/prog/vnet/subnet_nexus_spec.rb index e82f40aa4..a82dbe79c 100644 --- a/spec/prog/vnet/subnet_nexus_spec.rb +++ b/spec/prog/vnet/subnet_nexus_spec.rb @@ -24,43 +24,27 @@ end it "uses ipv6_addr if passed and creates entities" do - ps = instance_double(PrivateSubnet, id: "57afa8a7-2357-4012-9632-07fbe13a3133") - expect(ps).to receive(:associate_with_project).with(prj).and_return(true) - expect(PrivateSubnet).to receive(:create).with( - name: "default-ps", - location: "hetzner-hel1", - net6: "fd10:9b0b:6b4b:8fbb::/64", - net4: "10.0.0.0/26", - state: "waiting" - ).and_return(ps) expect(described_class).to receive(:random_private_ipv4).and_return("10.0.0.0/26") - expect(Strand).to receive(:create).with(prog: "Vnet::SubnetNexus", label: "wait").and_yield(Strand.new).and_return(Strand.new) - described_class.assemble( + ps = described_class.assemble( prj.id, name: "default-ps", location: "hetzner-hel1", ipv6_range: "fd10:9b0b:6b4b:8fbb::/64" ) + + expect(ps.subject.net6.to_s).to eq("fd10:9b0b:6b4b:8fbb::/64") end it "uses ipv4_addr if passed and creates entities" do - ps = instance_double(PrivateSubnet, id: "57afa8a7-2357-4012-9632-07fbe13a3133") - expect(ps).to receive(:associate_with_project).with(prj).and_return(true) - expect(PrivateSubnet).to receive(:create).with( - name: "default-ps", - location: "hetzner-hel1", - net6: "fd10:9b0b:6b4b:8fbb::/64", - net4: "10.0.0.0/26", - state: "waiting" - ).and_return(ps) expect(described_class).to receive(:random_private_ipv6).and_return("fd10:9b0b:6b4b:8fbb::/64") - expect(Strand).to receive(:create).with(prog: "Vnet::SubnetNexus", label: "wait").and_yield(Strand.new).and_return(Strand.new) - described_class.assemble( + ps = described_class.assemble( prj.id, name: "default-ps", location: "hetzner-hel1", ipv4_range: "10.0.0.0/26" ) + + expect(ps.subject.net4.to_s).to eq("10.0.0.0/26") end end @@ -134,6 +118,14 @@ expect { nx.wait }.to nap(30) end + it "triggers update_firewall_rules if when_update_firewall_rules_set?" do + expect(nx).to receive(:when_update_firewall_rules_set?).and_yield + expect(ps).to receive(:vms).and_return([instance_double(Vm, id: "vm1")]).at_least(:once) + expect(ps.vms.first).to receive(:incr_update_firewall_rules).and_return(true) + expect(nx).to receive(:decr_update_firewall_rules).and_return(true) + expect { nx.wait }.to nap(30) + end + it "naps if nothing to do" do expect { nx.wait }.to nap(30) end diff --git a/spec/routes/web/vm_spec.rb b/spec/routes/web/vm_spec.rb index d35a165e5..468074340 100644 --- a/spec/routes/web/vm_spec.rb +++ b/spec/routes/web/vm_spec.rb @@ -332,7 +332,7 @@ expect(page).to have_content "12.12.12.0/26" expect(page).to have_content "443" - expect(SemSnap.new(vm.id).set?("update_firewall_rules")).to be true + expect(SemSnap.new(vm.private_subnets.first.id).set?("update_firewall_rules")).to be true end end From a8e57097088af778bea979c58600035e67b5650f Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Wed, 10 Apr 2024 14:11:32 +0300 Subject: [PATCH 03/16] Move Postgres Firewalls from VMs to Subnets --- model/postgres/postgres_server.rb | 2 +- prog/postgres/postgres_server_nexus.rb | 1 - routes/web/project/location/postgres.rb | 3 ++- spec/prog/postgres/postgres_server_nexus_spec.rb | 4 ++-- spec/routes/api/project/location/postgres_spec.rb | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/model/postgres/postgres_server.rb b/model/postgres/postgres_server.rb index 96d67afcc..32150c0dd 100644 --- a/model/postgres/postgres_server.rb +++ b/model/postgres/postgres_server.rb @@ -165,7 +165,7 @@ def health_monitor_socket_path end def create_resource_firewall_rules - fw = Firewall.create_with_id(vm_id: vm.id, name: ubid.to_s, description: "Postgres default firewall") + fw = Firewall.create_with_id(private_subnet_id: vm.private_subnets.first.id, name: ubid.to_s, description: "Postgres default firewall") resource.firewall_rules.each do |pg_fwr| fw.insert_firewall_rule(pg_fwr.cidr.to_s, Sequel.pg_range(5432..5432)) end diff --git a/prog/postgres/postgres_server_nexus.rb b/prog/postgres/postgres_server_nexus.rb index e173daea1..f343ff639 100644 --- a/prog/postgres/postgres_server_nexus.rb +++ b/prog/postgres/postgres_server_nexus.rb @@ -291,7 +291,6 @@ def before_run # create a new set of firewall rules postgres_server.create_resource_firewall_rules - vm.incr_update_firewall_rules hop_wait end diff --git a/routes/web/project/location/postgres.rb b/routes/web/project/location/postgres.rb index 38cd429ce..4e854e266 100644 --- a/routes/web/project/location/postgres.rb +++ b/routes/web/project/location/postgres.rb @@ -27,11 +27,12 @@ class CloverWeb r.on "firewall-rule" do r.post true do Authorization.authorize(@current_user.id, "Postgres:Firewall:edit", pg.id) + parsed_cidr = Validation.validate_cidr(r.params["cidr"]) DB.transaction do PostgresFirewallRule.create_with_id( postgres_resource_id: pg.id, - cidr: r.params["cidr"] + cidr: parsed_cidr.to_s ) pg.incr_update_firewall_rules end diff --git a/spec/prog/postgres/postgres_server_nexus_spec.rb b/spec/prog/postgres/postgres_server_nexus_spec.rb index efa4c64f8..1c8e4f367 100644 --- a/spec/prog/postgres/postgres_server_nexus_spec.rb +++ b/spec/prog/postgres/postgres_server_nexus_spec.rb @@ -19,7 +19,8 @@ Vm, id: "1c7d59ee-8d46-8374-9553-6144490ecec5", sshable: sshable, - ephemeral_net4: "1.1.1.1" + ephemeral_net4: "1.1.1.1", + private_subnets: [instance_double(PrivateSubnet)] ) ) } @@ -472,7 +473,6 @@ expect(postgres_server.vm).to receive(:firewalls).and_return([fw]) expect(fw).to receive(:destroy) expect(postgres_server).to receive(:create_resource_firewall_rules) - expect(postgres_server.vm).to receive(:incr_update_firewall_rules) expect { nx.update_firewall_rules }.to hop("wait") end diff --git a/spec/routes/api/project/location/postgres_spec.rb b/spec/routes/api/project/location/postgres_spec.rb index d364dd608..ff7a00bd9 100644 --- a/spec/routes/api/project/location/postgres_spec.rb +++ b/spec/routes/api/project/location/postgres_spec.rb @@ -227,7 +227,7 @@ }.to_json expect(last_response.status).to eq(400) - expect(JSON.parse(last_response.body)["error"]["details"]["CIDR"]).to eq("Invalid CIDR") + expect(JSON.parse(last_response.body)["error"]["details"]["cidr"]).to eq("Invalid CIDR") end it "restore" do From 110d10e17611d79780a8be37cf104549751fcc55 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Thu, 25 Apr 2024 10:48:12 +0200 Subject: [PATCH 04/16] Remove vm_id from the firewall table --- migrate/20240425_remove_fw_vm_id.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 migrate/20240425_remove_fw_vm_id.rb diff --git a/migrate/20240425_remove_fw_vm_id.rb b/migrate/20240425_remove_fw_vm_id.rb new file mode 100644 index 000000000..fd42197ed --- /dev/null +++ b/migrate/20240425_remove_fw_vm_id.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + alter_table(:firewall) do + drop_column :vm_id + end + end + + down do + alter_table(:firewall) do + add_foreign_key :vm_id, :vm, type: :uuid + end + end +end From fcd6a3320e71c93ca8b8c5a4958312ab3915e99d Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Wed, 17 Apr 2024 13:49:53 +0200 Subject: [PATCH 05/16] Make Firewalls assignable to multiple Subnets migration --- migrate/20240412_add_multiple_fw_to_subnet.rb | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 migrate/20240412_add_multiple_fw_to_subnet.rb diff --git a/migrate/20240412_add_multiple_fw_to_subnet.rb b/migrate/20240412_add_multiple_fw_to_subnet.rb new file mode 100644 index 000000000..ccfd24947 --- /dev/null +++ b/migrate/20240412_add_multiple_fw_to_subnet.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + create_table(:firewalls_private_subnets) do + foreign_key :private_subnet_id, :private_subnet, type: :uuid + foreign_key :firewall_id, :firewall, type: :uuid + primary_key %i[private_subnet_id firewall_id] + end + + run <<~SQL + INSERT INTO firewalls_private_subnets (private_subnet_id, firewall_id) + SELECT private_subnet_id, id AS firewall_id + FROM firewall; + SQL + + alter_table(:firewall) do + drop_column :private_subnet_id + end + end +end From 07ce1ce630b27b20744b573976edf0158f03c4fc Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Wed, 17 Apr 2024 14:03:28 +0200 Subject: [PATCH 06/16] Make Firewalls assignable to multiple Subnets With this commit, our backend starts supporting Firewalls <-> PrivateSubnets many_to_many relationship. For that to work, we introduce a new model called FirewallsPrivateSubnets and maintain the relationship properly at the de/provisioning times. --- model/firewall.rb | 26 ++++++++++++++++-- model/firewalls_private_subnets.rb | 7 +++++ model/postgres/postgres_server.rb | 3 +- model/private_subnet.rb | 11 ++++++-- prog/vm/github_runner.rb | 5 +++- prog/vnet/subnet_nexus.rb | 4 ++- spec/model/firewall_spec.rb | 44 +++++++++++++++++++++++++++++- spec/model/private_subnet_spec.rb | 10 +++++++ spec/prog/vm/github_runner_spec.rb | 5 ++++ 9 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 model/firewalls_private_subnets.rb diff --git a/model/firewall.rb b/model/firewall.rb index d01fb2749..7b1790c18 100644 --- a/model/firewall.rb +++ b/model/firewall.rb @@ -4,7 +4,7 @@ class Firewall < Sequel::Model one_to_many :firewall_rules, key: :firewall_id - many_to_one :private_subnet, key: :private_subnet_id + many_to_many :private_subnets plugin :association_dependencies, firewall_rules: :destroy @@ -17,7 +17,29 @@ def insert_firewall_rule(cidr, port_range) port_range: port_range ) - private_subnet&.incr_update_firewall_rules + private_subnets.each(&:incr_update_firewall_rules) fwr end + + def destroy + DB.transaction do + private_subnets.each(&:incr_update_firewall_rules) + FirewallsPrivateSubnets.where(firewall_id: id).all.each(&:destroy) + super + end + end + + def associate_with_private_subnet(private_subnet, apply_firewalls: true) + add_private_subnet(private_subnet) + private_subnet.incr_update_firewall_rules if apply_firewalls + end + + def disassociate_from_private_subnet(private_subnet, apply_firewalls: true) + FirewallsPrivateSubnets.where( + private_subnet_id: private_subnet.id, + firewall_id: id + ).destroy + + private_subnet.incr_update_firewall_rules if apply_firewalls + end end diff --git a/model/firewalls_private_subnets.rb b/model/firewalls_private_subnets.rb new file mode 100644 index 000000000..aab26a9d5 --- /dev/null +++ b/model/firewalls_private_subnets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require_relative "../model" + +class FirewallsPrivateSubnets < Sequel::Model + include ResourceMethods +end diff --git a/model/postgres/postgres_server.rb b/model/postgres/postgres_server.rb index 32150c0dd..6396d3419 100644 --- a/model/postgres/postgres_server.rb +++ b/model/postgres/postgres_server.rb @@ -165,7 +165,8 @@ def health_monitor_socket_path end def create_resource_firewall_rules - fw = Firewall.create_with_id(private_subnet_id: vm.private_subnets.first.id, name: ubid.to_s, description: "Postgres default firewall") + fw = Firewall.create_with_id(name: ubid.to_s, description: "Postgres default firewall") + fw.add_private_subnet(vm.private_subnets.first) resource.firewall_rules.each do |pg_fwr| fw.insert_firewall_rule(pg_fwr.cidr.to_s, Sequel.pg_range(5432..5432)) end diff --git a/model/private_subnet.rb b/model/private_subnet.rb index 187062c22..226e15cfe 100644 --- a/model/private_subnet.rb +++ b/model/private_subnet.rb @@ -6,7 +6,7 @@ class PrivateSubnet < Sequel::Model many_to_many :vms, join_table: Nic.table_name, left_key: :private_subnet_id, right_key: :vm_id one_to_many :nics, key: :private_subnet_id one_to_one :strand, key: :id - one_to_many :firewalls + many_to_many :firewalls PRIVATE_SUBNET_RANGES = [ "10.0.0.0/8", @@ -14,8 +14,6 @@ class PrivateSubnet < Sequel::Model "192.168.0.0/16" ].freeze - plugin :association_dependencies, firewalls: :destroy - dataset_module Pagination dataset_module Authorization::Dataset include Authorization::HyperTagMethods @@ -25,6 +23,13 @@ def hyper_tag_name(project) include Authorization::TaggableMethods + def destroy + DB.transaction do + FirewallsPrivateSubnets.where(private_subnet_id: id).all.each(&:destroy) + super + end + end + def display_location LocationNameConverter.to_display_name(location) end diff --git a/prog/vm/github_runner.rb b/prog/vm/github_runner.rb index f54fce602..ea3510d89 100644 --- a/prog/vm/github_runner.rb +++ b/prog/vm/github_runner.rb @@ -352,7 +352,10 @@ def setup_info end if vm - vm.private_subnets.each { _1.incr_destroy } + vm.private_subnets.each do |subnet| + subnet.firewalls.map(&:destroy) + subnet.incr_destroy + end # If the runner is not assigned any job and we destroy it after a # timeline, the workflow_job is nil, in that case, we want to be able to diff --git a/prog/vnet/subnet_nexus.rb b/prog/vnet/subnet_nexus.rb index d66f84580..a3499ab1f 100644 --- a/prog/vnet/subnet_nexus.rb +++ b/prog/vnet/subnet_nexus.rb @@ -21,8 +21,9 @@ def self.assemble(project_id, name: nil, location: "hetzner-hel1", ipv6_range: n ps = PrivateSubnet.create(name: name, location: location, net6: ipv6_range, net4: ipv4_range, state: "waiting") { _1.id = ubid.to_uuid } ps.associate_with_project(project) port_range = allow_only_ssh ? 22..22 : 0..65535 - fw = Firewall.create_with_id(private_subnet_id: ubid.to_uuid, name: "#{name}-default") + fw = Firewall.create_with_id(name: "#{name}-default") ["0.0.0.0/0", "::/0"].each { |cidr| FirewallRule.create_with_id(firewall_id: fw.id, cidr: cidr, port_range: Sequel.pg_range(port_range)) } + fw.associate_with_private_subnet(ps, apply_firewalls: false) Strand.create(prog: "Vnet::SubnetNexus", label: "wait") { _1.id = ubid.to_uuid } end @@ -137,6 +138,7 @@ def gen_reqid decr_destroy strand.children.each { _1.destroy } + private_subnet.firewalls.map { _1.disassociate_from_private_subnet(private_subnet, apply_firewalls: false) } if private_subnet.nics.empty? DB.transaction do diff --git a/spec/model/firewall_spec.rb b/spec/model/firewall_spec.rb index 524d776e3..7ad3a30cf 100644 --- a/spec/model/firewall_spec.rb +++ b/spec/model/firewall_spec.rb @@ -19,9 +19,51 @@ it "increments VMs update_firewall_rules if there is a VM" do private_subnet = instance_double(PrivateSubnet) - expect(fw).to receive(:private_subnet).and_return(private_subnet) + expect(fw).to receive(:private_subnets).and_return([private_subnet]) expect(private_subnet).to receive(:incr_update_firewall_rules) fw.insert_firewall_rule("0.0.0.0/0", nil) end + + it "associates with a private subnet" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + expect(ps).to receive(:incr_update_firewall_rules) + fw.associate_with_private_subnet(ps) + + expect(fw.private_subnets.count).to eq(1) + expect(fw.private_subnets.first.id).to eq(ps.id) + end + + it "disassociates from a private subnet" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + fw.associate_with_private_subnet(ps, apply_firewalls: false) + expect(fw.private_subnets.count).to eq(1) + + expect(ps).to receive(:incr_update_firewall_rules) + fw.disassociate_from_private_subnet(ps) + expect(fw.reload.private_subnets.count).to eq(0) + expect(FirewallsPrivateSubnets.where(firewall_id: fw.id).count).to eq(0) + end + + it "disassociates from a private subnet without applying firewalls" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + fw.associate_with_private_subnet(ps, apply_firewalls: false) + expect(fw.private_subnets.count).to eq(1) + + expect(ps).not_to receive(:incr_update_firewall_rules) + fw.disassociate_from_private_subnet(ps, apply_firewalls: false) + expect(fw.reload.private_subnets.count).to eq(0) + expect(FirewallsPrivateSubnets.where(firewall_id: fw.id).count).to eq(0) + end + + it "destroys firewall" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + fw.associate_with_private_subnet(ps, apply_firewalls: false) + expect(fw.reload.private_subnets.count).to eq(1) + expect(fw.private_subnets).to receive(:each).and_return([ps]) + expect(FirewallsPrivateSubnets.where(firewall_id: fw.id).count).to eq(1) + fw.destroy + expect(FirewallsPrivateSubnets.where(firewall_id: fw.id).count).to eq(0) + expect(described_class[fw.id]).to be_nil + end end end diff --git a/spec/model/private_subnet_spec.rb b/spec/model/private_subnet_spec.rb index b580f2887..7c62c3508 100644 --- a/spec/model/private_subnet_spec.rb +++ b/spec/model/private_subnet_spec.rb @@ -72,4 +72,14 @@ expect(private_subnet.display_state).to eq "failed" end end + + describe "destroy" do + it "destroys firewalls private subnets" do + ps = described_class.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + fwps = instance_double(FirewallsPrivateSubnets) + expect(FirewallsPrivateSubnets).to receive(:where).with(private_subnet_id: ps.id).and_return(instance_double(Sequel::Dataset, all: [fwps])) + expect(fwps).to receive(:destroy).once + ps.destroy + end + end end diff --git a/spec/prog/vm/github_runner_spec.rb b/spec/prog/vm/github_runner_spec.rb index c0825c384..4abf79754 100644 --- a/spec/prog/vm/github_runner_spec.rb +++ b/spec/prog/vm/github_runner_spec.rb @@ -506,6 +506,11 @@ expect(github_runner).to receive(:workflow_job).and_return({"conclusion" => "failure"}).at_least(:once) vm_host = instance_double(VmHost, sshable: sshable) + fws = [instance_double(Firewall)] + ps = instance_double(PrivateSubnet, firewalls: fws) + expect(fws.first).to receive(:destroy) + expect(ps).to receive(:incr_destroy) + expect(vm).to receive(:private_subnets).and_return([ps]) expect(vm).to receive(:vm_host).and_return(vm_host) expect(sshable).to receive(:cmd).with("sudo ln /vm/9qf22jbv/serial.log /var/log/ubicloud/serials/#{github_runner.ubid}_serial.log") expect(sshable).to receive(:cmd).with("journalctl -u runner-script --no-pager | grep -v -e Started -e sudo") From 06150d3a95050e52459005c0fafbf0b71fef8280 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 14:43:10 +0200 Subject: [PATCH 07/16] Update Sidebar to have Firewalls This commit updates sidebar to have firewalls. Additionally, we change the Networking tab to Private Subnet to make it more inline with the endpoint name. --- views/components/icon.erb | 5 +++++ views/layouts/sidebar/content.erb | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/views/components/icon.erb b/views/components/icon.erb index a74d66d56..035d46e80 100644 --- a/views/components/icon.erb +++ b/views/components/icon.erb @@ -113,6 +113,11 @@ +<% when "hero-firewall" %> + + + + <% else %>

Not found icon

<% end %> diff --git a/views/layouts/sidebar/content.erb b/views/layouts/sidebar/content.erb index c309b4caa..5895fe7c5 100644 --- a/views/layouts/sidebar/content.erb +++ b/views/layouts/sidebar/content.erb @@ -28,12 +28,21 @@ <%== render( "layouts/sidebar/item", locals: { - name: "Networking", + name: "Private Subnet", url: "#{@project_data[:path]}/private-subnet", is_active: request.path.start_with?("#{@project_data[:path]}/private-subnet"), icon: "hero-globe-alt" } ) %> + <%== render( + "layouts/sidebar/item", + locals: { + name: "Firewall", + url: "#{@project_data[:path]}/firewall", + is_active: request.path.start_with?("#{@project_data[:path]}/firewall"), + icon: "hero-firewall" + } + ) %> <%== render( "layouts/sidebar/item", locals: { From 1e1709b4a1ed01ae7d38d7e350a381beada7bdcf Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 14:49:14 +0200 Subject: [PATCH 08/16] Add Firewall Index page --- model/firewall.rb | 12 ++++++ model/project.rb | 1 + routes/web/project/firewall.rb | 15 ++++++++ serializers/web/firewall.rb | 2 + views/firewall/index.erb | 68 ++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 routes/web/project/firewall.rb create mode 100644 views/firewall/index.erb diff --git a/model/firewall.rb b/model/firewall.rb index 7b1790c18..d4357da7c 100644 --- a/model/firewall.rb +++ b/model/firewall.rb @@ -9,6 +9,18 @@ class Firewall < Sequel::Model plugin :association_dependencies, firewall_rules: :destroy include ResourceMethods + include Authorization::TaggableMethods + include Authorization::HyperTagMethods + def hyper_tag_name(project) + "project/#{project.ubid}/firewall/#{ubid}" + end + + dataset_module Pagination + dataset_module Authorization::Dataset + + def path + "/firewall/#{ubid}" + end def insert_firewall_rule(cidr, port_range) fwr = FirewallRule.create_with_id( diff --git a/model/project.rb b/model/project.rb index a9e98d46f..563c4df04 100644 --- a/model/project.rb +++ b/model/project.rb @@ -13,6 +13,7 @@ class Project < Sequel::Model many_to_many :minio_clusters, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id many_to_many :private_subnets, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id many_to_many :postgres_resources, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id + many_to_many :firewalls, join_table: AccessTag.table_name, left_key: :project_id, right_key: :hyper_tag_id one_to_many :invoices, order: Sequel.desc(:created_at) diff --git a/routes/web/project/firewall.rb b/routes/web/project/firewall.rb new file mode 100644 index 000000000..5912ca701 --- /dev/null +++ b/routes/web/project/firewall.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CloverWeb + hash_branch(:project_prefix, "firewall") do |r| + @serializer = Serializers::Web::Firewall + + r.get true do + authorized_firewalls = @project.firewalls_dataset.authorized(@current_user.id, "Firewall:view").all + @firewalls = serialize(authorized_firewalls) + + view "firewall/index" + end + + end +end diff --git a/serializers/web/firewall.rb b/serializers/web/firewall.rb index 909467837..52d452f16 100644 --- a/serializers/web/firewall.rb +++ b/serializers/web/firewall.rb @@ -3,9 +3,11 @@ class Serializers::Web::Firewall < Serializers::Base def self.base(firewall) { + ubid: firewall.ubid, id: firewall.id, name: firewall.name, description: firewall.description, + path: firewall.path, firewall_rules: firewall.firewall_rules.sort_by { |fwr| fwr.cidr.version && fwr.cidr.to_s }.map { |fw| Serializers::Web::FirewallRule.serialize(fw) } } end diff --git a/views/firewall/index.erb b/views/firewall/index.erb new file mode 100644 index 000000000..822c5ab49 --- /dev/null +++ b/views/firewall/index.erb @@ -0,0 +1,68 @@ +<% @page_title = "Firewalls" %> + +<% if @firewalls.count > 0 %> +
+ <%== render( + "components/breadcrumb", + locals: { + back: @project_data[:path], + parts: [%w[Projects /project], [@project_data[:name], @project_data[:path]], ["Firewalls", "#"]] + } + ) %> + + <%== render( + "components/page_header", + locals: { + title: "Firewalls", + right_items: has_project_permission("Firewall:create") ? [ + render("components/button", locals: { text: "Create Firewall", link: "firewall/create" }) + ] : [] + } + ) %> +
+ +
+
+ + + + + + + + + <% @firewalls.each do |fw| %> + + + + + <% end %> + +
NameDescription
+ <% if Authorization.has_permission?(@current_user.id, "Firewall:view", fw[:id]) %> + <%= fw[:name] + %> + <% else %> + <%= fw[:name] %> + <% end %> + <%= fw[:description] %>
+
+
+<% else %> + <%== render( + "components/empty_state", + locals: { + icon: "hero-x-circle", + title: "No firewalls", + description: "You don't have permission to create firewalls." + }.merge(has_project_permission("Firewall:create") ? { + description: "Get started by creating a new firewall.", + button_link: "#{@project_data[:path]}/firewall/create", + button_title: "New Firewall" + } : {}) + ) %> +<% end %> From 19307d212ee8c60af582d86c6f81dd0611b78f28 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 14:54:53 +0200 Subject: [PATCH 09/16] Add Firewall Create page --- routes/web/project/firewall.rb | 26 ++++++++++++ views/firewall/create.erb | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 views/firewall/create.erb diff --git a/routes/web/project/firewall.rb b/routes/web/project/firewall.rb index 5912ca701..91acbf27a 100644 --- a/routes/web/project/firewall.rb +++ b/routes/web/project/firewall.rb @@ -11,5 +11,31 @@ class CloverWeb view "firewall/index" end + r.on "create" do + r.get true do + Authorization.authorize(@current_user.id, "Firewall:create", @project.id) + authorized_subnets = @project.private_subnets_dataset.authorized(@current_user.id, "PrivateSubnet:edit").all + @subnets = Serializers::Web::PrivateSubnet.serialize(authorized_subnets) + view "firewall/create" + end + end + + r.post true do + Authorization.authorize(@current_user.id, "Firewall:create", @project.id) + Validation.validate_name(r.params["name"]) + + fw = Firewall.create_with_id( + name: r.params["name"], + description: r.params["description"] + ) + fw.associate_with_project(@project) + + ps = PrivateSubnet.from_ubid(r.params["private-subnet-id"]) + fw.associate_with_private_subnet(ps) if ps + + flash["notice"] = "'#{r.params["name"]}' is created" + + r.redirect "#{@project.path}#{Firewall[fw.id].path}" + end end end diff --git a/views/firewall/create.erb b/views/firewall/create.erb new file mode 100644 index 000000000..729e8a8cf --- /dev/null +++ b/views/firewall/create.erb @@ -0,0 +1,78 @@ +<% @page_title = "Create Firewall" %> + +
+ <%== render( + "components/breadcrumb", + locals: { + back: "#{@project_data[:path]}/firewall", + parts: [ + %w[Projects /project], + [@project_data[:name], @project_data[:path]], + ["Firewalls", "#{@project_data[:path]}/firewall"], + %w[Create #] + ] + } + ) %> + <%== render("components/page_header", locals: { title: "Create Firewall" }) %> +
+ +
+
" method="POST"> + <%== csrf_tag("#{@project_data[:path]}/firewall") %> + +
+
+
+
+

Details

+

Enter details for your firewall.

+
+
+ <%== render( + "components/form/text", + locals: { + name: "name", + label: "Name", + attributes: { + required: true, + placeholder: "Enter name" + } + } + ) %> +
+
+ <%== render( + "components/form/text", + locals: { + name: "description", + label: "Description", + attributes: { + placeholder: "Enter description" + } + } + ) %> +
+
+ <%== render( + "components/form/select", + locals: { + name: "private-subnet-id", + label: "Private Subnet", + options: @subnets.map { |s| [s[:ubid], s[:name]] }, + placeholder: "Select private subnet" + } + ) %> +
+
+
+
+
+
+
+ Cancel + <%== render("components/form/submit_button", locals: { text: "Create" }) %> +
+
+
+
+
From 098b15b10d6891118c0fbee00263b19b46fd2f23 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 14:59:13 +0200 Subject: [PATCH 10/16] Add Firewall Show page This commit does not only adds a show page but implements various functionality to manage Firewall <-> Private Subnet and Firewall <-> Firewall Rules relationships. Here are the itemized actions; 1. Show Firewall. 2. Show Firewall Rules. 3. List Attached Private Subnets. 4. Attach Firewall to a Private Subnet. 5. Detach Firewall from a Private Subnet. 6. Add a Firewall Rule to Firewall. 7. Remove a Firewall Rule from a Firewall. --- model/firewall.rb | 5 + routes/web/project/firewall.rb | 103 ++++++++ serializers/web/firewall.rb | 6 + spec/routes/web/firewall_spec.rb | 341 +++++++++++++++++++++++++ spec/routes/web/private_subnet_spec.rb | 14 + views/firewall/show.erb | 183 +++++++++++++ 6 files changed, 652 insertions(+) create mode 100644 spec/routes/web/firewall_spec.rb create mode 100644 views/firewall/show.erb diff --git a/model/firewall.rb b/model/firewall.rb index d4357da7c..5ab7f6928 100644 --- a/model/firewall.rb +++ b/model/firewall.rb @@ -22,6 +22,11 @@ def path "/firewall/#{ubid}" end + def remove_firewall_rule(firewall_rule) + firewall_rule.destroy + private_subnets.map(&:incr_update_firewall_rules) + end + def insert_firewall_rule(cidr, port_range) fwr = FirewallRule.create_with_id( firewall_id: id, diff --git a/routes/web/project/firewall.rb b/routes/web/project/firewall.rb index 91acbf27a..fc76acb75 100644 --- a/routes/web/project/firewall.rb +++ b/routes/web/project/firewall.rb @@ -37,5 +37,108 @@ class CloverWeb r.redirect "#{@project.path}#{Firewall[fw.id].path}" end + + r.on String do |fw_ubid| + fw = Firewall.from_ubid(fw_ubid) + + unless fw + response.status = 404 + r.halt + end + + r.on "attach-subnet" do + r.post true do + Authorization.authorize(@current_user.id, "Firewall:view", fw.id) + ps = PrivateSubnet.from_ubid(r.params["private-subnet-id"]) + unless ps + flash["error"] = "Private subnet not found" + response.status = 404 + r.redirect "#{@project.path}#{fw.path}" + end + + Authorization.authorize(@current_user.id, "PrivateSubnet:edit", ps.id) + + fw.associate_with_private_subnet(ps) + + flash["notice"] = "Private subnet is attached to the firewall" + + r.redirect "#{@project.path}#{fw.path}" + end + end + + r.on "detach-subnet" do + r.post true do + Authorization.authorize(@current_user.id, "Firewall:view", fw.id) + ps = PrivateSubnet.from_ubid(r.params["private-subnet-id"]) + unless ps + flash["error"] = "Private subnet not found" + response.status = 404 + r.redirect "#{@project.path}#{fw.path}" + end + + Authorization.authorize(@current_user.id, "PrivateSubnet:edit", ps.id) + + fw.disassociate_from_private_subnet(ps) + + flash["notice"] = "Private subnet #{ps.name} is detached from the firewall" + + r.redirect "#{@project.path}#{fw.path}" + end + end + + r.get true do + Authorization.authorize(@current_user.id, "Firewall:view", fw.id) + project_subnets = @project.private_subnets_dataset.authorized(@current_user.id, "PrivateSubnet:view").all + attached_subnets = fw.private_subnets_dataset.all + @attachable_subnets = Serializers::Web::PrivateSubnet.serialize(project_subnets.reject { |ps| attached_subnets.map(&:id).include?(ps.id) }) + @firewall = serialize(fw, :detailed) + + view "firewall/show" + end + + r.on "firewall-rule" do + r.post true do + Authorization.authorize(@current_user.id, "Firewall:edit", fw.id) + + port_range = if r.params["port_range"].empty? + [0, 65535] + else + Validation.validate_port_range(r.params["port_range"]) + end + + parsed_cidr = Validation.validate_cidr(r.params["cidr"]) + pg_range = Sequel.pg_range(port_range.first..port_range.last) + + fw.insert_firewall_rule(parsed_cidr.to_s, pg_range) + flash["notice"] = "Firewall rule is created" + + r.redirect "#{@project.path}#{fw.path}" + end + + r.is String do |firewall_rule_ubid| + r.delete true do + Authorization.authorize(@current_user.id, "Firewall:edit", fw.id) + fwr = FirewallRule.from_ubid(firewall_rule_ubid) + unless fwr + response.status = 204 + r.halt + end + + fw.remove_firewall_rule(fwr) + + return {message: "Firewall rule deleted"}.to_json + end + end + end + + r.delete true do + Authorization.authorize(@current_user.id, "Firewall:delete", fw.id) + fw.private_subnets.map { Authorization.authorize(@current_user.id, "PrivateSubnet:edit", _1.id) } + fw.dissociate_with_project(@project) + fw.destroy + + return {message: "Deleting #{fw.name}"}.to_json + end + end end end diff --git a/serializers/web/firewall.rb b/serializers/web/firewall.rb index 52d452f16..2e8d669de 100644 --- a/serializers/web/firewall.rb +++ b/serializers/web/firewall.rb @@ -15,4 +15,10 @@ def self.base(firewall) structure(:default) do |firewall| base(firewall) end + + structure(:detailed) do |firewall| + base(firewall).merge( + private_subnets: firewall.private_subnets.map { |ps| Serializers::Web::PrivateSubnet.serialize(ps) } + ) + end end diff --git a/spec/routes/web/firewall_spec.rb b/spec/routes/web/firewall_spec.rb new file mode 100644 index 000000000..2656d2e11 --- /dev/null +++ b/spec/routes/web/firewall_spec.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe Clover, "firewall" do + let(:user) { create_account } + + let(:project) { user.create_project_with_default_policy("project-1") } + + let(:project_wo_permissions) { user.create_project_with_default_policy("project-2", policy_body: []) } + + let(:firewall) do + fw = Firewall.create_with_id(name: "dummy-fw", description: "dummy-fw") + fw.associate_with_project(project) + fw + end + + let(:fw_wo_permission) { + fw = Firewall.create_with_id(name: "dummy-fw-2", description: "dummy-fw-2") + fw.associate_with_project(project_wo_permissions) + fw + } + + describe "unauthenticated" do + it "can not list without login" do + visit "/firewall" + + expect(page.title).to eq("Ubicloud - Login") + end + + it "can not create without login" do + visit "/firewall/create" + + expect(page.title).to eq("Ubicloud - Login") + end + end + + describe "authenticated" do + before do + login(user.email) + end + + describe "list" do + it "can list no firewalls" do + visit "#{project.path}/firewall" + + expect(page.title).to eq("Ubicloud - Firewalls") + expect(page).to have_content "No firewalls" + + click_link "New Firewall" + expect(page.title).to eq("Ubicloud - Create Firewall") + end + + it "can not list firewalls when does not have permissions" do + firewall + fw_wo_permission + visit "#{project.path}/firewall" + + expect(page.title).to eq("Ubicloud - Firewalls") + expect(page).to have_content firewall.name + expect(page).to have_no_content fw_wo_permission.name + end + end + + describe "create" do + it "can create new firewall" do + project + visit "#{project.path}/firewall/create" + + expect(page.title).to eq("Ubicloud - Create Firewall") + name = "dummy-fw" + fill_in "Name", with: name + fill_in "Description", with: name + + click_button "Create" + + expect(page.title).to eq("Ubicloud - #{name}") + expect(page).to have_content "'#{name}' is created" + expect(Firewall.count).to eq(1) + expect(Firewall.first.projects.first.id).to eq(project.id) + end + + it "can create new firewall with private subnet" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1", location: "hetzner-hel1").subject + + visit "#{project.path}/firewall/create" + + expect(page.title).to eq("Ubicloud - Create Firewall") + name = "dummy-fw-1" + fill_in "Name", with: name + fill_in "Description", with: name + select ps.name, from: "private-subnet-id" + + click_button "Create" + + expect(page.title).to eq("Ubicloud - #{name}") + expect(page).to have_content "'#{name}' is created" + fw = Firewall[name: name] + expect(fw.private_subnets.first.id).to eq(ps.id) + + visit "#{project.path}#{ps.path}" + expect(page).to have_content name + + visit "#{project.path}#{fw.path}" + expect(page).to have_content ps.name + end + + it "can not create firewall with invalid name" do + project + visit "#{project.path}/firewall/create" + + expect(page.title).to eq("Ubicloud - Create Firewall") + + fill_in "Name", with: "invalid name" + + click_button "Create" + + expect(page.title).to eq("Ubicloud - Create Firewall") + expect(page).to have_content "Name must only contain" + expect((find "input[name=name]")["value"]).to eq("invalid name") + end + + it "can not create firewall in a project when does not have permissions" do + project_wo_permissions + visit "#{project_wo_permissions.path}/firewall/create" + + expect(page.title).to eq("Ubicloud - Forbidden") + expect(page.status_code).to eq(403) + expect(page).to have_content "Forbidden" + end + end + + describe "show" do + it "can show firewall details" do + firewall + visit "#{project.path}/firewall" + + expect(page.title).to eq("Ubicloud - Firewalls") + expect(page).to have_content firewall.name + + click_link firewall.name, href: "#{project.path}#{firewall.path}" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content firewall.name + end + + it "raises forbidden when does not have permissions" do + visit "#{project_wo_permissions.path}#{fw_wo_permission.path}" + + expect(page.title).to eq("Ubicloud - Forbidden") + expect(page.status_code).to eq(403) + expect(page).to have_content "Forbidden" + end + + it "raises not found when firewall not exists" do + visit "#{project.path}/firewall/08s56d4kaj94xsmrnf5v5m3mav" + + expect(page.title).to eq("Ubicloud - ResourceNotFound") + expect(page.status_code).to eq(404) + expect(page).to have_content "ResourceNotFound" + end + end + + describe "subnets" do + it "can show" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1", location: "hetzner-hel1").subject + firewall.associate_with_private_subnet(ps) + + visit "#{project.path}#{firewall.path}" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content ps.name + end + + it "can attach subnet" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1", location: "hetzner-hel1").subject + + visit "#{project.path}#{firewall.path}" + select ps.name, from: "private-subnet-id" + click_button "Attach" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Private subnet is attached to the firewall" + expect(firewall.private_subnets_dataset.count).to eq(1) + + visit "#{project.path}#{firewall.path}" + expect(page).to have_content ps.name + + visit "#{project.path}#{ps.path}" + expect(page).to have_content firewall.name + end + + it "can not attach subnet when it does not exist" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1", location: "hetzner-hel1").subject + visit "#{project.path}#{firewall.path}" + select "dummy-ps-1", from: "private-subnet-id" + ps.destroy + click_button "Attach" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Private subnet not found" + expect(firewall.private_subnets_dataset.count).to eq(0) + end + + it "can detach subnet" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1111", location: "hetzner-hel1").subject + expect(page).to have_no_content ps.name + + firewall.associate_with_private_subnet(ps) + + visit "#{project.path}#{firewall.path}" + click_button "Detach" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Private subnet #{ps.name} is detached from the firewall" + expect(firewall.private_subnets_dataset.count).to eq(0) + + visit "#{project.path}#{ps.path}" + expect(page).to have_no_content firewall.name + end + + it "can not detach subnet when it does not exist" do + ps = Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-1", location: "hetzner-hel1").subject + visit "#{project.path}#{firewall.path}" + select "dummy-ps-1", from: "private-subnet-id" + click_button "Attach" + visit "#{project.path}#{firewall.path}" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(firewall.private_subnets_dataset.count).to eq(1) + ps.destroy + click_button "Detach" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Private subnet not found" + expect(firewall.private_subnets_dataset.count).to eq(0) + end + end + + describe "rules" do + it "can add" do + visit "#{project.path}#{firewall.path}" + + fill_in "cidr", with: "1.1.1.1/8" + fill_in "port_range", with: "80" + + click_button "Create" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Firewall rule is created" + expect(firewall.firewall_rules_dataset.count).to eq(1) + end + + it "can not add rule when it is invalid" do + visit "#{project.path}#{firewall.path}" + + fill_in "cidr", with: "invalid" + + click_button "Create" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Invalid CIDR" + + fill_in "cidr", with: "1.1.1.1/32" + fill_in "port_range", with: "invalid" + + click_button "Create" + + expect(page.title).to eq("Ubicloud - #{firewall.name}") + expect(page).to have_content "Invalid port range" + + expect(firewall.firewall_rules_dataset.count).to eq(0) + end + + it "can delete rule" do + firewall.insert_firewall_rule("1.0.0.0/8", Sequel.pg_range(80..80)) + + visit "#{project.path}#{firewall.path}" + + btn = find "#fwr-delete-#{firewall.firewall_rules.first.ubid} .delete-btn" + page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]} + + expect(page.body).to eq({message: "Firewall rule deleted"}.to_json) + expect(firewall.firewall_rules_dataset.count).to eq(0) + + visit "#{project.path}#{firewall.path}" + expect(page).to have_no_content "1.0.0.0/8" + end + + it "accepts delete rule if it's already deleted" do + firewall.insert_firewall_rule("1.0.0.0/8", Sequel.pg_range(80..80)) + + visit "#{project.path}#{firewall.path}" + + firewall.remove_firewall_rule(firewall.firewall_rules.first) + btn = find "#fwr-delete-#{firewall.firewall_rules.first.ubid} .delete-btn" + expect { page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]} }.not_to raise_error + + expect(firewall.firewall_rules_dataset.count).to eq(0) + end + + it "can show firewall rules which have port_range nil" do + firewall.insert_firewall_rule("1.0.0.0/8", nil) + + visit "#{project.path}#{firewall.path}" + + expect(page).to have_content "1.0.0.0/8" + expect(page).to have_content "0..65535" + + expect(firewall.firewall_rules_dataset.count).to eq(1) + end + end + + describe "delete" do + it "can delete firewall" do + visit "#{project.path}#{firewall.path}" + + # We send delete request manually instead of just clicking to button because delete action triggered by JavaScript. + # UI tests run without a JavaScript enginer. + btn = find ".delete-btn" + page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]} + + expect(page.body).to eq({message: "Deleting #{firewall.name}"}.to_json) + expect(Firewall.count).to eq(0) + end + + it "can not delete firewall when does not have permissions" do + # Give permission to view, so we can see the detail page + project_wo_permissions.access_policies.first.update(body: { + acls: [ + {subjects: user.hyper_tag_name, actions: ["Firewall:view"], objects: project_wo_permissions.hyper_tag_name} + ] + }) + + visit "#{project_wo_permissions.path}#{fw_wo_permission.path}" + + expect { find ".delete-btn" }.to raise_error Capybara::ElementNotFound + end + end + end +end diff --git a/spec/routes/web/private_subnet_spec.rb b/spec/routes/web/private_subnet_spec.rb index 2f947c0dc..6e1524aaa 100644 --- a/spec/routes/web/private_subnet_spec.rb +++ b/spec/routes/web/private_subnet_spec.rb @@ -169,6 +169,20 @@ end end + describe "show firewalls" do + it "can show attached firewalls" do + private_subnet + fw = Firewall.create_with_id(name: "dummy-fw", description: "dummy-fw") + fw.associate_with_private_subnet(private_subnet) + + visit "#{project.path}#{private_subnet.path}" + + expect(page.title).to eq("Ubicloud - #{private_subnet.name}") + expect(page).to have_content fw.name + expect(page).to have_content fw.description + end + end + describe "delete" do it "can delete private subnet" do visit "#{project.path}#{private_subnet.path}" diff --git a/views/firewall/show.erb b/views/firewall/show.erb new file mode 100644 index 000000000..f7e63ed24 --- /dev/null +++ b/views/firewall/show.erb @@ -0,0 +1,183 @@ +<% @page_title = @firewall[:name] %> + +
+ <%== render( + "components/breadcrumb", + locals: { + back: "#{@project_data[:path]}/firewall", + parts: [ + %w[Projects /project], + [@project_data[:name], @project_data[:path]], + ["Firewall", "#{@project_data[:path]}/firewall"], + [@firewall[:name], "#"] + ] + } + ) %> + <%== render("components/page_header", locals: { title: @firewall[:name] }) %> +
+
+ + <%== render( + "components/kv_data_card", + locals: { + data: [["ID", @firewall[:ubid]], ["Name", @firewall[:name]], ["Description", @firewall[:description]]] + } + ) %> +
+
+

+ Firewall Rules +

+
+
+
+ + + + + + <% if Authorization.has_permission?(@current_user.id, "Firewall:edit", @firewall[:id]) %> + + <% end %> + + + + <% @firewall[:firewall_rules].each do |fwr| %> + + + + <% if Authorization.has_permission?(@current_user.id, "Firewall:edit", @firewall[:id]) %> + + <% end %> + + <% end %> + <% if Authorization.has_permission?(@current_user.id, "Firewall:edit", @firewall[:id]) %> + + " role="form" method="POST"> + <%== csrf_tag("#{request.path}/firewall-rule") %> + + + + + + <% end %> + +
CIDRPort range
<%= fwr[:cidr] %><%= fwr[:port_range] %> + +
+ <%== render( + "components/form/text", + locals: { + name: "cidr", + type: "cidr", + attributes: { + placeholder: "0.0.0.0/0", + required: true + } + } + ) %> + + <%== render("components/form/text", locals: { name: "port_range", type: "text", attributes: { placeholder: "0..65536" } }) %> + + <%== render("components/form/submit_button", locals: { text: "Create" }) %> +
+
+
+
+

+ Attached Private Subnets +

+
+
+
+ + + + + <% if Authorization.has_permission?(@current_user.id, "Firewall:edit", @firewall[:id]) %> + + <% end %> + + + + <% @firewall[:private_subnets].each do |ps| %> + + " role="form" method="POST"> + <%== csrf_tag("#{request.path}/detach-subnet") %> + + + + + + <% end %> + <% if Authorization.has_permission?(@current_user.id, "Firewall:edit", @firewall[:id]) %> + + " role="form" method="POST"> + <%== csrf_tag("#{request.path}/attach-subnet") %> + + + + + <% end %> + +
Name
+ " + class="text-orange-600 hover:text-orange-700" + ><%= ps[:name] %> + + <%== render("components/form/submit_button", locals: { text: "Detach" }) %> +
+ <%== render( + "components/form/select", + locals: { + name: "private-subnet-id", + placeholder: "Select a subnet", + options: @attachable_subnets.map { |s| [s[:ubid], s[:name]] }, + attributes: { + required: true + } + } + ) %> + + <%== render("components/form/submit_button", locals: { text: "Attach" }) %> +
+
+ + <% if Authorization.has_permission?(@current_user.id, "Firewall:delete", @firewall[:id]) %> +
+
+
+
+

Delete firewall

+
+

This action will permanently delete this firewall. Deleted firewall cannot be recovered. Use it + carefully.

+
+
+
+ <%== render( + "components/delete_button", + locals: { + url: request.path, + confirmation: @firewall[:name], + redirect: "#{@project_data[:path]}/firewall" + } + ) %> +
+
+
+
+ <% end %> +
From d13acb21ba19376ee7979b1590bb959740279bce Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 15:12:10 +0200 Subject: [PATCH 11/16] Refactor VM Page to accommodate the Firewalls in a better way This commit removes the Network Interfaces box and refactors the Firewall Rules in the Vm Show page. The main reason we remove the Nics is that we do not have multiple Nics infrastructure just yet. It is staying in the main VM page unnecessarily. Also, with the introduction of the new multiple Firewalls functionality, the main VM page would get longer. Instead; 1. Since every VM will have a single Private Subnet and the Firewalls are attached to it, I have moved the private IPv4/6 addresses to the main details card. 2. Removed the Nics box. 3. Added a new line to see the private subnet. 4. Added a new box for Firewalls to list the firewall and the applied rules. 5. Remove Firewall rule editing from the VM. This is important because now firewall rules are applied at the subnet level and editing it directly in the VM page may result in a change that impacts more than one resources. --- routes/api/project/location/vm.rb | 43 -------- routes/web/project/location/vm.rb | 36 ------- serializers/api/vm.rb | 8 +- serializers/web/vm.rb | 8 +- spec/routes/api/project/location/vm_spec.rb | 74 -------------- spec/routes/web/vm_spec.rb | 102 ------------------- spec/serializers/web/vm_spec.rb | 12 +++ views/vm/show.erb | 105 ++++---------------- 8 files changed, 38 insertions(+), 350 deletions(-) diff --git a/routes/api/project/location/vm.rb b/routes/api/project/location/vm.rb index 3a678cb7f..0ec010396 100644 --- a/routes/api/project/location/vm.rb +++ b/routes/api/project/location/vm.rb @@ -85,48 +85,5 @@ def handle_vm_requests(user, vm) response.status = 204 request.halt end - - request.on "firewall-rule" do - request.post true do - Authorization.authorize(user.id, "Vm:Firewall:edit", vm.id) - - required_parameters = ["cidr"] - allowed_optional_parameters = ["port_range"] - - request_body_params = Validation.validate_request_body(request.body.read, required_parameters, allowed_optional_parameters) - - parsed_cidr = Validation.validate_cidr(request_body_params["cidr"]) - port_range = if request_body_params["port_range"].nil? - [0, 65535] - else - request_body_params["port_range"] = Validation.validate_port_range(request_body_params["port_range"]) - end - - pg_range = Sequel.pg_range(port_range.first..port_range.last) - - vm.firewalls.first.insert_firewall_rule(parsed_cidr.to_s, pg_range) - - serialize(vm, :detailed) - end - - request.get true do - Authorization.authorize(user.id, "Vm:Firewall:view", vm.id) - Serializers::Api::Firewall.serialize(vm.firewalls.first) - end - - request.is String do |firewall_rule_ubid| - request.delete true do - Authorization.authorize(user.id, "Vm:Firewall:edit", vm.id) - - if (fwr = FirewallRule.from_ubid(firewall_rule_ubid)) - fwr.destroy - vm.incr_update_firewall_rules - end - - response.status = 204 - request.halt - end - end - end end end diff --git a/routes/web/project/location/vm.rb b/routes/web/project/location/vm.rb index 830f624a3..825d27026 100644 --- a/routes/web/project/location/vm.rb +++ b/routes/web/project/location/vm.rb @@ -20,42 +20,6 @@ class CloverWeb view "vm/show" end - r.on "firewall-rule" do - r.post true do - Authorization.authorize(@current_user.id, "Vm:Firewall:edit", vm.id) - - port_range = if r.params["port_range"].empty? - [0, 65535] - else - Validation.validate_port_range(r.params["port_range"]) - end - - parsed_cidr = Validation.validate_cidr(r.params["cidr"]) - pg_range = Sequel.pg_range(port_range.first..port_range.last) - - vm.firewalls.first.insert_firewall_rule(parsed_cidr.to_s, pg_range) - flash["notice"] = "Firewall rule is created" - - r.redirect "#{@project.path}#{vm.path}" - end - - r.is String do |firewall_rule_ubid| - r.delete true do - Authorization.authorize(@current_user.id, "Vm:Firewall:edit", vm.id) - fwr = FirewallRule.from_ubid(firewall_rule_ubid) - unless fwr - response.status = 404 - r.halt - end - - fwr.destroy - vm.incr_update_firewall_rules - - return {message: "Firewall rule deleted"}.to_json - end - end - end - r.delete true do Authorization.authorize(@current_user.id, "Vm:delete", vm.id) diff --git a/serializers/api/vm.rb b/serializers/api/vm.rb index 85a03e273..9083725ea 100644 --- a/serializers/api/vm.rb +++ b/serializers/api/vm.rb @@ -23,10 +23,10 @@ def self.base(vm) structure(:detailed) do |vm| base(vm).merge( - { - nics: vm.nics.map { |nic| Serializers::Api::Nic.serialize(nic) }, - firewalls: vm.firewalls.map { |fw| Serializers::Api::Firewall.serialize(fw) } - } + firewalls: vm.firewalls.map { |fw| Serializers::Api::Firewall.serialize(fw) }, + private_ipv4: vm.nics.first.private_ipv4.network, + private_ipv6: vm.nics.first.private_ipv6.nth(2), + subnet: vm.nics.first.private_subnet.name ) end end diff --git a/serializers/web/vm.rb b/serializers/web/vm.rb index 810a03a63..d94f3937e 100644 --- a/serializers/web/vm.rb +++ b/serializers/web/vm.rb @@ -24,10 +24,10 @@ def self.base(vm) structure(:detailed) do |vm| base(vm).merge( - { - nics: vm.nics.map { |nic| Serializers::Web::Nic.serialize(nic) }, - firewalls: vm.firewalls.map { |fw| Serializers::Web::Firewall.serialize(fw) } - } + firewalls: vm.firewalls.map { |fw| Serializers::Web::Firewall.serialize(fw) }, + private_ip4: vm.nics.first.private_ipv4.network, + private_ip6: vm.nics.first.private_ipv6.nth(2), + subnet: vm.nics.first.private_subnet.name ) end end diff --git a/spec/routes/api/project/location/vm_spec.rb b/spec/routes/api/project/location/vm_spec.rb index ab1d6e04a..5f89de4f4 100644 --- a/spec/routes/api/project/location/vm_spec.rb +++ b/spec/routes/api/project/location/vm_spec.rb @@ -55,20 +55,6 @@ expect(last_response.status).to eq(401) expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") end - - it "not create firewall rule" do - post "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule" - - expect(last_response.status).to eq(401) - expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") - end - - it "not delete firewall rule" do - delete "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule/foo_ubid" - - expect(last_response.status).to eq(401) - expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") - end end describe "authenticated" do @@ -250,41 +236,6 @@ expect(last_response.status).to eq(400) expect(JSON.parse(last_response.body)["error"]["details"]["body"]).to eq("Only following parameters are allowed: public_key, size, unix_user, boot_image, enable_ip4, private_subnet_id") end - - it "firewall-rule" do - post "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule", { - cidr: "0.0.0.0/0", - port_range: "100..101" - }.to_json - - expect(last_response.status).to eq(200) - end - - it "firewall-rule vm ubid" do - post "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/id/#{vm.ubid}/firewall-rule", { - cidr: "0.0.0.0/0", - port_range: "100..1012" - }.to_json - - expect(last_response.status).to eq(200) - end - - it "firewall-rule no port range" do - post "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule", { - cidr: "0.0.0.0/1" - }.to_json - - expect(last_response.status).to eq(200) - end - - it "firewall-rule single port" do - post "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule", { - cidr: "0.0.0.0/1", - port_range: "11111" - }.to_json - - expect(last_response.status).to eq(200) - end end describe "show" do @@ -308,13 +259,6 @@ expect(last_response.status).to eq(404) expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Sorry, we couldn’t find the resource you’re looking for.") end - - it "firewall" do - get "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule" - - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)["description"]).to eq("Default firewall") - end end describe "delete" do @@ -345,24 +289,6 @@ expect(last_response.status).to eq(204) expect(SemSnap.new(vm.id).set?("destroy")).to be false end - - it "firewall-rule" do - delete "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule/#{vm.firewalls.map(&:firewall_rules).flatten.first.ubid}" - - expect(last_response.status).to eq(204) - end - - it "firewall-rule ubid" do - delete "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/id/#{vm.ubid}/firewall-rule/#{vm.firewalls.map(&:firewall_rules).flatten.first.ubid}" - - expect(last_response.status).to eq(204) - end - - it "firewall-rule not exist" do - delete "/api/project/#{project.ubid}/location/#{vm.display_location}/vm/#{vm.name}/firewall-rule/foo_ubid" - - expect(last_response.status).to eq(204) - end end end end diff --git a/spec/routes/web/vm_spec.rb b/spec/routes/web/vm_spec.rb index 468074340..b9e9c8561 100644 --- a/spec/routes/web/vm_spec.rb +++ b/spec/routes/web/vm_spec.rb @@ -234,108 +234,6 @@ end end - describe "firewall_rules" do - before do - vm.update(display_state: "running") - end - - it "does not list firewall rules if the VM is getting created" do - vm.update(display_state: "creating") - visit "#{project.path}#{vm.path}" - - expect(page.title).to eq("Ubicloud - #{vm.name}") - expect(page).to have_no_content "Firewall Rules" - end - - it "can show firewall rules" do - vm - # can visualize port_range nil as 0..65535 - vm.firewalls.map(&:firewall_rules).flatten.first.update(port_range: nil) - - visit "#{project.path}#{vm.path}" - - expect(page.title).to eq("Ubicloud - #{vm.name}") - expect(page).to have_content "Firewall Rules" - expect(page).to have_content "0.0.0.0/0" - expect(page).to have_content "0..65535" - end - - it "can delete firewall rule" do - visit "#{project.path}#{vm.path}" - - # We send delete request manually instead of just clicking to button - # because delete action triggered by JavaScript. - # UI tests run without a JavaScript engine. - btn = find "#fwr-delete-#{vm.firewalls.map(&:firewall_rules).flatten.first.ubid} .delete-btn" - page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]} - - expect(page.body).to eq({message: "Firewall rule deleted"}.to_json) - expect(SemSnap.new(vm.id).set?("update_firewall_rules")).to be true - end - - it "can not delete firewall rule if not exist" do - visit "#{project.path}#{vm.path}" - - # We send delete request manually instead of just clicking to button - # because delete action triggered by JavaScript. - # UI tests run without a JavaScript engine. - btn = find "#fwr-delete-#{vm.firewalls.map(&:firewall_rules).flatten.first.ubid} .delete-btn" - expect(FirewallRule).to receive(:[]).and_return(nil) - page.driver.delete btn["data-url"], {_csrf: btn["data-csrf"]} - expect(page.status_code).to eq(404) - end - - it "can not delete firewall rule when does not have permissions" do - # Give permission to view, so we can see the detail page - project_wo_permissions.access_policies.first.update(body: { - acls: [ - {subjects: user.hyper_tag_name, actions: ["Vm:view", "Vm:Firewall:view"], objects: project_wo_permissions.hyper_tag_name} - ] - }) - - visit "#{project_wo_permissions.path}#{vm_wo_permission.path}" - - expect { find "#fwr-delete-#{vm.firewalls.map(&:firewall_rules).flatten.first.ubid} .delete-btn" }.to raise_error Capybara::ElementNotFound - end - - it "does not show create firewall rule when does not have permissions" do - # Give permission to view, so we can see the detail page - project_wo_permissions.access_policies.first.update(body: { - acls: [ - {subjects: user.hyper_tag_name, actions: ["Vm:view", "Vm:Firewall:view"], objects: project_wo_permissions.hyper_tag_name} - ] - }) - visit "#{project_wo_permissions.path}#{vm.path}" - expect { find_by_id "fwr-create" }.to raise_error Capybara::ElementNotFound - end - - it "can create firewall rule" do - visit "#{project.path}#{vm.path}" - - fill_in "cidr", with: "1.1.1.2" - click_button "Create" - expect(page).to have_content "Firewall rule is created" - expect(page).to have_content "1.1.1.2/32" - expect(page).to have_content "0..65535" - - fill_in "cidr", with: "10.10.10.10" - fill_in "port_range", with: "80..8080" - click_button "Create" - expect(page).to have_content "Firewall rule is created" - expect(page).to have_content "10.10.10.10/32" - expect(page).to have_content "80..8080" - - fill_in "cidr", with: "12.12.12.0/26" - fill_in "port_range", with: "443" - click_button "Create" - expect(page).to have_content "Firewall rule is created" - expect(page).to have_content "12.12.12.0/26" - expect(page).to have_content "443" - - expect(SemSnap.new(vm.private_subnets.first.id).set?("update_firewall_rules")).to be true - end - end - describe "delete" do it "can delete virtual machine" do visit "#{project.path}#{vm.path}" diff --git a/spec/serializers/web/vm_spec.rb b/spec/serializers/web/vm_spec.rb index f272b5ad2..f87b26bbf 100644 --- a/spec/serializers/web/vm_spec.rb +++ b/spec/serializers/web/vm_spec.rb @@ -3,9 +3,21 @@ require_relative "../../spec_helper" RSpec.describe Serializers::Web::Vm do + let(:nic) { + ps = PrivateSubnet.new(name: "test").tap { _1.id = "a410a91a-dc31-4119-9094-3c6a1fb49601" } + Nic.new( + private_ipv4: NetAddr::IPv4Net.parse("1.2.3.4/32"), + private_ipv6: NetAddr::IPv6Net.parse("::0/0"), + private_subnet: ps + ).tap { _1.id = "a410a91a-dc31-4119-9094-3c6a1fb49601" } + } let(:vm) { Vm.new(name: "test-vm", family: "standard", cores: 1).tap { _1.id = "a410a91a-dc31-4119-9094-3c6a1fb49601" } } let(:ser) { described_class.new } + before do + allow(vm).to receive(:nics).and_return([nic]) + end + it "can serialize with the default structure" do data = ser.serialize(vm) expect(data[:name]).to eq(vm.name) diff --git a/views/vm/show.erb b/views/vm/show.erb index e8669f2f5..7a5c47209 100644 --- a/views/vm/show.erb +++ b/views/vm/show.erb @@ -41,55 +41,23 @@ "SSH Command", "#{h("ssh -i #{@vm[:unix_user]}@#{@vm[:ip4] || @vm[:ip6]}")}", { escape: false } + ], + ["Private IPv4", @vm[:private_ip4], { copieble: true }], + ["Private IPv6", @vm[:private_ip6], { copieble: true }], + [ + "Private subnet", + "#{@vm[:subnet]}", + { escape: false } ] ] } ) %> - -
-
-

- Network Interfaces -

-
-
-
- - - - - - - - - - - <% @vm[:nics].each do |nic| %> - - - - - - - <% end %> - -
NamePrivate IPv4Private IPv6Subnet
<%= nic[:name] %> - <%== render("components/copieble_content", locals: { content: nic[:private_ipv4], message: "Copied Private IPv4" }) %> - - <%== render("components/copieble_content", locals: { content: nic[:private_ipv6], message: "Copied Private IPv6" }) %> - - " - class="text-orange-600 hover:text-orange-700" - ><%= nic[:subnet_name] %> -
-
- <% if Authorization.has_permission?(@current_user.id, "Vm:Firewall:view", @vm[:id]) && @vm[:state] != "creating" %> + <% if Authorization.has_permission?(@current_user.id, "Vm:Firewall:view", @vm[:id]) %>

- Firewall Rules + Applied Firewalls

@@ -97,64 +65,27 @@ + - - <% if Authorization.has_permission?(@current_user.id, "Vm:Firewall:edit", @vm[:id]) %> - - <% end %> + <% @vm[:firewalls].each do |fw| %> <% fw[:firewall_rules].each do |fwr| %> + - <% if Authorization.has_permission?(@current_user.id, "Vm:Firewall:edit", @vm[:id]) %> - - <% end %> <% end %> <% end %> - <% if Authorization.has_permission?(@current_user.id, "Vm:Firewall:edit", @vm[:id]) %> - - " role="form" method="POST"> - <%== csrf_tag("#{request.path}/firewall-rule") %> - - - - - - <% end %>
Firewall CIDRPort RangePort Range
+ <% if Authorization.has_permission?(@current_user.id, "Firewall:view", fw[:id]) %> + " class="text-orange-600 hover:text-orange-700"><%= fw[:name] %> + <% else %> + <%= fw[:name] %> + <% end %> + <%= fwr[:cidr] %> <%= fwr[:port_range] %> - -
- <%== render( - "components/form/text", - locals: { - name: "cidr", - type: "cidr", - attributes: { - placeholder: "0.0.0.0/0", - required: true - } - } - ) %> - - <%== render("components/form/text", locals: { name: "port_range", type: "text", attributes: { placeholder: "0..65536" } }) %> - - <%== render("components/form/submit_button", locals: { text: "Create" }) %> -
From 77a9bda12f87633364e868af90e8a94ce470b5b0 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 15:12:40 +0200 Subject: [PATCH 12/16] Add attached Firewalls to the Subnet show page --- routes/web/project/location/private_subnet.rb | 2 +- serializers/web/private_subnet.rb | 6 +++ spec/routes/web/private_subnet_spec.rb | 1 - views/private_subnet/show.erb | 50 +++++++++++++++---- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/routes/web/project/location/private_subnet.rb b/routes/web/project/location/private_subnet.rb index cd3897543..6fbaf6d6a 100644 --- a/routes/web/project/location/private_subnet.rb +++ b/routes/web/project/location/private_subnet.rb @@ -11,7 +11,7 @@ class CloverWeb response.status = 404 r.halt end - @ps = serialize(ps) + @ps = serialize(ps, :detailed) r.get true do Authorization.authorize(@current_user.id, "PrivateSubnet:view", ps.id) diff --git a/serializers/web/private_subnet.rb b/serializers/web/private_subnet.rb index 3425aad8e..e539e3a47 100644 --- a/serializers/web/private_subnet.rb +++ b/serializers/web/private_subnet.rb @@ -17,4 +17,10 @@ def self.base(ps) structure(:default) do |ps| base(ps) end + + structure(:detailed) do |ps| + base(ps).merge( + attached_firewalls: ps.firewalls.map { |f| Serializers::Web::Firewall.serialize(f) } + ) + end end diff --git a/spec/routes/web/private_subnet_spec.rb b/spec/routes/web/private_subnet_spec.rb index 6e1524aaa..0d3f3c37a 100644 --- a/spec/routes/web/private_subnet_spec.rb +++ b/spec/routes/web/private_subnet_spec.rb @@ -163,7 +163,6 @@ visit "#{project.path}#{private_subnet.path}" expect(page.title).to eq("Ubicloud - #{private_subnet.name}") - expect(page).to have_content nic.name expect(page).to have_content nic.private_ipv4.network.to_s expect(page).to have_content nic.private_ipv6.nth(2).to_s end diff --git a/views/private_subnet/show.erb b/views/private_subnet/show.erb index e1b059e52..31205bfac 100644 --- a/views/private_subnet/show.erb +++ b/views/private_subnet/show.erb @@ -38,7 +38,7 @@

- Network Interfaces + Attached VMs

@@ -46,22 +46,14 @@ - + - <% @nics.each do |nic| %> - - - + + + + <% end %> + +
NameVM Private IPv4 Private IPv6Attached VM
<%= nic[:name] %> - <%== render("components/copieble_content", locals: { content: nic[:private_ipv4], message: "Copied Private IPv4" }) %> - - <%== render("components/copieble_content", locals: { content: nic[:private_ipv6], message: "Copied Private IPv6" }) %> - <% if nic[:vm_name] %> + <%== render("components/copieble_content", locals: { content: nic[:private_ipv4], message: "Copied Private IPv4" }) %> + + <%== render("components/copieble_content", locals: { content: nic[:private_ipv6], message: "Copied Private IPv6" }) %> +
+ + +
+
+

+ Attached Firewalls +

+
+
+
+ + + + + + + + + <% @ps[:attached_firewalls].each do |fw| %> + + + <% end %> From f917623a672ac5a341d2849a7643e905b4ea0611 Mon Sep 17 00:00:00 2001 From: Furkan Sahin Date: Tue, 30 Apr 2024 15:56:47 +0200 Subject: [PATCH 13/16] Associate default firewall with the project --- prog/vnet/subnet_nexus.rb | 1 + spec/lib/authorization_spec.rb | 10 +++++----- spec/routes/api/project/private_subnet_spec.rb | 2 +- spec/routes/api/project/vm_spec.rb | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/prog/vnet/subnet_nexus.rb b/prog/vnet/subnet_nexus.rb index a3499ab1f..badb3a775 100644 --- a/prog/vnet/subnet_nexus.rb +++ b/prog/vnet/subnet_nexus.rb @@ -22,6 +22,7 @@ def self.assemble(project_id, name: nil, location: "hetzner-hel1", ipv6_range: n ps.associate_with_project(project) port_range = allow_only_ssh ? 22..22 : 0..65535 fw = Firewall.create_with_id(name: "#{name}-default") + fw.associate_with_project(project) ["0.0.0.0/0", "::/0"].each { |cidr| FirewallRule.create_with_id(firewall_id: fw.id, cidr: cidr, port_range: Sequel.pg_range(port_range)) } fw.associate_with_private_subnet(ps, apply_firewalls: false) diff --git a/spec/lib/authorization_spec.rb b/spec/lib/authorization_spec.rb index 557409a4b..e54c09330 100644 --- a/spec/lib/authorization_spec.rb +++ b/spec/lib/authorization_spec.rb @@ -25,10 +25,10 @@ [[], SecureRandom.uuid, ["Vm:view"], 0], [[], users[0].id, "Vm:view", 0], [[], users[0].id, ["Vm:view"], 0], - [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: projects[0].hyper_tag_name}], users[0].id, "Vm:view", 6], - [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: projects[0].hyper_tag_name}], users[0].id, ["Vm:view", "Vm:create"], 6], - [[{subjects: [users[0].hyper_tag_name], actions: ["Vm:view"], objects: [projects[0].hyper_tag_name]}], users[0].id, "Vm:view", 6], - [[{subjects: [users[0].hyper_tag_name, users[1].hyper_tag_name], actions: ["Vm:view", "Vm:delete"], objects: [projects[0].hyper_tag_name]}], users[0].id, ["Vm:view", "Vm:create"], 6], + [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: projects[0].hyper_tag_name}], users[0].id, "Vm:view", 8], + [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: projects[0].hyper_tag_name}], users[0].id, ["Vm:view", "Vm:create"], 8], + [[{subjects: [users[0].hyper_tag_name], actions: ["Vm:view"], objects: [projects[0].hyper_tag_name]}], users[0].id, "Vm:view", 8], + [[{subjects: [users[0].hyper_tag_name, users[1].hyper_tag_name], actions: ["Vm:view", "Vm:delete"], objects: [projects[0].hyper_tag_name]}], users[0].id, ["Vm:view", "Vm:create"], 8], [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: vms[0].hyper_tag_name(access_policy.project)}], users[0].id, "Vm:view", 1], [[{subjects: users[0].hyper_tag_name, actions: "Vm:view", objects: vms.map { _1.hyper_tag_name(access_policy.project) }}], users[0].id, "Vm:view", 2], [[{subjects: users[0].hyper_tag_name, actions: "Vm:delete", objects: vms[0].hyper_tag_name(access_policy.project)}], users[0].id, "Vm:view", 0], @@ -87,7 +87,7 @@ describe "#authorized_resources" do it "returns resource ids when has matched policies" do - ids = [vms[0].id, vms[1].id, projects[0].id, users[0].id, vms[0].private_subnets[0].id, vms[1].private_subnets[0].id] + ids = [vms[0].id, vms[1].id, projects[0].id, users[0].id, vms[0].private_subnets[0].id, vms[1].private_subnets[0].id, vms[0].firewalls[0].id, vms[1].firewalls[0].id] expect(described_class.authorized_resources(users[0].id, "Vm:view").sort).to eq(ids.sort) end diff --git a/spec/routes/api/project/private_subnet_spec.rb b/spec/routes/api/project/private_subnet_spec.rb index 8b620d3cd..aa22f230f 100644 --- a/spec/routes/api/project/private_subnet_spec.rb +++ b/spec/routes/api/project/private_subnet_spec.rb @@ -23,7 +23,7 @@ it "success all pss" do Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-2", location: "hetzner-fsn1") - Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-2", location: "hetzner-hel1") + Prog::Vnet::SubnetNexus.assemble(project.id, name: "dummy-ps-3", location: "hetzner-hel1") get "/api/project/#{project.ubid}/private-subnet" diff --git a/spec/routes/api/project/vm_spec.rb b/spec/routes/api/project/vm_spec.rb index e1aa21f3d..07453d5c3 100644 --- a/spec/routes/api/project/vm_spec.rb +++ b/spec/routes/api/project/vm_spec.rb @@ -25,7 +25,7 @@ it "success all vms" do Prog::Vm::Nexus.assemble("dummy-public-key", project.id, name: "dummy-vm-2", location: "hetzner-fsn1") - Prog::Vm::Nexus.assemble("dummy-public-key", project.id, name: "dummy-vm-2", location: vm.location) + Prog::Vm::Nexus.assemble("dummy-public-key", project.id, name: "dummy-vm-3", location: vm.location) get "/api/project/#{project.ubid}/vm" From 09e9cb7c3e1a7ca19660ab565db7372985956dd8 Mon Sep 17 00:00:00 2001 From: Burak Velioglu Date: Wed, 1 May 2024 00:37:00 -0700 Subject: [PATCH 14/16] Add private subnet to detailed firewall api serialization As firewalls are started to be associated with private subnets, adding private subnet information to the detailed firewall api serialization. --- serializers/api/firewall.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/serializers/api/firewall.rb b/serializers/api/firewall.rb index 0f95c519e..818da730b 100644 --- a/serializers/api/firewall.rb +++ b/serializers/api/firewall.rb @@ -13,4 +13,10 @@ def self.base(firewall) structure(:default) do |firewall| base(firewall) end + + structure(:detailed) do |firewall| + base(firewall).merge({ + private_subnets: firewall.private_subnets.map { |ps| Serializers::Api::PrivateSubnet.serialize(ps) } + }) + end end From ac91c8f37f61252f4a984321cf4225fbc70ae2a8 Mon Sep 17 00:00:00 2001 From: Burak Velioglu Date: Wed, 1 May 2024 00:37:36 -0700 Subject: [PATCH 15/16] Add firewall API endpoints Adding API endpoints to manage firewalls. Endpoints include - Adding Firewall - Deleting Firewall - Getting/Listing Firewalls - Associate/Dissociate with subnets Firewall rule management will be added with the subsequent commit. --- routes/api/project/firewall.rb | 93 +++++++++++++ spec/routes/api/project/firewall_spec.rb | 163 +++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 routes/api/project/firewall.rb create mode 100644 spec/routes/api/project/firewall_spec.rb diff --git a/routes/api/project/firewall.rb b/routes/api/project/firewall.rb new file mode 100644 index 000000000..55b341529 --- /dev/null +++ b/routes/api/project/firewall.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class CloverApi + hash_branch(:project_prefix, "firewall") do |r| + @serializer = Serializers::Api::Firewall + + r.get true do + result = @project.firewalls_dataset.authorized(@current_user.id, "Firewall:view").eager(:firewall_rules).paginated_result( + start_after: r.params["start_after"], + page_size: r.params["page_size"], + order_column: r.params["order_column"] + ) + + { + items: serialize(result[:records]), + count: result[:count] + } + end + + r.post true do + Authorization.authorize(@current_user.id, "Firewall:create", @project.id) + + required_parameters = ["name"] + allowed_optional_parameters = ["description"] + request_body_params = Validation.validate_request_body(r.body.read, required_parameters, allowed_optional_parameters) + Validation.validate_name(request_body_params["name"]) + + firewall = Firewall.create_with_id(name: request_body_params["name"], description: request_body_params["description"] || "") + firewall.associate_with_project(@project) + + serialize(firewall) + end + + r.on String do |firewall_ubid| + @firewall = Firewall.from_ubid(firewall_ubid) + + unless @firewall + response.status = r.delete? ? 204 : 404 + r.halt + end + + r.delete true do + Authorization.authorize(@current_user.id, "Firewall:delete", @project.id) + + @firewall.dissociate_with_project(@project) + @firewall.destroy + + response.status = 204 + r.halt + end + + r.get true do + Authorization.authorize(@current_user.id, "Firewall:view", @project.id) + + serialize(@firewall, :detailed) + end + + r.post "attach-subnet" do + Authorization.authorize(@current_user.id, "PrivateSubnet:edit", @project.id) + + required_parameters = ["private_subnet_id"] + request_body_params = Validation.validate_request_body(r.body.read, required_parameters) + + private_subnet = PrivateSubnet.from_ubid(request_body_params["private_subnet_id"]) + unless private_subnet + fail Validation::ValidationFailed.new({private_subnet_id: "Private subnet with the given id \"#{request_body_params["private_subnet_id"]}\" is not found"}) + end + + @firewall.associate_with_private_subnet(private_subnet) + + serialize(@firewall, :detailed) + end + + r.post "detach-subnet" do + Authorization.authorize(@current_user.id, "PrivateSubnet:edit", @project.id) + + required_parameters = ["private_subnet_id"] + request_body_params = Validation.validate_request_body(r.body.read, required_parameters) + + private_subnet = PrivateSubnet.from_ubid(request_body_params["private_subnet_id"]) + unless private_subnet + fail Validation::ValidationFailed.new({private_subnet_id: "Private subnet with the given id \"#{request_body_params["private_subnet_id"]}\" is not found"}) + end + + @firewall.disassociate_from_private_subnet(private_subnet) + + serialize(@firewall, :detailed) + end + + r.hash_branches(:project_firewall_prefix) + end + end +end diff --git a/spec/routes/api/project/firewall_spec.rb b/spec/routes/api/project/firewall_spec.rb new file mode 100644 index 000000000..a6fbf56ee --- /dev/null +++ b/spec/routes/api/project/firewall_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe Clover, "firewall" do + let(:user) { create_account } + + let(:project) { user.create_project_with_default_policy("project-1") } + + let(:firewall) { Firewall.create_with_id(name: "default-firewall").tap { _1.associate_with_project(project) } } + + describe "unauthenticated" do + it "not list" do + get "/api/project/#{project.ubid}/firewall" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not create" do + post "/api/project/#{project.ubid}/firewall" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not delete" do + delete "/api/project/#{project.ubid}/firewall/#{firewall.ubid}" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not get" do + get "/api/project/#{project.ubid}/firewall/#{firewall.ubid}" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not associate" do + get "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/attach-subnet" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not dissociate" do + get "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/detach-subnet" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + end + + describe "authenticated" do + before do + login_api(user.email) + end + + it "success get all firewalls" do + Firewall.create_with_id(name: firewall.name).associate_with_project(project) + + get "/api/project/#{project.ubid}/firewall" + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["items"].length).to eq(2) + end + + it "success get firewall" do + get "/api/project/#{project.ubid}/firewall/#{firewall.ubid}" + + expect(last_response.status).to eq(200) + end + + it "get does not exist" do + get "/api/project/#{project.ubid}/firewall/foo_ubid" + + expect(last_response.status).to eq(404) + end + + it "success post" do + post "/api/project/#{project.ubid}/firewall", { + name: "foo-name", + description: "Firewall description" + }.to_json + + expect(last_response.status).to eq(200) + end + + it "success delete" do + delete "/api/project/#{project.ubid}/firewall/#{firewall.ubid}" + + expect(last_response.status).to eq(204) + end + + it "delete not exist" do + delete "/api/project/#{project.ubid}/firewall/foo_ubid" + + expect(last_response.status).to eq(204) + end + + it "attach to subnet" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + expect(PrivateSubnet).to receive(:from_ubid).and_return(ps) + expect(ps).to receive(:incr_update_firewall_rules) + + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/attach-subnet", { + private_subnet_id: ps.ubid + }.to_json + + expect(firewall.private_subnets.count).to eq(1) + expect(firewall.private_subnets.first.id).to eq(ps.id) + expect(last_response.status).to eq(200) + end + + it "attach to subnet not exist" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/attach-subnet", { + private_subnet_id: "fooubid" + }.to_json + + expect(last_response.status).to eq(400) + end + + it "detach from subnet" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + expect(PrivateSubnet).to receive(:from_ubid).and_return(ps) + expect(ps).to receive(:incr_update_firewall_rules) + + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/detach-subnet", { + private_subnet_id: ps.ubid + }.to_json + + expect(last_response.status).to eq(200) + end + + it "detach from subnet not exist" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/detach-subnet", { + private_subnet_id: "fooubid" + }.to_json + + expect(last_response.status).to eq(400) + end + + it "attach and detach" do + ps = PrivateSubnet.create_with_id(name: "test-ps", location: "hetzner-hel1", net6: "2001:db8::/64", net4: "10.0.0.0/24") + expect(PrivateSubnet).to receive(:from_ubid).and_return(ps).twice + expect(ps).to receive(:incr_update_firewall_rules).twice + + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/attach-subnet", { + private_subnet_id: ps.ubid + }.to_json + + expect(firewall.private_subnets.count).to eq(1) + + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/detach-subnet", { + private_subnet_id: ps.ubid + }.to_json + + expect(firewall.reload.private_subnets.count).to eq(0) + end + end +end From c60bf21d4fbfc1b67e9ab9342d9a4d69cad21b41 Mon Sep 17 00:00:00 2001 From: Burak Velioglu Date: Wed, 1 May 2024 00:38:07 -0700 Subject: [PATCH 16/16] Add firewall rule API endpoints Adding API endpoints to manage firewall rules. Endpoints include - Adding firewall rules - Deleting firewall rules --- routes/api/project/firewall/firewall_rule.rb | 43 ++++++++++ spec/routes/api/project/firewall_rule_spec.rb | 80 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 routes/api/project/firewall/firewall_rule.rb create mode 100644 spec/routes/api/project/firewall_rule_spec.rb diff --git a/routes/api/project/firewall/firewall_rule.rb b/routes/api/project/firewall/firewall_rule.rb new file mode 100644 index 000000000..bae68b284 --- /dev/null +++ b/routes/api/project/firewall/firewall_rule.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class CloverApi + hash_branch(:project_firewall_prefix, "firewall-rule") do |r| + @serializer = Serializers::Api::FirewallRule + + r.post true do + Authorization.authorize(@current_user.id, "Firewall:edit", @firewall.id) + + required_parameters = ["cidr"] + allowed_optional_parameters = ["port_range"] + + request_body_params = Validation.validate_request_body(request.body.read, required_parameters, allowed_optional_parameters) + + parsed_cidr = Validation.validate_cidr(request_body_params["cidr"]) + port_range = if request_body_params["port_range"].nil? + [0, 65535] + else + request_body_params["port_range"] = Validation.validate_port_range(request_body_params["port_range"]) + end + + pg_range = Sequel.pg_range(port_range.first..port_range.last) + + firewall_rule = @firewall.insert_firewall_rule(parsed_cidr.to_s, pg_range) + + serialize(firewall_rule) + end + + r.is String do |firewall_rule_ubid| + firewall_rule = FirewallRule.from_ubid(firewall_rule_ubid) + + request.delete true do + if firewall_rule + Authorization.authorize(@current_user.id, "Firewall:edit", @firewall.id) + @firewall.remove_firewall_rule(firewall_rule) + end + + response.status = 204 + r.halt + end + end + end +end diff --git a/spec/routes/api/project/firewall_rule_spec.rb b/spec/routes/api/project/firewall_rule_spec.rb new file mode 100644 index 000000000..86c4a6133 --- /dev/null +++ b/spec/routes/api/project/firewall_rule_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe Clover, "firewall" do + let(:user) { create_account } + + let(:project) { user.create_project_with_default_policy("project-1") } + + let(:firewall) { Firewall.create_with_id(name: "default-firewall").tap { _1.associate_with_project(project) } } + + let(:firewall_rule) { FirewallRule.create_with_id(firewall_id: firewall.id, cidr: "0.0.0.0/0", port_range: Sequel.pg_range(80..5432)) } + + describe "unauthenticated" do + it "not post" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + + it "not delete" do + delete "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule/#{firewall_rule.ubid}" + + expect(last_response.status).to eq(401) + expect(JSON.parse(last_response.body)["error"]["message"]).to eq("Please login to continue") + end + end + + describe "authenticated" do + before do + login_api(user.email) + end + + it "create firewall rule" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule", { + cidr: "0.0.0.0/0", + port_range: "100..101" + }.to_json + + expect(last_response.status).to eq(200) + end + + it "can not create same firewall rule" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule", { + cidr: firewall_rule.cidr, + port_range: "80..5432" + }.to_json + + expect(last_response.status).to eq(400) + end + + it "firewall rule no port range" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule", { + cidr: "0.0.0.0/1" + }.to_json + + expect(last_response.status).to eq(200) + end + + it "firewall rule single port" do + post "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule", { + cidr: "0.0.0.0/1", + port_range: "11111" + }.to_json + + expect(last_response.status).to eq(200) + end + + it "firewall rule delete" do + delete "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule/#{firewall_rule.ubid}" + expect(last_response.status).to eq(204) + end + + it "firewall rule delete does not exist" do + delete "/api/project/#{project.ubid}/firewall/#{firewall.ubid}/firewall-rule/fooubid" + expect(last_response.status).to eq(204) + end + end +end
NameDescription
+ <% if Authorization.has_permission?(@current_user.id, "Firewall:view", fw[:id]) %> + " class="text-orange-600 hover:text-orange-700"><%= fw[:name] %> + <% else %> + <%= fw[:name] %> + <% end %> + <%= fw[:description] %>