#!/usr/bin/env ruby # vim: set nosta noet ts=4 sw=4: # encoding: UTF-8 require 'digest/sha2' require 'etc' require 'logger' require 'net/https' require 'optparse' require 'ostruct' require 'socket' require 'time' require 'uri' require 'yaml' # == Description # # A reference client for the StaticCling API. # # This script should have zero external dependencies outside of the ruby stdlib, # and should work under Ruby 1.8 or 1.9. # Run with the --help flag to see available options. # # == Synopsis # # StaticCling::Client.run( opts ) do |c| # c.open_session # ... # c.close_session # end # # client = StaticCling::Client.new # puts client.get_ip # # == Version # # $Id: api.rb,v 44436cff9ed7 2012/05/11 15:19:18 mahlon $ # # == Author # # * Mahlon E. Smith # # == License # # Copyright (c) 2013, Mahlon E. Smith # # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, are # permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # * Redistributions in binary form must reproduce the above copyright notice, this # list of conditions and the following disclaimer in the documentation and/or # other materials provided with the distribution. # # * Neither the name of the author, nor the names of contributors may be used to # endorse or promote products derived from this software without specific prior # written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # module StaticCling class ServerError < RuntimeError; end class ClientError < RuntimeError; end class Client # Versioning VCSRev = %q$Rev: 44436cff9ed7 $ VERSION = '1.0.1' # URI elements API_VERSION = 1 API_ROOT = '/api/v' + API_VERSION.to_s # The main StaticCling domain DEFAULT_HOST = 'staticcling.org' ######################################################################## ### C L A S S M E T H O D S ######################################################################## ### Create a new client object, yielding it to the block. ### Automatically cleans up the connection after the block closes. ### def self::run( opts=nil ) client = new( opts ) yield( client ) client.log.debug 'Closing connection' client.connection.finish end ### Return the version string ### def self::version_string build = VCSRev.match( /: ([[:xdigit:]]+)/ )[1] # "CLI"ent -- Command Line Interface client! Yeah! return "StaticCling CLIent %s (build %s)" % [ VERSION, build ] end ######################################################################## ### I N S T A N C E M E T H O D S ######################################################################## ### Create a new StaticCling::Client object. ### def initialize( opts=nil ) @opts = opts.nil ? OpenStruct.new : opts @digest = nil @session = nil @connection = nil @log = Logger.new( $stderr ) @log.level = case self.opts.debug when 1; Logger::INFO when 2..3; Logger::DEBUG else Logger::WARN end self.log.info "%s startup" % [ self.class.version_string ] self.check_remote end # A persistent Net::HTTP connection. attr_reader :connection # A StaticCling session string, if a session is opened. attr_accessor :session # Options, as passed in from the command line. attr_reader :opts # The logger object. attr_reader :log ### Open a new session on the server. ### ### Note that this should only be performed in a trusted environment or ### over an encrypted channel, since the session key can be sniffed/replayed. ### def open_session uri = self.uri( :session ) creds = self.auth_creds req = Net::HTTP::Post.new( uri.path ) req.body = creds.to_yaml res = connect( uri, req, nil, false ) payload = self.get_payload( res ) self.session = payload[ :session ] self.log.info "Session opened: %s" % [ self.session ] return payload rescue ClientError abort "Invalid credentials?" end ### Invalidate a prior session. ### def close_session return unless self.session uri = self.uri( :session ) req = Net::HTTP::Delete.new( uri.path ) res = connect( uri, req ) self.log.info "Session closed: %s" % [ self.session ] self.session = nil return self.get_payload( res ) end ### Get and return all DNS records after formatting for console output. ### def get_records( format=false ) uri = self.uri( :records ) req = Net::HTTP::Get.new( uri.path ) res = connect( uri, req ) records = self.get_payload( res ) return records unless format a_records = Hash.new {|h,k| h[k] = []} ns_records = Hash.new {|h,k| h[k] = []} records.each do |record| hostname = [ opts.account, DEFAULT_HOST ] hostname.unshift( record[:subdomain] ) if record[ :subdomain ] hostname.unshift( '*' ) if record[ :wildcard ] hostname = hostname.join( '.' ) if record[:type] == 'a' a_records[ hostname ] << record else ns_records[ hostname ] << record end end return a_records, ns_records end ### Add a new DNS +record+. ### def add_record( record ) uri = self.uri( :records ) req = Net::HTTP::Post.new( uri.path ) req.body = record.to_yaml res = connect( uri, req ) return res.code.to_i == 201 end ### Remove a DNS record, identified by +id+. ### def delete_record( id ) uri = self.uri( :records ) uri.path = uri.path + '/' + id req = Net::HTTP::Delete.new( uri.path ) res = connect( uri, req ) return res.code.to_i == 200 end ### Update an existing DNS record identified by +id+ with the ### settings in the +attrs+ hash. ### def update_record( id, attrs ) uri = self.uri( :records ) uri.path = uri.path + '/' + id req = Net::HTTP::Put.new( uri.path ) req.body = attrs.to_yaml res = connect( uri, req ) return res.code.to_i == 204 end ### Use the local routing table by default to determine the right ### interface to use, if an IP address wasn't specified on the command line. ### If the IP address option (-i) is set to the string "remote", ### get and return the IP address the server thinks we're coming from. ### Otherwise, attempt to use the IP address as passed. ### def get_ip ip = nil case self.opts.ip when 'remote' uri = self.uri( :ip ) req = Net::HTTP::Get.new( uri.path ) res = connect( uri, req, 'text/plain' ) ip = res.body self.log.info "IP obtained via remote detection: %p" % [ ip ] when nil BasicSocket.do_not_reverse_lookup = true # 1.8 and 1.9 compatible ip = UDPSocket.open do |s| s.connect( DEFAULT_HOST, 1 ); s.addr.last end self.log.info "IP obtained via local autodetect: %p" % [ ip ] else ip = self.opts.ip self.log.info "IP obtained explicitly: %p" % [ ip ] end return ip.to_s rescue SocketError self.bomb( ClientError, 'Unable to auto-detect IP address. (Use -i?)' ) rescue => err self.bomb( ClientError, err ) end ### Given a +new_password+, update the account. ### def change_pw( new_password ) uri = self.uri( :accounts ) uri.path = uri.path + '/' + opts.account req = Net::HTTP::Put.new( uri.path ) req.body = { :password => new_password }.to_yaml res = connect( uri, req ) return res.code.to_i == 204 end ### Fetch and return a serialized account record. ### def account_info uri = self.uri( :accounts ) uri.path = uri.path + '/' + opts.account req = Net::HTTP::Get.new( uri.path ) res = connect( uri, req ) return self.get_payload( res ) end ### Exit with an exception, providing the appropriate level of info ### based on the current debug level. ### def bomb( klass, err ) if self.opts.debug > 0 raise klass, err else abort err.respond_to?( :message ) ? err.message : err end ensure self.close_session if self.session end ######### protected ######### ### Setup a new persistent connection. Try and make sure the remote host ### is a StaticCling server, and we're speaking the expected API version. ### def check_remote unless @connection self.log.debug 'Creating new connection' @connection = Net::HTTP.new( self.opts.host, self.opts.port ) # Enable SSL communication, including the CACert public key # for propa' connection verification. # if opts.secure @connection.use_ssl = true @connection.verify_mode = OpenSSL::SSL::VERIFY_PEER end @connection.set_debug_output( self.log ) if self.opts.debug > 2 @connection.start end uri = self.uri( '' ) req = Net::HTTP::Head.new( uri.path ) res = connect( uri, req ) remote_version = res[ 'x-staticcling' ] or self.bomb( ServerError, "Not a StaticCling server?" ) remote_version = remote_version.match( / (\d+)\.\d+\.\d+/ ) or raise "Couldn't identify server version." remote_version = remote_version[1].to_i version_comparison = "Client API: %d, Server API: %d" % [ API_VERSION, remote_version ] self.log.debug version_comparison if remote_version != API_VERSION self.log.warn "Remote API doesn't match! %s" % [ version_comparison ] self.log.warn "Continuing, but things may be wonky." end rescue => err self.bomb( ClientError, err ) end ### Build and return a URI object for the given +endpoint+. ### def uri( endpoint ) protocol = opts.secure ? 'https://' : 'http://' return URI.parse( protocol + self.opts.host + "#{API_ROOT}/#{endpoint.to_s}" ) end ### Fetch and return a StaticCling challenge string. ### def get_challenge uri = self.uri( :challenge ) req = Net::HTTP::Get.new( uri.path ) res = connect( uri, req ) payload = self.get_payload( res ) return payload[ :challenge ] end ### Accept a +uri+ and a +req+ Net::HTTP object, set headers, and return ### a Net::HTTP response. ### def connect( uri, req, accept='application/x-yaml', bomb=true ) accept ||= 'application/x-yaml' req[ 'User-Agent' ] = self.class.version_string req[ 'Accept' ] = accept req.set_content_type( 'application/x-yaml' ) req.add_field( 'Cookie', 'sc-session=%s' % [self.session] ) if self.session self.log.debug '-' * 72 self.log.debug "--> %s to %s with %p" % [ req.method, uri, req.body ] res = @connection.request( req ) self.log.debug "<-- %d (%s): %p" % [ res.code, res.message, res.body ] # stop on error # unless ( 200..299 ).include?( res.code.to_i ) msg = "%s --> %d: %s\n" % [ uri, res.code, res.message ] if res.code.to_i == 400 errors = YAML.load( res.body ) rescue [] msg << ' - ' + errors.join( "\n - " ) end raise msg end return res rescue => err raise ClientError, 'Unable to speak to remote: perhaps you need to enable SSL?' unless res exception = (400..499).include?( res.code.to_i ) ? ClientError : ServerError if bomb self.bomb( exception, err ) else raise exception, err.message end end ### Take a serialized YAML response body and return ### a ruby data structure, after error checking. ### def get_payload( response ) payload = YAML.load( response.body ) rescue {} return payload end ### Return a hexdigest of the given +string+. ### def hexdigest( string ) @digest = Digest::SHA2.new( 256 ) unless @digest @digest.update( string ) return @digest.to_s ensure @digest.reset end ### Generate an authentication data structure (challenge, response, account) ### for API calls that require it. ### def auth_creds chal = self.get_challenge resp = self.hexdigest( chal + self.hexdigest(self.opts.password) ) auth = { :challenge => chal, :response => resp, :account => self.opts.account } return auth end end end ### Colorize logger output. ### Taken nearly wholesale from the logger-colors gem, http://rbjl.net/50-exploring-the-stdlib-logger ### Jan Lelis ### class Logger # Terminal color escapes module Colors NOTHING = '0;0' BLACK = '0;30' RED = '0;31' GREEN = '0;32' BROWN = '0;33' BLUE = '0;34' PURPLE = '0;35' CYAN = '0;36' LIGHT_GRAY = '0;37' DARK_GRAY = '1;30' LIGHT_RED = '1;31' LIGHT_GREEN = '1;32' YELLOW = '1;33' LIGHT_BLUE = '1;34' LIGHT_PURPLE = '1;35' LIGHT_CYAN = '1;36' WHITE = '1;37' end include Colors # DEBUG, INFO, WARN, ERROR, FATAL, UNKNOWN COLOR_SCHEMA = %w[ CYAN YELLOW WHITE RED LIGHT_RED ] alias :format_message_colorless :format_message def format_message( level, *args ) level_pos = Logger.const_get( level.to_sym ) color = Logger.const_get( COLOR_SCHEMA[level_pos] || 'UNKNOWN' ) rescue NOTHING if $color return "\e[#{ color }m#{ format_message_colorless( level, *args ) }\e[0;0m" else return format_message_colorless( level, *args ) end end end ######################################################################## ### R U N T I M E ######################################################################## if __FILE__ == $0 #################################################################### ### O P T I O N P A R S I N G #################################################################### ### Parse command line arguments. Return a struct of global options. ### def parse_args( args ) options = OpenStruct.new options.account = Etc.getpwuid( Process.uid ).name options.batch = false options.chpw = false options.debug = 0 options.host = 'www.' + StaticCling::Client::DEFAULT_HOST options.ip = nil options.password = nil options.port = 80 options.redisplay = false options.secure = false options.quiet = false $color = $stdin.tty? && $stdout.tty? uuid = 'ed1c373e-7ecc-4a67-a4a0-d6e7700f0b77' opts = OptionParser.new do |opts| opts.banner = "Usage: #{$0} [options] [action [id]] [flags]" opts.separator '' opts.separator 'Actions: show, add, update, delete' opts.separator 'Flags: active=, wildcard=, subdomain=[string|-], type=[A|NS]' opts.separator '' opts.separator 'Examples:' opts.separator <<-HELP Display records and their unique IDs: #{$0} show Add a new record to the account DNS namespace, auto-detect IP: #{$0} add Add a new record under a 'test' subdomain namespace: #{$0} add subdomain=test Update the default record, specify an IP: #{$0} -i 1.2.3.4 update Update the default record with the IP StaticCling "sees" you from: #{$0} -i remote update Update a specific record, auto-detect IP, while making it a wildcard: #{$0} update #{uuid} wildcard=true Disable a record without deleting it: #{$0} update #{uuid} active=false Remove a record entirely: #{$0} delete #{uuid} HELP opts.separator '' opts.separator 'Connection options:' opts.on( '-h', '--host=HOSTNAME', "Staticcling server (default: \"#{options.host}\")" ) do |host| options.host = host end opts.on( '-p', '--port=PORT', "Server port (default: \"#{options.port}\")", Integer ) do |port| options.port = port end opts.on( '-a', '--account=NAME', "Account name (default: \"#{options.account}\")" ) do |account| options.account = account end opts.on( '-P', '--password=PASS', 'Account password (default: prompt)' ) do |pw| options.password = pw end opts.on( '-i', '--ipaddress=IP', 'Use the specified IP address, or "remote" (default: local autodetect)' ) do |ip| options.ip = ip end opts.separator '' opts.separator 'Account options:' opts.on( '--change-password', 'Update the account password' ) do |chpw| options.chpw = true end opts.separator '' opts.separator 'Other options:' opts.on_tail( '-r', '--redisplay', 'Re-fetch and display records after modifications' ) do options.redisplay = true end opts.on_tail( '-s', '--secure', 'Connect using SSL' ) do options.secure = true options.port = 443 if options.port == 80 end opts.on_tail( '-b', '--batch', 'Accept actions on stdin, exiting on EOF' ) do options.batch = true end opts.on_tail( '-q', '--quiet', 'Suppress output on success.' ) do options.quiet = true end opts.on_tail( '--debug=LEVEL', Integer, 'Show debug output to stderr (1-3)' ) do |debug| abort "Valid debug levels: 1-3" unless ( 1..3 ).include?( debug ) options.debug = debug end opts.on_tail( '--no-color', 'Disable color output' ) do $color = false end opts.on_tail( '--help', 'Show this help, then exit' ) do $stderr.puts opts exit end opts.on_tail( '--version', 'Show client version' ) do $stderr.puts StaticCling::Client.version_string exit end end begin opts.parse!( args ) rescue OptionParser::MissingArgument => err $stderr.puts "%s (Try --help)" % [ err.message ] abort end unless options.password print 'Password for %s: ' % [ options.account ] begin system 'stty -echo' options.password = $stdin.gets.chomp ensure system 'stty echo' puts end end return options end ################################################################### ### C L I D I S P L A Y M E T H O D S ################################################################### ### Terminal color output. ### def cstr( color, message ) return message unless $color color = Logger::Colors.const_get( color.to_s.upcase ) rescue Logger::Colors::NOTHING return "\e[#{ color }m#{ message }\e[0;0m" end ### CLI interaction for changing an account password. ### def change_password( client ) new_pw = begin print 'New password: ' system 'stty -echo' npw1 = gets.chomp print "\nNew password (again): " system 'stty -echo' npw2 = gets.chomp raise "New passwords didn't match." unless npw1 == npw2 npw1 rescue => err client.bomb( StaticCling::ClientError, err ) ensure system 'stty echo' puts end if client.change_pw( new_pw ) puts "Password for account '%s' was updated successfully." % [ client.opts.account ] else puts "Password for account '%s' was NOT updated: %s" % [ client.opts.account, res.join(', ') ] end exit end ### Output +records+ nicely to the console. ### def print_records( records ) records.each_pair do |hostname, records| puts " %s %s" % [ cstr( :yellow, hostname ), records.length > 1 ? cstr( :light_purple, '(randomized)' ) : '' ] records.each do |record| state_str = cstr( :dark_gray, '(inactive)' ) puts " %16s %#{state_str.length}s --> %s" % [ record[ :ip ], record[ :active ] ? cstr( :green, '(active)' ) : state_str, cstr( :light_gray, record[ :id ] ) ] end puts end end ### Convert an array of "key=value" attributes into a hash. ### def parse_action_attributes( pairs ) attrs = {} pairs.each do |attr_pair| key, value = attr_pair.split( '=' ) attrs[ key.to_sym ] = value end return attrs end ### The main "do stuff" method, suitable for single runs or within a loop. ### Requires an authenticated client +c+, the +action+ string, and an optional ### array of key=val strings. ### def action_loop( c, action, attrs=[] ) case action # Display current records and account info. # when 'show' account = c.account_info puts "%s (%s%s <%s>)" % [ cstr( :yellow, account[:name] ), account[ :givenname ], account[ :surname ] ? ' ' + account[ :surname ] : '', account[ :email ] ] puts "Member for %s. %s account." % [ account[ :date_signup_approx ], account[ :verified ] ? cstr( :cyan, 'Validated' ) : cstr( :purple, 'Unvalidated' ) ] puts "Last updated %s ago." % [ account[:date_modified_approx] ] puts "Currently %s." % [ account[ :active ] ? cstr( :green, 'active' ) : cstr( :dark_gray, 'inactive' ) ] puts a_records, ns_records = c.get_records( true ) total = a_records.length + ns_records.length puts "%d record%s found." % [ total, total == 1 ? '' : 's' ] puts "\nA records", '-' * 30 unless ns_records.length.zero? print_records( a_records ) puts "NS records", '-' * 30 unless ns_records.length.zero? print_records( ns_records ) # Add a new record # when 'add' record_attrs = parse_action_attributes( attrs ) record_attrs[ :ip ] = c.get_ip if c.add_record( record_attrs ) puts "Record added." unless c.opts.quiet end # Update a record. # when 'update' records = c.get_records if records.length.zero? c.bomb( StaticCling::ClientError, "You have no records to update. (Try 'add'?)" ) end # assume the record ID if we only have one. id = records.length == 1 ? records.first[ :id ] : ARGV.shift unless id c.bomb( StaticCling::ClientError, "Missing record ID. (Use 'show' to display your records.)" ) end attrs = parse_action_attributes( attrs ) attrs[ :ip ] = c.get_ip if c.update_record( id, attrs ) puts "Record updated." unless c.opts.quiet end # Remove a record. # when 'delete' id = ARGV.shift or c.bomb( StaticCling::ClientError, "A record ID is required." ) if c.delete_record( id ) puts "Record deleted." unless c.opts.quiet end end end ### Runtime -- parse opts, instantiate a new Client object, and get busy. ### def main opts = parse_args( ARGV ) StaticCling::Client.run( opts ) do |c| c.open_session at_exit do c.close_session if c && c.session end change_password( c ) if opts.chpw if opts.batch while input = gets do input = input.split action = input.shift action_loop( c, action, input ) end else action = ARGV.shift || 'show' unless %w[ add update delete show ].include?( action ) abort "Action must be one of: 'show', 'add', 'update', or 'delete'" end action_loop( c, action, ARGV ) action_loop( c, 'show' ) if opts.redisplay and action != 'show' end end end main() end