aikido-zen 0.1.0.alpha4-x86_64-linux → 0.1.1-x86_64-linux

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +19 -0
  3. data/CHANGELOG.md +16 -0
  4. data/README.md +136 -23
  5. data/Rakefile +4 -0
  6. data/benchmarks/README.md +27 -0
  7. data/benchmarks/rails7.1_sql_injection.js +74 -0
  8. data/docs/banner.svg +203 -0
  9. data/docs/config.md +123 -0
  10. data/docs/rails.md +70 -0
  11. data/lib/aikido/zen/actor.rb +1 -1
  12. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  13. data/lib/aikido/zen/agent.rb +100 -112
  14. data/lib/aikido/zen/collector/hosts.rb +15 -0
  15. data/lib/aikido/zen/collector/routes.rb +64 -0
  16. data/lib/aikido/zen/{stats → collector}/sink_stats.rb +1 -1
  17. data/lib/aikido/zen/collector/stats.rb +111 -0
  18. data/lib/aikido/zen/{stats → collector}/users.rb +6 -2
  19. data/lib/aikido/zen/collector.rb +117 -0
  20. data/lib/aikido/zen/config.rb +17 -11
  21. data/lib/aikido/zen/context.rb +8 -1
  22. data/lib/aikido/zen/errors.rb +3 -1
  23. data/lib/aikido/zen/event.rb +7 -4
  24. data/lib/aikido/zen/internals.rb +4 -0
  25. data/lib/aikido/zen/libzen-v0.1.31.x86_64.so +0 -0
  26. data/lib/aikido/zen/middleware/set_context.rb +4 -1
  27. data/lib/aikido/zen/rails_engine.rb +27 -18
  28. data/lib/aikido/zen/request/schema/builder.rb +0 -2
  29. data/lib/aikido/zen/request.rb +6 -0
  30. data/lib/aikido/zen/runtime_settings.rb +6 -11
  31. data/lib/aikido/zen/scanners/ssrf_scanner.rb +12 -6
  32. data/lib/aikido/zen/sinks/action_controller.rb +64 -0
  33. data/lib/aikido/zen/sinks/http.rb +1 -1
  34. data/lib/aikido/zen/sinks/pg.rb +13 -12
  35. data/lib/aikido/zen/sinks/typhoeus.rb +1 -1
  36. data/lib/aikido/zen/sinks.rb +1 -0
  37. data/lib/aikido/zen/version.rb +2 -2
  38. data/lib/aikido/zen/worker.rb +82 -0
  39. data/lib/aikido/zen.rb +55 -50
  40. data/tasklib/bench.rake +70 -0
  41. metadata +20 -9
  42. data/CODE_OF_CONDUCT.md +0 -132
  43. data/lib/aikido/zen/libzen-v0.1.26.x86_64.so +0 -0
  44. data/lib/aikido/zen/stats/routes.rb +0 -53
  45. data/lib/aikido/zen/stats.rb +0 -171
@@ -8,6 +8,13 @@ require_relative "context"
8
8
 
9
9
  module Aikido::Zen
10
10
  class Config
11
+ # @return [Boolean] whether Aikido should be turned completely off (no
12
+ # intercepting calls to protect the app, no agent process running, no
13
+ # middleware installed). Defaults to false (so, enabled). Can be set
14
+ # via the AIKIDO_DISABLED environment variable.
15
+ attr_accessor :disabled
16
+ alias_method :disabled?, :disabled
17
+
11
18
  # @return [Boolean] whether Aikido should only report infractions or block
12
19
  # the request by raising an Exception. Defaults to whether AIKIDO_BLOCKING
13
20
  # is set to a non-empty value in your environment, or +false+ otherwise.
@@ -45,7 +52,7 @@ module Aikido::Zen
45
52
  # into an Object. Defaults to the standard library's JSON.parse method.
46
53
  attr_accessor :json_decoder
47
54
 
48
- # @returns [Logger]
55
+ # @return [Logger]
49
56
  attr_accessor :logger
50
57
 
51
58
  # @return [Integer] maximum number of timing measurements to keep in memory
@@ -83,11 +90,9 @@ module Aikido::Zen
83
90
  # differentiate different clients. By default this uses the request IP.
84
91
  attr_accessor :rate_limiting_discriminator
85
92
 
