#!/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 <mahlon@martini.nu>
#
# == License
#
# Copyright (c) 2013, Mahlon E. Smith <mahlon@martini.nu>
#
# 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 <mail@janlelis.de>
###
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=<true|false>, wildcard=<true|false>, 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