From 9d4f69250ea0e6bb733cde1e42183480f48b5d8f Mon Sep 17 00:00:00 2001 From: ssrq Date: Thu, 19 Nov 2020 19:33:47 +0100 Subject: [PATCH] __sshd config: New type --- cdist/conf/type/__sshd_config/explorer/state | 121 ++++++++ .../files/update_sshd_config.awk | 293 ++++++++++++++++++ cdist/conf/type/__sshd_config/gencode-remote | 97 ++++++ cdist/conf/type/__sshd_config/man.rst | 94 ++++++ cdist/conf/type/__sshd_config/manifest | 48 +++ .../type/__sshd_config/parameter/default/file | 1 + .../__sshd_config/parameter/default/state | 1 + .../type/__sshd_config/parameter/optional | 4 + .../__sshd_config/parameter/optional_multiple | 1 + 9 files changed, 660 insertions(+) create mode 100644 cdist/conf/type/__sshd_config/explorer/state create mode 100644 cdist/conf/type/__sshd_config/files/update_sshd_config.awk create mode 100755 cdist/conf/type/__sshd_config/gencode-remote create mode 100644 cdist/conf/type/__sshd_config/man.rst create mode 100755 cdist/conf/type/__sshd_config/manifest create mode 100644 cdist/conf/type/__sshd_config/parameter/default/file create mode 100644 cdist/conf/type/__sshd_config/parameter/default/state create mode 100644 cdist/conf/type/__sshd_config/parameter/optional create mode 100644 cdist/conf/type/__sshd_config/parameter/optional_multiple diff --git a/cdist/conf/type/__sshd_config/explorer/state b/cdist/conf/type/__sshd_config/explorer/state new file mode 100644 index 00000000..75c68b8a --- /dev/null +++ b/cdist/conf/type/__sshd_config/explorer/state @@ -0,0 +1,121 @@ +#!/bin/sh -e +# +# 2020 Dennis Camera (dennis.camera at ssrq-sds-fds.ch) +# +# This file is part of cdist. +# +# cdist is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cdist is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cdist. If not, see . +# +# Determines the current state of the config option. +# Possible output: +# - present: "should" option present in config file +# - default: the "should" option is the default -> don’t know if present +# - absent: no such option present in config file +# + +joinlines() { sed -n -e H -e "\${x;s/^\\n//;s/\\n/${1:?}/g;p;}"; } +trlower() { tr '[:upper:]' '[:lower:]'; } +tolower() { printf '%s' "$*" | trlower; } + +default_value() { + sshd -T -f /dev/null -C "$(make_conn_spec)" \ + | sed -n -e 's/^'"$(tolower "${1:?}")"'[[:blank:]]\{1,\}//p' +} + +make_conn_spec() { + if test -s "${__object:?}/parameter/match" + then + _match_file="${__object:?}/parameter/match" + else + _match_file='/dev/null' + fi + + for _kw in \ + addr=Address \ + user=User \ + host=Host \ + laddr=LocalAddress \ + lport=LocalPort \ + rdomain=RDomain + do + _specname=${_kw%%=*} + _confname=$(tolower "${_kw#*=}") + while read -r _k _v + do + if test "$(tolower "${_k}")" = "${_confname}" + then + printf '%s=%s\n' "${_specname}" "${_v}" + continue 2 + fi + done <"${_match_file}" + + # NOTE: Print test spec even for empty keys to suppress errors like: + # 'Match User' in configuration but 'user' not in connection test specification. + # except lport: + # Invalid port '' in test mode specification lport= + test "${_specname}" = 'lport' || printf '%s=\n' "${_specname}" + done \ + | joinlines ',' + unset _match_file +} + +sshd_config_file=$(cat "${__object:?}/parameter/file") +state_should=$(cat "${__object:?}/parameter/state") + +if test -s "${__object:?}/parameter/option" +then + option_name=$(cat "${__object:?}/parameter/option") +else + option_name=${__object_id:?} +fi + +value_should=$(cat "${__object:?}/parameter/value" 2>/dev/null) \ +|| test "${state_should}" = absent || exit 0 # param optional if --state absent + +command -v sshd >/dev/null 2>&1 || { + echo 'Cannot find sshd.' >&2 + exit 1 +} + +test -e "${sshd_config_file}" || { + echo 'absent' + exit 0 +} + +value_is=$( + sshd -T -f "${sshd_config_file}" -C "$(make_conn_spec)" \ + | sed -n -e 's/^'"$(tolower "${option_name}")"'[[:blank:]]\{1,\}//p') + +if printf '%s\n' "${value_is}" | { + if test -n "${value_should}" + then + grep -q -x -F "${value_should}" + else + # if no value provided, assume "any" value + grep -q -e . + fi + } +then + if default_value "${option_name}" | grep -q -x -F "${value_is}" + then + # Might produce false positives for default values. + # TODO: Manual checking should be done, but for simplicity, this case is + # currently ignored here. + echo default + else + echo present + fi +else + echo absent +fi diff --git a/cdist/conf/type/__sshd_config/files/update_sshd_config.awk b/cdist/conf/type/__sshd_config/files/update_sshd_config.awk new file mode 100644 index 00000000..d0bc2b4b --- /dev/null +++ b/cdist/conf/type/__sshd_config/files/update_sshd_config.awk @@ -0,0 +1,293 @@ +# -*- mode: awk; indent-tabs-mode: t -*- + +function usage() { + print_err("Usage: awk -f update_sshd_config.awk -- -o set|unset [-m 'User git'] -l 'X11Forwarding no' /etc/ssh/sshd_config") +} + +function print_err(s) { print s | "cat >&2" } + +function alength(a, i) { + for (i = 0; (i + 1) in a; ++i); + return i +} + +function join(sep, a, i, s) { + for (i = i ? i : 1; i in a; i++) + s = s sep a[i] + return substr(s, 2) +} + +function getopt(opts, argv, target, files, i, c, lv, idx, nf) { + # trivial getopt(3) implementation; only basic functionality + if (argv[1] == "--") i++ + for (i += 1; i in argv; i++) { + if (lv) { target[c] = argv[i]; lv = 0; continue } + if (argv[i] ~ /^-/) { + c = substr(argv[i], 2, 1) + idx = index(opts, c) + if (!idx) { + print_err(sprintf("invalid option -%c\n", c)) + continue + } + if (substr(opts, idx + 1, 1) == ":") { + # option takes argument + if (length(argv[i]) > 2) + target[c] = substr(argv[i], 3) + else + lv = 1 + } else { + target[c] = 1 + } + } else + files[++nf] = argv[i] + } +} + +# tokenise configuration line +# this function mimics the counterpart in OpenSSH (misc.c) +# but it returns two (next token SUBSEP rest) because I didn’t want to have to +# simulate any pointer magic. +function strdelim_internal(s, split_equals, old) { + if (!s) + return "" + + old = s + + if (!match(s, WHITESPACE "|" QUOTE "" (split_equals ? "|" EQUALS : ""))) + return s + + s = substr(s, RSTART) + old = substr(old, 1, RSTART - 1) + + if (s ~ "^" QUOTE) { + old = substr(old, 2) + + # Find matching quote + if (match(s, QUOTE)) { + old = substr(old, 1, RSTART) + # s = substr() + if (match(s, "^" WHITESPACE "*")) + s = substr(s, RLENGTH) + return old + } else { + # no matching quote + return "" + } + } + + if (match(s, "^" WHITESPACE "+")) { + sub("^" WHITESPACE "+", "", s) + if (split_equals) + sub(EQUALS WHITESPACE "*", "", s) + } else if (s ~ "^" EQUALS) { + s = substr(s, 2) + } + + return old SUBSEP s +} +function strdelim(s) { return strdelim_internal(s, 1) } +function strdelimw(s) { return strdelim_internal(s, 0) } + +function singleton_option(opt) { + return tolower(opt) !~ /^(acceptenv|allowgroups|allowusers|authenticationmethods|authorizedkeysfile|denygroups|denyusers|hostcertificate|hostkey|listenaddress|logverbose|permitlisten|permitopen|port|setenv|subsystem)$/ +} + +function print_update() { + if (mode) { + if (match_only) printf "\t" + printf "%s\n", line_should + updated = 1 + } +} + +BEGIN { + FS = "\n" # disable field splitting + + WHITESPACE = "[ \t]" # servconf.c, misc.c:strdelim_internal (without line breaks, cf. bugs) + QUOTE = "[\"]" # misc.c:strdelim_internal + EQUALS = "[=]" + + split("", opts) + split("", files) + getopt("ho:l:m:", ARGV, opts, files) + + if (opts["h"]) { usage(); exit (e="0") } + + line_should = opts["l"] + match_only = opts["m"] + num_files = alength(files) + + if (num_files != 1 || !opts["o"] || !line_should) { + usage() + exit (e=126) + } + + if (opts["o"] == "set") { + mode = 1 + } else if (opts["o"] == "unset") { + mode = 0 + } else { + print_err(sprintf("invalid mode %s\n", mode)) + exit (e=1) + } + + if (mode) { + # loop over sshd_config twice! + ARGV[2] = ARGV[1] = files[1] + ARGC = 3 + } else { + # only loop once + ARGV[1] = files[1] + ARGC = 2 + } + + split(strdelim(line_should), should, SUBSEP) + option_should = tolower(should[1]) + value_should = should[2] +} + +{ + line = $0 + + # Strip trailing whitespace. Allow \f (form feed) at EOL only + sub("(" WHITESPACE "|\f)*$", "", line) + + # Strip leading whitespace + sub("^" WHITESPACE "*", "", line) + + if (match(line, "^#" WHITESPACE "*")) { + prefix = substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } else { + prefix = "" + } + + line_type = "invalid" + option_is = value_is = "" + + if (line) { + split(strdelim(line), toks, SUBSEP) + + if (tolower(toks[1]) == "match") { + MATCH = (prefix ~ /^#/ ? "#" : "") join(" ", toks, 2) + line_type = "match" + } else if (toks[1] ~ /^[A-Za-z][A-Za-z0-9]+$/) { + # This could be an option line + line_type = "option" + option_is = tolower(toks[1]) + value_is = toks[2] + } + } else { + line_type = "empty" + } +} + +# mode: unset + +!mode { + # delete matching config + if (prefix !~ /^#/) + if (MATCH == match_only && option_is == option_should) + if (!value_should || value_should == value_is) + next + + print + next +} + + +# mode: set + +mode && NR == FNR { + if (line_type == "option") { + if (MATCH !~ /^#/) { + if (prefix ~ /^#/) { + # comment line + last_occ[MATCH, "#" option_is] = FNR + } else { + # option line + last_occ[MATCH, option_is] = FNR + } + last_occ[MATCH] = FNR + } + } else if (line_type == "invalid" && !prefix) { + # INVALID LINE + print_err(sprintf("%s: syntax error on line %u\n", ARGV[0], FNR)) + } + + next +} + +# before second pass prepare hashes containing location information to be used +# in the second pass. +mode && NR > FNR && FNR == 1 { + # First we drop the locations of commented-out options if a non-commented + # option is available. If a non-commented option is available, we will + # append new config options there to have them all at one place. + for (k in last_occ) { + if (k ~ /^#/) { + # delete entries of commented out match blocks + delete last_occ[k] + continue + } + + split(k, parts, SUBSEP) + + if (parts[2] ~ /^#/ && ((parts[1], substr(parts[2], 2)) in last_occ)) + delete last_occ[k] + } + + # Reverse the option => line mapping. The line_map allows for easier lookups + # in the second pass. + # We only keep options, not top-level keywords, because we can only have + # one entry per line and there are conflicts with last lines of "sections". + for (k in last_occ) { + if (!index(k, SUBSEP)) continue + line_map[last_occ[k]] = k + } +} + +# Second pass +mode && line_map[FNR] == match_only SUBSEP option_should && !updated { + split(line_map[FNR], parts, SUBSEP) + + # If option allows multiple values, print current value + if (!singleton_option(parts[2])) { + if (value_should != value_is) + print + } + + print_update() + + next +} + +mode { print } + +# Is a comment option +mode && line_map[FNR] == match_only SUBSEP "#" option_should && !updated { + print_update() +} + +# Last line of the should match section +mode && last_occ[match_only] == FNR && !updated { + # NOTE: Inserting empty lines is only cosmetic. It is only done if + # different options are next to each other and not in a match block + # (match blocks are usually not in the default config and thus don’t + # contain commented blocks.) + if (line && option_is != option_should && !MATCH) + print "" + print_update() +} + +END { + if (e) exit e + + if (mode && !updated) { + if (match_only && MATCH != match_only) { + printf "\nMatch %s\n", match_only + } + + print_update() + } +} diff --git a/cdist/conf/type/__sshd_config/gencode-remote b/cdist/conf/type/__sshd_config/gencode-remote new file mode 100755 index 00000000..0b44dfa7 --- /dev/null +++ b/cdist/conf/type/__sshd_config/gencode-remote @@ -0,0 +1,97 @@ +#!/bin/sh -e +# +# 2020 Dennis Camera (dennis.camera at ssrq-sds-fds.ch) +# +# This file is part of cdist. +# +# cdist is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cdist is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cdist. If not, see . +# + +joinlines() { sed -n -e H -e "\${x;s/^\\n//;s/\\n/${1:?}/g;p;}"; } + +state_is=$(cat "${__object:?}/explorer/state") +state_should=$(cat "${__object:?}/parameter/state") + +if test "${state_is}" = "${state_should}" -o "${state_is}" = 'default' +then + # nothing to do (if the value is the default, ignore its state) + exit 0 +fi + +case ${state_should} +in + (present) + mode='set' + ;; + (absent) + mode='unset' + ;; + (*) + printf 'Invalid --state: %s\n' "${state_should}" >&2 + exit 1 + ;; +esac + +sshd_config_file=$(cat "${__object:?}/parameter/file") + +quote() { printf "'%s'" "$(printf '%s' "$*" | sed -e "s/'/'\\\\''/g")"; } +drop_awk_comments() { quote "$(sed '/^[[:blank:]]*#.*$/d;/^$/d' "$@")"; } + +# Ensure the sshd_config file is there +cat <$(quote "${sshd_config_file}") + chown 0:0 $(quote "${sshd_config_file}") + chmod 0644 $(quote "${sshd_config_file}") +} + +EOF + +match_only= +if test -s "${__object:?}/parameter/match" +then + match_only=$(joinlines ' ' <"${__object:?}/parameter/match") +fi + +if test -s "${__object:?}/parameter/option" +then + option_line=$(cat "${__object:?}/parameter/option") +else + option_line=${__object_id:?} +fi + +if test -s "${__object:?}/parameter/value" +then + option_line="${option_line} $(cat "${__object:?}/parameter/value")" +fi + +# Send message on config update +printf '%s%s %s\n' "${mode}" "${match_only:+ [${match_only}]}" \ + "${option_line}" >>"${__messages_out:?}" + +# Update sshd_config (remote code) +cat <$(quote "${sshd_config_file}.tmp") \\ +|| exit + +cmp -s $(quote "${sshd_config_file}") $(quote "${sshd_config_file}.tmp") || { + sshd -t -f $(quote "${sshd_config_file}.tmp") \\ + && cat $(quote "${sshd_config_file}.tmp") >$(quote "${sshd_config_file}") +} +rm -f $(quote "${sshd_config_file}.tmp") +EOF diff --git a/cdist/conf/type/__sshd_config/man.rst b/cdist/conf/type/__sshd_config/man.rst new file mode 100644 index 00000000..8b0069ac --- /dev/null +++ b/cdist/conf/type/__sshd_config/man.rst @@ -0,0 +1,94 @@ +cdist-type__sshd_config(7) +========================== + +NAME +---- +cdist-type__sshd_config - Manage options in sshd_config + + +DESCRIPTION +----------- +This space intentionally left blank. + + +REQUIRED PARAMETERS +------------------- +None. + + +OPTIONAL PARAMETERS +------------------- +file + The path to the sshd_config file to edit. + Defaults to ``/etc/ssh/sshd_config``. +match + Restrict this option to apply only for certain connections. + Allowed values are what would be allowed to be written after a ``Match`` + keyword in ``sshd_config``, e.g. ``--match 'User anoncvs'``. + + Can be used multiple times. All of the values are ANDed together. +option + The name of the option to manipulate. Defaults to ``__object_id``. +state + Can be: + + - ``present``: ensure a matching config line is present (or the default + value). + - ``absent``: ensure no matching config line is present. +value + The option's value to be assigned to the option (if ``--state present``) or + removed (if ``--state absent``). + + This option is required if ``--state present``. If not specified and + ``--state absent``, all values for the given option are removed. + + +BOOLEAN PARAMETERS +------------------ +None. + + +EXAMPLES +-------- + +.. code-block:: sh + + # Disallow root logins with password + __sshd_config PermitRootLogin --value without-password + + # Disallow password-based authentication + __sshd_config PasswordAuthentication --value no + + # Accept the EDITOR environment variable + __sshd_config AcceptEnv:EDITOR --option AcceptEnv --value EDITOR + + # Force command for connections as git user + __sshd_config git@ForceCommand --match 'User git' --option ForceCommand \ + --value 'cd ~git && exec git-shell ${SSH_ORIGINAL_COMMAND:+-c "${SSH_ORIGINAL_COMMAND}"}' + + +SEE ALSO +-------- +:strong:`sshd_config`\ (5) + + +BUGS +---- +- This type assumes a nicely formatted config file, + i.e. no config options spanning multiple lines. +- ``Include`` directives are ignored. +- Config options are not added/removed to/from the config file if their value is + the default value. + + +AUTHORS +------- +Dennis Camera + + +COPYING +------- +Copyright \(C) 2020 Dennis Camera. You can redistribute it +and/or modify it under the terms of the GNU General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. diff --git a/cdist/conf/type/__sshd_config/manifest b/cdist/conf/type/__sshd_config/manifest new file mode 100755 index 00000000..566bde90 --- /dev/null +++ b/cdist/conf/type/__sshd_config/manifest @@ -0,0 +1,48 @@ +#!/bin/sh -e +# +# 2020 Dennis Camera (dennis.camera at ssrq-sds-fds.ch) +# +# This file is part of cdist. +# +# cdist is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cdist is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cdist. If not, see . +# + +os=$(cat "${__global:?}/explorer/os") + +state_should=$(cat "${__object:?}/parameter/state") + +case ${os} +in + (alpine|centos|fedora|redhat|scientific|debian|devuan|ubuntu) + if test "${state_should}" != 'absent' + then + __package openssh-server --state present + fi + ;; + (archlinux|gentoo|slackware|suse) + if test "${state_should}" != 'absent' + then + __package openssh --state present + fi + ;; + (freebsd|netbsd|openbsd) + # whitelist + ;; + (*) + printf 'Your operating system (%s) is currently not supported by this type (%s)\n' \ + "${os}" "${__type##*/}" >&2 + printf 'Please contribute an implementation for it if you can.\n' >&2 + exit 1 + ;; +esac diff --git a/cdist/conf/type/__sshd_config/parameter/default/file b/cdist/conf/type/__sshd_config/parameter/default/file new file mode 100644 index 00000000..d8ea5dfc --- /dev/null +++ b/cdist/conf/type/__sshd_config/parameter/default/file @@ -0,0 +1 @@ +/etc/ssh/sshd_config diff --git a/cdist/conf/type/__sshd_config/parameter/default/state b/cdist/conf/type/__sshd_config/parameter/default/state new file mode 100644 index 00000000..e7f6134f --- /dev/null +++ b/cdist/conf/type/__sshd_config/parameter/default/state @@ -0,0 +1 @@ +present diff --git a/cdist/conf/type/__sshd_config/parameter/optional b/cdist/conf/type/__sshd_config/parameter/optional new file mode 100644 index 00000000..922ab093 --- /dev/null +++ b/cdist/conf/type/__sshd_config/parameter/optional @@ -0,0 +1,4 @@ +file +option +state +value diff --git a/cdist/conf/type/__sshd_config/parameter/optional_multiple b/cdist/conf/type/__sshd_config/parameter/optional_multiple new file mode 100644 index 00000000..02b1d1a9 --- /dev/null +++ b/cdist/conf/type/__sshd_config/parameter/optional_multiple @@ -0,0 +1 @@ +match