86
- # @return [Boolean] whether Zen should infer the schema from request bodies
87
- # sent to the app. Defaults to +false+ or the value of the environment
88
- # variable AIKIDO_FEATURE_COLLECT_API_SCHEMA.
89
- attr_accessor :api_schema_collection_enabled
90
- alias_method :api_schema_collection_enabled?, :api_schema_collection_enabled
93
+ # @return [Integer] max number of requests we sample per endpoint when
94
+ # computing the schema.
95
+ attr_accessor :api_schema_max_samples
91
96
 
92
97
  # @api private
93
98
  # @return [Integer] max number of levels deep we want to read a nested
@@ -125,7 +130,8 @@ module Aikido::Zen
125
130
  attr_accessor :imds_allowed_hosts
126
131
 
127
132
  def initialize
128
- self.blocking_mode = !!ENV.fetch("AIKIDO_BLOCKING", false)
133
+ self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
134
+ self.blocking_mode = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCKING", false))
129
135
  self.api_timeouts = 10
130
136
  self.api_base_url = ENV.fetch("AIKIDO_BASE_URL", DEFAULT_API_BASE_URL)
131
137
  self.runtime_api_base_url = ENV.fetch("AIKIDO_RUNTIME_URL", DEFAULT_RUNTIME_BASE_URL)
@@ -146,11 +152,9 @@ module Aikido::Zen
146
152
  self.server_rate_limit_deadline = 1800 # 30 min
147
153
  self.client_rate_limit_period = 3600 # 1 hour
148
154
  self.client_rate_limit_max_events = 100
149
-
150
- self.api_schema_collection_enabled = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", false))
155
+ self.api_schema_max_samples = Integer(ENV.fetch("AIKIDO_MAX_API_DISCOVERY_SAMPLES", 10))
151
156
  self.api_schema_collection_max_depth = 20
152
157
  self.api_schema_collection_max_properties = 20
153
-
154
158
  self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
155
159
  end
156
160
 
@@ -224,6 +228,8 @@ module Aikido::Zen
224
228
  end
225
229
 
226
230
  # @!visibility private
227
- DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) { request.ip }
231
+ DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) {
232
+ request.actor ? "actor:#{request.actor.id}" : request.ip
233
+ }
228
234
  end
229
235
  end
@@ -7,13 +7,20 @@ require_relative "payload"
7
7
 
8
8
  module Aikido::Zen
9
9
  class Context
10
+ # Build a Context object for the current HTTP request based on the currently
11
+ # configured request builder.
12
+ #
13
+ # @param env [Hash] the Rack env hash.
14
+ # @param config [Aikido::Zen::Config]
15
+ # @return [Aikido::Zen::Context]
10
16
  def self.from_rack_env(env, config = Aikido::Zen.config)
11
17
  config.request_builder.call(env)
12
18
  end
13
19
 
20
+ # @return [Aikido::Zen::Request]
14
21
  attr_reader :request
15
22
 
16
- # @param [Rack::Request] a Request object that implements the
23
+ # @param request [Rack::Request] a Request object that implements the
17
24
  # Rack::Request API, to which we will delegate behavior.
18
25
  # @param settings [Aikido::Zen::RuntimeSettings]
19
26
  #
@@ -3,11 +3,13 @@
3
3
  require "forwardable"
4
4
 
5
5
  module Aikido
6
+ # @!visibility private
6
7
  # Support rescuing Aikido::Error without forcing a single base class to all
7
8
  # errors (so things that should be e.g. a TypeError, can have the correct
8
9
  # superclass).
9
- module Error; end
10
+ module Error; end # :nodoc:
10
11
 
12
+ # @!visibility private
11
13
  # Generic error for problems with the Agent.
12
14
  class ZenError < RuntimeError
13
15
  include Error
@@ -48,17 +48,20 @@ module Aikido::Zen
48
48
  end
49
49
 
50
50
  class Heartbeat < Event
51
- def initialize(stats:, **opts)
51
+ def initialize(stats:, users:, hosts:, routes:, **opts)
52
52
  super(type: "heartbeat", **opts)
53
53
  @stats = stats
54
+ @users = users
55
+ @hosts = hosts
56
+ @routes = routes
54
57
  end
55
58
 
56
59
  def as_json
57
60
  super.update(
58
61
  stats: @stats.as_json,
59
- routes: @stats.routes.as_json,
60
- hostnames: @stats.outbound_connections.as_json,
61
- users: @stats.users.as_json
62
+ users: @users.as_json,
63
+ routes: @routes.as_json,
64
+ hostnames: @hosts.as_json
62
65
  )
