upright 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.
Files changed (121) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +10 -0
  3. data/README.md +455 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/upright/_global.css +104 -0
  6. data/app/assets/stylesheets/upright/artifact.css +148 -0
  7. data/app/assets/stylesheets/upright/base.css +68 -0
  8. data/app/assets/stylesheets/upright/buttons.css +21 -0
  9. data/app/assets/stylesheets/upright/dashboard.css +287 -0
  10. data/app/assets/stylesheets/upright/forms.css +104 -0
  11. data/app/assets/stylesheets/upright/header.css +124 -0
  12. data/app/assets/stylesheets/upright/layout.css +100 -0
  13. data/app/assets/stylesheets/upright/map.css +25 -0
  14. data/app/assets/stylesheets/upright/pagination.css +45 -0
  15. data/app/assets/stylesheets/upright/probes.css +72 -0
  16. data/app/assets/stylesheets/upright/reset.css +26 -0
  17. data/app/assets/stylesheets/upright/tables.css +63 -0
  18. data/app/assets/stylesheets/upright/typography.css +27 -0
  19. data/app/assets/stylesheets/upright/uptime-bars.css +154 -0
  20. data/app/controllers/concerns/upright/authentication.rb +21 -0
  21. data/app/controllers/concerns/upright/subdomain_scoping.rb +18 -0
  22. data/app/controllers/upright/alertmanager_proxy_controller.rb +21 -0
  23. data/app/controllers/upright/application_controller.rb +12 -0
  24. data/app/controllers/upright/artifacts_controller.rb +5 -0
  25. data/app/controllers/upright/dashboards/uptimes_controller.rb +6 -0
  26. data/app/controllers/upright/jobs_controller.rb +4 -0
  27. data/app/controllers/upright/probe_results_controller.rb +17 -0
  28. data/app/controllers/upright/prometheus_proxy_controller.rb +62 -0
  29. data/app/controllers/upright/sessions_controller.rb +29 -0
  30. data/app/controllers/upright/sites_controller.rb +5 -0
  31. data/app/helpers/upright/application_helper.rb +11 -0
  32. data/app/helpers/upright/dashboards_helper.rb +31 -0
  33. data/app/helpers/upright/probe_results_helper.rb +49 -0
  34. data/app/javascript/upright/application.js +2 -0
  35. data/app/javascript/upright/controllers/application.js +5 -0
  36. data/app/javascript/upright/controllers/form_controller.js +7 -0
  37. data/app/javascript/upright/controllers/index.js +4 -0
  38. data/app/javascript/upright/controllers/popover_controller.js +15 -0
  39. data/app/javascript/upright/controllers/probe_results_chart_controller.js +79 -0
  40. data/app/javascript/upright/controllers/results_table_controller.js +16 -0
  41. data/app/javascript/upright/controllers/sites_map_controller.js +33 -0
  42. data/app/jobs/upright/application_job.rb +2 -0
  43. data/app/jobs/upright/probe_check_job.rb +42 -0
  44. data/app/models/concerns/upright/exception_recording.rb +38 -0
  45. data/app/models/concerns/upright/playwright/form_authentication.rb +27 -0
  46. data/app/models/concerns/upright/playwright/helpers.rb +7 -0
  47. data/app/models/concerns/upright/playwright/lifecycle.rb +44 -0
  48. data/app/models/concerns/upright/playwright/logging.rb +87 -0
  49. data/app/models/concerns/upright/playwright/otel_tracing.rb +137 -0
  50. data/app/models/concerns/upright/playwright/video_recording.rb +60 -0
  51. data/app/models/concerns/upright/probe_yaml_source.rb +10 -0
  52. data/app/models/concerns/upright/probeable.rb +125 -0
  53. data/app/models/concerns/upright/staggerable.rb +22 -0
  54. data/app/models/concerns/upright/traceroute/otel_tracing.rb +108 -0
  55. data/app/models/upright/application_record.rb +3 -0
  56. data/app/models/upright/artifact.rb +61 -0
  57. data/app/models/upright/current.rb +9 -0
  58. data/app/models/upright/http/request.rb +59 -0
  59. data/app/models/upright/http/response.rb +55 -0
  60. data/app/models/upright/playwright/authenticator/base.rb +128 -0
  61. data/app/models/upright/playwright/storage_state.rb +31 -0
  62. data/app/models/upright/probe_result.rb +31 -0
  63. data/app/models/upright/probes/http_probe.rb +102 -0
  64. data/app/models/upright/probes/playwright/base.rb +48 -0
  65. data/app/models/upright/probes/smtp_probe.rb +48 -0
  66. data/app/models/upright/probes/traceroute_probe.rb +32 -0
  67. data/app/models/upright/probes/uptime/summary.rb +36 -0
  68. data/app/models/upright/probes/uptime.rb +36 -0
  69. data/app/models/upright/traceroute/hop.rb +49 -0
  70. data/app/models/upright/traceroute/ip_metadata_lookup.rb +107 -0
  71. data/app/models/upright/traceroute/mtr_parser.rb +47 -0
  72. data/app/models/upright/traceroute/result.rb +57 -0
  73. data/app/models/upright/user.rb +14 -0
  74. data/app/views/layouts/upright/_header.html.erb +23 -0
  75. data/app/views/layouts/upright/application.html.erb +25 -0
  76. data/app/views/upright/active_storage/attachments/_attachment.html.erb +21 -0
  77. data/app/views/upright/alertmanager_proxy/show.html.erb +1 -0
  78. data/app/views/upright/artifacts/show.html.erb +9 -0
  79. data/app/views/upright/dashboards/_uptime_bars.html.erb +17 -0
  80. data/app/views/upright/dashboards/_uptime_probe_row.html.erb +22 -0
  81. data/app/views/upright/dashboards/uptimes/show.html.erb +17 -0
  82. data/app/views/upright/jobs/show.html.erb +1 -0
  83. data/app/views/upright/probe_results/_pagination.html.erb +19 -0
  84. data/app/views/upright/probe_results/index.html.erb +72 -0
  85. data/app/views/upright/prometheus_proxy/show.html.erb +1 -0
  86. data/app/views/upright/sessions/new.html.erb +6 -0
  87. data/app/views/upright/sites/index.html.erb +22 -0
  88. data/config/brakeman.ignore +39 -0
  89. data/config/ci.rb +7 -0
  90. data/config/importmap.rb +18 -0
  91. data/config/routes.rb +41 -0
  92. data/db/migrate/20250114000001_create_upright_probe_results.rb +19 -0
  93. data/lib/generators/upright/install/install_generator.rb +83 -0
  94. data/lib/generators/upright/install/templates/alertmanager.yml +14 -0
  95. data/lib/generators/upright/install/templates/deploy.yml +118 -0
  96. data/lib/generators/upright/install/templates/development_alertmanager.yml +11 -0
  97. data/lib/generators/upright/install/templates/development_prometheus.yml +12 -0
  98. data/lib/generators/upright/install/templates/docker-compose.yml +38 -0
  99. data/lib/generators/upright/install/templates/http_probes.yml +14 -0
  100. data/lib/generators/upright/install/templates/omniauth.rb +8 -0
  101. data/lib/generators/upright/install/templates/otel_collector.yml +24 -0
  102. data/lib/generators/upright/install/templates/prometheus.yml +10 -0
  103. data/lib/generators/upright/install/templates/puma.rb +40 -0
  104. data/lib/generators/upright/install/templates/sites.yml +26 -0
  105. data/lib/generators/upright/install/templates/smtp_probes.yml +9 -0
  106. data/lib/generators/upright/install/templates/upright.rb +21 -0
  107. data/lib/generators/upright/install/templates/upright.rules.yml +256 -0
  108. data/lib/generators/upright/playwright_probe/playwright_probe_generator.rb +30 -0
  109. data/lib/generators/upright/playwright_probe/templates/authenticator.rb.tt +14 -0
  110. data/lib/generators/upright/playwright_probe/templates/probe.rb.tt +14 -0
  111. data/lib/omniauth/strategies/static_credentials.rb +57 -0
  112. data/lib/tasks/upright_tasks.rake +4 -0
  113. data/lib/upright/configuration.rb +106 -0
  114. data/lib/upright/engine.rb +157 -0
  115. data/lib/upright/metrics.rb +62 -0
  116. data/lib/upright/playwright/collect_performance_metrics.js +36 -0
  117. data/lib/upright/site.rb +49 -0
  118. data/lib/upright/tracing.rb +49 -0
  119. data/lib/upright/version.rb +3 -0
  120. data/lib/upright.rb +68 -0
  121. metadata +513 -0
