Skip to content
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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions de1plus/plugins/webhook/README.md
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 `!`.
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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


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.
129 changes: 129 additions & 0 deletions de1plus/plugins/webhook/plugin.tcl
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"
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On msg, please set severity codes. We're working on getting the logs down to the point where they can be meaningfully run by everyone, all the time, especially to help out the support team.

See https://github.com/decentespresso/de1app/blob/main/de1plus/logging.tcl#L28

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)]
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

@sudowork sudowork Apr 1, 2021

Choose a reason for hiding this comment

The 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
https://wiki.tcl-lang.org/page/HTTPS

Copy link
Contributor

Choose a reason for hiding this comment

The 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} {
Copy link
Contributor

@jeffsf jeffsf Apr 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The {old new} arg list was from the previous system that only triggered on major state changes.

As you're not doing anything with the states (yet), {event_dict} is what the event will be called with.

after [expr 1000 * $::plugins::webhook::settings(webhook_trigger_after)] ::plugins::webhook::post_shot_data
}

proc msg { msg } {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

::msg [namespace current] $msg
}

proc main {} {
::de1::event::listener::after_flow_complete_add \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package require wibble ??

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment meant for another PR?

Copy link
Contributor

Choose a reason for hiding this comment

The 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. ::http is shown as part of the standard distro, so you don't need wibble.

[lambda {event_dict} {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See note above at Line 113

If your callback accepts {event_dict} the wrapper for old-style callbacks is not needed.

::plugins::webhook::async_dispatch \
[dict get $event_dict previous_state] \
[dict get $event_dict this_state] \
} ]
}
}
4 changes: 4 additions & 0 deletions de1plus/plugins/webhook/settings.tdb
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