rails_nexus 2.0.6 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +33 -6
  4. data/SECURITY.md +11 -0
  5. data/THIRD_PARTY_NOTICES.md +14 -0
  6. data/app/assets/config/rails_nexus_manifest.js +1 -1
  7. data/app/assets/javascripts/rails_nexus/application.js +55 -0
  8. data/app/assets/javascripts/rails_nexus/controllers.js +82 -5
  9. data/app/assets/stylesheets/rails_nexus/application.css +170 -60
  10. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  11. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  12. data/app/controllers/rails_nexus/backup_controller.rb +22 -19
  13. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  14. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  15. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  16. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  17. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  18. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  19. data/app/controllers/rails_nexus/stats_controller.rb +43 -43
  20. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  21. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  22. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  23. data/app/javascript/controllers/rails_nexus_controller.js +6 -2
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
  25. data/app/javascript/rails_nexus/application.js +15 -3
  26. data/app/jobs/rails_nexus/backup_job.rb +18 -0
  27. data/app/models/rails_nexus/backup.rb +11 -11
  28. data/app/models/rails_nexus/backup_config.rb +38 -2
  29. data/app/models/rails_nexus/logged_exception.rb +27 -13
  30. data/app/services/rails_nexus/backup_service.rb +342 -120
  31. data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
  32. data/app/views/layouts/rails_nexus/application.html.erb +72 -14
  33. data/app/views/rails_nexus/backup/_form.html.erb +3 -0
  34. data/app/views/rails_nexus/backup/history.html.erb +17 -2
  35. data/app/views/rails_nexus/backup/index.html.erb +29 -72
  36. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
  37. data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
  38. data/app/views/rails_nexus/stats/index.html.erb +1 -1
  39. data/db/seeds/backup_configs.rb +5 -4
  40. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  41. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  42. data/lib/generators/rails_nexus/templates/migration.rb +8 -2
  43. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  44. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  45. data/lib/rails_nexus/configuration.rb +10 -0
  46. data/lib/rails_nexus/database_adapter.rb +53 -0
  47. data/lib/rails_nexus/engine.rb +0 -13
  48. data/lib/rails_nexus/logger.rb +1 -12
  49. data/lib/rails_nexus/notifications.rb +27 -20
  50. data/lib/rails_nexus/version.rb +1 -1
  51. data/lib/rails_nexus/webhook_client.rb +151 -0
  52. data/lib/rails_nexus.rb +37 -23
  53. data/lib/tasks/rails_nexus.rake +4 -4
  54. data/rails_nexus.gemspec +3 -2
  55. metadata +9 -16
  56. data/config/importmap.rb +0 -15
@@ -3,6 +3,8 @@
3
3
  require "open3"
4
4
  require "json"
5
5
  require "fileutils"
6
+ require "shellwords"
7
+ require "tempfile"
6
8
 
7
9
  module RailsNexus
8
10
  class BackupService
@@ -20,28 +22,34 @@ module RailsNexus
20
22
 
21
23
  def run
22
24
  @record = RailsNexus::Backup.start!(
23
- model_name: @config.name,
25
+ config_name: @config.name,
24
26
  triggered_by: "service"
25
27
  )
26
28
 
27
29
  begin
28
- FileUtils.mkdir_p(@config.storage_path_expanded)
30
+ @stage = "preparing storage"
31
+ validate_identifier!(@config.name, "backup name")
32
+ FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
29
33
 
30
34
  # Step 1: Dump database
35
+ @stage = "database dump"
31
36
  dump_path = dump_database
32
37
  raise "Database dump failed" unless dump_path
33
38
 
34
39
  # Step 2: Create archive if enabled (tar additional files/dirs)
40
+ @stage = "archive creation"
35
41
  if @config.archive_enabled? && @config.archive_paths_list.any?
36
42
  dump_path = create_archive(dump_path)
37
43
  end
38
44
 
39
45
  # Step 3: Split into chunks if enabled
46
+ @stage = "file splitting"
40
47
  if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
41
48
  dump_path = split_file(dump_path)
42
49
  end
43
50
 
44
51
  # Step 4: Compress
52
+ @stage = "compression"
45
53
  if @config.compress? && !@config.split_chunks?
46
54
  dump_path = compress(dump_path)
