Skip to content

Commit

Permalink
Add NervesHub REST API implementation.
Browse files Browse the repository at this point in the history
This is the all the code for the remote api calls
FarmBot API will need to make to NervesHub for
generating devices. No public access to this yet.
  • Loading branch information
ConnorRigby committed Sep 20, 2018
1 parent a1eb3e5 commit f2b7054
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 1 deletion.
14 changes: 14 additions & 0 deletions app/controllers/api/device_certs_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Api
class DeviceCertsController < Api::AbstractController
def show
render json: {finish: :this}
end

def create
mutate DeviceCerts::Create.run(raw_json, device: current_device)
end

private

end
end
6 changes: 5 additions & 1 deletion app/lib/key_gen.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ def self.try_file
end

def self.generate_new_key(path = SAVE_PATH)
rsa = OpenSSL::PKey::RSA.generate(2048)
rsa = generate_new_key()
File.open(path, 'w') { |f| f.write(rsa.to_pem) }
return rsa
end

def self.generate_new_key
OpenSSL::PKey::RSA.generate(2048)
end

# Heroku / 12Factor users can't store stuff on the file system. Store your pem
# file in ENV['RSA_KEY'] if that applies to you.
def self.try_env
Expand Down
201 changes: 201 additions & 0 deletions app/lib/nerves_hub.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
require "net/http"
require "openssl"
require "base64"

class NervesHub
# There is a lot of configuration available in this class to support:
# * Self Hosters
# * Running a local instance of Nerves-Hub
# * Not using NervesHub at all.
# Here is a short description of each configurable option and what it does.
# * NERVES_HUB_HOST - Hostname or ip address of NervesHub server.
# * NERVES_HUB_PORT - Port for NervesHub server.
# * NERVES_HUB_ORG - Organization name that the FarmBot API is authorized
# to use.
# * NERVES_HUB_DEVICE_CSR_DIR - Where device csr will be generated
# temporarily. The private device cert will
# never be stored to disk.
#
# Authorizing the FarmBot API is done by installing an authorized client side
# SSL certificate as assigned by the NervesHub CA. There are two options for
# configuring this. If none or many of the settings below are missing,
# This module will simply not do anything.
#
# * Environment variables (Heroku style deploys, will be loaded first)
# * NERVES_HUB_CERT - X509 PEM Cert (not a path to a file)
# * NERVES_HUB_KEY - EC Private key (not a path to a file)
# * NERVES_HUB_CA - NervesHub Certificates. (not a path to a file)
# * Files (Self hosters/local development, will be loaded if they exist)
# * nerves_hub_cert.<ENV>.pem - for example: nerves_hub_cert.production.pem
# * nerves_hub_key.<ENV>.pem - for example: nerves_hub_key.production.pem
# * nerves_hub_ca.<ENV>.pem - for example: nerves_hub_ca.production.pem
#
# Once the FarmBot API is authenticated to make calls to NervesHub here's what
# will happen from a fresh boot/flash of FarmBotOS:
# * New FarmBot boots without access to NervesHub
# * FarmBot gets configured via configurator
# * FarmBot gets a JWT from the FarmBot API
# * FarmBot makes an authenticated call to the FarmBot API asking for a
# NervesHub SSL Cert
# * FarmBot API makes a call to NervesHub generating a `device` resource.
# * FarmBot API makes a call to NervesHub generating a `device_cert` resouce.
# * FarmBot API sends this cert (without saving it) directly to the FarmBot.
# * FarmBot burns that cert into internal storage on it's SD card.

# Device Certs are generated locally, and should be discarded
# after a successful request to nerves-hub.
NERVES_HUB_DEVICE_CSR_DIR = ENV["NERVES_HUB_DEVICE_CSR_DIR"] || "/tmp/"
NERVES_HUB_HOST = ENV["NERVES_HUB_HOST"] || "api.nerves-hub.org"
NERVES_HUB_PORT = ENV["NERVES_HUB_PORT"] || 443
NERVES_HUB_ORG = ENV["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"

# 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"

# Create a new device in NervesHub. `tags` should be a list of strings
# 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
}
resp = conn.post(devices_path(), data.to_json(), headers())
(resp.code == "201") || raise("NervesHub request failed: #{resp.code}: #{resp.body}")
JSON(resp.body)["data"].deep_symbolize_keys()
end

# Delete a device.
def self.delete_device(serial_number)
resp = conn.delete("#{devices_path()}/#{serial_number}")
(resp.code == "204") || raise("NervesHub request failed: #{resp.code}: #{resp.body}")
resp.body
end

# Creates a device certificate that is able to access NervesHub.
def self.sign_device(serial_number)
key_file = generate_device_key(serial_number)
csr_file = generate_device_csr(serial_number, key_file)

key_bin = File.read(key_file)
csr_bin = File.read(csr_file)

key_safe = Base64.strict_encode64(key_bin)
csr_safe = Base64.strict_encode64(csr_bin)

data = {
identifier: serial_number
csr: csr_safe,
}
resp = conn.post(device_sign_path(serial_number), data.to_json(), headers())
(resp.code == "200") || raise("NervesHub request failed: #{resp.code}: #{resp.body}")
cert = JSON(resp.body)["data"].deep_symbolize_keys()[:cert]
FileUtils.rm(key_file)
FileUtils.rm(csr_file)
ret = {
cert: Base64.strict_encode64(cert)
csr: csr_safe,
key: key_safe,
}
end

def self.active?
!(current_cert.nil? && current_key.nil?)
end

private

def self.devices_path
"/orgs/#{NERVES_HUB_ORG}/devices"
end

def self.device_sign_path(serial_number)
"#{devices_path}/#{serial_number}/certificates/sign"
end

def self.headers
{"Content-Type" => "application/json"}
end

def self.generate_device_key(serial_number)
file = File.join(NERVES_HUB_DEVICE_CSR_DIR, "#{serial_number}-key.pem")
%x[openssl ecparam -genkey -name prime256v1 -noout -out #{file}]
file
end

def self.generate_device_csr(serial_number, key_file)
file = File.join(NERVES_HUB_DEVICE_CSR_DIR, "#{serial_number}-csr.pem")
%x[openssl req -new -sha256 -key #{key_file} -out #{file} -subj /O=#{serial_number}]
file
end

def self.try_env_cert
OpenSSL::X509::Certificate.new(ENV['NERVES_HUB_CERT']) if ENV['NERVES_HUB_CERT']
end

def self.try_file_cert
OpenSSL::X509::Certificate.new(File.read(NERVES_HUB_CERT_PATH)) if File.exist?(NERVES_HUB_CERT_PATH)
end

def self.try_env_key
OpenSSL::PKey::EC.new(ENV['NERVES_HUB_KEY']) if ENV['NERVES_HUB_KEY']
end

def self.try_file_key
OpenSSL::PKey::EC.new(File.read(NERVES_HUB_KEY_PATH)) if File.exist?(NERVES_HUB_KEY_PATH)
end

def self.try_env_ca_file
File.exist?(NERVES_HUB_CA_PATH) && NERVES_HUB_CA_PATH
end

# This is a hack because net/http doesn't
# Allo loading this as a normal cert, it only allows
# loading a flie.
def self.try_file_ca_file
if ENV['NERVES_HUB_KEY']
file = File.open(NERVES_HUB_CA_HACK, 'w')
file.write(ENV['NERVES_HUB_KEY'])
file.close()
NERVES_HUB_CA_HACK
end
end

def self.current_cert
@current_cert ||= (try_env_cert() || try_file_cert() || nil)
end

def self.current_key
@current_key ||= (try_env_key() || try_file_key() || nil)
end

def self.current_ca_file
@current_ca_file ||= (try_env_ca_file() || try_file_ca_file() || nil)
end

def self.conn
if active?() && !@conn
FileUtils.mkdir_p NERVES_HUB_DEVICE_CSR_DIR
@conn = Net::HTTP.new(NERVES_HUB_URI.host, NERVES_HUB_URI.port)
@conn.use_ssl = true
@conn.cert = current_cert()
@conn.key = current_key()
# Setting the contents of this
# in the CA store doesn't work for some reason?
@conn.ca_file = self.current_ca_file()
# Don't think this is absolutely needed.
@conn.cert_store = nil
end
@conn
end

end
11 changes: 11 additions & 0 deletions app/mutations/device_certs/create.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module DeviceCerts
class Create < Mutations::Command
required do
model :device, class: Device
end

def execute
{}
end
end
end
23 changes: 23 additions & 0 deletions config/application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,26 @@ RABBIT_MGMT_URL: "http://delete_this_line.com"
# to use the server.
# DELETE THIS LINE IF YOU RUN A PUBLIC SERVER.
TRUSTED_DOMAINS: "farmbot.io,farm.bot"
# Nerves Hub Configuration
# Nerves Hub handles OTA Firmware updates.
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_HOST: "delete_this.org"
# Port to connect to.
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_PORT: 443
# Organization that the cert and key are authorized to access.
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_ORG: "delete_this_line"
# Where to temporarily store device cert sign requests
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_DEVICE_CSR_DIR: "/delete/this/path"
# Contents of the NervesHub authorized cert
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_CERT: "Change this! Cert looks like `-----BEGIN CERTIFICATE-----`"
# Contents of the NervesHub authorized key
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_KEY: "Change this! Key looks like `-----BEGIN EC PRIVATE KEY-----`"
# Contents of the NervesHub cert chain. Should contain one root-ca,
# and 3 intermediates.
# DELETE THIS LINE if you are a self-hosted user.
NERVES_HUB_CA: "Change this! CA looks like `-----BEGIN CERTIFICATE-----`"
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
# Singular API Resources:
{
device: [:create, :destroy, :show, :update],
device_cert: [:create, :show],
fbos_config: [:destroy, :show, :update,],
firmware_config: [:destroy, :show, :update,],
public_key: [:show],
Expand Down
17 changes: 17 additions & 0 deletions spec/controllers/api/device_cert/decive_certs_create_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require 'spec_helper'

describe Api::DeviceCertsController do
include Devise::Test::ControllerHelpers
describe '#create' do
let(:user) { FactoryBot.create(:user) }
let(:device) { user.device }

it 'creates a cert' do
skip "brb"
sign_in user
payload = {}
post :create, body: payload.to_json, params: {format: :json}
expect(response.status).to eq(200)
end
end
end
17 changes: 17 additions & 0 deletions spec/controllers/api/device_cert/device_certs_show_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require 'spec_helper'

describe Api::DeviceCertsController do
include Devise::Test::ControllerHelpers
describe '#show' do
let(:user) { FactoryBot.create(:user) }
let(:device) { user.device }

it 'shows an existing cert' do
skip "brb"
sign_in user
get :show, params: { }
expect(response.status).to eq(200)
# expect(json[:id]).to eq(tool.id)
end
end
end

0 comments on commit f2b7054

Please sign in to comment.