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-clean ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # sg-clean -- SecureDGram CLI tool to purge old messages from the database
5
+ #
6
+ # Deletes terminal-state messages older than the specified age.
7
+ # By default purges:
8
+ # - Outbound: acknowledged, send_failed
9
+ # - Inbound: ack_sent, ack_failed (only where read_count > 0)
10
+ #
11
+ # Usage:
12
+ # sg-clean <age> # e.g., 7d, 24h, 4w
13
+ # sg-clean --before <datetime> # ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS
14
+ # sg-clean --dry-run 30d # preview what would be deleted
15
+ #
16
+ # Age suffixes (GNU sleep / systemd convention):
17
+ # s = seconds, m = minutes, h = hours, d = days, w = weeks
18
+
19
+ require 'securedgram/version'
20
+ require 'securedgram/env_loader'
21
+
22
+ ## Load .env:
23
+ SecureDGram::EnvLoader.load_dotenv
24
+
25
+ require 'optparse'
26
+ require 'json'
27
+ require 'sqlite3'
28
+ require 'time'
29
+
30
+ ## Duration suffix multipliers (GNU sleep / systemd convention):
31
+ DURATION_MULTIPLIERS = {
32
+ 's' => 1,
33
+ 'm' => 60,
34
+ 'h' => 3600,
35
+ 'd' => 86400,
36
+ 'w' => 604800,
37
+ }.freeze
38
+
39
+ ##
40
+ ## Parse a duration string like "7d", "24h", "30m", "3600s", "4w"
41
+ ## Returns seconds as an integer, or nil if invalid.
42
+ ##
43
+ def parse_duration(str)
44
+ str = str.strip
45
+ if m = /\A(\d+)([smhdw])\z/.match(str)
46
+ return m[1].to_i * DURATION_MULTIPLIERS[m[2]]
47
+ end
48
+ nil
49
+ end
50
+
51
+ ##
52
+ ## Parse an age argument: either a duration suffix or an ISO 8601 datetime.
53
+ ## Returns a Time object representing the cutoff.
54
+ ##
55
+ def parse_cutoff(str)
56
+ ## Try duration first:
57
+ seconds = parse_duration(str)
58
+ if seconds
59
+ return Time.now - seconds
60
+ end
61
+
62
+ ## Try ISO 8601 datetime (Time.parse handles YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, etc.):
63
+ begin
64
+ return Time.parse(str)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+ end
69
+
70
+ ## Defaults:
71
+ db_path = ENV.fetch('SECUREDGRAM_DB', 'securedgram.db')
72
+ dry_run = false
73
+ auto_yes = false
74
+ no_vacuum = false
75
+ outbound_only = false
76
+ inbound_only = false
77
+ include_unread = false
78
+ all_states = false
79
+ target_state = nil
80
+ before_str = nil
81
+
82
+ optparser = OptionParser.new do |opts|
83
+ opts.banner = "Usage: #{File.basename($0)} [options] <age>"
84
+ opts.separator ""
85
+ opts.separator "SecureDGram v#{SecureDGram::VERSION} -- Purge old messages from the database."
86
+ opts.separator ""
87
+ opts.separator "Age format: <number><suffix> where suffix is:"
88
+ opts.separator " s = seconds, m = minutes, h = hours, d = days, w = weeks"
89
+ opts.separator " Examples: 7d (7 days), 24h (24 hours), 4w (4 weeks), 3600s (1 hour)"
90
+ opts.separator ""
91
+ opts.separator "Options:"
92
+
93
+ opts.on("--db PATH", "SQLite3 database path (default: from .env)") do |path|
94
+ db_path = path
95
+ end
96
+
97
+ opts.on("--before DATETIME", "Purge messages before this date (ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)") do |dt|
98
+ before_str = dt
99
+ end
100
+
101
+ opts.on("--dry-run", "Show what would be deleted without actually deleting") do
102
+ dry_run = true
103
+ end
104
+
105
+ opts.on("-y", "--yes", "Skip confirmation prompt (for scripting)") do
106
+ auto_yes = true
107
+ end
108
+
109
+ opts.on("--no-vacuum", "Skip VACUUM after deletion") do
110
+ no_vacuum = true
111
+ end
112
+
113
+ opts.on("--outbound-only", "Only purge outbound messages") do
114
+ outbound_only = true
115
+ end
116
+
117
+ opts.on("--inbound-only", "Only purge inbound messages") do
118
+ inbound_only = true
119
+ end
120
+
121
+ opts.on("--include-unread", "Also purge inbound messages with read_count = 0") do
122
+ include_unread = true
123
+ end
124
+
125
+ opts.on("--all-states", "Purge ALL matching rows regardless of state (DANGEROUS)") do
126
+ all_states = true
127
+ end
128
+
129
+ opts.on("-s", "--state STATE", "Target a specific state only (e.g., send_failed, acknowledged)") do |s|
130
+ target_state = s
131
+ end
132
+
133
+ opts.on("-V", "--version", "Show version and exit") do
134
+ puts "sg-clean (SecureDGram) #{SecureDGram::VERSION}"
135
+ exit
136
+ end
137
+
138
+ opts.on("-h", "--help", "Show this help") do
139
+ puts opts
140
+ exit
141
+ end
142
+ end
143
+
144
+ begin
145
+ optparser.parse!(ARGV)
146
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
147
+ STDERR.puts "ERROR: #{e.message}"
148
+ STDERR.puts optparser
149
+ exit 1
150
+ end
151
+
152
+ ## Conflicting flags:
153
+ if outbound_only && inbound_only
154
+ STDERR.puts "ERROR: --outbound-only and --inbound-only are mutually exclusive."
155
+ exit 1
156
+ end
157
+
158
+ ## Parse cutoff time:
159
+ cutoff = nil
160
+
161
+ if before_str
162
+ cutoff = parse_cutoff(before_str)
163
+ unless cutoff
164
+ STDERR.puts "ERROR: Cannot parse --before value: #{before_str.inspect}"
165
+ STDERR.puts "Expected ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS"
166
+ exit 1
167
+ end
168
+ elsif ARGV.size >= 1
169
+ cutoff = parse_cutoff(ARGV[0])
170
+ unless cutoff
171
+ STDERR.puts "ERROR: Cannot parse age: #{ARGV[0].inspect}"
172
+ STDERR.puts "Expected format: <number><suffix> where suffix is s/m/h/d/w"
173
+ STDERR.puts " Examples: 7d, 24h, 30m, 3600s, 4w"
174
+ STDERR.puts "Or ISO 8601: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS"
175
+ exit 1
176
+ end
177
+ else
178
+ STDERR.puts "ERROR: Missing required age argument."
179
+ STDERR.puts optparser
180
+ exit 1
181
+ end
182
+
183
+ cutoff_str = cutoff.strftime('%Y-%m-%dT%H:%M:%S.%6N')
184
+
185
+ ## Verify database exists:
186
+ unless File.file?(db_path)
187
+ STDERR.puts "ERROR: Database not found at #{db_path}"
188
+ exit 1
189
+ end
190
+
191
+ ## Terminal states for each table:
192
+ OUTBOUND_TERMINAL = ['acknowledged', 'send_failed'].freeze
193
+ INBOUND_TERMINAL = ['ack_sent', 'ack_failed'].freeze
194
+
195
+ ##
196
+ ## Build DELETE conditions for a table.
197
+ ## Returns [where_clause, params] or nil if this table should be skipped.
198
+ ##
199
+ def build_conditions(table, cutoff_str, target_state, all_states, include_unread)
200
+ conditions = []
201
+ params = []
202
+
203
+ conditions << "updated_at < ?"
204
+ params << cutoff_str
205
+
206
+ ## State filter:
207
+ if target_state
208
+ conditions << "state = ?"
209
+ params << target_state
210
+ elsif !all_states
211
+ if table == 'outbound_messages'
212
+ placeholders = OUTBOUND_TERMINAL.map { '?' }.join(', ')
213
+ conditions << "state IN (#{placeholders})"
214
+ params.concat(OUTBOUND_TERMINAL)
215
+ else
216
+ placeholders = INBOUND_TERMINAL.map { '?' }.join(', ')
217
+ conditions << "state IN (#{placeholders})"
218
+ params.concat(INBOUND_TERMINAL)
219
+ end
220
+ end
221
+
222
+ ## For inbound: skip unread unless --include-unread:
223
+ if table == 'inbound_messages' && !include_unread && !all_states
224
+ conditions << "read_count > 0"
225
+ end
226
+
227
+ where = conditions.join(" AND ")
228
+ return where, params
229
+ end
230
+
231
+ begin
232
+ db = SQLite3::Database.new(db_path)
233
+ db.results_as_hash = true
234
+ db.execute("PRAGMA journal_mode = WAL")
235
+ db.busy_timeout = 5000
236
+
237
+ tables = []
238
+ tables << 'outbound_messages' unless inbound_only
239
+ tables << 'inbound_messages' unless outbound_only
240
+
241
+ total_to_delete = 0
242
+ table_counts = {}
243
+
244
+ ## Count phase:
245
+ tables.each do |table|
246
+ where, params = build_conditions(table, cutoff_str, target_state, all_states, include_unread)
247
+ count_query = "SELECT COUNT(*) as cnt FROM #{table} WHERE #{where}"
248
+ row = db.get_first_row(count_query, params)
249
+ count = row ? row['cnt'] : 0
250
+ table_counts[table] = { count: count, where: where, params: params }
251
+ total_to_delete += count
252
+ end
253
+
254
+ ## Report:
255
+ puts "Cutoff: #{cutoff.strftime('%Y-%m-%d %H:%M:%S')} (messages last updated before this time)"
256
+ puts ""
257
+
258
+ tables.each do |table|
259
+ info = table_counts[table]
260
+ label = table == 'outbound_messages' ? 'Outbound' : 'Inbound'
261
+
262
+ if info[:count] > 0
263
+ ## Show state breakdown:
264
+ where = info[:where]
265
+ params = info[:params]
266
+ breakdown_query = "SELECT state, COUNT(*) as cnt FROM #{table} WHERE #{where} GROUP BY state ORDER BY cnt DESC"
267
+ breakdown = db.execute(breakdown_query, params)
268
+ puts " #{label}: #{info[:count]} rows to delete"
269
+ breakdown.each do |brow|
270
+ puts " #{brow['state']}: #{brow['cnt']}"
271
+ end
272
+ else
273
+ puts " #{label}: 0 rows to delete"
274
+ end
275
+ end
276
+
277
+ puts ""
278
+
279
+ if total_to_delete == 0
280
+ puts "Nothing to delete."
281
+ db.close
282
+ exit 0
283
+ end
284
+
285
+ if dry_run
286
+ puts "(dry run — no rows were deleted)"
287
+ db.close
288
+ exit 0
289
+ end
290
+
291
+ ## Confirmation:
292
+ unless auto_yes
293
+ STDERR.print "Delete #{total_to_delete} rows? [y/N] "
294
+ STDERR.flush
295
+ answer = STDIN.gets
296
+ unless answer && answer.strip.downcase == 'y'
297
+ puts "Cancelled."
298
+ db.close
299
+ exit 0
300
+ end
301
+ end
302
+
303
+ ## Delete phase:
304
+ total_deleted = 0
305
+ tables.each do |table|
306
+ info = table_counts[table]
307
+ next if info[:count] == 0
308
+
309
+ delete_query = "DELETE FROM #{table} WHERE #{info[:where]}"
310
+ db.execute(delete_query, info[:params])
311
+ deleted = db.changes
312
+ total_deleted += deleted
313
+
314
+ label = table == 'outbound_messages' ? 'Outbound' : 'Inbound'
315
+ puts " #{label}: #{deleted} rows deleted"
316
+ end
317
+
318
+ puts ""
319
+ puts "Total: #{total_deleted} rows deleted."
320
+
321
+ ## VACUUM (default unless --no-vacuum):
322
+ unless no_vacuum
323
+ puts "Running VACUUM to reclaim disk space..."
324
+ db.execute("VACUUM")
325
+ puts "VACUUM complete."
326
+ end
327
+
328
+ db.close
329
+
330
+ rescue SQLite3::BusyException => e
331
+ STDERR.puts "ERROR: Database is busy: #{e.message}"
332
+ STDERR.puts "The daemon or another process is holding the lock. Try again."
333
+ exit 1
334
+ rescue SQLite3::Exception => e
335
+ STDERR.puts "ERROR: Database error: #{e.class}: #{e.message}"
336
+ exit 1
337
+ end
data/exe/sg-recv ADDED
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # sg-recv -- SecureDGram CLI tool to read received (inbound) messages
5
+ #
6
+ # Queries the inbound_messages table and outputs results as JSON.
7
+ # Can also query outbound message status.
8
+ #
9
+ # Usage:
10
+ # sg-recv [options] # show recent inbound messages
11
+ # sg-recv --outbound [options] # show outbound message status
12
+ # sg-recv --follow # poll for new messages continuously
13
+
14
+ require 'securedgram/version'
15
+ require 'securedgram/env_loader'
16
+
17
+ ## Load .env:
18
+ SecureDGram::EnvLoader.load_dotenv
19
+
20
+ require 'optparse'
21
+ require 'json'
22
+ require 'sqlite3'
23
+
24
+ ## Defaults:
25
+ db_path = ENV.fetch('SECUREDGRAM_DB', 'securedgram.db')
26
+ limit = 20
27
+ outbound = false
28
+ state = nil
29
+ from_addr = nil
30
+ msg_id = nil
31
+ follow = false
32
+ interval = 1.0
33
+ since_id = nil
34
+ compact = false
35
+ payload_only = false
36
+ read_included = false
37
+ no_mark = false
38
+
39
+ optparser = OptionParser.new do |opts|
40
+ opts.banner = "Usage: #{File.basename($0)} [options]"
41
+ opts.separator ""
42
+ opts.separator "SecureDGram v#{SecureDGram::VERSION} -- Read messages from the database."
43
+ opts.separator ""
44
+ opts.separator "Options:"
45
+
46
+ opts.on("--db PATH", "SQLite3 database path (default: from .env)") do |path|
47
+ db_path = path
48
+ end
49
+
50
+ opts.on("-n", "--limit N", Integer, "Number of messages to return (default: 20, 0 = all)") do |n|
51
+ limit = n
52
+ end
53
+
54
+ opts.on("-o", "--outbound", "Query outbound messages instead of inbound") do
55
+ outbound = true
56
+ end
57
+
58
+ opts.on("-s", "--state STATE", "Filter by message state (e.g., ack_sent, received, pending, sent, acknowledged)") do |s|
59
+ state = s
60
+ end
61
+
62
+ opts.on("--from ADDR", "Filter inbound by sender IP address") do |addr|
63
+ from_addr = addr
64
+ end
65
+
66
+ opts.on("--to ADDR", "Filter outbound by destination IP address") do |addr|
67
+ from_addr = addr ## Reuse same variable; context changes meaning
68
+ end
69
+
70
+ opts.on("-i", "--id MESSAGE_ID", "Look up a specific message by message_id") do |mid|
71
+ msg_id = mid.strip.downcase
72
+ end
73
+
74
+ opts.on("-f", "--follow", "Continuously poll for new messages (Ctrl+C to stop)") do
75
+ follow = true
76
+ end
77
+
78
+ opts.on("--interval SECS", Float, "Poll interval in seconds for --follow mode (default: 1.0)") do |secs|
79
+ interval = secs
80
+ end
81
+
82
+ opts.on("--since-id ID", Integer, "Only show messages with row id > ID") do |id|
83
+ since_id = id
84
+ end
85
+
86
+ opts.on("-c", "--compact", "Output one JSON object per line (JSONL) instead of pretty-printed") do
87
+ compact = true
88
+ end
89
+
90
+ opts.on("-p", "--payload-only", "Output only the payload field of each message") do
91
+ payload_only = true
92
+ end
93
+
94
+ opts.on("-r", "--read-included", "Include already-read messages (default: unread only for inbound)") do
95
+ read_included = true
96
+ end
97
+
98
+ opts.on("--no-mark", "Don't increment read_count (peek without marking as read)") do
99
+ no_mark = true
100
+ end
101
+
102
+ opts.on("-V", "--version", "Show version and exit") do
103
+ puts "sg-recv (SecureDGram) #{SecureDGram::VERSION}"
104
+ exit
105
+ end
106
+
107
+ opts.on("-h", "--help", "Show this help") do
108
+ puts opts
109
+ exit
110
+ end
111
+ end
112
+
113
+ begin
114
+ optparser.parse!(ARGV)
115
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
116
+ STDERR.puts "ERROR: #{e.message}"
117
+ STDERR.puts optparser
118
+ exit 1
119
+ end
120
+
121
+ ## Verify database exists:
122
+ unless File.file?(db_path)
123
+ STDERR.puts "ERROR: Database not found at #{db_path}"
124
+ STDERR.puts "Has the daemon been started at least once? (It creates the DB on first run.)"
125
+ exit 1
126
+ end
127
+
128
+ ## Build query:
129
+ def build_query(outbound, msg_id, state, from_addr, since_id, limit, read_included)
130
+ table = outbound ? "outbound_messages" : "inbound_messages"
131
+ conditions = []
132
+ params = []
133
+
134
+ if msg_id
135
+ conditions << "message_id = ?"
136
+ params << msg_id
137
+ end
138
+
139
+ if state
140
+ conditions << "state = ?"
141
+ params << state
142
+ end
143
+
144
+ if from_addr
145
+ if outbound
146
+ conditions << "dst_addr = ?"
147
+ else
148
+ conditions << "src_addr = ?"
149
+ end
150
+ params << from_addr
151
+ end
152
+
153
+ if since_id
154
+ conditions << "id > ?"
155
+ params << since_id
156
+ end
157
+
158
+ ## For inbound queries, default to unread only (read_count = 0):
159
+ if !outbound && !read_included && !msg_id
160
+ conditions << "read_count = 0"
161
+ end
162
+
163
+ where = conditions.empty? ? "" : " WHERE " + conditions.join(" AND ")
164
+ limit_clause = (limit > 0) ? " LIMIT #{limit}" : ""
165
+
166
+ query = "SELECT * FROM #{table}#{where} ORDER BY id DESC#{limit_clause}"
167
+ return query, params
168
+ end
169
+
170
+ ## Format a row for output:
171
+ def format_row(row, payload_only)
172
+ if payload_only
173
+ begin
174
+ return JSON.parse(row['payload'])
175
+ rescue
176
+ return row['payload']
177
+ end
178
+ end
179
+ ## Convert the Hash to a clean structure:
180
+ result = {}
181
+ row.each do |key, value|
182
+ next if key.is_a?(Integer) ## Skip numeric index keys from results_as_hash
183
+ result[key] = value
184
+ end
185
+ ## Parse payload JSON for nested output:
186
+ if result['payload']
187
+ begin
188
+ result['payload'] = JSON.parse(result['payload'])
189
+ rescue
190
+ ## Leave as string if not valid JSON
191
+ end
192
+ end
193
+ result
194
+ end
195
+
196
+ begin
197
+ db = SQLite3::Database.new(db_path)
198
+ db.results_as_hash = true
199
+ db.execute("PRAGMA journal_mode = WAL")
200
+ db.busy_timeout = 5000
201
+
202
+ if follow
203
+ ## Follow mode: continuously poll for new messages
204
+ last_id = since_id || 0
205
+
206
+ ## Get the current max id as starting point if no since_id given:
207
+ if last_id == 0
208
+ table = outbound ? "outbound_messages" : "inbound_messages"
209
+ max_row = db.get_first_row("SELECT MAX(id) as max_id FROM #{table}")
210
+ last_id = (max_row && max_row['max_id']) ? max_row['max_id'] : 0
211
+ STDERR.puts "Following new messages (since id #{last_id}). Press Ctrl+C to stop."
212
+ end
213
+
214
+ begin
215
+ loop do
216
+ query, params = build_query(outbound, msg_id, state, from_addr, last_id, 0, read_included)
217
+ ## Override ORDER to ASC for follow mode:
218
+ query = query.sub("ORDER BY id DESC", "ORDER BY id ASC")
219
+ rows = db.execute(query, params)
220
+
221
+ rows.each do |row|
222
+ formatted = format_row(row, payload_only)
223
+ if compact
224
+ puts JSON.generate(formatted)
225
+ else
226
+ puts JSON.pretty_generate(formatted)
227
+ end
228
+ STDOUT.flush
229
+ last_id = row['id'] if row['id'] > last_id
230
+
231
+ ## Mark as read (increment read_count) for inbound messages:
232
+ if !outbound && !no_mark
233
+ db.execute(
234
+ "UPDATE inbound_messages SET read_count = read_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id = ?",
235
+ [row['id']]
236
+ )
237
+ end
238
+ end
239
+
240
+ sleep interval
241
+ end
242
+ rescue Interrupt
243
+ STDERR.puts "\nStopped."
244
+ end
245
+
246
+ else
247
+ ## One-shot query:
248
+ query, params = build_query(outbound, msg_id, state, from_addr, since_id, limit, read_included)
249
+ rows = db.execute(query, params)
250
+
251
+ results = rows.map { |row| format_row(row, payload_only) }
252
+
253
+ ## Mark as read (increment read_count) for inbound messages:
254
+ if !outbound && !no_mark
255
+ rows.each do |row|
256
+ db.execute(
257
+ "UPDATE inbound_messages SET read_count = read_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE id = ?",
258
+ [row['id']]
259
+ )
260
+ end
261
+ end
262
+
263
+ if msg_id && results.size == 1
264
+ ## Single lookup: output the object directly:
265
+ if compact
266
+ puts JSON.generate(results.first)
267
+ else
268
+ puts JSON.pretty_generate(results.first)
269
+ end
270
+ else
271
+ if compact
272
+ results.each { |r| puts JSON.generate(r) }
273
+ else
274
+ puts JSON.pretty_generate(results)
275
+ end
276
+ end
277
+ end
278
+
279
+ db.close
280
+
281
+ rescue SQLite3::BusyException => e
282
+ STDERR.puts "ERROR: Database is busy: #{e.message}"
283
+ exit 1
284
+ rescue SQLite3::Exception => e
285
+ STDERR.puts "ERROR: Database error: #{e.class}: #{e.message}"
286
+ exit 1
287
+ end