nfsn-api.rb

#! /usr/bin/env ruby

require "digest/sha1"
require "logger"
require "net/https"
require "uri"

begin
	require "rubygems"
	require "json"
rescue LoadError
	fail "Uh, oh -- couldn't load JSON library!"
end

# == NFSN
#
# A Ruby module for accessing the NearlyFreeSpeech.NET API.
#
# https://api.nearlyfreespeech.net
#
# The most useful class to start with is NFSN::Manager.
#
# Last update: 2008-11-09
#
# == Example
#
#	mgr = NFSN::Manager.new("example", "A1b2C3d4E5f6G7h8")
#	site = mgr.siteHandle("example")
#	site.addAlias("sub.example.com")
#
#
module NFSN

# A log device for use with Ruby's built-in logger.
class LogDevice	# :nodoc:
	def initialize(stream = $stderr)
		@stream = stream
	end

	def write(str)
		str = str.sub(/^., /, "")	# remove leading char.
		str = str.sub(/#\d+\]/, "]")	# remove PID
		@stream.write(str)
	end

	def close
		@stream.close
	end
end

class Error < ::Exception
	attr_reader :msg, :detail
	def initialize(message, detail = {})
		super(message)
		@detail = detail
	end
end
class BadAuthenticationError < NFSN::Error
	def initialize(detail)
		super("Authentication failed", detail)
	end
end
class BadTimestampError < NFSN::Error
	def initialize(detail, delta)
		super("Timestamp was out of range (by #{delta}s)", detail)
	end
end
class NotImplementedError < NFSN::Error
	def initialize(detail)
		super("Unimplemented or unknown API call", detail)
	end
end

# The main entry point for using this API. Use this to access all the other
# components of the API.
class Manager
	# The hostname of the API server
	attr_reader :hostname
	# e.g. "401 Authorization Required"
	attr_reader :server_error
	# The API error hash (de-JSON'ed)
	attr_reader :error

	# The +login+ parameter should be your NFSN membership login name.
	# +api_key+ is your NFSN-issued API key.
	# Setting the optional +debug+ parameter to +true+ will increase the
	# log verbosity.
	def initialize(login, api_key, debug = false)
		@login = login
		@api_key = api_key
		@debug = debug

		@logger = Logger.new(NFSN::LogDevice.new($stderr))
		@logger.datetime_format = "%d %b %Y %H:%M:%S"
		@logger.level = @debug ? Logger::DEBUG : Logger::INFO

		@hostname = "api.nearlyfreespeech.net"
		@server_error = @error = nil
		@error_response = nil

		@timestamp = nil	# Timestamp of last request generated
		@time_nudge = 0
	end

	def setTimeNudge(time_nudge)
		# TODO: sanity checking
		@time_nudge = time_nudge
	end

	# Generate a random 16-character salt.
	def genSalt
		charset = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
		return (1..16).map { charset[rand(charset.size)] }.join
	end
	private :genSalt

	# Generate the X-NFSN-Authentication HTTP header.
	def genAuthHeader(uri, body = "")
		@timestamp = (Time.now + @time_nudge).to_i
		salt = genSalt
		#@logger.debug "body: #{body}"
		body_hash = Digest::SHA1.hexdigest(body)

		check_str = [@login, @timestamp.to_s, salt,
				@api_key, uri.request_uri, body_hash].join(";")
		#@logger.debug "check_str: #{check_str}"
		hash = Digest::SHA1.hexdigest(check_str)

		auth_str = [@login, @timestamp.to_s, salt, hash].join(";")
		auth_str
	end
	private :genAuthHeader

	# Throws an appropriate NFSN::Error exception.
	def handle40x(response)
		code = response.code
		@logger.debug "Got a #{code}! (msg=#{response.message})"
		if code == "401" and @error["debug"] =~ /timestamp/
			hdr = response["WWW-Authenticate"]
			delta = hdr.scan(/time=(\d+)/)[0]
			delta = (delta[0].to_i - @timestamp) if delta
			raise NFSN::BadTimestampError.new(@error, delta)
		elsif code == "401" and @error["debug"] =~ /authentication hash/
			raise NFSN::BadAuthenticationError.new(@error)
		elsif code == "404" and @error["error"] =~ /API.*not valid/
			raise NFSN::NotImplementedError.new(@error)
		else
			raise NFSN::Error.new("Unknown error!", @error)
		end
	end
	private :handle40x

	def describeError
		fail "no error yet!" unless @error and @error_response
		@error_response.each_capitalized { |key, val|
			$stderr.puts "\t#{key}: #{val}"
		}
		require "pp"; pp @error
	end

	# +method+ should be one of :GET, :PUT, :POST, indicating the
	# type of API call.
	# For :GET, +data+ is ignored.
	# For :PUT, +data+ should be the data to store.
	# For :POST, +data+ should be a Hash of attributes.
	#
	# This should only be called from NFSN::APIObject or a descendent.
	def doOp(method, uri, data = nil)
		http = Net::HTTP.new(uri.host, uri.port)
		http.use_ssl = (uri.scheme == "https")
		http.verify_mode = OpenSSL::SSL::VERIFY_NONE
		req = nil
		if method == :GET
			req = Net::HTTP::Get.new(uri.request_uri)
		elsif method == :PUT
			req = Net::HTTP::Put.new(uri.request_uri)
			req.body = data.to_s
		elsif method == :POST
			req = Net::HTTP::Post.new(uri.path)
			req.form_data = data
		else
			fail "Bad HTTP method '#{method}'!"
		end

		auth_str = genAuthHeader(uri, req.body || "")
		req["Host"] = @hostname
		req["X-NFSN-Authentication"] = auth_str

		@logger.debug "#{method} #{uri}"
		#@logger.debug "X-NFSN-Authentication: #{auth_str}"
		data = begin
			response = http.start { |http| http.request(req) }
			if response.kind_of? Net::HTTPSuccess
				response.body
			else
				response.error!
			end
		rescue Net::HTTPServerException
			@server_error = "#{$!}"
			@error = JSON.parse(response.body)
			@error_response = response

			@logger.debug "Server returned: #{@server_error}"
			if response.code =~ /40\d/
				handle40x(response)
			end

			fail $!		# fallback
		end
		if (not data.nil?) and (data.size > 0)
			@logger.debug "Got #{data.size} bytes"
			return data
		end
		@logger.debug "Got empty return value"
		return nil
	end

	# Returns an NFSN::Account object.
	def accountHandle(account_id)
		NFSN::Account.new(self, account_id)
	end

	# Returns an NFSN::DNS object.
	def dnsHandle(hostname)
		NFSN::DNS.new(self, hostname)
	end

	# Returns an NFSN::Member object.
	def memberHandle(login = nil)
		NFSN::Member.new(self, login || @login)
	end

	# Returns an NFSN::Site object.
	def siteHandle(shortname)
		NFSN::Site.new(self, shortname)
	end

	# Returns an NFSN::Email object.
	def emailHandle(hostname)
		NFSN::Email.new(self, hostname)
	end
end

# An abstract API object.
class APIObject
	def initialize(mgr, type, instance_id)
		@mgr = mgr
		@type = type
		@instance_id = instance_id
	end

	def uriForAttribute(attribute)
		# TODO: is there a more proper way to build this?
		uri = URI.parse("")
		uri.scheme = "https"
		uri.host = @mgr.hostname
		uri.path = [nil, @type, @instance_id, attribute].join("/")
		URI.parse(uri.to_s)
	end
	private :uriForAttribute

	# In descendent classes, use something like:
	#	nfsn_property_rw :minTTL, Integer
	# to declare an +Integer+ property called +minTTL+. Supported types are
	# String and Integer at the moment. This will declare both
	# a getter (+minTTL+) and a setter (<tt>minTTL=</tt>).
	def self.nfsn_property_rw(symbol, rtype = String)
		self.nfsn_property_ro(symbol, rtype)
		self.nfsn_property_wo(symbol, rtype)
	end

	# Defines a read-only property. For example:
	#	nfsn_property_ro :balance, Integer
	# declares an +Integer+ property called +balance+. Supported types are
	# String and Integer at the moment. This will declare
	# a getter (+balance+).
	def self.nfsn_property_ro(symbol, rtype = String)
		# Define reader
		class_eval %{
			def #{symbol}
				uri = uriForAttribute("#{symbol}")
				val = @mgr.doOp(:GET, uri)
				return val if val.nil?
				if #{rtype} == String
					return val.to_s
				elsif #{rtype} == Integer
					return val.to_i
				else
					fail "Unknown type #{rtype}"
				end
			end
		}
	end

	# Defines a write-only property. For example:
	#	nfsn_property_wo :password, String
	# declares an +String+ property called +password+. Supported types are
	# String and Integer at the moment. This will declare
	# a setter (<tt>password=</tt>).
	def self.nfsn_property_wo(symbol, rtype = String)
		# Define writer
		class_eval %{
			def #{symbol}=(val)
				uri = uriForAttribute("#{symbol}")
				@mgr.doOp(:PUT, uri, val)
			end
		}
	end

	# Defines a method. For example:
	#	nfsn_method :addAlias, [:alias]
	# declares a method called +addAlias+ that takes a single argument
	# called +alias+. This will declare a method that takes the correct
	# number of arguments.
	def self.nfsn_method(symbol, arg_list = [])
		args = arg_list.map { |a| "arg_#{a}" }.join(",")
		args_map = arg_list.map { |a|
			"\"#{a}\" => arg_#{a}"
		}.join(",\n")
		args_sym = arg_list.map { |a| ":#{a}" }.join(",")
		class_eval %{
			def #{symbol}(#{args})
				uri = uriForAttribute("#{symbol}")
				data = {
					#{args_map}
				}
				@mgr.doOp(:POST, uri, data)
			end
		}
	end
end

class Account < NFSN::APIObject
	def initialize(mgr, account_id)
		if account_id !~ /^[0-9A-F]{4}-[0-9A-F]{8}$/
			fail "Bad format for Account ID"
		end
		super(mgr, "account", account_id)
	end

	# FIXME: Should this be Integer, Float or String?
	nfsn_property_ro :balance, String
end

class DNS < NFSN::APIObject
	def initialize(mgr, hostname)
		super(mgr, "dns", hostname)
	end

	nfsn_property_rw :minTTL, Integer

	nfsn_method :getInfo, []
end

class Member < NFSN::APIObject
	def initialize(mgr, login)
		super(mgr, "member", login)
	end

	nfsn_property_rw :email, String
	nfsn_property_rw :status, String
	nfsn_property_rw :sites, String
end

class Site < NFSN::APIObject
	def initialize(mgr, shortname)
		super(mgr, "site", shortname)
	end

	nfsn_method :getInfo, []
	nfsn_method :addAlias, [:alias]
	nfsn_method :removeAlias, [:alias]
end

class Email < NFSN::APIObject
	def initialize(mgr, hostname)
		super(mgr, "email", hostname)
	end

	nfsn_method :getInfo, []
	nfsn_method :listForwards, []
	nfsn_method :removeForward, [:forward]
	nfsn_method :setForward, [:forward, :dest_email]
end
end

if __FILE__ == $0
	$defout.sync = true
	puts "Testing NFSN API..."

	login = "dsymonds"		# Member login name
	key = "XXXXXXXXXXXXXXXX"	# API key

	mgr = NFSN::Manager.new(login, key, true)

	def testDNS(mgr)
		dns = mgr.dnsHandle("dagii.org")
		dns_minTTL = dns.minTTL
		print "======> DNS minTTL: "
		puts dns_minTTL ? "#{dns_minTTL}s" : "failed"
	end

	mgr.setTimeNudge(13)

	bar = "-" * 30

	puts "#{bar} Testing with normal settings #{bar}"
	testDNS(mgr)

	begin
		puts "#{bar} Testing unimplemented API call #{bar}"
		mgr.accountHandle("XXXX-XXXXXXXX").balance
	rescue NFSN::NotImplementedError
		puts "Caught expected exception"
	end

	begin
		puts "#{bar} Testing with bad time #{bar}"
		mgr.setTimeNudge(-30)
		testDNS(mgr)
	rescue NFSN::BadTimestampError
		puts "Caught expected exception"
	end

	begin
		puts "#{bar} Testing with bad API key #{bar}"
		mgr.setTimeNudge(12)
		mgr.instance_variable_set(:@api_key, "badkey")
		testDNS(mgr)
	rescue NFSN::BadAuthenticationError
		puts "Caught expected exception"
	end

	exit

	# This works fine, but I probably shouldn't do this too much...
	if false
		site = mgr.siteHandle("dagii")
		new_alias = "silly.dagii.org"
		puts "======> Adding alias"
		puts site.addAlias(new_alias)
		sleep 3
		puts "======> Removing alias"
		puts site.removeAlias(new_alias)
	end

	dns = mgr.dnsHandle("dagii.org")
	dns_minTTL = dns.minTTL
	print "======> DNS minTTL: "
	puts dns_minTTL ? "#{dns_minTTL}s" : "failed"
	#puts "======> DNS getInfo(): #{dns.getInfo}"

	# Not yet implemented
	#acc = mgr.accountHandle("XXXX-XXXXXXXX")
	#puts "======> Account balance: \"#{acc.balance}\""

	# Not yet implemented
	#mem = mgr.memberHandle("dsymonds")
	#puts "======> email: #{mem.email}"
	#puts "======> status: #{mem.status}"
	#puts "======> sites: #{mem.sites}"
end

Generated by GNU Enscript 1.6.6.