From 68e0d00f6e0b8b60518fbacebc15099dd35c3138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 11 Jan 2023 19:17:27 +0800 Subject: [PATCH 01/10] WIP Add Webhooks controller, allowed IP config --- .env.example | 2 ++ .env.production | 2 ++ .env.test | 4 ++++ app/controllers/webhooks_controller.rb | 23 +++++++++++++++++++++++ config/routes.rb | 2 ++ spec/requests/webhooks_spec.rb | 20 ++++++++++++++++++++ 6 files changed, 53 insertions(+) create mode 100644 app/controllers/webhooks_controller.rb create mode 100644 spec/requests/webhooks_spec.rb diff --git a/.env.example b/.env.example index 9f308d3..cd29496 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,5 @@ BTCPAY_API_URL='http://localhost:23001/api/v1' LNDHUB_API_URL='http://localhost:3023' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.163' diff --git a/.env.production b/.env.production index 243f0e4..d99e4fe 100644 --- a/.env.production +++ b/.env.production @@ -5,3 +5,5 @@ BTCPAY_API_URL='http://10.1.1.163:23001/api/v1' LNDHUB_LEGACY_API_URL='http://10.1.1.163:3026' LNDHUB_API_URL='http://10.1.1.163:3026' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.163' diff --git a/.env.test b/.env.test index ce5e1f7..03daa76 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,9 @@ EJABBERD_API_URL='http://xmpp.example.com/api' + BTCPAY_API_URL='http://btcpay.example.com/api/v1' + LNDHUB_LEGACY_API_URL='http://localhost:3023' LNDHUB_API_URL='http://localhost:3026' LNDHUB_PUBLIC_URL='https://lndhub.kosmos.org' + +WEBHOOKS_ALLOWED_IPS='10.1.1.23' diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 0000000..ae5ac96 --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,23 @@ +class WebhooksController < ApplicationController + skip_forgery_protection + + before_action :authorize_request + + def lndhub + begin + payload = JSON.parse(request.body.read, symbolize_names: true) + rescue + head :unprocessable_entity and return + end + + head :ok + end + + private + + def authorize_request + if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip) + head :forbidden and return + end + end +end diff --git a/config/routes.rb b/config/routes.rb index b0cf70e..12f3236 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,8 @@ Rails.application.routes.draw do get 'lnurlpay/:address', to: 'lnurlpay#index', constraints: { address: /[^\/]+/} get 'lnurlpay/:address/invoice', to: 'lnurlpay#invoice', constraints: { address: /[^\/]+/} + post 'webhooks/lndhub', to: 'webhooks#lndhub' + namespace :api do get 'kredits/onchain_btc_balance', to: 'kredits#onchain_btc_balance' end diff --git a/spec/requests/webhooks_spec.rb b/spec/requests/webhooks_spec.rb new file mode 100644 index 0000000..30d28db --- /dev/null +++ b/spec/requests/webhooks_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe "Webhooks", type: :request do + describe "Allowed IP addresses" do + context "IP not allowed" do + it "returns a 403 status" do + post "/webhooks/lndhub" + expect(response).to have_http_status(:forbidden) + end + end + + context "IP allowed" do + it "returns a 403 status" do + ENV['WEBHOOKS_ALLOWED_IPS'] = '127.0.0.1' + post "/webhooks/lndhub" + expect(response).to have_http_status(:unprocessable_entity) + end + end + end +end From 51952ecdc28947584c8121d66d9776c97ca2864c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Wed, 11 Jan 2023 19:50:01 +0800 Subject: [PATCH 02/10] Add migration for unencrypted ln login field --- app/jobs/create_lndhub_account_job.rb | 3 ++- .../20230111113139_add_ln_account_to_users.rb | 9 +++++++++ db/schema.rb | 3 ++- spec/fixtures/lndhub/incoming.json | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20230111113139_add_ln_account_to_users.rb create mode 100644 spec/fixtures/lndhub/incoming.json diff --git a/app/jobs/create_lndhub_account_job.rb b/app/jobs/create_lndhub_account_job.rb index 9cf33b1..52d2a2c 100644 --- a/app/jobs/create_lndhub_account_job.rb +++ b/app/jobs/create_lndhub_account_job.rb @@ -7,7 +7,8 @@ class CreateLndhubAccountJob < ApplicationJob lndhub = LndhubV2.new credentials = lndhub.create_account - user.update! ln_login: credentials["login"], + user.update! ln_account: credentials["login"], + ln_login: credentials["login"], # TODO remove when production is migrated ln_password: credentials["password"] end end diff --git a/db/migrate/20230111113139_add_ln_account_to_users.rb b/db/migrate/20230111113139_add_ln_account_to_users.rb new file mode 100644 index 0000000..d625344 --- /dev/null +++ b/db/migrate/20230111113139_add_ln_account_to_users.rb @@ -0,0 +1,9 @@ +class AddLnAccountToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :ln_account, :string + + User.all.each do |user| + user.update! ln_account: user.ln_login + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 773ba10..486c465 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2021_11_20_010540) do +ActiveRecord::Schema[7.0].define(version: 2023_01_11_113139) do create_table "donations", force: :cascade do |t| t.integer "user_id" t.integer "amount_sats" @@ -48,6 +48,7 @@ ActiveRecord::Schema[7.0].define(version: 2021_11_20_010540) do t.string "unconfirmed_email" t.text "ln_login_ciphertext" t.text "ln_password_ciphertext" + t.string "ln_account" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/spec/fixtures/lndhub/incoming.json b/spec/fixtures/lndhub/incoming.json new file mode 100644 index 0000000..fca7c8f --- /dev/null +++ b/spec/fixtures/lndhub/incoming.json @@ -0,0 +1,19 @@ +{ + "id": 58, + "type": "incoming", + "user_login": "689e27b237798b41d123", + "amount": 100, + "fee": 0, + "memo": "Sats for jimmy@kosmos.org: \"Buy you some beers\"", + "description_hash": "106af234beebd478206535486051b4f212bd31d2ed0f93e3efce7b5e7603d743", + "payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu", + "destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946", + "r_hash": "03aa6aeb2ed5a569f1a40925041f7d7163458e96b705e5eafe0f10188575dc08", + "preimage": "3539663535656537343331663432653165396430623966633664656664646563", + "keysend": false, + "state": "settled", + "created_at": "2023-01-11T09:22:57.546364Z", + "expires_at": "2023-01-12T09:22:57.547209Z", + "updated_at": "2023-01-11T09:22:58.046236131Z", + "settled_at": "2023-01-11T09:22:58.046232174Z" +} From 2c8b3cdacc25f97b7acfaa570172e3c877c67de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 11:43:30 +0800 Subject: [PATCH 03/10] Rename job --- ...hange_xmpp_contacts_job.rb => xmpp_exchange_contacts_job.rb} | 2 +- app/services/create_account.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename app/jobs/{exchange_xmpp_contacts_job.rb => xmpp_exchange_contacts_job.rb} (91%) diff --git a/app/jobs/exchange_xmpp_contacts_job.rb b/app/jobs/xmpp_exchange_contacts_job.rb similarity index 91% rename from app/jobs/exchange_xmpp_contacts_job.rb rename to app/jobs/xmpp_exchange_contacts_job.rb index 5e5caa2..01f64f1 100644 --- a/app/jobs/exchange_xmpp_contacts_job.rb +++ b/app/jobs/xmpp_exchange_contacts_job.rb @@ -1,4 +1,4 @@ -class ExchangeXmppContactsJob < ApplicationJob +class XmppExchangeContactsJob < ApplicationJob queue_as :default def perform(inviter, username, domain) diff --git a/app/services/create_account.rb b/app/services/create_account.rb index f332a53..c5cb69a 100644 --- a/app/services/create_account.rb +++ b/app/services/create_account.rb @@ -46,7 +46,7 @@ class CreateAccount < ApplicationService def exchange_xmpp_contacts #TODO enable in development when we have easy setup of ejabberd etc. return if Rails.env.development? - ExchangeXmppContactsJob.perform_later(@invitation.user, @username, @domain) + XmppExchangeContactsJob.perform_later(@invitation.user, @username, @domain) end def create_lndhub_account(user) From 4232df302b3a30d54dc0a1ad1ad8e8a0c7cd6ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 11:44:28 +0800 Subject: [PATCH 04/10] Add send_message to ejabberd service --- app/jobs/xmpp_send_message_job.rb | 8 ++++++++ app/services/ejabberd_api_client.rb | 4 ++++ 2 files changed, 12 insertions(+) create mode 100644 app/jobs/xmpp_send_message_job.rb diff --git a/app/jobs/xmpp_send_message_job.rb b/app/jobs/xmpp_send_message_job.rb new file mode 100644 index 0000000..e8eebf5 --- /dev/null +++ b/app/jobs/xmpp_send_message_job.rb @@ -0,0 +1,8 @@ +class XmppSendMessageJob < ApplicationJob + queue_as :default + + def perform(payload) + ejabberd = EjabberdApiClient.new + ejabberd.send_message payload + end +end diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index 65b3795..7930aa1 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -17,4 +17,8 @@ class EjabberdApiClient def add_rosteritem(payload) post "add_rosteritem", payload end + + def send_message(payload) + post "send_message", payload + end end From 9e988e92d14e3231f9647590726700b075baa1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 11:44:55 +0800 Subject: [PATCH 05/10] Notify user about incoming sats via XMPP --- app/controllers/webhooks_controller.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index ae5ac96..2efc9dc 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,15 +6,33 @@ class WebhooksController < ApplicationController def lndhub begin payload = JSON.parse(request.body.read, symbolize_names: true) + return unless payload[:type] == "incoming" rescue head :unprocessable_entity and return end + user = User.find_by(ln_account: payload[:user_login]) + + # TODO make configurable + notify_xmpp(user.address, payload[:amount], payload[:memo]) + head :ok end private + def notify_xmpp(address, amt_sats, memo) + payload = { + type: "normal", + from: "kosmos.org", # TODO domain config + # to: address, + to: "raucao@kosmos.org", + subject: "Sats received!", + body: "#{amt_sats} sats received in your wallet. Memo: \"#{memo}\"" + } + XmppSendMessageJob.perform_later(payload) + end + def authorize_request if !ENV['WEBHOOKS_ALLOWED_IPS'].split(',').include?(request.remote_ip) head :forbidden and return From d4a3f8dadb0144a7bbfa1c5d39e41003c5682ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 11:50:13 +0800 Subject: [PATCH 06/10] Fix spec after renaming job --- ..._contacts_job_spec.rb => xmpp_exchange_contacts_job_spec.rb} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename spec/jobs/{exchange_xmpp_contacts_job_spec.rb => xmpp_exchange_contacts_job_spec.rb} (95%) diff --git a/spec/jobs/exchange_xmpp_contacts_job_spec.rb b/spec/jobs/xmpp_exchange_contacts_job_spec.rb similarity index 95% rename from spec/jobs/exchange_xmpp_contacts_job_spec.rb rename to spec/jobs/xmpp_exchange_contacts_job_spec.rb index 177e03c..0064b69 100644 --- a/spec/jobs/exchange_xmpp_contacts_job_spec.rb +++ b/spec/jobs/xmpp_exchange_contacts_job_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' require 'webmock/rspec' -RSpec.describe ExchangeXmppContactsJob, type: :job do +RSpec.describe XmppExchangeContactsJob, type: :job do let(:user) { create :user, cn: "willherschel", ou: "kosmos.org" } subject(:job) { From 4c0d8283e31781df039bf69da59d02f72793280c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 14:32:35 +0800 Subject: [PATCH 07/10] Make status code explicit --- app/controllers/webhooks_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 2efc9dc..1831ac5 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,7 +6,7 @@ class WebhooksController < ApplicationController def lndhub begin payload = JSON.parse(request.body.read, symbolize_names: true) - return unless payload[:type] == "incoming" + head :no_content and return unless payload[:type] == "incoming" rescue head :unprocessable_entity and return end From aa3c2b4fa297c6fb90a120c99a88cf7f967ce84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 14:32:53 +0800 Subject: [PATCH 08/10] Remove hardcoded user address from hook --- app/controllers/webhooks_controller.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 1831ac5..8dbfbc8 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -25,8 +25,7 @@ class WebhooksController < ApplicationController payload = { type: "normal", from: "kosmos.org", # TODO domain config - # to: address, - to: "raucao@kosmos.org", + to: address, subject: "Sats received!", body: "#{amt_sats} sats received in your wallet. Memo: \"#{memo}\"" } From a1663b9f9d29fe78542255924f6785225b0027cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 12 Jan 2023 14:33:31 +0800 Subject: [PATCH 09/10] Add specs for lndhub webhook --- spec/factories/devise.rb | 2 + spec/fixtures/lndhub/incoming.json | 6 +-- spec/fixtures/lndhub/outgoing.json | 19 ++++++++++ spec/requests/webhooks_spec.rb | 59 +++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 spec/fixtures/lndhub/outgoing.json diff --git a/spec/factories/devise.rb b/spec/factories/devise.rb index cf486ba..a2188e9 100644 --- a/spec/factories/devise.rb +++ b/spec/factories/devise.rb @@ -2,8 +2,10 @@ FactoryBot.define do factory :user do id { 1 } cn { "jimmy" } + ou { "kosmos.org" } email { "jimmy@example.com" } password { "dis-muh-password" } confirmed_at { DateTime.now } + ln_account { "123456" } end end diff --git a/spec/fixtures/lndhub/incoming.json b/spec/fixtures/lndhub/incoming.json index fca7c8f..4de12ab 100644 --- a/spec/fixtures/lndhub/incoming.json +++ b/spec/fixtures/lndhub/incoming.json @@ -1,10 +1,10 @@ { "id": 58, "type": "incoming", - "user_login": "689e27b237798b41d123", - "amount": 100, + "user_login": "123456abcdef", + "amount": 12300, "fee": 0, - "memo": "Sats for jimmy@kosmos.org: \"Buy you some beers\"", + "memo": "Buy you some beers", "description_hash": "106af234beebd478206535486051b4f212bd31d2ed0f93e3efce7b5e7603d743", "payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu", "destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946", diff --git a/spec/fixtures/lndhub/outgoing.json b/spec/fixtures/lndhub/outgoing.json new file mode 100644 index 0000000..fcd9acb --- /dev/null +++ b/spec/fixtures/lndhub/outgoing.json @@ -0,0 +1,19 @@ +{ + "id": 59, + "type": "outgoing", + "user_login": "123456abcdef", + "amount": 12400, + "fee": 10, + "memo": "Top up mobile phone", + "description_hash": "106af234beebd478206535486051b4f212bd31d2ed0f93e3efce7b5e7603d743", + "payment_request": "lnbc1u1p3mull3pp5qw4x46ew6kjknudypyjsg8maw935tr5kkuz7t6h7pugp3pt4msyqhp5zp40yd97a028sgr9x4yxq5d57gft6vwja58e8cl0eea4uasr6apscqzpgxqyz5vqsp53m2n8h6yeflgukv5fhwm802kur6un9w8nvycl7auk67w5g2u008q9qyyssqml8rfmxyvp32qd5939qx7uu0w6ppjuujlpwsrz28m9u0dzp799hz5j72w0xm8pg97hd4hdvwh9zxaw2hewnnmzewvc550f9y3qsfaegphmk0mu", + "destination_pubkey_hex": "024cd3be18617f39cf645851e3ba63f51fc13f0bb09e3bb25e6fd4de556486d946", + "r_hash": "03aa6aeb2ed5a569f1a40925041f7d7163458e96b705e5eafe0f10188575dc08", + "preimage": "3539663535656537343331663432653165396430623966633664656664646563", + "keysend": false, + "state": "settled", + "created_at": "2023-01-11T09:22:57.546364Z", + "expires_at": "2023-01-12T09:22:57.547209Z", + "updated_at": "2023-01-11T09:22:58.046236131Z", + "settled_at": "2023-01-11T09:22:58.046232174Z" +} diff --git a/spec/requests/webhooks_spec.rb b/spec/requests/webhooks_spec.rb index 30d28db..06c834e 100644 --- a/spec/requests/webhooks_spec.rb +++ b/spec/requests/webhooks_spec.rb @@ -10,11 +10,68 @@ RSpec.describe "Webhooks", type: :request do end context "IP allowed" do - it "returns a 403 status" do + before do ENV['WEBHOOKS_ALLOWED_IPS'] = '127.0.0.1' + end + + it "does not return a 403 status" do post "/webhooks/lndhub" + expect(response).not_to have_http_status(:forbidden) + end + end + end + + # Webhooks from lndhub.go + describe "/webhooks/lndhub" do + before do + ENV['WEBHOOKS_ALLOWED_IPS'] = '127.0.0.1' + end + + describe "Payload cannot be processed as JSON" do + before do + post "/webhooks/lndhub", params: "Foo" + end + + it "returns a 422 status" do expect(response).to have_http_status(:unprocessable_entity) end end + + describe "Valid payload for outgoing payment" do + let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/outgoing.json", File.dirname(__FILE__)))) } + + before do + post "/webhooks/lndhub", params: payload.to_json + end + + it "returns a 204 status" do + expect(response).to have_http_status(:no_content) + end + end + + describe "Valid payload for incoming payment" do + let(:user) { create :user, ln_account: "123456abcdef" } + let(:payload) { JSON.parse(File.read(File.expand_path("../fixtures/lndhub/incoming.json", File.dirname(__FILE__)))) } + + before do + user.save! #FIXME this should not be necessary + post "/webhooks/lndhub", params: payload.to_json + end + + it "returns a 200 status" do + expect(response).to have_http_status(:ok) + end + + it "sends an XMPP message to the account owner's JID" do + expect(enqueued_jobs.size).to eq(1) + + msg = enqueued_jobs.first['arguments'].first + expect(msg["type"]).to eq('normal') + expect(msg["from"]).to eq('kosmos.org') + expect(msg["to"]).to eq(user.address) + expect(msg["subject"]).to eq('Sats received!') + expect(msg["body"]).to match(/^12300 sats received/) + end + end end end From b0c787bbc77e4b15677c5b8d713f27c1e623a564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Fri, 13 Jan 2023 12:24:22 +0800 Subject: [PATCH 10/10] Throw exception when user cannot be found --- app/controllers/webhooks_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 8dbfbc8..487f43c 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -11,7 +11,7 @@ class WebhooksController < ApplicationController head :unprocessable_entity and return end - user = User.find_by(ln_account: payload[:user_login]) + user = User.find_by!(ln_account: payload[:user_login]) # TODO make configurable notify_xmpp(user.address, payload[:amount], payload[:memo])