openc3 7.0.0.pre.rc2 → 7.0.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +13 -4
  3. data/bin/pipinstall +6 -7
  4. data/bin/pipuninstall +3 -5
  5. data/data/config/interface_modifiers.yaml +1 -1
  6. data/data/config/item_modifiers.yaml +18 -6
  7. data/data/config/telemetry.yaml +1 -1
  8. data/data/config/widgets.yaml +10 -0
  9. data/lib/openc3/accessors/json_accessor.rb +1 -1
  10. data/lib/openc3/api/cmd_api.rb +2 -0
  11. data/lib/openc3/api/settings_api.rb +2 -0
  12. data/lib/openc3/api/tlm_api.rb +3 -3
  13. data/lib/openc3/config/config_parser.rb +4 -4
  14. data/lib/openc3/conversions/conversion.rb +3 -3
  15. data/lib/openc3/core_ext/faraday.rb +4 -0
  16. data/lib/openc3/logs/log_writer.rb +24 -6
  17. data/lib/openc3/logs/packet_log_writer.rb +1 -4
  18. data/lib/openc3/logs/stream_log_pair.rb +11 -4
  19. data/lib/openc3/logs/text_log_writer.rb +1 -4
  20. data/lib/openc3/microservices/interface_microservice.rb +8 -2
  21. data/lib/openc3/microservices/log_microservice.rb +7 -2
  22. data/lib/openc3/microservices/microservice.rb +10 -4
  23. data/lib/openc3/microservices/queue_microservice.rb +9 -2
  24. data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
  25. data/lib/openc3/microservices/text_log_microservice.rb +4 -1
  26. data/lib/openc3/migrations/20241208080000_no_critical_cmd.rb +1 -1
  27. data/lib/openc3/migrations/20250402000000_periodic_only_default.rb +1 -1
  28. data/lib/openc3/migrations/20260203000000_remove_store_id.rb +28 -0
  29. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +29 -1
  30. data/lib/openc3/models/activity_model.rb +41 -9
  31. data/lib/openc3/models/auth_model.rb +54 -19
  32. data/lib/openc3/models/cvt_model.rb +2 -265
  33. data/lib/openc3/models/model.rb +16 -0
  34. data/lib/openc3/models/plugin_model.rb +18 -12
  35. data/lib/openc3/models/plugin_store_model.rb +1 -1
  36. data/lib/openc3/models/python_package_model.rb +2 -2
  37. data/lib/openc3/models/queue_model.rb +5 -3
  38. data/lib/openc3/models/script_engine_model.rb +1 -1
  39. data/lib/openc3/models/target_model.rb +75 -42
  40. data/lib/openc3/models/tool_config_model.rb +12 -0
  41. data/lib/openc3/models/tool_model.rb +18 -5
  42. data/lib/openc3/models/trigger_model.rb +1 -1
  43. data/lib/openc3/models/widget_model.rb +2 -9
  44. data/lib/openc3/operators/operator.rb +9 -7
  45. data/lib/openc3/packets/json_packet.rb +2 -0
  46. data/lib/openc3/packets/packet.rb +1 -0
  47. data/lib/openc3/packets/packet_config.rb +28 -12
  48. data/lib/openc3/script/calendar.rb +8 -0
  49. data/lib/openc3/script/script.rb +19 -0
  50. data/lib/openc3/script/storage.rb +6 -6
  51. data/lib/openc3/script/web_socket_api.rb +1 -1
  52. data/lib/openc3/system/system.rb +6 -6
  53. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
  54. data/lib/openc3/top_level.rb +15 -63
  55. data/lib/openc3/topics/command_topic.rb +1 -0
  56. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  57. data/lib/openc3/utilities/authentication.rb +46 -7
  58. data/lib/openc3/utilities/authorization.rb +8 -1
  59. data/lib/openc3/utilities/aws_bucket.rb +2 -3
  60. data/lib/openc3/utilities/bucket_utilities.rb +3 -1
  61. data/lib/openc3/utilities/cli_generator.rb +7 -0
  62. data/lib/openc3/utilities/cmd_log.rb +1 -1
  63. data/lib/openc3/utilities/local_mode.rb +3 -0
  64. data/lib/openc3/utilities/process_manager.rb +1 -1
  65. data/lib/openc3/utilities/python_proxy.rb +11 -4
  66. data/lib/openc3/utilities/questdb_client.rb +764 -2
  67. data/lib/openc3/utilities/running_script.rb +25 -7
  68. data/lib/openc3/utilities/script.rb +452 -0
  69. data/lib/openc3/utilities/secrets.rb +1 -1
  70. data/lib/openc3/version.rb +5 -5
  71. data/templates/conversion/conversion.py +0 -8
  72. data/templates/conversion/conversion.rb +0 -11
  73. data/templates/tool_angular/package.json +2 -2
  74. data/templates/tool_react/package.json +1 -1
  75. data/templates/tool_svelte/package.json +1 -1
  76. data/templates/tool_vue/package.json +3 -3
  77. data/templates/widget/package.json +2 -2
  78. metadata +19 -19
  79. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
  80. data/lib/openc3/migrations/20251213120000_reinstall_plugins.rb +0 -45
