Commit 708b39cf authored by m.pausch's avatar m.pausch
Browse files

Add resources and recipes for configuring firewall rules.

This is an alternative implementation of
https://github.com/sous-chefs/firewall

The accumulator pattern is used, to collect firewall_rule resources
across various recipes, which are then combined by the firewall resource
into a single configuration file.

The firewall_rule resources are converted to nftables-rules by the
library sys_helpers_firewall.  The firewall_rule resources are supposed
to be largely compatible with those of the firewall cookbook from the
sous-chefs.
parent af443805
......@@ -42,6 +42,7 @@ kitchen:
- banner
- chef
- ferm
- firewall
- linuxlogo
- mail
- nsswitch
......
......@@ -169,6 +169,14 @@ suites:
- 'policy ACCEPT;'
FORWARD:
- 'policy DROP;'
- name: sys_firewall
run_list:
- recipe[firewall-test::default]
attributes:
sys:
firewall:
manage: true
disable: false
- name: sys_linuxlogo
run_list:
- recipe[sys::linuxlogo]
......
......@@ -8,3 +8,7 @@ cookbook 'fixtures', path: 'test/unit/fixtures', group: :chefspec
# avoid https://github.com/sous-chefs/line/issues/92
# by pulling directly from github:
cookbook 'line', github: 'sous-chefs/line', tag: "v0.6.3"
group :integration do
cookbook 'firewall-test', path: 'test/fixtures/cookbooks/firewall-test'
end
......@@ -3,6 +3,15 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
## [1.64.0]
### Added
- New recipe [`sys::firewall`](recipes/firewall.rb)
- New resource [`firewall`](resources/firewall_rule.rb)
- New resource [`firewall_rule`](resources/firewall_rule.rb)
- New attributes for configuring [`firewall`](attributes/firewall.rb)
- [`Documentation`](documents/firewall.md)
- Tests
### Changed
- Updated [documentation for `sys::pam`](documents/pam.md)
- Send chef-client output to logfile in systemd-timer mode (!39)
......
default['sys']['firewall']['manage'] = false
default['sys']['firewall']['disable'] = true
default['sys']['firewall']['allow_ssh'] = true
default['sys']['firewall']['allow_loopback'] = true
default['sys']['firewall']['allow_icmp'] = true
default['sys']['firewall']['allow_established'] = true
default['sys']['firewall']['defaults']['policy'] = {
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
}
default['sys']['firewall']['defaults']['ruleset'] = {
'add table inet filter' => 1,
'add table ip6 nat' => 1,
'add table ip nat' => 1,
"add chain inet filter INPUT { type filter hook input priority 0 ; policy #{node['sys']['firewall']['defaults']['policy']['input']}; }" => 2,
"add chain inet filter OUTPUT { type filter hook output priority 0 ; policy #{node['sys']['firewall']['defaults']['policy']['output']}; }" => 2,
"add chain inet filter FOWARD { type filter hook forward priority 0 ; policy #{node['sys']['firewall']['defaults']['policy']['forward']}; }" => 2,
'add chain ip nat POSTROUTING { type nat hook postrouting priority 100 ;}' => 2,
'add chain ip nat PREROUTING { type nat hook prerouting priority -100 ;}' => 2,
'add chain ip6 nat POSTROUTING { type nat hook postrouting priority 100 ;}' => 2,
'add chain ip6 nat PREROUTING { type nat hook prerouting priority -100 ;}' => 2,
}
Use the firewall recipe to configure nftables.
`attributes/firewall.rb`
`recipes/firewall.rb`
`resources/firewall.rb`
`resources/firewall_rule.rb`
`libraries/sys_helpers_firewall.rb`
`documents/firewall.rb`
`test/unit/recipies/firewall_spec.rb`
# Examples
## Disable nftables
Use attributes in `node['sys']['firewall']`, e.g. to switch off nftables:
node['sys']['firewall']['manage'] = true # To manage nftables at all
node['sys']['firewall']['disable'] = true # To disable nftables
## Default rules
If you want to use `sys::firewall`, just include the recipe and set
`node['sys']['firewall']['manage'] = true`. Adjust the follwing
self-explaining attributes to your needs. They all default to true:
`node['sys']['firewall']['allow_established']`
`node['sys']['firewall']['allow_icmp']`
`node['sys']['firewall']['allow_loopback']`
`node['sys']['firewall']['allow_ssh']`
This will give you the following default rules:
table inet filter {
chain INPUT {
type filter hook input priority 0; policy drop;
iif "lo" accept comment "allow loopback"
icmp type echo-request accept comment "allow icmp"
tcp dport ssh accept comment "allow world to ssh"
ct state established,related accept comment "established"
}
chain OUTPUT {
type filter hook output priority 0; policy accept;
}
chain FOWARD {
type filter hook forward priority 0; policy drop;
}
}
table ip6 nat {
chain POSTROUTING {
type nat hook postrouting priority 100; policy accept;
}
chain PREROUTING {
type nat hook prerouting priority -100; policy accept;
}
}
table ip nat {
chain POSTROUTING {
type nat hook postrouting priority 100; policy accept;
}
chain PREROUTING {
type nat hook prerouting priority -100; policy accept;
}
}
## Using `sys::firewall` from other recipes
Depend on the `sys`-cookbook in the `metadata.rb` and include
`sys::firewall` in the runlist. If access via ports `443` and `80`
should be possible, write a resource like this:
firewall_rule 'allow http(s)` do
port [80,443]
end
For further examples see the recipe
[sys::firewall](recipes/firewall.rb) and the recipe [firewall-test::default](test/fixtures/cookbooks/firewall-test/recipes/default.rb).
#
# Author:: Matthias Pausch (<m.pausch@gsi.de>)
# Cookbook Name:: sys
# Library:: Helpers::Firewall
#
# Copyright 2022 GSI Helmholtzzentrum fuer Schwerionenforschung GmbH
#
# Authors:
# Matthias Pausch (m.pausch@gsi.de)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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.
#
# This code is an adjustment of https://github.com/sous-chefs/firewall
#
module Sys
module Helpers
module Firewall
require 'ipaddr'
include Chef::Mixin::ShellOut
def dport(new_resource)
new_resource.dest_port || new_resource.port
end
def sport(new_resource)
new_resource.source_port
end
def valid_ips?(ips)
Array(ips).inject(false) do |a, ip|
a || !!IPAddr.new(ip)
end
end
def port_to_s(p)
if p.is_a?(String)
p
elsif p && p.is_a?(Integer)
p.to_s
elsif p && p.is_a?(Array)
p_strings = p.map { |o| port_to_s(o) }
"{#{p_strings.sort.join(',')}}"
elsif p && p.is_a?(Range)
"#{p.first}-#{p.last}"
end
end
def disabled?
node['sys']['firewall']['disable']
end
def managed?
node['sys']['firewall']['manage']
end
def build_rule_file(rules)
contents = []
sorted_values = rules.values.sort.uniq
sorted_values.each do |sorted_value|
contents << "# position #{sorted_value}"
contents << rules.select { |_,v| v == sorted_value }.keys.join("\n")
end
"#{contents.join("\n")}\n"
end
unless defined? CHAIN
CHAIN = {
in: 'INPUT',
out: 'OUTPUT',
pre: 'PREROUTING',
post: 'POSTROUTING',
forward: 'FORWARD',
}.freeze
end
unless defined? TARGET
TARGET = {
accept: 'accept',
allow: 'accept',
deny: 'drop',
drop: 'drop',
log: 'log prefix "nftables:" group 0',
masquerade: 'masquerade',
redirect: 'redirect',
reject: 'reject',
}.freeze
end
def build_firewall_rule(rule_resource)
return rule_resource.raw.strip if rule_resource.raw
ip_family = rule_resource.family
table = if [:pre, :post].include?(rule_resource.direction)
'nat'
else
'filter'
end
firewall_rule = if table == 'nat'
"add rule #{ip_family} #{table} "
else
"add rule inet #{table} "
end
firewall_rule << CHAIN.fetch(rule_resource.direction.to_sym)
firewall_rule << ' '
firewall_rule << "iif #{rule_resource.interface} " if rule_resource.interface
firewall_rule << "oif #{rule_resource.dest_interface} " if rule_resource.dest_interface
if rule_resource.source
source_ips = Array(rule_resource.source).map { |ip| IPAddr.new(ip) }
source_ips.delete(IPAddr.new('0.0.0.0/0'))
source_ips.delete(IPAddr.new('::/128'))
# Only works on buster and newer. In older debian-versions
# there is no prefix-method for IPv4-addresses.
addrs = source_ips.map { |ip| "#{ip}/#{ip.prefix}" }
if addrs.length == 1
firewall_rule << "#{ip_family} saddr #{addrs.first} "
elsif addrs.length > 1
firewall_rule << "#{ip_family} saddr {#{addrs.join(',')}} "
end
end
firewall_rule << "#{ip_family} daddr #{rule_resource.destination} " if rule_resource.destination
case rule_resource.protocol
when :icmp
firewall_rule << 'icmp type echo-request '
when :'ipv6-icmp', :icmpv6
firewall_rule << 'icmpv6 type { echo-request, nd-router-solicit, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } '
when :tcp, :udp
firewall_rule << "#{rule_resource.protocol} sport #{port_to_s(sport(rule_resource))} " if sport(rule_resource)
firewall_rule << "#{rule_resource.protocol} dport #{port_to_s(dport(rule_resource))} " if dport(rule_resource)
when :esp, :ah
firewall_rule << "#{ip_family} #{ip_family == :ip6 ? 'nexthdr' : 'protocol'} #{rule_resource.protocol} "
# nothing to do default :ipv6, :none
end
firewall_rule << "ct state #{Array(rule_resource.stateful).join(',').downcase} " if rule_resource.stateful
firewall_rule << "#{TARGET[rule_resource.command.to_sym]} "
firewall_rule << " to #{rule_resource.redirect_port} " if rule_resource.command == :redirect
firewall_rule << "comment \"#{rule_resource.description}\" " if rule_resource.include_comment
firewall_rule.strip!
firewall_rule
end
def log_nftables
shell_out!('nft -n list ruleset')
rescue Mixlib::ShellOut::ShellCommandFailed
Chef::Log.info('log_nftables failed!')
rescue Mixlib::ShellOut::CommandTimeout
Chef::Log.info('log_nftables timed out!')
end
def default_ruleset(current_node)
current_node['sys']['firewall']['defaults']['ruleset'].to_h
end
def ensure_default_rules_exist(current_node, new_resource)
input = new_resource.rules || {}
input.merge!(default_ruleset(current_node).to_h)
new_resource.rules(input)
end
end
end
end
......@@ -16,4 +16,4 @@ supports 'debian'
depends 'line', '< 1.0'
depends 'chef-vault'
version '1.63.1'
version '1.64.0'
#
# Author:: Matthias Pausch (<m.pausch@gsi.de>)
# Cookbook Name:: sys
# Recipe:: firewall
#
# Copyright 2022 GSI Helmholtzzentrum fuer Schwerionenforschung GmbH
#
# Authors:
# Matthias Pausch (m.pausch@gsi.de)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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.
#
# This code is an adjustment of https://github.com/sous-chefs/firewall
#
if node['sys']['firewall']['manage']
fw_action = node['sys']['firewall']['disable'] ? :disable : :install
firewall 'default' do
action fw_action
end
firewall_rule 'allow loopback' do
interface 'lo'
protocol :none
command :allow
only_if { node['sys']['firewall']['allow_loopback'] }
end
firewall_rule 'allow icmp' do
protocol :icmp
command :allow
only_if { node['sys']['firewall']['allow_icmp'] }
end
firewall_rule 'allow world to ssh' do
port 22
source '0.0.0.0/0'
only_if { node['sys']['firewall']['allow_ssh'] }
end
# allow established connections
firewall_rule 'established' do
stateful [:related, :established]
protocol :none # explicitly don't specify protocol
command :allow
only_if { node['sys']['firewall']['allow_established'] }
end
end
......@@ -9,34 +9,6 @@ unless node['sys']['nftables'].empty?
if node['debian'] && node['debian']['codename'] && node['debian']['codename'].eql?('stretch')
package 'nftables' do
action :upgrade
end
# Future version will not include the init-script, cf. https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=804648
file '/etc/init.d/nftables' do
action :delete
end
nftables_serviceaction = :enable
nftables_action = :start
unless node['sys']['nftables']['active']
nftables_serviceaction = :disable
nftables_action = :stop
end
template '/etc/nftables.conf' do
source 'etc_nftables.conf.erb'
mode '0644'
owner 'root'
group 'adm'
notifies :reload, 'service[nftables]', :immediately
end
service 'nftables' do
supports :reload => true, :restart => true
action [ nftables_action, nftables_serviceaction ]
end
end
end
#
# Author:: Matthias Pausch (<m.pausch@gsi.de>)
# Cookbook Name:: sys
# Resource:: firewall
#
# Copyright 2022 GSI Helmholtzzentrum fuer Schwerionenforschung GmbH
#
# Authors:
# Matthias Pausch (m.pausch@gsi.de)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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.
#
# This code is an adjustment of https://github.com/sous-chefs/firewall
#
if Gem::Requirement.new('>= 12.5').satisfied_by?(Gem::Version.new(Chef::VERSION))
action_class do
include Sys::Helpers::Firewall
def lookup_or_create_service(name)
begin
nftables_service = Chef.run_context.resource_collection.find(service: name)
rescue
nftables_service = service name do
action :nothing
end
end
nftables_service
end
def lookup_or_create_rulesfile(name)
begin
nftables_file = Chef.run_context.resource_collection.find(file: name)
rescue
nftables_file = file name do
action :nothing
end
end
nftables_file
end
end
#unified_mode true
provides :firewall, os: 'linux', platform: %w(debian)
property :rules, Hash
def whyrun_supported?
false
end
action :install do
return unless managed?
# Ensure the package is installed
nft_pkg = package 'nftables' do
action :nothing
end
nft_pkg.run_action(:install)
with_run_context :root do
edit_resource('sys_firewall', new_resource.name) do
action :nothing
delayed_action :rebuild
end
end
end
action :rebuild do
return if !managed?
ensure_default_rules_exist(node, new_resource)
# prints all the firewall rules
log_nftables
# this takes the commands in each hash entry and builds a rule file
nftables_file = lookup_or_create_rulesfile('/etc/nftables.conf')
nftables_file.content "#!/usr/sbin/nft -f\nflush ruleset\n#{build_rule_file(new_resource.rules)}"
nftables_file.run_action(:create)
if disabled?
new_resource.run_action(:disable)
return
end
nftables_service = lookup_or_create_service('nftables')
nftables_service.run_action(:enable)
if nftables_file.updated_by_last_action?
nftables_service.run_action(:restart)
else
nftables_service.run_action(:start)
end
new_resource.updated_by_last_action(true) if nftables_service.updated_by_last_action?
end
action :restart do
nftables_service = lookup_or_create_service('nftables')
nftables_service.run_action(:restart)
new_resource.updated_by_last_action(true)
end
action :disable do
return unless managed?
nftables_service = lookup_or_create_service('nftables')
%i(disable stop).each do |a|
nftables_service.run_action(a)
new_resource.updated_by_last_action(true) if nftables_service.updated_by_last_action?
end
end
end
#
# Author:: Matthias Pausch (<m.pausch@gsi.de>)
# Cookbook Name:: sys
# Resource:: firewall_rule
#
# Copyright 2022 GSI Helmholtzzentrum fuer Schwerionenforschung GmbH
#
# Authors:
# Matthias Pausch (m.pausch@gsi.de)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# 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.
#
# This code is an adjustment of https://github.com/sous-chefs/firewall
#
if Gem::Requirement.new('>= 12.5').satisfied_by?(Gem::Version.new(Chef::VERSION))
require 'ipaddr'
action_class do
include Sys::Helpers::Firewall
def return_early?(new_resource)
!new_resource.notify_firewall ||
!(new_resource.action.include?(:create) &&
!new_resource.should_skip?(:create)) ||
!managed?
end
end
provides :firewall_rule
default_action :create
property :firewall_name, String, default: 'default'
property :command, Symbol, default: :allow, equal_to: %i[
accept allow deny drop log masquerade redirect reject
]
property :protocol, [Integer, Symbol], default: :tcp,
callbacks: { 'must be either :tcp, :udp, :icmp, :\'ipv6-icmp\', :icmpv6, :none, or a valid IP protocol number' =>
->(p) do
%i[udp tcp icmp icmpv6 ipv6-icmp esp ah ipv6 none].include?(p) || (0..142).include?(p)
end }
property :direction, Symbol, equal_to: [:in, :out, :pre, :post, :forward], default: :in
property :logging, Symbol, equal_to: [:connections, :packets]
# nftables handles ip6 and ip simultaneously. Except for directions
# :pre and :post, where where either :ip6 or :ip must be specified.
# callback should prevent from mixing that up.
property :family, Symbol, equal_to: [:ip6, :ip], default: :ip
property :source, [String, Array], callbacks: {
'must be a valid ip address' => ->(ips) do
Array(ips).inject(false) do |a, ip|