47
55
  elsif @config.bzip2_compress? && !@config.split_chunks?
@@ -49,6 +57,7 @@ module RailsNexus
49
57
  end
50
58
 
51
59
  # Step 5: Encrypt
60
+ @stage = "encryption"
52
61
  if @config.encrypted?
53
62
  dump_path = encrypt_openssl(dump_path)
54
63
  elsif @config.gpg_enabled?
@@ -56,26 +65,32 @@ module RailsNexus
56
65
  end
57
66
 
58
67
  # Step 6: Sync to S3
68
+ @stage = "S3 sync"
59
69
  sync_s3(dump_path) if @config.s3_enabled?
60
70
 
61
71
  # Step 7: Sync to remote
62
- rsync_result = sync_remote(dump_path) if @config.rsync_enabled?
72
+ @stage = "remote sync"
73
+ sync_remote(dump_path) if @config.rsync_enabled?
63
74
 
64
75
  # Step 8: Cleanup old backups
76
+ @stage = "retention cleanup"
65
77
  cleanup_old_backups
66
78
 
67
79
  # Step 9: Record success
80
+ @stage = "recording success"
68
81
  file_size = calculate_size(dump_path)
69
82
  @record.succeed!(file_path: dump_path, file_size: file_size)
70
83
 
71
84
  # Step 10: Notify
85
+ @stage = "success notification"
72
86
  notify_success
73
87
 
74
88
  { success: true, record: @record, file_path: dump_path }
75
89
  rescue StandardError => e
76
- @record&.fail!(error_message: e.message)
77
- notify_failure(e.message)
78
- { success: false, error: e.message, record: @record }
90
+ full_error = failure_message(e)
91
+ @record&.fail!(error_message: full_error)
92
+ notify_failure(full_error)
93
+ { success: false, error: full_error, record: @record }
79
94
  ensure
80
95
  cleanup_temp_files
81
96
  end
@@ -100,11 +115,14 @@ module RailsNexus
100
115
 
101
116
  # ─── MySQL ─────────────────────────────────────────────────────
102
117
  def dump_mysql
118
+ validate_identifier!(@config.database_name, "database name")
119
+ validate_host!(@config.host) if @config.host.present?
120
+ validate_port!(@config.port) if @config.port.present?
103
121
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
122
+ password = resolve_password(@config.password)
104
123
  cmd = ["mysqldump"]
105
- cmd << "--user=#{@config.username}" if @config.username.present?
106
- cmd << "--password=#{@config.password}" if @config.password.present?
107
- cmd << "--host=#{@config.host}" if @config.host.present?
124
+ cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
125
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
108
126
  cmd << "--port=#{@config.port}" if @config.port.present?
109
127
  cmd << "--result-file=#{raw_path}"
110
128
  cmd << "--single-transaction"
@@ -112,16 +130,27 @@ module RailsNexus
112
130
  cmd << "--routines"
113
131
  cmd << "--triggers"
114
132
  cmd << "--events"
133
+ # Additional MySQL options (e.g. --defaults-extra-file)
134
+ additional_options = if @config.respond_to?(:mysql_additional_options)
135
+ @config.mysql_additional_options
136
+ end
137
+ if additional_options.present?
138
+ cmd.concat(validated_mysql_options(additional_options))
139
+ end
115
140
 
116
141
  # Skip tables
117
- @config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
142
+ @config.skip_tables_list.each do |table|
143
+ validate_identifier!(table, "table name")
144
+ cmd << "--ignore-table=#{@config.database_name}.#{table}"
145
+ end
118
146
 
119
147
  cmd << @config.database_name
120
148
 
121
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
149
+ env = {}
150
+ env["MYSQL_PWD"] = password if password.present?
151
+ stdout, stderr, status = capture_command(env, *cmd)
122
152
  unless status.success?
123
- @errors << "mysqldump failed: #{stderr}"
124
- return nil
153
+ raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
125
154
  end
126
155
  register_temp_file(raw_path)
127
156
  raw_path
@@ -129,10 +158,14 @@ module RailsNexus
129
158
 
130
159
  # ─── PostgreSQL ────────────────────────────────────────────────
131
160
  def dump_postgresql
