github-pages-health-check 0.6.1 → 1.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +6 -0
  6. data/Gemfile +3 -0
  7. data/README.md +83 -0
  8. data/github-pages-health-check.gemspec +28 -0
  9. data/lib/github-pages-health-check.rb +18 -245
  10. data/lib/github-pages-health-check/checkable.rb +62 -0
  11. data/lib/github-pages-health-check/cloudflare.rb +11 -7
  12. data/lib/github-pages-health-check/domain.rb +260 -0
  13. data/lib/github-pages-health-check/error.rb +7 -6
  14. data/lib/github-pages-health-check/errors.rb +13 -0
  15. data/lib/github-pages-health-check/errors/build_error.rb +8 -0
  16. data/lib/github-pages-health-check/errors/deprecated_ip_error.rb +11 -0
  17. data/lib/github-pages-health-check/errors/invalid_a_record_error.rb +11 -0
  18. data/lib/github-pages-health-check/errors/invalid_cname_error.rb +11 -0
  19. data/lib/github-pages-health-check/errors/invalid_dns_error.rb +11 -0
  20. data/lib/github-pages-health-check/errors/invalid_domain_error.rb +11 -0
  21. data/lib/github-pages-health-check/errors/invalid_repository_error.rb +11 -0
  22. data/lib/github-pages-health-check/errors/missing_access_token_error.rb +11 -0
  23. data/lib/github-pages-health-check/errors/not_served_by_pages_error.rb +11 -0
  24. data/lib/github-pages-health-check/printer.rb +71 -0
  25. data/lib/github-pages-health-check/repository.rb +74 -0
  26. data/lib/github-pages-health-check/site.rb +32 -0
  27. data/lib/github-pages-health-check/version.rb +3 -3
  28. data/script/bootstrap +5 -0
  29. data/script/check +11 -0
  30. data/script/check-cloudflare-ips +17 -0
  31. data/script/cibuild +9 -0
  32. data/script/console +5 -0
  33. data/script/release +42 -0
  34. data/script/test +2 -0
  35. data/script/update-cloudflare-ips +14 -0
  36. metadata +60 -7
  37. data/lib/github-pages-health-check/errors/deprecated_ip.rb +0 -9
  38. data/lib/github-pages-health-check/errors/invalid_a_record.rb +0 -9
  39. data/lib/github-pages-health-check/errors/invalid_cname.rb +0 -9
  40. data/lib/github-pages-health-check/errors/invalid_dns.rb +0 -9
  41. data/lib/github-pages-health-check/errors/not_served_by_pages.rb +0 -9
@@ -1,9 +1,10 @@
1
- class GitHubPages
2
- class HealthCheck
1
+ module GitHubPages
2
+ module HealthCheck
3
3
  class CloudFlare
4
4
  include Singleton
5
5
 
6
- CONFIG_PATH = File.expand_path("../../config/cloudflare-ips.txt", File.dirname(__FILE__))
6
+ # Internal: The path of the config file.
7
+ attr_reader :path
7
8
 
8
9
  # Public: Does cloudflare control this address?
9
10
  def self.controls_ip?(address)
@@ -12,17 +13,16 @@ class GitHubPages
12
13
 
13
14
  # Internal: Create a new cloudflare info instance.
14
15
  def initialize(options = {})
15
- @path = options.fetch(:path) { CONFIG_PATH }
16
+ @path = options.fetch(:path) { default_config_path }
16
17
  end
17
18
 
18
- # Internal: The path of the config file.
19
- attr_reader :path
20
-
21
19
  # Internal: Does cloudflare control this address?
22
20
  def controls_ip?(address)
23
21
  ranges.any? { |range| range.include?(address) }
24
22
  end
25
23
 
24
+ private
25
+
26
26
  # Internal: The IP address ranges that cloudflare controls.
27
27
  def ranges
28
28
  @ranges ||= load_ranges
@@ -32,6 +32,10 @@ class GitHubPages
32
32
  def load_ranges
33
33
  File.read(path).lines.map { |line| IPAddr.new(line.chomp) }
