#!/usr/bin/jruby

require 'getoptlong'
require 'java'
begin; require 'json'; rescue LoadError; $noJSON = true; end
require 'ffi'

require '/usr/share/java/netdb_rmi.jar'
require 'date'

if RUBY_VERSION >= '1.9'
  long_date = ' ' * 128 + '2021-10-11'
  limit_supported = begin
    Date.parse(long_date)
  rescue ArgumentError
    (Date.parse(long_date, true, Date::ITALY, :limit=>nil) == Date.new(2021, 10, 11)) rescue false
  else
    false
  end

  # Modify parsing methods to handle american date format correctly.
  Date.instance_eval do
    # American date format detected by the library.
    AMERICAN_DATE_RE = eval('%r_(?<!\d)(\d{1,2})/(\d{1,2})/(\d{4}|\d{2})(?!\d)_').freeze
    # Negative lookbehinds, which are not supported in Ruby 1.8
    # so by using eval, we prevent an error when this file is first parsed
    # since the regexp itself will only be parsed at runtime if the RUBY_VERSION condition is met.

    # Alias for stdlib Date._parse
    alias _parse_without_american_date _parse

    if limit_supported
      instance_eval(<<-END, __FILE__, __LINE__+1)
        def _parse(string, comp=true, limit: 128)
          _parse_without_american_date(convert_american_to_iso(string), comp, limit: limit)
        end
      END
    else
      # Transform american dates into ISO dates before parsing.
      def _parse(string, comp=true)
        _parse_without_american_date(convert_american_to_iso(string), comp)
      end
    end

    if RUBY_VERSION >= '1.9.3'
      # Alias for stdlib Date.parse
      alias parse_without_american_date parse

      if limit_supported
        instance_eval(<<-END, __FILE__, __LINE__+1)
          def parse(string, comp=true, start=Date::ITALY, limit: 128)
            parse_without_american_date(convert_american_to_iso(string), comp, start, limit: limit)
          end
        END
      else
        # Transform american dates into ISO dates before parsing.
        def parse(string, comp=true, start=Date::ITALY)
          parse_without_american_date(convert_american_to_iso(string), comp, start)
        end
      end
    end

    private

    # Transform american date fromat into ISO format.
    def convert_american_to_iso(string)
      unless string.is_a?(String)
        if string.respond_to?(:to_str)
          str = string.to_str
          unless str.is_a?(String)
            raise TypeError, "no implicit conversion of #{string.inspect} into String"
          end
          string = str
        else
          raise TypeError, "no implicit conversion of #{string.inspect} into String"
        end
      end
      string.sub(AMERICAN_DATE_RE){|m| "#$3-#$1-#$2"}
    end
  end

  if RUBY_VERSION >= '1.9.3'
    # Modify parsing methods to handle american date format correctly.
    DateTime.instance_eval do
      # Alias for stdlib Date.parse
      alias parse_without_american_date parse

      if limit_supported
        instance_eval(<<-END, __FILE__, __LINE__+1)
          def parse(string, comp=true, start=Date::ITALY, limit: 128)
            parse_without_american_date(convert_american_to_iso(string), comp, start, limit: limit)
          end
        END
      else
        # Transform american dates into ISO dates before parsing.
        def parse(string, comp=true, start=Date::ITALY)
          parse_without_american_date(convert_american_to_iso(string), comp, start)
        end
      end
    end
  end
end


java_import 'java.lang.System'

java_import 'stanford.netdb.NetDB'
java_import 'stanford.netdb.NetDB_Datastore'

java_import 'stanford.netdb.A_Name'
java_import 'stanford.netdb.Address_Space'
java_import 'stanford.netdb.Admin_Team'
java_import 'stanford.netdb.Alias'
java_import 'stanford.netdb.Consultant'
java_import 'stanford.netdb.Custom_Field'
java_import 'stanford.netdb.DHCP_Option'
java_import 'stanford.netdb.DHCP_Service'
java_import 'stanford.netdb.DHCP_Setting'
java_import 'stanford.netdb.DS_Record'
java_import 'stanford.netdb.Defaults'
java_import 'stanford.netdb.Department'
java_import 'stanford.netdb.Directory_Person'
java_import 'stanford.netdb.Domain'
java_import 'stanford.netdb.Domain_Name'
java_import 'stanford.netdb.Group'
java_import 'stanford.netdb.IP_Address'
java_import 'stanford.netdb.IP_Pool'
java_import 'stanford.netdb.Interface'
java_import 'stanford.netdb.Interface_IP_Address'
java_import 'stanford.netdb.Interface_Type'
java_import 'stanford.netdb.Location'
java_import 'stanford.netdb.Log'
java_import 'stanford.netdb.Log_Entry'
java_import 'stanford.netdb.Log_Search_Parameters'
java_import 'stanford.netdb.MX'
java_import 'stanford.netdb.Make'
java_import 'stanford.netdb.Model'
java_import 'stanford.netdb.Model_Type'
java_import 'stanford.netdb.Name'
java_import 'stanford.netdb.Network'
java_import 'stanford.netdb.Node'
java_import 'stanford.netdb.Node_Type'
java_import 'stanford.netdb.OS'
java_import 'stanford.netdb.Person'
java_import 'stanford.netdb.Privilege'
java_import 'stanford.netdb.State'
java_import 'stanford.netdb.TXT_Name'
java_import 'stanford.netdb.TXT_Record'
java_import 'stanford.netdb.TXT_Value'
java_import 'stanford.netdb.User'
java_import 'stanford.netdb.VLAN_Area'
java_import 'stanford.netdb.IP.IPaddress'
java_import 'stanford.netdb.IP.Prefix'

$debug = false

# default domain until we get the NetDB user's default domain
$default_domain = 'stanford.edu'

# netdb service
$rmi_service = 'netdb'

# development netdb service
DEV_RMI_SERVICE = "netdb-dev.#{$default_domain}"

LOCK = true

NETID = Directory_Person::SEARCH_TYPE::NETID
REGID = Directory_Person::SEARCH_TYPE::REGID

ALLOW_ALIAS = A_Name::ALLOW::ALIAS
ALLOW_MX    = A_Name::ALLOW::MX

PREF_ALL     = Interface_IP_Address::PTR_PREF::ALL
PREF_CLOSEST = Interface_IP_Address::PTR_PREF::CLOSEST

NODE    = Log_Entry::RECORD_TYPE::NODE
NETWORK = Log_Entry::RECORD_TYPE::NETWORK
USER    = Log_Entry::RECORD_TYPE::USER

INSERT = Log_Entry::ACTION::INSERT
DELETE = Log_Entry::ACTION::DELETE
UPDATE = Log_Entry::ACTION::UPDATE

PAGER = ENV['PAGER'] || 'more'
USAGE = <<'_EOU'

Usage:

   netdb node admin --add admin,... --remove admin,... INPUT
   netdb node address_space [ --set prefix | --clear ] node
   netdb node alias --add alias,... --remove alias,... name
   netdb node comment [ --set comment | --clear ] INPUT
   netdb node custom --add name[=value],... --remove name[=value],... INPUT
   netdb node delete [ --keep_mx ] [--force ] INPUT
   netdb node department --set department INPUT
   netdb node expiration [ --set date | --clear ] INPUT
   netdb node group --add group,... --remove group,... INPUT
   netdb node info [ --json ] INPUT
   netdb node ip_address --remove old_ip [ --add new_ip[+] ] node
   netdb node ip_address --set [in]active address ...
   netdb node ipc_address [ --remove ip,... ]
                          [ --add ip[+][/count],... ] node
   netdb node location --set [building]:room INPUT
   netdb node log [ --name name ] [ --ip ip ] [ --id record id ]
                  [ --after date ] [ --before date ]
                  [ --state state ] [ --user netid ]
   netdb node model --set make:model INPUT
   netdb node name [ --add new_name ]
                   [ --remove old_name | --ip IP address |
                     --interface (hardware address | IP address) ] node
   netdb node os --add os,... --remove os,... INPUT
   netdb node ptr_pref --set (closest | all) address ...
   netdb node receive_mail_for --add mailname[:preference],...
                               --remove mailname,... name
   netdb node search INPUT
   netdb node state --set state INPUT
   netdb node type --add type,... --remove type,...
   netdb node user --add user,... --remove user,... INPUT

     where INPUT is either a list of one or more nodes
     separated by spaces or the '--input file' option.

   netdb node clone --template node --name name
         [ --location building:room | :room ]
         [ --hardware|hw hardware address [ --dhcp ] [ --roam ] ]
         [ --ip ip address[+] | none ] [ --address_space prefix ]
         [ --model make:model ] [ --os os,... ]
         [ --user user,... ] [ --admin admin,... ]
         [ --custom all | names | none ]
         [ --comment comment ] [ --type type,... ]

   netdb node interface --add hardware address
         [ --dhcp[=(on|off)] ] [ --options option=value,... ] [ --roam ]
         [ --ip ip address[+] [ --ptr_pref (closest | all) ] ]
         [ --comment comment ] [ --name name ] node

   netdb node interface --add none --ip ip address[+]
         [ --ptr_pref (closest | all) ]
         [ --comment comment ] [ --name name ] node

   netdb node interface --modify (hardware address | IP address)
         [ --hardware|hw hardware address | none ]
         [ --dhcp[=(on|off)] ] [ --options option=value,... ]
         [ --roam[=(on|off)] ]
         [ --ip ip address[+] [ --ptr_pref (closest | all) ] ]
         [ --comment comment ] [ --name name ] node

   netdb node interface
         --move (hardware address | IP address)[=new_hardware_address],...
         --destination dest_node node

   netdb node interface --remove (hardware address | IP address),... node

   netdb user active_flag [ --set | --clear ] INPUT
   netdb user all_groups_flag [ --set | --clear ] INPUT
   netdb user all_records_flag [ --set | --clear ] INPUT
   netdb user comment [ --set comment | --clear ] INPUT
   netdb user default_domain --set domain INPUT
   netdb user default_group --set group INPUT
   netdb user delete INPUT
   netdb user department --add department;... --remove department;... INPUT
   netdb user group --add group,... --remove group,... INPUT
   netdb user info [ --json ] INPUT
   netdb user oauthid [ --set id | --clear ] INPUT
   netdb user record --add record,... --remove record,... INPUT
   netdb user search INPUT
   netdb user starting_address [ --set address | --clear ] INPUT

   netdb user clone --template netid
         [ --comment comment ] [ --oauthid id ] INPUT

   netdb user create --domain domain --def[ault]_group group
         [ --department department;... ]
         [ --group group,... ]
         [ --[in]active ]
         [ --all_groups ]
         [ --all_records ]
         [ --record record,... ]
         [ --starting_address address ]
         [ --comment comment ]
         [ --oauthid id ]
         INPUT

     where INPUT is either a list of one or more netids
     separated by spaces or the '--input file' option.

   netdb list [ --json ] [ departments | locations | models
                                       | oses | vlan_areas ] [ <regexp> ]
   netdb list [ --json ] [ node_types | states ] [ all ]
   netdb list [ --json ] groups [ all | <regexp> | all <regexp> ]
   netdb list [ --json ] dhcp_options [ interface | address_space
                                        | network | dhcp_service ]

   netdb [ domain | network | txt_record ] help

   netdb --batch [ command_file | - ]

   netdb --keytab keytab --principal principal (node | user | list ) ...
   netdb --keytab keytab --principal principal --batch command_file

   --quiet         don't print informational warning messages

   --help          print a detailed description of how to use netdb
   --usage         display this message and exit
   --version       print netdb version information

_EOU

module Spoon
  extend FFI::Library
  ffi_lib 'c'

  # int
  # posix_spawn(pid_t *restrict pid, const char *restrict path,
  #     const posix_spawn_file_actions_t *file_actions,
  #     const posix_spawnattr_t *restrict attrp, char *const argv[restrict],
  #     char *const envp[restrict]);

  attach_function :_posix_spawn, :posix_spawn, [:pointer, :string, :pointer, :pointer, :pointer, :pointer], :int
  attach_function :_posix_spawnp, :posix_spawnp, [:pointer, :string, :pointer, :pointer, :pointer, :pointer], :int

  def self.spawn(*args)
    spawn_args = _prepare_spawn_args(args)
    _posix_spawn(*spawn_args)
    spawn_args[0].read_int
  end

  def self.spawnp(*args)
    spawn_args = _prepare_spawn_args(args)
    _posix_spawnp(*spawn_args)
    spawn_args[0].read_int
  end

  private

  def self._prepare_spawn_args(args)
    pid_ptr = FFI::MemoryPointer.new(:pid_t, 1)

    args_ary = FFI::MemoryPointer.new(:pointer, args.length + 1)
    str_ptrs = args.map {|str| FFI::MemoryPointer.from_string(str)}
    args_ary.put_array_of_pointer(0, str_ptrs)

    env_ary = FFI::MemoryPointer.new(:pointer, ENV.length + 1)
    env_ptrs = ENV.map {|key,value| FFI::MemoryPointer.from_string("#{key}=#{value}")}
    env_ary.put_array_of_pointer(0, env_ptrs)

    [pid_ptr, args[0], nil, nil, args_ary, env_ary]
  end
end