161
+ validate_identifier!(@config.database_name, "database name")
162
+ validate_host!(@config.host) if @config.host.present?
163
+ validate_port!(@config.port) if @config.port.present?
132
164
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
165
+ password = resolve_password(@config.password)
133
166
  cmd = ["pg_dump"]
134
- cmd << "--username=#{@config.username}" if @config.username.present?
135
- cmd << "--host=#{@config.host}" if @config.host.present?
167
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
168
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
136
169
  cmd << "--port=#{@config.port}" if @config.port.present?
137
170
  cmd << "--file=#{raw_path}"
138
171
  cmd << "--format=plain"
@@ -140,36 +173,40 @@ module RailsNexus
140
173
  cmd << "--no-privileges"
141
174
 
142
175
  # Skip tables
143
- @config.skip_tables_list.each { |t| cmd << "--exclude-table=#{t}" }
176
+ @config.skip_tables_list.each do |table|
177
+ validate_identifier!(table, "table name")
178
+ cmd << "--exclude-table=#{table}"
179
+ end
144
180
 
145
181
  cmd << @config.database_name
146
182
 
147
183
  env = {}
148
- env["PGPASSWORD"] = @config.password if @config.password.present?
184
+ env["PGPASSWORD"] = password if password.present?
149
185
 
150
- stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
186
+ stdout, stderr, status = capture_command(env, *cmd)
151
187
  unless status.success?
152
- @errors << "pg_dump failed: #{stderr}"
153
- return nil
188
+ raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
154
189
  end
155
190
  register_temp_file(raw_path)
156
191
  raw_path
157
192
  end
158
193
 
194
+ def capture_command(*command, **options)
195
+ Open3.capture3(*command, **options)
196
+ end
197
+
159
198
  # ─── SQLite ────────────────────────────────────────────────────
160
199
  def dump_sqlite
161
200
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
162
- db_path = @config.database_name
201
+ db_path = validated_path(@config.database_name, "SQLite database path")
163
202
 
164
203
  unless File.exist?(db_path)
165
- @errors << "SQLite database not found: #{db_path}"
166
- return nil
204
+ raise "SQLite database not found: #{db_path}"
167
205
  end
168
206
 
169
- stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
207
+ stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
170
208
  unless status.success?
171
- @errors << "sqlite3 dump failed: #{stderr}"
172
- return nil
209
+ raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
173
210
  end
174
211
 
175
212
  File.write(raw_path, stdout)
@@ -179,35 +216,47 @@ module RailsNexus
179
216
 
180
217
  # ─── MongoDB ───────────────────────────────────────────────────
181
218
  def dump_mongodb
219
+ validate_identifier!(@config.database_name, "database name")
220
+ validate_host!(@config.host.presence || "localhost")
221
+ validate_port!(@config.port || 27_017)
182
222
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
183
223
  dir_path = raw_path.gsub(/\.sql$/, "")
184
224
  FileUtils.mkdir_p(dir_path)
185
225
 
226
+ password = resolve_password(@config.password)
186
227
  cmd = ["mongodump"]
187
- cmd << "--host=#{@config.host || 'localhost'}"
228
+ cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
188
229
  cmd << "--port=#{@config.port || 27017}"
189
230
  cmd << "--db=#{@config.database_name}"
190
231
  cmd << "--out=#{dir_path}"
191
- cmd << "--username=#{@config.username}" if @config.username.present?
192
- cmd << "--password=#{@config.password}" if @config.password.present?
232
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
193
233
  cmd << "--authenticationDatabase=admin" if @config.username.present?
194
234
 
195
235
  # Skip collections
196
- @config.skip_tables_list.each { |t| cmd << "--excludeCollection=#{t}" }
236
+ @config.skip_tables_list.each do |collection|
237
+ validate_identifier!(collection, "collection name")
238
+ cmd << "--excludeCollection=#{collection}"
239
+ end
197
240
 
198
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
241
+ stdout, stderr, status = if password.present?
242
+ with_secret_file("rails_nexus_mongo", "password: #{password.to_json}\n") do |config_path|
243
+ capture_command(*cmd, "--config=#{config_path}")
244
+ end
245
+ else
246
+ capture_command(*cmd)
247
+ end
199
248
  unless status.success?
200
- @errors << "mongodump failed: #{stderr}"
201
- FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
202
- return nil
249
+ FileUtils.rm_rf(dir_path)
250
+ raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
203
251
  end