34
34
  end
35
+
36
+ def default_config_path
37
+ File.expand_path("../../config/cloudflare-ips.txt", File.dirname(__FILE__))
38
+ end
35
39
  end
36
40
  end
37
41
  end
@@ -0,0 +1,260 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ class Domain < Checkable
4
+
5
+ attr_reader :host
6
+
7
+ LEGACY_IP_ADDRESSES = %w[
8
+ 207.97.227.245
9
+ 204.232.175.78
10
+ 199.27.73.133
11
+ ].freeze
12
+
13
+ CURRENT_IP_ADDRESSES = %w[
14
+ 192.30.252.153
15
+ 192.30.252.154
16
+ ].freeze
17
+
18
+ HASH_METHODS = [
19
+ :host, :uri, :dns_resolves?, :proxied?, :cloudflare_ip?,
20
+ :old_ip_address?, :a_record?, :cname_record?, :valid_domain?,
21
+ :apex_domain?, :should_be_a_record?, :cname_to_github_user_domain?,
22
+ :cname_to_pages_dot_github_dot_com?, :cname_to_fastly?,
23
+ :pointed_to_github_pages_ip?, :pages_domain?, :served_by_pages?,
24
+ :valid_domain?
25
+ ].freeze
26
+
27
+ def initialize(host)
28
+ unless host.is_a? String
29
+ raise ArgumentError, "Expected string, got #{host.class}"
30
+ end
31
+
32
+ @host = host_from_uri(host)
33
+ end
34
+
35
+ # Runs all checks, raises an error if invalid
36
+ def check!
37
+ raise Errors::InvalidDNSError unless dns_resolves?
38
+ return true if proxied?
39
+ raise Errors::DeprecatedIPError if deprecated_ip?
40
+ raise Errors::InvalidARecordError if invalid_a_record?
41
+ raise Errors::InvalidCNAMEError if invalid_cname?
42
+ raise Errors::NotServedByPagesError unless served_by_pages?
43
+ true
44
+ end
45
+
46
+ def deprecated_ip?
47
+ return @deprecated_ip if defined? @deprecated_ip
48
+ @deprecated_ip = (a_record? && old_ip_address?)
49
+ end
50
+
51
+ def invalid_a_record?
52
+ return @invalid_a_record if defined? @invalid_a_record
53
+ @invalid_a_record = (valid_domain? && a_record? && !should_be_a_record?)
54
+ end
55
+
56
+ def invalid_cname?
57
+ return @invalid_cname if defined? @invalid_cname
58
+ @invalid_cname = begin
59
+ return false unless valid_domain?
60
+ return false if github_domain? || apex_domain?
61
+ return true if cname_to_pages_dot_github_dot_com? || cname_to_fastly?
62
+ !cname_to_github_user_domain?
63
+ end
64
+ end
65
+
66
+ # Is this a valid domain that PublicSuffix recognizes?
67
+ # Used as an escape hatch to prevent false positives on DNS checkes
68
+ def valid_domain?
69
+ return @valid if defined? @valid
70
+ @valid = PublicSuffix.valid?(host)
71
+ end
72
+
73
+ # Is this domain an apex domain, meaning a CNAME would be innapropriate
74
+ def apex_domain?
75
+ return @apex_domain if defined?(@apex_domain)
76
+
77
+ answers = Resolv::DNS.open { |dns|
78
+ dns.getresources(absolute_domain, Resolv::DNS::Resource::IN::NS)
79
+ }
80
+
81
+ @apex_domain = answers.any?
82
+ end
83
+
84
+ # Should the domain be an apex record?
85
+ def should_be_a_record?
86
+ !pages_domain? && apex_domain?
87
+ end
88
+
89
+ # Is the domain's first response an A record to a valid GitHub Pages IP?
90
+ def pointed_to_github_pages_ip?
91
+ a_record? && CURRENT_IP_ADDRESSES.include?(dns.first.value)
92
+ end
93
+
94
+ # Is the domain's first response a CNAME to a pages domain?
95
+ def cname_to_github_user_domain?
96
+ cname? && !cname_to_pages_dot_github_dot_com? && cname.pages_domain?
97
+ end
98
+
99
+ # Is the given domain a CNAME to pages.github.(io|com)
100
+ # instead of being CNAME'd to the user's subdomain?
101
+ #
102
+ # domain - the domain to check, generaly the target of a cname
103
+ def cname_to_pages_dot_github_dot_com?
104
+ cname? && cname.pages_dot_github_dot_com?
105
+ end
106
+
107
+ # Is the given domain CNAME'd directly to our Fastly account?
108
+ def cname_to_fastly?
109
+ cname? && !pages_domain? && cname.fastly?
110
+ end
111
+
112
+ # Is the host a *.github.io domain?
113
+ def pages_domain?
114
+ !!host.match(/\A[\w-]+\.github\.(io|com)\.?\z/i)
115
+ end
116
+
117
+ # Is the host pages.github.com or pages.github.io?
118
+ def pages_dot_github_dot_com?
119
+ !!host.match(/\Apages\.github\.(io|com)\.?\z/i)
120
+ end
121
+
122
+ # Is this domain owned by GitHub?
123
+ def github_domain?
124
+ !!host.match(/\.github\.com\z/)
125
+ end
126
+
127
+ # Is the host our Fastly CNAME?
128
+ def fastly?
129
+ !!host.match(/\Agithub\.map\.fastly\.net\.?\z/i)
130
+ end
131
+
132
+ # Does the domain resolve to a CloudFlare-owned IP
133
+ def cloudflare_ip?
134
+ return unless dns?
135
+ dns.all? do |answer|
136
+ answer.class == Net::DNS::RR::A && CloudFlare.controls_ip?(answer.address)
137
+ end
138
+ end
139
+
140
+ # Does this non-GitHub-pages domain proxy a GitHub Pages site?
141
+ #
142
+ # This can be:
143
+ # 1. A Cloudflare-owned IP address
144
+ # 2. A site that returns GitHub.com server headers, but isn't CNAME'd to a GitHub domain
145
+ # 3. A site that returns GitHub.com server headers, but isn't CNAME'd to a GitHub IP
146
+ def proxied?
147
+ return unless dns?
148
+ return true if cloudflare_ip?
149
+ return false if pointed_to_github_pages_ip? || cname_to_github_user_domain?
150
+ return false if cname_to_pages_dot_github_dot_com? || cname_to_fastly?
151
+ served_by_pages?
152
+ end
153
+
154
+ # Returns an array of DNS answers
155
+ def dns
156
+ return @dns if defined? @dns
157
+ @dns = Timeout.timeout(TIMEOUT) do
158
+ GitHubPages::HealthCheck.without_warnings do
159
+ Net::DNS::Resolver.start(absolute_domain).answer unless host.nil?
160
+ end
161
+ end
162
+ rescue StandardError
163
+ @dns = nil
164
+ end
165
+
166
+ # Are we even able to get the DNS record?
167
+ def dns?
168
+ !(dns.nil? || dns.empty?)
169
+ end
170
+ alias_method :dns_resolves?, :dns?
171
+
172
+ # Does this domain have *any* A record that points to the legacy IPs?
173
+ def old_ip_address?
174
+ dns.any? do |answer|
175
+ answer.class == Net::DNS::RR::A && LEGACY_IP_ADDRESSES.include?(answer.address.to_s)
176
+ end if dns?
177
+ end
178
+
179
+ # Is this domain's first response an A record?
180
+ def a_record?
181
+ return unless dns?
182
+ dns.first.class == Net::DNS::RR::A
183
+ end
184
+
185
+ # Is this domain's first response a CNAME record?
186
+ def cname_record?
187
+ return unless dns?
188
+ dns.first.class == Net::DNS::RR::CNAME
189
+ end
190
+ alias cname? cname_record?
191
+
192
+ # The domain to which this domain's CNAME resolves
193
+ # Returns nil if the domain is not a CNAME
194
+ def cname
195
+ return unless cname?
196
+ @cname ||= Domain.new(dns.first.cname.to_s)
197
+ end
198
+
199
+ def served_by_pages?
200
+ return @served_by_pages if defined? @served_by_pages
201
+
202
+ @served_by_pages = begin
203
+ response = Typhoeus.head(uri, TYPHOEUS_OPTIONS)
204
+
205
+ # Workaround for webmock not playing nicely with Typhoeus redirects
206
+ # See https://github.com/bblimke/webmock/issues/237
207
+ if response.mock? && response.headers["Location"]
208
+ response = Typhoeus.head(response.headers["Location"], TYPHOEUS_OPTIONS)
209
+ end
210
+
211
+ return false unless response.mock? || response.return_code == :ok
212
+ return true if response.headers["Server"] == "GitHub.com"
213
+
214
+ # Typhoeus mangles the case of the header, compare insensitively
215
+ response.headers.any? { |k,v| k =~ /X-GitHub-Request-Id/i }
216
+ end
217
+ end
218
+
219
+ def uri
220
+ @uri ||= begin
221
+ options = { :host => host, :scheme => scheme, :path => "/" }
222
+ Addressable::URI.new(options).normalize.to_s
223
+ end
224
+ end
225
+
226
+ private
227
+
228
+ # Parse the URI. Accept either domain names or full URI's.
229
+ # Used by the initializer so we can be more flexible with inputs.
230
+ #
231
+ # domain - a URI or domain name.
232
+ #
233
+ # Examples
234
+ #
235
+ # host_from_uri("benbalter.github.com")
236
+ # # => 'benbalter.github.com'
237
+ # host_from_uri("https://benbalter.github.com")
238
+ # # => 'benbalter.github.com'
239
+ # host_from_uri("benbalter.github.com/help-me-im-a-path/")
240
+ # # => 'benbalter.github.com'
241
+ #
242
+ # Return the hostname.
243
+ def host_from_uri(domain)
244
+ Addressable::URI.parse(domain).host || Addressable::URI.parse("http://#{domain}").host
245
+ end
246
+
247
+ # Adjust `domain` so that it won't be searched for with /etc/resolv.conf's search rules.
248
+ #
249
+ # GitHubPages::HealthCheck.new("anything.io").absolute_domain
250
+ # => "anything.io."
251
+ def absolute_domain
252
+ host.end_with?(".") ? host : "#{host}."
253
+ end
254
+
255
+ def scheme
256
+ @scheme ||= github_domain? ? "https" : "http"
257
+ end
258
+ end
259
+ end
260
+ end
@@ -1,11 +1,12 @@
1
- class GitHubPages
2
- class HealthCheck
1
+ module GitHubPages
2
+ module HealthCheck
3
3
  class Error < StandardError
4
- def message
5
- "Invalid domain"
4
+ def self.inherited(base)
5
+ subclasses << base
6
6
  end
7
- def to_s
8
- message
7
+
8
+ def self.subclasses
9
+ @subclasses ||= []
9
10
  end
10
11
  end
11
12
  end
@@ -0,0 +1,13 @@
1
+ Dir[File.expand_path("../errors/*_error.rb", __FILE__)].each do |f|
2
+ require f
3
+ end
4
+
5
+ module GitHubPages
6
+ module HealthCheck
7
+ module Errors
8
+ def self.all
9
+ Error.subclasses
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class BuildError < GitHubPages::HealthCheck::Error
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class DeprecatedIPError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "A record points to deprecated IP address"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class InvalidARecordError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Should not be an A record"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class InvalidCNAMEError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "CNAME does not point to GitHub Pages"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class InvalidDNSError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Domain's DNS record could not be retrieved"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class InvalidDomainError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Domain is not a valid domain"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class InvalidRepositoryError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Repository is not a valid repository"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class MissingAccessTokenError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Cannot retrieve repository information with a valid access token"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module GitHubPages
2
+ module HealthCheck
3
+ module Errors
4
+ class NotServedByPagesError < GitHubPages::HealthCheck::Error
5
+ def message
6
+ "Domain does not resolve to the GitHub Pages server"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end