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
def perform(device_id:, serial_number:, tags:)
device = Device.find(device_id)
resp_data = NervesHub.create_or_update(serial_number, tags)
certs = NervesHub.sign_device(resp_data.fetch(:identifier))
device = Device.find(device_id)
resp_data = NervesHub.maybe_create_or_update(serial_number, tags)
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")
rescue => error
NervesHub.report_problem({ error: error,
device_id: device_id,
NervesHub.report_problem({ error: error,
device_id: device_id,
serial_number: serial_number,
tags: tags, })
tags: tags })
raise error
end
end

View File

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

View File

@ -4,18 +4,32 @@ describe SendNervesHubInfoJob do
let(:device) { FactoryBot.create(:device) }
it "handles failure" do
params = { device_id: device.id,
serial_number: "xyz",
tags: [],
error: StandardError.new("Hello!"), }
not_work = \
receive(:create_or_update).with(any_args).and_raise(params.fetch(:error))
params = { device_id: device.id,
serial_number: "xyz",
tags: [],
error: StandardError.new("Hello!") }
not_work = receive(:maybe_create_or_update)
.with(any_args)
.and_raise(params.fetch(:error))
expect(NervesHub).to not_work
old_logger = ActiveJob::Base.logger
old_logger = ActiveJob::Base.logger
ActiveJob::Base.logger = Logger.new(nil)
expect do
SendNervesHubInfoJob.perform_now(**params.except(:error))
end.to raise_error(params.fetch(:error))
ActiveJob::Base.logger = old_logger
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

View File

@ -2,14 +2,15 @@ require "spec_helper"
describe NervesHub do
def stub_connection
double(SecureRandom.hex.first(6), :ca_file= => nil,
:cert_store => nil,
double(SecureRandom.hex.first(6), :ca_file= => nil,
:cert_store => nil,
:cert_store= => nil,
:use_ssl => nil,
:use_ssl= => nil,
:cert= => nil,
:key= => nil)
:use_ssl => nil,
:use_ssl= => nil,
:cert= => nil,
:key= => nil)
end
before(:each) { NervesHub.set_conn(stub_connection) }
# reset to default.
@ -20,14 +21,13 @@ describe NervesHub do
it "generates HTTP failure messages" do
status = "800"
msg = "failed to reticulate splines."
expect { NervesHub.bad_http(status, msg) }
.to raise_error(NervesHub::NervesHubHTTPError)
msg = "failed to reticulate splines."
expect { NervesHub.bad_http(status, msg) }.to raise_error(NervesHub::NervesHubHTTPError)
end
it "generates URL paths" do
expect(NervesHub.devices_path).to eq "/orgs/farmbot/devices"
expect(NervesHub.device_path("foo")).to eq "/orgs/farmbot/devices/foo"
expect(NervesHub.devices_path).to eq "/orgs/farmbot/devices"
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"
end
@ -53,21 +53,20 @@ describe NervesHub do
end
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),
{"tags":["foo"]}.to_json,
{ "tags": ["foo"] }.to_json,
NervesHub::HEADERS]
expect(NervesHub.conn).to receive(:put).with(*expected_args).and_return(resp)
expect { NervesHub.update(ser, ["foo"]) }
.to raise_error(NervesHub::NervesHubHTTPError)
expect { NervesHub.update(ser, ["foo"]) }.to raise_error(NervesHub::NervesHubHTTPError)
end
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),
{"tags":["foo"]}.to_json,
{ "tags": ["foo"] }.to_json,
NervesHub::HEADERS]
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
expect(NervesHub.conn)
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)
tags = [ "A:B", "C:D" ]
xpect_args2 = [ "/orgs/farmbot/devices",
{ "description": "farmbot-X",
"identifier": "X",
"tags": tags }.to_json,
NervesHub::HEADERS ]
data = {fake: "Farmbot"}
resp2 = StubResp.new("201", { "data" => data }.to_json)
tags = ["A:B", "C:D"]
xpect_args2 = ["/orgs/farmbot/devices",
{ "description": "farmbot-X",
"identifier": "X",
"tags": tags }.to_json,
NervesHub::HEADERS]
data = { fake: "Farmbot" }
resp2 = StubResp.new("201", { "data" => data }.to_json)
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
it "sometimes performs the NERVES_HUB_CA_HACK" do
@ -111,16 +110,16 @@ describe NervesHub do
it "detects malformed tags" do
tags = ["wrong", "also_wrong", "ok:tag"].shuffle
serial_number = "0xCAFEF00D"
expected = { error: NervesHub::BAD_TAG,
expected = { error: NervesHub::BAD_TAG,
serial_number: serial_number,
tags: tags, }
tags: tags }
resp = StubResp.new("200", {
"data" => { hello: :world, identifier: "?" }
"data" => { hello: :world, identifier: "?" },
}.to_json)
do_it = \
do_it =
receive(:get).with("/orgs/farmbot/devices/#{serial_number}").and_return(resp)
expect(NervesHub.conn).to do_it
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