# Copyright:: Copyright (c) 2017 eGlobalTech, Inc., all rights reserved
#
# Licensed under the BSD-3 license (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the root of the project or at
#
# http://egt-labs.com/mu/LICENSE.html
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
module MU
class Cloud
class Google
# A firewall ruleset as configured in {MU::Config::BasketofKittens::firewall_rules}
class FirewallRule < MU::Cloud::FirewallRule
@admin_sgs = Hash.new
@admin_sg_semaphore = Mutex.new
# Firewall protocols supported by GCP as of early 2019
PROTOS = ["udp", "tcp", "icmp", "esp", "ah", "sctp", "ipip"]
# Our default subset of supported firewall protocols
STD_PROTOS = ["icmp", "tcp", "udp"]
# Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us.
# @param args [Hash]: Hash of named arguments passed via Ruby's double-splat
def initialize(**args)
super
if !@vpc.nil?
@mu_name ||= @deploy.getResourceName(@config['name'], need_unique_string: true, max_length: 61)
else
@mu_name ||= @deploy.getResourceName(@config['name'], max_length: 61)
end
end
attr_reader :rulesets
# Called by {MU::Deploy#createResources}
def create
@cloud_id = @mu_name.downcase.gsub(/[^-a-z0-9]/, "-")
vpc_id = if @vpc
@vpc.url if !@vpc.nil?
else
vpc_ref = MU::Config::Ref.get(@config['vpc'])
if !vpc_ref.kitten
MU.log "Failed to resolve VPC for FirewallRule #{@mu_name}", MU::ERR, details: @config['vpc']
raise MuError, "Failed to resolve VPC for FirewallRule #{@mu_name}"
else
vpc_ref.kitten.url
end
end
if vpc_id.nil?
raise MuError, "Failed to resolve VPC for #{self}"
end
params = {
:name => @cloud_id,
:network => vpc_id
}
@config['rules'].each { |rule|
srcs = []
ruleobj = nil
# XXX 'all' and 'standard' keywords
if ["tcp", "udp"].include?(rule['proto']) and (rule['port_range'] or rule['port'])
ruleobj = MU::Cloud::Google.compute(:Firewall)::Allowed.new(
ip_protocol: rule['proto'],
ports: [rule['port_range'] || rule['port']]
)
else
ruleobj = MU::Cloud::Google.compute(:Firewall)::Allowed.new(
ip_protocol: rule['proto']
)
end
if rule['hosts']
rule['hosts'].each { |cidr| srcs << cidr }
end
dir = (rule["ingress"] or !rule["egress"]) ? "INGRESS" : "EGRESS"
if params[:direction] and params[:direction] != dir
MU.log "Google Cloud firewalls cannot mix ingress and egress rules", MU::ERR, details: @config['rules']
raise MuError, "Google Cloud firewalls cannot mix ingress and egress rules"
end
params[:direction] = dir
if @deploy
params[:description] = @deploy.deploy_id
end
filters = if dir == "INGRESS"
['source_service_accounts', 'source_tags']
else
['target_service_accounts', 'target_tags']
end
filters.each { |filter|
if config[filter] and config[filter].size > 0
params[filter.to_sym] = config[filter].dup
end
}
action = rule['deny'] ? :denied : :allowed
params[action] ||= []
params[action] << ruleobj
ipparam = dir == "INGRESS" ? :source_ranges : :destination_ranges
params[ipparam] ||= []
params[ipparam].concat(srcs)
params[:priority] = rule['weight'] if rule['weight']
}
fwobj = MU::Cloud::Google.compute(:Firewall).new(params)
MU.log "Creating firewall #{@cloud_id} in project #{@project_id}", details: fwobj
begin
MU::Cloud::Google.compute(credentials: @config['credentials']).insert_firewall(@project_id, fwobj)
rescue ::Google::Apis::ClientError => e
MU.log @config['project']+"/"+@config['name']+": "+@cloud_id, MU::ERR, details: @config['vpc']
MU.log e.inspect, MU::ERR, details: fwobj
if e.message.match(/Invalid value for field/)
dependencies(use_cache: false, debug: true)
end
raise e
end
# Make sure it actually got made before we move on
desc = nil
begin
desc = MU::Cloud::Google.compute(credentials: @config['credentials']).get_firewall(@project_id, @cloud_id)
sleep 1
end while desc.nil?
desc
end
# Called by {MU::Deploy#createResources}
def groom
end
# Log metadata about this ruleset to the currently running deployment
def notify
sg_data = MU.structToHash(
MU::Cloud::Google::FirewallRule.find(cloud_id: @cloud_id, region: @config['region'])
)
sg_data ||= {}
sg_data["group_id"] = @cloud_id
sg_data["project_id"] = habitat_id
sg_data["cloud_id"] = @cloud_id
return sg_data
end
# Insert a rule into an existing security group.
#
# @param hosts [Array]: An array of CIDR network addresses to which this rule will apply.
# @param proto [String]: One of "tcp," "udp," or "icmp"
# @param port [Integer]: A port number. Only valid with udp or tcp.
# @param egress [Boolean]: Whether this is an egress ruleset, instead of ingress.
# @param port_range [String]: A port range descriptor (e.g. 0-65535). Only valid with udp or tcp.
# @return [void]
def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535")
end
# Locate and return cloud provider descriptors of this resource type
# which match the provided parameters, or all visible resources if no
# filters are specified. At minimum, implementations of +find+ must
# honor +credentials+ and +cloud_id+ arguments. We may optionally
# support other search methods, such as +tag_key+ and +tag_value+, or
# cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}.
# @param args [Hash]: Hash of named arguments passed via Ruby's double-splat
# @return [Hash]: The cloud provider's complete descriptions of matching resources
def self.find(**args)
args = MU::Cloud::Google.findLocationArgs(args)
found = {}
resp = begin
MU::Cloud::Google.compute(credentials: args[:credentials]).list_firewalls(args[:project])
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/^(?:notFound|forbidden): /)
end
if resp and resp.items
resp.items.each { |fw|
next if !args[:cloud_id].nil? and fw.name != args[:cloud_id]
found[fw.name] = fw
}
end
found
end
# Does this resource type exist as a global (cloud-wide) artifact, or
# is it localized to a region/zone?
# @return [Boolean]
def self.isGlobal?
true
end
# Denote whether this resource implementation is experiment, ready for
# testing, or ready for production use.
def self.quality
MU::Cloud::RELEASE
end
# Remove all security groups (firewall rulesets) associated with the currently loaded deployment.
# @param noop [Boolean]: If true, will only print what would be done
# @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
# @return [void]
def self.cleanup(noop: false, ignoremaster: false, credentials: nil, flags: {})
flags["habitat"] ||= MU::Cloud::Google.defaultProject(credentials)
return if !MU::Cloud::Google::Habitat.isLive?(flags["habitat"], credentials)
filter = %Q{(labels.mu-id = "#{MU.deploy_id.downcase}")}
if !ignoremaster and MU.mu_public_ip
filter += %Q{ AND (labels.mu-master-ip = "#{MU.mu_public_ip.gsub(/\./, "_")}")}
end
MU.log "Placeholder: Google FirewallRule artifacts do not support labels, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: filter
MU::Cloud::Google.compute(credentials: credentials).delete(
"firewall",
flags["habitat"],
nil,
noop
)
end
# Reverse-map our cloud description into a runnable config hash.
# We assume that any values we have in +@config+ are placeholders, and
# calculate our own accordingly based on what's live in the cloud.
def toKitten(**_args)
if cloud_desc.name.match(/^[a-f0-9]+$/)
gke_ish = true
cloud_desc.target_tags.each { |tag|
gke_ish = false if !tag.match(/^gke-/)
}
if gke_ish
MU.log "FirewallRule #{cloud_desc.name} appears to belong to a ContainerCluster, skipping adoption", MU::DEBUG
return nil
end
end
bok = {
"cloud" => "Google",
"project" => @config['project'],
"credentials" => @config['credentials']
}
bok['rules'] = []
bok['name'] = cloud_desc.name.dup
bok['cloud_id'] = cloud_desc.name.dup
cloud_desc.network.match(/(?:^|\/)projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/)
vpc_proj = Regexp.last_match[1]
vpc_id = Regexp.last_match[2]
if vpc_id == "default" and !@config['project']
raise MuError, "FirewallRule toKitten: I'm in 'default' VPC but can't figure out what project I'm in"
end
# XXX make sure this is sane (that these rules come with default VPCs)
if vpc_id == "default" and ["default-allow-icmp", "default-allow-http"].include?(cloud_desc.name)
return nil
end
if vpc_id != "default"
bok['vpc'] = MU::Config::Ref.get(
id: vpc_id,
habitat: MU::Config::Ref.get(
id: vpc_proj,
cloud: "Google",
credentials: @credentials,
type: "habitats"
),
cloud: "Google",
credentials: @config['credentials'],
type: "vpcs"
)
end
byport = {}
rule_list = []
is_deny = false
if cloud_desc.denied
rule_list = cloud_desc.denied
is_deny = true
else
rule_list = cloud_desc.allowed
end
rule_list.each { |rule|
hosts = if cloud_desc.direction == "INGRESS"
cloud_desc.source_ranges ? cloud_desc.source_ranges : ["0.0.0.0/0"]
else
cloud_desc.destination_ranges ? cloud_desc.destination_ranges : ["0.0.0.0/0"]
end
hosts.map! { |h|
h = h+"/32" if h.match(/^\d+\.\d+\.\d+\.\d+$/)
h
}
proto = rule.ip_protocol ? rule.ip_protocol : "all"
if rule.ports
rule.ports.each { |ports|
ports = "0-65535" if ["1-65535", "1-65536", "0-65536"].include?(ports)
byport[ports] ||= {}
byport[ports][hosts] ||= []
byport[ports][hosts] << proto
}
else
byport["0-65535"] ||= {}
byport["0-65535"][hosts] ||= []
byport["0-65535"][hosts] << proto
end
}
byport.each_pair { |ports, hostlists|
hostlists.each_pair { |hostlist, protos|
protolist = if protos.sort.uniq == PROTOS.sort.uniq
["all"]
elsif protos.sort.uniq == ["icmp", "tcp", "udp"]
["standard"]
else
protos
end
protolist.each { |proto|
rule = {
"proto" => proto,
"hosts" => hostlist
}
rule["deny"] = true if is_deny
if cloud_desc.priority and cloud_desc.priority != 1000
rule["weight"] = cloud_desc.priority
end
if ports.match(/-/)
rule["port_range"] = ports
else
rule["port"] = ports.to_i
end
if cloud_desc.source_service_accounts
rule["source_service_accounts"] = cloud_desc.source_service_accounts
end
if cloud_desc.source_tags
rule["source_tags"] = cloud_desc.source_tags
end
if cloud_desc.target_service_accounts
rule["target_service_accounts"] = cloud_desc.target_service_accounts
end
if cloud_desc.target_tags
rule["target_tags"] = cloud_desc.target_tags
end
if cloud_desc.direction == "EGRESS"
rule['egress'] = true
rule['ingress'] = false
end
bok['rules'] << rule
}
}
}
bok
end
# Cloud-specific configuration properties.
# @param _config [MU::Config]: The calling MU::Config object
# @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource
def self.schema(_config = nil)
toplevel_required = []
schema = {
"rules" => {
"items" => {
"properties" => {
"weight" => {
"type" => "integer",
"description" => "Explicitly set a priority for this firewall rule, between 0 and 65535, with lower numbered priority rules having greater precedence."
},
"deny" => {
"type" => "boolean",
"default" => false,
"description" => "Set this rule to +DENY+ traffic instead of +ALLOW+"
},
"proto" => {
"description" => "The protocol to allow with this rule. The +standard+ keyword will expand to a series of identical rules covering +icmp+, +tcp+, and +udp; the +all+ keyword will expand to a series of identical rules for all supported protocols.",
"enum" => PROTOS + ["all", "standard"]
},
"source_tags" => {
"type" => "array",
"description" => "VMs with these tags, from which traffic will be allowed",
"items" => {
"type" => "string"
}
},
"source_service_accounts" => {
"type" => "array",
"description" => "Resources using these service accounts, from which traffic will be allowed",
"items" => {
"type" => "string"
}
},
"target_tags" => {
"type" => "array",
"description" => "VMs with these tags, to which traffic will be allowed",
"items" => {
"type" => "string"
}
},
"target_service_accounts" => {
"type" => "array",
"description" => "Resources using these service accounts, to which traffic will be allowed",
"items" => {
"type" => "string"
}
}
}
}
},
"project" => {
"type" => "string",
"description" => "The project into which to deploy resources"
}
}
[toplevel_required, schema]
end
# Cloud-specific pre-processing of {MU::Config::BasketofKittens::firewall_rules}, bare and unvalidated.
# @param acl [Hash]: The resource to process and validate
# @param config [MU::Config]: The overall deployment config of which this resource is a member
# @return [Boolean]: True if validation succeeded, False otherwise
def self.validateConfig(acl, config)
ok = true
if acl['vpc']
if !acl['vpc']['habitat']
acl['vpc']['project'] ||= acl['project']
elsif acl['vpc']['habitat'] and acl['vpc']['habitat']['id']
acl['vpc']['project'] = acl['vpc']['habitat']['id']
elsif acl['vpc']['habitat'] and acl['vpc']['habitat']['name']
acl['vpc']['project'] = acl['vpc']['habitat']['name']
end
correct_vpc = MU::Cloud::Google::VPC.pickVPC(
acl['vpc'],
acl,
"firewall_rule",
config
)
acl['vpc'] = correct_vpc if correct_vpc
end
acl['rules'] ||= []
# Firewall entries without rules are illegal in GCP, so insert a
# default-deny placeholder.
if acl['rules'].empty?
acl['rules'] << {
"deny" => true,
"proto" => "all",
"hosts" => ["0.0.0.0/0"],
"weight" => 65535
}
end
# First, expand some of our protocol shorthand into a real list
append = []
delete = []
acl['rules'].each { |r|
if !r['egress']
if !r['source_tags'] and !r['source_service_accounts'] and
(!r['hosts'] or r['hosts'].empty?)
r['hosts'] = ['0.0.0.0/0']
end
else
if !r['destination_tags'] and !r['destination_service_accounts'] and
(!r['hosts'] or r['hosts'].empty?)
r['hosts'] = ['0.0.0.0/0']
end
end
if r['proto'] == "standard"
STD_PROTOS.each { |p|
newrule = r.dup
newrule['proto'] = p
append << newrule
}
delete << r
elsif r['proto'] == "all"
PROTOS.each { |p|
newrule = r.dup
newrule['proto'] = p
append << newrule
}
delete << r
end
}
delete.each { |r|
acl['rules'].delete(r)
}
acl['rules'].concat(append)
# Next, bucket these by what combination of allow/deny and
# ingress/egress rule they are. If we have more than one
# classification
rules_by_class = {
"allow-ingress" => [],
"allow-egress" => [],
"deny-ingress" => [],
"deny-egress" => [],
}
acl['rules'].each { |rule|
if rule['deny']
if rule['egress']
rules_by_class["deny-egress"] << rule
else
rules_by_class["deny-ingress"] << rule
end
else
if rule['egress']
rules_by_class["allow-egress"] << rule
else
rules_by_class["allow-ingress"] << rule
end
end
}
rules_by_class.reject! { |_k, v| v.size == 0 }
# Generate other firewall rule objects to cover the other behaviors
# we've requested, if indeed we've done so.
if rules_by_class.size > 1
keep = rules_by_class.keys.first
acl['rules'] = rules_by_class[keep]
rules_by_class.delete(keep)
rules_by_class.each_pair { |behaviors, rules|
newrule = acl.dup
newrule['name'] += "-"+behaviors
newrule['rules'] = rules
ok = false if !config.insertKitten(newrule, "firewall_rules")
}
end
ok
end
end #class
end #class
end
end #module