@@ -16,6 +16,120 @@ require 'openc3/microservices/cleanup_microservice'
16
16
 
17
17
  module OpenC3
18
18
  class ScopeCleanupMicroservice < CleanupMicroservice
19
+ TSDB_HEALTH_QUERY =
20
+ "SELECT
21
+ table_name,
22
+ table_row_count,
23
+ wal_pending_row_count,
24
+ CASE
25
+ WHEN table_suspended THEN 'SUSPENDED'
26
+ WHEN table_memory_pressure_level = 2 THEN 'BACKOFF'
27
+ WHEN table_memory_pressure_level = 1 THEN 'PRESSURE'
28
+ ELSE 'OK'
29
+ END AS status,
30
+ wal_txn - table_txn AS lag_txns,
31
+ table_write_amp_p50 AS write_amp,
32
+ table_merge_rate_p99 AS slowest_merge
33
+ FROM tables()
34
+ WHERE walEnabled
35
+ ORDER BY
36
+ table_suspended DESC,
37
+ table_memory_pressure_level DESC,
38
+ wal_pending_row_count DESC;"
39
+
40
+ GROWTH_NUM_SAMPLE_PERIODS = 4
41
+
42
+ def initialize(*args)
43
+ super(*args)
44
+ @run_time = nil
45
+ @cleanup_poll_time = nil
46
+ @delta_time = 0.0
47
+ @wal_pending_row_count = {}
48
+ @lag_txns = {}
49
+ end
50
+
51
+ def cleanup(areas, bucket)
52
+ current_time = Time.now
53
+ if @run_time
54
+ delta = current_time - @run_time
55
+ if delta > 0.0
56
+ @delta_time += delta
57
+ end
58
+ end
59
+ @run_time = current_time
60
+ if @delta_time > @cleanup_poll_time
61
+ @delta_time = 0.0
62
+ super(areas, bucket)
63
+ end
64
+
65
+ # Always check TSDB health
66
+ if @scope == 'DEFAULT'
67
+ begin
68
+ conn = OpenC3::QuestDBClient.connection
69
+ result = conn.exec(TSDB_HEALTH_QUERY)
70
+ columns = result.fields
71
+ rows = result.values
72
+
73
+ table_name_column = columns.index("table_name")
74
+ wal_pending_row_count_column = columns.index("wal_pending_row_count")
75
+ status_column = columns.index("status")
76
+ lag_txns_column = columns.index("lag_txns")
77
+
78
+ rows.each do |values|
79
+ table_name = values[table_name_column]
80
+ wal_pending_row_count = values[wal_pending_row_count_column].to_i
81
+ status = values[status_column]
82
+ lag_txns = values[lag_txns_column].to_i
83
+
84
+ if status != 'OK'
85
+ @logger.error("QuestDB: #{table_name} in bad state: #{status}")
86
+
87
+ if status == 'SUSPENDED'
88
+ # Try to automatically unsuspend
89
+ @logger.info("QuestDB: Attempting to unsuspend: #{table_name}")
90
+ conn.exec("ALTER TABLE #{table_name} RESUME WAL;")
91
+ end
92
+ end
93
+
94
+ @wal_pending_row_count[table_name] ||= []
95
+ @wal_pending_row_count[table_name] << wal_pending_row_count
96
+ @lag_txns[table_name] ||= []
97
+ @lag_txns[table_name] << lag_txns
98
+
99
+ if @wal_pending_row_count[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
100
+ if detect_growth(@wal_pending_row_count[table_name], GROWTH_NUM_SAMPLE_PERIODS)
101
+ # Crossed threshold of sample periods of growth
102
+ @logger.error("QuestDB: #{table_name} has growing wal_pending_row_count: #{wal_pending_row_count}")
103
+ end
104
+
105
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
106
+ @wal_pending_row_count[table_name] = @wal_pending_row_count[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
107
+ end
108
+
109
+ if @lag_txns[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
110
+ if detect_growth(@lag_txns[table_name], GROWTH_NUM_SAMPLE_PERIODS)
111
+ # Crossed threshold of sample periods of growth
112
+ @logger.error("QuestDB: #{table_name} has growing lag_txns: #{lag_txns}")
113
+ end
114
+
115
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
116
+ @lag_txns[table_name] = @lag_txns[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
117
+ end
118
+ end
119
+ rescue => e
120
+ OpenC3::QuestDBClient.disconnect
121
+ @logger.error("QuestDB Error: #{e.formatted}")
122
+ end
123
+ end
124
+ end
125
+
126
+ def detect_growth(array, num_samples)
127
+ num_samples.times do |index|
128
+ return false if array[index + 1] <= array[index]
129
+ end
130
+ return true
131
+ end
132
+
19
133
  def get_areas_and_poll_time
20
134
  scope = ScopeModel.get_model(name: @scope)
21
135
  areas = [
@@ -28,7 +142,8 @@ module OpenC3
28
142
  areas << ["NOSCOPE/tool_logs/sr", scope.tool_log_retain_time]
29
143
  end
30
144
 
31
- return areas, scope.cleanup_poll_time
145
+ @cleanup_poll_time = scope.cleanup_poll_time
146
+ return areas, 60 # Run every 1 minute for TSDB checks
32
147
  end
33
148
  end
34
149
  end
@@ -98,13 +98,16 @@ module OpenC3
98
98
  def shutdown
99
99
  # Make sure all the existing logs are properly closed down
100
100
  threads = []
101
- @tlws.each do |topic, tlw|
101
+ @tlws.each do |_topic, tlw|
102
102
  threads.concat(tlw.shutdown)
103
103
  end
104
104
  # Wait for all the logging threads to move files to buckets
105
105
  threads.flatten.compact.each do |thread|
106
106
  thread.join
107
107
  end
108
+ @tlws.each do |_topic, tlw|
109
+ tlw.cleanup
110
+ end
108
111
  super()
109
112
  end
110
113
  end
@@ -13,7 +13,7 @@ module OpenC3
13
13
  end
14
14
 
15
15
  def self.run
16
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
16
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
17
17
  model = MicroserviceModel.get_model(name: "#{scope}__CRITICALCMD__#{scope}", scope: scope)
18
18
  if BASE # Only remove the critical command model if we're not enterprise
19
19
  model.destroy if model
@@ -5,7 +5,7 @@ require 'openc3/models/microservice_model'
5
5
  module OpenC3
6
6
  class PeriodicOnlyDefault < Migration
7
7
  def self.run
8
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
8
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
9
9
  next if scope == 'DEFAULT'
10
10
  model = MicroserviceModel.get_model(name: "#{scope}__SCOPEMULTI__#{scope}", scope: scope)
11
11
  if model
@@ -0,0 +1,28 @@
1
+ require 'openc3/utilities/migration'
2
+ require 'openc3/models/scope_model'
3
+ require 'openc3/models/plugin_model'
4
+
5
+ module OpenC3
6
+ # Removes the store_id property from plugin models. It got renamed to
7
+ # store_plugin_id in PR #2858 but that also depends on having
8
+ # store_version_id, which didn't exist prior to this version and can't
9
+ # be determined without some introspection on the plugin and querying
10
+ # the app store (online). When store_plugin_id is unset, COSMOS treats
11
+ # the plugin like it was installed from a gem file.
12
+ class RemoveStoreId < Migration
13
+ def self.run
14
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
15
+ plugin_models = PluginModel.all(scope: scope)
16
+ plugin_models.each do |_name, plugin_model|
17
+ plugin_model.delete("store_id")
18
+ model = PluginModel.from_json(plugin_model, scope: scope)
19
+ model.update()
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ unless ENV['OPENC3_NO_MIGRATE']
27
+ OpenC3::RemoveStoreId.run
28
+ end
@@ -10,17 +10,21 @@
10
10
  # if purchased from OpenC3, Inc.
11
11
 
12
12
  require 'openc3/utilities/migration'
13
+ require 'openc3/utilities/bucket'
13
14
  require 'openc3/models/scope_model'
14
15
  require 'openc3/models/target_model'
15
16
  require 'openc3/models/microservice_model'
17
+ require 'openc3/models/plugin_model'
16
18
 
17
19
  module OpenC3
18
20
  class RemoveDecomLogSettings < Migration
19
21
  def self.run
20
- ScopeModel.get_all_models(scope: nil).each do |scope, scope_model|
22
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
21
23
  target_models = TargetModel.all(scope: scope)
22
24
  target_models.each do |name, target_model|
23
25
  # Remove deprecated decom log settings from target model
26
+ target_model.delete("cmd_unique_id_mode")
27
+ target_model.delete("tlm_unique_id_mode")
24
28
  target_model.delete("cmd_decom_log_cycle_time")
25
29
  target_model.delete("cmd_decom_log_cycle_size")
26
30
  target_model.delete("cmd_decom_log_retain_time")
@@ -51,6 +55,30 @@ module OpenC3
51
55
  end
52
56
  end
53
57
  end
58
+
59
+ # Reinstall all plugins to regenerate microservice configs with correct settings
60
+ # This must happen after removing deprecated keys above
61
+ client = Bucket.getClient()
62
+ unless client.exist?(ENV['OPENC3_CONFIG_BUCKET']) && client.exist?(ENV['OPENC3_TOOLS_BUCKET'])
63
+ Logger.info("Skipping plugin reinstall - buckets do not exist yet (fresh install or new storage backend)")
64
+ return
65
+ end
66
+
67
+ ScopeModel.get_all_models(scope: nil).each do |scope, _scope_model|
68
+ plugins = PluginModel.all(scope: scope)
69
+ plugins.each do |plugin_name, plugin_data|
70
+ begin
71
+ Logger.info("Reinstalling plugin #{plugin_name} in scope #{scope}")
72
+ plugin_model = PluginModel.from_json(plugin_data, scope: scope)
73
+ plugin_model.undeploy
74
+ plugin_model.restore
75
+ Logger.info("Successfully reinstalled plugin #{plugin_name} in scope #{scope}")
76
+ rescue Exception => e
77
+ Logger.error("Error reinstalling plugin #{plugin_name} in scope #{scope}: #{e.formatted}")
78
+ # Continue with other plugins even if one fails
79
+ end
80
+ end
81
+ end
54
82
  end
55
83
  end
56
84
  end
@@ -18,6 +18,7 @@
18
18
  # https://www.rubydoc.info/gems/redis/Redis/Commands/SortedSets
19
19
 
20
20
  require 'openc3/models/model'
21
+ require 'openc3/models/timeline_model'
21
22
  require 'openc3/topics/timeline_topic'
22
23
  require 'securerandom'
23
24
 
@@ -28,6 +29,11 @@ module OpenC3
28
29
 
29
30
  class ActivityModel < Model
30
31
  MAX_DURATION = Time::SEC_PER_DAY
32
+ # Grace window (in seconds) to allow creating activities slightly in the past.
33
+ # This handles race conditions where real-time activity notifications arrive
34
+ # after the start time has already passed (e.g. from external systems).
35
+ # This is consistent with the -15 second window in the timeline microservice.
36
+ START_GRACE_SECONDS = 15
31
37
  PRIMARY_KEY = '__openc3_timelines'.freeze # MUST be equal to `TimelineModel::PRIMARY_KEY` minus the leading __
32
38
  # See run_activity(activity) in openc3/lib/openc3/microservices/timeline_microservice.rb
33
39
  VALID_KINDS = %w(command script reserve expire)
@@ -212,7 +218,7 @@ module OpenC3
212
218
  end
213
219
 
214
220
  # validate the input to the rules we have created for timelines.
215
- # - A task's start MUST NOT be in the past.
221
+ # - A task's start MUST NOT be more than START_GRACE_SECONDS in the past.
216
222
  # - A task's start MUST be before the stop.
217
223
  # - A task CAN NOT be longer than MAX_DURATION (86400) in seconds.
218
224
  # - A task MUST have a kind.
@@ -230,8 +236,8 @@ module OpenC3
230
236
  rescue NoMethodError
231
237
  raise ActivityInputError.new "start and stop must be seconds: #{start}, #{stop}"
232
238
  end
233
- if now_f >= start and kind != 'expire'
234
- raise ActivityInputError.new "activity must be in the future, current_time: #{now_f} vs #{start}"
239
+ if now_f >= start + START_GRACE_SECONDS and kind != 'expire'
240
+ raise ActivityInputError.new "activity must not be more than #{START_GRACE_SECONDS} seconds in the past, current_time: #{now_f} vs #{start}"
235
241
  elsif duration > MAX_DURATION and kind != 'expire'
236
242
  raise ActivityInputError.new "activity can not be longer than #{MAX_DURATION} seconds"
237
243
  elsif duration <= 0
@@ -261,7 +267,13 @@ module OpenC3
261
267
 
262
268
  # Update the Redis hash at primary_key and set the score equal to the start Epoch time
263
269
  # the member is set to the JSON generated via calling as_json
264
- def create(overlap: true)
270
+ def create(overlap: true, username: nil)
271
+ # Validate that the timeline exists in this scope before creating activities.
272
+ # Activities must be attached to an existing timeline within the same scope.
273
+ unless TimelineModel.get(name: @name, scope: @scope)
274
+ raise ActivityError.new "timeline '#{@name}' does not exist in scope '#{@scope}'"
275
+ end
276
+
265
277
  if @recurring['end'] and @recurring['frequency'] and @recurring['span']
266
278
  # First validate the initial recurring activity ... all others are just offsets
267
279
  validate_input(start: @start, stop: @stop, kind: @kind, data: @data)
@@ -290,7 +302,7 @@ module OpenC3
290
302
 
291
303
  # Update @updated_at and add an event assuming it all completes ok
292
304
  @updated_at = Time.now.to_nsec_from_epoch
293
- add_event(status: 'created')
305
+ add_event(status: 'created', username: username)
294
306
 
295
307
  Store.multi do |multi|
296
308
  (@start..@recurring['end']).step(recurrence).each do |start_time|
@@ -326,7 +338,7 @@ module OpenC3
326
338
  end
327
339
  end
328
340
  @updated_at = Time.now.to_nsec_from_epoch
329
- add_event(status: 'created')
341
+ add_event(status: 'created', username: username)
330
342
  Store.zadd(@primary_key, @start, JSON.generate(self.as_json, allow_nan: true))
331
343
  notify(kind: 'created')
332
344
  end
@@ -335,7 +347,7 @@ module OpenC3
335
347
  # Update the Redis hash at primary_key and remove the current activity at the current score
336
348
  # and update the score to the new score equal to the start Epoch time this uses a multi
337
349
  # to execute both the remove and create.
338
- def update(start:, stop:, kind:, data:, overlap: true)
350
+ def update(start:, stop:, kind:, data:, overlap: true, username: nil)
339
351
  array = Store.zrangebyscore(@primary_key, @start, @start)
340
352
  if array.length == 0
341
353
  raise ActivityError.new "failed to find activity at: #{@start}"
@@ -350,10 +362,22 @@ module OpenC3
350
362
  raise ActivityOverlapError.new "failed to update #{old_start}, no activities can overlap, collision: #{collision}"
351
363
  end
352
364
  end
365
+
366
+ # Compute changeset for audit trail before applying changes
367
+ changes = {}
368
+ diff_field(changes, 'start', @start, start)
369
+ diff_field(changes, 'stop', @stop, stop)
370
+ diff_field(changes, 'kind', @kind, kind)
371
+ old_data = @data.reject { |k, _| k == 'username' }
372
+ new_data = data.reject { |k, _| k == 'username' }
373
+ (old_data.keys | new_data.keys).each do |key|
374
+ diff_field(changes, "data.#{key}", old_data[key], new_data[key])
375
+ end
376
+
353
377
  set_input(start: start, stop: stop, kind: kind, data: data, events: @events)
354
378
  @updated_at = Time.now.to_nsec_from_epoch
355
379
 
356
- add_event(status: 'updated')
380
+ add_event(status: 'updated', username: username, changes: changes.empty? ? nil : changes)
357
381
  json = Store.zrangebyscore(@primary_key, old_start, old_start)
358
382
  parsed = json.map { |value| JSON.parse(value, allow_nan: true, create_additions: true) }
359
383
  parsed.each_with_index do |value, index|
@@ -397,14 +421,22 @@ module OpenC3
397
421
 
398
422
  # add_event will make an event. This will NOT save the object to the redis database
399
423
  # @param [String] status - the event status such as "queued" or "updated" or "created"
400
- def add_event(status:)
424
+ # @param [String] username - optional username of who performed the action
425
+ # @param [Hash] changes - optional hash describing what fields changed (for audit)
426
+ def add_event(status:, username: nil, changes: nil)
401
427
  event = {
402
428
  'time' => Time.now.to_i,
403
429
  'event' => status
404
430
  }
431
+ event['username'] = username if username
432
+ event['changes'] = changes if changes
405
433
  @events << event
406
434
  end
407
435
 
436
+ def diff_field(changes, field, old_val, new_val)
437
+ changes[field] = {'old' => old_val, 'new' => new_val} unless old_val == new_val
438
+ end
439
+
408
440
  # update the redis stream / timeline topic that something has changed
409
441
  def notify(kind:, extra: nil)
410
442
  notification = {
@@ -41,6 +41,9 @@ module OpenC3
41
41
 
42
42
  MIN_PASSWORD_LENGTH = 8
43
43
 
44
+ SESSION_PREFIX = "ses_"
45
+ OTP_PREFIX = "otp_"
46
+
44
47
  def self.set?(key = PRIMARY_KEY)
45
48
  Store.exists(key) == 1
46
49
  end
@@ -58,36 +61,49 @@ module OpenC3
58
61
 
59
62
  return false if service_only
60
63
 
61
- return verify_no_service(token, no_password: no_password)
64
+ mode = no_password ? :token : :any
65
+ return verify_no_service(token, mode: mode)
62
66
  end
63
67
 
64
68
  # Checks whether the provided token is a valid user password or session token.
65
69
  # @param token [String] the plaintext password or session token to check (required)
66
- # @param no_password [Boolean] enforces use of a session token (default: true)
70
+ # @param mode [String] optionally restrict verification to just the password or token. Valid values: :password, :token, or :any (default :token)
67
71
  # @return [Boolean] whether the provided password/token is valid
68
- def self.verify_no_service(token, no_password: true)
72
+ def self.verify_no_service(token, mode: :token)
73
+ modes = [:password, :token, :any]
74
+ raise ArgumentError, "Invalid mode '#{mode}': must be one of #{modes}" unless modes.include?(mode)
75
+
69
76
  return false if token.nil? or token.empty?
70
77
 
71
78
  # Check cached session tokens and password hash
72
79
  time = Time.now
73
- return true if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
74
- unless no_password
75
- return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
80
+ unless mode == :password
81
+ if @@session_cache and (time - @@session_cache_time) < SESSION_CACHE_TIMEOUT and @@session_cache[token]
82
+ terminate_otp(token)
83
+ return true
84
+ end
85
+
86
+ # Check stored session tokens
87
+ @@session_cache = Store.hgetall(SESSIONS_KEY)
88
+ @@session_cache_time = time
89
+ if @@session_cache[token]
90
+ terminate_otp(token)
91
+ return true
92
+ end
76
93
  end
77
94
 
78
- # Check stored session tokens
79
- @@session_cache = Store.hgetall(SESSIONS_KEY)
80
- @@session_cache_time = time
81
- return true if @@session_cache[token]
95
+ unless mode == :token
96
+ return true if @@pw_hash_cache and (time - @@pw_hash_cache_time) < PW_HASH_CACHE_TIMEOUT and Argon2::Password.verify_password(token, @@pw_hash_cache)
82
97
 
83
- return false if no_password
98
+ # Check stored password hash
99
+ pw_hash = Store.get(PRIMARY_KEY)
100
+ raise "invalid password hash" if pw_hash.nil? || !pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
101
+ @@pw_hash_cache = pw_hash
102
+ @@pw_hash_cache_time = time
103
+ return true if Argon2::Password.verify_password(token, @@pw_hash_cache)
104
+ end
84
105
 
85
- # Check stored password hash
86
- pw_hash = Store.get(PRIMARY_KEY)
87
- raise "invalid password hash" unless pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
88
- @@pw_hash_cache = pw_hash
89
- @@pw_hash_cache_time = time
90
- return Argon2::Password.verify_password(token, @@pw_hash_cache)
106
+ return false
91
107
  end
92
108
 
93
109
  def self.set(password, old_password, key = PRIMARY_KEY)
@@ -96,17 +112,25 @@ module OpenC3
96
112
 
97
113
  if set?(key)
98
114
  raise "old_password must not be nil or empty" if old_password.nil? or old_password.empty?
99
- raise "old_password incorrect" unless verify_no_service(old_password, no_password: false)
115
+ raise "old_password incorrect" unless verify_no_service(old_password, mode: :password)
100
116
  end
101
117
  pw_hash = Argon2::Password.create(password, profile: ARGON2_PROFILE)
102
118
  Store.set(key, pw_hash)
103
119
  @@pw_hash_cache = nil
104
120
  @@pw_hash_cache_time = nil
121
+ logout
105
122
  end
106
123
 
107
124
  # Creates a new session token. DO NOT CALL BEFORE VERIFYING.
108
- def self.generate_session
125
+ # @param otp [Boolean] whether to create a one-time use token (default: false)
126
+ # @return [String] the new session token
127
+ def self.generate_session(otp: false)
109
128
  token = SecureRandom.urlsafe_base64(nil, false)
129
+ if otp
130
+ token = OTP_PREFIX + token
131
+ else
132
+ token = SESSION_PREFIX + token
133
+ end
110
134
  Store.hset(SESSIONS_KEY, token, Time.now.iso8601)
111
135
  return token
112
136
  end
@@ -117,5 +141,16 @@ module OpenC3
117
141
  @@session_cache = nil
118
142
  @@session_cache_time = nil
119
143
  end
144
+
145
+ # Terminates the given session token.
146
+ def self.terminate(token)
147
+ Store.hdel(SESSIONS_KEY, token)
148
+ @@session_cache.delete(token) if @@session_cache
149
+ end
150
+
151
+ # Terminates the session if the token is an OTP.
152
+ def self.terminate_otp(token)
153
+ terminate(token) if token.start_with?(OTP_PREFIX)
154
+ end
120
155
  end
121
156
  end