class String

  # unique string matching
  def match_abbrev(*args)
    matches = args.select { |a| a.index(self) == 0 }
    return matches.length == 1 ? matches.shift : nil
  end

  # find unique keyword or abort
  def match_keyword(*args)
    matches = args.select { |a| a.index(self) == 0 }
    return matches.shift if matches.length == 1
    abort "ERROR: unknown keyword `#{self}'" if matches.length == 0
    abort "ERROR: keyword `#{self}' is ambiguous between #{matches.join(', ')}"
  end

  # ensure fully qualified names (might be invoked on IP or HW address strings)
  def fully_qualify(domain=$default_domain)
    # return the string unmolested if it's a fully quallified name, IP address, or HW address
    return self if self =~ /[.:]/ or self =~ /^([0-9a-f]{12}|[0-9a-f]{2}(-[0-9a-f]{2}){5})$/i
    return self + '.' + domain
  end

  # split on non-escaped separator followed by any amount of whitespace,
  #   then process escapes and strip surrounding whitespace
  def split_with_escape(sep,limit=0)
    return self.split(/(?<!\\)#{sep}\s*/,limit).map { |s| s.gsub(/\\#{sep}/,"#{sep}").strip }
  end

  def integer?
    Integer(self) != nil rescue false
  end

  # return a fully-formatted error message composed of the initial
  # string (self), e.g., "ERROR: ", followed by the sanitized error
  # message hanging and wrapped to 'width' (works as a general wrap
  # and fill when the initial string is empty or just spaces ;-)
  def format_message(message, width=84)
    width -= self.length
    sep = "\n" + " " * self.length
    self + message.sub(/^[^:]+xception[^:]*: /,'')
                  .gsub(/\n/,' ').gsub(/ +/,' ')
                  .scan(/\S.{0,#{width}}\S(?=\s|$)|\S+/)
                  .join(sep)
  end

end

class IPaddress

  def dyname
    radix = self.family == 4 ? 16 : 32
    domain = self.is_private ? $private_domain : $default_domain
    return "DN" + self.toString(RADIX,radix).fully_qualify(domain)
  end

end

class Node

  # alias for RMI Node.unlock
  alias __unlock unlock

  # unlock record ignoring exceptions
  def unlock
    begin
      self.__unlock if self.is_locked
    rescue Exception => e
    end
  end

end

class User

  # alias for RMI User.unlock
  alias __unlock unlock

  # unlock record ignoring exceptions
  def unlock
    begin
      self.__unlock if self.is_locked
    rescue Exception => e
    end
  end

end

class Domain

  # alias for RMI Domain.unlock
  alias __unlock unlock

  # unlock record ignoring exceptions
  def unlock
    begin
      self.__unlock if self.is_locked
    rescue Exception => e
    end
  end

end

class TXT_Record

  # alias for RMI TXT_Record.unlock
  alias __unlock unlock

  # unlock record ignoring exceptions
  def unlock
    begin
      self.__unlock if self.is_locked
    rescue Exception => e
    end
  end

end

class Network

  # alias for RMI Network.unlock
  alias __unlock unlock

  # unlock record ignoring exceptions
  def unlock
    begin
      self.__unlock if self.is_locked
    rescue Exception => e
    end
  end

end

# Per entry logging -
#
# . Logging starts with ``<record_type|message> "<record_name>" ...''
# . Errors/warnings/info are on separate lines and indented 2 spaces per level
# . Sucessful commits end in ``.. done''
# . Sucessful no-ops end in  ``.. done (no changes)''
#
# Examples -
#
#   % netdb node custom --rem foo,bar,baz foo bar baz qux
#   Node "foo.Stanford.EDU" ..... done
#   Node "bar.Stanford.EDU" ...
#     INFO: custom field 'foo' not present
#     INFO: custom field 'bar' not present
#     INFO: custom field 'baz' not present
#   .. done (no changes)
#   Node "baz.Stanford.EDU" ...
#     WARNING: The Node "baz.Stanford.EDU" does not exist.
#   Node "qux.Stanford.EDU" ...
#     INFO: custom field 'bar' not present
#     INFO: custom field 'baz' not present
#   .. done
#   %
#
# JSON Support for <record>_info methods -
#
#   The constructor prints the JSON key for the record and handles characters
#   that come right before a JSON structure: "{" before the first one and ","
#   before the rest. Several methods are no-op if JSON is specified to avoid
#   a lot of "unless $options(:json)" in the info methods. The class method
#   "json_finalize" prints the closing bracket, "}".

class Entry_Log

  def initialize(prefix, name, json_name=name)
    @prefix = prefix
    @name = name
    if $options.has_key?(:json)
      self.print $needs_comma ? ",\n" : "{\n"
      self.print %Q[  "#{json_name}": ]
      $needs_comma = true
    else
      self.print @prefix + ' "' + @name + '" ...'
      @needs_cr = true
    end
    return self
  end

  def print_cr_if_needed
    if @needs_cr
      $stdout.puts ""
      @needs_cr = false
    end
  end

  def print(message)
    print_cr_if_needed
    $stdout.print message
  end

  def puts(message)
    print_cr_if_needed
    $stdout.puts message
  end

  def info(message)
    return if $options[:quiet]
    print_cr_if_needed
    $stderr.puts message
  end

  def warn(message)
    return if $options.has_key?(:json)
    print_cr_if_needed
    $stderr.puts message
  end

  def finalize(message)
    return if $options.has_key?(:json)
    $stdout.puts message unless message.empty?
  end

  def json(key, value)
    return unless $options.has_key?(:json)
    $stdout.print '{ "%s": "%s" }' % [key, value.gsub(/"/,'\"')]
  end

  def self.json_finalize
    puts "\n}" if $options.has_key?(:json)
  end

  # ask a yes/no question with a default answer
  def ask(question, default="n")
    yesno = default =~ /^n/i ? "y/N" : "Y/n"
    self.print "#{question}? (#{yesno}) "
    return $stdin.gets =~ /^y/i
  end

end

# non-log info(), i.e. warn() that respects --quiet
def info(message)
  return if $options[:quiet]
  warn message
end

def usage(status=0)
  if $stdout.tty?
    if RUBY_PLATFORM == "java"
      require 'tempfile'
      file = Tempfile.new('netdb-cli-')
      file.write(USAGE)
      file.close
      pid = Spoon.spawnp(PAGER, file.path)
      Process.waitpid(pid)
      file.unlink
    else
      $stdout = IO.popen(PAGER,"w")
      print USAGE
      $stdout.close
    end
  else
    print USAGE
  end
  exit status
end

def help
  if RUBY_PLATFORM == "java"
    pid = Spoon.spawnp("perldoc",__FILE__)
    Process.waitpid(pid)
    exit
  else
    exec "perldoc #{__FILE__}"
  end
end

def version
  if $debug
    # java version ala java.lang.VersionProps.print()
    launcher_name   = System.get_property('java.vm.name').sub(/ .*/,'').downcase
    lts             = System.get_property('java.runtime.version').include?('LTS') ? " LTS" : ""
    vendor_version  = System.get_property('java.vendor.version').nil? ? " " : " #{System.get_property('java.vendor.version')} "
    runtime_version = System.get_property('java.runtime.version')
    puts "#{launcher_name} version \"#{System.get_property('java.version')}\" #{System.get_property('java.version.date')}#{lts}"
    puts "#{System.get_property('java.runtime.name')}#{vendor_version}(build #{runtime_version})"
    puts "#{System.get_property('java.vm.name')}#{vendor_version}(build #{runtime_version}, #{System.get_property('java.vm.info')})"
  end
  puts "NetDB CLI version 4.2.0 running on JRuby #{JRUBY_VERSION}, RMI version #{NetDB.version()}"
  exit
end


java_import 'stanford.netdb.Node_SS_Result'
java_import 'stanford.netdb.ssparser.SSparser'

class String

  # return string classification (hw, name, ip)
  def hw_name_or_ip(ignore_errors=true)
    begin
      result = JRUBY_VERSION > '1.6' ? SSparser.parse(self) : Hash[*SSparser.parse(self).to_a.flatten]
      return [result.has_key?("hw"), result.has_key?("name"), result.has_key?("ip_low")]
    rescue Exception => e
      return [false, false, false] if ignore_errors
      raise e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  # return the node handle for a name, IP address, or HW address
  def node_handle(ds=NetDB.default_datastore)
    (hw,name,ip) = self.hw_name_or_ip
    return self unless hw or ip
    matches = Node.search(ds, self)
    raise "No node with address '#{self}'" unless matches.length == 1
    return matches[0].node_name
  end

end

class << Location

  # alias for RMI Location.load
  alias _load load

  # load location by location name, location name plus (site-code), or just site-code
  def load(string)
    begin
      return Location._load(string)

    rescue Exception => e
      begin
        case
        when qc = string.rindex('(')
          # the string may include "(site-code)" - if so, strip that and try again
          return Location._load(string[0,qc].rstrip)

        when string.match(/^([^-]+)-([^-]+)$/)
          # the string may be just "site-code" - if so, look for that
          (site,code) = string.split(/-/,2)
          locations = Location.list.select { |location| location.site.eql?(site) and location.code.eql?(code) }
          return Location._load(locations[0].handle) unless locations.empty?
        end

      rescue
        # ignore exceptions here and raise the outer exception with the full location string
      end
      raise
    end
  end
end

class Node_SS_Result

  # sort key - name or normalized IP address
  def key(name)
    if name
      if self.name_type == Node_SS_Result::NAME_TYPE::MX
        # sort by preference and MX name
        return self.name.downcase + "," +
          "%-5d %s" % [self.preference, self.received_by_fullname.downcase]
      else
        return self.name.downcase
      end
    end
    return IPaddress.new(self.IP.sub(/\/.*$/,'')).toString(IPaddress::NORMALIZED)
  end

  # node match output formats
  def format(name)
    if !!name != name
      # hw address
      return "  %-17s  %-39s  %s\n" % [ name, self.display_name, self.IP ]
    elsif name
      # name - Alias, MX, or IP address name
      case self.name_type
      when Node_SS_Result::NAME_TYPE::ALIAS
        return "  %-39s %-s\n" %
          [ self.name, "CNAME " + self.display_name ]
      when Node_SS_Result::NAME_TYPE::MX
        return "  %-39s %-s\n" %
          [ self.name, "MX " + self.preference.to_s + " " + self.received_by_fullname ]
      else
        return "  %-39s %-s\n" % [ self.name, self.IP ]
      end
    else
      # ip address
      return "  %-39s %-s\n" % [ self.IP, self.name ]
    end
  end

end

# connect plus lock/modify/commit loop with logging
def _node_loop

  connect()

  ARGV.each do |node_name| # note: ``node_name'' has morphed to allow HW and IP addresses
    node = nil
    fq_node_name = "#{node_name}".fully_qualify

    begin
      log = Entry_Log.new("Node", fq_node_name)
      node = Node.load(LOCK, fq_node_name.node_handle)

      i_did_something = yield(node, log, fq_node_name)

      node.commit if i_did_something
      log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      node.unlock if node
    end
  end

end

# netdb node admin --add admin, ... --remove admin, ...
def node_admin

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        add.push(team) unless add.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        add.push(person) unless add.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  remove = []
  removals.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        remove.push(team) unless remove.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        remove.push(person) unless remove.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  _node_loop do |node, log, fq_node_name|

    i_did_something = false

    remove.each do |admin|
      if node.admins.include?(admin)
        node.remove_admin(admin)
        i_did_something = true
      else
        log.info "  INFO: admin \"#{admin.name}\" is not an administrator of node \"#{fq_node_name}\""
      end
    end

    add.each do |admin|
      if node.admins.include?(admin)
        log.info "  INFO: admin \"#{admin.name}\" is already an administrator of node \"#{fq_node_name}\""
      else
        node.add_admin(admin)
        i_did_something = true
      end
    end

    i_did_something

  end

end

# netdb node address_space [ --set prefix | --clear ]
def node_address_space

  checkopts(:set, :clear)
  connect()

  begin
    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    if $options.has_key?(:set)
      address_space = Address_Space.new(Prefix.new($options[:set]),0,0)
    else
      address_space = nil
    end

    node.address_space(address_space).commit
    puts ".. done"

  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  ensure
    node.unlock if node
  end

end

# netdb node alias --add alias, ... --remove alias, ... name
def node_alias

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  input_name = ARGV.shift
  name_to_modify = A_Name.new("#{input_name}".fully_qualify)

  begin
    node = Node.load(LOCK, name_to_modify.full_name)
    name = node.all_names(ALLOW_ALIAS).select { |n| n == name_to_modify }.shift or
      raise "#{input_name} cannot have aliases"

    i_did_something = false

    removals.each do |a|
      old_alias = Alias.new("#{a}".fully_qualify)
      if name.aliases.include?(old_alias)
        name.remove_alias(old_alias)
        i_did_something = true
      else
        info "INFO: '#{a}' is not an alias for name '#{input_name}'"
      end
    end

    additions.each do |a|
      new_alias = Alias.new("#{a}".fully_qualify)
      if name.aliases.include?(new_alias)
        info "INFO: '#{a}' is already an alias for name '#{input_name}'"
      else
        name.add_alias(new_alias)
        i_did_something = true
      end
    end

    if i_did_something
      node.commit
      puts ".. done"
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
  end

end

# netdb node comment [ --set comment | --clear ]
def node_comment

  checkopts(:set, :clear)
  comment = $options.has_key?(:set) ? $options[:set] : ""
  _node_loop { |node, log| node.comment(comment) }

end

# netdb node custom --add name[=value], ... --remove name[=value], ...
def node_custom

  checkopts(:add, :remove)

  add = []
  $options.has_key?(:add) and $options[:add].split_with_escape(",").each do |custom|
    begin
      name, value = custom.split_with_escape("=",2)
      add.push(Custom_Field.new(name,value))
    rescue Exception => e
      abort "ERROR: bad custom field expression, \"#{custom}\"" + e.message
    end
  end

  remove = []
  $options.has_key?(:remove) and $options[:remove].split_with_escape(",").each do |custom|
    begin
      name, value = custom.split_with_escape("=",2)
      remove.push(Custom_Field.new(name,value))
    rescue Exception => e
      abort "ERROR: bad custom field expression, \"#{custom}\"" + e.message
    end
  end

  _node_loop do |node, log|

    i_did_something = false
    custom_fields = node.custom_fields

    remove.each do |custom_field|
      if custom_field.value == nil and
          match = custom_fields.select { |cf| cf.name == custom_field.name }.first
        node.remove_custom_field(match)
        i_did_something = true
      elsif custom_fields.any? { |cf| cf == custom_field }
        node.remove_custom_field(custom_field)
        i_did_something = true
      else
        data  = custom_field.name
        data += "=" + custom_field.value if custom_field.value
        log.info "  INFO: custom field '" + data + "' not present"
      end
    end

    add.each do |custom_field|
      # adding an existing field replaces it
      if match = custom_fields.select { |cf| cf.name == custom_field.name }.first
        node.remove_custom_field(match)
      end
      node.add_custom_field(custom_field)
      i_did_something = true
    end

    i_did_something

  end

end

# the number of names or IP addresses that constitues "a lot"
LOTS = 10

# netdb node delete [ --keep_mx ] [ --force ]
def node_delete

  connect()

  ARGV.each do |node_name|
    node = nil
    fq_node_name = "#{node_name}".fully_qualify

    begin
      log = Entry_Log.new("Node", fq_node_name)
      node = Node.load(LOCK, fq_node_name.node_handle)

      # unless --force was specified see if this is an important node
      unless $options[:force]
        question = nil

        # is it a router?
        if node.types.collect{|type| type.name.downcase}.include?('router')
          question = "  #{node_name} is a router, really delete"

        else
          # does it have a lot of names/aliases/mxes?
          all_names = node.all_names
          names = all_names.reduce(all_names.count) do |sum, name|
            sum + name.aliases.count + name.mxes.count
          end
          if names > LOTS
            question = "  #{node_name} has a lot of names, really delete"

          else
            # does it have a lot of IP addresses?
            addresses = node.interfaces.reduce(node.addresses.count) do |sum, interface|
              sum + interface.addresses.count
            end
            if addresses > LOTS
              question = "  #{node_name} has a lot of IP addresses, really delete"
            end
          end
        end

        # if there's a question ask it and skip to the next node if the answer is no
        if question and not log.ask(question)
          node.unlock
          next
        end
      end # --force

      # save names and mail exchanger nodes if we're nuking MXes
      unless $options[:keep_mx]
        node_names = node.all_names()
        mail_exchangers = node.mail_exchangers()
      end

      # do the deed
      node.delete

      unless $options[:keep_mx] or mail_exchangers.empty?
        log.print_cr_if_needed

        # loop over the mail exchangers nuking the MXes on each
        mail_exchangers.each do |mail_exchanger|
          mlog = Entry_Log.new("  Removing MXes from", mail_exchanger.handle)
          server = nil

          # list of MXes for error messages
          mxes = Array.new
          mail_exchanger.all_names(ALLOW_MX).each do |name|
            mxes += name.mxes.select { |mx| node_names.include?(mx) }
          end

          begin
            server = Node.load(true, mail_exchanger.handle)

            i_did_something = false
            server.all_names(ALLOW_MX).each do |name|
              nuke = name.mxes.select { |mx| node_names.include?(mx) }
              next if nuke.empty?
              nuke.each { |mx| name.remove_mx(mx) }
              i_did_something = true
            end

            if i_did_something
              server.commit
              mlog.finalize("  .. done")
            else
              mlog.info("    INFO: didn't find any MXes to delete on #{mail_exchanger.handle}")
              mlog.finalize("  .. done (no changes)")
            end

          rescue Exception => e
            mlog.warn "    WARNING: unable to modify #{mail_exchanger.handle}, MX%s not removed: %s" %
              [ mxes.count == 1 ? "" : "es",  mxes.join(', ') ] +
              "\n             " + e.message.sub(/^[^:]+xception[^:]*: /,'')
            mlog.finalize("")

          ensure
            server.unlock if server
          end

        end # each mail_exchanger

      end # MX removal processing

      log.finalize(".. done")

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      node.unlock if node
    end

  end # each ARGV
end

# netdb node department --set department
def node_department

  checkopts(:set)
  connect()

  begin
    department = Department.load($options[:set])
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _node_loop { |node, log| node.department(department) }

end

# netdb node expiration [ --set date | --clear ]
def node_expiration

  checkopts(:set, :clear)
  date = $options.has_key?(:set) ? Date.parse($options[:set]).strftime("%m/%d/%Y") : ''
  _node_loop { |node, log| node.expiration_date(date) }

end

# netdb node group --add group, ... --remove group, ...
def node_group

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |group_name|
    begin
      group = Group.load(group_name)
      add.push(group) unless add.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  remove = []
  removals.each do |group_name|
    begin
      group = Group.load(group_name)
      remove.push(group) unless remove.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  _node_loop do |node, log, fq_node_name|

    i_did_something = false

    add.each do |group|
      if node.owners.include?(group)
        log.info "  INFO: group \"#{group.name}\" is already assigned to node \"#{fq_node_name}\""
      else
        node.add_owner(group)
        i_did_something = true
      end
    end

    remove.each do |group|
      if node.owners.include?(group)
        node.remove_owner(group)
        i_did_something = true
      else
        log.info "  INFO: group \"#{group.name}\" is not assigned to node \"#{fq_node_name}\""
      end
    end

    i_did_something

  end

end

# netdb node info [ --json[=(compact|pretty) ]
def node_info
  connect()

  ARGV.each do |node_name|
    fq_node_name = "#{node_name}".fully_qualify
    node_id = node_name.match('^id:') ? node_name.sub(/^id:/,'') : nil
    begin
      log = Entry_Log.new("Node", fq_node_name, node_name)
      node = Node.load(node_id.nil? ? fq_node_name.node_handle : node_id.to_i)
      if $options.has_key?(:json)
        print node.to_json($options[:json]).strip.gsub(/^}/,'  }')
      else
        puts "\n\n", node, "\n"
      end
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
      log.json "error", e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  Entry_Log.json_finalize

end

# netdb node ip_address --remove old_ip [ --add new_ip[+] ] node
# netdb node ip_address --set [in]active
def node_ip_address

  checkopts(:remove, :set)
  connect()

  # --set [in]active is handled in its own routine
  return node_ip_address_state if $options.has_key?(:set)

  begin
    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    old_ip = Interface_IP_Address.new($options[:remove])

    if $options[:add]
      exact = true
      starting_ip = $options[:add]
      if starting_ip.end_with?("+")
        exact = false
        starting_ip.chop!
      end
      new_ip = Interface_IP_Address.reserve(starting_ip, 1, exact, false)[0]
      puts "IP address `#{new_ip.address}' reserved" unless exact
    end

    i_did_something = false

    node.interfaces.each do |interface|
      next unless ip = interface.addresses.select { |ip| ip == old_ip }.first
      interface.remove_address(ip)
      if $options[:add]
        new_ip.add_names(ip.names)
        new_ip.active(ip.is_active)
        new_ip.ptr_pref(ip.ptr_pref)
        interface.add_address(new_ip)
      end
      i_did_something = true
      break
    end

    if i_did_something
      node.commit
      puts ".. done"
    else
      raise "IP address '#{$options[:remove]}' is not on node #{node_name}"
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
    new_ip.unreserve if new_ip
  end

end

# netdb node ip_address --set [in]active
def node_ip_address_state

  state = $options[:set] =~ /^a/i ? true : false

  ARGV.each do |address|
    begin
      log = Entry_Log.new("Address", address)

      # find and lock the node
      matches = Node.search(address)
      if matches.length == 1
        node = Node.load(LOCK,matches[0].node_name)
      else
        raise "No node using IP address '#{address}'"
      end

      # find the IP and set the state
      the_ip = Interface_IP_Address.new(address)
      if interface = node.interfaces.select { |i| i.addresses.include?(the_ip) }.first
        ip = interface.addresses.select { |ip| ip == the_ip }.first
        ip.active(state)
        node.commit
        log.finalize(".. done")
      else
        # must be an IPC address
        raise "IP address '#{address}' is not an interface address"
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      node.unlock if node
    end
  end

end

# netdb node ipc_address [ --remove ip,... ] [ --add ip[+][/count], ... ] node
def node_ipc_address

  checkopts(:add, :remove)
  connect()

  begin
    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    if $options.has_key?(:remove)
      ips = node.addresses
      $options[:remove].split_with_escape(",").each do |address|
        ip = IP_Address.new(address)
        raise "IP address '#{address}' is not on node #{node_name}" unless ips.include?(ip)
        node.remove_address(ip)
      end
    end

    if $options.has_key?(:add)
      new_ips = []
      $options[:add].split_with_escape(",").each do |arg|
        exact = true
        (starting_ip, count) = arg.split(/\//)
        count = 1 unless count
        if starting_ip.end_with?("+")
          exact = false
          starting_ip.chop!
        end
        new_ips += IP_Address.reserve(starting_ip, count.to_i, exact, false).to_a
      end
      new_ips.each { |ip| ip.add_name(A_Name.new(ip.address.dyname)) }
      node.add_addresses(new_ips)
    end

    node.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
    new_ips.each { |ip| ip.unreserve } if new_ips
  end

end

# netdb node location --set [building]:room
def node_location

  checkopts(:set)

  abort "ERROR: room must be specified" unless $options[:set].include?(":")
  (building,colon,room) = $options[:set].strip.rpartition(/\s*:\s*/)

  connect()
  begin
    location = Location.load(building.rstrip) unless building.empty?
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _node_loop do |node, log|
    node.location(location) unless building.empty?
    node.room(room)
  end

end

# netdb node log [ --name name ] [ --ip ip ] [ --id record id ]
#                [ --after date ] [ --before date ]
#                [ --state state ] [ --user netid ]
def node_log

  abort "ERROR: Please specify search criteria using at least one option" if
    countopts(:name, :ip, :id, :after, :before, :state, :user) < 1
  connect()

  lsp = Log_Search_Parameters.new()
  lsp.record_type(NODE)
  lsp.actions([INSERT, UPDATE, DELETE], true)

  lsp.record_name($options[:name]) if $options.has_key?(:name)
  lsp.ip_address($options[:ip]) if $options.has_key?(:ip)
  lsp.record_id($options[:id]) if $options.has_key?(:id)

  lsp.after(DateTime.parse($options[:after]).strftime("%Y-%m-%d %T"),true) if $options.has_key?(:after)
  lsp.before(DateTime.parse($options[:before]).strftime("%Y-%m-%d %T"),true) if $options.has_key?(:before)

  lsp.node_state($options[:state]) if $options.has_key?(:state)
  lsp.user_netid($options[:user]) if $options.has_key?(:user)

  lsp.include_date(true)
  lsp.include_IP_address(true)
  lsp.include_node_state(true)
  lsp.include_record_name(true)
  lsp.include_record_id(true)
  lsp.include_user(true)

  puts
  format = "%19s %10s %-32s %-39s %-7s %6s%s"
  puts format % ["Date", "Record ID", "Record Name", "IP Address", "State", "Action", " and User"]
  puts format % [19, 10, 32, 39, 7, 6, 42].map { |n| "=" * n }

  Log.full_search(lsp).to_array.sort{ |a,b| a.date <=> b.date }.each do |entry|
    lines = [entry.IP_address.length, entry.record_name.length].max
    for i in 0..lines-1
      date   = i > 0 ? "" : entry.date.to_s[0,19]
      id     = i > 0 ? "" : entry.record_id.to_s
      state  = i > 0 ? "" : entry.node_state.name
      action = i > 0 ? "" : entry.action
      user   = i > 0 ? "" : " by #{entry.user_name} (#{entry.user_netid})"
      puts format % [date, id, entry.record_name[i], entry.IP_address[i], state, action, user]
    end
  end
  puts

end

# netdb node model --set make:model
def node_model

  checkopts(:set)

  (make,colon,model_name) = $options[:set].strip.rpartition(/\s*:\s*/)
  abort "ERROR: make and model must be specified" if make.empty? or model_name.empty?

  connect()

  begin
    model = Model.load(make.rstrip,model_name)
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _node_loop { |node, log| node.model(model) }

end

# netdb node name [ --add new_name ]
#                 [ --remove old_name | --ip IP address |
#                   --interface (hardware | IP) address ] node
def node_name

  checkopts(:add, :remove)
  abort "ERROR: options --remove, --ip, and --interface are mutually exclusive" if
    countopts(:remove, :ip, :interface) > 1
  connect()

  begin
    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    new_name = A_Name.new("#{$options[:add]}".fully_qualify)    if $options[:add]
    old_name = A_Name.new("#{$options[:remove]}".fully_qualify) if $options[:remove]

    if $options[:remove]
      # remove or replace a name, wherever it occurs (node, interface, or ip)
      thing = nil

      if name = node.names.select { |name| name == old_name }.first
        thing = node

      elsif thing = node.interfaces.select { |i| i.names.include?(old_name) }.first
        name = thing.names.select { |name| name == old_name }.first

      else
        ips = node.interfaces.collect { |i| i.addresses.to_a } + node.addresses.to_a
        if thing = ips.flatten.select { |ip| ip.names.include?(old_name) }.first
          name = thing.names.select { |name| name == old_name }.first
        end
      end
      raise "'#{$options[:remove]}' is not a name on node #{node_name}" unless thing

      thing.remove_name(name)

      if $options[:add]
        # preserve the old name aliases and MXes
        new_name.add_aliases(name.aliases)
        new_name.add_MXes(name.MXes)
        thing.add_name(new_name)
      end

    elsif $options[:ip]
      # add a name to an IP address
      the_ip = IP_Address.new($options[:ip])

      ips = node.interfaces.collect { |i| i.addresses.to_a } + node.addresses.to_a
      if ip = ips.flatten.select { |ip| ip == the_ip }.first
        ip.add_name(new_name)
      else
        raise "IP address '#{$options[:ip]}' is not on node #{node_name}"
      end

    elsif $options[:interface]
      # add a name to an interface
      (hw,ip) = hw_or_ip($options[:interface])

      if interface = node.interfaces.select { |i|
          ((hw and hw.downcase == i.hardware_address) or (ip and i.addresses.include?(ip)))
        }.first
        interface.add_name(new_name)
      else
        raise "No interface with address '#{$options[:interface]}' on node #{node_name}"
      end

    else
      # just add a node name
      node.add_name(new_name)
    end

    node.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
  end

end

# netdb node os --add os, ... --remove os, ...
def node_os

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |os_name|
    begin
      os = OS.load(os_name)
      add.push(os) unless add.include?(os)
    rescue
      abort "ERROR: no such os, \"#{os_name}\""
    end
  end

  remove = []
  removals.each do |os_name|
    begin
      os = OS.load(os_name)
      remove.push(os) unless remove.include?(os)
    rescue
      abort "ERROR: no such os, \"#{os_name}\""
    end
  end

  _node_loop do |node, log, fq_node_name|

    i_did_something = false

    add.each do |os|
      if node.OSes.include?(os)
        log.info "  INFO: os \"#{os.name}\" is already on node \"#{fq_node_name}\""
      else
        node.add_OS(os)
        i_did_something = true
      end
    end

    remove.each do |os|
      if node.OSes.include?(os)
        node.remove_OS(os)
        i_did_something = true
      else
        log.info "  INFO: os \"#{os.name}\" is not on node \"#{fq_node_name}\""
      end
    end

    i_did_something

  end

end

# netdb node ptr_pref --set ( closest | all ) address
def node_ptr_pref
  checkopts(:set)
  connect()

  pref = $options[:set] =~ /^a/i ? PREF_ALL : PREF_CLOSEST

  ARGV.each do |address|
    begin
      log = Entry_Log.new("Address", address)

      # find and lock the node
      matches = Node.search(address)
      if matches.length == 1
        node = Node.load(LOCK,matches[0].node_name)
      else
        raise "No node using IP address '#{address}'"
      end

      # find the IP and set the PTR preference
      the_ip = Interface_IP_Address.new(address)
      if interface = node.interfaces.select { |i| i.addresses.include?(the_ip) }.first
        ip = interface.addresses.select { |ip| ip == the_ip }.first
        ip.ptr_pref(pref)
        node.commit
        log.finalize(".. done")
      else
        raise "IP address '#{address}' is not an interface address"
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      node.unlock if node
    end
  end

end

# netdb node receive_mail_for --add mailname[:preference], ... --remove mailname, ... name
def node_receive_mail_for

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  input_name = ARGV.shift
  name_to_modify = A_Name.new("#{input_name}".fully_qualify)

  begin
    node = Node.load(LOCK, name_to_modify.full_name)
    name = node.all_names(ALLOW_MX).select { |n| n == name_to_modify }.shift or
      raise "Name \"#{input_name}\" cannot receive mail for other names" unless name

    i_did_something = false

    removals.each do |mailname|
      old_mx = MX.new("#{mailname}".fully_qualify)
      removed = false
      name.mxes.each do |mx|
        # Compare only full_name in case MX comparison compares name _and_ preference
        next unless mx.full_name.casecmp(old_mx.full_name) == 0
        name.remove_mx(mx)
        i_did_something = true
        removed = true
      end
      info "INFO: '#{input_name}' does not receive mail for '#{mailname}'" unless removed
    end

    additions.each do |m|
      (mailname,pref) = m.split(/:/)
      new_mx = pref ? MX.new("#{mailname}".fully_qualify,pref.to_i) : MX.new("#{mailname}".fully_qualify)
      if name.mxes.include?(new_mx)
        info "INFO: '#{input_name}' already receives mail for '#{mailname}'"
      else
        name.add_mx(new_mx)
        i_did_something = true
      end
    end

    node.commit if i_did_something
    puts ".. done" + (i_did_something ? "" : " (no changes)")

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
  end

end

# netdb node search
def node_search
  connect()

  ARGV.each do |pat|

    log = Entry_Log.new("Results for", pat)

    begin
      # determine search type
      (hw,name,ip) = pat.hw_name_or_ip
      if hw or name or ip
        results = Node.search(pat)
      else
        log.finalize(" invalid search string")
        next
      end

      if results.length == 0
        log.finalize(" no match")

      elsif hw
        # complete HW address
        log.print("\n")
        log.print results[0].format(pat)
        log.finalize("\n")

      else
        # print the results sorted by name or IP address
        i = 0
        results.sort_by { |match| match.key(name) }.each do |node|
          log.print("\n") if i%3 == 0; i += 1;
          log.print node.format(name)
        end
        log.finalize("\n")
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
end

# netdb node state --set state
def node_state

  checkopts(:set)
  connect()

  begin
    state = State.load($options[:set])
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _node_loop { |node, log| node.state(state) }

end

# netdb node type --add type,... --remove type,...
def node_type

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |node_type|
    begin
      type = Node_Type.load(node_type)
      add.push(type) unless add.include?(type)
    rescue
      abort "ERROR: no such node type, \"#{node_type}\""
    end
  end

  remove = []
  removals.each do |node_type|
    begin
      type = Node_Type.load(node_type)
      remove.push(type) unless remove.include?(type)
    rescue
      abort "ERROR: no such node type, \"#{node_type}\""
    end
  end

  _node_loop do |node, log, fq_node_name|

    i_did_something = false

    add.each do |type|
      if node.types.include?(type)
        log.info "  INFO: #{fq_node_name} is already type \"#{type.name}\""
      else
        node.add_type(type)
        i_did_something = true
      end
    end

    remove.each do |type|
      if node.types.include?(type)
        node.remove_type(type)
        i_did_something = true
      else
        log.info "  INFO: #{fq_node_name} is not type \"#{type.name}\""
      end
    end

    i_did_something

  end

end

# netdb node user --add user, ... --remove user, ...
def node_user

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |user|
    person = Directory_Person.lookup(NETID,user)
    if person
      add.push(person) unless add.include?(person)
    else
      abort "ERROR: no directory entry for NetID \"#{user}\""
    end
  end

  remove = []
  removals.each do |user|
    person = Directory_Person.lookup(NETID,user)
    if person
      remove.push(person) unless remove.include?(person)
    else
      abort "ERROR: no directory entry for NetID \"#{user}\""
    end
  end

  _node_loop do |node, log, fq_node_name|

    i_did_something = false

    add.each do |user|
      if node.users.include?(user)
        log.info "  INFO: user \"#{user.name}\" is already a user of node \"#{fq_node_name}\""
      else
        node.add_user(user)
        i_did_something = true
      end
    end

    remove.each do |user|
      if node.users.include?(user)
        node.remove_user(user)
        i_did_something = true
      else
        log.info "  INFO: user \"#{user.name}\" is not a user of node \"#{fq_node_name}\""
      end
    end

    i_did_something

  end

end

# netdb node clone --template node --name name
#       [ --location building:room | :room ]
#       [ --hardware|hw hardware address [ --dhcp [ --roam ] ] ]
#       [ --ip ip address[+] | none ] [ --address_space prefix ]
#       [ --model make:model ] [ --os os, ... ]
#       [ --user user, ... ] [ --admin admin, ... ]
#       [ --custom all | names | none ]
#       [ --comment comment ] [ --type type, ... ]
def node_clone

  checkopts(:template)
  checkopts(:name)
  connect()

  begin
    node = Node.load("#{$options[:template]}".fully_qualify.node_handle).unlink()

    # trim/adjust:
    begin
      # remove template type
      node.types.each { |type| node.remove_type(type) if type.name =~ /template/i }
      # keep expiration date, set state to good
      node.state(State.load("Good"))
      # nuke ipcp addresses
      node.remove_addresses(node.addresses) if node.addresses
    end

    # remove names and add new name
    node.remove_names(node.names)
    node.add_name(A_Name.new("#{$options[:name]}".fully_qualify))

    # [ --location building:room | :room ]
    if $options.has_key?(:location)
      raise "room must be specified" unless $options[:location].include?(":")
      (building,colon,room) = $options[:location].strip.rpartition(/\s*:\s*/)
      node.location(Location.load(building.rstrip)) unless building.empty?
      node.room(room)
    end

    # [ --hardware|hw hardware address [ --dhcp ] [ --roam ] ]
    hw = $options[:hardware] if $options.has_key?(:hardware)
    hw = $options[:hw]       if $options.has_key?(:hw)

    if hw
      interface = Interface.new()
      interface.hardware_address(hw).disable_dhcp.disable_roaming
      interface.enable_dhcp                if $options.has_key?(:dhcp)
      interface.enable_dhcp.enable_roaming if $options.has_key?(:roam)
    elsif $options.has_key?(:dhcp)
      raise "a hardware address must be specified when using the --dhcp option"
    elsif $options.has_key?(:roam)
      raise "a hardware address must be specified when using the --roam option"
    end

    # [ --ip ip address[+] | none ]
    unless  $options.has_key?(:address_space) or
           ($options.has_key?(:ip) and $options[:ip] == "none")
      # try to assign an IP address

      # first get starting address
      starting_ip = nil
      exact = false

      if $options.has_key?(:ip)
        # specified on the command line
        starting_ip = $options[:ip]
        if starting_ip.end_with?("+")
          starting_ip.chop!
        else
          exact = true
        end

      elsif node.address_space
        # template node
        starting_ip = node.address_space.prefix.address.to_s

      elsif ip = node.interfaces.collect { |i| i.addresses.to_a }.flatten.sort.first
        # interface address
        starting_ip = Address_Space.containing(ip).prefix.address.to_s

      elsif ip = $operating_user.starting_address
        # user.starting_address
        starting_ip = ip.to_s
      end

      # reserve IP and add it to the interface
      if starting_ip
        interface = Interface.new() unless interface
        ip = Interface_IP_Address.reserve(starting_ip, 1, exact, false)[0]
        puts "IP address `#{ip.address}' reserved" unless exact
        interface.add_address(ip)
      end

    end  # --ip ip address[+]

    node.remove_interfaces(node.interfaces) if node.interfaces
    node.add_interface(interface) if interface

    # [ --address_space prefix ]
    if $options.has_key?(:address_space)
      node.address_space(Address_Space.new(Prefix.new($options[:address_space]),0,0))
    end

    # [ --model make:model ]
    if $options.has_key?(:model)
      (make,colon,model_name) = $options[:model].strip.rpartition(/\s*:\s*/)
      raise "both make and model must be specified" if make.empty? or model_name.empty?
      model = Model.load(make.rstrip,model_name)
      node.model(model)
    end

    # [ --os os, ... ]
    if $options.has_key?(:os)
      node.remove_OSes(node.OSes)
      $options[:os].split_with_escape(",").each { |os| node.add_OS(OS.load(os)) }
    end

    # [ --user user, ... ]
    if $options.has_key?(:user)
      node.remove_users(node.users) if node.users
      $options[:user].split_with_escape(",").each do |user|
        node.add_user(Directory_Person.lookup(NETID,user))
      end
    end

    # [ --admin admin, ... ]
    if $options.has_key?(:admin)
      node.remove_admins(node.admins) if node.admins
      $options[:admin].split_with_escape(",").each do |admin|
        if admin.end_with?(":")
          node.add_admin(Admin_Team.load(admin.chop))
        else
          node.add_admin(Directory_Person.lookup(NETID,admin))
        end
      end
    end

    # [ --custom all | names | none ]
    if $options.has_key?(:custom)
      keep = $options[:custom].match_abbrev("all", "names", "none") or
        raise "invalid or ambiguous --custom value '#{$options[:custom]}'"
      if keep == "names"
        node.custom_fields.each { |cf| cf.value("") }
      elsif keep == "none"
        node.remove_custom_fields(node.custom_fields)
      end
    else
      node.custom_fields.each { |cf| cf.value("") }
    end

    # [ --comment comment ]
    comment = $options.has_key?(:comment) ? $options[:comment] : ""
    node.comment(comment)

    # [ --type type, ... ]
    if $options.has_key?(:type)
      node.remove_types(node.types)
      $options[:type].split_with_escape(",").each { |type| node.add_type(Node_Type.load(type)) }
    end

    node.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  ip.unreserve if ip

end

# netdb node interface --add | --modify | --move | --remove ...
def node_interface
  checkopts(:add, :modify, :move, :remove)
  connect()
  if    $options.has_key?(:add)    then node_interface_add
  elsif $options.has_key?(:modify) then node_interface_modify
  elsif $options.has_key?(:move)   then node_interface_move
  else                                  node_interface_remove
  end
end

# netdb node interface --add none --ip ip address[+]
#                    [ --ptr_pref (closest | all) ]
#                    [ --comment comment ] [ --name name ] node

# netdb node interface --add hardware address
#                    [ --dhcp[=(on|off)] [ --options option=value,... ] [ --roam ] ]
#                    [ --ip ip address[+] [ --ptr_pref (closest | all) ] ]
#                    [ --comment comment ] [ --name name ] node
def node_interface_add
  begin

    raise "option --ip is required with option --ptr_pref" if
      $options[:ptr_pref] and not $options[:ip]

    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    interface = Interface.new()

    # --add none | hardware address
    hw = $options[:add]

    unless hw =~ /^(n|no|non|none)$/i

      # HW address specified, assign it and process DHCP stuff
      interface.hardware_address(hw).enable_dhcp.disable_roaming

      # [ --dhcp[=(on|off)] ]
      if $options.has_key?(:dhcp)
        interface.disable_dhcp if $options[:dhcp].strip =~ /^off?$/i
      end

      # [ --roam ]
      interface.enable_dhcp.enable_roaming if $options.has_key?(:roam)

      # [ --options option=value,... ]
      if $options[:options]
        options = $options[:options].split_with_escape(",").each do |setting|
          (option,value) = setting.split_with_escape("=",2)
          interface.add_dhcp_setting(DHCP_Setting.new(option,value))
        end
      end

    end # HW address / DHCP stuff

    # [ --ip ip address[+] ]
    if $options[:ip]
      exact = true
      starting_ip = $options[:ip]
      if starting_ip.end_with?("+")
        exact = false
        starting_ip = starting_ip.chop
      end
      new_ip = Interface_IP_Address.reserve(starting_ip, 1, exact, false)[0]
      puts "IP address `#{new_ip.address}' reserved" unless exact

      # [ --ptr_pref (closest | all) ]
      if $options[:ptr_pref]
        pref = $options[:ptr_pref] =~ /^a/i ? PREF_ALL : PREF_CLOSEST
        new_ip.ptr_pref(pref)
      end

      interface.add_address(new_ip)
    end

    # [ --comment comment ]
    interface.comment($options[:comment]) if $options[:comment]

    # [ --name name ]
    if $options[:name]
      name = A_Name.new("#{$options[:name]}".fully_qualify)
      interface.add_name(name)
    end

    node.add_interface(interface).commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
    new_ip.unreserve if new_ip
  end

end

# determine if input is a HW or IP address and return a suitable version of it
def hw_or_ip(address)
  ip = hw = nil
  begin
    ip = Interface_IP_Address.new(address)
  rescue
    begin
      hw = Interface.new.hardware_address(address).hardware_address
    rescue
      raise "\"#{address}\" is not a valid IP or hardware address" unless hw =~ /[0-9a-f]{12}/i
    end
  end
  return [hw,ip]
end

# netdb node interface --modify (hardware address | IP address)
#                    [ --hardware|hw hardware address | none ]
#                    [ --dhcp[=(on|off)] ] [ --options option=value,... ]
#                    [ --roam[=(on|off)] ]
#                    [ --ip ip address[+] [ --ptr_pref (closest | all) ] ]
#                    [ --comment comment ] [ --name name ] node
def node_interface_modify
  begin

    raise "option --ip is required with option --ptr_pref" if
      $options[:ptr_pref] and not $options[:ip]

    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    # type and normalize the input address
    (hw,ip) = hw_or_ip($options[:modify])

    # find the interface
    interface = nil
    node.interfaces.each do |i|
      if hw
        next unless hw.downcase == i.hardware_address
      else
        next unless i.addresses.include?(ip)
      end
      interface = i
      break
    end

    raise "No matching interfaces found" unless interface

    # [ --hardware|hw hardware address | none ]
    new_hw = $options[:hw]       if $options.has_key?(:hw)
    new_hw = $options[:hardware] if $options.has_key?(:hardware)
    if new_hw
      if new_hw =~ /^(n|no|non|none)$/i
        new_hw = ''
        interface.dhcp(false).roaming(false)
      end
      interface.hardware_address(new_hw)
    end

    # [ --dhcp[=(on|off)] ]
    if $options.has_key?(:dhcp)
      if $options[:dhcp].strip =~ /^off?$/i
        interface.dhcp(false).roaming(false)
      else
        interface.dhcp(true)
      end
    end

    # [ --roam[=(on|off)] ]
    if $options.has_key?(:roam)
      if $options[:roam].strip =~ /^off?$/i
        interface.roaming(false)
      else
        interface.dhcp(true).roaming(true)
      end
    end

    # [ --options option=value,... ]
    if $options.has_key?(:options)
      # empty string means remove settings
      interface.remove_dhcp_settings(interface.dhcp_settings) if $options[:options] == ""

      options = $options[:options].split_with_escape(",").each do |option_value|
        if option_value == ""
          # empty string means remove all settings
          interface.remove_dhcp_settings(interface.dhcp_settings)
        else
          (option,value) = option_value.split_with_escape("=",2)
          new = DHCP_Setting.new(option,value)
          # check for replacement
          interface.dhcp_settings.each do |current|
            interface.remove_dhcp_setting(current) if current.option == new.option
          end
          interface.add_dhcp_setting(new) unless value == ""
        end
      end

    end

    # [ --ip ip address[+] ]
    if $options[:ip]
      exact = true
      starting_ip = $options[:ip]
      if starting_ip.end_with?("+")
        exact = false
        starting_ip = starting_ip.chop
      end
      new_ip = Interface_IP_Address.reserve(starting_ip, 1, exact, false)[0]
      puts "IP address `#{new_ip.address}' reserved" unless exact

      # [ --ptr_pref (closest | all) ]
      if $options[:ptr_pref]
        pref = $options[:ptr_pref] =~ /^a/i ? PREF_ALL : PREF_CLOSEST
        new_ip.ptr_pref(pref)
      end

      interface.add_address(new_ip)
    end

    # [ --comment comment ]
    interface.comment($options[:comment]) if $options[:comment]

    # [ --name name ]
    if $options[:name]
      name = A_Name.new("#{$options[:name]}".fully_qualify)
      interface.remove_names(interface.names).add_name(name)
    end

    node.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
    new_ip.unreserve if new_ip
  end

end

# netdb node interface
#       --move (hardware address | IP address)[=new_hardware_address],...
#       --destination dest_node node
def node_interface_move

  checkopts(:destination)

  node_name = ARGV.shift.fully_qualify

  begin
    node = Node.load(node_name)
    move = java.util.HashMap.new
    i_did_something = false

    $options[:move].split_with_escape(",").each do |address|
      hw_or_ip, new_hw = address.split_with_escape("=",2)

      # type and normalize the address
      (hw,ip) = hw_or_ip(hw_or_ip)

      # find the interface and add it to the move hash
      node.interfaces.each do |interface|
        if hw
          next unless hw.downcase == interface.hardware_address
        else
          next unless interface.addresses.include?(ip)
        end
        move[java.lang.Long.new(interface.id)] = new_hw
        i_did_something = true
        break
      end

    end # each address

    if i_did_something
      Interface.move(move, node_name, $options[:destination].fully_qualify)
      puts ".. done"
    else
      raise "No matching interfaces found"
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

end

# netdb node interface --remove (hardware address | IP address), ... node
def node_interface_remove
  begin

    node_name = ARGV.shift
    node = Node.load(LOCK,"#{node_name}".fully_qualify.node_handle)

    i_did_something = false

    $options[:remove].split_with_escape(",").each do |address|
      # type and normalize the address
      (hw,ip) = hw_or_ip(address)

      node.interfaces.each do |interface|
        if hw
          next unless hw.downcase == interface.hardware_address
        else
          next unless interface.addresses.include?(ip)
        end
        node.remove_interface(interface)
        i_did_something = true
        break
      end

    end # each address

    if i_did_something
      node.commit
      puts ".. done"
    else
      raise "No matching interfaces found"
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    node.unlock if node
  end

end

# netdb node dev2prod [ --force ] [ help ]
def node_dev2prod

  if ARGV[0].match_abbrev("help")
    puts <<'    _EOH'

  netdb node dev2prod [ --force ] --input file | node ...

    Copies a node or nodes from the NetDB user development database to the
    production NetDB database.  Unless ``--force'' is specified each node
    is listed followed by a prompt for confirmation.

    Copying of any node will fail if any of its names, hardware addresses,
    or IP addresses exist in the production NetDB database.

    _EOH
    return
  end

  begin
    dev  = _connect(DEV_RMI_SERVICE)
    prod =  connect()
  rescue Exception => e
    abort "SYSTEM ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  # copy each node from dev to prod
  ARGV.each do |node_name|
    node = nil
    fq_node_name = "#{node_name}".fully_qualify

    begin
      log = Entry_Log.new("Node", fq_node_name)
      node = Node.load(dev,fq_node_name.node_handle(dev))
      next unless $options[:force] or log.ask("\n #{node}\nReally copy #{fq_node_name}")
      node.unlink.commit(prod)
      log.finalize(".. done")

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")
    end
  end

end



# connect plus lock/modify/commit loop with logging
def _user_loop

  connect()

  ARGV.each do |username|
    user = nil
    i_did_something = false

    begin
      log = Entry_Log.new("User", username)
      user = User.load(LOCK, username)

      i_did_something = yield(user, log, username)

      user.commit if i_did_something
      log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      user.unlock if user
    end
  end

end

# set or clear user flags
def _user_flag(flag)

  checkopts(:set, :clear)
  state = $options.has_key?(:set)
  _user_loop { |user, log| user.send(flag, state) }

end

# netdb user active_flag [ --set | --clear ]
def user_active_flag
  _user_flag("active")
end

# netdb user all_groups_flag [ --set | --clear ]
def user_all_groups_flag
  _user_flag("all_groups")
end

# netdb user all_records_flag [ --set | --clear ]
def user_all_records_flag
  _user_flag("all_records")
end

# netdb user comment [ --set comment | --clear ]
def user_comment

  checkopts(:set, :clear)
  comment = $options.has_key?(:set) ? $options[:set] : ""
  _user_loop { |user, log| user.comment(comment) }

end

# netdb user default_domain --set domain
def user_default_domain

  checkopts(:set)
  connect()

  begin
    domain = Domain.load($options[:set])
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _user_loop { |user, log| user.default_domain(domain) }

end

# netdb user def[ault]_group --set group
def user_def_group
  user_default_group
end
  
def user_default_group

  checkopts(:set)
  connect()

  begin
    group = Group.load($options[:set])
  rescue Exception => e
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _user_loop { |user, log| user.add_owner(group).default_group(group) }

end

# netdb user delete
def user_delete

  connect()

  ARGV.each do |username|
    begin
      log = Entry_Log.new("User", username)
      user = User.delete(username)
      log.finalize(".. done")
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")
    end
  end

end

# netdb user department --add department;... --remove department;...
def user_department

  checkopts(:add, :remove)
  additions, removals = unique_add_remove(";")
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |department_name|
    begin
      department = Department.load(department_name)
      add.push(department) unless add.include?(department)
    rescue
      abort "ERROR: no such department, \"#{department_name}\""
    end
  end

  remove = []
  removals.each do |department_name|
    begin
      department = Department.load(department_name)
      remove.push(department) unless remove.include?(department)
    rescue
      abort "ERROR: no such department, \"#{department_name}\""
    end
  end

  _user_loop do |user, log, username|

    i_did_something = false

    add.each do |department|
      if user.departments.include?(department)
        log.info "  INFO: user \"#{username}\" is already an LNA for \"#{department.name}\""
      else
        user.add_department(department)
        i_did_something = true
      end
    end

    remove.each do |department|
      if user.departments.include?(department)
        user.remove_department(department)
        i_did_something = true
      else
        log.info "  INFO: user \"#{username}\" is not an LNA for \"#{department.name}\""
      end
    end

    i_did_something

  end

end

# netdb user group --add group,... --remove group,...
def user_group

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |group_name|
    begin
      group = Group.load(group_name)
      add.push(group) unless add.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  remove = []
  removals.each do |group_name|
    begin
      group = Group.load(group_name)
      remove.push(group) unless remove.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  _user_loop do |user, log, username|

    i_did_something = false

    add.each do |group|
      if user.owners.include?(group)
        log.info "  INFO: user \"#{username}\" is already a member of group \"#{group.name}\""
      else
        user.add_owner(group)
        i_did_something = true
      end
    end

    remove.each do |group|
      if user.default_group == group
        log.warn "  WARNING: \"#{group.name}\" is #{username}'s default group - not removed"
      elsif user.owners.include?(group)
        user.remove_owner(group)
        i_did_something = true
      else
        log.info "  INFO: user \"#{username}\" is not a member of group \"#{group.name}\""
      end
    end

    i_did_something

  end

end

# netdb user info [ --json[=(compact|pretty) ]
def user_info
  connect()

  ARGV.each do |username|
    username = username.sub(/^id:/,'') if username.match('^id:')
    begin
      log = Entry_Log.new("User", username)
      user = User.load(username.integer? ? username.to_i : username)
      if $options.has_key?(:json)
        print user.to_json($options[:json]).strip.gsub(/^}/,'  }')
      else
        puts "\n\n", user, "\n"
      end
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
      log.json "error", e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  Entry_Log.json_finalize

end

# netdb user oauthid [ --set id | --clear ]
def user_oauthid

  checkopts(:set, :clear)
  oauthid = $options.has_key?(:set) ? $options[:set] : ""
  _user_loop { |user, log| user.oauthid(oauthid) }

end

# netdb user record --add record,... --remove record,...
def user_record

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  name = Hash[ Privilege.list.map { |priv| [priv.display_name.downcase, priv.name] } ]

  add = []
  additions.each do |priv_name|
    priv_name = name[priv_name.downcase] if name.keys.include?(priv_name.downcase)
    begin
      privilege = Privilege.load(priv_name)
      add.push(privilege) unless add.include?(privilege)
    rescue
      abort "ERROR: no such record access, \"#{priv_name}\""
    end
  end

  remove = []
  removals.each do |priv_name|
    priv_name = name[priv_name.downcase] if name.keys.include?(priv_name.downcase)
    begin
      privilege = Privilege.load(priv_name)
      remove.push(privilege) unless remove.include?(privilege)
    rescue
      abort "ERROR: no such record access, \"#{priv_name}\""
    end
  end

  _user_loop do |user, log, username|

    i_did_something = false

    add.each do |privilege|
      if user.privileges.include?(privilege)
        log.info "  INFO: user \"#{username}\" already has \"#{privilege.name}\" access"
      else
        user.grant_privilege(privilege)
        i_did_something = true
      end
    end

    remove.each do |privilege|
      if user.privileges.include?(privilege)
        user.revoke_privilege(privilege)
        i_did_something = true
      else
        log.info "  INFO: user \"#{username}\" does not have \"#{privilege.name}\" access"
      end
    end

    i_did_something

  end

end

# netdb user privilege --add privilege,... --remove privilege,...
alias user_privilege user_record

# netdb user search
def user_search
  connect()

  ARGV.each do |pat|

    begin
      log = Entry_Log.new("Results for", pat)
      results = User.search(pat)

      if results.length == 0
        log.finalize(" no match")

      else
        i = 0
        results.sort_by { |match| match.netid.downcase }.each do |user|
          log.print("\n") if i%3 == 0; i += 1;
          log.print "  %-32s  %s\n" % [ user.netid, user.name ]
        end
        log.finalize("\n")
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
end

# netdb user starting_address [ --set address | --clear ]
def user_starting_address

  checkopts(:set, :clear)

  begin
    ip = $options.has_key?(:set) ? IPaddress.new($options[:set]) : nil
  rescue Exception => e
    abort "ERROR: ``option --set #{$options[:set]}'' - " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

  _user_loop { |user, log| user.starting_address(ip) }

end

# netdb user clone --template netid
#       [ --comment comment ] [ --oauthid id ]
def user_clone

  checkopts(:template)
  connect()

  comment = $options.has_key?(:comment) ? $options[:comment] : ""
  oauthid = $options.has_key?(:oauthid) ? $options[:oauthid] : ""

  begin
    t = User.load($options[:template])

    ARGV.each do |username|
      user = nil
      begin
        log = Entry_Log.new("New user", username)
        who = Directory_Person.lookup(NETID,username)
        raise "no directory entry for NetID \"#{username}\"" unless who
        user = User.new().identity(who).active(t.active)
        user.default_domain(t.default_domain).default_group(t.default_group)
        user.all_groups(t.all_groups).all_records(t.all_records)
        user.add_owners(t.owners)
        user.starting_address(t.starting_address) if t.starting_address
        user.grant_privileges(t.privileges)       if t.privileges
        user.add_departments(t.departments)       if t.departments
        user.comment(comment)                     if comment
        user.oauthid(oauthid)                     if oauthid
        user.commit
        log.finalize(".. done")
      rescue Exception => e
        log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
        log.finalize("")
      end
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

end

# netdb user create --domain domain --def[ault]_group group
#       [ --department department;... ]
#       [ --group group,... ]
#       [ --[in]active ]
#       [ --all_groups ]
#       [ --all_records ]
#       [ --record record,... ]
#       [ --starting_address address ]
#       [ --comment comment ]
#       [ --oauthid oauthid ]
def user_create

  checkopts(:domain)
  connect()

  begin
    # --domain domain
    t = User.new().default_domain(Domain.load($options[:domain]))

    # --def[ault]_group group
    def_group = $options.has_key?(:def_group) ? $options[:def_group] : $options[:default_group]
    abort "ERROR: default group not specified" unless def_group
    group = Group.load(def_group)
    t.add_owner(group).default_group(group)

    # [ --department department;... ]
    if $options.has_key?(:department)
      $options[:department].split_with_escape(";").each do |department|
        t.add_department(Department.load(department))
      end
    end

    # [ --group group,... ]
    if $options.has_key?(:group)
      $options[:group].split_with_escape(",").each do |group|
        t.add_owner(Group.load(group))
      end
    end

    # [ --[in]active ]
    t.active(!$options.has_key?(:inactive))

    # [ --all_groups ]
    t.all_groups($options.has_key?(:all_groups))

    # [ --all_records ]
    t.all_records($options.has_key?(:all_records))

    # [ --record record,... ]
    if $options.has_key?(:record)
      name = Hash[ Privilege.list.map { |priv| [priv.display_name.downcase, priv.name] } ]
      $options[:record].split_with_escape(",").each do |priv|
        priv = name[priv.downcase] if name.keys.include?(priv.downcase)
        t.grant_privilege(Privilege.load(priv))
      end
    end

    # [ --starting_address address ]
    if $options.has_key?(:starting_address)
      t.starting_address(IPaddress.new($options[:starting_address]))
    end

    # [ --comment comment ]
    comment = $options.has_key?(:comment) ? $options[:comment] : ""

    # [ --oauthid oauthid ]
    oauthid = $options.has_key?(:oauthid) ? $options[:oauthid] : ""

    ARGV.each do |username|
      user = nil
      begin
        log = Entry_Log.new("New user", username)
        who = Directory_Person.lookup(NETID,username)
        raise "no directory entry for NetID \"#{username}\"" unless who
        user = User.new().identity(who).active(t.active)
        user.default_domain(t.default_domain).default_group(t.default_group)
        user.all_groups(t.all_groups).all_records(t.all_records)
        user.add_owners(t.owners)
        user.starting_address(t.starting_address) if t.starting_address
        user.grant_privileges(t.privileges)       if t.privileges
        user.add_departments(t.departments)       if t.departments
        user.comment(comment)                     if comment
        user.oauthid(oauthid)                     if oauthid
        user.commit
        log.finalize(".. done")
      rescue Exception => e
        log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
        log.finalize("")
      end
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

end


java_import 'stanford.netdb.Domain_SS_Result'

# domain command summary
def domain_help
  puts <<_EOH

   netdb domain admin --add admin,... --remove admin,... INPUT
   netdb domain comment [ --set comment | --clear ] INPUT
   netdb domain create_names_in --add group,... --remove group,... INPUT
   netdb domain delegated_flag [ --set | --clear ] INPUT
   netdb domain delete
   netdb domain ds --add key_tag:algorithm:digest_type:digest,...
                   --publish[=(YES|no)] domain
   netdb domain ds --modify key_tag,... --publish[=(YES|no)] domain
   netdb domain ds --remove key_tag,... domain
   netdb domain group --add group,... --remove group,... INPUT
   netdb domain help
   netdb domain info [ --json ] INPUT
   netdb domain limited_flag [ --set | --clear ] INPUT
   netdb domain name --set new_name domain
   netdb domain nameserver --add nameserver,... --remove nameserver,... INPUT
   netdb domain ns --add nameserver,... --remove nameserver,... INPUT
   netdb domain search INPUT
   netdb domain use_as_name --add group,... --remove group,... INPUT

   netdb domain create --group group,... [ --admin admin,... ]
         [ --create_names_in group,... ] [ --use_as_name group,... ]
         [ --nameservers nameserver,... ] [ --ns nameserver,... ]
         [ --delegated ] [ --limited ] [ --comment comment ] INPUT

     where INPUT is either a list of one or more domains
     separated by spaces or the '--input file' option.

     When the "--publish" option is not given it defaults
     to no; when given without a value it is true/yes.

_EOH
end

# shorten domain access types
CREATE_NAMES_IN = Domain::ACCESS_TYPE::CREATE_NAMES_IN_DOMAIN
MODIFY_DOMAIN = Domain::ACCESS_TYPE::MODIFY_DOMAIN
USE_AS_NAME = Domain::ACCESS_TYPE::USE_DOMAIN_AS_NAME

# domain access type descriptions
ACCESS = { CREATE_NAMES_IN => "create names in",
           USE_AS_NAME     => "use domain as name",
           MODIFY_DOMAIN   => "modify"              }

# connect plus lock/modify/commit loop with logging
def _domain_loop

  connect()

  ARGV.each do |domainname|
    domain = nil
    i_did_something = false

    begin
      log = Entry_Log.new("Domain", domainname)
      domain = Domain.load(LOCK, domainname)

      i_did_something = yield(domain, log, domainname)

      domain.commit if i_did_something
      log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      domain.unlock if domain
    end
  end

end

# netdb domain admin --add admin, ... --remove admin, ...
def domain_admin

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        add.push(team) unless add.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        add.push(person) unless add.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  remove = []
  removals.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        remove.push(team) unless remove.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        remove.push(person) unless remove.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  _domain_loop do |domain, log, domainname|

    i_did_something = false

    remove.each do |admin|
      if domain.admins.include?(admin)
        domain.remove_admin(admin)
        i_did_something = true
      else
        log.info "  INFO: admin \"#{admin.name}\" is not an administrator of domain \"#{domainname}\""
      end
    end

    add.each do |admin|
      if domain.admins.include?(admin)
        log.info "  INFO: admin \"#{admin.name}\" is already an administrator of domain \"#{domainname}\""
      else
        domain.add_admin(admin)
        i_did_something = true
      end
    end

    i_did_something

  end

end

# set or clear domain flags
def _domain_flag(flag)

  checkopts(:set, :clear)
  state = $options.has_key?(:set)
  _domain_loop { |domain, log| domain.send(flag, state) }

end

# netdb domain delegated_flag [ --set | --clear ]
def domain_delegated_flag
  _domain_flag("delegated")
end

# netdb domain limited_flag [ --set | --clear ]
def domain_limited_flag
  _domain_flag("limited")
end

# netdb domain comment [ --set comment | --clear ]
def domain_comment

  checkopts(:set, :clear)
  comment = $options.has_key?(:set) ? $options[:set] : ""
  _domain_loop { |domain, log| domain.comment(comment) }

end

# netdb domain delete
def domain_delete

  connect()

  ARGV.each do |domainname|
    begin
      log = Entry_Log.new("Domain", domainname)
      domain = Domain.delete(domainname)
      log.finalize(".. done")
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")
    end
  end

end

# netdb domain ds --add key_tag:algorithm:digest_type:digest,...
#                 --publish[=(yes|no)] domain
# netdb domain ds --modify key_tag,... --publish[=(yes|no)] domain
# netdb domain ds --remove key_tag,... domain
def domain_ds

  checkopts(:add, :modify, :remove)
  connect()

  begin
    domainname = ARGV.shift
    i_did_something = false
    log = Entry_Log.new("Domain", domainname)
    domain = Domain.load(LOCK, domainname)

    # publishing can be dangerous, so the default is false
    publish = false 

    # set 'publish' per the --publish option
    if $options.has_key?(:publish)
      case $options[:publish].strip
      when /^(no|false|off)$/i
        publish = false
      when /^(yes|true||on)?$/i
        publish = true
      else
        raise "Bad \"--publish\" value"
      end
    end
    
    # add DS records
    if $options.has_key?(:add)
      $options[:add].split_with_escape(",").each do |ds|
        (tag, algo, type, digest) = ds.split_with_escape(":",4)
        if domain.DS_records.any? { |ds| ds.key_tag.to_s.eql?(tag.to_s) }
          raise "Domain \"#{domainname}\" already has a key tag #{tag}."
        else
          domain.add_DS_record( DS_Record.new(tag.to_i, algo.to_i, type.to_i, digest, publish) )
        end
      end
      i_did_something = true

    # remove DS records
    elsif $options.has_key?(:remove)
      tags = $options[:remove].split_with_escape(",").map { |tag| tag.to_i }
      removals = domain.DS_records.select { |ds| tags.any?(ds.key_tag) }
      if removals.empty?
        raise "No matching key tags found on domain \"#{domainname}\"."
      else
        domain.remove_DS_records(removals)
        i_did_something = true
      end

    # set DS record publish flags
    elsif $options.has_key?(:modify)
      tags = $options[:modify].split_with_escape(",").map { |tag| tag.to_i }
      updates = domain.DS_records.select { |ds| tags.any?(ds.key_tag) }
      if updates.empty?
        raise "No matching key tags found on domain \"#{domainname}\"."
      else
        updates.each { |ds| ds.publish(publish) }
        i_did_something = true
      end
    end

    domain.commit if i_did_something
    log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))
    
  rescue Exception => e
    log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
    log.finalize("")

  ensure
    domain.unlock if domain
  end
end

# grant/revoke access to domains
def _domain_access(access)

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |group_name|
    begin
      group = Group.load(group_name)
      add.push(group) unless add.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  remove = []
  removals.each do |group_name|
    begin
      group = Group.load(group_name)
      remove.push(group) unless remove.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  _domain_loop do |domain, log, domainname|

    i_did_something = false

    add.each do |group|
      if domain.get_acl(access).include?(group)
        log.info "  INFO: Group \"#{group.name}\" already has ``#{ACCESS[access]}'' access for domain \"#{domainname}\""
      else
        domain.grant_access(group,access)
        i_did_something = true
      end
    end

    remove.each do |group|
      if domain.get_acl(access).include?(group)
        domain.revoke_access(group,access)
        i_did_something = true
      else
        log.info "  INFO: Group \"#{group.name}\" doesn't have ``#{ACCESS[access]}'' access for domain \"#{domainname}\""
      end
    end

    i_did_something

  end

end

# netdb domain create_names_in --add group,... --remove group,...
def domain_create_names_in
  _domain_access(CREATE_NAMES_IN)
end

# netdb domain group --add group,... --remove group,...
def domain_group
  _domain_access(MODIFY_DOMAIN)
end

# netdb domain use_as_name --add group,... --remove group,...
def domain_use_as_name
  _domain_access(USE_AS_NAME)
end

# netdb domain info [ --jsonn[=(compact|pretty) ]
def domain_info
  connect()

  ARGV.each do |domainname|
    begin
      log = Entry_Log.new("Domain", domainname)
      domain = Domain.load(domainname)
      if $options.has_key?(:json)
        print domain.to_json($options[:json]).strip.gsub(/^}/,'  }')
      else
        puts "\n\n", domain, "\n"
      end
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
      log.json "error", e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  Entry_Log.json_finalize

end

# netdb domain name --set new_name domain
def domain_name

  checkopts(:set)
  connect()

  begin
    domain = Domain.load(LOCK,ARGV.shift).name(Domain_Name.new($options[:set])).commit
    puts ".. done"
  rescue Exception => e
    warn "  ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  ensure
    domain.unlock if domain
  end

end

# netdb domain nameserver --add nameserver,... --remove nameserver,...
def domain_nameserver

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |nameserver|
    add.push(nameserver) if add.select { |ns| ns.downcase.eql?(nameserver.downcase) }.empty?
  end

  remove = []
  removals.each do |nameserver|
    remove.push(nameserver) if remove.select { |ns| ns.downcase.eql?(nameserver.downcase) }.empty?
  end

  _domain_loop do |domain, log, domainname|

    i_did_something = false

    add.each do |nameserver|
      if domain.nameservers.select { |ns| ns.downcase.eql?(nameserver.downcase) }.empty?
        domain.add_nameserver(nameserver)
        i_did_something = true
      else
        log.info "  INFO: #{domainname} already has nameserver \"#{nameserver}\""
      end
    end

    remove.each do |nameserver|
      if domain.nameservers.select { |ns| ns.downcase.eql?(nameserver.downcase) }.empty?
        log.info "  INFO: #{domainname} doesn't have nameserver \"#{nameserver}\""
      else
        domain.remove_nameserver(nameserver)
        i_did_something = true
      end
    end

    i_did_something

  end

end

# netdb domain ns --add nameserver,... --remove nameserver,...
alias domain_ns domain_nameserver

# netdb domain search
def domain_search
  connect()

  ARGV.each do |pat|

    log = Entry_Log.new("Results for", pat)

    begin
      results = Domain.search(pat)

      if results.length == 0
        log.finalize(" no match")

      else
        # print the results sorted by name
        i = 0
        results.sort_by { |match| match.name.downcase }.each do |domain|
          log.print("\n") if i%3 == 0; i += 1;
          log.print "  %s\n" % domain.name
        end
        log.finalize("\n")
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
end

#   netdb domain create --group group,... [ --admin admin,... ]
#         [ --create_names_in group,... ] [ --use_as_name group,... ]
#         [ --nameservers nameserver,... ] [ --ns nameserver,... ]
#         [ --delegated ] [ --limited ] [ --comment comment ]

def domain_create

  checkopts(:group)
  connect()

  begin

    # template domain
    t = Domain.new()

    # --group group,...
    $options[:group].split_with_escape(",").each do |group|
        t.add_owner(Group.load(group))
    end

    # [ --admin admin,... ]
    if $options.has_key?(:admin)
      $options[:admin].split_with_escape(",").each do |admin|
        if admin.end_with?(":")
          t.add_admin(Admin_Team.load(admin.chop))
        else
          t.add_admin(Directory_Person.lookup(NETID,admin))
        end
      end
    end

    # [ --create_names_in group,... ]
    if $options.has_key?(:create_names_in)
      $options[:create_names_in].split_with_escape(",").each do |group|
        t.grant_access(Group.load(group),CREATE_NAMES_IN)
      end
    end

    # [ --use_as_name group,... ]
    if $options.has_key?(:use_as_name)
      $options[:use_as_name].split_with_escape(",").each do |group|
        t.grant_access(Group.load(group),USE_AS_NAME)
      end
    end

    # [ --nameservers nameserver,... ]
    if $options.has_key?(:nameservers)
      $options[:nameservers].split_with_escape(",").each do |nameserver|
        t.add_nameserver(nameserver)
      end
    end

    # [ --ns nameserver,... ]
    if $options.has_key?(:ns)
      $options[:ns].split_with_escape(",").each do |ns|
        t.add_nameserver(ns)
      end
    end

    # [ --delegated ]
    t.delegated($options.has_key?(:delegated))

    # [ --limited ]
    t.limited($options.has_key?(:limited))

    # [ --comment comment ]
    comment = $options.has_key?(:comment) ? $options[:comment] : ""

    ARGV.each do |domainname|
      domain = nil
      begin
        log = Entry_Log.new("New domain", domainname)
        domain = Domain.new(Domain_Name.new(domainname))
        domain.delegated(t.delegated).limited(t.limited).add_owners(t.owners)
        domain.add_admins(t.admins).add_nameservers(t.nameservers)
        domain.grant_access(t.get_acl(CREATE_NAMES_IN),CREATE_NAMES_IN)
        domain.grant_access(t.get_acl(USE_AS_NAME),USE_AS_NAME)
        domain.comment(comment) if comment
        domain.commit
        log.finalize(".. done")
      rescue Exception => e
        log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
        log.finalize("")
      end
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

end



java_import 'stanford.netdb.Network_SS_Result'

class String

  # return the network handle for a name, IP address, or prefix
  def network_handle(ds=NetDB.default_datastore)
    (hw,name,ip) = self.hw_name_or_ip
    return self unless ip
    matches = Network.search(ds, self)
    raise "No network with address '#{self}'" unless matches.length == 1
    return matches[0].network_name
  end

end

# network command summary
def network_help
  puts <<_EOH

   netdb network comment [ --set comment | --clear ] INPUT
   netdb network delete INPUT
   netdb network dhcp_options --options option=value,... INPUT
   netdb network group --add group,... --remove group,... INPUT
   netdb network help
   netdb network info [ --json ] INPUT
   netdb network ip_address --remove address,... network
   netdb network ip_address --set [in]active --ip address,... network
   netdb network location --add building,... --remove building,... INPUT
   netdb network name --add new_name --remove old_name network
   netdb network search INPUT
   netdb network vlan [ --set area:number | --clear ] INPUT
   netdb network vlan_area [ --set area | --clear ] INPUT
   netdb network vlan_number [ --set number | --clear ] INPUT

   netdb network address_space --add prefix
         [ --low_reserve count ] [ --high_reserve count ]
         [ --comment comment ] [ --group group,... ]
         [ --options option=value,... ] network

   netdb network address_space --modify prefix
         [ --low_reserve count ] [ --high_reserve count ]
         [ --comment comment ] [ --add group,... ]
         [ --remove group,... ] [ --options option=value,... ]
         [ --ip ip address[+][/count],... ] network

   netdb network address_space --resize new_prefix_length prefix

   netdb network address_space --remove prefix,... network

   netdb network address_space --move prefix
           --destination dest_network source_network

   netdb network create --group group,...
         [ --comment comment ] [ --location building,... ]
         [ --options option=value,... ] [ [ --vlan area:number ]
         | [ --vlan_area area ] [ --vlan_number number ] ] INPUT

     where INPUT is either a list of one or more networks separated by
     spaces or the '--input file' option. Networks can be specified by
     name, IP address, or prefix.

_EOH
end

class Network_SS_Result

  # sort key - name or normalized IP address
  def key(name)
    return self.name.downcase if name
    return IPaddress.new(self.IP.sub(/\/.*$/,'')).toString(IPaddress::NORMALIZED)
  end

  # network match output formats
  def format(name)
    return "  %-43s %-s\n" % [ self.name, self.IP ] if name
    return "  %-43s %-s\n" % [ self.IP, self.name ]
  end

end

# connect plus lock/modify/commit loop with logging
def _network_loop

  connect()

  ARGV.each do |network_name|
    network = nil
    i_did_something = false
    fq_network_name = "#{network_name}".fully_qualify

    begin
      log = Entry_Log.new("Network", fq_network_name)
      network = Network.load(LOCK, fq_network_name.network_handle)

      i_did_something = yield(network, log, fq_network_name)

      network.commit if i_did_something
      log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")

    ensure
      network.unlock if network
    end
  end

end

# netdb network comment [ --set comment | --clear ]
def network_comment

  checkopts(:set, :clear)
  comment = $options.has_key?(:set) ? $options[:set] : ""
  _network_loop { |network, log| network.comment(comment) }

end

# netdb network delete
def network_delete

  connect()

  ARGV.each do |network_name|
    fq_network_name = "#{network_name}".fully_qualify
    begin
      log = Entry_Log.new("Network", fq_network_name)
      network = Network.delete(fq_network_name)
      log.finalize(".. done")
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("")
    end
  end

end

# netdb network dhcp_options --options option=value,...
def network_dhcp_options

  # empty string means remove settings
  if $options[:options] == ""
    _network_loop do |network, log, fq_network_name|
      network.remove_dhcp_settings(network.dhcp_settings)
    end

  else
    _network_loop do |network, log, fq_network_name|
      $options[:options].split_with_escape(",").each do |dhcp_setting|
        # skip blank dhcp settings
        unless dhcp_setting == ""
          (option,value) = dhcp_setting.split_with_escape("=",2)
          new = DHCP_Setting.new(option,value)
          # check for replacement
          network.dhcp_settings.each do |current|
            network.remove_dhcp_setting(current) if current.option == new.option
          end
          network.add_dhcp_setting(new) unless value == ""
        end
      end
    end
  end

end

# netdb network group --add group,... --remove group,...
def network_group

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |group_name|
    begin
      group = Group.load(group_name)
      add.push(group) unless add.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  remove = []
  removals.each do |group_name|
    begin
      group = Group.load(group_name)
      remove.push(group) unless remove.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  _network_loop do |network, log, fq_network_name|

    i_did_something = false

    add.each do |group|
      if network.owners.include?(group)
        log.info "  INFO: group \"#{group.name}\" is already assigned to network \"#{fq_network_name}\""
      else
        network.add_owner(group)
        i_did_something = true
      end
    end

    remove.each do |group|
      if network.owners.include?(group)
        network.remove_owner(group)
        i_did_something = true
      else
        log.info "  INFO: group \"#{group.name}\" is not assigned to network \"#{fq_network_name}\""
      end
    end

    i_did_something

  end

end

# netdb network info [ --json[=(compact|pretty) ]
def network_info
  connect()

  ARGV.each do |network_name|
    fq_network_name = "#{network_name}".fully_qualify
    begin
      log = Entry_Log.new("Network", fq_network_name, network_name)
      network = Network.load(fq_network_name.network_handle)
      if $options.has_key?(:json)
        print network.to_json($options[:json]).strip.gsub(/^}/,'  }')
      else
        puts "\n\n", network, "\n"
      end
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
      log.json "error", e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  Entry_Log.json_finalize

