#!/usr/bin/env jruby

require 'getoptlong'
require 'java'
require 'ffi'

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

if RUBY_VERSION >= '1.9'
  # Modify parsing methods to handle american date format correctly.
  class << Date
    # American date format detected by the library.
    AMERICAN_DATE_RE = %r_\A\s*(\d{1,2})/(\d{1,2})/(\d{4}|\d{2})_.freeze

    # Alias for stdlib Date._parse
    alias _parse_without_american_date _parse

    # Transform american dates into ISO dates before parsing.
    def _parse(string, comp=true)
      _parse_without_american_date(convert_american_to_iso(string), comp)
    end

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

      # 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

    private

    # Transform american date fromat into ISO format.
    def convert_american_to_iso(string)
      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.
    class << DateTime
      # Alias for stdlib Date.parse
      alias parse_without_american_date parse

      # 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
  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.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.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.User'
java_import 'stanford.netdb.IP.IPaddress'

$debug = false

# netdb-dev service
DEV_RMI_SERVICE = '_netdb-dev._rmi.stanford.edu'
DEV_RMI_SERVER  = 'netdb-dev.stanford.edu'
DEV_RMI_PORT    = 6022

# netdb service
if ENV.has_key?('NETDB_SERVICE')

  if ENV['NETDB_SERVICE'].start_with?('_')
    # SRV record service
    (RMI_SERVICE, RMI_SERVER, RMI_PORT) = [ENV['NETDB_SERVICE'], '', '']

  elsif ENV['NETDB_SERVICE'].include?(':')
    # host:port service location
    (RMI_SERVICE, RMI_SERVER, RMI_PORT) = ":#{ENV['NETDB_SERVICE']}".split(/:/,3)

  elsif ENV['NETDB_SERVICE'] == "dev"
    # development service
    (RMI_SERVICE, RMI_SERVER, RMI_PORT) = [DEV_RMI_SERVICE, DEV_RMI_SERVER, DEV_RMI_PORT]

  else
    abort "invalid service name in environment, NETDB_SERVICE='#{ENV['NETDB_SERVICE']}'"
   end

else
  # default netdb service
  RMI_SERVICE = '_netdb._rmi.stanford.edu'
  RMI_SERVER  = ''
  RMI_PORT    = 0

end

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

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

Usage:

   netdb node admin --add admin,... --remove admin,... INPUT
   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 INPUT
   netdb node ip_address --remove old_ip [ --add new_ip[+] ] node
   netdb node ipc_address [ --remove ip,... ]
                          [ --add ip[+][/count],... ] node
   netdb node location --set [building]:room INPUT
   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 state --set state INPUT
   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 ]
         [ --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) ] ]
         [ --name name ] node

   netdb node interface --add none --ip ip address[+]
         [ --ptr_pref (closest | all) ] [ --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) ] ]
         [ --name name ] 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 INPUT
   netdb user record --add record,... --remove record,... INPUT
   netdb user starting_address [ --set address | --clear ] INPUT

   netdb user clone --template netid [ --comment comment ] 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 ]
         INPUT

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

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

   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

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

# 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
#   %

class Entry_Log

  def initialize(prefix, name)
    @prefix = prefix
    @name = name
    self.print @prefix + ' "' + @name + '" ...'
    @needs_cr = TRUE
  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)
    print_cr_if_needed
    $stderr.puts message
  end

  def finalize(message)
    $stdout.puts message unless message.empty?
  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
  puts "NetDB CLI version 2.6.0 running on JRuby #{JRUBY_VERSION}, RMI version #{NetDB.version()}"
  exit
end


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

# 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 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.include?(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.collect { |name| name.full_name() }
        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.full_name) }
          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.full_name) }
              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
