chatops-controller 3.0.3
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 +7 -0
- data/README.md +204 -0
- data/lib/chatops-controller.rb +1 -0
- data/lib/chatops.rb +21 -0
- data/lib/chatops/controller.rb +209 -0
- data/lib/chatops/controller/rspec.rb +5 -0
- data/lib/chatops/controller/test_case.rb +5 -0
- data/lib/chatops/controller/test_case_helpers.rb +91 -0
- data/lib/chatops/controller/version.rb +3 -0
- data/spec/dummy/README.rdoc +28 -0
- data/spec/dummy/Rakefile +6 -0
- data/spec/dummy/app/assets/javascripts/application.js +13 -0
- data/spec/dummy/app/assets/stylesheets/application.css +15 -0
- data/spec/dummy/app/controllers/application_controller.rb +5 -0
- data/spec/dummy/app/helpers/application_helper.rb +2 -0
- data/spec/dummy/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy/bin/bundle +3 -0
- data/spec/dummy/bin/rails +4 -0
- data/spec/dummy/bin/rake +4 -0
- data/spec/dummy/bin/setup +29 -0
- data/spec/dummy/config.ru +4 -0
- data/spec/dummy/config/application.rb +24 -0
- data/spec/dummy/config/boot.rb +5 -0
- data/spec/dummy/config/database.yml +25 -0
- data/spec/dummy/config/environment.rb +5 -0
- data/spec/dummy/config/environments/development.rb +41 -0
- data/spec/dummy/config/environments/production.rb +79 -0
- data/spec/dummy/config/environments/test.rb +46 -0
- data/spec/dummy/config/initializers/assets.rb +13 -0
- data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/spec/dummy/config/initializers/inflections.rb +16 -0
- data/spec/dummy/config/initializers/mime_types.rb +4 -0
- data/spec/dummy/config/initializers/session_store.rb +3 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy/config/locales/en.yml +23 -0
- data/spec/dummy/config/routes.rb +56 -0
- data/spec/dummy/config/secrets.yml +22 -0
- data/spec/dummy/db/development.sqlite3 +0 -0
- data/spec/dummy/db/schema.rb +16 -0
- data/spec/dummy/db/test.sqlite3 +0 -0
- data/spec/dummy/log/test.log +3830 -0
- data/spec/dummy/public/404.html +67 -0
- data/spec/dummy/public/422.html +67 -0
- data/spec/dummy/public/500.html +66 -0
- data/spec/dummy/public/favicon.ico +0 -0
- data/spec/dummy/spec +1 -0
- data/spec/lib/chatops/controller_spec.rb +376 -0
- data/spec/rails_helper.rb +65 -0
- data/spec/spec_helper.rb +92 -0
- data/spec/support/json_response.rb +28 -0
- metadata +195 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: eb59e0c9f0d9fcac1924c0faa12c5b0bfb9e8762
|
4
|
+
data.tar.gz: e7f3395d1105eccabb0e1552e5580a57c04c40fe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 949d8c2702e391ffe3d1eef5bd45f5a2741a81ff5e1684ed9d376bb07a1ffd5378e29a60c6e654611da25ca9f95d2798a5cd4db503420f698243c2efd6f7e004
|
7
|
+
data.tar.gz: d1d3878610807932e90a3925dbadb9f873098dd3d1e9810ffb37fdc4dffef268707c9fa15f4bd40667f6fe980ab1e25da07d234d6b56983c16d6a57d68d1255c
|
data/README.md
ADDED
@@ -0,0 +1,204 @@
|
|
1
|
+
# Chatops Controller
|
2
|
+
|
3
|
+
Rails helpers for easy and well-tested Chatops RPC. See the [protocol docs](docs/protocol-description.md)
|
4
|
+
for background information on Chatops RPC.
|
5
|
+
|
6
|
+
A minimal controller example:
|
7
|
+
|
8
|
+
```ruby
|
9
|
+
class ChatopsController < ApplicationController
|
10
|
+
include ::Chatops::Controller
|
11
|
+
|
12
|
+
# The default chatops RPC prefix. Clients may replace this.
|
13
|
+
chatops_namespace :echo
|
14
|
+
|
15
|
+
chatop :echo,
|
16
|
+
/(?<text>.*)?/,
|
17
|
+
"<text> - Echo some text back" do
|
18
|
+
jsonrpc_success "Echoing back to you: #{jsonrpc_params[:text]}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
```
|
22
|
+
|
23
|
+
Some routing boilerplate is required in `config/routes.rb`:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
Rails.application.routes.draw do
|
27
|
+
# Replace the controller: argument with your controller's name
|
28
|
+
post "/_chatops/:chatop", controller: "chatops", action: :execute_chatop
|
29
|
+
get "/_chatops" => "chatops#list"
|
30
|
+
end
|
31
|
+
```
|
32
|
+
|
33
|
+
It's easy to test:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class MyControllerTestCase < ActionController::TestCase
|
37
|
+
include Chatops::Controller::TestCaseHelpers
|
38
|
+
before do
|
39
|
+
chatops_prefix "echo"
|
40
|
+
chatops_auth!
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_it_works
|
44
|
+
chat "echo foo bar baz"
|
45
|
+
assert_equal "foo bar baz", chatop_response
|
46
|
+
end
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
Before you deploy, add the RPC authentication tokens to your app's environment,
|
51
|
+
below.
|
52
|
+
|
53
|
+
You're all done. Try `.echo foo`, and you should see your client respond with
|
54
|
+
`Echoing back to you: foo`.
|
55
|
+
|
56
|
+
A hubot client implementation is available at
|
57
|
+
<https://github.com/hubot-scripts/hubot-chatops-rpc>
|
58
|
+
|
59
|
+
## Usage
|
60
|
+
|
61
|
+
#### Namespaces
|
62
|
+
|
63
|
+
Every chatops controller has a namespace. All commands associated with this
|
64
|
+
controller will be displayed with `.<namespace>` in chat. The namespace is a
|
65
|
+
default chatops RPC prefix and may be overridden by a client.
|
66
|
+
|
67
|
+
```
|
68
|
+
chatops_namespace :foo
|
69
|
+
```
|
70
|
+
|
71
|
+
#### Creating Chatops
|
72
|
+
|
73
|
+
Creating a chatop is a DSL:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
chatop :echo,
|
77
|
+
/(?<text>.*)?/,
|
78
|
+
"<text> - Echo some text back" do
|
79
|
+
jsonrpc_success "Echoing back to you: #{jsonrpc_params[:text]}"
|
80
|
+
end
|
81
|
+
```
|
82
|
+
|
83
|
+
In this example, we've created a chatop called `echo`. The next argument is a
|
84
|
+
regular expression with [named
|
85
|
+
captures](http://ruby-doc.org/core-1.9.3/Regexp.html#method-i-named_captures).
|
86
|
+
In this example, only one capture group is available, `text`.
|
87
|
+
|
88
|
+
The next line is a string, which is a single line of help that will be displayed
|
89
|
+
in chat for `.echo`.
|
90
|
+
|
91
|
+
The DSL takes a block, which is the code that will run when the chat robot sees
|
92
|
+
this regex. Arguments will be available in the `params` hash. `params[:user]`
|
93
|
+
and `params[:room_id]` are special, and will be set by the client. `user` will
|
94
|
+
always be the login of the user typing the command, and `room_id` will be where
|
95
|
+
it was typed.
|
96
|
+
|
97
|
+
You can return `jsonrpc_success` with a string to return text to chat. If you
|
98
|
+
have an input validation or other handle-able error, you can use
|
99
|
+
`jsonrpc_failure` to send a helpful error message.
|
100
|
+
|
101
|
+
Chatops are regular old rails controller actions, and you can use niceties like
|
102
|
+
`before_action` and friends. `before_action :echo, :load_user` for the above
|
103
|
+
case would call `load_user` before running `echo`.
|
104
|
+
|
105
|
+
## Authentication
|
106
|
+
|
107
|
+
Authentication uses the Chatops v3 public key signing protocol. You'll need
|
108
|
+
two environment variables to use this protocol:
|
109
|
+
|
110
|
+
`CHATOPS_AUTH_PUBLIC_KEY` is the public key of your chatops client in PEM
|
111
|
+
format. This environment variable will be the contents of a `.pub` file,
|
112
|
+
newlines and all.
|
113
|
+
|
114
|
+
`CHATOPS_AUTH_BASE_URL` is the base URL of your server as the chatops client
|
115
|
+
sees it. This is specified as an environment variable since rails will trust
|
116
|
+
client headers about a forwarded hostname. For example, if your chatops client
|
117
|
+
has added the url `https://example.com/_chatops`, you'd set this to
|
118
|
+
`https://example.com`.
|
119
|
+
|
120
|
+
You can also optionally set `CHATOPS_AUTH_ALT_PUBLIC_KEY` to a second public key
|
121
|
+
which will be accepted. This is helpful when rolling keys.
|
122
|
+
|
123
|
+
## Rails compatibility
|
124
|
+
|
125
|
+
This gem is intended to work with rails 4.x and 5.x. If you find a version
|
126
|
+
with a problem, please report it in an issue.
|
127
|
+
|
128
|
+
## Development
|
129
|
+
|
130
|
+
Changes are welcome. Getting started:
|
131
|
+
|
132
|
+
```
|
133
|
+
script/bootstrap
|
134
|
+
script/test
|
135
|
+
```
|
136
|
+
|
137
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution instructions.
|
138
|
+
|
139
|
+
## Upgrading from early versions
|
140
|
+
|
141
|
+
Early versions of RPC chatops had two major changes:
|
142
|
+
|
143
|
+
##### Using Rails' dynamic `:action` routing, which was deprecated in Rails 5.
|
144
|
+
|
145
|
+
To work around this, you need to update your router boilerplate:
|
146
|
+
|
147
|
+
This:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
post "/_chatops/:action", controller: "chatops"
|
151
|
+
```
|
152
|
+
|
153
|
+
Becomes this:
|
154
|
+
|
155
|
+
```ruby
|
156
|
+
post "/_chatops/:chatop", controller: "chatops" action: :execute_chatop
|
157
|
+
```
|
158
|
+
|
159
|
+
##### Adding a prefix
|
160
|
+
|
161
|
+
Version 2 of the Chatops RPC protocol assumes a unique prefix for each endpoint. This decision was made for several reasons:
|
162
|
+
|
163
|
+
* The previous suffix-based system creates semantic ambiguities with keyword arguments
|
164
|
+
* Prefixes allow big improvements to `.help`
|
165
|
+
* Prefixes make regex-clobbering impossible
|
166
|
+
|
167
|
+
To upgrade to version 2, upgrade to version 2.x of this gem. To migrate:
|
168
|
+
|
169
|
+
* Migrate your chatops to remove any prefixes you have:
|
170
|
+
|
171
|
+
```ruby
|
172
|
+
chatop :foo, "help", /ci build whatever/, do "yay" end
|
173
|
+
```
|
174
|
+
|
175
|
+
Becomes:
|
176
|
+
|
177
|
+
```ruby
|
178
|
+
chatop :foo, "help", /build whatever/, do "yay" end
|
179
|
+
```
|
180
|
+
|
181
|
+
* Update your tests:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
chat "ci build foobar"
|
185
|
+
```
|
186
|
+
|
187
|
+
Becomes:
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
chat "build foobar"
|
191
|
+
# or
|
192
|
+
chatops_prefix "ci"
|
193
|
+
chat "ci build foobar"
|
194
|
+
```
|
195
|
+
|
196
|
+
##### Using public key authentication
|
197
|
+
|
198
|
+
Previous versions used a `CHATOPS_ALT_AUTH_TOKEN` as a shared secret. This form
|
199
|
+
of authentication was deprecated and the public key form used above is now
|
200
|
+
used instead.
|
201
|
+
|
202
|
+
### License
|
203
|
+
|
204
|
+
MIT. See the accompanying LICENSE file.
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'chatops/controller'
|
data/lib/chatops.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Chatops
|
2
|
+
def self.public_key
|
3
|
+
ENV[public_key_env_var_name]
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.public_key_env_var_name
|
7
|
+
"CHATOPS_AUTH_PUBLIC_KEY"
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.alt_public_key
|
11
|
+
ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"]
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.auth_base_url
|
15
|
+
ENV[auth_base_url_env_var_name]
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.auth_base_url_env_var_name
|
19
|
+
"CHATOPS_AUTH_BASE_URL"
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,209 @@
|
|
1
|
+
require "chatops"
|
2
|
+
|
3
|
+
module Chatops
|
4
|
+
module Controller
|
5
|
+
class ConfigurationError < StandardError ; end
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
before_action :ensure_valid_chatops_url, if: :should_authenticate_chatops?
|
10
|
+
before_action :ensure_valid_chatops_timestamp, if: :should_authenticate_chatops?
|
11
|
+
before_action :ensure_valid_chatops_signature, if: :should_authenticate_chatops?
|
12
|
+
before_action :ensure_valid_chatops_nonce, if: :should_authenticate_chatops?
|
13
|
+
before_action :ensure_chatops_authenticated, if: :should_authenticate_chatops?
|
14
|
+
before_action :ensure_user_given
|
15
|
+
before_action :ensure_method_exists
|
16
|
+
end
|
17
|
+
|
18
|
+
def list
|
19
|
+
chatops = self.class.chatops
|
20
|
+
chatops.each { |name, hash| hash[:path] = name }
|
21
|
+
render :json => {
|
22
|
+
namespace: self.class.chatops_namespace,
|
23
|
+
help: self.class.chatops_help,
|
24
|
+
error_response: self.class.chatops_error_response,
|
25
|
+
methods: chatops,
|
26
|
+
version: "3" }
|
27
|
+
end
|
28
|
+
|
29
|
+
def process(*args)
|
30
|
+
scrubbed_params = jsonrpc_params.except(
|
31
|
+
:user, :method, :controller, :action, :params, :room_id)
|
32
|
+
|
33
|
+
scrubbed_params.each { |k, v| params[k] = v }
|
34
|
+
|
35
|
+
if params[:chatop].present?
|
36
|
+
params[:action] = params[:chatop]
|
37
|
+
args[0] = params[:action]
|
38
|
+
unless self.respond_to?(params[:chatop].to_sym)
|
39
|
+
raise AbstractController::ActionNotFound
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
super *args
|
44
|
+
rescue AbstractController::ActionNotFound
|
45
|
+
return jsonrpc_method_not_found
|
46
|
+
end
|
47
|
+
|
48
|
+
def execute_chatop
|
49
|
+
# This needs to exist for route declarations, but we'll be overriding
|
50
|
+
# things in #process to make a method the action.
|
51
|
+
end
|
52
|
+
|
53
|
+
protected
|
54
|
+
|
55
|
+
def jsonrpc_params
|
56
|
+
params["params"] || {}
|
57
|
+
end
|
58
|
+
|
59
|
+
def jsonrpc_success(message)
|
60
|
+
jsonrpc_response :result => message.to_s
|
61
|
+
end
|
62
|
+
alias_method :chatop_send, :jsonrpc_success
|
63
|
+
|
64
|
+
def jsonrpc_parse_error
|
65
|
+
jsonrpc_error(-32700, 500, "Parse error")
|
66
|
+
end
|
67
|
+
|
68
|
+
def jsonrpc_invalid_request
|
69
|
+
jsonrpc_error(-32600, 400, "Invalid request")
|
70
|
+
end
|
71
|
+
|
72
|
+
def jsonrpc_method_not_found
|
73
|
+
jsonrpc_error(-32601, 404, "Method not found")
|
74
|
+
end
|
75
|
+
|
76
|
+
def jsonrpc_invalid_params(message)
|
77
|
+
message ||= "Invalid parameters"
|
78
|
+
jsonrpc_error(-32602, 400, message.to_s)
|
79
|
+
end
|
80
|
+
alias_method :jsonrpc_failure, :jsonrpc_invalid_params
|
81
|
+
|
82
|
+
def jsonrpc_error(number, http_status, message)
|
83
|
+
jsonrpc_response({ :error => { :code => number, :message => message.to_s } }, http_status)
|
84
|
+
end
|
85
|
+
|
86
|
+
def jsonrpc_response(hash, http_status = nil)
|
87
|
+
http_status ||= 200
|
88
|
+
render :status => http_status,
|
89
|
+
:json => { :jsonrpc => "2.0",
|
90
|
+
:id => params[:id] }.merge(hash)
|
91
|
+
end
|
92
|
+
|
93
|
+
def ensure_user_given
|
94
|
+
return true unless chatop_names.include?(params[:action].to_sym)
|
95
|
+
return true if params[:user].present?
|
96
|
+
jsonrpc_invalid_params("A username must be supplied as 'user'")
|
97
|
+
end
|
98
|
+
|
99
|
+
def ensure_chatops_authenticated
|
100
|
+
body = request.raw_post || ""
|
101
|
+
signature_string = [@chatops_url, @chatops_nonce, @chatops_timestamp, body].join("\n")
|
102
|
+
# We return this just to aid client debugging.
|
103
|
+
response.headers["Chatops-Signature-String"] = Base64.strict_encode64(signature_string)
|
104
|
+
raise ConfigurationError.new("You need to add a client's public key in .pem format via #{Chatops.public_key_env_var_name}") unless Chatops.public_key.present?
|
105
|
+
if signature_valid?(Chatops.public_key, @chatops_signature, signature_string) ||
|
106
|
+
signature_valid?(Chatops.alt_public_key, @chatops_signature, signature_string)
|
107
|
+
return true
|
108
|
+
end
|
109
|
+
return render :status => :forbidden, :plain => "Not authorized"
|
110
|
+
end
|
111
|
+
|
112
|
+
def ensure_valid_chatops_url
|
113
|
+
unless Chatops.auth_base_url.present?
|
114
|
+
raise ConfigurationError.new("You need to set the server's base URL to authenticate chatops RPC via #{Chatops.auth_base_url_env_var_name}")
|
115
|
+
end
|
116
|
+
if Chatops.auth_base_url[-1] == "/"
|
117
|
+
raise ConfigurationError.new("Don't include a trailing slash in #{Chatops.auth_base_url_env_var_name}; the rails path will be appended and it must match exactly.")
|
118
|
+
end
|
119
|
+
@chatops_url = Chatops.auth_base_url + request.path
|
120
|
+
end
|
121
|
+
|
122
|
+
def ensure_valid_chatops_nonce
|
123
|
+
@chatops_nonce = request.headers["Chatops-Nonce"]
|
124
|
+
return render :status => :forbidden, :plain => "A Chatops-Nonce header is required" unless @chatops_nonce.present?
|
125
|
+
end
|
126
|
+
|
127
|
+
def ensure_valid_chatops_signature
|
128
|
+
signature_header = request.headers["Chatops-Signature"]
|
129
|
+
|
130
|
+
begin
|
131
|
+
# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
|
132
|
+
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
|
133
|
+
@chatops_signature = signature_items["signature"]
|
134
|
+
rescue NoMethodError
|
135
|
+
# The signature header munging, if something's amiss, can produce a `nil` that raises a
|
136
|
+
# no method error. We'll just carry on; the nil signature will raise below
|
137
|
+
end
|
138
|
+
|
139
|
+
unless @chatops_signature.present?
|
140
|
+
return render :status => :forbidden, :plain => "Failed to parse signature header"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def ensure_valid_chatops_timestamp
|
145
|
+
@chatops_timestamp = request.headers["Chatops-Timestamp"]
|
146
|
+
time = Time.iso8601(@chatops_timestamp)
|
147
|
+
if !(time > 1.minute.ago && time < 1.minute.from_now)
|
148
|
+
return render :status => :forbidden, :plain => "Chatops timestamp not within 1 minute of server time: #{@chatops_timestamp} vs #{Time.now.utc.iso8601}"
|
149
|
+
end
|
150
|
+
rescue ArgumentError, TypeError
|
151
|
+
# time parsing or missing can raise these
|
152
|
+
return render :status => :forbidden, :plain => "Invalid Chatops-Timestamp: #{@chatops_timestamp}"
|
153
|
+
end
|
154
|
+
|
155
|
+
def request_is_chatop?
|
156
|
+
(chatop_names + [:list]).include?(params[:action].to_sym)
|
157
|
+
end
|
158
|
+
|
159
|
+
def chatops_test_auth?
|
160
|
+
Rails.env.test? && request.env["CHATOPS_TESTING_AUTH"]
|
161
|
+
end
|
162
|
+
|
163
|
+
def should_authenticate_chatops?
|
164
|
+
request_is_chatop? && !chatops_test_auth?
|
165
|
+
end
|
166
|
+
|
167
|
+
def signature_valid?(key_string, signature, signature_string)
|
168
|
+
return false unless key_string.present?
|
169
|
+
digest = OpenSSL::Digest::SHA256.new
|
170
|
+
decoded_signature = Base64.decode64(signature)
|
171
|
+
public_key = OpenSSL::PKey::RSA.new(key_string)
|
172
|
+
public_key.verify(digest, decoded_signature, signature_string)
|
173
|
+
end
|
174
|
+
|
175
|
+
def ensure_method_exists
|
176
|
+
return jsonrpc_method_not_found unless (chatop_names + [:list]).include?(params[:action].to_sym)
|
177
|
+
end
|
178
|
+
|
179
|
+
def chatop_names
|
180
|
+
self.class.chatops.keys
|
181
|
+
end
|
182
|
+
|
183
|
+
module ClassMethods
|
184
|
+
def chatop(method_name, regex, help, &block)
|
185
|
+
chatops[method_name] = { help: help,
|
186
|
+
regex: regex.source,
|
187
|
+
params: regex.names }
|
188
|
+
define_method method_name, &block
|
189
|
+
end
|
190
|
+
|
191
|
+
%w{namespace help error_response}.each do |setting|
|
192
|
+
method_name = "chatops_#{setting}".to_sym
|
193
|
+
variable_name = "@#{method_name}".to_sym
|
194
|
+
define_method method_name do |*args|
|
195
|
+
assignment = args.first
|
196
|
+
if assignment.present?
|
197
|
+
instance_variable_set variable_name, assignment
|
198
|
+
end
|
199
|
+
instance_variable_get variable_name.to_sym
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def chatops
|
204
|
+
@chatops ||= {}
|
205
|
+
@chatops
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|