end

# netdb network ip_address --remove address,... network
# netdb network ip_address --set [in]active --ip address,... network
def network_ip_address

  checkopts(:remove, :set)

  # --set [in]active is handled in its own routine
  return network_ip_address_state if $options.has_key?(:set)

  ips = $options[:remove].split_with_escape(",").map { |ip| IP_Address.new(ip) }
  n = ips.length

  connect()

  begin
    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    i_did_something = false

    network.address_spaces.each do |address_space|
      address_space.pools.each do |pool|
        ips.each do |ip_to_remove|
          next unless victim = pool.addresses.select { |ip| ip == ip_to_remove }.first
          pool.remove_address(victim)
          i_did_something = true
        end
      end
    end

    if i_did_something
      network.commit
      puts ".. done"
    else
      if n == 1
        raise "IP address '#{$options[:remove]}' is not on network #{network_name}"
      else
        raise "IP addresses '#{$options[:remove]}' are not on network #{network_name}"
      end
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    network.unlock if network
  end

end

# netdb network ip_address --set [in]active --ip address,... network
def network_ip_address_state

  checkopts(:ip)
  connect()

  state = $options[:set] =~ /^a/i ? true : false
  ips = $options[:ip].split_with_escape(",").map { |ip| IP_Address.new(ip) }
  n = ips.length

  begin
    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    i_did_something = false

    network.address_spaces.each do |address_space|
      address_space.pools.each do |pool|
        ips.each do |ip_to_set|
          next unless target = pool.addresses.select { |ip| ip == ip_to_set }.first
          target.active(state)
          i_did_something = true
        end
      end
    end

    if i_did_something
      network.commit
      puts ".. done"
    else
      if n == 1
        raise "IP address '#{$options[:remove]}' is not on network #{network_name}"
      else
        raise "IP addresses '#{$options[:remove]}' are not on network #{network_name}"
      end
    end
  end