204
252
 
205
253
  # Tar the dump directory
206
254
  tar_path = "#{dir_path}.tar"
207
- stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
255
+ stdout, stderr, status = capture_command(
256
+ "tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
257
+ )
208
258
  unless status.success?
209
- @errors << "tar failed: #{stderr}"
210
- return nil
259
+ raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
211
260
  end
212
261
 
213
262
  FileUtils.rm_rf(dir_path)
@@ -220,16 +269,14 @@ module RailsNexus
220
269
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
221
270
  raw_path = raw_path.gsub(/\.sql$/, ".rdb")
222
271
 
223
- host = @config.host || "localhost"
272
+ host = @config.host.presence || "localhost"
224
273
  port = @config.port || 6379
225
- password = @config.password
274
+ password = resolve_password(@config.password)
275
+ validate_host!(host)
276
+ validate_port!(port)
226
277
 
227
278
  # Trigger BGSAVE first
228
- cmd = "redis-cli -h #{host} -p #{port}"
229
- cmd += " -a #{password}" if password.present?
230
- cmd += " BGSAVE"
231
-
232
- stdout, stderr, status = Open3.capture3(cmd)
279
+ _, stderr, status = capture_redis(host, port, password, "BGSAVE")
233
280
  unless status.success?
234
281
  @errors << "redis BGSAVE failed: #{stderr}"
235
282
  return nil
@@ -240,8 +287,7 @@ module RailsNexus
240
287
 
241
288
  # Try to find the dump.rdb on the server or use redis-cli to copy
242
289
  # If local Redis, copy the dump.rdb directly
243
- redis_dir_cmd = "#{cmd.gsub(' BGSAVE', '')} CONFIG GET dir"
244
- dir_out, = Open3.capture3(redis_dir_cmd)
290
+ dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
245
291
  redis_dir = dir_out.split("\n").last || "/var/lib/redis"
246
292
 
247
293
  redis_rdb = File.join(redis_dir, "dump.rdb")
@@ -257,23 +303,20 @@ module RailsNexus
257
303
  end
258
304
 
259
305
  def dump_all_keys(host, port, password, output_path)
260
- cmd = "redis-cli -h #{host} -p #{port}"
261
- cmd += " -a #{password}" if password.present?
262
-
263
- keys_out, = Open3.capture3("#{cmd} KEYS '*'")
306
+ keys_out, = capture_redis(host, port, password, "KEYS", "*")
264
307
  keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
265
308
 
266
309
  File.open(output_path, "w") do |f|
267
310
  keys.each do |key|
268
- type_out, = Open3.capture3("#{cmd} TYPE \"#{key}\"")
311
+ type_out, = capture_redis(host, port, password, "TYPE", key)
269
312
  type = type_out.strip.split("\n").last
270
313
 
271
314
  val_out, = case type
272
- when "string" then Open3.capture3("#{cmd} GET \"#{key}\"")
273
- when "list" then Open3.capture3("#{cmd} LRANGE \"#{key}\" 0 -1")
274
- when "set" then Open3.capture3("#{cmd} SMEMBERS \"#{key}\"")
275
- when "hash" then Open3.capture3("#{cmd} HGETALL \"#{key}\"")
276
- else Open3.capture3("#{cmd} DUMP \"#{key}\"")
315
+ when "string" then capture_redis(host, port, password, "GET", key)
316
+ when "list" then capture_redis(host, port, password, "LRANGE", key, "0", "-1")
317
+ when "set" then capture_redis(host, port, password, "SMEMBERS", key)
318
+ when "hash" then capture_redis(host, port, password, "HGETALL", key)
319
+ else capture_redis(host, port, password, "DUMP", key)
277
320
  end
278
321
 
279
322
  f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
@@ -305,14 +348,19 @@ module RailsNexus
305
348
  @temp_files << dump_path
306
349
  end
307
350
 
308
- @config.archive_paths_list.each { |p| cmd << p }
351
+ @config.archive_paths_list.each do |path|
352
+ cmd << validated_path(path, "archive path")
353
+ end
309
354
 
310
355
  # Exclude patterns
311
356
  if @config.archive_excludes_list.any?
