mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-05 01:11:58 +02:00
Fix legacy configuration migration to XDG directories (#317)
* Handle legacy config migration for XDG assets * Ensure legacy key migration precedes identity load * Apply rufo formatting to identity module
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
* Preserve legacy configuration assets when migrating to XDG directories.
|
||||
|
||||
## v0.5.0
|
||||
|
||||
* Add JavaScript configuration tests and coverage workflow
|
||||
|
||||
@@ -67,6 +67,20 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
|
||||
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
|
||||
links and generated metadata resolve correctly.
|
||||
|
||||
### Configuration storage
|
||||
|
||||
PotatoMesh stores its runtime assets using the XDG base directory specification.
|
||||
During startup the web application migrates existing configuration from
|
||||
`web/.config` and `web/config` into the resolved `XDG_CONFIG_HOME` directory.
|
||||
This preserves previously generated instance key material and
|
||||
`/.well-known/potato-mesh` documents so upgrades do not create new credentials
|
||||
unnecessarily. When XDG directories are not provided the application falls back
|
||||
to the repository root.
|
||||
|
||||
The migrated key is written to `<XDG_CONFIG_HOME>/potato-mesh/keyfile` and the
|
||||
well-known document is staged in
|
||||
`<XDG_CONFIG_HOME>/potato-mesh/well-known/potato-mesh`.
|
||||
|
||||
The web app can be configured with environment variables (defaults shown):
|
||||
|
||||
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
|
||||
|
||||
@@ -25,6 +25,7 @@ module PotatoMesh
|
||||
def perform_initial_filesystem_setup!
|
||||
migrate_legacy_database!
|
||||
migrate_legacy_keyfile!
|
||||
migrate_legacy_well_known_assets!
|
||||
end
|
||||
|
||||
private
|
||||
@@ -47,12 +48,33 @@ module PotatoMesh
|
||||
#
|
||||
# @return [void]
|
||||
def migrate_legacy_keyfile!
|
||||
migrate_legacy_file(
|
||||
PotatoMesh::Config.legacy_keyfile_path,
|
||||
PotatoMesh::Config.keyfile_path,
|
||||
chmod: 0o600,
|
||||
context: "filesystem.keys",
|
||||
PotatoMesh::Config.legacy_keyfile_candidates.each do |candidate|
|
||||
migrate_legacy_file(
|
||||
candidate,
|
||||
PotatoMesh::Config.keyfile_path,
|
||||
chmod: 0o600,
|
||||
context: "filesystem.keys",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Copy the legacy well-known document into the configured XDG directory.
|
||||
#
|
||||
# @return [void]
|
||||
def migrate_legacy_well_known_assets!
|
||||
destination = File.join(
|
||||
PotatoMesh::Config.well_known_storage_root,
|
||||
File.basename(PotatoMesh::Config.well_known_relative_path),
|
||||
)
|
||||
|
||||
PotatoMesh::Config.legacy_well_known_candidates.each do |candidate|
|
||||
migrate_legacy_file(
|
||||
candidate,
|
||||
destination,
|
||||
chmod: 0o644,
|
||||
context: "filesystem.well_known",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Migrate a legacy file if it exists and the destination has not been created yet.
|
||||
|
||||
@@ -47,6 +47,7 @@ module PotatoMesh
|
||||
# @return [Array<OpenSSL::PKey::RSA, Boolean>] tuple of key and generation flag.
|
||||
def load_or_generate_instance_private_key
|
||||
keyfile_path = PotatoMesh::Config.keyfile_path
|
||||
migrate_legacy_keyfile_for_identity!(keyfile_path)
|
||||
FileUtils.mkdir_p(File.dirname(keyfile_path))
|
||||
if File.exist?(keyfile_path)
|
||||
contents = File.binread(keyfile_path)
|
||||
@@ -72,6 +73,46 @@ module PotatoMesh
|
||||
[key, true]
|
||||
end
|
||||
|
||||
# Migrate an existing legacy keyfile into the configured destination.
|
||||
#
|
||||
# @param destination_path [String] absolute path where the keyfile should reside.
|
||||
# @return [void]
|
||||
def migrate_legacy_keyfile_for_identity!(destination_path)
|
||||
return if File.exist?(destination_path)
|
||||
|
||||
PotatoMesh::Config.legacy_keyfile_candidates.each do |candidate|
|
||||
next unless File.exist?(candidate)
|
||||
next if candidate == destination_path
|
||||
|
||||
begin
|
||||
FileUtils.mkdir_p(File.dirname(destination_path))
|
||||
FileUtils.cp(candidate, destination_path)
|
||||
File.chmod(0o600, destination_path)
|
||||
|
||||
debug_log(
|
||||
"Migrated legacy keyfile to XDG directory",
|
||||
context: "identity.keys",
|
||||
source: candidate,
|
||||
destination: destination_path,
|
||||
)
|
||||
rescue SystemCallError => e
|
||||
warn_log(
|
||||
"Failed to migrate legacy keyfile",
|
||||
context: "identity.keys",
|
||||
source: candidate,
|
||||
destination: destination_path,
|
||||
error_class: e.class.name,
|
||||
error_message: e.message,
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
private :migrate_legacy_keyfile_for_identity!
|
||||
|
||||
# Return the directory used to store well-known documents.
|
||||
#
|
||||
# @return [String] absolute path to the staging directory.
|
||||
|
||||
@@ -215,7 +215,19 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] absolute filesystem path to the legacy keyfile.
|
||||
def legacy_keyfile_path
|
||||
File.join(legacy_config_directory, "keyfile")
|
||||
legacy_keyfile_candidates.find { |path| File.exist?(path) } || legacy_keyfile_candidates.first
|
||||
end
|
||||
|
||||
# Enumerate known legacy keyfile locations for migration.
|
||||
#
|
||||
# @return [Array<String>] ordered list of absolute legacy keyfile paths.
|
||||
def legacy_keyfile_candidates
|
||||
[
|
||||
File.join(web_root, ".config", "keyfile"),
|
||||
File.join(web_root, ".config", "potato-mesh", "keyfile"),
|
||||
File.join(web_root, "config", "keyfile"),
|
||||
File.join(web_root, "config", "potato-mesh", "keyfile"),
|
||||
].map { |path| File.expand_path(path) }.uniq
|
||||
end
|
||||
|
||||
# Legacy location for well known assets within the public folder.
|
||||
@@ -225,6 +237,23 @@ module PotatoMesh
|
||||
File.join(web_root, "public", well_known_relative_path)
|
||||
end
|
||||
|
||||
# Enumerate known legacy well-known document locations for migration.
|
||||
#
|
||||
# @return [Array<String>] ordered list of absolute legacy well-known document paths.
|
||||
def legacy_well_known_candidates
|
||||
filename = File.basename(well_known_relative_path)
|
||||
[
|
||||
File.join(web_root, ".config", "well-known", filename),
|
||||
File.join(web_root, ".config", ".well-known", filename),
|
||||
File.join(web_root, ".config", "potato-mesh", "well-known", filename),
|
||||
File.join(web_root, ".config", "potato-mesh", ".well-known", filename),
|
||||
File.join(web_root, "config", "well-known", filename),
|
||||
File.join(web_root, "config", ".well-known", filename),
|
||||
File.join(web_root, "config", "potato-mesh", "well-known", filename),
|
||||
File.join(web_root, "config", "potato-mesh", ".well-known", filename),
|
||||
].map { |path| File.expand_path(path) }.uniq
|
||||
end
|
||||
|
||||
# Interval used to refresh well known documents from disk.
|
||||
#
|
||||
# @return [Integer] refresh duration in seconds.
|
||||
|
||||
@@ -90,6 +90,21 @@ RSpec.describe PotatoMesh::Config do
|
||||
File.join(described_class.web_root, ".config", "keyfile"),
|
||||
)
|
||||
end
|
||||
|
||||
it "prefers repository config keyfiles when present" do
|
||||
Dir.mktmpdir do |dir|
|
||||
web_root = File.join(dir, "web")
|
||||
legacy_key = File.join(web_root, "config", "potato-mesh", "keyfile")
|
||||
FileUtils.mkdir_p(File.dirname(legacy_key))
|
||||
File.write(legacy_key, "legacy")
|
||||
|
||||
allow(described_class).to receive(:web_root).and_return(web_root)
|
||||
|
||||
expect(described_class.legacy_keyfile_path).to eq(legacy_key)
|
||||
end
|
||||
ensure
|
||||
allow(described_class).to receive(:web_root).and_call_original
|
||||
end
|
||||
end
|
||||
|
||||
describe ".legacy_db_path" do
|
||||
@@ -100,6 +115,22 @@ RSpec.describe PotatoMesh::Config do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".legacy_well_known_candidates" do
|
||||
it "includes repository config directories" do
|
||||
Dir.mktmpdir do |dir|
|
||||
web_root = File.join(dir, "web")
|
||||
allow(described_class).to receive(:web_root).and_return(web_root)
|
||||
|
||||
candidates = described_class.legacy_well_known_candidates
|
||||
expect(candidates).to include(
|
||||
File.join(web_root, "config", "potato-mesh", "well-known", "potato-mesh"),
|
||||
)
|
||||
end
|
||||
ensure
|
||||
allow(described_class).to receive(:web_root).and_call_original
|
||||
end
|
||||
end
|
||||
|
||||
describe ".federation_announcement_interval" do
|
||||
it "returns eight hours in seconds" do
|
||||
expect(described_class.federation_announcement_interval).to eq(8 * 60 * 60)
|
||||
|
||||
@@ -67,9 +67,9 @@ RSpec.describe PotatoMesh::App::Filesystem do
|
||||
legacy_db_path: legacy_db,
|
||||
db_path: new_db,
|
||||
default_db_path: new_db,
|
||||
legacy_keyfile_path: legacy_key,
|
||||
keyfile_path: new_key,
|
||||
)
|
||||
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_return([legacy_key])
|
||||
|
||||
harness_class.perform_initial_filesystem_setup!
|
||||
|
||||
@@ -84,6 +84,40 @@ RSpec.describe PotatoMesh::App::Filesystem do
|
||||
end
|
||||
end
|
||||
|
||||
it "migrates repository configuration assets from web/config" do
|
||||
Dir.mktmpdir do |dir|
|
||||
web_root = File.join(dir, "web")
|
||||
legacy_key = File.join(web_root, "config", "potato-mesh", "keyfile")
|
||||
legacy_well_known = File.join(web_root, "config", "potato-mesh", "well-known", "potato-mesh")
|
||||
destination_root = File.join(dir, "xdg-config", "potato-mesh")
|
||||
new_key = File.join(destination_root, "keyfile")
|
||||
new_well_known = File.join(destination_root, "well-known", "potato-mesh")
|
||||
|
||||
FileUtils.mkdir_p(File.dirname(legacy_key))
|
||||
File.write(legacy_key, "legacy-key")
|
||||
FileUtils.mkdir_p(File.dirname(legacy_well_known))
|
||||
File.write(legacy_well_known, "{\"legacy\":true}")
|
||||
|
||||
allow(PotatoMesh::Config).to receive(:web_root).and_return(web_root)
|
||||
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(new_key)
|
||||
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_return(File.dirname(new_well_known))
|
||||
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_return(".well-known/potato-mesh")
|
||||
allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(File.join(dir, "legacy", "mesh.db"))
|
||||
allow(PotatoMesh::Config).to receive(:db_path).and_return(File.join(dir, "data", "potato-mesh", "mesh.db"))
|
||||
allow(PotatoMesh::Config).to receive(:default_db_path).and_return(File.join(dir, "data", "potato-mesh", "mesh.db"))
|
||||
|
||||
harness_class.perform_initial_filesystem_setup!
|
||||
|
||||
expect(File).to exist(new_key)
|
||||
expect(File.read(new_key)).to eq("legacy-key")
|
||||
expect(File.stat(new_key).mode & 0o777).to eq(0o600)
|
||||
expect(File).to exist(new_well_known)
|
||||
expect(File.read(new_well_known)).to eq("{\"legacy\":true}")
|
||||
expect(File.stat(new_well_known).mode & 0o777).to eq(0o644)
|
||||
expect(harness_class.debug_entries.map { |entry| entry[:context] }).to include("filesystem.keys", "filesystem.well_known")
|
||||
end
|
||||
end
|
||||
|
||||
it "skips database migration when using a custom destination" do
|
||||
Dir.mktmpdir do |dir|
|
||||
legacy_db = File.join(dir, "legacy", "mesh.db")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "spec_helper"
|
||||
require "openssl"
|
||||
|
||||
RSpec.describe PotatoMesh::App::Identity do
|
||||
let(:harness_class) do
|
||||
Class.new do
|
||||
extend PotatoMesh::App::Identity
|
||||
end
|
||||
end
|
||||
|
||||
describe ".load_or_generate_instance_private_key" do
|
||||
it "loads an existing key without generating a new one" do
|
||||
Dir.mktmpdir do |dir|
|
||||
key_path = File.join(dir, "config", "potato-mesh", "keyfile")
|
||||
FileUtils.mkdir_p(File.dirname(key_path))
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
File.write(key_path, key.export)
|
||||
|
||||
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(key_path)
|
||||
|
||||
loaded_key, generated = harness_class.load_or_generate_instance_private_key
|
||||
|
||||
expect(generated).to be(false)
|
||||
expect(loaded_key.to_pem).to eq(key.to_pem)
|
||||
end
|
||||
ensure
|
||||
allow(PotatoMesh::Config).to receive(:keyfile_path).and_call_original
|
||||
end
|
||||
|
||||
it "migrates a legacy keyfile before loading" do
|
||||
Dir.mktmpdir do |dir|
|
||||
key_path = File.join(dir, "config", "potato-mesh", "keyfile")
|
||||
legacy_key_path = File.join(dir, "legacy", "keyfile")
|
||||
FileUtils.mkdir_p(File.dirname(legacy_key_path))
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
File.write(legacy_key_path, key.export)
|
||||
|
||||
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(key_path)
|
||||
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_return([legacy_key_path])
|
||||
|
||||
loaded_key, generated = harness_class.load_or_generate_instance_private_key
|
||||
|
||||
expect(generated).to be(false)
|
||||
expect(loaded_key.to_pem).to eq(key.to_pem)
|
||||
expect(File.exist?(key_path)).to be(true)
|
||||
expect(File.binread(key_path)).to eq(key.export)
|
||||
end
|
||||
ensure
|
||||
allow(PotatoMesh::Config).to receive(:keyfile_path).and_call_original
|
||||
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_call_original
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user