Merge branch 'master' into global_messages

pull/1126/head
Rick Carlino 2019-03-08 09:38:14 -06:00
commit f60a56c894
4 changed files with 99 additions and 81 deletions

View File

@ -2,15 +2,18 @@ class SendNervesHubInfoJob < ApplicationJob
queue_as :default queue_as :default
def perform(device_id:, serial_number:, tags:) def perform(device_id:, serial_number:, tags:)
device = Device.find(device_id) device = Device.find(device_id)
resp_data = NervesHub.create_or_update(serial_number, tags) resp_data = NervesHub.maybe_create_or_update(serial_number, tags)
certs = NervesHub.sign_device(resp_data.fetch(:identifier)) unless resp_data # Probably has bad tags if nil
return
end
certs = NervesHub.sign_device(resp_data.fetch(:identifier))
Transport.current.amqp_send(certs.to_json, device_id, "nerves_hub") Transport.current.amqp_send(certs.to_json, device_id, "nerves_hub")
rescue => error rescue => error
NervesHub.report_problem({ error: error, NervesHub.report_problem({ error: error,
device_id: device_id, device_id: device_id,
serial_number: serial_number, serial_number: serial_number,
tags: tags, }) tags: tags })
raise error raise error
end end
end end

View File

@ -1,8 +1,10 @@
require "net/http" require "net/http"
require "openssl" require "openssl"
require "base64" require "base64"
class NervesHub class NervesHub
class NervesHubHTTPError < StandardError; end class NervesHubHTTPError < StandardError; end
# There is a lot of configuration available in this class to support: # There is a lot of configuration available in this class to support:
# * Self Hosters # * Self Hosters
# * Running a local instance of Nerves-Hub # * Running a local instance of Nerves-Hub
@ -41,27 +43,27 @@ class NervesHub
# via AMQP. # via AMQP.
# * FarmBot burns that cert into internal storage on it's SD card. # * FarmBot burns that cert into internal storage on it's SD card.
NERVES_HUB_HOST = ENV.fetch("NERVES_HUB_HOST") { "api.nerves-hub.org" } NERVES_HUB_HOST = ENV.fetch("NERVES_HUB_HOST") { "api.nerves-hub.org" }
NERVES_HUB_PORT = ENV.fetch("NERVES_HUB_PORT") { 443 } NERVES_HUB_PORT = ENV.fetch("NERVES_HUB_PORT") { 443 }
NERVES_HUB_ORG = ENV.fetch("NERVES_HUB_ORG") { "farmbot" } NERVES_HUB_ORG = ENV.fetch("NERVES_HUB_ORG") { "farmbot" }
NERVES_HUB_BASE_URL = "https://#{NERVES_HUB_HOST}:#{NERVES_HUB_PORT}" NERVES_HUB_BASE_URL = "https://#{NERVES_HUB_HOST}:#{NERVES_HUB_PORT}"
NERVES_HUB_URI = URI.parse(NERVES_HUB_BASE_URL) NERVES_HUB_URI = URI.parse(NERVES_HUB_BASE_URL)
# Locations of where files _may_ exist. # Locations of where files _may_ exist.
NERVES_HUB_CERT_PATH = "nerves_hub_cert.#{Rails.env}.pem" NERVES_HUB_CERT_PATH = "nerves_hub_cert.#{Rails.env}.pem"
NERVES_HUB_KEY_PATH = "nerves_hub_key.#{Rails.env}.pem" NERVES_HUB_KEY_PATH = "nerves_hub_key.#{Rails.env}.pem"
NERVES_HUB_CA_PATH = "nerves_hub_ca.#{Rails.env}.pem" NERVES_HUB_CA_PATH = "nerves_hub_ca.#{Rails.env}.pem"
# This file is for loading the CA from ENV. # This file is for loading the CA from ENV.
# net/http doesn't support loading this as a X509::Certificate # net/http doesn't support loading this as a X509::Certificate
# So it needs to be written to a path. # So it needs to be written to a path.
NERVES_HUB_CA_HACK = "/tmp/nerves_hub_ca.#{Rails.env}.pem" NERVES_HUB_CA_HACK = "/tmp/nerves_hub_ca.#{Rails.env}.pem"
NERVES_HUB_ERROR = "NervesHub request failed: %s: %s" NERVES_HUB_ERROR = "NervesHub request failed: %s: %s"
COLON = ":" COLON = ":"
BAD_TAG = "A device sent a malformed tag" BAD_TAG = "A device sent a malformed tag"
# HEADERS for HTTP requests to NervesHub # HEADERS for HTTP requests to NervesHub
HEADERS = {"Content-Type" => "application/json"} HEADERS = { "Content-Type" => "application/json" }
DEFAULT_HTTP = Net::HTTP.new(NERVES_HUB_URI.host, NERVES_HUB_URI.port) DEFAULT_HTTP = Net::HTTP.new(NERVES_HUB_URI.host, NERVES_HUB_URI.port)
# Raises an exception for when NervesHub API requests fail. # Raises an exception for when NervesHub API requests fail.
@ -70,7 +72,7 @@ class NervesHub
end end
APPLICATION = "application" APPLICATION = "application"
CHANNEL = "channel" CHANNEL = "channel"
def self.update_channel(serial_number, channel) def self.update_channel(serial_number, channel)
dev = device(serial_number) dev = device(serial_number)
@ -80,19 +82,19 @@ class NervesHub
# NEVER DUPLICATE TAG PREFIXES (thing before COLON). Must be unique! # NEVER DUPLICATE TAG PREFIXES (thing before COLON). Must be unique!
tag_map = dev.fetch(:tags).map { |x| x.split(COLON) }.to_h tag_map = dev.fetch(:tags).map { |x| x.split(COLON) }.to_h
tag_map[CHANNEL] = channel tag_map[CHANNEL] = channel
next_tags = tag_map.to_a.map { |x| x.join(COLON) } next_tags = tag_map.to_a.map { |x| x.join(COLON) }
update(serial_number, next_tags) update(serial_number, next_tags)
end end
# Checks if a device exists in NervesHub # Checks if a device exists in NervesHub
# if it does -> does a PUT request updating the tags. # if it does -> does a PUT request updating the tags.
# if it does not -> does a POST request creating the device with given tags. # if it does not -> does a POST request creating the device with given tags.
def self.create_or_update(serial_number, tags) def self.maybe_create_or_update(serial_number, tags)
# Hash | nil # Hash | nil
current_nerves_hub_device = device(serial_number) current_nerves_hub_device = device(serial_number)
# It's really hard to debug malformed tags; Catch them here: # It's really hard to debug malformed tags; Catch them here:
if tags.detect{|x| !x.include?(COLON) } if tags.detect { |x| !x.include?(COLON) }
report_problem(error: BAD_TAG, serial_number: serial_number, tags: tags) report_problem(error: BAD_TAG, serial_number: serial_number, tags: tags)
return return
end end
@ -119,7 +121,7 @@ class NervesHub
# PUT request to a device to update it's tags. # PUT request to a device to update it's tags.
def self.update(serial_number, tags) def self.update(serial_number, tags)
data = {tags: tags} data = { tags: tags }
resp = conn.put(device_path(serial_number), data.to_json, HEADERS) resp = conn.put(device_path(serial_number), data.to_json, HEADERS)
bad_http(resp.code, resp.body) if resp.code != "201" bad_http(resp.code, resp.body) if resp.code != "201"
JSON(resp.body)["data"].deep_symbolize_keys JSON(resp.body)["data"].deep_symbolize_keys
@ -129,8 +131,8 @@ class NervesHub
# to identify the ENV that FarmBotOS is running in. # to identify the ENV that FarmBotOS is running in.
def self.new_device(serial_number, tags) def self.new_device(serial_number, tags)
data = { description: "farmbot-#{serial_number}", data = { description: "farmbot-#{serial_number}",
identifier: serial_number, identifier: serial_number,
tags: tags } tags: tags }
resp = conn.post(devices_path, data.to_json, HEADERS) resp = conn.post(devices_path, data.to_json, HEADERS)
bad_http(resp.code, resp.body) if resp.code != "201" bad_http(resp.code, resp.body) if resp.code != "201"
JSON(resp.body)["data"].deep_symbolize_keys JSON(resp.body)["data"].deep_symbolize_keys
@ -146,14 +148,14 @@ class NervesHub
csr_safe = Base64.strict_encode64(csr.to_pem) csr_safe = Base64.strict_encode64(csr.to_pem)
data = { identifier: serial_number, data = { identifier: serial_number,
csr: csr_safe, } csr: csr_safe }
resp = conn.post(device_sign_path(serial_number), data.to_json, HEADERS) resp = conn.post(device_sign_path(serial_number), data.to_json, HEADERS)
bad_http(resp.code, resp.body) if resp.code != "200" bad_http(resp.code, resp.body) if resp.code != "200"
cert = JSON(resp.body)["data"].deep_symbolize_keys[:cert] cert = JSON(resp.body)["data"].deep_symbolize_keys[:cert]
return { cert: Base64.strict_encode64(cert), return { cert: Base64.strict_encode64(cert),
csr: csr_safe, csr: csr_safe,
key: key_safe, } key: key_safe }
end end
# Is the NervesHub module configured. # Is the NervesHub module configured.
@ -163,16 +165,16 @@ class NervesHub
end end
def self.set_conn(obj = DEFAULT_HTTP) def self.set_conn(obj = DEFAULT_HTTP)
@conn = obj @conn = obj
# Setting the contents of this # Setting the contents of this
# in the CA store doesn't work for some reason? # in the CA store doesn't work for some reason?
@conn.ca_file = self.current_ca_file @conn.ca_file = self.current_ca_file
# Don't think this is absolutely needed. # Don't think this is absolutely needed.
@conn.cert_store = nil @conn.cert_store = nil
@conn = obj @conn = obj
@conn.use_ssl = true @conn.use_ssl = true
@conn.cert = current_cert @conn.cert = current_cert
@conn.key = current_key @conn.key = current_key
@conn @conn
end end
@ -181,7 +183,7 @@ class NervesHub
(active? && !@conn) ? set_conn : @conn (active? && !@conn) ? set_conn : @conn
end end
private private
# Helper for making requests to a device url on NervesHub # Helper for making requests to a device url on NervesHub
def self.devices_path(*chunks) def self.devices_path(*chunks)
@ -216,7 +218,7 @@ private
request = OpenSSL::X509::Request.new request = OpenSSL::X509::Request.new
request.version = 0 request.version = 0
request.subject = OpenSSL::X509::Name.new([ request.subject = OpenSSL::X509::Name.new([
['O', options[:organization], OpenSSL::ASN1::UTF8STRING], ["O", options[:organization], OpenSSL::ASN1::UTF8STRING],
]) ])
request.public_key = real_public_key(key) request.public_key = real_public_key(key)
request.sign(key, OpenSSL::Digest::SHA1.new) request.sign(key, OpenSSL::Digest::SHA1.new)
@ -236,7 +238,7 @@ private
# Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub # Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub
def self.try_env_cert def self.try_env_cert
OpenSSL::X509::Certificate.new(ENV['NERVES_HUB_CERT']) if ENV['NERVES_HUB_CERT'] OpenSSL::X509::Certificate.new(ENV["NERVES_HUB_CERT"]) if ENV["NERVES_HUB_CERT"]
end end
# Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub # Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub
@ -249,7 +251,7 @@ private
# Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub # Cert for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub
def self.try_env_key def self.try_env_key
OpenSSL::PKey::EC.new(ENV['NERVES_HUB_KEY']) if ENV['NERVES_HUB_KEY'] OpenSSL::PKey::EC.new(ENV["NERVES_HUB_KEY"]) if ENV["NERVES_HUB_KEY"]
end end
# Private Key for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub # Private Key for authenticating Farmbot API (NOT FARMBOT OS) to NervesHub
@ -276,9 +278,9 @@ private
# loading a file from the filesystem. # loading a file from the filesystem.
# https://stackoverflow.com/questions/36993208/how-to-enumerate-through-multiple-certificates-in-a-bundle # https://stackoverflow.com/questions/36993208/how-to-enumerate-through-multiple-certificates-in-a-bundle
def self.try_env_ca_file def self.try_env_ca_file
if ENV['NERVES_HUB_CA'] if ENV["NERVES_HUB_CA"]
file = File.open(NERVES_HUB_CA_HACK, 'w') file = File.open(NERVES_HUB_CA_HACK, "w")
file.write(ENV['NERVES_HUB_CA']) file.write(ENV["NERVES_HUB_CA"])
file.close file.close
NERVES_HUB_CA_HACK NERVES_HUB_CA_HACK
end end