312
- @config.archive_excludes_list.each { |e| cmd << "--exclude=#{e}" }
357
+ @config.archive_excludes_list.each do |pattern|
358
+ validate_text_argument!(pattern, "archive exclusion")
359
+ cmd << "--exclude=#{pattern}"
360
+ end
313
361
  end
314
362
 
315
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
363
+ _, stderr, status = capture_command(*cmd)
316
364
  unless status.success?
317
365
  @errors << "archive tar failed: #{stderr}"
318
366
  return dump_path
@@ -338,7 +386,7 @@ module RailsNexus
338
386
 
339
387
  def compress_bzip2(file_path)
340
388
  bz2_path = "#{file_path}.bz2"
341
- stdout, stderr, status = Open3.capture3("bzip2 -zk \"#{file_path}\"")
389
+ _, stderr, status = capture_command("bzip2", "-zk", file_path)
342
390
  unless status.success?
343
391
  @errors << "bzip2 failed: #{stderr}"
344
392
  return file_path
@@ -353,13 +401,18 @@ module RailsNexus
353
401
 
354
402
  def encrypt_openssl(file_path)
355
403
  enc_path = "#{file_path}.enc"
356
- password = @config.encryption_password
404
+ password = resolve_secret(@config.encryption_password)
357
405
 
358
- cmd = "openssl aes-256-cbc -salt -pbkdf2 -iter 100000 " \
359
- "-in \"#{file_path}\" -out \"#{enc_path}\" " \
360
- "-pass pass:#{password}"
406
+ # Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
407
+ # (AES-GCM not available in all OpenSSL builds)
408
+ env = { "RAILS_NEXUS_OPENSSL_PASSWORD" => password.to_s }
409
+ cmd = [
410
+ "openssl", "enc", "-aes-256-cbc", "-pbkdf2", "-iter", "600000",
411
+ "-salt", "-in", file_path, "-out", enc_path,
412
+ "-pass", "env:RAILS_NEXUS_OPENSSL_PASSWORD"
413
+ ]
361
414
 
362
- stdout, stderr, status = Open3.capture3(cmd)
415
+ _, stderr, status = capture_command(env, *cmd)
363
416
  unless status.success?
364
417
  @errors << "OpenSSL encryption failed: #{stderr}"
365
418
  return file_path
@@ -371,14 +424,18 @@ module RailsNexus
371
424
 
372
425
  def encrypt_gpg(file_path)
373
426
  enc_path = "#{file_path}.gpg"
374
- password = @config.gpg_password
375
-
376
- cmd = "echo #{password} | gpg --batch --yes --symmetric " \
377
- "--cipher-algo AES256 " \
378
- "--passphrase-fd 0 " \
379
- "-o \"#{enc_path}\" \"#{file_path}\""
380
-
381
- stdout, stderr, status = Open3.capture3(cmd)
427
+ password = resolve_secret(@config.gpg_password)
428
+
429
+ # Best practice: AES256 with an iteration-counted S2K. The passphrase is
430
+ # provided through a mode-0600 temporary file, never process arguments.
431
+ _, stderr, status = with_secret_file("rails_nexus_gpg", password.to_s) do |password_path|
432
+ capture_command(
433
+ "gpg", "--batch", "--yes", "--symmetric",
434
+ "--cipher-algo", "AES256", "--s2k-mode", "3",
435
+ "--s2k-count", "65011712", "--passphrase-file", password_path,
436
+ "-o", enc_path, file_path
437
+ )
438
+ end
382
439
  unless status.success?
383
440
  @errors << "GPG encryption failed: #{stderr}"
384
441
  return file_path
@@ -397,8 +454,7 @@ module RailsNexus
397
454
  FileUtils.mkdir_p(chunk_dir)
398
455
 
399
456
  # Split 50MB chunks
400
- cmd = "split -b 50m \"#{file_path}\" \"#{chunk_dir}/part_\""
401
- stdout, stderr, status = Open3.capture3(cmd)
457
+ _, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
402
458
 
403
459
  unless status.success?
404
460
  @errors << "split failed: #{stderr}"
@@ -417,18 +473,44 @@ module RailsNexus
417
473
  return unless @config.rsync_enabled?
418
474
  return unless @config.rsync_host.present?
419
475
 
