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.
- checksums.yaml +7 -0
- data/.env.example +40 -0
- data/CONTRIBUTORS +22 -0
- data/DESIGN.md +289 -0
- data/LICENSE +12 -0
- data/README.md +754 -0
- data/SECURITY.md +161 -0
- data/data/schema.sql +48 -0
- data/exe/securedgram +176 -0
- data/exe/sg-clean +337 -0
- data/exe/sg-recv +287 -0
- data/exe/sg-send +178 -0
- data/lib/securedgram/crypto.rb +64 -0
- data/lib/securedgram/daemon_utils.rb +420 -0
- data/lib/securedgram/env_loader.rb +44 -0
- data/lib/securedgram/udp_server.rb +623 -0
- data/lib/securedgram/version.rb +5 -0
- data/lib/securedgram.rb +21 -0
- metadata +135 -0
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
|