end

# netdb network location --add building,... --remove building,...
def network_location

  checkopts(:add, :remove)

  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |location_name|
    begin
      location = Location.load(location_name)
      add.push(location) unless add.include?(location)
    rescue
      abort "ERROR: no such location, \"#{location_name}\""
    end
  end

  remove = []
  removals.each do |location_name|
    begin
      location = Location.load(location_name)
      remove.push(location) unless remove.include?(location)
    rescue
      abort "ERROR: no such location, \"#{location_name}\""
    end
  end

  _network_loop do |network, log, fq_network_name|

    i_did_something = false

    add.each do |location|
      if network.locations.include?(location)
        log.info "  INFO: location \"#{location.name}\" is already assigned to network \"#{fq_network_name}\""
      else
        network.add_location(location)
        i_did_something = true
      end
    end

    remove.each do |location|
      if network.locations.include?(location)
        network.remove_location(location)
        i_did_something = true
      else
        log.info "  INFO: location \"#{location.name}\" is not assigned to network \"#{fq_network_name}\""
      end
    end

    i_did_something

  end

end

# netdb network name --add new_name --remove old_name network
def network_name

  checkopts(:add)
  checkopts(:remove)
  connect()

  begin
    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    new_name = A_Name.new("#{$options[:add]}".fully_qualify)
    old_name = A_Name.new("#{$options[:remove]}".fully_qualify)

    # replace a name, wherever it occurs (network or ip)
    thing = nil

    if name = network.names.select { |name| name == old_name }.first
      thing = network
    else

      network.address_spaces.each do |address_space|
        address_space.pools.each do |pool|
          thing = pool.addresses.select { |ip| ip.names.include?(old_name) }.first
          break if thing
        end
        if thing
          name = thing.names.select { |name| name == old_name }.first
          break
        end
      end
    end

    if thing
      # preserve the old name aliases and MXes, then replace old name with new name
      new_name.add_aliases(name.aliases)
      new_name.add_MXes(name.MXes)
      thing.remove_name(name)
      thing.add_name(new_name)
    else
      raise "'#{$options[:remove]}' is not a name on network #{network_name}"
    end

    network.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    network.unlock if network
  end