476
+ validate_host!(@config.rsync_host)
477
+ validate_port!(@config.rsync_port.presence || 22)
478
+ validate_remote_component!(@config.rsync_user, "rsync user")
479
+ validate_remote_path!(@config.rsync_path)
420
480
  remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
421
481
 
422
- cmd = [
423
- "rsync",
424
- "-avz",
425
- "--progress",
426
- "-e", "ssh -p #{@config.rsync_port}",
427
- "\"#{file_path}\"",
428
- remote
429
- ].join(" ")
430
-
431
- stdout, stderr, status = Open3.capture3(cmd)
482
+ # Build rsync command
483
+ cmd = ["rsync"]
484
+ cmd << "-a"
485
+ cmd << "-z" # compress
486
+ cmd << "--progress"
487
+ cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
488
+ cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
489
+ cmd << "-e"
490
+ cmd << "ssh -p #{@config.rsync_port.presence || 22}"
491
+
492
+ # Add the dump file
493
+ cmd << file_path
494
+
495
+ # Add additional directories if configured
496
+ if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
497
+ @config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
498
+ expanded = dir.gsub("~", Dir.home)
499
+ cmd << expanded
500
+ end
501
+ end
502
+
503
+ # Add excludes
504
+ if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
505
+ @config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
506
+ validate_text_argument!(excl, "rsync exclusion")
507
+ cmd << "--exclude=#{excl}"
508
+ end
509
+ end
510
+
511
+ cmd << remote
512
+
513
+ stdout, stderr, status = capture_command(*cmd)
432
514
  unless status.success?
433
515
  @errors << "RSync failed: #{stderr}"
434
516
  end
@@ -451,16 +533,20 @@ module RailsNexus
451
533
  end
452
534
 
453
535
  def sync_s3_aws(file_path)
536
+ validate_s3_bucket!(@config.s3_bucket)
454
537
  bucket = @config.s3_bucket
455
538
  prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
456
539
 
457
- cmd = "AWS_ACCESS_KEY_ID=#{@config.s3_access_key} " \
458
- "AWS_SECRET_ACCESS_KEY=#{@config.s3_secret_key} " \
459
- "aws s3 cp \"#{file_path}\" " \
460
- "s3://#{bucket}/#{prefix}" \
461
- "--region #{@config.s3_region || 'us-east-1'}"
540
+ env = {
541
+ "AWS_ACCESS_KEY_ID" => @config.s3_access_key.to_s,
542
+ "AWS_SECRET_ACCESS_KEY" => @config.s3_secret_key.to_s
543
+ }
544
+ cmd = [
545
+ "aws", "s3", "cp", file_path, "s3://#{bucket}/#{prefix}",
546
+ "--region", @config.s3_region.presence || "us-east-1"
547
+ ]
462
548
 
463
- stdout, stderr, status = Open3.capture3(cmd)
549
+ stdout, stderr, status = capture_command(env, *cmd)
464
550
  unless status.success?
465
551
  @errors << "S3 upload failed: #{stderr}"
466
552
  end
@@ -469,16 +555,20 @@ module RailsNexus
469
555
  end
470
556
 
471
557
  def sync_s3_s3cmd(file_path)
558
+ validate_s3_bucket!(@config.s3_bucket)
472
559
  bucket = @config.s3_bucket
473
560
  prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
474
561
 
475
- cmd = "s3cmd put \"#{file_path}\" " \
476
- "s3://#{bucket}/#{prefix} " \
477
- "--access_key=#{@config.s3_access_key} " \
478
- "--secret_key=#{@config.s3_secret_key} " \
479
- "--region=#{@config.s3_region || 'us-east-1'}"
562
+ env = {
563
+ "AWS_ACCESS_KEY" => @config.s3_access_key.to_s,
564
+ "AWS_SECRET_KEY" => @config.s3_secret_key.to_s
565
+ }
566
+ cmd = [
567
+ "s3cmd", "put", file_path, "s3://#{bucket}/#{prefix}",
568
+ "--region=#{@config.s3_region.presence || 'us-east-1'}"
569
+ ]
480
570
 
481
- stdout, stderr, status = Open3.capture3(cmd)
571
+ stdout, stderr, status = capture_command(env, *cmd)
482
572
  unless status.success?
483
573
  @errors << "S3 upload (s3cmd) failed: #{stderr}"
484
574
  end