View File

@ -4,18 +4,32 @@ describe SendNervesHubInfoJob do
let(:device) { FactoryBot.create(:device) } let(:device) { FactoryBot.create(:device) }
it "handles failure" do it "handles failure" do
params = { device_id: device.id, params = { device_id: device.id,
serial_number: "xyz", serial_number: "xyz",
tags: [], tags: [],
error: StandardError.new("Hello!"), } error: StandardError.new("Hello!") }
not_work = \ not_work = receive(:maybe_create_or_update)
receive(:create_or_update).with(any_args).and_raise(params.fetch(:error)) .with(any_args)
.and_raise(params.fetch(:error))
expect(NervesHub).to not_work expect(NervesHub).to not_work
old_logger = ActiveJob::Base.logger old_logger = ActiveJob::Base.logger
ActiveJob::Base.logger = Logger.new(nil) ActiveJob::Base.logger = Logger.new(nil)
expect do expect do
SendNervesHubInfoJob.perform_now(**params.except(:error)) SendNervesHubInfoJob.perform_now(**params.except(:error))
end.to raise_error(params.fetch(:error)) end.to raise_error(params.fetch(:error))
ActiveJob::Base.logger = old_logger ActiveJob::Base.logger = old_logger
end end
it "returns early if create/update is nil" do
params = { device_id: device.id,
serial_number: "xyz",
tags: [],
error: StandardError.new("Hello!") }
return_nil = receive(:maybe_create_or_update)
.with(any_args)
.and_return(nil)
expect(NervesHub).to return_nil
expect(NervesHub).not_to receive(:sign_device)
SendNervesHubInfoJob.perform_now(**params.except(:error))
end
end end

