diff --git a/cfbs.json b/cfbs.json index 9a1f191..a957671 100644 --- a/cfbs.json +++ b/cfbs.json @@ -302,6 +302,15 @@ "append enable.cf services/init.cf" ] }, + "promise-type-sshd": { + "description": "Promise type to configure sshd.", + "subdirectory": "promise-types/sshd", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy sshd_promise_type.py modules/promises/", + "append enable.cf services/init.cf" + ] + }, "promise-type-systemd": { "description": "Promise type to manage systemd services.", "subdirectory": "promise-types/systemd", diff --git a/promise-types/sshd/LICENSE b/promise-types/sshd/LICENSE new file mode 100644 index 0000000..eb2ada6 --- /dev/null +++ b/promise-types/sshd/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Northern.tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/promise-types/sshd/README.md b/promise-types/sshd/README.md new file mode 100644 index 0000000..c7eac1b --- /dev/null +++ b/promise-types/sshd/README.md @@ -0,0 +1,63 @@ +# `sshd` promise type + +Configures sshd and restarts the service when configuration changes. + +## Promiser +The sshd configuration keyword to manage (e.g. `PermitRootLogin`, `AllowUsers`). +Each promise manages a single directive in the drop-in config file. + +## Attributes +- `value` (required) — the value for the directive, either a string or an slist + +## What the module manages internally +1. **Include directive** — ensures the base `sshd_config` includes the drop-in directory (`sshd_config.d/`) as its first non-comment directive +2. **Drop-in directory** — creates the drop-in directory if it doesn't exist +3. **Drop-in file** — writes directives to `sshd_config.d/00-cfengine.conf` +4. **Service restart** — restarts sshd if configuration was changed and the service is already running +5. **Verification** — verifies the desired directive appears in the effective sshd config (`sshd -T`) + +## Conflicting promisers +Having multiple promises with the same sshd keyword is not recommended. +In case of conflicting promisers, the agent will attempt to converge the correct state for each one in the order they are evaluated. +This means the last promise wins and determines the final value in the configuration file. +It will also cause multiple restarts of the sshd service, which may be disruptive. + +## What the module does NOT do +- Install sshd — that is a `packages:` promise +- Ensure sshd is running — that is a `services:` promise +- Manage match blocks — those are a policy-level concern + +## Policy +```cf3 +bundle agent sshd_config +{ + packages: + "openssh-server" policy => "present"; + + services: + "sshd" service_policy => "start"; + + vars: + "allowed_users" slist => { "alice", "bob" }; + + sshd: + "PermitRootLogin" value => "no"; + "PasswordAuthentication" value => "no"; + "Port" value => "22"; + "AllowUsers" value => @(allowed_users); +} +``` + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/sshd/enable.cf b/promise-types/sshd/enable.cf new file mode 100644 index 0000000..bc3ea19 --- /dev/null +++ b/promise-types/sshd/enable.cf @@ -0,0 +1,6 @@ +promise agent sshd +# @brief Define sshd promise type +{ + path => "$(sys.workdir)/modules/promises/sshd_promise_type.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/sshd/example.cf b/promise-types/sshd/example.cf new file mode 100644 index 0000000..3e83b4b --- /dev/null +++ b/promise-types/sshd/example.cf @@ -0,0 +1,30 @@ +promise agent sshd +# @brief Define sshd promise type +{ + path => "$(sys.workdir)/modules/promises/sshd_promise_type.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent example +{ + packages: + "openssh-server" policy => "present"; + + services: + "sshd" service_policy => "start"; + + vars: + "allowed_users" slist => { "alice", "bob" }; + + sshd: + "PermitRootLogin" value => "no"; + "PasswordAuthentication" value => "no"; + "Port" value => "22"; + "AllowUsers" value => @(allowed_users); +} + +bundle agent __main__ +{ + methods: + "example"; +} diff --git a/promise-types/sshd/sshd_promise_type.py b/promise-types/sshd/sshd_promise_type.py new file mode 100644 index 0000000..d573a09 --- /dev/null +++ b/promise-types/sshd/sshd_promise_type.py @@ -0,0 +1,319 @@ +import os +import re +import subprocess +import tempfile + +from cfengine_module_library import PromiseModule, ValidationError, Result + + +BASE_CONFIG = "/etc/ssh/sshd_config" +DROP_IN_DIR = "/etc/ssh/sshd_config.d/" +CFE_CONFIG = os.path.join(DROP_IN_DIR, "00-cfengine.conf") + + +# TODO: Add a "restart" attribute (default: True) to allow overriding the +# automatic restart of sshd after configuration changes. +# TODO: Add a "start" attribute (default: False) to optionally start the sshd +# service if it is not already running. +# TODO: Append the policy comment (e.g. "# Promised by CFEngine") as a trailing +# comment on each directive written to the drop-in file. + +REQUIRED_ATTRIBUTES = ("value",) # Add required attributes here +ACCEPTED_ATTRIBUTES = REQUIRED_ATTRIBUTES + () # Add optional attributes here + + +def sshd_quote(value: str) -> str: + """Quote a string for sshd_config. Values containing whitespace, '#', or + '\"' are wrapped in double quotes, with internal backslashes and double + quotes escaped.""" + if not value: + return '""' + if re.search(r'[\s#"]', value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return value + + +def to_sshd_value(value) -> str: + """Convert a Python value to an sshd config value. Lists are space-joined, + individual strings are quoted when necessary.""" + if isinstance(value, list): + return " ".join(sshd_quote(v) for v in value) + if isinstance(value, str): + return sshd_quote(value) + raise TypeError(f"Expected str or list[str], got {type(value).__name__}") + + +def try_unlink(path: str): + """Remove a file, ignoring errors if it no longer exists.""" + try: + os.unlink(path) + except OSError: + pass + + +def is_drop_in_directive(directive: str) -> bool: + """Check if a directive is an Include for the drop-in config directory.""" + m = re.match( + rf"include(\s+|\s*=\s*){re.escape(DROP_IN_DIR)}\*\.conf", + directive.strip(), + re.IGNORECASE, + ) + return m is not None + + +def update_result(old: str, new: str) -> str: + """Return the worst of two results. Severity: KEPT < REPAIRED < NOT_KEPT.""" + if old == Result.NOT_KEPT or new == Result.NOT_KEPT: + return Result.NOT_KEPT + if old == Result.REPAIRED or new == Result.REPAIRED: + return Result.REPAIRED + return Result.KEPT + + +def get_first_directive(lines: list[str]) -> str | None: + """Return the first non-comment, non-empty directive, or None if not found.""" + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + return stripped + return None + + +class SshdPromiseTypeModule(PromiseModule): + def __init__(self): + super().__init__("sshd_promise_module", "0.0.0") + + def validate_promise( + self, promiser: str, attributes: dict[str, object], metadata: dict[str, str] + ): + # Check that promiser is a valid sshd keyword + if not re.fullmatch(r"[a-zA-Z0-9]+", promiser): + raise ValidationError( + f"Promiser '{promiser}' must be a valid sshd keyword containing only letters and numbers" + ) + + # Check for unknown attributes + for attr in attributes: + if attr not in ACCEPTED_ATTRIBUTES: + raise ValidationError(f"Attribute '{attr}' is NOT accepted") + + # Check for any missing required attributes + for attr in REQUIRED_ATTRIBUTES: + if attr not in attributes: + raise ValidationError(f"Missing required attribute '{attr}'") + + # Check type of 'value' attributes + value = attributes.get("value") + if not isinstance(value, (str, list)): + raise ValidationError("Attribute 'value' must be a string or an slist") + + # Make sure 'value' attribute is not empty + if not value: + raise ValidationError("Attribute 'value' cannot be empty") + + def validate_config(self, filename: str) -> bool: + """Validate the sshd syntax on a file""" + r = subprocess.run( + ["/usr/sbin/sshd", "-t", "-f", filename], + capture_output=True, + text=True, + ) + if r.returncode != 0: + self.log_error(f"Configuration validation failed: {r.stderr.strip()}") + return False + return True + + def safe_write_config(self, path: str, lines: list[str]) -> bool: + """Atomically write config lines to a temporary file and replace the + target only if sshd validates the syntax successfully.""" + directory = os.path.dirname(path) + base = os.path.basename(path) + prefix, suffix = os.path.splitext(base) + + fd, tmp_path = tempfile.mkstemp(prefix=prefix, suffix=suffix, dir=directory) + try: + os.fchmod(fd, 0o600) # rw------- + with os.fdopen(fd, "w") as f: + f.writelines(lines) + f.flush() # Push data to kernel buffer so sshd can read it + success = self.validate_config(tmp_path) + if success: + os.replace(tmp_path, path) + return success + finally: + try_unlink(tmp_path) + + def ensure_include_directive(self) -> str: + """Ensure the base sshd config includes the drop-in directory.""" + try: + with open(BASE_CONFIG, "r") as f: + lines = f.readlines() + except FileNotFoundError: + self.log_error(f"Base configuration file '{BASE_CONFIG}' does not exist") + return Result.NOT_KEPT + + first_directive = get_first_directive(lines) + + if (first_directive is None) or (not is_drop_in_directive(first_directive)): + include_directive = f"Include {DROP_IN_DIR}*.conf" + self.log_debug( + f"Expected first directive in '{BASE_CONFIG}' to be '{include_directive}'" + ) + + lines.insert(0, f"{include_directive} # Added by CFEngine\n") + try: + if not self.safe_write_config(BASE_CONFIG, lines): + # Error already logged + return Result.NOT_KEPT + except Exception as e: + self.log_error(f"Failed to write '{BASE_CONFIG}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Added include directive to '{BASE_CONFIG}'") + return Result.REPAIRED + + return Result.KEPT + + def ensure_drop_in_dir(self) -> str: + """Ensure the drop-in config directory exists.""" + if os.path.isdir(DROP_IN_DIR): + return Result.KEPT + + try: + os.makedirs(DROP_IN_DIR, mode=0o755) # rwxr-xr-x + except Exception as e: + self.log_error(f"Failed to create drop-in directory '{DROP_IN_DIR}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Created drop-in directory '{DROP_IN_DIR}'") + return Result.REPAIRED + + def ensure_drop_in_config(self, keyword: str, value: str | list[str]) -> str: + """Write the CFEngine drop-in config file with the given attribute""" + + try: + with open(CFE_CONFIG, "r") as f: + lines = f.readlines() + except FileNotFoundError: + lines = [] + + # Remove conflicting directives + lines = [line for line in lines if not line.lower().startswith(keyword.lower())] + + # Remove the disclaimer so that we can put it back on top + lines = [line for line in lines if not line.startswith("# ")] + + # Add the promised directive to the top (just after the disclaimer) + disclaimer = ["# Managed by CFEngine\n", "# Do NOT manually edit this file\n"] + lines = disclaimer + [f"{keyword} {to_sshd_value(value)}\n"] + lines + + try: + if not self.safe_write_config(CFE_CONFIG, lines): + return Result.NOT_KEPT + except Exception as e: + self.log_error(f"Failed to write drop-in config '{CFE_CONFIG}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Updated drop-in config '{CFE_CONFIG}'") + return Result.REPAIRED + + def restart_sshd(self) -> str: + """Restart the sshd service if it is currently running.""" + r = subprocess.run( + ["systemctl", "is-active", "--quiet", "sshd"], + ) + if r.returncode != 0: + # If sshd is not running, do nothing + self.log_debug("The service sshd is not running") + return Result.KEPT + + r = subprocess.run( + ["systemctl", "restart", "--quiet", "sshd"], + ) + if r.returncode != 0: + self.log_error("Failed to restart sshd service") + return Result.NOT_KEPT + + self.log_info("Restarted sshd service") + return Result.REPAIRED + + def effective_config_has_directive( + self, keyword: str, values: str | list[str] + ) -> bool: + """Check if the running sshd effective configuration (via sshd -T) + contains the given directive(s) for a keyword.""" + r = subprocess.run( + ["/usr/sbin/sshd", "-T"], + capture_output=True, + text=True, + ) + if r.returncode != 0: + self.log_error("Failed to get effective sshd config") + return False + effective = r.stdout.strip().splitlines() + + # sshd -T splits multi-argument keywords into separate + # lines (e.g. "AllowUsers user1 user2" becomes two lines: + # "allowusers user1" and "allowusers user2"), so we must + # expand list values into individual directives to match + # the effective config format for set comparison. + + for value in values if isinstance(values, list) else [values]: + assert isinstance(value, str) + directive = f"{keyword.lower()} {to_sshd_value(value)}" + if directive in effective: + self.log_debug( + f"Directive '{directive}' is present in effective sshd config" + ) + else: + self.log_debug( + f"Directive '{directive}' is NOT present in effective sshd config" + ) + return False + + return True + + def verify_effective_config(self, keyword: str, values: str | list[str]) -> str: + """Verify the effective sshd config contains the expected directive, + returning KEPT on success or NOT_KEPT on failure.""" + if self.effective_config_has_directive(keyword, values): + self.log_verbose("Successfully verified effective sshd config") + return Result.KEPT + + self.log_error("Failed to verify effective sshd config") + return Result.NOT_KEPT + + def evaluate_promise( + self, promiser: str, attributes: dict[str, object], metadata: dict[str, str] + ) -> str: + assert "value" in attributes, "expected 'value' in attributes" + value = attributes["value"] + assert isinstance(value, (str, list)), "expected type str or list" + assert value, "expected non-empty str or list" + + # Check if the effective config already has the desired state + if self.effective_config_has_directive(promiser, value): + return Result.KEPT + + # Ensure the base config includes the drop-in directory + result = update_result(Result.KEPT, self.ensure_include_directive()) + + # Ensure the drop-in directory exists + result = update_result(result, self.ensure_drop_in_dir()) + + # Ensure the drop-in config file contains the desired directive + result = update_result(result, self.ensure_drop_in_config(promiser, value)) + + # Restart sshd only if configuration was changed + if result == Result.REPAIRED: + result = update_result(result, self.restart_sshd()) + + # Verify the effective config matches the desired state + result = update_result(result, self.verify_effective_config(promiser, value)) + + return result + + +if __name__ == "__main__": + SshdPromiseTypeModule().start() diff --git a/promise-types/sshd/test_sshd_promise_type.py b/promise-types/sshd/test_sshd_promise_type.py new file mode 100644 index 0000000..32354ef --- /dev/null +++ b/promise-types/sshd/test_sshd_promise_type.py @@ -0,0 +1,165 @@ +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "../../libraries/python")) + +from cfengine_module_library import Result # noqa: E402 + +from sshd_promise_type import ( # noqa: E402 + sshd_quote, + to_sshd_value, + get_first_directive, + is_drop_in_directive, + update_result, + DROP_IN_DIR, +) + + +def test_sshd_quote_simple(): + assert sshd_quote("no") == "no" + + +def test_sshd_quote_empty(): + assert sshd_quote("") == '""' + + +def test_sshd_quote_space(): + assert sshd_quote("some value") == '"some value"' + + +def test_sshd_quote_tab(): + assert sshd_quote("some\tvalue") == '"some\tvalue"' + + +def test_sshd_quote_hash(): + assert sshd_quote("before#after") == '"before#after"' + + +def test_sshd_quote_double_quote(): + assert sshd_quote('say "hello"') == '"say \\"hello\\""' + + +def test_sshd_quote_backslash(): + assert sshd_quote("path\\to") == "path\\to" + + +def test_sshd_quote_backslash_and_space(): + assert sshd_quote("path\\to dir") == '"path\\\\to dir"' + + +def test_to_sshd_value_str(): + assert to_sshd_value("no") == "no" + + +def test_to_sshd_value_str_with_spaces(): + assert to_sshd_value("some value") == '"some value"' + + +def test_to_sshd_value_list(): + assert to_sshd_value(["user1", "user2"]) == "user1 user2" + + +def test_to_sshd_value_list_with_quoting(): + assert to_sshd_value(["user1", "user 2"]) == 'user1 "user 2"' + + +def test_get_first_directive(): + lines = ["# comment\n", "PermitRootLogin no\n", "Port 22\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_no_comments(): + lines = ["PermitRootLogin no\n", "Port 22\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_all_comments(): + lines = ["# comment\n", "# another comment\n"] + assert get_first_directive(lines) is None + + +def test_get_first_directive_empty(): + assert get_first_directive([]) is None + + +def test_get_first_directive_extra_whitespace(): + lines = ["# comment\n", "PermitRootLogin no\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_equal_sign(): + lines = ["# comment\n", "PermitRootLogin=no\n"] + assert get_first_directive(lines) == "PermitRootLogin=no" + + +def test_get_first_directive_blank_lines(): + lines = ["\n", " \n", "# comment\n", "Port 22\n"] + assert get_first_directive(lines) == "Port 22" + + +def test_is_drop_in_directive_space(): + assert is_drop_in_directive(f"Include {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_equal(): + assert is_drop_in_directive(f"Include={DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_space_equal_space(): + assert is_drop_in_directive(f"Include = {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_case_insensitive(): + assert is_drop_in_directive(f"include {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_extra_files(): + assert is_drop_in_directive(f"Include {DROP_IN_DIR}*.conf /other/path") + + +def test_is_drop_in_directive_wrong_path(): + assert not is_drop_in_directive("Include /other/path/*.conf") + + +def test_is_drop_in_directive_no_separator(): + assert not is_drop_in_directive(f"Include{DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_not_include(): + assert not is_drop_in_directive("permitrootlogin no") + + +def test_update_result_kept_kept(): + assert update_result(Result.KEPT, Result.KEPT) == Result.KEPT + + +def test_update_result_kept_repaired(): + assert update_result(Result.KEPT, Result.REPAIRED) == Result.REPAIRED + + +def test_update_result_kept_not_kept(): + assert update_result(Result.KEPT, Result.NOT_KEPT) == Result.NOT_KEPT + + +def test_update_result_repaired_kept(): + assert update_result(Result.REPAIRED, Result.KEPT) == Result.REPAIRED + + +def test_update_result_repaired_repaired(): + assert update_result(Result.REPAIRED, Result.REPAIRED) == Result.REPAIRED + + +def test_update_result_repaired_not_kept(): + assert update_result(Result.REPAIRED, Result.NOT_KEPT) == Result.NOT_KEPT + + +def test_update_result_not_kept_kept(): + assert update_result(Result.NOT_KEPT, Result.KEPT) == Result.NOT_KEPT + + +def test_update_result_not_kept_repaired(): + assert update_result(Result.NOT_KEPT, Result.REPAIRED) == Result.NOT_KEPT + + +def test_update_result_not_kept_not_kept(): + assert update_result(Result.NOT_KEPT, Result.NOT_KEPT) == Result.NOT_KEPT diff --git a/pyrightconfig.json b/pyrightconfig.json index 389015c..3ab6871 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,3 +1,4 @@ { - "reportMissingImports": "none" + "reportMissingImports": "none", + "extraPaths": ["libraries/python"] }