63
66
  end
64
67
  end
@@ -31,6 +31,8 @@ module Aikido::Zen
31
31
  attach_function :detect_sql_injection_native, :detect_sql_injection,
32
32
  [:string, :string, :int], :int
33
33
  rescue LoadError, FFI::NotFoundError => err
34
+ # :nocov:
35
+
34
36
  # Emit an $stderr warning at startup.
35
37
  warn "Zen could not load its binary extension #{libzen_name}: #{err}"
36
38
 
@@ -38,6 +40,8 @@ module Aikido::Zen
38
40
  attempt = format("%p for SQL injection", query)
39
41
  raise InternalsError.new(attempt, "loading", Internals.libzen_name)
40
42
  end
43
+
44
+ # :nocov:
41
45
  else
42
46
  # Analyzes the SQL query to detect if the provided user input is being
43
47
  # passed as-is without escaping.
@@ -3,6 +3,9 @@
3
3
  require_relative "../context"
4
4
 
5
5
  module Aikido::Zen
6
+ # @!visibility private
7
+ ENV_KEY = "aikido.context"
8
+
6
9
  module Middleware
7
10
  # Rack middleware that keeps the current context in a Thread/Fiber-local
8
11
  # variable so that other parts of the agent/firewall can access it.
@@ -14,7 +17,7 @@ module Aikido::Zen
14
17
  def call(env)
15
18
  context = Context.from_rack_env(env)
16
19
 
17
- Aikido::Zen.current_context = context
20
+ Aikido::Zen.current_context = env[ENV_KEY] = context
18
21
  Aikido::Zen.track_request(context.request)
19
22
 
20
23
  @app.call(env)
@@ -5,44 +5,53 @@ require "action_dispatch"
5
5
  module Aikido::Zen
6
6
  class RailsEngine < ::Rails::Engine
7
7
  config.before_configuration do
8
- # Access library configuration at `Rails.application.config.aikido_zen`.
9
- config.aikido_zen = Aikido::Zen.config
8
+ # Access library configuration at `Rails.application.config.zen`.
9
+ config.zen = Aikido::Zen.config
10
10
  end
11
11
 
12
12
  initializer "aikido.add_middleware" do |app|
13
+ next if config.zen.disabled?
14
+
13
15
  app.middleware.use Aikido::Zen::Middleware::SetContext
14
16
  app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
15
- app.middleware.use Aikido::Zen::Middleware::Throttler
16
-
17
- # Due to how Rails sets up its middleware chain, the routing is evaluated
18
- # (and the Request object constructed) in the app that terminates the
19
- # chain, so no amount of middleware will be able to access it.
20
- #
21
- # This way, we overwrite the Request object as early as we can in the
22
- # request handling, so that by the time we start evaluating inputs, we
23
- # have assigned the request correctly.
17
+
24
18
  ActiveSupport.on_load(:action_controller) do
19
+ # Due to how Rails sets up its middleware chain, the routing is evaluated
20
+ # (and the Request object constructed) in the app that terminates the
21
+ # chain, so no amount of middleware will be able to access it.
22
+ #
23
+ # This way, we overwrite the Request object as early as we can in the
24
+ # request handling, so that by the time we start evaluating inputs, we
25
+ # have assigned the request correctly.
25
26
  before_action { Aikido::Zen.current_context.update_request(request) }
26
27
  end
27
28
  end
28
29
 
29
30
  initializer "aikido.configuration" do |app|
30
- app.config.aikido_zen.logger = ::Rails.logger.tagged("aikido")
31
- app.config.aikido_zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
31
+ # Allow the logger to be configured before checking if disabled? so we can
32
+ # let the user know that the agent is disabled.
33
+ app.config.zen.logger = ::Rails.logger.tagged("aikido")
34
+
35
+ next if config.zen.disabled?
36
+
37
+ app.config.zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
32
38
 
33
39
  # Plug Rails' JSON encoder/decoder, but only if the user hasn't changed
34
40
  # them for something else.
35
- if app.config.aikido_zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
36
- app.config.aikido_zen.json_encoder = ActiveSupport::JSON.method(:encode)
41
+ if app.config.zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
42
+ app.config.zen.json_encoder = ActiveSupport::JSON.method(:encode)
37
43
  end
38
44
 