def node_info
  connect()

  ARGV.each do |node_name|
    node = nil
    fq_node_name = "#{node_name}".fully_qualify
    begin
      log = Entry_Log.new("Node", fq_node_name)
      puts "\n\n", Node.load(fq_node_name.node_handle), "\n"
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
end

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

  checkopts(:remove)
  connect()

  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 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
    # building may include "(site-code)" - if so, strip that and try again
    abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'') unless qc = building.rindex('(')
    begin
      location = Location.load(building[0,qc].rstrip)
    rescue Exception => ee
      abort "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
    end
  end

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

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 == 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 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 ]
#       [ --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*/)

      begin
        location = Location.load(building.rstrip) unless building.empty?
      rescue Exception => e
        # building may include "(site-code)" - if so, strip that and try again
        raise e unless qc = building.rindex('(')
        begin
          location = Location.load(building[0,qc].rstrip)
        rescue Exception => ee
          raise e
        end
      end

      node.location(location)
      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?(: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

    # [ --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 | --remove ...
def node_interface
  checkopts(:add, :modify, :remove)
  connect()
  if    $options.has_key?(:add)    then node_interface_add
  elsif $options.has_key?(:modify) then node_interface_modify
  else                                  node_interface_remove
  end
end

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

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

    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

    # [ --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) ] ]
#                    [ --name name ] node
def node_interface_modify
  begin

    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 == 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

    # [ --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 --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 == 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, DEV_RMI_SERVER, DEV_RMI_PORT)
    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 default_group --set group
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
def user_info
  connect()

  ARGV.each do |username|
    user = nil
    begin
      log = Entry_Log.new("User", username)
      puts "\n\n", User.load(username), "\n"
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
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 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 ]
def user_clone

  checkopts(:template)
  connect()

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

  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.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 ]
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] : ""

    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.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


# domain command summary
def domain_help
  puts <<_EOH

   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 group --add group,... --remove group,... INPUT
   netdb domain help
   netdb domain info INPUT
   netdb domain limited_flag [ --set | --clear ] INPUT
   netdb domain name --set new_name domain
   netdb domain use_as_name --add group,... --remove group,... INPUT

   netdb domain create --group group,...
         [ --create_names_in group,... ] [ --use_as_name group,... ]
         [ --delegated ] [ --limited ] [ --comment comment ] INPUT

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

_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

# 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

# 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
def domain_info
  connect()

  ARGV.each do |domainname|
    domain = nil
    begin
      log = Entry_Log.new("Domain", domainname)
      puts "\n\n", Domain.load(domainname), "\n"
    rescue Exception => e
      log.warn "  WARNING: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
      log.finalize("\n")
    end
  end
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 create --group group,...
#         [ --create_names_in group,... ] [ --use_as_name group,... ]
#         [ --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

    # [ --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

    # [ --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.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



# netdb list [ departments | locations | models | oses ] [ <regexp> ]