@@ -499,22 +589,12 @@ module RailsNexus
499
589
 
500
590
  while files.size > @config.keep_count
501
591
  old_file = files.shift
502
- if File.directory?(old_file)
503
- FileUtils.rm_rf(old_file)
504
- else
505
- File.delete(old_file) if File.exist?(old_file)
506
- end
592
+ FileUtils.rm_rf(old_file)
507
593
  end
508
594
  end
509
595
 
510
596
  def cleanup_temp_files
511
- @temp_files.each do |f|
512
- if File.directory?(f)
513
- FileUtils.rm_rf(f)
514
- else
515
- File.delete(f) if File.exist?(f)
516
- end
517
- end
597
+ @temp_files.each { |file| FileUtils.rm_rf(file) }
518
598
  end
519
599
 
520
600
  # ════════════════════════════════════════════════════════════════
@@ -536,13 +616,23 @@ module RailsNexus
536
616
  def execute_notify_command(status, error = nil)
537
617
  return if @config.notify_command.blank?
538
618
 
539
- cmd = @config.notify_command
540
- .gsub("{STATUS}", status.to_s)
541
- .gsub("{MODEL}", @config.name)
542
- .gsub("{ERROR}", error.to_s)
543
- .gsub("{TIME}", Time.current.iso8601)
619
+ command = Shellwords.split(@config.notify_command.to_s)
620
+ raise ArgumentError, "notify command is empty" if command.empty?
621
+ if %w[sh bash dash zsh ksh fish env].include?(File.basename(command.first))
622
+ raise ArgumentError, "shell-based notify commands are not supported"
623
+ end
544
624
 
545
- Open3.capture3(cmd)
625
+ replacements = {
626
+ "{STATUS}" => status.to_s,
627
+ "{MODEL}" => @config.name.to_s,
628
+ "{ERROR}" => error.to_s,
629
+ "{TIME}" => Time.current.iso8601
630
+ }
631
+ command.map! do |argument|
632
+ replacements.reduce(argument) { |value, (placeholder, replacement)| value.gsub(placeholder, replacement) }
633
+ end
634
+
635
+ capture_command(*command)
546
636
  rescue StandardError
547
637
  # Don't fail backup because notification failed
548
638
  end
@@ -559,12 +649,10 @@ module RailsNexus
559
649
 
560
650
  # Build mail command based on available MTA
561
651
  if command_exists?("mail")
562
- cmd = "echo #{body.shellescape} | mail -s #{subject.shellescape} #{@config.email_to}"
563
- Open3.capture3(cmd)
652
+ capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
564
653
  elsif command_exists?("sendmail")
565
654
  mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
566
- cmd = "echo #{mail_content.shellescape} | sendmail -t"
567
- Open3.capture3(cmd)
655
+ capture_command("sendmail", "-t", stdin_data: mail_content)
568
656
  else
569
657
  @errors << "No mail command found (mail/sendmail)"
570
658
  end
@@ -588,8 +676,142 @@ module RailsNexus
588
676
  @temp_files << path unless @temp_files.include?(path)
589
677
  end
590
678
 
