diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcf193..8b0f398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.3] - 2026-10-05 + +### Fixed + +- Keep temporary WireGuard configurations private during reconciliation and + remove them after successful updates or failed updates with rollback. + ## [0.9.2] - 2026-10-02 ### Changed diff --git a/Gemfile.lock b/Gemfile.lock index 4dfc47e..d587d26 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - messhy (0.9.2) + messhy (0.9.3) bcrypt_pbkdf (~> 1.1) ed25519 (~> 1.4) sshkit (~> 1.25) diff --git a/README.md b/README.md index d3bd6e4..e130e6f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ Apply DNS without touching WireGuard: messhy dns --environment=production ``` +Use `messhy reconcile --environment=production` to update active peers without +restarting their WireGuard interfaces. Reconciliation uploads configuration +inside a private temporary directory and passes a separate owner-only file to +`wg syncconf`. Both temporary files are removed after a successful update or +a failed update with rollback. The installed and previous configurations remain +root-owned with mode `0600`. + ## Secret Management `messhy setup` stores generated WireGuard key pairs inside `.secrets/wireguard/*.yml` with `0600` permissions. Each node gets its own YAML file (`.secrets/wireguard/.yml`) and all peer pre‑shared keys live in `.secrets/wireguard/psks.yml`. The directory is gitignored by default, and the Rails generator ensures the ignore rules are present in your application. After provisioning, copy the YAML files into 1Password (or another vault) and remove them from disk if you do not want long‑lived local copies. diff --git a/lib/messhy/ssh_executor.rb b/lib/messhy/ssh_executor.rb index 6c4db93..11469ac 100644 --- a/lib/messhy/ssh_executor.rb +++ b/lib/messhy/ssh_executor.rb @@ -127,11 +127,22 @@ def wireguard_public_key(node_name) end def reconcile_config(node_name, config_content) - temp_file = '/tmp/messhy-wg0.conf' - script = reconcile_script(temp_file) + executor = self execute_on_node(node_name) do - upload! StringIO.new(config_content), temp_file - execute :sudo, :bash, '-lc', Shellwords.escape(script) + temp_directory = capture(:mktemp, '-d', '/tmp/messhy-reconcile.XXXXXXXXXX').strip + unless temp_directory.match?(%r{\A/tmp/messhy-reconcile\.[A-Za-z0-9]+\z}) + raise Error, 'Could not create a private WireGuard upload directory' + end + + temp_file = File.join(temp_directory, 'wg0.conf') + begin + upload! StringIO.new(config_content), temp_file + script = executor.send(:reconcile_script, temp_file) + execute :sudo, :bash, '-lc', Shellwords.escape(script) + ensure + execute :rm, '-f', temp_file + execute :rmdir, temp_directory + end end end @@ -397,9 +408,11 @@ def reconcile_script(temp_file) target=/etc/wireguard/wg0.conf candidate=/etc/wireguard/wg0.next.conf previous=/etc/wireguard/wg0.conf.previous - stripped=/tmp/messhy-wg0.stripped + stripped=$(mktemp /tmp/messhy-wg0.XXXXXXXXXX) + trap 'rm -f "$stripped"' EXIT + trap 'exit 1' HUP INT TERM - install -o root -g root -m 600 #{temp_file} "$candidate" + install -o root -g root -m 600 #{Shellwords.escape(temp_file)} "$candidate" wg-quick strip "$candidate" > "$stripped" if systemctl is-active --quiet wg-quick@wg0; then @@ -421,7 +434,6 @@ def reconcile_script(temp_file) fi fi - rm -f "$stripped" #{temp_file} SCRIPT end end diff --git a/lib/messhy/version.rb b/lib/messhy/version.rb index de7f286..3e86952 100644 --- a/lib/messhy/version.rb +++ b/lib/messhy/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Messhy - VERSION = '0.9.2' + VERSION = '0.9.3' end diff --git a/test/ssh_executor_test.rb b/test/ssh_executor_test.rb index 88d063d..bc2db43 100644 --- a/test/ssh_executor_test.rb +++ b/test/ssh_executor_test.rb @@ -1,5 +1,9 @@ require 'test_helper' require 'tempfile' +require 'tmpdir' +require 'fileutils' +require 'open3' +require 'json' class SSHExecutorTest < Minitest::Test def test_target_can_connect_through_a_declared_jump_host @@ -82,4 +86,105 @@ def test_reconcile_script_syncs_active_interfaces_and_rolls_back_on_failure assert_includes script, 'systemctl start wg-quick@wg0' refute_includes script, 'systemctl restart wg-quick@wg0' end + + def test_reconcile_private_key_file_is_private_and_removed_after_success_or_rollback + [false, true].each do |fail_sync| + Dir.mktmpdir('mesh-permissions') do |directory| + FileUtils.mkdir_p("#{directory}/wireguard") + File.write("#{directory}/wireguard/wg0.conf", "previous configuration\n") + source = "#{directory}/upload" + File.write(source, "PrivateKey = test-fixture-only\n") + stub_reconcile_commands(directory) + config = Messhy::Configuration.new({ 'test' => { 'nodes' => {} } }, 'test') + script = Messhy::SSHExecutor.new(config).send(:reconcile_script, source) + script = script.gsub('/etc/wireguard', "#{directory}/wireguard") + .gsub('/tmp/messhy-wg0', "#{directory}/stripped") + observations = "#{directory}/observations" + output, status = Open3.capture2e({ + 'PATH' => "#{directory}:#{ENV.fetch('PATH')}", + 'MESH_TEST_RUBY' => RbConfig.ruby, 'MESH_TEST_OBSERVATIONS' => observations, + 'MESH_TEST_FAIL' => fail_sync.to_s + }, 'bash', '-c', "umask 022\n#{script}") + assert_equal !fail_sync, status.success?, output + rows = File.readlines(observations).map { |line| JSON.parse(line) } + assert_equal(fail_sync ? 2 : 1, rows.size) + rows.each do |row| + assert_equal 0o600, row.fetch('mode') + refute_path_exists row.fetch('path') + end + expected = fail_sync ? "previous configuration\n" : "PrivateKey = test-fixture-only\n" + assert_equal expected, File.read("#{directory}/wireguard/wg0.conf") + end + end + end + + def test_reconcile_upload_is_private_and_removed_when_remote_apply_fails + backend = FailingReconcileBackend.new + config = Messhy::Configuration.new({ 'test' => { 'nodes' => {} } }, 'test') + executor = Messhy::SSHExecutor.new(config) + executor.define_singleton_method(:execute_on_node) { |_node, &block| backend.instance_eval(&block) } + + error = assert_raises(RuntimeError) { executor.reconcile_config('fixture', 'private fixture') } + assert_equal 'remote apply failed', error.message + assert_equal 1, backend.uploaded.size + assert_equal 0o700, backend.uploaded.first.fetch(:directory_mode) + refute_path_exists backend.uploaded.first.fetch(:path) + backend.directories.each { |path| refute_path_exists path } + ensure + backend&.directories&.each { |path| FileUtils.remove_entry(path) if File.directory?(path) } + end + + private + + def stub_reconcile_commands(directory) + programs = { + 'install' => "#!/bin/sh\nshift 4\nexec /usr/bin/install \"$@\"\n", + 'wg-quick' => "#!/bin/sh\ncat \"$2\"\n", + 'systemctl' => "#!/bin/sh\nexit 0\n", + 'wg' => <<~SH + #!/bin/sh + exec "$MESH_TEST_RUBY" -rjson -e ' + observations = ENV.fetch("MESH_TEST_OBSERVATIONS") + first = !File.exist?(observations) + File.open(observations, "a") do |file| + file.puts JSON.generate(path: ARGV[2], mode: File.stat(ARGV[2]).mode & 0777) + end + exit(ENV["MESH_TEST_FAIL"] == "true" && first ? 1 : 0) + ' "$@" + SH + } + programs.each do |name, content| + File.write("#{directory}/#{name}", content) + File.chmod(0o755, "#{directory}/#{name}") + end + end + + class FailingReconcileBackend + attr_reader :directories, :uploaded + + def initialize + @directories = [] + @uploaded = [] + end + + def capture(*arguments) + path, status = Open3.capture2(*arguments.map(&:to_s)) + raise 'mktemp failed' unless status.success? + + directories << path.strip + path + end + + def upload!(input, path) + uploaded << { path: path, directory_mode: File.stat(File.dirname(path)).mode & 0o777 } + File.write(path, input.read) + end + + def execute(*arguments) + raise 'remote apply failed' if arguments.first == :sudo + + _output, status = Open3.capture2e(*arguments.map(&:to_s)) + raise 'cleanup failed' unless status.success? + end + end end