39
- if app.config.aikido_zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
40
- app.config.aikido_zen.json_decoder = ActiveSupport::JSON.method(:decode)
45
+ if app.config.zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
46
+ app.config.zen.json_decoder = ActiveSupport::JSON.method(:decode)
41
47
  end
42
48
  end
43
49
 
44
50
  config.after_initialize do
45
- Aikido::Zen.initialize!
51
+ if config.zen.disabled?
52
+ config.zen.logger.warn("Zen has been disabled and will not run.")
53
+ next
54
+ end
46
55
 
47
56
  # Make sure this is run at the end of the initialization process, so
48
57
  # that any gems required after aikido-zen are detected and patched
@@ -16,8 +16,6 @@ module Aikido::Zen
16
16
  end
17
17
 
18
18
  def schema
19
- return unless @config.api_schema_collection_enabled?
20
-
21
19
  Request::Schema.new(
22
20
  content_type: body_data_type,
23
21
  body_schema: body_schema,
@@ -11,6 +11,12 @@ module Aikido::Zen
11
11
  # @return [Aikido::Zen::Router]
12
12
  attr_reader :router
13
13
 
14
+ # The current user, if set by the host app.
15
+ #
16
+ # @return [Aikido::Zen::Actor, nil]
17
+ # @see Aikido::Zen.track_user
18
+ attr_accessor :actor
19
+
14
20
  def initialize(delegate, framework:, router:)
15
21
  super(delegate)
16
22
  @framework = framework
@@ -11,16 +11,11 @@ module Aikido::Zen
11
11
  #
12
12
  # You can subscribe to changes with +#add_observer(object, func_name)+, which
13
13
  # will call the function passing the settings as an argument.
14
- class RuntimeSettings < Concurrent::MutableStruct.new(
15
- :updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats
16
- )
17
- include Concurrent::Concern::Observable
18
-
14
+ RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats) do
19
15
  def initialize(*)
20
- self.observers = Concurrent::Collection::CopyOnWriteObserverSet.new
21
16
  super
22
- self.endpoints ||= Endpoints.new
23
- self.skip_protection_for_ips ||= IPSet.new
17
+ self.endpoints ||= RuntimeSettings::Endpoints.new
18
+ self.skip_protection_for_ips ||= RuntimeSettings::IPSet.new
24
19
  end
25
20
 
26
21
  # @!attribute [rw] updated_at
@@ -56,12 +51,12 @@ module Aikido::Zen
56
51
 
57
52
  self.updated_at = Time.at(data["configUpdatedAt"].to_i / 1000)
58
53
  self.heartbeat_interval = (data["heartbeatIntervalInMS"].to_i / 1000)
59
- self.endpoints = Endpoints.from_json(data["endpoints"])
54
+ self.endpoints = RuntimeSettings::Endpoints.from_json(data["endpoints"])
60
55
  self.blocked_user_ids = data["blockedUserIds"]
61
- self.skip_protection_for_ips = IPSet.from_json(data["allowedIPAddresses"])
56
+ self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
62
57
  self.received_any_stats = data["receivedAnyStats"]
63
58
 
64
- observers.notify_observers(self) if updated_at != last_updated_at
59
+ Aikido::Zen.agent.updated_settings! if updated_at != last_updated_at
65
60
  end
66
61
  end
67
62
  end
@@ -112,7 +112,8 @@ module Aikido::Zen
112
112
  is_port_relevant = input_uri.port != input_uri.default_port
113
113
  return false if is_port_relevant && input_uri.port != conn_uri.port
114
114
 
115
- conn_uri.hostname == input_uri.hostname
115
+ conn_uri.hostname == input_uri.hostname &&
116
+ conn_uri.port == input_uri.port
116
117
  end
117
118
 
118
119
  def private_ip?(hostname)
@@ -128,8 +129,11 @@ module Aikido::Zen
128
129
  # * The input itself, if it already looks like a URI.
129
130
  # * The input prefixed with http://
130
131
  # * The input prefixed with https://
132
+ # * The input prefixed with the scheme of the request's URI, to consider
133
+ # things like an FTP request (to "ftp://localhost") with a plain host
134
+ # as a user-input ("localhost").
131
135
  #
132
- # @return [Set<URI>]
136
+ # @return [Array<URI>] a list of unique URIs based on the above criteria.
133
137
  def uris_from_input
134
138
  input = @input.to_s
135
139
 
@@ -138,10 +142,12 @@ module Aikido::Zen
138
142
  # valid hostname. We should do the same for the input.