View File

@ -2,14 +2,15 @@ require "spec_helper"
describe NervesHub do describe NervesHub do
def stub_connection def stub_connection
double(SecureRandom.hex.first(6), :ca_file= => nil, double(SecureRandom.hex.first(6), :ca_file= => nil,
:cert_store => nil, :cert_store => nil,
:cert_store= => nil, :cert_store= => nil,
:use_ssl => nil, :use_ssl => nil,
:use_ssl= => nil, :use_ssl= => nil,
:cert= => nil, :cert= => nil,
:key= => nil) :key= => nil)
end end
before(:each) { NervesHub.set_conn(stub_connection) } before(:each) { NervesHub.set_conn(stub_connection) }
# reset to default. # reset to default.
@ -20,14 +21,13 @@ describe NervesHub do
it "generates HTTP failure messages" do it "generates HTTP failure messages" do
status = "800" status = "800"
msg = "failed to reticulate splines." msg = "failed to reticulate splines."
expect { NervesHub.bad_http(status, msg) } expect { NervesHub.bad_http(status, msg) }.to raise_error(NervesHub::NervesHubHTTPError)
.to raise_error(NervesHub::NervesHubHTTPError)
end end
it "generates URL paths" do it "generates URL paths" do
expect(NervesHub.devices_path).to eq "/orgs/farmbot/devices" expect(NervesHub.devices_path).to eq "/orgs/farmbot/devices"
expect(NervesHub.device_path("foo")).to eq "/orgs/farmbot/devices/foo" expect(NervesHub.device_path("foo")).to eq "/orgs/farmbot/devices/foo"
expect(NervesHub.device_sign_path(123)).to eq "/orgs/farmbot/devices/123/certificates/sign" expect(NervesHub.device_sign_path(123)).to eq "/orgs/farmbot/devices/123/certificates/sign"
end end
@ -53,21 +53,20 @@ describe NervesHub do
end end
it "handles failed updates to a device" do it "handles failed updates to a device" do
resp = StubResp.new("500", { "data" => { } }.to_json) resp = StubResp.new("500", { "data" => {} }.to_json)
expected_args = [NervesHub.device_path(ser), expected_args = [NervesHub.device_path(ser),
{"tags":["foo"]}.to_json, { "tags": ["foo"] }.to_json,
NervesHub::HEADERS] NervesHub::HEADERS]
expect(NervesHub.conn).to receive(:put).with(*expected_args).and_return(resp) expect(NervesHub.conn).to receive(:put).with(*expected_args).and_return(resp)
expect { NervesHub.update(ser, ["foo"]) } expect { NervesHub.update(ser, ["foo"]) }.to raise_error(NervesHub::NervesHubHTTPError)
.to raise_error(NervesHub::NervesHubHTTPError)
end end
it "updates the device via REST" do it "updates the device via REST" do
resp = StubResp.new("201", { "data" => {x: "y"} }.to_json) resp = StubResp.new("201", { "data" => { x: "y" } }.to_json)
expected_args = [NervesHub.device_path(ser), expected_args = [NervesHub.device_path(ser),
{"tags":["foo"]}.to_json, { "tags": ["foo"] }.to_json,
NervesHub::HEADERS] NervesHub::HEADERS]
expect(NervesHub.conn).to receive(:put).with(*expected_args).and_return(resp) expect(NervesHub.conn).to receive(:put).with(*expected_args).and_return(resp)
@ -85,18 +84,18 @@ describe NervesHub do
it "calls `new_device` if device does not exist" do it "calls `new_device` if device does not exist" do
expect(NervesHub.conn) expect(NervesHub.conn)
xpect_args = "/orgs/farmbot/devices/X" xpect_args = "/orgs/farmbot/devices/X"
resp = StubResp.new("404", { "data" => { } }.to_json) resp = StubResp.new("404", { "data" => {} }.to_json)
expect(NervesHub.conn).to receive(:get).with(xpect_args).and_return(resp) expect(NervesHub.conn).to receive(:get).with(xpect_args).and_return(resp)
tags = [ "A:B", "C:D" ] tags = ["A:B", "C:D"]
xpect_args2 = [ "/orgs/farmbot/devices", xpect_args2 = ["/orgs/farmbot/devices",
{ "description": "farmbot-X", { "description": "farmbot-X",
"identifier": "X", "identifier": "X",
"tags": tags }.to_json, "tags": tags }.to_json,
NervesHub::HEADERS ] NervesHub::HEADERS]
data = {fake: "Farmbot"} data = { fake: "Farmbot" }
resp2 = StubResp.new("201", { "data" => data }.to_json) resp2 = StubResp.new("201", { "data" => data }.to_json)
expect(NervesHub.conn).to receive(:post).with(*xpect_args2).and_return(resp2) expect(NervesHub.conn).to receive(:post).with(*xpect_args2).and_return(resp2)
expect(NervesHub.create_or_update("X", tags)).to eq(data) expect(NervesHub.maybe_create_or_update("X", tags)).to eq(data)
end end
it "sometimes performs the NERVES_HUB_CA_HACK" do it "sometimes performs the NERVES_HUB_CA_HACK" do
@ -111,16 +110,16 @@ describe NervesHub do
it "detects malformed tags" do it "detects malformed tags" do
tags = ["wrong", "also_wrong", "ok:tag"].shuffle tags = ["wrong", "also_wrong", "ok:tag"].shuffle
serial_number = "0xCAFEF00D" serial_number = "0xCAFEF00D"
expected = { error: NervesHub::BAD_TAG, expected = { error: NervesHub::BAD_TAG,
serial_number: serial_number, serial_number: serial_number,
tags: tags, } tags: tags }
resp = StubResp.new("200", { resp = StubResp.new("200", {
"data" => { hello: :world, identifier: "?" } "data" => { hello: :world, identifier: "?" },
}.to_json) }.to_json)
do_it = \ do_it =
receive(:get).with("/orgs/farmbot/devices/#{serial_number}").and_return(resp) receive(:get).with("/orgs/farmbot/devices/#{serial_number}").and_return(resp)
expect(NervesHub.conn).to do_it expect(NervesHub.conn).to do_it
expect(NervesHub).to receive(:report_problem).with(expected) expect(NervesHub).to receive(:report_problem).with(expected)
NervesHub.create_or_update(serial_number, tags) NervesHub.maybe_create_or_update(serial_number, tags)
end end
end end