@@ -0,0 +1,128 @@
1
+ class Upright::Playwright::Authenticator::Base
2
+ include Upright::Playwright::Helpers
3
+
4
+ attr_reader :page
5
+
6
+ def self.authenticate_on(page)
7
+ new.authenticate_on(page)
8
+ end
9
+
10
+ def initialize(browser = nil, context_options = {})
11
+ @browser = browser
12
+ @context_options = context_options
13
+ @storage_state = Upright::Playwright::StorageState.new(service_name)
14
+ end
15
+
16
+ def authenticate_on(page)
17
+ @page = page
18
+ authenticate
19
+ self
20
+ end
21
+
22
+ def session_valid?
23
+ wait_for_network_idle(page)
24
+
25
+ if page.url == signin_redirect_url
26
+ true
27
+ else
28
+ page.goto(signin_redirect_url, timeout: 10.seconds.in_ms)
29
+ !page.url.include?(signin_path)
30
+ end
31
+ end
32
+
33
+ def authenticated_context
34
+ if (cached_state = @storage_state.load)
35
+ context = create_context(cached_state)
36
+ return context if context_has_valid_session?(context)
37
+ context.close
38
+ end
39
+
40
+ perform_authentication
41
+ end
42
+
43
+ protected
44
+
45
+ def signin_redirect_url
46
+ raise NotImplementedError
47
+ end
48
+
49
+ def signin_path
50
+ raise NotImplementedError
51
+ end
52
+
53
+ private
54
+
55
+ def service_name
56
+ raise NotImplementedError
57
+ end
58
+
59
+ def authenticate
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def context_has_valid_session?(context)
64
+ page = context.new_page
65
+ page.goto(signin_redirect_url, timeout: 10.seconds.in_ms)
66
+ !page.url.include?(signin_path)
67
+ rescue ::Playwright::TimeoutError
68
+ false
69
+ ensure
70
+ page&.close
71
+ end
72
+
73
+ def perform_authentication
74
+ context = create_context
75
+ @page = context.new_page
76
+ setup_page_logging(page)
77
+
78
+ authenticate
79
+
80
+ state = context.storage_state
81
+ @storage_state.save(state)
82
+ context.close
83
+
84
+ create_context(state)
85
+ end
86
+
87
+ def user_agent
88
+ Upright.configuration.user_agent.presence ||
89
+ Upright::Playwright::Lifecycle::DEFAULT_USER_AGENT
90
+ end
91
+
92
+ def create_context(state = nil)
93
+ options = { userAgent: user_agent, serviceWorkers: "block" }
94
+ options[:storageState] = state if state
95
+ options.merge!(@context_options)
96
+ @browser.new_context(**options)
97
+ end
98
+
99
+ def setup_page_logging(page)
100
+ if defined?(RailsStructuredLogging::Recorder)
101
+ RailsStructuredLogging::Recorder.instance.messages.tap do |messages|
102
+ page.on("response", ->(response) {
103
+ next if skip_logging?(response)
104
+ RailsStructuredLogging::Recorder.instance.sharing(messages)
105
+ log_response(response)
106
+ })
107
+ end
108
+ else
109
+ page.on("response", ->(response) {
110
+ next if skip_logging?(response)
111
+ log_response(response)
112
+ })
113
+ end
114
+ end
115
+
116
+ def log_response(response)
117
+ headers = response.headers.slice("x-request-id", "x-runtime").compact
118
+ Rails.logger.info "#{response.status} #{response.request.resource_type.upcase} #{response.url} #{headers.to_query}"
119
+ end
120
+
121
+ def skip_logging?(response)
122
+ %w[image asset avatar].any? { |pattern| response.url.include?(pattern) }
123
+ end
124
+
125
+ def credentials
126
+ Rails.application.credentials
127
+ end
128
+ end
@@ -0,0 +1,31 @@
1
+ class Upright::Playwright::StorageState
2
+ def initialize(service)
3
+ @service = service
4
+ end
5
+
6
+ def exists?
7
+ path.exist?
8
+ end
9
+
10
+ def load
11
+ JSON.parse(path.read) if exists?
12
+ end
13
+
14
+ def save(state)
15
+ FileUtils.mkdir_p(storage_dir)
16
+ path.write(JSON.pretty_generate(state))
17
+ end
18
+
19
+ def clear
20
+ path.delete if exists?
21
+ end
22
+
23
+ private
24
+ def storage_dir
25
+ Upright.configuration.storage_state_dir
26
+ end
27
+
28
+ def path
29
+ storage_dir.join("#{@service}.json")
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ class Upright::ProbeResult < Upright::ApplicationRecord
2
+ include Upright::ExceptionRecording
3
+
4
+ has_many_attached :artifacts
5
+
6
+ scope :by_type, ->(type) { where(probe_type: type) if type.present? }
7
+ scope :by_status, ->(status) { where(status: status) if status.present? }
8
+ scope :by_name, ->(name) { where(probe_name: name) if name.present? }
9
+ scope :stale, -> { where(created_at: ...24.hours.ago) }
10
+
11
+ enum :status, [ :ok, :fail ]
12
+
13
+ after_create :increment_metrics
14
+
15
+ def to_chart
16
+ {
17
+ probe_name: probe_name,
18
+ status: status,
19
+ created_at: created_at.iso8601,
20
+ duration: duration.to_f
21
+ }
22
+ end
23
+
24
+ private
25
+ def increment_metrics
26
+ labels = { type: probe_type, name: probe_name, probe_target: probe_target, probe_service: probe_service }
27
+
28
+ Yabeda.upright_probe_duration_seconds.set(labels.merge(status: status), duration.to_f)
29
+ Yabeda.upright_probe_up.set(labels, ok? ? 1 : 0)
30
+ end
31
+ end
@@ -0,0 +1,102 @@
1
+ class Upright::Probes::HTTPProbe < FrozenRecord::Base
2
+ include Upright::Probeable
3
+ include Upright::ProbeYamlSource
4
+
5
+ stagger_by_site 3.seconds
6
+
7
+ DEFAULT_EXPECTED_STATUS = 200..399
8
+
9
+ attr_accessor :last_response
10
+
11
+ def check
12
+ self.last_response = perform_request
13
+ record_response_status
14
+ status_matches_expected?
15
+ end
16
+
17
+ def on_check_recorded(probe_result)
18
+ attach_verbose_log(probe_result)
19
+ attach_response_body(probe_result)
20
+ end
21
+
22
+ def probe_type = "http"
23
+ def probe_target = url
24
+
25
+ private
26
+ def perform_request
27
+ Upright::HTTP::Request.new(url, **request_options).get
28
+ end
29
+
30
+ def request_options
31
+ credentials_hash.merge(proxy_hash)
32
+ end
33
+
34
+ def status_matches_expected?
35
+ if last_response.network_error?
36
+ false
37
+ else
38
+ last_response.status_in?(expected_status_range)
39
+ end
40
+ end
41
+
42
+ def expected_status_range
43
+ if try(:expected_status).is_a?(Integer)
44
+ expected_status..expected_status
45
+ else
46
+ DEFAULT_EXPECTED_STATUS
47
+ end
48
+ end
49
+
50
+ def credentials_hash
51
+ if credentials
52
+ { username: credentials[:username], password: credentials[:password] }
53
+ else
54
+ {}
55
+ end
56
+ end
57
+
58
+ def credentials
59
+ if try(:basic_auth_credentials)
60
+ Rails.application.credentials.dig(:http_probes, basic_auth_credentials.to_sym)
61
+ end
62
+ end
63
+
64
+ def proxy_hash
65
+ if proxy_credentials
66
+ {
67
+ proxy: proxy_credentials[:url],
68
+ proxy_username: proxy_credentials[:username],
69
+ proxy_password: proxy_credentials[:password]
70
+ }.compact
71
+ else
72
+ {}
73
+ end
74
+ end
75
+
76
+ def proxy_credentials
77
+ if try(:proxy)
78
+ Rails.application.credentials.dig(:proxies, proxy.to_sym)
79
+ end
80
+ end
81
+
82
+ def record_response_status
83
+ if last_response && !last_response.network_error? && defined?(Yabeda)
84
+ Yabeda.upright_http_response_status.set(
85
+ { name: probe_name, probe_target: probe_target, probe_service: probe_service },
86
+ last_response.status
87
+ )
88
+ end
89
+ end
90
+
91
+ def attach_verbose_log(probe_result)
92
+ if last_response
93
+ Upright::Artifact.new(name: "curl.log", content: last_response.verbose_log_content).attach_to(probe_result)
94
+ end
95
+ end
96
+
97
+ def attach_response_body(probe_result)
98
+ if last_response && last_response.body.present?
99
+ Upright::Artifact.new(name: "response.#{last_response.file_extension}", content: last_response.body).attach_to(probe_result)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,48 @@
1
+ require "playwright"
2
+
3
+ class Upright::Probes::Playwright::Base
4
+ include ActiveSupport::Callbacks
5
+ include Upright::Probeable
6
+
7
+ define_callbacks :perform_check
8
+
9
+ include Upright::Playwright::Lifecycle
10
+ include Upright::Playwright::FormAuthentication
11
+ include Upright::Playwright::Logging
12
+ include Upright::Playwright::OtelTracing
13
+ include Upright::Playwright::VideoRecording
14
+ include Upright::Playwright::Helpers
15
+
16
+ set_callback :perform_check, :after, :wait_for_network_idle
17
+
18
+ def self.check
19
+ new.perform_check
20
+ end
21
+
22
+ def self.check_and_record_later
23
+ Upright::ProbeCheckJob.set(wait: stagger_delay).perform_later(name)
24
+ end
25
+
26
+ def perform_check
27
+ with_browser do |browser|
28
+ with_context(browser, **video_recording_options) do
29
+ run_callbacks :perform_check do
30
+ check
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def check
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def on_check_recorded(probe_result)
41
+ attach_video(probe_result)
42
+ attach_log(probe_result)
43
+ end
44
+
45
+ def probe_type = "playwright"
46
+ def probe_target = nil
47
+ def probe_service = authentication_service&.to_s
48
+ end
@@ -0,0 +1,48 @@
1
+ require "net/smtp"
2
+
3
+ class Upright::Probes::SMTPProbe < FrozenRecord::Base
4
+ include Upright::Probeable
5
+ include Upright::ProbeYamlSource
6
+
7
+ stagger_by_site 3.seconds
8
+
9
+ attr_accessor :smtp_log
10
+
11
+ def check
12
+ self.smtp_log = StringIO.new
13
+
14
+ current_site = Upright.current_site
15
+
16
+ smtp = Net::SMTP.new(host)
17
+ smtp.open_timeout = current_site.default_timeout
18
+ smtp.read_timeout = current_site.default_timeout
19
+ smtp.debug_output = smtp_log
20
+
21
+ smtp.start("upright") { }
22
+
23
+ true
24
+ rescue Net::SMTPError, Net::OpenTimeout, Net::ReadTimeout
25
+ false
26
+ end
27
+
28
+ def on_check_recorded(probe_result)
29
+ attach_log(probe_result)
30
+ end
31
+
32
+ def probe_type = "smtp"
33
+ def probe_target = host
34
+
35
+ private
36
+ def attach_log(probe_result)
37
+ if smtp_log
38
+ smtp_log.rewind
39
+ log_content = smtp_log.read
40
+
41
+ if log_content.present?
42
+ logger.debug { log_content }
43
+
44
+ Upright::Artifact.new(name: "smtp.log", content: log_content).attach_to(probe_result)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ class Upright::Probes::TracerouteProbe < FrozenRecord::Base
2
+ include Upright::Probeable
3
+ include Upright::ProbeYamlSource
4
+ include Upright::Traceroute::OtelTracing
5
+
6
+ stagger_by_site 3.seconds
7
+
8
+ TIMEOUT = 60.seconds
9
+ MAX_HOPS = Upright::Traceroute::Result::MAX_HOPS
10
+ PROBE_COUNT = Upright::Traceroute::Result::PROBE_COUNT
11
+
12
+ def check
13
+ Upright::Traceroute::Result.for(host).tap do |result|
14
+ @traceroute_result = result
15
+ trace_result(result)
16
+ end.reached_destination?
17
+ end
18
+
19
+ def on_check_recorded(probe_result)
20
+ attach_traceroute_json(probe_result, @traceroute_result)
21
+ end
22
+
23
+ def probe_type = "traceroute"
24
+ def probe_target = host
25
+
26
+ private
27
+ def attach_traceroute_json(probe_result, traceroute_result)
28
+ if traceroute_result.raw_json.present?
29
+ Upright::Artifact.new(name: "traceroute.json", content: traceroute_result.raw_json).attach_to(probe_result)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ class Upright::Probes::Uptime::Summary
2
+ include Comparable
3
+
4
+ def initialize(result)
5
+ @result = result
6
+ end
7
+
8
+ def name
9
+ @result[:metric][:name]
10
+ end
11
+
12
+ def type
13
+ @result[:metric][:type]
14
+ end
15
+
16
+ def overall_uptime
17
+ if daily_uptimes.empty?
18
+ 0
19
+ else
20
+ (daily_uptimes.values.sum / daily_uptimes.size) * 100
21
+ end
22
+ end
23
+
24
+ def uptime_for_date(date)
25
+ daily_uptimes[date.to_date]
26
+ end
27
+
28
+ def <=>(other)
29
+ [ type, name ] <=> [ other.type, other.name ]
30
+ end
31
+
32
+ private
33
+ def daily_uptimes
34
+ @daily_uptimes ||= @result[:values].to_h { |timestamp, value| [ Time.at(timestamp).to_date, value.to_f ] }
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ class Upright::Probes::Uptime
2
+ class << self
3
+ def all
4
+ for_type(nil)
5
+ end
6
+
7
+ def for_type(probe_type)
8
+ results = prometheus_client.query_range(
9
+ query: query(probe_type),
10
+ start: 30.days.ago.iso8601,
11
+ end: Time.current.iso8601,
12
+ step: "86400s" # 1 day
13
+ ).deep_symbolize_keys
14
+
15
+ results[:result].map { |result| Summary.new(result) }.sort
16
+ end
17
+
18
+ private
19
+ def query(probe_type)
20
+ "upright:probe_uptime_daily#{label_selector(probe_type)}"
21
+ end
22
+
23
+ def label_selector(probe_type)
24
+ if probe_type.present?
25
+ "{type=\"#{probe_type}\"}"
26
+ end
27
+ end
28
+
29
+ def prometheus_client
30
+ Prometheus::ApiClient.client(
31
+ url: ENV.fetch("PROMETHEUS_URL", "http://localhost:9090"),
32
+ options: { timeout: 30.seconds }
33
+ )
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ class Upright::Traceroute::Hop
2
+ attr_reader :number, :ip, :loss_percent,
3
+ :last_ms, :avg_ms, :best_ms, :worst_ms, :stddev_ms,
4
+ :hostname, :as, :isp, :city, :country, :country_code, :geohash
5
+
6
+ def initialize(hop_number: nil, ip: nil, hostname: nil, loss_percent: nil,
7
+ last_ms: nil, avg_ms: nil, best_ms: nil, worst_ms: nil, stddev_ms: nil,
8
+ as: nil, isp: nil, city: nil, country: nil, country_code: nil, geohash: nil)
9
+ @number = hop_number
10
+ @ip = ip
11
+ @hostname = hostname
12
+ @loss_percent = loss_percent
13
+ @last_ms = last_ms
14
+ @avg_ms = avg_ms
15
+ @best_ms = best_ms
16
+ @worst_ms = worst_ms
17
+ @stddev_ms = stddev_ms
18
+ @as = as
19
+ @isp = isp
20
+ @city = city
21
+ @country = country
22
+ @country_code = country_code
23
+ @geohash = geohash
24
+ end
25
+
26
+ def responded?
27
+ ip.present?
28
+ end
29
+
30
+ def high_packet_loss?
31
+ loss_percent && loss_percent > 50
32
+ end
33
+
34
+ def any_packet_loss?
35
+ loss_percent && loss_percent > 0
36
+ end
37
+
38
+ def display_name
39
+ if responded?
40
+ if isp.present?
41
+ isp
42
+ else
43
+ ip
44
+ end
45
+ else
46
+ "???"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "resolv"
4
+ require "geohash_ruby"
5
+
6
+ class Upright::Traceroute::IpMetadataLookup
7
+ API_URL = "http://ip-api.com/batch"
8
+ TIMEOUT = 5.seconds
9
+ GEOHASH_PRECISION = 6
10
+ CACHE_TTL = 24.hours
11
+
12
+ class << self
13
+ def for_many(ips)
14
+ results, uncached_ips = partition_cached(ips)
15
+
16
+ if uncached_ips.any?
17
+ fetch_batch(uncached_ips).each do |ip, metadata|
18
+ cache_write(ip, metadata)
19
+ results[ip] = metadata
20
+ end
21
+ end
22
+
23
+ results
24
+ end
25
+
26
+ def clear_cache
27
+ cache.clear
28
+ end
29
+
30
+ private
31
+ def partition_cached(ips)
32
+ results = {}
33
+ uncached = []
34
+
35
+ valid_ips(ips).each do |ip|
36
+ cached = cache_read(ip)
37
+ if cached
38
+ results[ip] = cached
39
+ else
40
+ uncached << ip
41
+ end
42
+ end
43
+
44
+ [ results, uncached ]
45
+ end
46
+
47
+ def valid_ips(ips)
48
+ ips.compact.uniq.select { |ip| ip =~ Resolv::IPv4::Regex }
49
+ end
50
+
51
+ def fetch_batch(ips)
52
+ uri = URI(API_URL)
53
+ request = Net::HTTP::Post.new(uri)
54
+ request.content_type = "application/json"
55
+ request.body = ips.to_json
56
+
57
+ response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |http|
58
+ http.request(request)
59
+ end
60
+
61
+ if response.is_a?(Net::HTTPSuccess)
62
+ parse_response(JSON.parse(response.body))
63
+ else
64
+ {}
65
+ end
66
+ end
67
+
68
+ def parse_response(results)
69
+ results.select { |result| result["status"] == "success" }.to_h do |result|
70
+ [ result["query"], build_metadata(result) ]
71
+ end
72
+ end
73
+
74
+ def build_metadata(result)
75
+ {
76
+ as: result["as"],
77
+ isp: result["isp"],
78
+ city: result["city"],
79
+ country: result["country"],
80
+ country_code: result["countryCode"],
81
+ geohash: encode_geohash(result["lat"], result["lon"])
82
+ }
83
+ end
84
+
85
+ def encode_geohash(latitude, longitude)
86
+ if latitude && longitude
87
+ Geohash.encode(latitude, longitude, GEOHASH_PRECISION)
88
+ end
89
+ end
90
+
91
+ def cache_read(ip)
92
+ cache.read("traceroute/ip_metadata/#{ip}")
93
+ end
94
+
95
+ def cache_write(ip, metadata)
96
+ cache.write("traceroute/ip_metadata/#{ip}", metadata, expires_in: CACHE_TTL)
97
+ end
98
+
99
+ def cache
100
+ @cache ||= if Rails.env.test?
101
+ ActiveSupport::Cache::MemoryStore.new
102
+ else
103
+ ActiveSupport::Cache::FileStore.new(Rails.root.join("storage/ip_metadata"))
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,47 @@
1
+ require "json"
2
+ require "resolv"
3
+
4
+ class Upright::Traceroute::MtrParser
5
+ def initialize(json_output)
6
+ @json_output = json_output
7
+ end
8
+
9
+ def parse
10
+ data = JSON.parse(@json_output)
11
+ hubs = data.dig("report", "hubs").to_a
12
+
13
+ hubs.map { |hub| parse_hub(hub) }
14
+ end
15
+
16
+ private
17
+ def parse_hub(hub)
18
+ {
19
+ **ip_and_hostname(hub["host"]),
20
+ hop_number: hub["count"],
21
+ loss_percent: hub["Loss%"],
22
+ last_ms: hub["Last"],
23
+ avg_ms: hub["Avg"],
24
+ best_ms: hub["Best"],
25
+ worst_ms: hub["Wrst"],
26
+ stddev_ms: hub["StDev"]
27
+ }
28
+ end
29
+
30
+ def ip_and_hostname(host)
31
+ if host.nil? || host == "???"
32
+ { ip: nil, hostname: nil }
33
+ elsif ip_address?(host)
34
+ { ip: host, hostname: nil }
35
+ else
36
+ { ip: resolve_ip(host), hostname: host }
37
+ end
38
+ end
39
+
40
+ def resolve_ip(hostname)
41
+ Resolv.getaddress(hostname) rescue nil
42
+ end
43
+
44
+ def ip_address?(address)
45
+ address.match?(Resolv::IPv4::Regex)
46
+ end
47
+ end