securedgram 0.1.0

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.
data/exe/sg-send ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # sg-send -- SecureDGram CLI tool to inject outbound messages
5
+ #
6
+ # Inserts a message into the outbound_messages table for the daemon to
7
+ # encrypt and send. The daemon picks up rows with state='pending' on
8
+ # its next polling cycle (~100ms).
9
+ #
10
+ # Usage:
11
+ # sg-send [options] <dst_addr> <dst_port> <json_payload>
12
+ # sg-send [options] --file <path> # read payload from file
13
+ # echo '{"content":"hi"}' | sg-send [options] <dst_addr> <dst_port> -
14
+
15
+ require 'securedgram/version'
16
+ require 'securedgram/env_loader'
17
+
18
+ ## Load .env:
19
+ SecureDGram::EnvLoader.load_dotenv
20
+
21
+ require 'optparse'
22
+ require 'json'
23
+ require 'securerandom'
24
+ require 'sqlite3'
25
+
26
+ ## Defaults:
27
+ db_path = ENV.fetch('SECUREDGRAM_DB', 'securedgram.db')
28
+ message_id = nil
29
+ quiet = false
30
+ payload_file = nil
31
+
32
+ optparser = OptionParser.new do |opts|
33
+ opts.banner = "Usage: #{File.basename($0)} [options] <dst_addr> <dst_port> <json_payload | - | --file PATH>"
34
+ opts.separator ""
35
+ opts.separator "SecureDGram v#{SecureDGram::VERSION} -- Inject a message into the outbound queue."
36
+ opts.separator "The daemon will encrypt and send it on the next poll cycle."
37
+ opts.separator ""
38
+ opts.separator "Options:"
39
+
40
+ opts.on("--db PATH", "SQLite3 database path (default: from .env or securedgram.db)") do |path|
41
+ db_path = path
42
+ end
43
+
44
+ opts.on("-m", "--message-id HEX", "Set message_id (24-char hex). If omitted, daemon generates one.") do |mid|
45
+ mid = mid.strip.downcase
46
+ unless /^[a-f0-9]{24}$/.match(mid)
47
+ STDERR.puts "ERROR: message_id must be a 24-character lowercase hex string."
48
+ exit 1
49
+ end
50
+ message_id = mid
51
+ end
52
+
53
+ opts.on("-f", "--file PATH", "Read JSON payload from a file instead of command line") do |path|
54
+ unless File.file?(path)
55
+ STDERR.puts "ERROR: File not found: #{path}"
56
+ exit 1
57
+ end
58
+ payload_file = path
59
+ end
60
+
61
+ opts.on("-q", "--quiet", "Suppress output on success (exit code only)") do
62
+ quiet = true
63
+ end
64
+
65
+ opts.on("-V", "--version", "Show version and exit") do
66
+ puts "sg-send (SecureDGram) #{SecureDGram::VERSION}"
67
+ exit
68
+ end
69
+
70
+ opts.on("-h", "--help", "Show this help") do
71
+ puts opts
72
+ exit
73
+ end
74
+ end
75
+
76
+ begin
77
+ optparser.parse!(ARGV)
78
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
79
+ STDERR.puts "ERROR: #{e.message}"
80
+ STDERR.puts optparser
81
+ exit 1
82
+ end
83
+
84
+ ## Parse positional arguments:
85
+ if ARGV.size < 2
86
+ STDERR.puts "ERROR: Missing required arguments: <dst_addr> <dst_port> <json_payload | ->"
87
+ STDERR.puts optparser
88
+ exit 1
89
+ end
90
+
91
+ dst_addr = ARGV[0]
92
+ dst_port = ARGV[1].to_i
93
+
94
+ if dst_port < 1 || dst_port > 65535
95
+ STDERR.puts "ERROR: Invalid port number: #{ARGV[1]}"
96
+ exit 1
97
+ end
98
+
99
+ ## Read payload:
100
+ if payload_file
101
+ payload_str = File.read(payload_file)
102
+ elsif ARGV.size >= 3
103
+ if ARGV[2] == '-'
104
+ payload_str = STDIN.read
105
+ else
106
+ payload_str = ARGV[2]
107
+ end
108
+ else
109
+ ## Try reading from stdin if no payload arg:
110
+ if STDIN.tty?
111
+ STDERR.puts "ERROR: Missing JSON payload argument."
112
+ STDERR.puts optparser
113
+ exit 1
114
+ end
115
+ payload_str = STDIN.read
116
+ end
117
+
118
+ ## Validate JSON:
119
+ begin
120
+ payload_json = JSON.parse(payload_str)
121
+ rescue JSON::ParserError => e
122
+ STDERR.puts "ERROR: Invalid JSON payload: #{e.message}"
123
+ exit 1
124
+ end
125
+
126
+ ## If message_id provided, inject/validate in payload:
127
+ if message_id
128
+ if payload_json.key?('message_id') && payload_json['message_id'] != message_id
129
+ STDERR.puts "ERROR: --message-id '#{message_id}' conflicts with payload message_id '#{payload_json['message_id']}'"
130
+ exit 1
131
+ end
132
+ payload_json['message_id'] = message_id
133
+ payload_str = payload_json.to_json
134
+ end
135
+
136
+ ## Verify database exists:
137
+ unless File.file?(db_path)
138
+ STDERR.puts "ERROR: Database not found at #{db_path}"
139
+ STDERR.puts "Has the daemon been started at least once? (It creates the DB on first run.)"
140
+ exit 1
141
+ end
142
+
143
+ ## Insert into outbound_messages:
144
+ begin
145
+ db = SQLite3::Database.new(db_path)
146
+ db.execute("PRAGMA journal_mode = WAL")
147
+ db.busy_timeout = 5000
148
+
149
+ db.execute(
150
+ "INSERT INTO outbound_messages (message_id, dst_addr, dst_port, payload) VALUES (?, ?, ?, ?)",
151
+ [message_id, dst_addr, dst_port, payload_str]
152
+ )
153
+
154
+ row_id = db.last_insert_row_id
155
+ db.close
156
+
157
+ unless quiet
158
+ result = {
159
+ status: "queued",
160
+ row_id: row_id,
161
+ message_id: message_id,
162
+ dst_addr: dst_addr,
163
+ dst_port: dst_port
164
+ }
165
+ puts JSON.pretty_generate(result)
166
+ end
167
+
168
+ rescue SQLite3::ConstraintException => e
169
+ STDERR.puts "ERROR: Constraint violation (duplicate message_id?): #{e.message}"
170
+ exit 1
171
+ rescue SQLite3::BusyException => e
172
+ STDERR.puts "ERROR: Database is busy: #{e.message}"
173
+ STDERR.puts "The daemon or another process is holding the lock. Try again."
174
+ exit 1
175
+ rescue SQLite3::Exception => e
176
+ STDERR.puts "ERROR: Database error: #{e.class}: #{e.message}"
177
+ exit 1
178
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # SecureDGram::Crypto -- ChaCha20-Poly1305-IETF AEAD encryption/decryption
4
+ #
5
+ # Written by Aaron D. Gifford - https://aarongifford.com/
6
+ # Copyright (c) 2016 Aaron D. Gifford
7
+ # Contributions by Claude Code Opus 4.6 - https://claude.ai/
8
+ #
9
+ # Usage of the works is permitted provided that this instrument is
10
+ # retained with the works, so that any entity that uses the works is
11
+ # notified of this instrument.
12
+ #
13
+ # DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.
14
+
15
+ require 'rbnacl'
16
+ require 'securerandom'
17
+
18
+ module SecureDGram
19
+ module Crypto
20
+ module_function
21
+
22
+ ##
23
+ ## Encrypt a plaintext message with a timestamp envelope.
24
+ ##
25
+ ## key - 32-byte binary symmetric key
26
+ ## plaintext - String to encrypt
27
+ ## timestamp - Time object (nanosecond precision preserved)
28
+ ##
29
+ ## Returns: nonce (12 bytes) || ciphertext+tag
30
+ ##
31
+ def encrypt(key, plaintext, timestamp)
32
+ nonce = SecureRandom.random_bytes(12) ## 12-byte random nonce
33
+ nsec = timestamp.nsec
34
+ timestamp = timestamp.to_i
35
+ timestamp = [timestamp * 1000000000 + nsec].pack("Q>")
36
+ return nonce + RbNaCl::AEAD::ChaCha20Poly1305IETF.new(key).encrypt(nonce, timestamp + plaintext, nil)
37
+ end
38
+
39
+ ##
40
+ ## Decrypt a ciphertext message and extract the timestamp.
41
+ ##
42
+ ## key - 32-byte binary symmetric key
43
+ ## ciphertext - nonce || ciphertext+tag (as produced by encrypt)
44
+ ##
45
+ ## Returns: [plaintext, timestamp] where timestamp is a Time with nanosecond precision
46
+ ##
47
+ ## Raises RbNaCl::LengthError if ciphertext is too short
48
+ ## Raises RbNaCl::CryptoError if decryption/authentication fails
49
+ ##
50
+ def decrypt(key, ciphertext)
51
+ if ciphertext.size < 28 ## 12-byte prepended NONCE and 16-byte tag for zero-byte data is minimum
52
+ raise RbNaCl::LengthError.new("Invalid nonce + ciphertext size #{ciphertext.size}. Expected a minimum of 12 + 16 = 28 bytes.")
53
+ end
54
+ nonce = ciphertext[0..11]
55
+ ciphertext = ciphertext[12..]
56
+ plaintext = RbNaCl::AEAD::ChaCha20Poly1305IETF.new(key).decrypt(nonce, ciphertext, nil)
57
+ timestamp = plaintext[0..7]
58
+ plaintext = plaintext[8..]
59
+ timestamp = timestamp.unpack("Q>").first
60
+ timestamp = Time.at(timestamp / 1000000000, timestamp % 1000000000, :nsec)
61
+ return plaintext, timestamp
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # SecureDGram::DaemonUtils -- Unix daemonization framework
4
+ #
5
+ # Written by Aaron D. Gifford - https://aarongifford.com/
6
+ # Contributions by Claude Code Opus 4.6 - https://claude.ai/
7
+ # Copyright (c) InfoWest, Inc.
8
+ #
9
+ # Usage of the works is permitted provided that this instrument is
10
+ # retained with the works, so that any entity that uses the works
11
+ # is notified of this instrument.
12
+ #
13
+ # DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.
14
+
15
+ require 'etc'
16
+ require 'ffi'
17
+ require 'fileutils'
18
+
19
+ module SecureDGram
20
+ module DaemonUtils
21
+ ## FFI binding for setproctitle (FreeBSD/macOS)
22
+ module LibC
23
+ extend FFI::Library
24
+ ffi_lib 'libc'
25
+ attach_function :setproctitle, [:string, :varargs], :void
26
+ rescue LoadError
27
+ # setproctitle not available on this platform
28
+ end
29
+
30
+ ##
31
+ ## Read PID from a PID file
32
+ ##
33
+ def self.get_pid_from_file(pidfile)
34
+ return nil unless File.file?(pidfile)
35
+ File.read(pidfile).strip.to_i
36
+ rescue
37
+ nil
38
+ end
39
+
40
+ ##
41
+ ## Write PID to a PID file
42
+ ##
43
+ def self.write_pidfile(pidfile, pid = Process.pid)
44
+ FileUtils.mkdir_p(File.dirname(pidfile))
45
+ File.open(pidfile, File::RDWR|File::CREAT|File::EXCL, 0600) do |pf|
46
+ pf.write(pid.to_s)
47
+ end
48
+ rescue Errno::EEXIST
49
+ # PID file already exists
50
+ raise "PID file #{pidfile} already exists"
51
+ end
52
+
53
+ ##
54
+ ## Remove PID file
55
+ ##
56
+ def self.remove_pidfile(pidfile)
57
+ File.delete(pidfile) if File.file?(pidfile)
58
+ end
59
+
60
+ ##
61
+ ## Check if a process is running by PID
62
+ ##
63
+ def self.process_running?(pid)
64
+ return false if pid.nil? || pid <= 0
65
+
66
+ begin
67
+ Process.kill(0, pid)
68
+ return true
69
+ rescue Errno::ESRCH
70
+ # Process doesn't exist
71
+ return false
72
+ rescue Errno::EPERM
73
+ # Process exists but we don't have permission (still running)
74
+ return true
75
+ end
76
+ end
77
+
78
+ ##
79
+ ## Get the command line of a process by PID using ps (works on macOS/FreeBSD/Linux)
80
+ ##
81
+ def self.get_process_cmdline(pid)
82
+ `ps -p #{pid} -o command= 2>/dev/null`.strip
83
+ rescue
84
+ nil
85
+ end
86
+
87
+ ##
88
+ ## Is a PID running a valid process or not?
89
+ ##
90
+ def self.is_pid_running(pid, prog_name = nil)
91
+ return false if pid.nil? || pid == 0
92
+
93
+ if process_running?(pid)
94
+ # Optionally validate process name
95
+ if prog_name
96
+ begin
97
+ require 'sys-proctable'
98
+ psinfo = Sys::ProcTable.ps(pid: pid)
99
+ if psinfo && !psinfo.cmdline.include?(prog_name)
100
+ STDERR.puts "WARNING: Process #{pid} is running but cmdline doesn't match: #{psinfo.cmdline.inspect}"
101
+ return false
102
+ end
103
+ rescue LoadError
104
+ # sys-proctable not available, fall back to ps command
105
+ cmdline = get_process_cmdline(pid)
106
+ if cmdline && !cmdline.empty? && !cmdline.include?(prog_name)
107
+ STDERR.puts "WARNING: Process #{pid} is running but cmdline doesn't match: #{cmdline.inspect}"
108
+ return false
109
+ end
110
+ end
111
+ end
112
+ return true
113
+ end
114
+
115
+ # Process not running:
116
+ return false
117
+ end
118
+
119
+ ##
120
+ ## Return the PID of a running daemon OR nil if not running
121
+ ## Optionally validates the process command line matches prog_name
122
+ ##
123
+ def self.running_pid(pidfile, prog_name = nil)
124
+ pid = get_pid_from_file(pidfile)
125
+ return pid if is_pid_running(pid, prog_name)
126
+ # Process not running, clean up stale PID file
127
+ remove_pidfile(pidfile)
128
+ return nil
129
+ end
130
+
131
+ ##
132
+ ## Stop a running process by PID
133
+ ##
134
+ def self.stop_process(pid, prog_name)
135
+ return true unless is_pid_running(pid, prog_name) # Already stopped
136
+
137
+ # Try sending a sequence of signals to terminate the process gracefully,
138
+ # finally resorting to KILL.
139
+ ['TERM', 'INT', 'KILL', 'KILL'].each_with_index do |signal, index|
140
+ # Check if the process has terminated between signals
141
+ return true unless is_pid_running(pid, prog_name)
142
+
143
+ begin
144
+ Process.kill(signal, pid)
145
+ rescue Errno::ESRCH
146
+ # Process ceased to exist between the check and the kill, which is success.
147
+ return true
148
+ end
149
+
150
+ # Wait for the process to die. Wait longer for graceful signals.
151
+ sleep(signal == 'KILL' ? 1 : 2)
152
+ end
153
+
154
+ # Final check to see if the process is still running after all our attempts
155
+ if is_pid_running(pid, prog_name)
156
+ STDERR.puts "Failed to terminate process PID #{pid} with signals. Please terminate it manually."
157
+ return false
158
+ end
159
+
160
+ # If we've reached here, the process is no longer running.
161
+ return true
162
+ end
163
+
164
+ ##
165
+ ## Stop a running daemon process
166
+ ##
167
+ def self.stop_daemon(pidfile, prog_name = nil)
168
+ pid = get_pid_from_file(pidfile)
169
+ if pid.nil? || pid == 0
170
+ STDERR.puts "Unable to read PID from file #{pidfile.inspect}"
171
+ return false
172
+ end
173
+
174
+ oldpid = pid
175
+ pid = running_pid(pidfile, prog_name)
176
+ if pid.nil? # Already stopped
177
+ STDERR.puts "Process (PID #{oldpid}) doesn't exist."
178
+ remove_pidfile(pidfile)
179
+ return true
180
+ end
181
+
182
+ ## Try killing the process:
183
+ is_stopped = stop_process(pid, prog_name)
184
+
185
+ if is_stopped
186
+ ## Check to see if there's a new replacement process:
187
+ pid = running_pid(pidfile, prog_name)
188
+ if !pid.nil?
189
+ if pid == oldpid
190
+ STDERR.puts "Process (PID #{oldpid}) has been stopped ALLEGEDLY, but there's a RUNNING process (PID #{pid}) with the SAME PID still running."
191
+ return false
192
+ end
193
+ STDERR.puts "Process (PID #{oldpid}) has been stopped. NOTE that a NEW process (PID #{pid}) has since started."
194
+ return false ## Process was stopped, but daemon is still running.
195
+ end
196
+ STDERR.puts "Process (PID #{oldpid}) has been stopped."
197
+ remove_pidfile(pidfile)
198
+ return true
199
+ end
200
+ STDERR.puts "Failed to stop process (PID #{oldpid})!"
201
+ return false
202
+ end
203
+
204
+ ##
205
+ ## Poll - wait for a daemon to stop
206
+ ##
207
+ def self.poll_daemon(pidfile, prog_name = nil)
208
+ pid = running_pid(pidfile, prog_name)
209
+ while !pid.nil?
210
+ sleep 0.1
211
+ pid = running_pid(pidfile, prog_name)
212
+ end
213
+ end
214
+
215
+ ##
216
+ ## Daemonize a process using double-fork
217
+ ##
218
+ ## klass: A class that responds to:
219
+ ## - initialize(options) - constructor
220
+ ## - run() - main loop (returns true to continue, false to stop)
221
+ ## - root_init() - operations requiring root privileges (optional)
222
+ ## - pre_fork() - called before forking (optional)
223
+ ## - post_fork() - called after forking in child (optional)
224
+ ## - quit() - called on shutdown signals (optional)
225
+ ## - reconfig() - called on HUP signal (optional)
226
+ ## - exit_code(code) - called to get exit code (optional)
227
+ ## - setup_logging() - called to re-open logs (after fork/daemonization) - returns a logging object
228
+ ##
229
+ ## options: Hash of options to pass to klass.new
230
+ ## Must include :log Logger object to log to
231
+ ## Must include :prog_name for process title
232
+ ## Must include :user for dropping privileges
233
+ ## which takes as first argument the instance of the klass,
234
+ ## an optional hash of options, and an optional logger object
235
+ ##
236
+ def self.daemonize(klass, options)
237
+ prog_name = options[:prog_name] or raise "Missing :prog_name in options"
238
+ daemon_user = options[:user] or raise "Missing :user in options"
239
+ log = options[:log] or raise "Missing :log in options"
240
+
241
+ # Create daemon instance
242
+ daemon = klass.new(options)
243
+
244
+ # Pre-fork hook
245
+ daemon.pre_fork if daemon.respond_to?(:pre_fork)
246
+
247
+ parent_pid = Process.pid
248
+ read_pid, write_pid = IO.pipe
249
+
250
+ Process.fork do
251
+ ## Child (soon to be a daemon) process:
252
+ read_pid.close
253
+
254
+ ## Set session ID:
255
+ Process.setsid
256
+
257
+ ## Fork again (parent exits):
258
+ exit 0 if Process.fork
259
+
260
+ ## Tell parent the daemon PID:
261
+ write_pid.write(Process.pid.to_s)
262
+ write_pid.close
263
+
264
+ ## Make working directory the root:
265
+ Dir.chdir('/')
266
+
267
+ ## Clear file creation mask:
268
+ File.umask(0)
269
+
270
+ ## Reroute standard I/O:
271
+ STDIN.reopen('/dev/null')
272
+ STDOUT.reopen('/dev/null', 'a')
273
+ STDERR.reopen(STDOUT)
274
+
275
+ ## Start child process logging initially:
276
+ log = daemon.setup_logging
277
+
278
+ ## Set proctitle (if available)
279
+ begin
280
+ LibC.setproctitle(prog_name) if defined?(LibC)
281
+ rescue
282
+ # setproctitle not available
283
+ end
284
+
285
+ log.info("Child daemon PID #{Process.pid} is initializing (parent PID #{parent_pid}) at #{Time.now}")
286
+
287
+ ## Perform any operations requiring root privileges:
288
+ begin
289
+ daemon.root_init if daemon.respond_to?(:root_init)
290
+ rescue => e
291
+ log.error("AN EXCEPTION OCCURRED: #{e.class}: #{e.message}")
292
+ log.error("*** BACKTRACE ***")
293
+ e.backtrace.each { |bt| log.error("... " + bt) }
294
+ log.error("*** END of BACKTRACE ***")
295
+ exit 1
296
+ end
297
+
298
+ ## Drop privileges (only if running as root):
299
+ user_info = Etc.getpwnam(daemon_user)
300
+ if Process.euid == 0
301
+ log.info("Daemon initialized. Dropping privileges to #{daemon_user}.")
302
+ Process::Sys.setgid(user_info.gid)
303
+ Process::Sys.setuid(user_info.uid)
304
+ log.info("Privileges dropped. Running as user #{daemon_user} GID #{user_info.gid} UID #{user_info.uid}")
305
+ elsif Process.euid == user_info.uid && Process.egid == user_info.gid
306
+ log.info("Already running as user #{daemon_user} (UID #{user_info.uid}, GID #{user_info.gid})")
307
+ else
308
+ current_user = Etc.getpwuid(Process.euid).name
309
+ log.warn("WARNING: Running as user #{current_user} (UID #{Process.euid}), expected #{daemon_user} (UID #{user_info.uid})")
310
+ end
311
+
312
+ ## Restart logging with dropped privileges:
313
+ oldlog = log
314
+ begin
315
+ log = daemon.setup_logging
316
+ rescue => e
317
+ oldlog.error("AN EXCEPTION OCCURRED: #{e.class}: #{e.message}")
318
+ oldlog.error("*** BACKTRACE ***")
319
+ e.backtrace.each { |bt| oldlog.error("... " + bt) }
320
+ oldlog.error("*** END of BACKTRACE ***")
321
+ end
322
+ oldlog.warn("Logging stopped.")
323
+ oldlog.close if oldlog.respond_to?(:close)
324
+ log.warn("Logging (re)started.")
325
+
326
+ ## Setup signal handling:
327
+ log.info("Trapping HUP/QUIT/INT/TERM/USR1 signals.")
328
+ read_sig, write_sig = IO.pipe
329
+ ['HUP', 'QUIT', 'INT', 'TERM', 'USR1'].each do |sig|
330
+ Signal.trap(sig) do
331
+ write_sig.write(sig[0])
332
+ write_sig.flush
333
+ end
334
+ end
335
+
336
+ Thread.new do
337
+ log.warn("Signal dispatch thread launched")
338
+ while (buf = read_sig.read(1))
339
+ next unless 'HQITU'.include?(buf)
340
+ sig = case buf
341
+ when 'H' then 'HUP'
342
+ when 'Q' then 'QUIT'
343
+ when 'I' then 'INT'
344
+ when 'T' then 'TERM'
345
+ when 'U' then 'USR1'
346
+ end
347
+ log.warn("Daemon received signal #{sig}")
348
+ if ['TERM', 'INT', 'QUIT'].include?(sig)
349
+ log.warn("Daemon exiting (#{sig} signal received)")
350
+ daemon.quit(sig) if daemon.respond_to?(:quit)
351
+ exit_code = daemon.respond_to?(:exit_code) ? daemon.exit_code(0) : 0
352
+ exit(exit_code)
353
+ elsif sig == 'HUP'
354
+ log.warn("Reopening logs / reloading configuration (HUP signal received)")
355
+ log = daemon.setup_logging
356
+ daemon.reconfig if daemon.respond_to?(:reconfig)
357
+ log.warn("Configuration reloaded")
358
+ elsif sig == 'USR1'
359
+ log.warn("USR1 signal received")
360
+ daemon.usr1() if daemon.respond_to?(:usr1)
361
+ else
362
+ log.error("Unknown signal #{sig.inspect} ignored")
363
+ end
364
+ end
365
+ end
366
+ ## Post-fork hook:
367
+ daemon.post_fork if daemon.respond_to?(:post_fork)
368
+
369
+ ## Execute daemon loop:
370
+ begin
371
+ while daemon.run
372
+ # The signal handling thread now manages signals, so the main loop doesn't need to.
373
+ end
374
+ rescue => e
375
+ log.error("AN EXCEPTION OCCURRED: #{e.class}: #{e.message}")
376
+ log.error("*** BACKTRACE ***")
377
+ e.backtrace.each { |bt| log.error("... " + bt) }
378
+ log.error("*** END of BACKTRACE ***")
379
+ end
380
+
381
+ ## Terminate:
382
+ log.warn("Daemon main loop ended.")
383
+ exit_code = daemon.respond_to?(:exit_code) ? daemon.exit_code(0) : 0
384
+ exit(exit_code)
385
+ end
386
+
387
+ ## Parent process:
388
+ write_pid.close
389
+ daemon_pid = read_pid.read.to_i
390
+ read_pid.close
391
+ STDERR.puts "Parent PID #{parent_pid} has spawned daemon PID #{daemon_pid}"
392
+ return daemon_pid
393
+ end
394
+
395
+ ##
396
+ ## Simple daemonize without privilege dropping (for non-root daemons)
397
+ ##
398
+ def self.simple_daemonize(working_dir = '/')
399
+ # First fork
400
+ exit if Process.fork
401
+
402
+ # Become session leader
403
+ Process.setsid
404
+
405
+ # Second fork
406
+ exit if Process.fork
407
+
408
+ # Change working directory
409
+ Dir.chdir(working_dir)
410
+
411
+ # Clear file creation mask
412
+ File.umask(0)
413
+
414
+ # Close file descriptors
415
+ STDIN.reopen('/dev/null')
416
+ STDOUT.reopen('/dev/null', 'a')
417
+ STDERR.reopen(STDOUT)
418
+ end
419
+ end
420
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # SecureDGram::EnvLoader -- .env file parser
4
+ #
5
+ # Loads environment variables from a .env file. Used by the daemon and
6
+ # all CLI tools to pick up configuration without requiring shell exports.
7
+
8
+ module SecureDGram
9
+ module EnvLoader
10
+ module_function
11
+
12
+ ##
13
+ ## Load a .env file into ENV.
14
+ ##
15
+ ## path - Path to the .env file (default: .env in the current directory)
16
+ ## force - If true, overwrite existing ENV values (used by HUP reload).
17
+ ## If false (default), only set values not already in ENV (||= semantics).
18
+ ##
19
+ ## Returns true if the file was loaded, false if not found.
20
+ ##
21
+ def load_dotenv(path = nil, force: false)
22
+ path ||= File.join(Dir.pwd, '.env')
23
+ return false unless File.file?(path)
24
+
25
+ File.readlines(path).each do |line|
26
+ line = line.strip
27
+ next if line.empty? || line.start_with?('#')
28
+ key, value = line.split('=', 2)
29
+ next unless key && value
30
+ value = value.strip
31
+ ## Strip matching surrounding quotes (single or double):
32
+ value = value[1..-2] if (value.start_with?('"') && value.end_with?('"')) ||
33
+ (value.start_with?("'") && value.end_with?("'"))
34
+ key = key.strip
35
+ if force
36
+ ENV[key] = value
37
+ else
38
+ ENV[key] ||= value
39
+ end
40
+ end
41
+ true
42
+ end
43
+ end
44
+ end