diff --git a/cookbooks/fb_init_sample/metadata.rb b/cookbooks/fb_init_sample/metadata.rb index 6740d2fc..0d5609a0 100644 --- a/cookbooks/fb_init_sample/metadata.rb +++ b/cookbooks/fb_init_sample/metadata.rb @@ -58,6 +58,7 @@ depends 'fb_sdparm' depends 'fb_securetty' depends 'fb_smartmon' +depends 'fb_ssh' depends 'fb_storage' depends 'fb_stunnel' depends 'fb_sudo' diff --git a/cookbooks/fb_init_sample/recipes/default.rb b/cookbooks/fb_init_sample/recipes/default.rb index 35b8f922..aea67cac 100644 --- a/cookbooks/fb_init_sample/recipes/default.rb +++ b/cookbooks/fb_init_sample/recipes/default.rb @@ -49,7 +49,16 @@ include_recipe 'fb_launchd' end include_recipe 'fb_nsswitch' -# HERE: ssh +# On a normal system, systemd-tmp-files will create all this, but in a container +# where ssh gets installed late, it doesn't exist, so we create it here +if node.ubuntu? || node.debian? + directory '/run/sshd' do + owner node.root_user + group node.root_group + mode '0755' + end +end +include_recipe 'fb_ssh' include_recipe 'fb_less' if node.linux? && !node.embedded? && !node.container? include_recipe 'fb_ethtool' diff --git a/cookbooks/fb_ssh/README.md b/cookbooks/fb_ssh/README.md new file mode 100644 index 00000000..db0df0cf --- /dev/null +++ b/cookbooks/fb_ssh/README.md @@ -0,0 +1,175 @@ +fb_ssh Cookbook +=============== +Installs and configures openssh + +Requirements +------------ + +Attributes +---------- +* node['fb_ssh']['manage_packages'] +* node['fb_ssh']['sshd_config'][$CONFIG] +* node['fb_ssh']['ssh_config'][$CONFIG] +* node['fb_ssh']['enable_central_authorized_keys'] +* node['fb_ssh']['authorized_keys_users'] +* node['fb_ssh']['enable_central_authorized_principals'] +* node['fb_ssh']['authorized_principals'][$USER][$KEYNAME] +* node['fb_ssh']['authorized_principals_users'] + +Usage +----- +### Packages +By default `fb_ssh` will install and keep updated both client and server +packages for ssh. + +You can skip package management if you have local packages or otherwise need to +do your own management by setting `manage_packages` to false. + +Given the many ways to manage packages on Windows, especially for SSH, +we default `manage_packages` to false on Windows. + +### Server configuration (sshd_config) +The `sshd_config` hash holds configs that go into `/etc/ssh/sshd_config`. In +general each key can have one of three types, bool, string/ints, or array. + +Bools are translated into `yes`/`no` when emitted into the config file. These +are straight-forward: + +```ruby +node.default['fb_ssh']['sshd_config']['PubkeyAuthentication'] = true +``` + +Becomes: + +```text +PubkeyAuthentication yes +``` + +Strings and ints are always treated like normal strings: + +```ruby +node.default['fb_ssh']['sshd_config']['ClientAliveInterval'] = 0 +node.default['fb_ssh']['sshd_config']['ForceCommand'] = '/bin/false' +``` + +Becomes: + +```text +ClientAliveInterval 0 +ForceCommand /bin/false +``` + +Arrays will be joined by spaces. It's worth noting that while this feature is +here to make management easy, one could clearly take a multi-value value key +and make it a string and it would work, but we support arrays to make modifying +the value later in the runlist easier. For example: + +```ruby +node.default['fb_ssh']['sshd_config']['AuthorizedKeysFile'] = [ + '.ssh/authorized_keys', + '.ssh/authorized_keys2', +] +``` + +Means later it's easy for someone to do: + +```ruby +node.default['fb_ssh']['sshd_config']['AuthorizedKeysFile']. + delete('.ssh/authorized_keys2') +``` + +or: + +```ruby +node.default['fb_ssh']['sshd_config']['AuthorizedKeysFile'] << + '/etc/ssh/authorized_keys/%u' +``` + +So be careful to be consistent about this. + +### Match Values +All match rules in sshd must come at the end, because Match blocks take effect +until the next match block, or the end of the file - indentation is irrelevant. + +You can use Match rules as normal. This cookbook will automatically move them +to the end of the file and keep them in the order that the users specified +them. + +This means that unlike the other keys which are sorted for easier diffing, +these are not sorted. As such, changing the order of your cookbooks could +change the order of your match statements, so be careful. + +Match statements are the exception to the datatype rule above - their value is +a hash, and that hash is treated the same as the top-level sshd_config hash: + +```ruby +node.default['fb_ssh']['sshd_config']['Match Address 1.2.3.4'] => { + 'PasswordAuthentication' => true, +} +``` + +#### Authorized Principals + +If you set `enable_central_authorized_principals` to true, then two things will +happen: +1. Your AuthorizedPrincipalsFile will be set to `/etc/ssh/authorized_princs/%u`, + regardless of what you set it to +2. The contents of `node['fb_ssh']['authorized_principals']` will be used + to populate `/etc/ssh/authorized_princs/` with one file for each + user. To limit which users are populated, simply populate the list + `node['fb_ssh']['authorized_principals_users']`. The format of the + `authorized_principals` attribute is: + +```ruby +node.default['fb_ssh']['authorized_principals'][$USER] = ['one', 'two'] +``` + +#### Authorized Keys + +These work similarly to Authorized Principals. If you set +`enable_central_authorized_keys` to true, then two things will happen: +1. Your AuthorizedKeysFile will be set to `/etc/ssh/authorized_keys/%u`, + regardless of what you set it to +2. The contents of the databag `fb_ssh_authorized_keys` + will be used to populate `/etc/ssh/authorized_keys/` with key files for each + user. To limit which keys go on a user, simply populate the list + `node['fb_ssh']['authorized_keys_users']`. The format of the items + in databag is: + +```ruby +{ + 'id': $USER, + 'keyname1': $KEY1, + 'keyname2': $KEY2, + ... +} +``` + +There should be one item for each user, as many keys as you'd like may +be in that item. + +Alternatively you can populate `node['fb_ssh']['authorized_keys'][$USER]`. +Doing so should be done similarly to the databags and each key given a name: + +```ruby +node.default['fb_ssh']['authorized_keys']['john']['key1'] = '...' +``` + +Anything in the node overrides databags. + +*NOTE FOR WINDOWS USERS*: On Windows the keys are managed in the homedirectory, +not in a central location. This is because usernames are often in the format of +`domain\user`, which means that `%u` causes sshd to expand the path to +`C:\ProgramData\ssh\authorized_keys\domain\\user`, which is an illegal filename +you can never make. + +### Client config (ssh_config) +The client config works the same as the server config, except the special-case +is `Host` keys instead of `Match` keys. As an example: + +```ruby +node.default['fb_ssh']['ssh_config']['ForwardAgent'] = true +node.default['fb_ssh']['ssh_config']['Host *.cool.com'] = { + 'ForwardX11' => true, +} +``` diff --git a/cookbooks/fb_ssh/attributes/default.rb b/cookbooks/fb_ssh/attributes/default.rb new file mode 100644 index 00000000..1fd59bc3 --- /dev/null +++ b/cookbooks/fb_ssh/attributes/default.rb @@ -0,0 +1,59 @@ +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +sftp_path = value_for_platform_family( + ['rhel', 'fedora'] => '/usr/libexec/openssh/sftp-server', + ['debian'] => '/usr/lib/openssh/sftp-server', +) + +# centos6 only supports 1... +if node.centos6? + auth_keys = '.ssh/authorized_keys' +else + auth_keys = [ + '.ssh/authorized_keys', + '.ssh/authorized_keys2', + ] +end + +default['fb_ssh'] = { + 'support_config_d' => true, + 'enable_central_authorized_keys' => false, + 'manage_packages' => !node.windows?, + 'sshd_config' => { + 'PermitRootLogin' => false, + 'UsePAM' => true, + 'Subsystem sftp' => sftp_path, + 'AuthorizedKeysFile' => auth_keys, + 'AcceptEnv' => [ + 'LANG', + 'LC_*', + ], + 'KbdInteractiveAuthentication' => false, + }, + 'authorized_keys' => {}, + 'authorized_keys_users' => [], + 'authorized_principals' => {}, + 'authorized_principals_users' => [], + 'ssh_config' => { + 'Host *' => { + 'SendEnv' => 'LANG LC_*', + 'HashKnownHosts' => true, + }, + }, +} diff --git a/cookbooks/fb_ssh/libraries/default.rb b/cookbooks/fb_ssh/libraries/default.rb new file mode 100644 index 00000000..8e4567b6 --- /dev/null +++ b/cookbooks/fb_ssh/libraries/default.rb @@ -0,0 +1,44 @@ +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +module FB + class SSH + def self.confdir(node) + node.windows? ? 'C:/ProgramData/ssh' : '/etc/ssh' + end + + # some configuration keys do not accept space-separated values. Most + # do, but a few require you to define the key several times. + # + # unfortuately, most can't be defined multiple times, so there's no + # one option that works for both. + # + # since nearly everything allows space-separated values, we hard-code + # the special-case ones that require multi-defining + MULTI_DEFINE_CONFIG_KEYS = [ + 'HostKey', + 'ListenAddress', + 'IdentityFile', + ].freeze + + DESTDIR = { + 'keys' => 'authorized_keys', + 'principals' => 'authorized_princs', + }.freeze + end +end diff --git a/cookbooks/fb_ssh/metadata.rb b/cookbooks/fb_ssh/metadata.rb new file mode 100644 index 00000000..dfa40c2e --- /dev/null +++ b/cookbooks/fb_ssh/metadata.rb @@ -0,0 +1,30 @@ +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +name 'fb_ssh' +maintainer 'Facebook' +maintainer_email 'noreply@facebook.com' +license 'Apache-2.0' +description 'Configures ssh and sshd including keys and principals' +source_url 'https://github.com/facebook/chef-cookbooks/' +# never EVER change this number, ever. +version '0.1.0' +supports 'centos' +supports 'debian' +supports 'ubuntu' +supports 'windows' diff --git a/cookbooks/fb_ssh/recipes/default.rb b/cookbooks/fb_ssh/recipes/default.rb new file mode 100644 index 00000000..ac869c91 --- /dev/null +++ b/cookbooks/fb_ssh/recipes/default.rb @@ -0,0 +1,152 @@ +# +# Cookbook:: fb_ssh +# Recipe:: default +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +client_pkg = value_for_platform_family( + ['rhel', 'fedora'] => 'openssh-clients', + ['debian'] => 'openssh-client', + ['mac_os_x'] => 'openssh', + # not used, but keeps the resource compiling + ['windows'] => 'openssh-client', +) + +svc = value_for_platform_family( + ['rhel', 'fedora'] => 'sshd', + ['debian'] => 'ssh', + ['mac_os_x'] => 'sshd', + ['windows'] => 'sshd', +) + +package client_pkg do + only_if { node['fb_ssh']['manage_packages'] } + action :upgrade +end + +package 'openssh-server' do + only_if { node['fb_ssh']['manage_packages'] } + action :upgrade + notifies :restart, 'service[ssh]' +end + +whyrun_safe_ruby_block 'handle late binding ssh configs' do + not_if { node.windows? } + block do + %w{keys principals}.each do |type| + enable_name = "enable_central_authorized_#{type}" + if node['fb_ssh'][enable_name] + cfgname = "Authorized#{type.capitalize}File" + if node['fb_ssh']['sshd_config'][cfgname] + Chef::Log.warn( + "fb_ssh: Overriding sshd '#{cfgname}' per '#{enable_name}'", + ) + end + node.default['fb_ssh']['sshd_config'][cfgname] = + File.join(FB::SSH.confdir(node), FB::SSH::DESTDIR[type], '%u') + end + end + end +end + +directory FB::SSH.confdir(node) do + if node.windows? + rights :full_control, 'Administrators' + rights :read_execute, ['Administrators', 'Authenticated Users'] + else + owner 'root' + group node.root_group + mode '0755' + end +end + +execute 'generate host keys if they do not exist' do + not_if { Dir.glob(::File.join(FB::SSH.confdir(node), '*key')).any? } + command 'ssh-keygen -A' +end + +# sshd won't start if the private keys are too readable, this will fix +# them appropriately +fb_ssh_hostkey_perms 'doit' + +# in firstboot we may not be able to get in until ssh is restarted +# on the desired config, so restart immediately. Otherwise, delay +ntype = node.firstboot_any_phase? ? :immediately : :delayed + +template ::File.join(FB::SSH.confdir(node), 'sshd_config') do + source 'ssh_config.erb' + unless node.windows? + owner 'root' + group node.root_group + mode '0644' + if node.windows? + verify '"C:/Program Files/OpenSSH-Win64/sshd.exe" -t -f %{path}' + else + verify '/usr/sbin/sshd -t -f %{path}' + end + end + variables({ :type => 'sshd_config' }) + notifies :restart, 'service[ssh]', ntype +end + +fb_ssh_clean_confd_dirs 'sshd' do + action :clean_sshd_d + notifies :restart, 'service[ssh]', ntype +end + +template ::File.join(FB::SSH.confdir(node), 'ssh_config') do + source 'ssh_config.erb' + unless node.windows? + owner 'root' + group node.root_group + mode '0644' + end + variables({ :type => 'ssh_config' }) +end + +fb_ssh_clean_confd_dirs 'ssh' do + action :clean_ssh_d +end + +fb_ssh_authorization 'manage keys' do + only_if { node['fb_ssh']['enable_central_authorized_keys'] } + action :manage_keys +end + +fb_ssh_authorization 'manage principals' do + only_if { node['fb_ssh']['enable_central_authorized_principals'] } + action :manage_principals +end + +service 'ssh' do + # rather than "service svc", give it a consistent name + # in case others want to notify it, and then just override + # the service name internally to the resource + service_name svc + if node['platform'] == 'mac_os_x' + # On OS X, we must specify the plist to get the right launchd service label. + plist '/System/Library/LaunchDaemons/ssh.plist' + end + action [:enable, :start] +end + +if node.windows? + service 'ssh-agent' do + action [:enable, :start] + end +end diff --git a/cookbooks/fb_ssh/resources/authorization.rb b/cookbooks/fb_ssh/resources/authorization.rb new file mode 100644 index 00000000..caa025c6 --- /dev/null +++ b/cookbooks/fb_ssh/resources/authorization.rb @@ -0,0 +1,103 @@ +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +action_class do + def manage(type) + keydir = ::File.join(FB::SSH.confdir(node), FB::SSH::DESTDIR[type]) + + directory keydir do + unless node.windows? + owner 'root' + group node.root_group + mode '0755' + end + end + + unless node['fb_ssh']["authorized_#{type}_users"].empty? + allowed_users = node['fb_ssh']["authorized_#{type}_users"] + end + if type == 'keys' + auth_map = data_bag('fb_ssh_authorized_keys').map { |x| [x, nil] }.to_h + auth_map.merge!(node['fb_ssh']['authorized_keys']) + else + auth_map = node['fb_ssh']["authorized_#{type}"] + end + + auth_map.each_key do |user| + next if allowed_users && !allowed_users.include?(user) + + # windows sucks and on ssh the "username" is "corp\\whatever" which is + # not a valid file name. Ugh. So we leave it in the user's homedir + if node.windows? + user = user.split('\\').last + homedir = "C:/Users/#{user}" + keyfile = "#{homedir}/.ssh/authorized_keys" + # users who don't have homedirectories, we skip + next unless ::File.exist?(homedir) + + directory "#{homedir}/.ssh" do + rights :read, user + rights :full_control, 'Administrators' + inherits false + end + else + keyfile = "#{keydir}/#{user}" + end + + template keyfile do + source "authorized_#{type}.erb" + if node.windows? + rights :read, user + rights :full_control, 'Administrators' + inherits false + else + owner 'root' + group node.root_group + mode '0644' + end + if type == 'keys' && !auth_map[user] + d = data_bag_item('fb_ssh_authorized_keys', user) + d.delete('id') + variables({ :data => d }) + else + variables({ :data => auth_map[user] }) + end + end + end + + Dir.glob("#{keydir}/*").each do |keyfile| + user = ::File.basename(keyfile) + if allowed_users + next if allowed_users.include?(user) + elsif auth_map[user] + next + end + file keyfile do + action :delete + end + end + end +end + +action :manage_keys do + manage('keys') +end + +action :manage_principals do + manage('principals') +end diff --git a/cookbooks/fb_ssh/resources/clean_confd_dirs.rb b/cookbooks/fb_ssh/resources/clean_confd_dirs.rb new file mode 100644 index 00000000..edaccef3 --- /dev/null +++ b/cookbooks/fb_ssh/resources/clean_confd_dirs.rb @@ -0,0 +1,75 @@ +# +# Copyright (c) 2025-present, Meta Platforms, Inc. +# Copyright (c) 2025-present, Phil Dibowitz +# All rights reserved. +# +# 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. +# + +action_class do + def determine_non_package_owned_configs(type) + unowned_files = [] + files = Dir.glob("/etc/ssh/#{type}_config.d/*") + return [] if files.empty? + if rpm_based? + s = Mixlib::ShellOut.new(['/bin/rpm', '-qf'] + files).run_command + # RPM will exit zero if all files are owned by a package + if s.exitstatus == 0 + return [] + end + s.stdout.split("\n").each do |line| + m = /file (.*) is not owned by any package/.match(line.strip) + next unless m + unowned_files << m[1] + end + elsif debian? + s = Mixlib::ShellOut.new(['dpkg', '-S'] + files).run_command + # dpkg will exit zero if all files are owned by a package + if s.exitstatus == 0 + return [] + end + # dpkg puts unfound files on stderr, not stdout + s.stderr.split("\n").each do |line| + m = /no path found matching pattern (.*)/.match(line.strip) + next unless m + unowned_files << m[1] + end + else + Chef::Log.warning('No ability to cleanup /etc/ssh/ssh*.d/ files') + return [] + end + if unowned_files.empty? + Chef::Log.error( + "There were unowned files in /etc/ssh/#{type}_config.d but we " + + 'could not determine which ones', + ) + end + unowned_files + end +end + +action :clean_ssh_d do + determine_non_package_owned_configs('ssh').each do |f| + file f do + action :delete + end + end +end + +action :clean_sshd_d do + determine_non_package_owned_configs('sshd').each do |f| + file f do + action :delete + end + end +end diff --git a/cookbooks/fb_ssh/resources/hostkey_perms.rb b/cookbooks/fb_ssh/resources/hostkey_perms.rb new file mode 100644 index 00000000..53d4abf6 --- /dev/null +++ b/cookbooks/fb_ssh/resources/hostkey_perms.rb @@ -0,0 +1,15 @@ +action :run do + Dir.glob(::File.join(FB::SSH.confdir(node), '*key')).each do |f| + file f do + if node.windows? + rights :full_control, 'Administrators' + rights :full_control, 'SYSTEM' + inherits false + else + owner 'root' + group node.root_group + mode '0600' + end + end + end +end diff --git a/cookbooks/fb_ssh/templates/authorized_keys.erb b/cookbooks/fb_ssh/templates/authorized_keys.erb new file mode 100644 index 00000000..2761d3f4 --- /dev/null +++ b/cookbooks/fb_ssh/templates/authorized_keys.erb @@ -0,0 +1,5 @@ +# Managed by Chef, do not modify! +<% @data.each do |name, key| %> +# <%= name %> +<%= key %> +<% end %> diff --git a/cookbooks/fb_ssh/templates/authorized_principals.erb b/cookbooks/fb_ssh/templates/authorized_principals.erb new file mode 100644 index 00000000..ec86ca03 --- /dev/null +++ b/cookbooks/fb_ssh/templates/authorized_principals.erb @@ -0,0 +1,4 @@ +# Managed by Chef, do not modify! +<% @data.each do |princ| %> +<%= princ %> +<% end %> diff --git a/cookbooks/fb_ssh/templates/ssh_config.erb b/cookbooks/fb_ssh/templates/ssh_config.erb new file mode 100644 index 00000000..5ba58285 --- /dev/null +++ b/cookbooks/fb_ssh/templates/ssh_config.erb @@ -0,0 +1,40 @@ +# This file is generated by Chef. Do not modify! +<% kw = @type == 'sshd_config' ? 'Match' : 'Host' %> +<% if node['fb_ssh']['support_config_d'] %> +Include /etc/ssh/<%= @type %>.d/*.conf +<% end %> +<% # Sort the keys so diffs are easier to read. %> +<% # But drop 'match' which (a) must be at the end and (b) must be ordered %> +<% node['fb_ssh'][@type].keys. + reject { |x| x.start_with?(kw) }. + sort.each do |key| %> +<% val = node['fb_ssh'][@type][key] %> +<% if val.is_a?(TrueClass) || val.is_a?(FalseClass) %> +<%= key %> <%= val ? 'yes' : 'no' %> +<% elsif val.is_a?(String) %> +<%= key %> <%= val %> +<% elsif val.is_a?(Array) %> +<% if FB::SSH::MULTI_DEFINE_CONFIG_KEYS.include?(key) %> +<% val.each do |v| %> +<%= key %> <%= v %> +<% end %> +<% else %> +<%= key %> <%= val.join(' ') %> +<% end %> +<% end %> +<% end %> + +<% node['fb_ssh'][@type].keys.select { |x| x.start_with?(kw) }. + each do |match| %> +<%= match %> +<% node['fb_ssh'][@type][match].keys.sort.each do |key| %> +<% val = node['fb_ssh'][@type][match][key] %> +<% if val.is_a?(TrueClass) || val.is_a?(FalseClass) %> + <%= key %> <%= val ? 'yes' : 'no' %> +<% elsif val.is_a?(String) %> + <%= key %> <%= val %> +<% elsif val.is_a?(Array) %> + <%= key %> <%= val.join(' ') %> +<% end %> +<% end %> +<% end %>