rails_nexus 2.1.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +33 -6
- data/SECURITY.md +11 -0
- data/THIRD_PARTY_NOTICES.md +14 -0
- data/app/assets/config/rails_nexus_manifest.js +1 -1
- data/app/assets/javascripts/rails_nexus/application.js +55 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
- data/app/controllers/rails_nexus/application_controller.rb +6 -2
- data/app/controllers/rails_nexus/backup_controller.rb +1 -2
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
- data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
- data/app/controllers/rails_nexus/settings_controller.rb +1 -39
- data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
- data/app/controllers/rails_nexus/stats_controller.rb +43 -43
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
- data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/models/rails_nexus/backup_config.rb +31 -0
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/services/rails_nexus/backup_service.rb +233 -98
- data/app/views/layouts/rails_nexus/application.html.erb +1 -1
- data/app/views/rails_nexus/stats/index.html.erb +1 -1
- data/lib/generators/rails_nexus/install_generator.rb +1 -1
- data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
- data/lib/rails_nexus/configuration.rb +10 -0
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +0 -13
- data/lib/rails_nexus/logger.rb +1 -12
- data/lib/rails_nexus/notifications.rb +27 -20
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/rails_nexus/webhook_client.rb +151 -0
- data/lib/rails_nexus.rb +37 -23
- data/rails_nexus.gemspec +3 -2
- metadata +8 -16
- data/config/importmap.rb +0 -15
|
@@ -24,14 +24,14 @@ module RailsNexus
|
|
|
24
24
|
scope :unassigned, -> { where(assigned_to: [nil, ""]) }
|
|
25
25
|
scope :active, -> { not_muted.not_snoozed }
|
|
26
26
|
|
|
27
|
-
def self.ransackable_attributes(
|
|
27
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
28
28
|
%w[action_name backtrace cause_chain controller_name created_at device_type environment
|
|
29
29
|
assigned_to comments_count exception_class fingerprint id instance_variables local_variables
|
|
30
30
|
message muted occurrence_count platform platform_version priority remote_ip request
|
|
31
31
|
snoozed_until system_health updated_at user_agent user_id user_info]
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def self.ransackable_associations(
|
|
34
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
35
35
|
[]
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -299,8 +299,8 @@ module RailsNexus
|
|
|
299
299
|
|
|
300
300
|
# Look for ActiveRecord collection iteration patterns:
|
|
301
301
|
# e.g., .each, .map, .find_each near ActiveRecord calls
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
|
|
303
|
+
app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
|
|
304
304
|
|
|
305
305
|
# Also detect: repeated similar backtrace across multiple exceptions of same class
|
|
306
306
|
fingerprint = backtrace_fingerprint(app_lines)
|
|
@@ -435,9 +435,11 @@ module RailsNexus
|
|
|
435
435
|
end
|
|
436
436
|
|
|
437
437
|
begin
|
|
438
|
+
stdout, _stderr, status = Open3.capture3("ps", "-o", "rss=,vsz=", "-p", Process.pid.to_s)
|
|
439
|
+
memory = status.success? ? stdout.split.map(&:to_i) : []
|
|
438
440
|
health[:memory] = {
|
|
439
|
-
rss_kb:
|
|
440
|
-
vsz_kb:
|
|
441
|
+
rss_kb: memory[0] || 0,
|
|
442
|
+
vsz_kb: memory[1] || 0
|
|
441
443
|
}
|
|
442
444
|
rescue StandardError
|
|
443
445
|
health[:memory] = { error: "unable to capture" }
|
|
@@ -495,17 +497,28 @@ module RailsNexus
|
|
|
495
497
|
if request.is_a?(String)
|
|
496
498
|
write_attribute :request, request
|
|
497
499
|
elsif request.respond_to?(:env) && request.env.is_a?(Hash)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
500
|
+
filtered_env = RailsNexus.filter_sensitive_data(request.env)
|
|
501
|
+
keys = filtered_env.keys.map(&:to_s).sort
|
|
502
|
+
max_length = keys.map(&:length).max || 0
|
|
503
|
+
env = keys.each_with_object([]) do |key, memo|
|
|
504
|
+
value = filtered_env[key] || filtered_env[key.to_sym]
|
|
505
|
+
memo << ("* %-*s: %s" % [max_length, key, value.to_s.strip])
|
|
501
506
|
end
|
|
502
507
|
write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
|
|
503
508
|
|
|
504
509
|
method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
|
|
510
|
+
parameters = request.respond_to?(:parameters) ? RailsNexus.filter_sensitive_data(request.parameters) : {}
|
|
511
|
+
request_path = if request.respond_to?(:path)
|
|
512
|
+
request.path
|
|
513
|
+
elsif request.respond_to?(:fullpath)
|
|
514
|
+
request.fullpath.to_s.split("?", 2).first
|
|
515
|
+
else
|
|
516
|
+
"/"
|
|
517
|
+
end
|
|
505
518
|
write_attribute(:request, [
|
|
506
|
-
"* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{
|
|
519
|
+
"* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{filtered_env["HTTP_HOST"] || "localhost"}#{request_path}",
|
|
507
520
|
"* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
|
|
508
|
-
"* Parameters: #{
|
|
521
|
+
"* Parameters: #{parameters.inspect}",
|
|
509
522
|
"* Rails Root: #{rails_root}"
|
|
510
523
|
].join("\n"))
|
|
511
524
|
else
|
|
@@ -545,10 +558,11 @@ module RailsNexus
|
|
|
545
558
|
|
|
546
559
|
# Build hourly time series of error counts
|
|
547
560
|
def self.build_time_series(days: 30)
|
|
561
|
+
days = (Integer(days, exception: false) || 30).clamp(1, 90)
|
|
548
562
|
start_time = days.days.ago.beginning_of_hour
|
|
563
|
+
bucket = RailsNexus::DatabaseAdapter.time_bucket_expression
|
|
549
564
|
raw = where("created_at >= ?", start_time)
|
|
550
|
-
.group(Arel.sql(
|
|
551
|
-
.order(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
|
|
565
|
+
.group(Arel.sql(bucket))
|
|
552
566
|
.count
|
|
553
567
|
|
|
554
568
|
# Fill gaps with zeros
|
|
@@ -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
|
|
@@ -26,7 +28,8 @@ module RailsNexus
|
|
|
26
28
|
|
|
27
29
|
begin
|
|
28
30
|
@stage = "preparing storage"
|
|
29
|
-
|
|
31
|
+
validate_identifier!(@config.name, "backup name")
|
|
32
|
+
FileUtils.mkdir_p(validated_path(@config.storage_path_expanded, "storage path"))
|
|
30
33
|
|
|
31
34
|
# Step 1: Dump database
|
|
32
35
|
@stage = "database dump"
|
|
@@ -112,11 +115,13 @@ module RailsNexus
|
|
|
112
115
|
|
|
113
116
|
# ─── MySQL ─────────────────────────────────────────────────────
|
|
114
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?
|
|
115
121
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
116
122
|
password = resolve_password(@config.password)
|
|
117
123
|
cmd = ["mysqldump"]
|
|
118
124
|
cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
|
|
119
|
-
cmd << "--password=#{password}" if password.present?
|
|
120
125
|
cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
|
|
121
126
|
cmd << "--port=#{@config.port}" if @config.port.present?
|
|
122
127
|
cmd << "--result-file=#{raw_path}"
|
|
@@ -130,15 +135,20 @@ module RailsNexus
|
|
|
130
135
|
@config.mysql_additional_options
|
|
131
136
|
end
|
|
132
137
|
if additional_options.present?
|
|
133
|
-
|
|
138
|
+
cmd.concat(validated_mysql_options(additional_options))
|
|
134
139
|
end
|
|
135
140
|
|
|
136
141
|
# Skip tables
|
|
137
|
-
@config.skip_tables_list.each
|
|
142
|
+
@config.skip_tables_list.each do |table|
|
|
143
|
+
validate_identifier!(table, "table name")
|
|
144
|
+
cmd << "--ignore-table=#{@config.database_name}.#{table}"
|
|
145
|
+
end
|
|
138
146
|
|
|
139
147
|
cmd << @config.database_name
|
|
140
148
|
|
|
141
|
-
|
|
149
|
+
env = {}
|
|
150
|
+
env["MYSQL_PWD"] = password if password.present?
|
|
151
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
142
152
|
unless status.success?
|
|
143
153
|
raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
144
154
|
end
|
|
@@ -148,6 +158,9 @@ module RailsNexus
|
|
|
148
158
|
|
|
149
159
|
# ─── PostgreSQL ────────────────────────────────────────────────
|
|
150
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?
|
|
151
164
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
152
165
|
password = resolve_password(@config.password)
|
|
153
166
|
cmd = ["pg_dump"]
|
|
@@ -160,14 +173,17 @@ module RailsNexus
|
|
|
160
173
|
cmd << "--no-privileges"
|
|
161
174
|
|
|
162
175
|
# Skip tables
|
|
163
|
-
@config.skip_tables_list.each
|
|
176
|
+
@config.skip_tables_list.each do |table|
|
|
177
|
+
validate_identifier!(table, "table name")
|
|
178
|
+
cmd << "--exclude-table=#{table}"
|
|
179
|
+
end
|
|
164
180
|
|
|
165
181
|
cmd << @config.database_name
|
|
166
182
|
|
|
167
183
|
env = {}
|
|
168
184
|
env["PGPASSWORD"] = password if password.present?
|
|
169
185
|
|
|
170
|
-
stdout, stderr, status =
|
|
186
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
171
187
|
unless status.success?
|
|
172
188
|
raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
173
189
|
end
|
|
@@ -175,20 +191,20 @@ module RailsNexus
|
|
|
175
191
|
raw_path
|
|
176
192
|
end
|
|
177
193
|
|
|
178
|
-
def capture_command(*command)
|
|
179
|
-
Open3.capture3(*command)
|
|
194
|
+
def capture_command(*command, **options)
|
|
195
|
+
Open3.capture3(*command, **options)
|
|
180
196
|
end
|
|
181
197
|
|
|
182
198
|
# ─── SQLite ────────────────────────────────────────────────────
|
|
183
199
|
def dump_sqlite
|
|
184
200
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
185
|
-
db_path = @config.database_name
|
|
201
|
+
db_path = validated_path(@config.database_name, "SQLite database path")
|
|
186
202
|
|
|
187
203
|
unless File.exist?(db_path)
|
|
188
204
|
raise "SQLite database not found: #{db_path}"
|
|
189
205
|
end
|
|
190
206
|
|
|
191
|
-
stdout, stderr, status =
|
|
207
|
+
stdout, stderr, status = capture_command("sqlite3", db_path, ".dump")
|
|
192
208
|
unless status.success?
|
|
193
209
|
raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
194
210
|
end
|
|
@@ -200,6 +216,9 @@ module RailsNexus
|
|
|
200
216
|
|
|
201
217
|
# ─── MongoDB ───────────────────────────────────────────────────
|
|
202
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)
|
|
203
222
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
204
223
|
dir_path = raw_path.gsub(/\.sql$/, "")
|
|
205
224
|
FileUtils.mkdir_p(dir_path)
|
|
@@ -211,21 +230,31 @@ module RailsNexus
|
|
|
211
230
|
cmd << "--db=#{@config.database_name}"
|
|
212
231
|
cmd << "--out=#{dir_path}"
|
|
213
232
|
cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
|
|
214
|
-
cmd << "--password=#{password}" if password.present?
|
|
215
233
|
cmd << "--authenticationDatabase=admin" if @config.username.present?
|
|
216
234
|
|
|
217
235
|
# Skip collections
|
|
218
|
-
@config.skip_tables_list.each
|
|
236
|
+
@config.skip_tables_list.each do |collection|
|
|
237
|
+
validate_identifier!(collection, "collection name")
|
|
238
|
+
cmd << "--excludeCollection=#{collection}"
|
|
239
|
+
end
|
|
219
240
|
|
|
220
|
-
stdout, stderr, status =
|
|
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
|
|
221
248
|
unless status.success?
|
|
222
|
-
FileUtils.rm_rf(dir_path)
|
|
249
|
+
FileUtils.rm_rf(dir_path)
|
|
223
250
|
raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
224
251
|
end
|
|
225
252
|
|
|
226
253
|
# Tar the dump directory
|
|
227
254
|
tar_path = "#{dir_path}.tar"
|
|
228
|
-
stdout, stderr, status =
|
|
255
|
+
stdout, stderr, status = capture_command(
|
|
256
|
+
"tar", "-cf", tar_path, "-C", File.dirname(dir_path), File.basename(dir_path)
|
|
257
|
+
)
|
|
229
258
|
unless status.success?
|
|
230
259
|
raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
|
|
231
260
|
end
|
|
@@ -240,16 +269,14 @@ module RailsNexus
|
|
|
240
269
|
raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
|
|
241
270
|
raw_path = raw_path.gsub(/\.sql$/, ".rdb")
|
|
242
271
|
|
|
243
|
-
host = @config.host || "localhost"
|
|
272
|
+
host = @config.host.presence || "localhost"
|
|
244
273
|
port = @config.port || 6379
|
|
245
274
|
password = resolve_password(@config.password)
|
|
275
|
+
validate_host!(host)
|
|
276
|
+
validate_port!(port)
|
|
246
277
|
|
|
247
278
|
# Trigger BGSAVE first
|
|
248
|
-
|
|
249
|
-
cmd += " -a #{password}" if password.present?
|
|
250
|
-
cmd += " BGSAVE"
|
|
251
|
-
|
|
252
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
279
|
+
_, stderr, status = capture_redis(host, port, password, "BGSAVE")
|
|
253
280
|
unless status.success?
|
|
254
281
|
@errors << "redis BGSAVE failed: #{stderr}"
|
|
255
282
|
return nil
|
|
@@ -260,8 +287,7 @@ module RailsNexus
|
|
|
260
287
|
|
|
261
288
|
# Try to find the dump.rdb on the server or use redis-cli to copy
|
|
262
289
|
# If local Redis, copy the dump.rdb directly
|
|
263
|
-
|
|
264
|
-
dir_out, = Open3.capture3(redis_dir_cmd)
|
|
290
|
+
dir_out, = capture_redis(host, port, password, "CONFIG", "GET", "dir")
|
|
265
291
|
redis_dir = dir_out.split("\n").last || "/var/lib/redis"
|
|
266
292
|
|
|
267
293
|
redis_rdb = File.join(redis_dir, "dump.rdb")
|
|
@@ -277,23 +303,20 @@ module RailsNexus
|
|
|
277
303
|
end
|
|
278
304
|
|
|
279
305
|
def dump_all_keys(host, port, password, output_path)
|
|
280
|
-
|
|
281
|
-
cmd += " -a #{password}" if password.present?
|
|
282
|
-
|
|
283
|
-
keys_out, = Open3.capture3("#{cmd} KEYS '*'")
|
|
306
|
+
keys_out, = capture_redis(host, port, password, "KEYS", "*")
|
|
284
307
|
keys = keys_out.split("\n").reject { |k| k.start_with?("redis") }
|
|
285
308
|
|
|
286
309
|
File.open(output_path, "w") do |f|
|
|
287
310
|
keys.each do |key|
|
|
288
|
-
type_out, =
|
|
311
|
+
type_out, = capture_redis(host, port, password, "TYPE", key)
|
|
289
312
|
type = type_out.strip.split("\n").last
|
|
290
313
|
|
|
291
314
|
val_out, = case type
|
|
292
|
-
when "string" then
|
|
293
|
-
when "list" then
|
|
294
|
-
when "set" then
|
|
295
|
-
when "hash" then
|
|
296
|
-
else
|
|
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)
|
|
297
320
|
end
|
|
298
321
|
|
|
299
322
|
f.puts("SET #{key.inspect} #{val_out.strip.inspect}")
|
|
@@ -325,14 +348,19 @@ module RailsNexus
|
|
|
325
348
|
@temp_files << dump_path
|
|
326
349
|
end
|
|
327
350
|
|
|
328
|
-
@config.archive_paths_list.each
|
|
351
|
+
@config.archive_paths_list.each do |path|
|
|
352
|
+
cmd << validated_path(path, "archive path")
|
|
353
|
+
end
|
|
329
354
|
|
|
330
355
|
# Exclude patterns
|
|
331
356
|
if @config.archive_excludes_list.any?
|
|
332
|
-
@config.archive_excludes_list.each
|
|
357
|
+
@config.archive_excludes_list.each do |pattern|
|
|
358
|
+
validate_text_argument!(pattern, "archive exclusion")
|
|
359
|
+
cmd << "--exclude=#{pattern}"
|
|
360
|
+
end
|
|
333
361
|
end
|
|
334
362
|
|
|
335
|
-
|
|
363
|
+
_, stderr, status = capture_command(*cmd)
|
|
336
364
|
unless status.success?
|
|
337
365
|
@errors << "archive tar failed: #{stderr}"
|
|
338
366
|
return dump_path
|
|
@@ -358,7 +386,7 @@ module RailsNexus
|
|
|
358
386
|
|
|
359
387
|
def compress_bzip2(file_path)
|
|
360
388
|
bz2_path = "#{file_path}.bz2"
|
|
361
|
-
|
|
389
|
+
_, stderr, status = capture_command("bzip2", "-zk", file_path)
|
|
362
390
|
unless status.success?
|
|
363
391
|
@errors << "bzip2 failed: #{stderr}"
|
|
364
392
|
return file_path
|
|
@@ -377,14 +405,14 @@ module RailsNexus
|
|
|
377
405
|
|
|
378
406
|
# Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
|
|
379
407
|
# (AES-GCM not available in all OpenSSL builds)
|
|
380
|
-
|
|
381
|
-
cmd
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
]
|
|
414
|
+
|
|
415
|
+
_, stderr, status = capture_command(env, *cmd)
|
|
388
416
|
unless status.success?
|
|
389
417
|
@errors << "OpenSSL encryption failed: #{stderr}"
|
|
390
418
|
return file_path
|
|
@@ -398,15 +426,16 @@ module RailsNexus
|
|
|
398
426
|
enc_path = "#{file_path}.gpg"
|
|
399
427
|
password = resolve_secret(@config.gpg_password)
|
|
400
428
|
|
|
401
|
-
# Best practice: AES256 with
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
410
439
|
unless status.success?
|
|
411
440
|
@errors << "GPG encryption failed: #{stderr}"
|
|
412
441
|
return file_path
|
|
@@ -425,8 +454,7 @@ module RailsNexus
|
|
|
425
454
|
FileUtils.mkdir_p(chunk_dir)
|
|
426
455
|
|
|
427
456
|
# Split 50MB chunks
|
|
428
|
-
|
|
429
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
457
|
+
_, stderr, status = capture_command("split", "-b", "50m", file_path, File.join(chunk_dir, "part_"))
|
|
430
458
|
|
|
431
459
|
unless status.success?
|
|
432
460
|
@errors << "split failed: #{stderr}"
|
|
@@ -445,6 +473,10 @@ module RailsNexus
|
|
|
445
473
|
return unless @config.rsync_enabled?
|
|
446
474
|
return unless @config.rsync_host.present?
|
|
447
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)
|
|
448
480
|
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
449
481
|
|
|
450
482
|
# Build rsync command
|
|
@@ -471,6 +503,7 @@ module RailsNexus
|
|
|
471
503
|
# Add excludes
|
|
472
504
|
if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
|
|
473
505
|
@config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
|
|
506
|
+
validate_text_argument!(excl, "rsync exclusion")
|
|
474
507
|
cmd << "--exclude=#{excl}"
|
|
475
508
|
end
|
|
476
509
|
end
|
|
@@ -500,16 +533,20 @@ module RailsNexus
|
|
|
500
533
|
end
|
|
501
534
|
|
|
502
535
|
def sync_s3_aws(file_path)
|
|
536
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
503
537
|
bucket = @config.s3_bucket
|
|
504
538
|
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
505
539
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
+
]
|
|
511
548
|
|
|
512
|
-
stdout, stderr, status =
|
|
549
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
513
550
|
unless status.success?
|
|
514
551
|
@errors << "S3 upload failed: #{stderr}"
|
|
515
552
|
end
|
|
@@ -518,16 +555,20 @@ module RailsNexus
|
|
|
518
555
|
end
|
|
519
556
|
|
|
520
557
|
def sync_s3_s3cmd(file_path)
|
|
558
|
+
validate_s3_bucket!(@config.s3_bucket)
|
|
521
559
|
bucket = @config.s3_bucket
|
|
522
560
|
prefix = @config.s3_prefix.present? ? "#{@config.s3_prefix}/" : ""
|
|
523
561
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
]
|
|
529
570
|
|
|
530
|
-
stdout, stderr, status =
|
|
571
|
+
stdout, stderr, status = capture_command(env, *cmd)
|
|
531
572
|
unless status.success?
|
|
532
573
|
@errors << "S3 upload (s3cmd) failed: #{stderr}"
|
|
533
574
|
end
|
|
@@ -548,22 +589,12 @@ module RailsNexus
|
|
|
548
589
|
|
|
549
590
|
while files.size > @config.keep_count
|
|
550
591
|
old_file = files.shift
|
|
551
|
-
|
|
552
|
-
FileUtils.rm_rf(old_file)
|
|
553
|
-
else
|
|
554
|
-
File.delete(old_file) if File.exist?(old_file)
|
|
555
|
-
end
|
|
592
|
+
FileUtils.rm_rf(old_file)
|
|
556
593
|
end
|
|
557
594
|
end
|
|
558
595
|
|
|
559
596
|
def cleanup_temp_files
|
|
560
|
-
@temp_files.each
|
|
561
|
-
if File.directory?(f)
|
|
562
|
-
FileUtils.rm_rf(f)
|
|
563
|
-
else
|
|
564
|
-
File.delete(f) if File.exist?(f)
|
|
565
|
-
end
|
|
566
|
-
end
|
|
597
|
+
@temp_files.each { |file| FileUtils.rm_rf(file) }
|
|
567
598
|
end
|
|
568
599
|
|
|
569
600
|
# ════════════════════════════════════════════════════════════════
|
|
@@ -585,13 +616,23 @@ module RailsNexus
|
|
|
585
616
|
def execute_notify_command(status, error = nil)
|
|
586
617
|
return if @config.notify_command.blank?
|
|
587
618
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
593
624
|
|
|
594
|
-
|
|
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)
|
|
595
636
|
rescue StandardError
|
|
596
637
|
# Don't fail backup because notification failed
|
|
597
638
|
end
|
|
@@ -608,12 +649,10 @@ module RailsNexus
|
|
|
608
649
|
|
|
609
650
|
# Build mail command based on available MTA
|
|
610
651
|
if command_exists?("mail")
|
|
611
|
-
|
|
612
|
-
Open3.capture3(cmd)
|
|
652
|
+
capture_command("mail", "-s", subject, @config.email_to.to_s, stdin_data: body)
|
|
613
653
|
elsif command_exists?("sendmail")
|
|
614
654
|
mail_content = "To: #{@config.email_to}\nSubject: #{subject}\n\n#{body}"
|
|
615
|
-
|
|
616
|
-
Open3.capture3(cmd)
|
|
655
|
+
capture_command("sendmail", "-t", stdin_data: mail_content)
|
|
617
656
|
else
|
|
618
657
|
@errors << "No mail command found (mail/sendmail)"
|
|
619
658
|
end
|
|
@@ -649,16 +688,12 @@ module RailsNexus
|
|
|
649
688
|
resolve_secret(value)
|
|
650
689
|
end
|
|
651
690
|
|
|
652
|
-
|
|
653
|
-
resolve_secret(value)
|
|
654
|
-
end
|
|
691
|
+
alias_method :resolve_env, :resolve_password
|
|
655
692
|
|
|
656
693
|
def resolve_secret(value)
|
|
657
694
|
return nil if value.blank?
|
|
658
695
|
case value
|
|
659
|
-
when /^ENV\[(.+?)\]$/
|
|
660
|
-
ENV[$1]
|
|
661
|
-
when /^\$\{(.+?)\}$/
|
|
696
|
+
when /^ENV\[(.+?)\]$/, /^\$\{(.+?)\}$/
|
|
662
697
|
ENV[$1]
|
|
663
698
|
when /^credentials\[/
|
|
664
699
|
# credentials[:key] or credentials[:key][:subkey]
|
|
@@ -670,7 +705,107 @@ module RailsNexus
|
|
|
670
705
|
end
|
|
671
706
|
|
|
672
707
|
def command_exists?(cmd)
|
|
673
|
-
|
|
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
|
|
674
809
|
end
|
|
675
810
|
|
|
676
811
|
def failure_message(exception)
|