def list_departments
  filter = ARGV.shift
  departments = Department.list.collect { |department| department.name }
  departments = departments.select { |department| department =~ /#{filter}/i } if filter
  puts departments
end

def list_locations
  filter = ARGV.shift
  locations = Location.list.collect { |location| location.name }
  locations = locations.select { |location| location =~ /#{filter}/i } if filter
  puts locations
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
  puts models.sort { |a,b| a.downcase <=> b.downcase }
end

def list_oses
  filter = ARGV.shift
  oses = OS.list.collect { |os| os.name }
  oses = oses.select { |os| os =~ /#{filter}/i } if filter
  puts oses
end

# netdb list [ privileges | records ] [ <regexp> ] (undocumented)

def list_privileges
  filter = ARGV.shift
  privs = Privilege.list.collect { |priv| priv.display_name + " (" + priv.name + ")" }
  privs = privs.select { |priv| priv =~ /#{filter}/i } if filter
  puts privs.sort
end

def list_records
  list_privileges()
end

# netdb list 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
  puts dhcp_options.collect { |dhcp_option| dhcp_option.name }
end

# netdb list 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
  puts groups
end

# netdb list [ node_types | states ] [ all ]

def list_node_types
  all = ARGV.shift
  types = all == "all" ? Node_Type.list(true) : Node_Type.list
  puts types.collect { |type| type.name }
end

def list_states
  all = ARGV.shift
  states = all == "all" ? State.list(true) : State.list
  puts states.collect { |state| state.name }
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
  return ds
end

# connect to the RMI
def _connect(service=RMI_SERVICE, server=RMI_SERVER, port=RMI_PORT)
  if $options.has_key?(:keytab)
    if service.start_with?('_')
      NetDB_Datastore.new(service, $options[:keytab], $options[:principal])
    else
      NetDB_Datastore.new(server, port.to_i, $options[:keytab], $options[:principal])
    end
  else
    if service.start_with?('_')
      NetDB_Datastore.new(service)
    else
      NetDB_Datastore.new(server, port.to_i)
    end
  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

# 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", "dev2prod")

  # 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

  if (keyword == "clone")
    # clone is the only keyword without arguments
    case
    when $options[:input]
      abort "ERRRR: '--input' option not allowed with keyword 'clone'"
    when ARGV.length > 0
      abort "ERROR: too many arguments"
    end

  elsif %w[alias ip_address 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", "delete",
                               "department", "group", "info", "record", "privilege",
                               "starting_address", "clone", "create")

  check_flag if keyword =~ /flag$/

  # 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

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

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

  check_flag if keyword =~ /flag$/

  # 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

# user 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")
  connect()
  send("list_#{keyword}")

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       ],

                        [ '--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 ],

                        [ '--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 ],
                        [ '--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       ],
                        [ '--record',           GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--starting_address', GetoptLong::REQUIRED_ARGUMENT ],

                        [ '--create_names_in',  GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--use_as_name',      GetoptLong::REQUIRED_ARGUMENT ],
                        [ '--delegated',        GetoptLong::NO_ARGUMENT       ],
                        [ '--limited',          GetoptLong::NO_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]

  unless $options.has_key?(:batch)

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

    input = ARGV.shift
    usage unless input
    record = input.downcase.match_abbrev("list","node","user","domain")
    raise "unknown or unsupported record type \"#{input}\"" unless record
    send("do_#{record}",ARGV.shift.downcase)

  else

    # batch mode - read commands from a file

    batfile = File.open($options[:batch], "r")
    require 'shellwords'
    command = ""

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

    batfile.each do |line|
      # sanatize the line
      line = line.strip.chomp.sub(/\s*#.*/,'')
      # more to come?
      if line.end_with?("\\")
        command += line.chop.rstrip + " "
        next
      end
      command += line
      # all for naught?
      next if command.empty?

      # got a command - print it and run it
      puts "\n% #{$PROGRAM_NAME} #{command}"
      ARGV.clear.concat(command.shellsplit)
      begin
        main()
      rescue Exception => e
        warn "ERROR: " + e.message.sub(/^[^:]+xception[^:]*: /,'')
        raise if $debug
      end
      command = ""
    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 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<--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 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 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 state --set> I<state>
  [ 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<--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<--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<--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<--name> I<name> ] 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<--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 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<--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<--input> I<file> | I<netid ...> ]

- or -

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

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

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

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

- or -

B<netdb --batch> I<command_file>

- 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>

- 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> 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.

Multiple commands can be run from a file 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 on NetDB I<node> and I<user> records at this time.

=head1 NODE KEYWORDS

=over

=item admin

Add and/or remove node administrators.  Administrators can be
specified by I<SUNetID> 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 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, or remove node interfaces.  Interfaces are identified by their
hardware or IP addresses.

=item ip_address

Change or remove a node IP address.  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.

=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 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 state

Change the state of a node or nodes.

=item user

Add and/or remove users of a node or nodes.  Users are specified by
I<SUNetID>.

=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 record

Change the types of records that a user or users can create, modify or delete.

=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 --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<SUNetID>.

=item --admin I<admin>, I<...>

Override the template administrator field with these administrators.
Administrators can be specified by I<SUNetID> 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 --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,...>.

=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 --name I<name>

Set the name of the interface.

=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 --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 Batch Option

=over

=item --batch I<command_file>

Take commands from the specified I<command_file>.  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.

=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.

=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://www.stanford.edu/group/networking/netdb/help/prod/index.html>)

=cut

_EOH