679
+ # ─── Password Resolution ──────────────────────────────────────
680
+ # Supports:
681
+ # "plain_text" → used as-is
682
+ # "ENV[MY_VAR]" → resolved from ENV
683
+ # "${MY_VAR}" → resolved from ENV
684
+ # "credentials[:key]" → from Rails encrypted credentials
685
+ # "credentials[:a][:b]" → nested credentials key
686
+ def resolve_password(value)
687
+ return nil if value.blank?
688
+ resolve_secret(value)
689
+ end
690
+
691
+ alias_method :resolve_env, :resolve_password
692
+
693
+ def resolve_secret(value)
694
+ return nil if value.blank?
695
+ case value
696
+ when /^ENV\[(.+?)\]$/, /^\$\{(.+?)\}$/
697
+ ENV[$1]
698
+ when /^credentials\[/
699
+ # credentials[:key] or credentials[:key][:subkey]
700
+ keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
701
+ Rails.application.credentials.dig(*keys)
702
+ else
703
+ value
704
+ end
705
+ end
706
+
591
707
  def command_exists?(cmd)
592
- system("which #{cmd} > /dev/null 2>&1")
708
+ executable = cmd.to_s
709
+ return false unless executable.match?(/\A[a-zA-Z0-9_.+-]+\z/)
710
+
711
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
712
+ path = File.join(directory, executable)
713
+ File.file?(path) && File.executable?(path)
714
+ end
715
+ end
716
+
717
+ def capture_redis(host, port, password, *arguments)
718
+ env = {}
719
+ env["REDISCLI_AUTH"] = password if password.present?
720
+ capture_command(env, "redis-cli", "-h", host.to_s, "-p", port.to_s, *arguments.map(&:to_s))
721
+ end
722
+
723
+ def with_secret_file(prefix, contents)
724
+ Tempfile.create([prefix, ".conf"]) do |file|
725
+ file.chmod(0o600)
726
+ file.write(contents)
727
+ file.flush
728
+ yield file.path
729
+ end
730
+ end
731
+
732
+ def validated_mysql_options(value)
733
+ allowed = %r{\A(?:
734
+ --ssl-mode=(?:DISABLED|PREFERRED|REQUIRED|VERIFY_CA|VERIFY_IDENTITY)|
735
+ --ssl-(?:ca|cert|key)=\S+|
736
+ --protocol=(?:TCP|SOCKET)|
737
+ --socket=\S+|
738
+ --default-character-set=[a-zA-Z0-9_-]+|
739
+ --max-allowed-packet=\d+[KMG]?|
740
+ --set-gtid-purged=(?:OFF|ON|AUTO)|
741
+ --column-statistics=[01]|
742
+ --no-tablespaces|
743
+ --skip-lock-tables
744
+ )\z}x
745
+
746
+ Shellwords.split(value.to_s).tap do |options|
747
+ invalid = options.reject { |option| option.match?(allowed) }
748
+ raise ArgumentError, "unsupported MySQL option: #{invalid.first}" if invalid.any?
749
+ end
750
+ rescue ArgumentError => error
751
+ raise ArgumentError, "invalid MySQL additional options: #{error.message}"
752
+ end
753
+
754
+ def validate_identifier!(value, label)
755
+ candidate = value.to_s
756
+ unless candidate.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !candidate.include?("..")
757
+ raise ArgumentError, "invalid #{label}"
758
+ end
759
+ candidate
760
+ end
761
+
762
+ def validate_port!(value)
763
+ port = Integer(value)
764
+ raise ArgumentError, "invalid port" unless port.between?(1, 65_535)
765
+ port
766
+ rescue ArgumentError, TypeError
767
+ raise ArgumentError, "invalid port"
768
+ end
769
+
770
+ def validate_host!(value)
771
+ host = value.to_s
772
+ unless host.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
773
+ raise ArgumentError, "invalid host"
774
+ end
775
+ host
776
+ end
777
+
778
+ def validated_path(value, label)
779
+ validate_text_argument!(value, label)
780
+ File.expand_path(value.to_s)
781
+ end
782
+
783
+ def validate_remote_component!(value, label)
784
+ candidate = value.to_s
785
+ raise ArgumentError, "invalid #{label}" unless candidate.match?(/\A[a-zA-Z0-9_.-]+\z/)
786
+ candidate
787
+ end
788
+
789
+ def validate_remote_path!(value)
790
+ path = value.to_s
791
+ raise ArgumentError, "invalid rsync path" unless path.start_with?("/")
792
+ validate_text_argument!(path, "rsync path")
793
+ end
794
+
795
+ def validate_s3_bucket!(value)
796
+ bucket = value.to_s
797
+ unless bucket.length.between?(3, 63) && bucket.match?(/\A[a-z0-9][a-z0-9.-]*[a-z0-9]\z/) && !bucket.include?("..")
798
+ raise ArgumentError, "invalid S3 bucket"
799
+ end
800
+ bucket
801
+ end
802
+
803
+ def validate_text_argument!(value, label)
804
+ candidate = value.to_s
805
+ if candidate.empty? || candidate.include?("\0") || candidate.include?("\n") || candidate.include?("\r")
806
+ raise ArgumentError, "invalid #{label}"
807
+ end
808
+ candidate
809
+ end
810
+
811
+ def failure_message(exception)
812
+ heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
813
+ trace = Array(exception.backtrace).first(5).join("\n")
814
+ [heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
593
815
  end
594
816
  end
595
817
  end