end

# netdb network search
def network_search
  connect()

  ARGV.each do |pat|

    log = Entry_Log.new("Results for", pat)

    begin
      # determine search type
      (hw,name,ip) = pat.hw_name_or_ip
      if name or ip
        results = Network.search(pat)
      else
        log.finalize(" invalid search string")
        next
      end

      if results.length == 0
        log.finalize(" no match")

      else
        # print the results sorted by name or IP address
        i = 0
        results.sort_by { |match| match.key(name) }.each do |network|
          log.print("\n") if i%3 == 0; i += 1;
          log.print network.format(name)
        end
        log.finalize("\n")
      end

    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
end

# netdb network vlan [ --set area:number | --clear ]
def network_vlan

  checkopts(:set, :clear)

  if $options.has_key?(:clear)
    area = "";  number = nil
  else
    (area,colon,number) = $options[:set].strip.rpartition(/\s*:\s*/)
    abort "ERROR: VLAN area and number must be specified" if area.empty? or number.empty?

    connect()

    vlan_area = VLAN_Area.load(area)

    Integer(number) rescue abort "ERROR: \"#{number}\" is not a number"
    vlan_number = number.to_i
    abort "ERROR: VLAN numbers must be in the range 1-4094" if
      vlan_number < 1 or vlan_number > 4094
  end

  _network_loop { |network, log| network.vlan_area(vlan_area).vlan(vlan_number) }

end

# netdb network vlan_area [ --set area | --clear ]
def network_vlan_area

  checkopts(:set, :clear)

  connect()

  area = $options.has_key?(:set) ? VLAN_Area.load($options[:set]) : nil

  _network_loop { |network, log| network.vlan_area(area) }

end

# netdb network vlan_number [ --set number | --clear ]
def network_vlan_number

  checkopts(:set, :clear)

  if $options.has_key?(:clear)
    number = nil
  else
    Integer($options[:set]) rescue abort "ERROR: \"#{$options[:set]}\" is not a number"
    number = $options[:set].to_i
    abort "ERROR: VLAN numbers must be in the range 1-4094" if number < 1 or number > 4094
  end

  _network_loop { |network, log| network.vlan(number) }

end

# netdb network create --group group,...
#       [ --comment comment ] [ --location building,... ]
#       [ --options option=value,... ] [ [ --vlan area:number ]
#       | [ --vlan_area area ] [ --vlan_number number ] ]
def network_create

  checkopts(:group)
  connect()

  begin

    # --group group,...
    owners = $options[:group].split_with_escape(",").map { |group| Group.load(group) }

    # [ --comment comment ]
    comment = $options.has_key?(:comment) ? $options[:comment] : "" if $options.has_key?(:comment)

    # --location building,...
    if $options.has_key?(:location)
      locations = $options[:location].split_with_escape(",").map { |loc| Location.load(loc) }
    end

    # [ --options option=value,... ]
    if $options.has_key?(:options)
      options = $options[:options].split_with_escape(",").collect do |setting|
        (option,value) = setting.split_with_escape("=",2)
        DHCP_Setting.new(option,value)
      end
    end

    # [ --vlan area:number ]
    if $options.has_key?(:vlan)
      raise "option --vlan cannot be combined with --vlan_area or --vlan_number" if
        countopts(:vlan_area, :vlan_number) > 0
      (area,colon,number) = $options[:vlan].strip.rpartition(/\s*:\s*/)
      raise "VLAN area and number must be specified" if area.empty? or number.empty?
    end

    # [ --vlan_area area ] [ --vlan_number number ]
    area   = $options[:vlan_area]   if $options.has_key?(:vlan_area)
    number = $options[:vlan_number] if $options.has_key?(:vlan_number)

    vlan_area = VLAN_Area.load(area) if area

    if number
      Integer(number) rescue raise "\"#{number}\" is not a number"
      vlan_number = number.to_i
      raise "VLAN numbers must be in the range 1-4094" if
        vlan_number < 1 or vlan_number > 4094
    end

    ARGV.each do |network_name|
      network = nil
      begin
        log = Entry_Log.new("New network", network_name)
        network = Network.new()
        network.add_name(A_Name.new("#{network_name}".fully_qualify))
        network.add_owners(owners)
        network.comment(comment) if comment
        network.add_locations(locations) if locations
        network.add_dhcp_settings(options) if options
        network.vlan_area(vlan_area) if area
        network.vlan(vlan_number) if number
        network.commit
        log.finalize(".. done")
      rescue Exception => e
        log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
        log.finalize("")
      end
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  end

end

# netdb network address_space --add prefix
#       [ --low_reserve count ] [ --high_reserve count ]
#       [ --comment comment ] [ --group group,... ]
#       [ --options option=value,... ] network

# netdb network address_space --modify prefix
#       [ --low_reserve count ] [ --high_reserve count ]
#       [ --comment comment ] [ --add group,... ]
#       [ --remove group,... ] [ --options option=value,... ]
#       [ --ip ip address[+][/count],... ] network

# netdb network address_space --remove prefix,... network

# netdb network address_space --resize new_prefix_length prefix

# netdb network address_space --move prefix --destination dest_network network

def network_address_space
  checkopts(:add, :modify, :remove, :resize, :move)
  connect()
  if    $options.has_key?(:modify) then network_address_space_modify
  elsif $options.has_key?(:move)   then network_address_space_move
  elsif $options.has_key?(:remove) then network_address_space_remove
  elsif $options.has_key?(:resize) then network_address_space_resize
  else                                  network_address_space_add
  end
end

# netdb network address_space --add prefix
#       [ --low_reserve count ] [ --high_reserve count ]
#       [ --comment comment ] [ --group group,... ]
#       [ --options option=value,... ] network
def network_address_space_add
  begin

    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    # [ --low_reserve count ] [ --high_reserve count ]
    low_reserve   = $options.has_key?(:low_reserve)  ? $options[:low_reserve].to_i  : 0
    high_reserve  = $options.has_key?(:high_reserve) ? $options[:high_reserve].to_i : 0
    address_space = Address_Space.new(Prefix.new($options[:add]), low_reserve, high_reserve)

    # [ --comment comment ]
    address_space.comment($options[:comment]) if $options[:comment]

    # [ --group group,... ]
    if $options.has_key?(:group)
      $options[:group].split_with_escape(",").each do |group|
        address_space.grant_access(Group.load(group))
      end
    end

    # [ --options option=value,... ]
    if $options[:options]
      options = $options[:options].split_with_escape(",").each do |setting|
        (option,value) = setting.split_with_escape("=",2)
        address_space.add_dhcp_setting(DHCP_Setting.new(option,value))
      end
    end

    network.add_address_space(address_space).commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    network.unlock if network
  end

end

