-
Notifications
You must be signed in to change notification settings - Fork 50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement webhook plugin #104
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
# "Webhook" DE1app plugin | ||
|
||
This plugin provides functionality to trigger a webhook after a shot has completed. | ||
|
||
Much of the code is directly inspired by the `visualizer_upload` plugin. | ||
|
||
## Why? | ||
|
||
If you are a programmer, you may have some automations or other things you may want to trigger as a result of pulling a shot of espresso. Having a way to receive a webhook, or push notification in a sense, makes it much easier to trigger your automation. | ||
|
||
## Settings | ||
|
||
The plugin provides the following settings to be configured: | ||
|
||
- `webhook_domain` (default: `example.com`) - The domain/origin for the website serving the webhook. This should _not_ include the protocol or any part of the path. | ||
- `webhook_endpoint` (default: `/decent/webhook`) - The resource path where the webhook should POST to. | ||
- `webhook_secret_key` (default: `SecretKeyForSigning`) - This is the key used for generating the HMAC signature, which is passed as part of the `Authorization` header. | ||
- `webhook_trigger_after` (default: `10`) - Number of seconds to wait after flow completes before triggering the webhook. | ||
|
||
## Behavior | ||
|
||
- After a shot completes (`after_flow_complete`) and after the built-in `after_flow_complete_delay`, wait an additional `webhook_trigger_after` seconds. | ||
- Send a `POST` request to the specified webhook URL | ||
- Connect to the `webhook_domain` over TLS. | ||
- Construct the body as a file attachment of the history file | ||
- Calculate the HMAC signature | ||
- Send the `POST` request with the appropriate `Authorization` header to `https://$webhook_domain$webhook_endpoint`. | ||
|
||
## HMAC Verification | ||
|
||
As a best practice with webhooks, you should be verifying the authenticity of the message being sent. We use HMAC to accomplish this to verify both the sender's identity and the message's validity. The plugin will automatically calculate an HMAC signature and send it in the `Authorization` header. The header will look something like below: | ||
|
||
``` | ||
Authorization: 1617092331!f728a8092b6882749efa61a6609414d14c6f25286bdb1fd76e0dda4bab0f0cdf | ||
^ clock ^ HMAC signature | ||
``` | ||
|
||
Where `1617092331` represents the clock at the time of the shot, and `f728a8092b6882749efa61a6609414d14c6f25286bdb1fd76e0dda4bab0f0cdf` is the calculated HMAC signature. Note that they are delimited by the character `!`. | ||
|
||
The signature calculation for verifying on your server-side should look like the following: | ||
|
||
``` | ||
HMAC_SHA256($webhook_secret_key, "$webhook_endpoint\n$clock\n$body") | ||
``` | ||
|
||
It can also be good to check against replay attacks by verifying that the clock time is not too old. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
package require http | ||
package require sha256 | ||
|
||
set plugin_name "webhook" | ||
|
||
namespace eval ::plugins::${plugin_name} { | ||
|
||
variable author "Kevin Gao" | ||
variable contact "@sudowork" | ||
variable version 1.0 | ||
variable description "Sends shot data as a webhook to a specified URL" | ||
variable name "Webhook" | ||
|
||
proc preload {} { | ||
return [build_settings_ui] | ||
} | ||
|
||
proc build_settings_ui {} { | ||
# Create settings if non-existant | ||
if {[array size ::plugins::webhook::settings] == 0} { | ||
array set ::plugins::webhook::settings { | ||
webhook_domain "example.com" | ||
webhook_endpoint "/decent/webhook" | ||
webhook_secret_key "SecretKeyForSigning" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can't have a default password on a device that is sold in California unless it is device specific. ref: https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=201720180SB327 Edit: This may not be an incoming password, but a signing secret. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this is just a signing secret for an outbound request. The signature is then attached as a header to the outbound request. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not make the password blank, and required to be created by the user before the plugin will work? |
||
webhook_trigger_after 10 | ||
} | ||
plugins save_settings webhook | ||
} | ||
|
||
# Unique name per page | ||
set page_name "plugin_webhook_page_default" | ||
|
||
# Background image and "Done" button | ||
add_de1_page "$page_name" "settings_message.png" "default" | ||
add_de1_text $page_name 1280 1310 -text [translate "Done"] -font Helv_10_bold -fill "#fAfBff" -anchor "center" | ||
add_de1_button $page_name {say [translate {Done}] $::settings(sound_button_in); page_to_show_when_off extensions} 980 1210 1580 1410 "" | ||
|
||
# Headline | ||
add_de1_text $page_name 1280 300 -text [translate "Webhook"] -font Helv_20_bold -width 1200 -fill "#444444" -anchor "center" -justify "center" | ||
|
||
# Render Settings | ||
build_setting_text_input $page_name [translate "Webhook Domain"] {::plugins::webhook::settings(webhook_domain)} 480 | ||
build_setting_text_input $page_name [translate "Webhook Endpoint"] {::plugins::webhook::settings(webhook_endpoint)} 660 | ||
build_setting_text_input $page_name [translate "Webhook Secret Key"] {::plugins::webhook::settings(webhook_secret_key)} 840 | ||
build_setting_text_input $page_name [translate "Trigger After (seconds)"] {::plugins::webhook::settings(webhook_trigger_after)} 1020 | ||
|
||
return $page_name | ||
} | ||
|
||
proc build_setting_text_input {page_name label var y} { | ||
set x 280 | ||
add_de1_text $page_name $x $y -text $label -font Helv_8 -width 300 -fill "#444444" -anchor "nw" -justify "center" | ||
add_de1_widget $page_name entry $x [expr $y + 60] { | ||
bind $widget <Return> { say [translate {save}] $::settings(sound_button_in); borg toast [translate "Saved"]; save_plugin_settings webhook; hide_android_keyboard} | ||
bind $widget <Leave> hide_android_keyboard | ||
} -width [expr {int(38 * $::globals(entry_length_multiplier))}] -font Helv_8 -borderwidth 1 -bg #fbfaff -foreground #4e85f4 -textvariable "$var" -relief flat -highlightthickness 1 -highlightcolor #000000 | ||
} | ||
|
||
proc post_shot_data {} { | ||
variable settings | ||
|
||
msg "triggering webhook" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On See https://github.com/decentespresso/de1app/blob/main/de1plus/logging.tcl#L28 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do |
||
borg toast [translate "Triggering Webhook"] | ||
|
||
http::register https 443 [list ::tls::socket -servername $settings(webhook_domain)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The docs might want to mention how to install a server certificate Getting into how to generate one can probably be skipped, with "search for 'self-signed certificate'" or something appropriate. If this is just sending POSTs, why is it opening a socket? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may be my misunderstanding, but I don’t believe this is binding anything/ opening a socket for listening. Based on my read of the docs, it’s used to establish a TLS transport for making the HTTPS request to the server: https://www.tcl-lang.org/man/tcl/TclCmd/http.htm#M49 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes that is correct, there is no listen there, that code is registering essentially an 'extension' to a socket. This is how https GET is implemented on tcl. |
||
|
||
# Craft HTTP Body | ||
set espresso_data [::shot::create] | ||
set clock [clock seconds] | ||
set boundary "--------$clock" | ||
set type "multipart/form-data, charset=utf-8, boundary=$boundary" | ||
set content [encoding convertto utf-8 $espresso_data] | ||
set contentHeader "Content-Disposition: form-data; name=\"file\"; filename=\"file.shot\"\r\nContent-Type: application/octet-stream\r\n" | ||
set body "--$boundary\r\n$contentHeader\r\n$content\r\n--$boundary--\r\n" | ||
|
||
# Build HMAC signature | ||
# HMAC Signature = SHA256(secret, endpoint_path + "\n" + clock + "\n" + body) | ||
set message_for_hmac "$settings(webhook_endpoint)\n$clock\n$body" | ||
set signature [sha2::hmac $settings(webhook_secret_key) $message_for_hmac] | ||
set headerl [list \ | ||
Authorization "$clock!$signature" \ | ||
Content-Type "multipart/form-data; boundary=$boundary"] | ||
|
||
if {[catch { | ||
# HTTPS only for now | ||
set url "https://$settings(webhook_domain)$settings(webhook_endpoint)" | ||
set token [http::geturl $url -headers $headerl -method POST -type $type -query $body -timeout 30000] | ||
set status [http::status $token] | ||
set answer [http::data $token] | ||
set returncode [http::ncode $token] | ||
set returnfullcode [http::code $token] | ||
msg $token | ||
msg "status: $status" | ||
# msg "answer: $answer" | ||
} err] != 0} { | ||
msg "Could not upload shot to webhook! $err" | ||
borg toast [translate "Webhook failed!"] | ||
catch { http::cleanup $token } | ||
return | ||
} | ||
|
||
http::cleanup $token | ||
if {$returncode != 200} { | ||
msg "Webhook failed: $returnfullcode" | ||
borg toast "Webhook failed!" | ||
return | ||
} | ||
|
||
msg "Webhook succeeded!" | ||
borg toast "Webhook succeeded!" | ||
} | ||
|
||
proc async_dispatch {old new} { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The As you're not doing anything with the states (yet), |
||
after [expr 1000 * $::plugins::webhook::settings(webhook_trigger_after)] ::plugins::webhook::post_shot_data | ||
} | ||
|
||
proc msg { msg } { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can go straight to https://github.com/decentespresso/de1app/blob/main/de1plus/logging.tcl#L6 |
||
::msg [namespace current] $msg | ||
} | ||
|
||
proc main {} { | ||
::de1::event::listener::after_flow_complete_add \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Definitely should not be in the outer body or file, as it causes loading of the library even if the plugin is disabled. There's some complexity here, looking back at #73 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this comment meant for another PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had thought you were opening a server, which I think people do with the wibble library. Though installed with AndroWish, it isn't part of the stock distro. |
||
[lambda {event_dict} { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See note above at Line 113 If your callback accepts |
||
::plugins::webhook::async_dispatch \ | ||
[dict get $event_dict previous_state] \ | ||
[dict get $event_dict this_state] \ | ||
} ] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
webhook_domain example.com | ||
webhook_endpoint /decent/webhook | ||
webhook_secret_key SecretKeyForSigning | ||
webhook_trigger_after 10 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
...clock at the time the payload generation began...
Why something predictable here? If this is just confirming that the sender and the receiver share the same secret, why not just a random number?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clock is used as a way to have a time-based component to the signature to mitigate replay attacks (really probably overkill for this). On the server receiving the request, it can verify that the timestamp is within some interval of the server time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, reasoning makes sense to me now