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 +4 -4
- data/README.md +1 -1
- data/lib/cronwatch/duration.rb +10 -0
- data/lib/cronwatch/serialize.rb +18 -1
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +31 -6
- data/lib/cronwatch/web/origin.rb +27 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4717ad1d0fcfb080755faed7d4f018257f1b382e3076ab3e16d6830d9f985732
|
|
4
|
+
data.tar.gz: a907c315c80b237b09eb40087cfa7f28f5ed261494686b00f4c6df3710afeaf1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/cronwatch/duration.rb
CHANGED
|
@@ -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
|
|
data/lib/cronwatch/serialize.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/cronwatch/version.rb
CHANGED
data/lib/cronwatch/web/app.rb
CHANGED
|
@@ -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
|
|
96
|
-
# host and any port)
|
|
97
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/cronwatch/web/origin.rb
CHANGED
|
@@ -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?("[")
|