# netdb network address_space --modify prefix
#       [ --low_reserve count ] [ --high_reserve count ]
#       [ --comment comment ] [ --add group,... ]
#       [ --remove group,... ] [ --options option=value,... ]
#       [ --ip ip address[+][/count],... ]
def network_address_space_modify
  begin

    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    # get the address space
    target = Address_Space.new($options[:modify],0,0).prefix.to_s
    address_space = network.address_spaces.select { |as| as.prefix.to_s == target }.first

    # [ --low_reserve count ] [ --high_reserve count ] [ --comment comment ]
    address_space.low_res($options[:low_reserve].to_i)   if $options.has_key?(:low_reserve)
    address_space.high_res($options[:high_reserve].to_i) if $options.has_key?(:high_reserve)
    address_space.comment($options[:comment])            if $options[:comment]

    # [ --add group,... ]
    if $options.has_key?(:add)
      $options[:add].split_with_escape(",").each do |group|
        address_space.grant_access(Group.load(group))
      end
    end

    # [ --remove group,... ]
    if $options.has_key?(:remove)
      $options[:remove].split_with_escape(",").each do |group|
        address_space.revoke_access(Group.load(group))
      end
    end

    # [ --options option=value,... ]
    if $options.has_key?(:options)
      # empty string means remove settings
      address_space.remove_dhcp_settings(address_space.dhcp_settings) if $options[:options] == ""

      options = $options[:options].split_with_escape(",").each do |option_value|
        if option_value == ""
          # empty string means remove all settings
          address_space.remove_dhcp_settings(address_space.dhcp_settings)
        else
          (option,value) = option_value.split_with_escape("=",2)
          new = DHCP_Setting.new(option,value)
          # check for replacement
          address_space.dhcp_settings.each do |current|
            address_space.remove_dhcp_setting(current) if current.option == new.option
          end
          address_space.add_dhcp_setting(new) unless value == ""
        end
      end

    end

    # [ --ip ip address[+][/count],... ]
    if $options.has_key?(:ip)
      new_ips = []
      $options[:ip].split_with_escape(",").each do |arg|
        exact = true
        (starting_ip, count) = arg.split(/\//)
        count = 1 unless count
        if starting_ip.end_with?("+")
          exact = false
          starting_ip.chop!
        end
        new_ips += IP_Address.reserve(starting_ip, count.to_i, exact, false).to_a
      end
      new_ips.each { |ip| ip.add_name(A_Name.new(ip.address.dyname)) }

      # create pool if necessary
      address_space.add_pool(IP_Pool.new) unless address_space.pools[0]

      pool = address_space.pools[0]
      pool.add_addresses(new_ips)
    end

    network.add_address_space(address_space).commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
    new_ips.each { |ip| ip.unreserve if ip.is_reserved } if new_ips

  ensure
    network.unlock if network
  end

end

# netdb network address_space --remove prefix,... network
def network_address_space_remove
  begin

    network_name = ARGV.shift
    network = Network.load(LOCK,"#{network_name}".fully_qualify.network_handle)

    i_did_something = false

    $options[:remove].split_with_escape(",").each do |address_space_to_remove|

      target = Address_Space.new(address_space_to_remove,0,0).prefix.to_s

      if address_space = network.address_spaces.select { |as| as.prefix.to_s == target }.first
        network.remove_address_space(address_space)
        i_did_something = true
      else
        warn "Address space '#{$options[:remove]}' is not part of network #{network_name}"
      end
    end

    if i_did_something
      network.commit
      puts ".. done"
    end

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    network.unlock if network
  end

end

# netdb network address_space --resize new_prefix_length prefix
def network_address_space_resize
  begin

    prefix = ARGV.shift
    (address,length) = prefix.split_with_escape("/")
    target = Address_Space.new(prefix,0,0).prefix.to_s

    # find the nwtwork with the address space and lock it
    matches = Network.search(address)
    raise "No network with address address space '#{prefix}'" unless
      matches and matches[0].ip == target

    network_name = matches[0].network_name
    network = Network.load(LOCK,network_name)

    # find the address space in the network and resize it
    address_space = network.address_spaces.select { |as| as.prefix.to_s == target }.first
    address_space.resize($options[:resize].to_i)

    network.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')

  ensure
    network.unlock if network
  end

end

# netdb network address_space --move prefix --destination dest_network network
def network_address_space_move

  checkopts(:move, :destination)

  Network.move_address_space($options[:move], ARGV.shift.fully_qualify,
                             $options[:destination].fully_qualify)
  puts ".. done"

end


java_import 'stanford.netdb.TXT_Record_SS_Result'

class String

  # return TXT record handle for a name or alias
  def txt_record_handle(ds=NetDB.default_datastore)
    matches = TXT_Record.search(ds, self)
    raise "No such TXT record, '#{self}'" unless matches.length == 1
    return matches[0].handle
  end

  # find unique TXT record value or raise error
  def find_value(values)
    matches = values.select { |v| v.value.index(self) == 0 }
    return matches.shift if matches.length == 1
    raise  "No matching value found" if matches.length == 0
    raise  "Value \"#{self.trunc(10)}\" is not unique on the txt_record."
  end

end

class Object

  # quick and dirty string truncation with ellipsis (on Object to cover nil)
  def trunc(limit)
    return "" if self.nil?
    return self if self.length <= limit
    return self[0,limit-3] + "..."
  end

end

# txt_record command summary
def txt_record_help
  puts <<_EOH

   netdb txt_record admin --add admin,... --remove admin,... INPUT
   netdb txt_record admin --value value --add admin,... --remove admin,... name
   netdb txt_record alias --add alias,... --remove alias,... name
   netdb txt_record comment [ --set comment | --clear ] INPUT
   netdb txt_record comment --value value [ --set comment | --clear ] name
   netdb txt_record delete INPUT
   netdb txt_record expiration --value value [ --set date | --clear ] name
   netdb txt_record group --add group,... --remove group,... INPUT
   netdb txt_record help
   netdb txt_record info [ --json ] INPUT

   netdb txt_record value --add value,... --remove value,...
         [ --comment comment ] [ --admin admin,... ] name

   netdb txt_record value --modify value --value value name

   netdb txt_record search INPUT

   netdb txt_record create --admin admin,... --group group,...
         [ --alias alias,... ] [ --comment comment ]
         [ --value value [ --expiration date ]
                         [ --value_admin admin,... ]
                         [ --value_comment comment ] ] name

     where INPUT is either a list of one or more txt_records
     separated by spaces or the '--input file' option.

   An abbreviated value may be supplied with the --value and
   --modify options as long as it is unique among the values
   of the TXT record.

_EOH
end

# connect plus lock/modify/commit loop with logging
def _txt_record_loop

  connect()

  ARGV.each do |txt_name|
    txt_record = nil
    fq_txt_name = "#{txt_name}".fully_qualify

    begin
      log = Entry_Log.new("TXT_Record", fq_txt_name)
      txt_record = TXT_Record.load(LOCK, fq_txt_name)

      i_did_something = yield(txt_record, log, txt_name)

      txt_record.commit if i_did_something
      log.finalize(".. done" + (i_did_something ? "" : " (no changes)"))

    rescue Exception => e
      log.warn "  WARNING: ".format_message(e.message)
      log.finalize("")

    ensure
      txt_record.unlock if txt_record
    end
  end

end

# netdb txt_record admin [ --value value ] --add admin, ... --remove admin, ...
def txt_record_admin

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        add.push(team) unless add.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        add.push(person) unless add.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  remove = []
  removals.each do |admin|
    if admin.end_with?(":")
      admin = admin.chop
      begin
        team = Admin_Team.load(admin)
        remove.push(team) unless remove.include?(team)
      rescue
        abort "ERROR: no such admin team, \"#{admin}\""
      end
    else
      person = Directory_Person.lookup(NETID,admin)
      if person
        remove.push(person) unless remove.include?(person)
      else
        abort "ERROR: no directory entry for NetID \"#{admin}\""
      end
    end
  end

  return txt_record_value_admin(add,remove) if $options.has_key?(:value)

  _txt_record_loop do |txt_record, log, fq_txt_name|

    i_did_something = false

    remove.each do |admin|
      if txt_record.admins.include?(admin)
        txt_record.remove_admin(admin)
        i_did_something = true
      else
        log.info "  INFO: admin \"#{admin.name}\" is not an administrator of txt_record \"#{fq_txt_name}\""
      end
    end

    add.each do |admin|
      if txt_record.admins.include?(admin)
        log.info "  INFO: admin \"#{admin.name}\" is already an administrator of txt_record \"#{fq_txt_name}\""
      else
        txt_record.add_admin(admin)
        i_did_something = true
      end
    end

    i_did_something

  end

end

# netdb txt_record admin --value value --add admin,... --remove admin,...
def txt_record_value_admin(add,remove)

  begin
    txt_name = ARGV.shift
    log = Entry_Log.new("TXT_Record", txt_name.fully_qualify)
    txt_record = TXT_Record.load(LOCK, txt_name.fully_qualify)

    # find the value
    value = $options[:value].find_value(txt_record.values)

    i_did_something = false

    remove.each do |admin|
      if value.admins.include?(admin)
        value.remove_admin(admin)
        i_did_something = true
      else
        log.info "  INFO: admin \"#{admin.name}\" is not an admin of txt_record \"#{txt_name}\" value \"#{$options[:value].trunc(10)}\""
      end
    end

    add.each do |admin|
      if value.admins.include?(admin)
        log.info "  INFO: admin \"#{admin.name}\" is already an admin of txt_record \"#{txt_name}\" value \"#{$options[:value].trunc(10)}\""
      else
        value.add_admin(admin)
        i_did_something = true
      end
    end

    if i_did_something
      txt_record.commit
      puts ".. done"
    else
      puts " (no changes)"
    end

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

end

# netdb txt_record alias --add alias, ... --remove alias, ... name
def txt_record_alias

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  input_name = ARGV.shift

  begin
    txt_record = TXT_Record.load(LOCK, input_name.fully_qualify)
    name = txt_record.name

    i_did_something = false

    removals.each do |a|
      old_alias = Alias.new("#{a}".fully_qualify)
      if name.aliases.include?(old_alias)
        name.remove_alias(old_alias)
        i_did_something = true
      else
        info "INFO: '#{a}' is not an alias for name '#{input_name}'"
      end
    end

    additions.each do |a|
      new_alias = Alias.new("#{a}".fully_qualify)
      if name.aliases.include?(new_alias)
        info "INFO: '#{a}' is already an alias for name '#{input_name}'"
      else
        name.add_alias(new_alias)
        i_did_something = true
      end
    end

    if i_did_something
      txt_record.commit
      puts ".. done"
    else
      puts " (no changes)"
    end

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

end

# netdb txt_record comment [ --value value ] [ --set comment | --clear ]
def txt_record_comment

  checkopts(:set, :clear)
  comment = $options.has_key?(:set) ? $options[:set] : ""

  if $options.has_key?(:value)
    return txt_record_value_comment(comment)
  else
    _txt_record_loop { |txt_record, log| txt_record.comment(comment) }
  end

end

# netdb txt_record comment --value value [ --set comment | --clear ]
def txt_record_value_comment(comment)

  connect()

  begin
    txt_record = TXT_Record.load(LOCK,ARGV.shift.fully_qualify)

    # find the value and set its comment
    value = $options[:value].find_value(txt_record.values)
    value.comment(comment)
    txt_record.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

end

# netdb txt_record delete
def txt_record_delete

  connect()

  ARGV.each do |txt_name|
    fq_txt_name = "#{txt_name}".fully_qualify
    begin
      log = Entry_Log.new("TXT_Record", fq_txt_name)
      txt_record = TXT_Record.delete(fq_txt_name)
      log.finalize(".. done")
    rescue Exception => e
      log.warn "  WARNING: ".format_message(e.message)
      log.finalize("")
    end
  end

end

# netdb txt_record expiration [ --set date | --clear ]
def txt_record_expiration

  checkopts(:set, :clear)
  date = $options.has_key?(:set) ? Date.parse($options[:set]).strftime("%m/%d/%Y") : ''
  connect()

  begin
    txt_record = TXT_Record.load(LOCK,ARGV.shift.fully_qualify)

    # find the value and set its expiration date
    value = $options[:value].find_value(txt_record.values)
    value.expiration_date(date)
    i_did_something = true

    if i_did_something
      txt_record.commit
      puts ".. done"
    else
      puts " (no changes)"
    end

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

end

# netdb txt_record group --add group, ... --remove group, ...
def txt_record_group

  checkopts(:add, :remove)
  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  add = []
  additions.each do |group_name|
    begin
      group = Group.load(group_name)
      add.push(group) unless add.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  remove = []
  removals.each do |group_name|
    begin
      group = Group.load(group_name)
      remove.push(group) unless remove.include?(group)
    rescue
      abort "ERROR: no such group, \"#{group_name}\""
    end
  end

  _txt_record_loop do |txt_record, log, fq_txt_name|

    i_did_something = false

    add.each do |group|
      if txt_record.owners.include?(group)
        log.info "  INFO: group \"#{group.name}\" is already assigned to txt_record \"#{fq_txt_name}\""
      else
        txt_record.add_owner(group)
        i_did_something = true
      end
    end

    remove.each do |group|
      if txt_record.owners.include?(group)
        txt_record.remove_owner(group)
        i_did_something = true
      else
        log.info "  INFO: group \"#{group.name}\" is not assigned to txt_record \"#{fq_txt_name}\""
      end
    end

    i_did_something

  end

end

# netdb txt_record info [ --jsonn[=(compact|pretty) ]
def txt_record_info
  connect()

  ARGV.each do |txt_name|
    fq_txt_name = "#{txt_name}".fully_qualify
    begin
      log = Entry_Log.new("TXT_Record", fq_txt_name)
      txt_record = TXT_Record.load(fq_txt_name.txt_record_handle)
      if $options.has_key?(:json)
        print txt_record.to_json($options[:json]).strip.gsub(/^}/,'  }')
      else
        puts "\n\n", txt_record, "\n"
      end
    rescue Exception => e
      log.warn "  WARNING: ".format_message(e.message)
      log.finalize("\n")
      log.json "error", e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

  Entry_Log.json_finalize

end

# netdb txt_record value --add value,... --remove value,...
#       [ --comment comment ] [ --admin admin,... ]
# netdb txt_record value --modify value --value value name
def txt_record_value

  checkopts(:add, :modify, :remove)
  return txt_record_modify_value if $options.has_key?(:modify)

  additions, removals = unique_add_remove()
  abort "ERROR: additions and removals cancel out" if additions.empty? and removals.empty?
  connect()

  comment = $options.has_key?(:comment) ? $options[:comment] : ""

  admins = []
  if $options.has_key?(:admin)
    $options[:admin].split_with_escape(",").each do |admin|
      if admin.end_with?(":")
        admin = admin.chop
        begin
          team = Admin_Team.load(admin)
          admins.push(team) unless admins.include?(team)
        rescue
          abort "ERROR: no such admin team, \"#{admin}\""
        end
      else
        person = Directory_Person.lookup(NETID,admin)
        if person
          admins.push(person) unless admins.include?(person)
        else
          abort "ERROR: no directory entry for NetID \"#{admin}\""
        end
      end
    end
  end

  begin
    txt_name = ARGV.shift
    log = Entry_Log.new("TXT_Record", txt_name.fully_qualify)
    txt_record = TXT_Record.load(LOCK, txt_name.fully_qualify)

    i_did_something = false

    additions.each do |value|
      if txt_record.values.find { |v| v.value == value }
        log.info "  INFO: #{txt_name} already has value \"#{value.trunc(10)}\""
      else
        txt_record.add_value(TXT_Value.new(value).comment(comment).add_admins(admins))
        i_did_something = true
      end
    end

    removals.each do |value|
      if target = txt_record.values.find { |v| v.value == value }
        txt_record.remove_value(target)
        i_did_something = true
      else
        log.info "  INFO: #{txt_name} doesn't have value \"#{value.trunc(1)}\""
      end
    end

    if i_did_something
      txt_record.commit
      puts ".. done"
    else
      puts " (no changes)"
    end

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

  end

# netdb txt_record value --modify value --value value name
def txt_record_modify_value

  checkopts(:modify, :value)
  connect()

  begin
    txt_record = TXT_Record.load(LOCK,ARGV.shift.fully_qualify)

    # find the value and set its value to the new value
    value = $options[:modify].find_value(txt_record.values)
    value.value($options[:value])
    txt_record.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)

  ensure
    txt_record.unlock if txt_record
  end

end

# netdb txt_record search
def txt_record_search
  connect()

  ARGV.each do |pat|

    log = Entry_Log.new("Results for", pat)

    begin
      results = TXT_Record.search(pat)

      if results.length == 0
        log.finalize(" no match")

      else
        i = 0;
        width = results.max { |a, b| a.name.length <=> b.name.length }.name.length

        # print the results sorted by name
        results.sort_by { |match| match.name.downcase }.each do |result|
          if result.name_type == TXT_Record_SS_Result::NAME_TYPE::ALIAS
            type, rhs = 'alias', result.display_name
          else
            type, rhs = '     ', result.value.trunc(48)
          end
          log.print("\n") if i%3 == 0; i += 1;
          log.print "  %-*s    %s    %s\n" % [width, result.name, type, rhs]

        end
        log.finalize("\n")
      end

    rescue Exception => e
      raise
      log.warn "  WARNING: ".format_message(e.message)
      log.finalize("\n")
    end
  end
end

# netdb txt_record create --admin admin,... --group group,...
#       [ --alias alias,... ] [ --comment comment ]
#       [ --value value [ --expiration date ]
#                       [ --value_admin admin,... ]
#                       [ --value_comment comment ] ]
def txt_record_create

  checkopts(:admin, :group)
  connect()

  begin
    name = TXT_Name.new(ARGV.shift.fully_qualify)

    # --alias alias,...
    if $options.has_key?(:alias)
      $options[:alias].split_with_escape(",").each do |a|
        name.add_alias(Alias.new(a.fully_qualify))
      end
    end

    txt_record = TXT_Record.new(name)

    # --group group,...
    $options[:group].split_with_escape(",").each do |group|
      txt_record.add_owner(Group.load(group))
    end

    # [ --admin admin,... ]
    if $options.has_key?(:admin)
      $options[:admin].split_with_escape(",").each do |admin|
        if admin.end_with?(":")
          txt_record.add_admin(Admin_Team.load(admin.chop))
        else
          txt_record.add_admin(Directory_Person.lookup(NETID,admin))
        end
      end
    end

    # [ --comment comment ]
    txt_record.comment($options[:comment]) if $options.has_key?(:comment)

    # [ --value value ]
    if $options.has_key?(:value)
      value = TXT_Value.new($options[:value])

      # [ --expiration date ]
      value.expiration_date($options[:expiration]) if $options.has_key?(:expiration)

      # [ --value_admin admin,... ]
      if $options.has_key?(:value_admin)
        $options[:value_admin].split_with_escape(",").each do |admin|
          if admin.end_with?(":")
            value.add_admin(Admin_Team.load(admin.chop))
          else
            value.add_admin(Directory_Person.lookup(NETID,admin))
          end
        end
      end

      # [ --value_comment comment ]
      value.comment($options[:value_comment]) if $options.has_key?(:value_comment)

      txt_record.add_value(value)
    end

    txt_record.commit
    puts ".. done"

  rescue Exception => e
    warn "ERROR: ".format_message(e.message)
  end

end



# netdb list [ --json[=(compact|pretty) ] [ departments | locations | models | oses | vlan_areas ] [ <regexp> ]