139
143
  input = format("[%s]", input) if unescaped_ipv6?(input)
140
144
 
141
- [input, "http://#{input}", "https://#{input}"]
142
- .map { |candidate| as_uri(candidate) }
143
- .compact
144
- .uniq
145
+ [
146
+ input,
147
+ "http://#{input}",
148
+ "https://#{input}",
149
+ "#{@request_uri.scheme}://#{input}"
150
+ ].map { |candidate| as_uri(candidate) }.compact.uniq
145
151
  end
146
152
 
147
153
  def as_uri(string)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Sinks
5
+ module ActionController
6
+ # Implements the "middleware" for rate limiting in Rails apps, where we
7
+ # need to check at the end of the `before_action` chain, rather than in
8
+ # an actual Rack middleware, to allow for calls to Zen.track_user being
9
+ # made from before_actions in the host app, thus allowing rate-limiting
10
+ # by user ID rather than solely by IP.
11
+ class Throttler
12
+ def initialize(
13
+ config: Aikido::Zen.config,
14
+ settings: Aikido::Zen.runtime_settings,
15
+ rate_limiter: Aikido::Zen::RateLimiter.new
16
+ )
17
+ @config = config
18
+ @settings = settings
19
+ @rate_limiter = rate_limiter
20
+ end
21
+
22
+ def throttle(controller)
23
+ context = controller.request.env[Aikido::Zen::ENV_KEY]
24
+ request = context.request
25
+
26
+ if should_throttle?(request)
27
+ status, headers, body = @config.rate_limited_responder.call(request)
28
+ controller.headers.update(headers)
29
+ controller.render plain: Array(body).join, status: status
30
+
31
+ return true
32
+ end
33
+
34
+ false
35
+ end
36
+
37
+ private def should_throttle?(request)
38
+ return false if @settings.skip_protection_for_ips.include?(request.ip)
39
+
40
+ @rate_limiter.throttle?(request)
41
+ end
42
+ end
43
+
44
+ def self.throttler
45
+ @throttler ||= Aikido::Zen::Sinks::ActionController::Throttler.new
46
+ end
47
+
48
+ module Extensions
49
+ def run_callbacks(kind, *)
50
+ return super unless kind == :process_action
51
+
52
+ super do
53
+ rate_limiter = Aikido::Zen::Sinks::ActionController.throttler
54
+ throttled = rate_limiter.throttle(self)
55
+
56
+ yield if block_given? && !throttled
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ ::AbstractController::Callbacks.prepend(Aikido::Zen::Sinks::ActionController::Extensions)
@@ -66,7 +66,7 @@ module Aikido::Zen
66
66
 
67
67
  response
68
68
  ensure
69
- context["ssrf.request"] = prev_request
69
+ context["ssrf.request"] = prev_request if context
70
70
  end
71
71
  end
72
72
  end
@@ -7,6 +7,17 @@ module Aikido::Zen
7
7
  module PG
8
8
  SINK = Sinks.add("pg", scanners: [Scanners::SQLInjectionScanner])
9
9
 
10
+ # For some reason, the ActiveRecord pg adapter does not wrap exceptions in
11
+ # StatementInvalid, which leads to inconsistent handling. This guarantees
12
+ # that all Zen errors are wrapped in a StatementInvalid, so documentation
13
+ # can be consistent.
14
+ WRAP_EXCEPTIONS = if defined?(ActiveRecord::StatementInvalid)
15
+ <<~RUBY
16
+ rescue Aikido::Zen::SQLInjectionError
17
+ raise ActiveRecord::StatementInvalid
18
+ RUBY
19
+ end
20
+
10
21
  module Extensions
