diff --git a/manifests/mod/md.pp b/manifests/mod/md.pp new file mode 100644 index 0000000000..d8847ed67f --- /dev/null +++ b/manifests/mod/md.pp @@ -0,0 +1,140 @@ +# @summary +# Installs and configures `mod_md`. +# +# @param md_activation_delay +# - +# +# @param md_base_server +# Control if base server may be managed or only virtual hosts. +# +# @param md_ca_challenges +# Type of ACME challenge used to prove domain ownership. +# +# @param md_certificate_agreement +# You confirm that you accepted the Terms of Service of the Certificate +# Authority. +# +# @param md_certificate_authority +# The URL of the ACME Certificate Authority service. +# +# @param md_certificate_check +# - +# +# @param md_certificate_monitor +# The URL of a certificate log monitor. +# +# @param md_certificate_protocol +# The protocol to use with the Certificate Authority. +# +# @param md_certificate_status +# Exposes public certificate information in JSON. +# +# @param md_challenge_dns01 +# Define a program to be called when the `dns-01` challenge needs to be +# setup/torn down. +# +# @param md_contact_email +# The ACME protocol requires you to give a contact url when you sign up. +# +# @param md_http_proxy +# Define a proxy for outgoing connections. +# +# @param md_members +# Control if the alias domain names are automatically added. +# +# @param md_message_cmd +# Handle events for Manage Domains. +# +# @param md_must_staple +# Control if new certificates carry the OCSP Must Staple flag. +# +# @param md_notify_cmd +# Run a program when a Managed Domain is ready. +# +# @param md_port_map +# Map external to internal ports for domain ownership verification. +# +# @param md_private_keys +# Set type and size of the private keys generated. +# +# @param md_renew_mode +# Controls if certificates shall be renewed. +# +# @param md_renew_window +# Control when a certificate will be renewed. +# +# @param md_require_https +# Redirects http: traffic to https: for Managed Domains. +# An http: Virtual Host must nevertheless be setup for that domain. +# +# @param md_server_status +# Control if Managed Domain information is added to server-status. +# +# @param md_staple_others +# Enable stapling for certificates not managed by mod_md. +# +# @param md_stapling +# Enable stapling for all or a particular MDomain. +# +# @param md_stapling_keep_response +# Controls when old responses should be removed. +# +# @param md_stapling_renew_window +# Control when the stapling responses will be renewed. +# +# @param md_store_dir +# Path on the local file system to store the Managed Domains data. +# +# @param md_warn_window +# Define the time window when you want to be warned about an expiring +# certificate. +# +# @see https://httpd.apache.org/docs/current/mod/mod_md.html for additional documentation. +# +# @note Unsupported platforms: CentOS: 6, 7; Debian: 8, 9; OracleLinux: all; RedHat: 6, 7; Scientific: all; SLES: all; Ubuntu: 14, 16, 18 +class apache::mod::md ( + Optional[String] $md_activation_delay = undef, + Optional[Enum['on', 'off']] $md_base_server = undef, + Optional[Array[Enum['dns-01', 'http-01', 'tls-alpn-01']]] $md_ca_challenges = undef, + Optional[Enum['accepted']] $md_certificate_agreement = undef, + Optional[Stdlib::HTTPUrl] $md_certificate_authority = undef, + Optional[String] $md_certificate_check = undef, # undocumented + Optional[String] $md_certificate_monitor = undef, + Optional[Enum['ACME']] $md_certificate_protocol = undef, + Optional[Enum['on', 'off']] $md_certificate_status = undef, + Optional[Stdlib::Absolutepath] $md_challenge_dns01 = undef, + Optional[String] $md_contact_email = undef, + Optional[Stdlib::HTTPUrl] $md_http_proxy = undef, + Optional[Enum['auto', 'manual']] $md_members = undef, + Optional[Stdlib::Absolutepath] $md_message_cmd = undef, + Optional[Enum['on', 'off']] $md_must_staple = undef, + Optional[Stdlib::Absolutepath] $md_notify_cmd = undef, + Optional[String] $md_port_map = undef, + Optional[String] $md_private_keys = undef, + Optional[Enum['always', 'auto', 'manual']] $md_renew_mode = undef, + Optional[String] $md_renew_window = undef, + Optional[Enum['off', 'permanent', 'temporary']] $md_require_https = undef, + Optional[Enum['on', 'off']] $md_server_status = undef, + Optional[Enum['on', 'off']] $md_staple_others = undef, + Optional[Enum['on', 'off']] $md_stapling = undef, + Optional[String] $md_stapling_keep_response = undef, + Optional[String] $md_stapling_renew_window = undef, + Optional[Stdlib::Absolutepath] $md_store_dir = undef, + Optional[String] $md_warn_window = undef, +) { + include apache + include apache::mod::watchdog + + apache::mod { 'md': + } + + file { 'md.conf': + ensure => file, + path => "${apache::mod_dir}/md.conf", + mode => $apache::file_mode, + content => epp('apache/mod/md.conf.epp'), + require => Exec["mkdir ${apache::mod_dir}"], + before => File[$apache::mod_dir], + notify => Class['apache::service'], + } +} diff --git a/manifests/mod/watchdog.pp b/manifests/mod/watchdog.pp new file mode 100644 index 0000000000..ff17a1cdd6 --- /dev/null +++ b/manifests/mod/watchdog.pp @@ -0,0 +1,31 @@ +# @summary +# Installs and configures `mod_watchdog`. +# +# @param watchdog_interval +# Sets the interval at which the watchdog_step hook runs. +# +# @see https://httpd.apache.org/docs/current/mod/mod_watchdog.html for additional documentation. +class apache::mod::watchdog ( + Optional[Integer] $watchdog_interval = undef, +) { + include apache + + $module_builtin = $facts['os']['family'] in ['Debian'] + + unless $module_builtin { + apache::mod { 'watchdog': + } + } + + if $watchdog_interval { + file { 'watchdog.conf': + ensure => file, + path => "${apache::mod_dir}/watchdog.conf", + mode => $apache::file_mode, + content => "WatchdogInterval ${watchdog_interval}\n", + require => Exec["mkdir ${apache::mod_dir}"], + before => File[$apache::mod_dir], + notify => Class['apache::service'], + } + } +} diff --git a/manifests/params.pp b/manifests/params.pp index d2ee9a5e76..f3b4a46ef1 100644 --- a/manifests/params.pp +++ b/manifests/params.pp @@ -231,6 +231,7 @@ default => 'mod_ldap', }, 'lookup_identity' => 'mod_lookup_identity', + 'md' => 'mod_md', 'pagespeed' => 'mod-pagespeed-stable', # NOTE: The passenger module isn't available on RH/CentOS without # providing dependency packages provided by EPEL and passenger diff --git a/manifests/vhost.pp b/manifests/vhost.pp index 091a99f574..6b02bf2514 100644 --- a/manifests/vhost.pp +++ b/manifests/vhost.pp @@ -1722,6 +1722,10 @@ # value of the $servername parameter. # When set to false (default), the existing behaviour of using the $name parameter # will remain. +# +# @param $mdomain +# All the names in the list are managed as one Managed Domain (MD). mod_md will request +# one single certificate that is valid for all these names. define apache::vhost ( Variant[Boolean,String] $docroot, @@ -1971,6 +1975,7 @@ Hash $define = {}, Boolean $auth_oidc = false, Optional[Apache::OIDCSettings] $oidc_settings = undef, + Optional[Variant[Boolean,String]] $mdomain = undef, ) { # The base class must be included first because it is used by parameter defaults if ! defined(Class['apache']) { @@ -2771,6 +2776,10 @@ } } + if $mdomain { + include apache::mod::md + } + # Template uses: # - $passenger_enabled # - $passenger_start_timeout diff --git a/spec/acceptance/mod_md_spec.rb b/spec/acceptance/mod_md_spec.rb new file mode 100644 index 0000000000..5bf0c1dc7d --- /dev/null +++ b/spec/acceptance/mod_md_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper_acceptance' + +describe 'apache::mod::md', if: mod_supported_on_platform?('apache::mod::md') do + pp = <<-MANIFEST + class { 'apache': + } + apache::vhost { 'example.com': + docroot => '/var/www/example.com', + port => 443, + ssl => true, + mdomain => true, + } + MANIFEST + + it 'succeeds in configuring a virtual host using mod_md' do + apply_manifest(pp, catch_failures: true) + end +end diff --git a/spec/classes/mod/md_spec.rb b/spec/classes/mod/md_spec.rb new file mode 100644 index 0000000000..11fe1599aa --- /dev/null +++ b/spec/classes/mod/md_spec.rb @@ -0,0 +1,199 @@ +require 'spec_helper' + +describe 'apache::mod::md', type: :class do + on_supported_os.each do |os, facts| + context "on #{os}" do + let :facts do + facts + end + + if facts[:os]['family'] == 'Debian' + context 'validating all md params - using Debian' do + md_options = { + 'md_activation_delay' => { type: 'Duration', pass_opt: 'MDActivationDelay' }, + 'md_base_server' => { type: 'OnOff', pass_opt: 'MDBaseServer' }, + 'md_ca_challenges' => { type: 'CAChallenges', pass_opt: 'MDCAChallenges' }, + 'md_certificate_agreement' => { type: 'MDCertificateAgreement', pass_opt: 'MDCertificateAgreement' }, + 'md_certificate_authority' => { type: 'URL', pass_opt: 'MDCertificateAuthority' }, + 'md_certificate_check' => { type: 'String', pass_opt: 'MDCertificateCheck' }, + 'md_certificate_monitor' => { type: 'URL', pass_opt: 'MDCertificateMonitor' }, + 'md_certificate_protocol' => { type: 'MDCertificateProtocol', pass_opt: 'MDCertificateProtocol' }, + 'md_certificate_status' => { type: 'OnOff', pass_opt: 'MDCertificateStatus' }, + 'md_challenge_dns01' => { type: 'Path', pass_opt: 'MDChallengeDns01' }, + 'md_contact_email' => { type: 'EMail', pass_opt: 'MDContactEmail' }, + 'md_http_proxy' => { type: 'URL', pass_opt: 'MDHttpProxy' }, + 'md_members' => { type: 'MDMembers', pass_opt: 'MDMembers' }, + 'md_message_cmd' => { type: 'Path', pass_opt: 'MDMessageCmd' }, + 'md_must_staple' => { type: 'OnOff', pass_opt: 'MDMustStaple' }, + 'md_notify_cmd' => { type: 'Path', pass_opt: 'MDNotifyCmd' }, + 'md_port_map' => { type: 'String', pass_opt: 'MDPortMap' }, + 'md_private_keys' => { type: 'String', pass_opt: 'MDPrivateKeys' }, + 'md_renew_mode' => { type: 'MDRenewMode', pass_opt: 'MDRenewMode' }, + 'md_renew_window' => { type: 'Duration', pass_opt: 'MDRenewWindow' }, + 'md_require_https' => { type: 'MDRequireHttps', pass_opt: 'MDRequireHttps' }, + 'md_server_status' => { type: 'OnOff', pass_opt: 'MDServerStatus' }, + 'md_staple_others' => { type: 'OnOff', pass_opt: 'MDStapleOthers' }, + 'md_stapling' => { type: 'OnOff', pass_opt: 'MDStapling' }, + 'md_stapling_keep_response' => { type: 'Duration', pass_opt: 'MDStaplingKeepResponse' }, + 'md_stapling_renew_window' => { type: 'Duration', pass_opt: 'MDStaplingRenewWindow' }, + 'md_store_dir' => { type: 'Path', pass_opt: 'MDStoreDir' }, + 'md_warn_window' => { type: 'Duration', pass_opt: 'MDWarnWindow' }, + } + + md_options.each do |config_option, config_hash| + puppetized_config_option = config_option + case config_hash[:type] + when 'CAChallenges' + valid_config_values = [['dns-01'], ['tls-alpn-01', 'http-01']] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => #{valid_value}" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value.join(' ')}}) } + end + end + when 'EMail' + valid_config_values = ['root@example.com'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => #{valid_value}" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'Duration' + valid_config_values = ['7d', '33%'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'MDCertificateAgreement' + valid_config_values = ['accepted'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'MDCertificateProtocol' + valid_config_values = ['ACME'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'MDMembers' + valid_config_values = ['auto', 'manual'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'MDRenewMode' + valid_config_values = ['always', 'auto', 'manual'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'MDRequireHttps' + valid_config_values = ['off', 'temporary', 'permanent'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'OnOff' + valid_config_values = ['on', 'off'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'Path' + valid_config_values = ['/some/path/to/somewhere'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => #{valid_value}" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} "#{valid_value}"$}) } + end + end + when 'String' + valid_config_values = ['a random string'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + when 'URL' + valid_config_values = ['https://example.com/example'] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + else + valid_config_values = config_hash[:type] + valid_config_values.each do |valid_value| + describe "with #{puppetized_config_option} => '#{valid_value}'" do + let :params do + { puppetized_config_option.to_sym => valid_value } + end + + it { is_expected.to contain_file('md.conf').with_content(%r{^#{config_hash[:pass_opt]} #{valid_value}$}) } + end + end + end + end + end + end + + it { is_expected.to contain_class('apache::mod::watchdog') } + it { is_expected.to contain_apache__mod('md') } + it { is_expected.to contain_file('md.conf') } + end + end +end diff --git a/spec/classes/mod/watchdog_spec.rb b/spec/classes/mod/watchdog_spec.rb new file mode 100644 index 0000000000..42e0c7d206 --- /dev/null +++ b/spec/classes/mod/watchdog_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe 'apache::mod::watchdog', type: :class do + it_behaves_like 'a mod class, without including apache' + + on_supported_os.each do |os, os_facts| + context "On #{os}" do + let :facts do + os_facts + end + + if os_facts[:os]['family'] == 'Debian' + it { is_expected.not_to contain_apache__mod('watchdog') } + else + it { is_expected.to contain_apache__mod('watchdog') } + end + + context 'with default configuration' do + it { is_expected.not_to contain_file('watchdog.conf') } + end + + context 'with custom configuration' do + let(:params) do + { + watchdog_interval: 5, + } + end + + it { is_expected.to contain_file('watchdog.conf').with_content(%r{^WatchdogInterval 5$}) } + end + end + end +end diff --git a/spec/defines/vhost_spec.rb b/spec/defines/vhost_spec.rb index f7de631e9a..52f17c6b55 100644 --- a/spec/defines/vhost_spec.rb +++ b/spec/defines/vhost_spec.rb @@ -484,6 +484,7 @@ 'RemoteUserClaim' => 'sub', 'ClientSecret' => 'aae053a9-4abf-4824-8956-e94b2af335c8', 'CryptoPassphrase' => '4ad1bb46-9979-450e-ae58-c696967df3cd' }, + 'mdomain' => 'example.com example.net auto', } end @@ -1483,6 +1484,12 @@ content: %r{^\s+OIDCCryptoPassphrase\s4ad1bb46-9979-450e-ae58-c696967df3cd$}, ) } + it { is_expected.to contain_class('apache::mod::md') } + it { + is_expected.to contain_concat__fragment('rspec.example.com-apache-header').with( + content: %r{^MDomain example\.com example\.net auto$}, + ) + } end context 'vhost with multiple ip addresses' do let :params do @@ -2451,6 +2458,19 @@ it { is_expected.not_to compile } end end + context 'mdomain' do + let :params do + default_params.merge( + 'mdomain' => true, + ) + end + + it { + is_expected.to contain_concat__fragment('rspec.example.com-apache-header').with( + content: %r{^MDomain rspec.example.com$}, + ) + } + end end end end diff --git a/templates/mod/md.conf.epp b/templates/mod/md.conf.epp new file mode 100644 index 0000000000..0ae45fc7cf --- /dev/null +++ b/templates/mod/md.conf.epp @@ -0,0 +1,84 @@ +<% if $apache::mod::md::md_activation_delay { -%> +MDActivationDelay <%= $apache::mod::md::md_activation_delay %> +<% } -%> +<% if $apache::mod::md::md_base_server { -%> +MDBaseServer <%= $apache::mod::md::md_base_server %> +<% } -%> +<% if $apache::mod::md::md_ca_challenges { -%> +MDCAChallenges <%= $apache::mod::md::md_ca_challenges.join(' ') %> +<% } -%> +<% if $apache::mod::md::md_certificate_agreement { -%> +MDCertificateAgreement <%= $apache::mod::md::md_certificate_agreement %> +<% } -%> +<% if $apache::mod::md::md_certificate_authority { -%> +MDCertificateAuthority <%= $apache::mod::md::md_certificate_authority %> +<% } -%> +<% if $apache::mod::md::md_certificate_check { -%> +MDCertificateCheck <%= $apache::mod::md::md_certificate_check %> +<% } -%> +<% if $apache::mod::md::md_certificate_monitor { -%> +MDCertificateMonitor <%= $apache::mod::md::md_certificate_monitor %> +<% } -%> +<% if $apache::mod::md::md_certificate_protocol { -%> +MDCertificateProtocol <%= $apache::mod::md::md_certificate_protocol %> +<% } -%> +<% if $apache::mod::md::md_certificate_status { -%> +MDCertificateStatus <%= $apache::mod::md::md_certificate_status %> +<% } -%> +<% if $apache::mod::md::md_challenge_dns01 { -%> +MDChallengeDns01 "<%= $apache::mod::md::md_challenge_dns01 %>" +<% } -%> +<% if $apache::mod::md::md_contact_email { -%> +MDContactEmail <%= $apache::mod::md::md_contact_email %> +<% } -%> +<% if $apache::mod::md::md_http_proxy { -%> +MDHttpProxy <%= $apache::mod::md::md_http_proxy %> +<% } -%> +<% if $apache::mod::md::md_members { -%> +MDMembers <%= $apache::mod::md::md_members %> +<% } -%> +<% if $apache::mod::md::md_message_cmd { -%> +MDMessageCmd "<%= $apache::mod::md::md_message_cmd %>" +<% } -%> +<% if $apache::mod::md::md_must_staple { -%> +MDMustStaple <%= $apache::mod::md::md_must_staple %> +<% } -%> +<% if $apache::mod::md::md_notify_cmd { -%> +MDNotifyCmd "<%= $apache::mod::md::md_notify_cmd %>" +<% } -%> +<% if $apache::mod::md::md_port_map { -%> +MDPortMap <%= $apache::mod::md::md_port_map %> +<% } -%> +<% if $apache::mod::md::md_private_keys { -%> +MDPrivateKeys <%= $apache::mod::md::md_private_keys %> +<% } -%> +<% if $apache::mod::md::md_renew_mode { -%> +MDRenewMode <%= $apache::mod::md::md_renew_mode %> +<% } -%> +<% if $apache::mod::md::md_renew_window { -%> +MDRenewWindow <%= $apache::mod::md::md_renew_window %> +<% } -%> +<% if $apache::mod::md::md_require_https { -%> +MDRequireHttps <%= $apache::mod::md::md_require_https %> +<% } -%> +<% if $apache::mod::md::md_server_status { -%> +MDServerStatus <%= $apache::mod::md::md_server_status %> +<% } -%> +<% if $apache::mod::md::md_staple_others { -%> +MDStapleOthers <%= $apache::mod::md::md_staple_others %> +<% } -%> +<% if $apache::mod::md::md_stapling { -%> +MDStapling <%= $apache::mod::md::md_stapling %> +<% } -%> +<% if $apache::mod::md::md_stapling_keep_response { -%> +MDStaplingKeepResponse <%= $apache::mod::md::md_stapling_keep_response %> +<% } -%> +<% if $apache::mod::md::md_stapling_renew_window { -%> +MDStaplingRenewWindow <%= $apache::mod::md::md_stapling_renew_window %> +<% } -%> +<% if $apache::mod::md::md_store_dir { -%> +MDStoreDir "<%= $apache::mod::md::md_store_dir %>" +<% } -%> +<% if $apache::mod::md::md_warn_window { -%> +MDWarnWindow <%= $apache::mod::md::md_warn_window %> +<% } -%> diff --git a/templates/vhost/_file_header.erb b/templates/vhost/_file_header.erb index ac119ae01e..6920f3e92a 100644 --- a/templates/vhost/_file_header.erb +++ b/templates/vhost/_file_header.erb @@ -3,6 +3,14 @@ # Managed by Puppet # ************************************ <%= [@comment].flatten.collect{|c| "# #{c}"}.join("\n") -%> +<% if @mdomain -%> + + <%- if @mdomain.is_a?(String) -%> +MDomain <%= @mdomain %> + <%- else -%> +MDomain <%= @servername %> + <%- end -%> +<% end -%> > <% @define.each do | k, v| -%> diff --git a/templates/vhost/_ssl.erb b/templates/vhost/_ssl.erb index 4200f78590..3c918c3672 100644 --- a/templates/vhost/_ssl.erb +++ b/templates/vhost/_ssl.erb @@ -2,8 +2,10 @@ ## SSL directives SSLEngine on + <%- unless @mdomain -%> SSLCertificateFile "<%= @ssl_cert %>" SSLCertificateKeyFile "<%= @ssl_key %>" + <%- end -%> <%- if @ssl_chain -%> SSLCertificateChainFile "<%= @ssl_chain %>" <%- end -%>