Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<node>.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.
Expand Down
26 changes: 19 additions & 7 deletions lib/messhy/ssh_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -421,7 +434,6 @@ def reconcile_script(temp_file)
fi
fi

rm -f "$stripped" #{temp_file}
SCRIPT
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/messhy/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Messhy
VERSION = '0.9.2'
VERSION = '0.9.3'
end
105 changes: 105 additions & 0 deletions test/ssh_executor_test.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading