286 lines
7.1 KiB
Ruby
286 lines
7.1 KiB
Ruby
module Api
|
|
# When RabbitMQ gets a connection, it will check in with the API to make sure
|
|
# the user is allowed to perform the action.
|
|
# Returning "allow" will allow them to perform the requested action.
|
|
# Any other response results in denial.
|
|
# Results are cached for 10 minutes to prevent too many requests to the API.
|
|
class RmqUtilsController < Api::AbstractController
|
|
class BrokerConnectionLimiter
|
|
attr_reader :cache
|
|
|
|
CACHE_KEY_TPL = "mqtt_limiter:%s"
|
|
TTL = 60 * 5 # Five Minutes
|
|
PER_DEVICE_MAX = 20
|
|
MAX_GUEST_COUNT = 256
|
|
WARNING = "'%s' was rate limited."
|
|
|
|
class RateLimit < StandardError; end
|
|
|
|
def self.current(cache = Rails.cache.redis)
|
|
self.new(cache)
|
|
end
|
|
|
|
def initialize(cache)
|
|
@cache = cache
|
|
end
|
|
|
|
def maybe_continue(username)
|
|
is_guest = (username == FARMBOT_DEMO_USER)
|
|
max = is_guest ? MAX_GUEST_COUNT : PER_DEVICE_MAX
|
|
key = CACHE_KEY_TPL % username
|
|
total = (cache.get(key) || "0").to_i
|
|
needs_ttl = cache.ttl(key) < 1
|
|
if total < max
|
|
cache.incr(key)
|
|
cache.expire(key, TTL) if needs_ttl
|
|
yield
|
|
else
|
|
Device
|
|
.delay
|
|
.connection_warning(username) if !is_guest
|
|
raise RateLimit, username
|
|
end
|
|
end
|
|
end
|
|
|
|
# List of AMQP/MQTT topics we support in the following format:
|
|
# "bot.device_123.<MAIN TOPIC HERE>"
|
|
BOT_CHANNELS = %w(
|
|
from_api
|
|
from_clients
|
|
from_device
|
|
logs
|
|
nerves_hub
|
|
ping
|
|
pong
|
|
resources_v0
|
|
status
|
|
status_v8
|
|
sync
|
|
telemetry
|
|
\\#
|
|
\\*
|
|
).map { |x| x + "(\\.|\\z)" }.join("|")
|
|
|
|
# The only valid format for AMQP / MQTT topics.
|
|
# Prevents a whole host of abuse / security issues.
|
|
DEVICE_SPECIFIC_CHANNELS =
|
|
Regexp.new("bot\\.device_\\d*\\.(#{BOT_CHANNELS})")
|
|
PUBLIC_BROADCAST = "public_broadcast"
|
|
PUBLIC_CHANNELS = ["", ".*", ".#"].map { |x| PUBLIC_BROADCAST + x }
|
|
|
|
MALFORMED_TOPIC = "malformed topic. Must match #{DEVICE_SPECIFIC_CHANNELS.inspect}"
|
|
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
|
|
RESOURCES = ["queue", "exchange"]
|
|
PERMISSIONS = ["configure", "read", "write"]
|
|
FARMBOT_DEMO_USER = "farmbot_demo"
|
|
DEMO_REGISTRY_ROOT = "demos"
|
|
|
|
class PasswordFailure < Exception; end
|
|
|
|
rescue_from PasswordFailure, with: :report_suspicious_behavior
|
|
rescue_from BrokerConnectionLimiter::RateLimit, with: :deny
|
|
|
|
skip_before_action :check_fbos_version, except: []
|
|
skip_before_action :authenticate_user!, except: []
|
|
|
|
before_action :always_allow_admin, except: [:user_action]
|
|
|
|
def user_action # Session entrypoint - Part I
|
|
# Example JSON:
|
|
# "username" => "foo@bar.com",
|
|
# "password" => "******",
|
|
# "vhost" => "/",
|
|
# "client_id" => "MQTT_FX_Client",
|
|
case username_param
|
|
# NOTE: "guest" is not the same as
|
|
# "farmbot_demo". We intentionally
|
|
# differentiate to avoid accidental
|
|
# security issues. -RC
|
|
when "guest" then deny
|
|
when "admin" then authenticate_admin
|
|
when FARMBOT_DEMO_USER
|
|
with_rate_limit { allow }
|
|
else
|
|
is_ok = device_id_in_username == current_device.id
|
|
is_ok ? (with_rate_limit { allow }) : deny
|
|
end
|
|
end
|
|
|
|
def vhost_action # Session entrypoint - Part II
|
|
# Example JSON:
|
|
# "username" => "admin",
|
|
# "vhost" => "/",
|
|
# "ip" => "::ffff:172.23.0.1",
|
|
vhost_param == VHOST ? allow : deny
|
|
end
|
|
|
|
def resource_action
|
|
# Example JSON:
|
|
# "username" => "admin",
|
|
# "vhost" => "/",
|
|
# "resource" => "queue",
|
|
# "name" => "mqtt-subscription-MQTT_FX_Clientqos0",
|
|
# "permission" => "configure",
|
|
ok = RESOURCES.include?(resource_param) && PERMISSIONS.include?(permission_param)
|
|
ok ? allow : deny
|
|
end
|
|
|
|
def topic_action # Called during subscribe
|
|
# Example JSON:
|
|
# "name" => "amq.topic",
|
|
# "permission" => "read",
|
|
# "resource" => "topic",
|
|
# "routing_key" => "from_api",
|
|
# "username" => "admin",
|
|
# "vhost" => "/",
|
|
case routing_key_param
|
|
when *PUBLIC_CHANNELS
|
|
permission_param == "read" ? allow : deny
|
|
else
|
|
if_topic_is_safe do
|
|
device_id_in_topic == device_id_in_username ? allow : deny
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def always_allow_admin
|
|
raise "NEVER" if action_name == "user" # Security failsafe
|
|
allow if admin?
|
|
end
|
|
|
|
def farmbot_demo?
|
|
username_param == FARMBOT_DEMO_USER
|
|
end
|
|
|
|
def admin?
|
|
username_param == "admin"
|
|
end
|
|
|
|
def authenticate_admin
|
|
correct_pw = password_param == admin_password
|
|
ok = admin? && correct_pw
|
|
if ok
|
|
allow("management", "administrator")
|
|
else
|
|
raise PasswordFailure
|
|
end
|
|
end
|
|
|
|
def admin_password
|
|
@admin_password ||= ENV.fetch("ADMIN_PASSWORD")
|
|
rescue KeyError
|
|
raise PasswordFailure
|
|
end
|
|
|
|
def report_suspicious_behavior
|
|
Rollbar.error("Failed password attempt on RMQ: " + password_param)
|
|
deny
|
|
end
|
|
|
|
def deny
|
|
render json: "deny", status: 403
|
|
end
|
|
|
|
def allow(*tags)
|
|
render json: (["allow"] + tags).join(" ")
|
|
end
|
|
|
|
def username_param
|
|
@username ||= params.fetch("username")
|
|
end
|
|
|
|
def password_param
|
|
@password ||= params.fetch("password")
|
|
end
|
|
|
|
def routing_key_param
|
|
@routing_key ||= params.fetch("routing_key")
|
|
end
|
|
|
|
def vhost_param
|
|
@vhost ||= params.fetch("vhost")
|
|
end
|
|
|
|
def resource_param
|
|
@resource ||= params.fetch("resource")
|
|
end
|
|
|
|
def permission_param
|
|
@permission ||= params.fetch("permission")
|
|
end
|
|
|
|
def if_topic_is_safe
|
|
if farmbot_demo?
|
|
a, b, c = (routing_key_param || "").split(".")
|
|
|
|
if !(permission_param == "read")
|
|
deny
|
|
return
|
|
end
|
|
|
|
if !(a == DEMO_REGISTRY_ROOT)
|
|
deny
|
|
return
|
|
end
|
|
|
|
if b.nil?
|
|
deny
|
|
return
|
|
end
|
|
|
|
if b.include?("*")
|
|
deny
|
|
return
|
|
end
|
|
|
|
if b.include?("#")
|
|
deny
|
|
return
|
|
end
|
|
|
|
if c.present?
|
|
deny
|
|
return
|
|
end
|
|
|
|
yield
|
|
return
|
|
end
|
|
|
|
if !!DEVICE_SPECIFIC_CHANNELS.match(routing_key_param)
|
|
yield
|
|
return
|
|
end
|
|
|
|
render json: MALFORMED_TOPIC, status: 422
|
|
end
|
|
|
|
def device_id_in_topic
|
|
(routing_key_param || "") # "bot.device_9.logs"
|
|
.gsub("bot.device_", "") # "9.logs"
|
|
.split(".") # ["9", "logs"]
|
|
.first # "9"
|
|
.to_i || 0 # 9
|
|
end
|
|
|
|
def with_rate_limit
|
|
BrokerConnectionLimiter
|
|
.current
|
|
.maybe_continue(username_param) { yield }
|
|
end
|
|
|
|
def current_device
|
|
@current_device ||= Auth::FromJWT.run!(jwt: password_param).device
|
|
rescue Mutations::ValidationException => e
|
|
raise JWT::VerificationError, "RMQ Provided bad token"
|
|
end
|
|
|
|
def device_id_in_username
|
|
@device_id ||= username_param.gsub("device_", "").to_i
|
|
end
|
|
end
|
|
end
|