chatops-controller 3.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|