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.