cronwatch 0.6.1 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f515d13b757f2063defbf1c390f41d8970417a02991d5f137639221daa095c9
4
- data.tar.gz: 8843a960d7249f58200456b706ce60cc8ecd5cb7490be84639aed8ff0bf5b639
3
+ metadata.gz: 4717ad1d0fcfb080755faed7d4f018257f1b382e3076ab3e16d6830d9f985732
4
+ data.tar.gz: a907c315c80b237b09eb40087cfa7f28f5ed261494686b00f4c6df3710afeaf1
5
5
  SHA512:
6
- metadata.gz: d95b276c86d464ab53c871f1967c5880822bcf677c7a96384cf7ee42f6d02f73da7eccf244aaceff5e4bd72ee06886f1dc025fe342e239b28ae5d74c87a5caf8
7
- data.tar.gz: 16c1ac8fcb3f1e18b1f81e7447d7a237fd80e4acc5276e06d99e55a2802b2cea3aca40372a4fd7b7679e3558072cdb31e0bfd1a6dda6aa1321ad73bd2e995247
6
+ metadata.gz: 201500f44422be56f7c741a3dfd200ef7b0eabd893b3ea7466b8c3504f36d834f07d2a3a41e55d5035255abba46c0462498c9920c8c0d12e19843f9e3d8b1daa
7
+ data.tar.gz: c6fd81253765bd342f698cfa93ff66a87b13e20fee82d7d6c48972d8c25769b8cb48db1971bc082cfb0fc1660f1900535279135169514615724668b1aa21e0c4
data/README.md CHANGED
@@ -168,7 +168,7 @@ Rails.application.routes.draw do
168
168
  end