11
22
  %i[
12
23
  send_query exec sync_exec async_exec
@@ -16,12 +27,7 @@ module Aikido::Zen
16
27
  def #{method}(query, *)
17
28
  SINK.scan(query: query, dialect: :postgresql, operation: :#{method})
18
29
  super
19
- rescue Aikido::Zen::SQLInjectionError
20
- # The pg adapter does not wrap exceptions in StatementInvalid, which
21
- # leads to inconsistent handling. This guarantees that all Aikido
22
- # errors are wrapped in a StatementInvalid, so documentation can be
23
- # consistent.
24
- raise ActiveRecord::StatementInvalid
30
+ #{WRAP_EXCEPTIONS}
25
31
  end
26
32
  RUBY
27
33
  end
@@ -33,12 +39,7 @@ module Aikido::Zen
33
39
  def #{method}(_, query, *)
34
40
  SINK.scan(query: query, dialect: :postgresql, operation: :#{method})
35
41
  super
36
- rescue Aikido::Zen::SQLInjectionError
37
- # The pg adapter does not wrap exceptions in StatementInvalid, which
38
- # leads to inconsistent handling. This guarantees that all Aikido
39
- # errors are wrapped in a StatementInvalid, so documentation can be
40
- # consistent.
41
- raise ActiveRecord::StatementInvalid
42
+ #{WRAP_EXCEPTIONS}
42
43
  end
43
44
  RUBY
44
45
  end
@@ -66,7 +66,7 @@ module Aikido::Zen
66
66
  operation: "request"
67
67
  )
68
68
  ensure
69
- context["ssrf.request"] = nil
69
+ context["ssrf.request"] = nil if context
70
70
  end
71
71
 
72
72
  true
@@ -4,6 +4,7 @@ require_relative "sink"
4
4
 
5
5
  require_relative "sinks/socket"
6
6
 
7
+ require_relative "sinks/action_controller" if defined?(::ActionController)
7
8
  require_relative "sinks/resolv" if defined?(::Resolv)
8
9
  require_relative "sinks/net_http" if defined?(::Net::HTTP)
9
10
  require_relative "sinks/http" if defined?(::HTTP)
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "0.1.0.alpha4"
5
+ VERSION = "0.1.1"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
- LIBZEN_VERSION = "0.1.26"
8
+ LIBZEN_VERSION = "0.1.31"
9
9
  end
10
10
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # The worker manages the background thread in which Zen communicates with the
9
+ # Aikido server.
10
+ class Worker
11
+ # @return [Concurrent::ExecutorService]
12
+ attr_reader :executor
13
+
14
+ # @!visibility private
15
+ attr_reader :timers, :deferrals
16
+
17
+ def initialize(config: Aikido::Zen.config)
18
+ @config = config
19
+ @timers = []
20
+ @deferrals = []
21
+ @executor = Concurrent::SingleThreadExecutor.new
22
+ end
23
+
24
+ # Queue a block to be run asynchronously in the background thread.
25
+ #
26
+ # @return [void]
27
+ def perform(&block)
28
+ executor.post do
29
+ yield
30
+ rescue Exception => err # rubocop:disable Lint/RescueException
31
+ @config.logger.error "Error in background worker: #{err.inspect}"
32
+ end
33
+ end
34
+
35
+ # Queue a block to be run asynchronously after a delay.
36
+ #
37
+ # @param interval [Integer] amount of seconds to wait.
38
+ # @return [void]
39
+ def delay(interval, &task)
40
+ Concurrent::ScheduledTask
41
+ .execute(interval, executor: executor) { perform(&task) }
42
+ .tap { |deferral| @deferrals << deferral }
43
+ end
44
+
45
+ # Queue a block to run repeatedly on a timer on the background thread. The
46
+ # timer will consider how long the block takes to run to schedule the next
47
+ # run. For example, if you schedule a block to run every 10 seconds, and the
48
+ # block itself takes 2 seconds, the second iteration will be run 8 seconds
49
+ # after the first one.
50
+ #
51
+ # If the block takes longer than the given interval, the second iteration
52
+ # will be run immediately.
53
+ #
54
+ # @param interval [Integer] amount of seconds to wait between runs.
55
+ # @param run_now [Boolean] whether to run the block immediately, or wait for
56
+ # +interval+ seconds before the first run. Defaults to +true+.
57
+ # @return [void]
58
+ def every(interval, run_now: true, &task)
59
+ Concurrent::TimerTask
60
+ .execute(
61
+ run_now: run_now,
62
+ executor: executor,
63
+ interval_type: :fixed_rate,
64
+ execution_interval: interval
65
+ ) {
66
+ perform(&task)
67
+ }
68
+ .tap { |timer| @timers << timer }
69
+ end
70
+
71
+ # Safely clean up and kill the thread, giving time to kill any ongoing tasks
72
+ # on the queue.
73
+ #
74
+ # @return [void]
75
+ def shutdown
76
+ @deferrals.each { |task| task.cancel if task.pending? }
77
+ @timers.each { |task| task.shutdown }
78
+ @executor.shutdown
79
+ @executor.wait_for_termination(30)
80
+ end
81
+ end
82
+ end