def list_departments
  filter = ARGV.shift
  departments = Department.list.collect { |department| department.name }
  departments = departments.select { |department| department =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts departments
  else
    puts $options[:json] ? JSON.pretty_generate(departments) : JSON.generate(departments)
  end
end

def list_locations
  filter = ARGV.shift
  locations = Location.list.collect { |location| location.name }
  locations = locations.select { |location| location =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts locations
  else
    puts $options[:json] ? JSON.pretty_generate(locations) : JSON.generate(locations)
  end
end

def list_models
  filter = ARGV.shift
  models = Model.list.collect { |model| model.make.name + " " + model.name }
  models = models.select { |model| model =~ /#{filter}/i } if filter
  models = models.sort { |a,b| a.downcase <=> b.downcase }
  unless $options.has_key?(:json)
    puts models
  else
    puts $options[:json] ? JSON.pretty_generate(models) : JSON.generate(models)
  end
end

def list_oses
  filter = ARGV.shift
  oses = OS.list.collect { |os| os.name }
  oses = oses.select { |os| os =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts oses
  else
    puts $options[:json] ? JSON.pretty_generate(oses) : JSON.generate(oses)
  end
end

def list_vlan_areas
  filter = ARGV.shift
  vlan_areas = VLAN_Area.list.collect { |area| area.name }
  vlan_areas = vlan_areas.select { |area| area =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts vlan_areas
  else
    puts $options[:json] ? JSON.pretty_generate(vlan_areas) : JSON.generate(vlan_areas)
  end
end

# netdb list [ --json[=(compact|pretty) ] [ privileges | records ] [ <regexp> ] (undocumented)

def list_privileges
  filter = ARGV.shift
  privs = Privilege.list.to_a
  privs.select! { |p| p.display_name =~ /#{filter}/i or p.name =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts privs.map { |priv| priv.display_name + " (" + priv.name + ")" }.sort
  else
    puts '{ "privileges": [', privs.map { |p| p.to_json($options[:json]).chomp }.join(",\n"), '] }'
  end
end

def list_records
  list_privileges()
end

# netdb list [ --json[=(compact|pretty) ] dhcp_options [ <scope> ]
#   where scope in (DHCP_SERVICE, NETWORK, ADDRESS_SPACE, INTERFACE)

def list_dhcp_options
  input = ARGV.shift
  if (input)
    scope = input.downcase.match_abbrev("dhcp_service", "network", "address_space", "interface")
    abort "ERROR: no such DHCP scope \"#{input}\"" unless (scope)
    scope = eval "DHCP_Option::SCOPE::#{scope.upcase}"
  end
  dhcp_options = scope ? DHCP_Option.list(scope) : DHCP_Option.list
  dhcp_options = dhcp_options.collect { |dhcp_option| dhcp_option.name }
  unless $options.has_key?(:json)
    puts dhcp_options
  else
    puts $options[:json] ? JSON.pretty_generate(dhcp_options) : JSON.generate(dhcp_options)
  end
end

# netdb list [ --json[=(compact|pretty) ] groups [ all | <regexp> | all <regexp> ]

def list_groups
  filter = ARGV.shift
  if (filter == "all")
    filter = ARGV.shift
    groups = Group.list(true).collect { |group| group.name }
  else
    groups = Group.list.collect { |group| group.name }
  end
  groups = groups.select { |group| group =~ /#{filter}/i } if filter
  unless $options.has_key?(:json)
    puts groups
  else
    puts $options[:json] ? JSON.pretty_generate(groups) : JSON.generate(groups)
  end
end

# netdb list [ --json[=(compact|pretty) ] [ node_types | states ] [ all ]

def list_node_types
  all = ARGV.shift
  types = all == "all" ? Node_Type.list(true) : Node_Type.list
  types = types.collect { |type| type.name }
  unless $options.has_key?(:json)
    puts types
  else
    puts $options[:json] ? JSON.pretty_generate(types) : JSON.generate(types)
  end
end

def list_states
  all = ARGV.shift
  states = all == "all" ? State.list(true) : State.list
  states = states.collect { |state| state.name }
  unless $options.has_key?(:json)
    puts states
  else
    puts $options[:json] ? JSON.pretty_generate(states) : JSON.generate(states)
  end
end

# netdb list service

def list_service
  java_import 'stanford.netdb.middleware.Datastore'

  (name, port) = $rmi_service.split(/:/,2)
  name = name.fully_qualify

  ds = port ? Datastore.new(name, port.to_i) : Datastore.new(name)

  puts "\nNetDB RMI service '#{$rmi_service}' status:\n\n", ds.probe(), ''
end



# push the contents of --input infile on to ARGV
def read_input_file
  abort "ERROR: input can be specified on the command line or using '--input', but not both" unless ARGV.empty?
  begin
    infile = File.open($options[:input], "r")
    infile.each { |line| ARGV.concat(line.split) }
  rescue StandardError => error_message
    abort "ERROR: " + error_message.to_s
  ensure
    infile.close if infile
  end
end

# use TCP for auth
System.setProperty("rmi.kerberos.force_tcp", "true")
#System.setProperty("sun.security.krb5.debug", "true")

$operating_user = nil

# connect to the RMI and set the default datastore and a few global variables
def connect
  return if $operating_user
  ds = _connect()
  NetDB.default_datastore(ds)
  NetDB.autocomplete(false)
  $operating_user = ds.operating_user
  $default_domain = $operating_user.default_domain.name.full_name
  $private_domain = Defaults.PrivateAddressDomain
  # puts "DEBUG: Using server " + ds.host() + ":" + ds.port().to_s if $debug  # XXX
  return ds
end

# connect to the RMI
def _connect(service=$rmi_service)

  (name, port) = service.split(/:/,2)
  name = name.fully_qualify

  if $options.has_key?(:keytab)
    port ? NetDB_Datastore.new(name, port.to_i, $options[:keytab], $options[:principal]) :
           NetDB_Datastore.new(name, $options[:keytab], $options[:principal])
  else
    port ? NetDB_Datastore.new(name, port.to_i) :
           NetDB_Datastore.new(name)
  end
end

# check optional --set value and adjust ARGV accordingly
def check_flag
  return unless $options.has_key?(:set)
  # set takes an optional value, which for flags is actually the first argument
  ARGV.unshift($options[:set]) unless $options[:set].strip.empty?
end

# really old versions of JRuby don't come with the json library
def bail_if_no_json_library
  return unless $noJSON
  raise 'JSON library not found, therefore the "--json" option cannot be used.'
end

# check optional --json value and adjust ARGV and the option accordingly.
def check_json
  return unless $options.has_key?(:json)
  case $options[:json].match_abbrev("compact","pretty")
  when "compact"
    $options[:json] = false
  when "pretty"
    $options[:json] = true
  else
    ARGV.unshift($options[:json])
    $options[:json] = true
  end
end

# node keyword/input check and dispatch
def do_node(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_keyword("admin", "alias", "comment", "custom", "delete", "department",
                                "expiration", "group", "info", "ip_address", "ipc_address",
                                "location", "model", "name", "os", "ptr_pref", "receive_mail_for",
                                "state", "type", "user", "clone", "interface", "log", "search",
                                "dev2prod", "address_space")

  # the interface keyword options --dhcp and --roam take an optional on/off
  # value.  if that value is omitted, --dhcp or --roam may have snagged the
  # first argument. check and adjust ARGV and the options accordingly.
  if keyword == "interface"
    if $options.has_key?(:dhcp) and $options[:dhcp].strip !~ /^(on|off?|)$/i
      ARGV.unshift($options[:dhcp])
      $options[:dhcp] = ''
    end
    if $options.has_key?(:roam) and $options[:roam].strip !~ /^(on|off?|)$/i
      ARGV.unshift($options[:roam])
      $options[:roam] = ''
    end
  end

  check_json if keyword == "info"

  if %w[clone log].include? keyword
    # clone and log don't take arguments
    case
    when $options[:input]
      abort "ERROR: '--input' option not allowed with keyword '#{keyword}'"
    when ARGV.length > 0
      abort "ERROR: too many arguments"
    end

  elsif (keyword == "ip_address")
    # ip_address has two, mutually exclusive, uses with their own conditions
    if $options[:remove]
      case
      when $options[:set]
        abort "ERROR: options --remove and --set are mutually exclusive"
      when $options[:input]
        abort "ERROR: --input option not allowed with keyword #{keyword}"
      when ARGV.length > 1
        abort "ERROR: too many arguments"
      when ARGV.length == 0
        abort "ERROR: no argument given"
      end
    elsif $options[:set]
      read_input_file if $options[:input]
      abort "ERROR: no arguments given" if ARGV.empty?
    end

  elsif %w[alias name receive_mail_for interface].include? keyword
    # these keywords take only a single command-line argument
    case
    when $options[:input]
      abort "ERROR: --input option not allowed with keyword #{keyword}"
    when ARGV.length == 0
      abort "ERROR: no argument given"
    when ARGV.length > 1
      abort "ERROR: too many arguments"
    end

  else
    # the remaining keywords take either command-line or file input
    read_input_file if $options[:input]
    abort "ERROR: no arguments given" if ARGV.empty?
  end

  send("node_#{keyword}")

end

# user keyword/input check and dispatch
def do_user(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_keyword("active_flag", "all_groups_flag", "all_records_flag",
                                "comment", "default_domain", "default_group",
                                "def_group", "delete", "department", "group", "info",
                                "oauthid", "record", "privilege", "starting_address",
                                "clone", "create", "search")

  check_flag if keyword =~ /flag$/
  check_json if keyword == "info"

  # all user keywords require command-line or file input
  read_input_file if $options[:input]
  abort "ERROR: no arguments given" if ARGV.empty?

  send("user_#{keyword}")

end

# network keyword/input check and dispatch
def do_network(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_abbrev("vlan")
  keyword = input.match_keyword("address_space", "comment", "create", "delete",
                                "dhcp_options", "group", "help", "info",
                                "ip_address", "location", "name", "search",
                                "vlan_area", "vlan_number") unless keyword

  check_json if keyword == "info"

  # all network keywords except 'help' require command-line or file input
  read_input_file if $options[:input]
  abort "ERROR: no arguments given" if ARGV.empty? and keyword != 'help'

    # ip_address has two, mutually exclusive, uses - remove and set
  if (keyword == "ip_address" and $options[:remove] and $options[:set])
    abort "ERROR: options --remove and --set are mutually exclusive"
  end

  send("network_#{keyword}")

end

# domain keyword/input check and dispatch
def do_domain(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_abbrev("name")
  keyword = input.match_abbrev("create") unless keyword
  keyword = input.match_keyword("admin", "delegated_flag", "limited_flag",
                                "comment", "delete", "group", "help", "ds",
                                "create_names_in", "use_as_name", "search",
                                "nameserver", "ns", "info") unless keyword

  check_flag if keyword =~ /flag$/
  check_json if keyword == "info"

  # all domain keywords except 'help'  require command-line or file input
  read_input_file if $options[:input]
  abort "ERROR: no arguments given" if ARGV.empty? and keyword != 'help'

  send("domain_#{keyword}")

end

# txt_record keyword/input check and dispatch
def do_txt_record(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_keyword("admin", "alias", "comment", "create", "delete",
                                "expiration", "group", "help", "info", "name",
                                "search", "value")

  check_json if keyword == "info"

  # all txt_record keywords except 'help' require command-line or file input
  read_input_file if $options[:input]
  abort "ERROR: no arguments given" if ARGV.empty? and keyword != 'help'

  send("txt_record_#{keyword}")

end

# list keyword check and dispatch
def do_list(input)

  # make sure we got a unique, valid keyword
  keyword = input.match_keyword("departments", "dhcp_options", "groups", "locations",
                                "models", "oses", "states", "node_types", "privileges",
                                "records", "service", "vlan_areas")
  connect() unless keyword == "service"
  send("list_#{keyword}")

end

alias do_show do_list

# sleep dispatch
def do_sleep(seconds)
  sleep(seconds.to_f)
end

# abort if none of the passed options were specified on the command line
def checkopts(*opts)
  abort "ERROR: required option or options not specified" if ($options.keys & opts).empty?
end

# return number of input options specified
def countopts(*opts)
  return ($options.keys & opts).length
end

# remove common elements from --add and --remove lists
def unique_add_remove(sep=",")

  add = $options.has_key?(:add)    ? $options[:add].split_with_escape(sep)    : []
  rem = $options.has_key?(:remove) ? $options[:remove].split_with_escape(sep) : []

  common = add & rem
  unless common.empty?
    add = add - common
    rem = rem - common
  end

  return [add, rem]
end

# main()
def main

  opts = GetoptLong.new(
                        [ '--debug',   '-d',    GetoptLong::NO_ARGUMENT       ],

                        [ '--help',    '-h',    GetoptLong::NO_ARGUMENT       ],
                        [ '--usage',   '-u',    GetoptLong::NO_ARGUMENT       ],
                        [ '--version', '-v',    GetoptLong::NO_ARGUMENT       ],

                        [ '--service', '-s',    GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--quiet',   '-q',    GetoptLong::NO_ARGUMENT       ],

                        [ '--add',              GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--remove',           GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--set',              GetoptLong::OPTIONAL_ARGUMENT ],
                        [ '--clear',            GetoptLong::NO_ARGUMENT       ],

                        [ '--interface',        GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--input',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--json',             GetoptLong::OPTIONAL_ARGUMENT ],

                        [ '--template',         GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--name',             GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--location',         GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--hardware',         GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--hw',               GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--dhcp',             GetoptLong::OPTIONAL_ARGUMENT ],
                        [ '--options',          GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--roam',             GetoptLong::OPTIONAL_ARGUMENT ],
                        [ '--ip',               GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--model',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--os',               GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--user',             GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--admin',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--address_space',    GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--comment',          GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--custom',           GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--modify',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--ptr_pref',         GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--force',            GetoptLong::NO_ARGUMENT       ],
                        [ '--keep_mx',          GetoptLong::NO_ARGUMENT       ],

                        [ '--domain',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--def_group',        GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--default_group',    GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--department',       GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--group',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--active',           GetoptLong::NO_ARGUMENT       ],
                        [ '--inactive',         GetoptLong::NO_ARGUMENT       ],
                        [ '--all_groups',       GetoptLong::NO_ARGUMENT       ],
                        [ '--all_records',      GetoptLong::NO_ARGUMENT       ],
                        [ '--oauthid',          GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--record',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--starting_address', GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--low_reserve',      GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--high_reserve',     GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--resize',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--move',             GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--destination',      GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--create_names_in',  GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--use_as_name',      GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--nameservers',      GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--ns',               GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--delegated',        GetoptLong::NO_ARGUMENT       ],
                        [ '--limited',          GetoptLong::NO_ARGUMENT       ],
                        [ '--publish',          GetoptLong::OPTIONAL_ARGUMENT ],

                        [ '--vlan',             GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--vlan_area',        GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--vlan_number',      GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--alias',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--value',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--expiration',       GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--value_admin',      GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--value_comment',    GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--after',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--before',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--id',               GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--state',            GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--type',             GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--batch',            GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--keytab',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--principal',        GetoptLong::REQUIRED_ARGUMENT ])

  opts.quiet = true

  $options = {}

  begin
    opts.each { |opt, arg| $options["#{opt[2..-1]}".to_sym] = arg }
  rescue StandardError => error_message
    abort "ERROR: " + error_message.to_s
  end

  $debug = true if $options[:debug]

  help    if $options[:help]
  usage   if $options[:usage]
  version if $options[:version]

  if $options[:service]
    $rmi_service = $options[:service] == "dev" ? DEV_RMI_SERVICE : $options[:service]
  end

  unless $options.has_key?(:batch)

    # call the uber method for the input record (node, user, network, ...)

    input = ARGV.shift
    usage unless input
    record = input.downcase.match_abbrev("list", "show", "sleep", "node", "user",
                                         "network", "domain", "txt_record")
    raise "unknown or unsupported record type \"#{input}\"" unless record
    ARGV.push('help') if ARGV.empty? and %w{domain network txt_record}.include?(record)
    bail_if_no_json_library if $options.has_key?(:json)
    check_json if ['list', 'show'].include?(record)
    raise "no keyword provided" unless keyword = ARGV.shift
    send("do_#{record}",keyword.downcase)

  else

    # batch mode - read commands from a file or STDIN

    unless $options[:batch] == '-'
      batfile = File.open($options[:batch], "r")
    else
      require "readline"
      begin
        # save history if possible
        require 'readline/history/restore'
        histfile = ENV.has_key?('NETDB_HISTORY') ? ENV['NETDB_HISTORY'] : '~/.netdb_history'
        Readline::History::Restore.new(File.expand_path(histfile))
      rescue LoadError
      end
      prompt = toplevel_prompt = "#{$rmi_service.sub(/\..+/,'')}> "
    end

    require 'shellwords'
    command = ""

    # connect now if we're using a keytab
    connect if $options.has_key?(:keytab)

    puts if batfile
    while line = batfile ? batfile.gets : Readline.readline(prompt, true)
      # comment?
      line = line.strip.chomp
      if line.start_with?("#")
        puts line if batfile
        next
      end
      # sanitize the line
      line = line.sub(/\s*#.*/,'').sub(/^netdb /,'')
      # more to come?
      if line.end_with?("\\")
        command += line.chop.rstrip + " "
        prompt = '> '
        next
      end
      command += line
      # all for naught?
      next if command.empty?
      exit if command.match_abbrev("exit","quit")

      # got a command - print it and run it
      puts "% #{$PROGRAM_NAME} #{command}" if batfile
      begin
        ARGV.clear.concat(command.shellsplit)
        main()
      rescue Exception => e
        message = e.message.sub(/^[^:]+xception[^:]*: /,'')
        warn "ERROR: " + message unless message =~ /^ERROR:/
        raise if $debug
      end
      prompt = toplevel_prompt
      command = ""
      puts
    end
  end

end # main()

begin
  main()
rescue SystemExit => e
rescue Exception => e
  abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'') unless $debug
  warn  "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
  raise
end

HELP = <<'_EOH'

=head1 NAME

netdb - Create, Modify or Delete NetDB Records

=head1 SYNOPSIS

B<netdb node admin --add> I<admin>, I<...> B<--remove> I<admin>, I<...>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node address_space> [ B<--set> I<prefix> | B<--clear> ] I<node>

B<netdb node alias --add> I<alias>, I<...> B<--remove> I<alias>, I<...> I<name>

B<netdb node comment> [ B<--set> I<comment> | B<--clear> ]
  [ B<--input> I<file> | I<node ...> ]

B<netdb node custom --add> I<name>[I<=value>], I<...> B<--remove> I<name>[I<=value>], I<...>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node delete> [ B<--keep_mx> ] [ B<--force> ]
  [ B<--input> I<file> | I<node ...> ]

B<netdb node department --set> I<department>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node expiration> [ B<--set> I<date> | B<--clear> ]
  [ B<--input> I<file> | I<node ...> ]

B<netdb node group --add> I<group>, I<...> B<--remove> I<group>, I<...>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node info> [ B<--json> ]
  [ B<--input> I<file> | I<node ...> ]

B<netdb node ip_address --remove> I<old_ip> [ B<--add> I<new_ip>[I<+>] ] I<node>

B<netdb node ip_address --set> [I<in>]I<active>
  [ B<--input> I<file> | I<address ...> ]

B<netdb node ipc_address> [ B<--remove> I<ip>, I<...> ]
  [ B<--add> I<ip>[I<+>][/I<count>], I<...> ] node

B<netdb node location --set> [I<building>]I<:room>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node log> [ B<--name> I<name> ] [ B<--ip> I<ip> ] [ B<--id> I<record_id> ]
  [ B<--after> I<date> ] [ B<--before> I<date> ]
  [ B<--state> I<state> ] [ B<--user> I<netid> ]

B<netdb node model --set> I<make:model>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node name> [ B<--add> I<new_name> ]
  [ B<--remove> I<old_name> | B<--ip> I<IP address> |
    B<--interface> (I<hardware address> | I<IP address>) ] I<node>

B<netdb node os --add> I<os>, I<...> B<--remove> I<os>, I<...>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node ptr_pref --set> I<closest> | I<all>
  [ B<--input> I<file> | I<address ...> ]

B<netdb node receive_mail_for --add> I<mailname>[I<:preference>], I<...>
    B<--remove> I<mailname>, I<...> I<name>

B<netdb node search>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node state --set> I<state>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node type --add> I<type>, I<...> B<--remove> I<type>, I<...>
  [ B<--input> I<file> | I<node ...> ]

B<netdb node user --add> I<user>, I<...> B<--remove> I<user>, I<...>
  [ B<--input> I<file> | I<node ...> ]

- or -

B<netdb node clone --template> I<node> B<--name> I<name>
  [ B<--location> I<building:room> | I<:room> ]
  [ B<--hardware>|B<hw> I<hardware address> [ B<--dhcp> ] [ B<--roam> ] ]
  [ B<--ip> I<ip address>[I<+>] | none ] [ B<--address_space> I<prefix> ]
  [ B<--model> I<make:model> ] [ B<--os> I<os>, I<...> ]
  [ B<--user> I<user>, I<...> ] [ B<--admin> I<admin>, I<...> ]
  [ B<--custom> all | names | none ]
  [ B<--comment> I<comment> ] [ B<--type> I<type>, I<...> ]

- or -

B<netdb node interface --add> I<hardware address>
  [ B<--dhcp>[B<=>(I<on>|I<off>)] ] [ B<--options> I<option=value,...> ] [ B<--roam> ]
  [ B<--ip> I<ip address>[I<+>] [ B<--ptr_pref> (closest | all) ] ]
  [ B<--comment> I<comment> ] [ B<--name> I<name> ] I<node>

B<netdb node interface --add> none B<--ip> I<ip address>[I<+>]
  [ B<--ptr_pref> (I<closest> | I<all>) ]
  [ B<--comment> I<comment> ] [ B<--name> I<name> ] I<node>

B<netdb node interface --modify> (I<hardware address> | I<IP address>)
  [ B<--hardware>|B<hw> I<hardware address> | none ]
  [ B<--dhcp>[B<=>(I<on>|I<off>)] ] [ B<--options> I<option=value,...> ]
  [ B<--roam>[B<=>(I<on>|I<off>)] ]
  [ B<--ip> I<ip address>[I<+>] [ B<--ptr_pref> (I<closest> | I<all>) ] ]
  [ B<--comment> I<comment> ] [ B<--name> I<name> ] I<node>

B<netdb node interface>
  B<--move> (I<hardware address> | I<IP address>)[I<=new_hardware_address>]I<,...>
  B<--destination dest_node> I<node>

B<netdb node interface --remove> (I<hardware address> | I<IP address>), I<... node>

- or -

B<netdb user active_flag> [ B<--set> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user all_groups_flag> [ B<--set> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user all_records_flag> [ B<--set> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user comment> [ B<--set> I<comment> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user default_domain --set> I<domain>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user default_group --set> I<group>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user delete>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user department --add> I<department>;I<...> B<--remove> I<department>;I<...>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user group --add> I<group>,I<...> B<--remove> I<group>,I<...>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user info> [ B<--json> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user oauthid> [ B<--set> I<oauthid> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user record --add> I<record>,I<...> B<--remove> I<record>,I<...>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user search>
  [ B<--input> I<file> | I<netid ...> ]

B<netdb user starting_address> [ B<--set> I<address> | B<--clear> ]
  [ B<--input> I<file> | I<netid ...> ]

- or -

B<netdb user clone --template> I<netid> [ B<--comment> I<comment> ]
  [ B<--oauthid> I<id> ] [ B<--input> I<file> | I<netid ...> ]

B<netdb user create> B<--domain> I<domain> B<--def>[B<ault>]B<_group> I<group>
  [ B<--department> I<department>;I<...> ]
  [ B<--group> I<group>,I<...> ]
  [ B<-->[B<in>]B<active> ]
  [ B<--all_groups> ]
  [ B<--all_records> ]
  [ B<--record> I<record>,I<...> ]
  [ B<--starting_address> I<address> ]
  [ B<--comment> I<comment> ]
  [ B<--oauthid> I<id> ]
  [ B<--input> I<file> | I<netid ...> ]

- or -

B<netdb list> [ B<--json> ] [ I<departments> | I<locations> | I<models>
                                    | I<oses> | I<vlan_areas> ] [ I<regexp> ]

B<netdb list> [ B<--json> ] [ I<node_types> | I<states> ] [ I<all> ]

B<netdb list> [ B<--json> ] I<groups> [ I<all> | I<regexp> | I<all> I<regexp> ]

B<netdb list> [ B<--json> ] I<dhcp_options> [ I<interface> | I<address_space>
                                   |  I<network>  | I<dhcp_service>  ]

B<netdb list> I<service>

- or -

B<netdb> [ B<domain> | B<network> | B<txt_record> ] B<help>

- or -

B<netdb --batch> [ I<command_file> | I<-> ]

- or -

B<netdb --keytab> I<keytab> B<--principal> I<principal> (B<node> | B<user> | B<list>) I<...>

B<netdb --keytab> I<keytab> B<--principal> I<principal> B<--batch> [ I<command_file> | I<-> ]

- or -

B<netdb --help>

B<netdb --usage>

B<netdb --version>

- to suppress informational warning messages -

B<netdb --quiet>


=head1 DESCRIPTION

B<netdb> is a utility for creating, modifying, and deleting NetDB
records.  Its first argument is the record type, e.g., I<node> or
I<network>, or I<list>, which lists valid values of various
attributes.  Its second argument is a keyword - I<clone>, I<delete>,
I<log>, I<search>, or the name of an attribute to be modified or
listed.  Keywords are followed by keyword-specific options and
arguments.  Keywords and options need not be spelled out completely -
providing enough characters to uniquely identify a keyword or option
is sufficient.

Records may be specified as arguments or read from a file, one per
line, using the I<--input> option.  Any name, alias, IP or hardware
address will work to identify a node record.  Your default domain will
be appended to unqualified node names.  Users should be specified by
NetID.  If more than one record is given, B<netdb> processes each in
the order listed.  If there is a problem deleting or modifying a
particular record, B<netdb> reports the error and continues to the
next record.

The I<clone> keyword creates a new record using an existing record
as a template.  The names of the existing and new records must be
supplied; other record attributes are taken from the template.
Options are available to override many of the template attribute
values.

The I<list> keyword lists the valid values of the given attribute
for the current user.  Adding I<all> lists all the values of the
attribute.  Include the optional I<regexp> parameter to limit the
listing to values that match the regular expression.  The I<list>
keyword can also be used to show the status of the RMI service.

The I<log> keyword searches the log for that record type using the
parameters provided in the options.  Log search options support
wildcards, Boolean operators, and regular expressions.

The I<search> keyword finds nodes by name or IP address, and users by
name or NetID. It supports wildcards and IP address ranges expressed
with a dash (e.g. I<171.64.32.17-21>) or as a prefix (e.g.,
I<171.64.32.0/29>).

Multiple commands can be run using the B<--batch> option.  This can be
a real time-saver when running a lot of commands as the time to start
B<netdb> is non-trivial.

B<netdb> uses your valid Kerberos ticket to authenticate you to the
NetDB server.  Or you can use the B<--keytab> and B<--principal>
options to authenticate using a Kerberos keytab file.  If you don't
have a valid ticket, or specify an invalid keytab or principal,
B<netdb> exits with an error.

=head1 RECORD TYPES

B<netdb> works with I<node>, I<user>, I<network>, I<domain>, and
I<txt_record> NetDB records. The remainder of this help deals with the
most popular records - I<node> and I<user>. Use the keyword I<help> to
get a brief usage summary for each of the other record types, e.g.,
I<netdb domain help>.

=head1 NODE KEYWORDS

=over

=item admin

Add and/or remove node administrators.  Administrators can be
specified by I<NetID> or I<Admin Team Name>.  To identify the
input as an admin team, append a colon (e.g. I<myteam:>).

=item alias

Add and/or remove aliases of a node name.

=item address_space

Add, change, or remove the address space of a node of type I<template>.

=item clone

Create a new record using an existing record as a template.

=item comment

Set or clear the comment of a record or records.

=item custom

Add and/or remove node custom fields.  Custom fields are specified
as I<name=value> with the value being optional.

=item delete

Delete a record or records.  When deleting node records, mail
exchanger entries on other nodes will also be removed unless the
B<--keep_mx> option is supplied.  Deleting nodes of type I<router>
or those having more than 10 aliases and/or mail names requires
confirmation unless the I<--force> option is used.

=item department

Change the department associated with a node or nodes.

=item expiration

Set or clear the expiration date of a node or nodes.  Specify dates in
the I<mm/dd/yyyy> form.

=item group

Add and/or remove record groups.

=item info

List information about a node or nodes.

=item interface

Add, modify, move, or remove node interfaces.  Interfaces are identified by
their hardware or IP addresses.

=item ip_address

Change or remove a node IP address with the B<--remove> option.  The old IP
address is required.  The operation will fail if I<node> does not have
I<old_ip> or I<new_ip> is not available.  If a plus is appended to the new IP
address, I<new_ip+>, B<netdb> searches for available IP addresses starting at
the specified IP address.  Use the B<interface> keyword to add an IP address
to a node.

Set node interface IP addresses active or inactive with the B<--set>
option. A node name is not required, only the IP address(es) to change.

=item ipc_address

Add or remove IP Connectivity Provider IP addresses.  Adds new IP addresses
starting with the specified IP address.  The operation will fail if the
specified IP address is not available.  If a plus is appended to the new IP
address, I<ip+>, B<netdb> searches for available IP addresses starting at the
specified IP address.  If I<count> is specified, B<netdb> attempts to add that
many addresses.  Addresses are added as available and aren't necessarily
contiguous.

=item location

Change the location of a node or nodes.  The location is specified as
I<building:room>.  I<building> is optional and if it's not specified
only I<room> is changed.

=item log

Search the NetDB log for node entries matching the conditions given in
the options.  Log search options support wildcards, Boolean operators,
and regular expressions.

=item model

Change the make and model of a node or nodes.  The new make and model
are specified as I<make:model>.

=item name

Add, remove, or replace a name.  If only B<--add> is specified the new name is
added as a node name.  If B<--ip> or B<--interface> is specified the new name
is added to that IP address or interface.  If both B<--add> and B<--remove>
are specified the new name replaces the old name, be it a node name, interface
name, interface IP address name, or IPC IP address name.  If only B<--remove>
is specified the old name is removed from wherever it occurs on the node.  The
operation will fail if the new name is not available or the old name is not
associated with the node.  The I<Advanced Node> privilege is required to make
changes to nodes with more than one name.

=item os

Add and/or remove OSes running on a node or nodes.

=item ptr_pref

Set the PTR preference for an interface IP address.  The PTR preference
controls what the DNS returns for reverse lookups of the IP address.  If set
to I<all>, then all existing IP address, Interface, and Node names will be
returned in no particular order.  If set to I<closest>, then the first
existing IP address name, Interface name, or Node name will be returned.

=item receive_mail_for

Add and/or remove mail destination names to a node name.  A mail exchanger
(MX) preference value can be specified in the form I<mailname:preference>.
If no MX preference is supplied, a default value of I<10> will be assigned.

=item search

Find nodes by name or IP address.  This keyword supports wildcards in
names and IP addresses, as well as IP address ranges expressed with a
dash (e.g. I<171.64.32.17-21>) or as a prefix (e.g., I<171.64.32.0/29>).

=item state

Change the state of a node or nodes.

=item type

Add and/or remove states of a node or nodes.

=item user

Add and/or remove users of a node or nodes.  Users are specified by
I<NetID>.

=back

=head1 USER KEYWORDS

=over

=item active_flag

Set or clear the active flag for a user or users.

=item all_groups_flag

Set or clear the all-groups flag, which allows a user to create,
modify, or delete records regardless of group membership.

=item all_records_flag

Set or clear the all-records flag, which allows a user to create,
modify, or delete records of any type.

=item clone

Create a new user or users using an existing user as a template. The
template user's comment field will not be carried over to the new
records, but a new comment may be provided.

=item comment

Set or clear the comment for a user or users.

=item create

Create a new user or users with the specified attributes.

=item default_domain

Set or clear the default domain for a user or users.

=item default_group

Set or clear the default group for a user or users.

=item delete

Delete a user or users.

=item department

Change the departments with which a user or users are officially affiliated as
Local Network Administrators.

=item group

Change the record groups to which a user or users have access.  Users
cannot create, modify, or delete records in groups they are not members
of (unless they have all-groups access).

=item info

List information about a user or users.

=item oauthid

Set or clear the OAuth ID for a user or users.

=item record

Change the types of records that a user or users can create, modify or delete.

=item search

Find users by name or NetID.  This keyword supports wildcards,
e.g. I<corn*.stanford.edu>.

=item starting_address

Set or clear the starting address for a user or users.

=back

=head1 OPTIONS

=head2 Modify Options

=over

=item --add I<values>

Add the specified I<values> to the record or records.  Values are input as a
comma- or semicolon-separated list of strings, as specified for the particular
keyword.  To add a value containing the delimiter, escape it with a backslash
(\); to add a value containing a backslash, escape the backslash with another
backslash.  For example, I<--add 'foo,bar\,baz,C:\\qux'> adds the values
I<foo> and I<bar,baz> and I<C:\qux>.

=item --remove I<values>

Remove the specified I<values> from the record or records.  Values are input
as a comma- or semicolon-separated list of strings, as specified for the
particular keyword.  To remove a value containing the delimiter, escape it
with a backslash (\); to remove a value containing a backslash, escape the
backslash with another backslash.

=item --set I<value>

Set the value of a single-valued attribute.

=item --clear

Clear the value of an optional single-valued attribute.

=item --input I<file>

Read the names of the records to create or modify from I<file>, one per line.
If I<file> is ``-'', names are read from standard input.  Your default domain
will be appended to unqualified names.

=back

=head2 Clone Options

=over

=item --template I<name>

The name of an existing record used as a model for the new record.

=item --comment I<comment>

Use the supplied comment for the new record.

=back

=head2 Node Clone Options

=over

=item --name I<name>

The name of the new record.

=item --location I<building:room> | I<:room>

Override the template location with this location.  To override
just the room specify the location as I<:room>.

=item --hardware|hw I<hardware address>

The hardware address of the new node.  Most common hardware address
forms (e.g., I<0800.2085.8b0f>, I<08:0:20:85:8b:f>, or
I<08-00-20-85-8b-0f>) are accepted.

=item --dhcp

Set the DHCP flag for the new node (hardware address required).

=item --roam

Set the DHCP and roaming flags for the new node (hardware address
required).

=item --ip I<ip address>[I<+>] | none

Override the default IP address assignment.  If I<ip address> is
specified, B<netdb> creates the new node with exactly that IP address.
If that IP address is not available, node creation fails.  If a plus is
appended, I<ip address+>, B<netdb> searches for available IP addresses
starting at the specified IP address.  The value I<none> means do not
assign an IP address to the new node.  A hardware address is required
if no IP address is requested.

=item --address_space I<prefix>

Set the address space of a node of type I<template>.

=item --model I<make:model>

Override the template make and model with this make and model.

=item --os I<os>, I<...>

Override the template OSes with these OSes.

=item --type I<type>, I<...>

Override the template types with these types.

=item --user I<user>, I<...>

Override the template user field with these users.  Users are
specified by I<NetID>.

=item --admin I<admin>, I<...>

Override the template administrator field with these administrators.
Administrators can be specified by I<NetID> or I<Admin Team Name>.
To identify the input as an admin team, append a colon (e.g. I<myteam:>).

=item --custom all | names | none

Specifies the parts of the template custom fields to keep.  I<all>
means keep both names and values; I<names> means keep the names and
clear the values; I<none> means don't keep any template custom fields.
If this option is not specified the custom field names are preserved
and the values are cleared, just as if I<names> was specified.

=back

=head2 Interface Options

=over

=item --add (I<hardware address> | none)

The hardware address of the interface to add.  Most common hardware address
forms (e.g., I<0800.2085.8b0f>, I<08:0:20:85:8b:f>, or I<08-00-20-85-8b-0f>)
are accepted.

To add an interface without a hardware address use the value I<none>.
In this case the the B<--ip> option is required since an interface must
have at least a hardware address or an IP address.

=item --modify (I<hardware address> | I<IP address>)

The hardware or IP address of an interface to modify.  See B<--add>
for valid hardware address forms.

=item --move (I<hardware address> | I<IP address>)[I<=new_hardware_address>]I<,...>

Move the interface(s) with the specified hardware or IP address(es) to the
node specified by the B<--destination> argument.

=item --destination I<dest_node>

Specifies the target node for the B<--move> option.

=item --remove (I<hardware address> | I<IP address>), I<...>

The hardware or IP address(es) of interfaces to be removed.  See
B<--add> for valid hardware address forms.

=item --hardware|hw I<hardware address> | none

Change or remove the interface hardware address.  See B<--add> for
valid hardware address forms.  Use the value I<none> to remove an
existing hardware address.

=item --dhcp[=(I<on>|I<off>)]

Set or clear the DHCP flag for the interface.  If neither I<on> nor I<off>
is specified, the flag is set.  The default state of the DHCP flag when
adding an interface with a hardware address is I<on>.

=item --roam[=(I<on>|I<off>)]

Set or clear the DHCP roaming flag for the interface.  If neither I<on> nor
I<off> is specified, the flag is set.  The default state of the roaming flag
when adding an interface is I<off>.  The roaming flag is automatically set
I<off> when the DHCP flag is I<off>.

=item --options I<option=value,...>

Add the specified DHCP options to the interface.  To remove an option omit
the I<value>: B<--options> I<option=>.  To remove all the options specify
a blank string: B<--options> I<"">.  To replace all the options with new
ones start with a blank option: B<--options> I<"",option=value,...>.

To add a value containing a comma, escape the comma with a backslash
(\). For example, I<--options 'ntp-servers=fred\,barny'>.  To add a value
containing a backslash, escape the backslash with another backslash.  For
example, I<--options 'filename=C:\\foo'> adds the option
I<filename=C:\foo>.

=item --ip I<ip address>[I<+>]

Add I<ip address> to the interface.  If the IP address is not available, the
interface modification fails.  If a plus is appended to the IP address, I<ip
address+>, B<netdb> searches for available IP addresses starting at the
specified IP address.

=item --ptr_pref I<closest> | I<all>

Set the PTR preference for the new interface IP address.  The PTR preference
controls what the DNS returns for reverse lookups of the IP address.  If set
to I<all>, then all existing IP address, Interface, and Node names will be
returned in no particular order.  If set to I<closest>, then the first
existing IP address name, Interface name, or Node name will be returned.

=item --comment I<comment>

Set the interface comment.  Use a blank string to clear the interface comment.

=item --name I<name>

Set the name of the interface.

=back

=head2 User Clone Option

=over

=item --oauthid I<ID>

Specify the OAuth ID mapping to the new user account.

=back

=head2 User Create Options

=over

=item --domain I<domain>

Specify the default domain for the new user.

=item --def[ault]_group I<group>

Specify the default group for the new user.

=item --group I<group,...>

Specify other groups to which the new user should have access.

=item --department I<department;...>

Specify the departments with which the new user is to be affiliated, in
an official LNA capacity.

=item --[in]active

Specify whether the new user's account should be active or inactive upon
creation.

=item --all_groups

Grant the new user all-groups access.  By default, users are created
without all-groups access.

=item --all_records

Grant the new user all-records access.  By default, users are created
without all-records access.

=item --oauthid I<ID>

Specify the OAuth ID mapping to the new user account.

=item --record I<record,...>

Specify the record types to which the new user should have access.

=item --starting_address I<address>

Specify a starting address for the new user.

=back

=head2 Log Options

=over

=item --name I<name>

Show log entries matching I<name>. If no domain is entered, all domains
are searched. The record name must be the actual name - not an alias,
interface name, or address name.

=item --ip I<ip>

Show log entries with IP addresses matching I<ip>.  NetDB logs the IP
addresses associated with a record after an action has occurred.  This
means that deleted IP addresses are not logged as part of the delete
log entry.  To see why an IP has disappeared from NetDB perform a
record ID search using the record ID of the last node with the IP
address.  Note that IPC addresses are not logged for Nodes, nor are
Dynamic DHCP addresses logged for Networks.

=item --id I<record id>

Show log entries for the record with the ID I<record id>.  The record
ID stays with a particular record, even if it is renamed, until it is
deleted.

=item --after I<date>

Show log entries after I<date>.

=item --before I<date>

Show log entries before I<date>.

=item --state I<state>

Show log entries of nodes with the state value I<state>.

=item --user I<NetID>

Show log entries where NetDB user I<NetID> created/updated/modified
the record.

=back

=head2 Batch Option

=over

=item --batch [ I<command_file> | I<-> ]

Take commands from the specified I<command_file> or, when I<-> is
specified, the terminal.

The command format is the same as on the command line, starting with a
record type followed by a keyword and the appropriate options.  Commands
may span multiple lines using a backslash (\) to signify that a command
continues on the next line.  Comments are allowed and delimited by the
pound sign (#).  Blank lines are ignored.

When taking commands from the terminal B<netdb> prompts with I<netdb>>
and supports command recall and editing. If the readline-history-restore
gem is installed the command history is preserved between sessions in
I<~/.netdb_history> or the file given by the NETDB_HISTORY environmental
variable.

Batch mode includes a ``sleep I<seconds>'' directive that can be used to
create a delay between commands. It takes a number, integer or float, as
the argument and sleeps for that many seconds.

=back

=head2 Authentication Options

=over

=item --keytab I<keytab>

Path to a Kerberos keytab file to be used for authentication.

=item --principal I<principal>

Authenticate as this Kerberos principal using the specified I<keytab>.

=back

=head2 Other Options

=over

=item --help

Print a detailed description of how to use B<netdb>.

=item --usage

Print a short description of how to use B<netdb>.

=item --version

Print B<netdb> version information. Java version information will be
included if the B<--debug> option is given.

=item --json=(I<compact>|I<pretty>)]

When used with the B<info> keyword, returns the results in JSON format,
keyed by the arguments.  Unmatched arguments are included with an I<{
"error": "reason" }> JSON structure.

When used with the B<list> keyword, returns a JSON array of values.

JSON output is pretty printed by default. Use the optional value
I<compact> to get the compact version instead.

=item --service I<RMI service>

Use the specified I<RMI service> instead of the default RMI service.
Services can be specified by service name or a server:port combination.

=item --quiet

Suppress informational warning messages, for example messages that say an
attribute to be added to an entry is already part of that entry.

=back

=head1 EXAMPLES

=over

=item Move nodes listed in file I<moved> to I<Sugar Hall>, room I<A3>.

 netdb node location --set "Sugar Hall:A3" --input moved

   - or -

 netdb node loc --s "Sugar Hall:A3" --in moved

=item Delete node I<diamond>

 netdb node delete diamond

   - or -

 netdb node del diamond

=item Create node I<chip> based on node I<oldblock>

  netdb node clone --template oldblock --name chip \
                   --hw aa:00:04:64:a7:08 --dhcp

=item Create a user I<stevie> with the same access and affiliations as I<ray>

 netdb user clone --template ray stevie

=item Grant all-groups access to the user I<nina>

 netdb user all_groups_flag --set nina

   - or -

 netdb user all_g --s nina

=item List locations with names beginning with ``main''.

 netdb list location 'main*'

   - or -

 netdb list loc 'main*'

=back

=head1 CAVEATS

Any I<SUNetID> (e.g., I<John.Doe>, or I<j.doe>, or I<jdoe>) will usually
work for adding node users or administrators, but only the I<Kerberos
SUNetID> (e.g., I<jdoe>) works for removing them.  The same is true for
creating, modifying, and deleting NetDB user records.  This is because
B<NetDB> doesn't store all the I<SUNetIDs>, only the I<Kerberos
SUNetID>.

The UNIX command interpreter, the shell, breaks commands up on spaces.
Elements of a command that contain spaces must be quoted for the shell
to treat them as a single entity.  So when entering B<netdb> commands
that have elements containing spaces, I<comment> or I<location> for
example, be sure to enclose those elements in quotes.

B<netdb> allows you to quickly change any number of B<NetDB> records.
If you're not careful, you can screw them up just as fast.

=head1 SEE ALSO

NetDB online help (I<http://web.stanford.edu/group/networking/netdb/help/prod/index.html>)

=cut

_EOH