169
169
  ```
170
170
 
171
- Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while `RAILS_ENV` or `RACK_ENV` is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
171
+ Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while `RAILS_ENV` or `RACK_ENV` is `development` or `test`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
172
172
 
173
173
  There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
174
174
 
@@ -5,6 +5,12 @@ module Cronwatch
5
5
  module Duration
6
6
  UNIT_MS = { "ms" => 1, "s" => 1000, "m" => 60_000, "h" => 3_600_000, "d" => 86_400_000, "w" => 604_800_000 }.freeze
7
7
  PART = Regexp.new("(\\d+(?:\\.\\d+)?)[#{JS::WHITESPACE}]*(ms|s|m|h|d|w)")
8
+ # The longest duration string read, in characters. No real duration
9
+ # comes near it, and PART is quadratic on a long run of digits, so a
10
+ # longer string is refused before it is read.
11
+ MAX_LENGTH = 64
12
+ # How much of a refused, overlong string its error quotes.
13
+ QUOTED = 32
8
14
 
9
15
  module_function
10
16
 
@@ -29,6 +35,10 @@ module Cronwatch
29
35
  raise ArgumentError, "#{label} \"#{value}\" is not a duration like \"15m\", \"1h30m\" or \"90s\""
30
36
  end
31
37
 
38
+ if value.length > MAX_LENGTH
39
+ raise ArgumentError, "#{label} \"#{value[0, QUOTED]}...\" is too long for a duration (more than #{MAX_LENGTH} characters)"
40
+ end
41
+
32
42
  text = JS.trim(value).downcase
33
43
  raise ArgumentError, "#{label} is empty" if text.empty?
34
44
 
@@ -4,6 +4,15 @@ module Cronwatch
4
4
  module Serialize
5
5
  module_function
6
6
 
7
+ # How long one expect pattern may take over a run's output, in seconds.
8
+ # Onigmo memoizes most patterns into linear time, but not one with a
9
+ # backreference or a lookaround, which can backtrack for minutes over an
10
+ # output it does not match. A match that times out does not match, so
11
+ # the run fails as the other ports' bounded engines fail it. A pattern
12
+ # with a shorter timeout of its own (or a shorter Regexp.timeout) keeps
13
+ # it.
14
+ PATTERN_TIMEOUT = 1.0
15
+
7
16
  # A definition as a store can hold it: `expect` becomes a description, and
8
17
  # moves to the end, as it does in the SDK.
9
18
  def to_stored(definition)
@@ -36,7 +45,7 @@ module Cronwatch
36
45
  # match? keeps no position between calls (JavaScript's /g and /y do,
37
46
  # which is why the SDK resets lastIndex) and does not touch $~, so
38
47
  # every run is checked from the start whatever the flags.
39
- expect.match?(text) ? nil : "Output did not match #{expect.inspect}"
48
+ matches?(expect, text) ? nil : "Output did not match #{expect.inspect}"
40
49
  else
41
50
  ok = false
42
51
  begin
@@ -47,5 +56,13 @@ module Cronwatch
47
56
  ok ? nil : "Output did not pass the expect() check"
48
57
  end
49
58
  end
59
+
60
+ # Whether the pattern matches within PATTERN_TIMEOUT; a timeout is no.
61
+ def matches?(pattern, text)
62
+ limit = [pattern.timeout, Regexp.timeout, PATTERN_TIMEOUT].compact.min
63
+ Regexp.new(pattern, timeout: limit).match?(text)
64
+ rescue Regexp::TimeoutError
65
+ false
66
+ end
50
67
  end
51
68
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.6.1"
5
+ VERSION = "0.7.0"
6
6
  end
@@ -92,12 +92,36 @@ module Cronwatch
92
92
  end
93
93
 
94
94
  # The line a development token is announced with, printed once to stdout
95
- # on the app's first request. `origin` is that request's origin (scheme,
96
- # host and any port), `base` the base path without a trailing slash (""
97
- # when mounted at the root).
95
+ # on the app's first request. `origin` is the `origin:` option when set,
96
+ # otherwise that request's origin (scheme, host and any port) when its
97
+ # host is loopback, and nil for any other host: the request's host is
98
+ # the client's to choose, so the line then leaves it out rather than
99
+ # point the link, token and all, somewhere else. `base` is the base path
100
+ # without a trailing slash ("" when mounted at the root).
98
101
  def self.development_sign_in_line(origin, base, token)
99
- "[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. " \
100
- "Sign in: #{origin}#{base}/?token=#{token}"
102
+ intro = "[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. Sign in: "
103
+ return "#{intro}#{base}/?token=#{token} on this server (the first request's host is not local, so the link leaves it out)" if origin.nil?
104
+
105
+ "#{intro}#{origin}#{base}/?token=#{token}"
106
+ end
107
+
108
+ # Whether an origin's host is loopback: "localhost", a name ending in
109
+ # ".localhost", an IPv4 address in 127.0.0.0/8, or the IPv6 address ::1.
110
+ # Only an origin that reads as one counts: a Host header is anyone's to
111
+ # send, and one such as "evil.example/.localhost" or
112
+ # "localhost:1@evil.example" must not put the development token in a
113
+ # link to another host.
114
+ def self.loopback_origin?(origin)
115
+ bare = Origin.bare(origin.to_s)
116
+ return false if bare.nil?
117
+
118
+ authority = bare.sub(%r{\A[a-z]+://}, "")
119
+ host = authority.start_with?("[") ? authority[0..(authority.index("]") || -1)] : authority.split(":", 2).first.to_s
120
+ host = host.downcase
121
+ return true if host == "localhost" || host.end_with?(".localhost") || host == "[::1]"
122
+
123
+ octets = /\A127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\z/.match(host)
124
+ !octets.nil? && octets.captures.all? { |o| o.to_i <= 255 }
101
125
  end
102
126
 
103
127
  # The `origin` option, normalised to scheme://host[:port], or nil when
@@ -319,7 +343,8 @@ module Cronwatch
319
343
  end
320
344
  return unless first
321
345
 
322
- $stdout.puts(Web.development_sign_in_line(public_origin(request), base, @token))
346
+ shown = @origin || (Web.loopback_origin?(request.origin) ? request.origin : nil)
347
+ $stdout.puts(Web.development_sign_in_line(shown, base, @token))
323
348
  $stdout.flush
324
349
  end
325
350
 
@@ -39,6 +39,33 @@ module Cronwatch
39
39
  "#{scheme}://#{host}#{port}"
40
40
  end
41
41
 
42
+ # scheme://host[:port] for text that is a scheme and a bare host, or nil
43
+ # when it carries credentials, a path, a query or a fragment, or is not
44
+ # an http or https URL.
45
+ def bare(value)
46
+ return nil unless value.is_a?(String)
47
+
48
+ text = value.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "").delete("\t\n\r")
49
+ match = text.match(%r{\A[A-Za-z][A-Za-z0-9+.\-]*:[/\\]*([^/\\?#]*)(.*)\z}m)
50
+ return nil if match.nil?
51
+
52
+ userinfo, at, = match[1].rpartition("@")
53
+ return nil if !at.empty? && !userinfo.empty? && userinfo != ":"
54
+ return nil if past_host?(match[2])
55
+
56
+ parse(text)
57
+ rescue ArgumentError
58
+ nil
59
+ end
60
+
61
+ # Whether what follows the host would show in the URL: a path other
62
+ # than "/", a query or a fragment.
63
+ def past_host?(after)
64
+ path, hash, fragment = after.partition("#")
65
+ path, _, query = path.partition("?")
66
+ (!path.empty? && path != "/" && path != "\\") || !query.empty? || (!hash.empty? && !fragment.empty?)
67
+ end
68
+
42
69
  # [host, port] with the scheme's default port when none is given.
43
70
  def split_port(authority, value)
44
71
  if authority.start_with?("[")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips