Skip to content
Chris Lasell edited this page Nov 18, 2017 · 2 revisions
#!/usr/bin/ruby

# == Synopsis
#   Add, remove, or change the Network Segments in the JSS based on data from an input file
#   in CSV, tab, or other delimited format.
#
# == Usage
#   subnet-update [-t | -d delimiter] [-h] file
#
#
# == Author
#   Chris Lasell <chrisl@pixar.com>
#
# == Copyright
#   Copyright (c) 2014 Pixar Animation Studios
##############################

# Libraries
##############################
require 'ruby-jss'
require 'getoptlong'
require 'English'

# The app object
##############################
class App

  # Constants
  ##############################

  PROG_NAME = File.basename($PROGRAM_NAME)

  USAGE = "Usage: #{PROG_NAME} [options] [--help] /path/to/file".freeze

  POTENTIAL_COLUMNS = %i(name starting ending cidr mask).freeze

  DEFAULT_CACHE_FILE = Pathname.new('~/.last_subnet_update').expand_path

  DEFAULT_DELIMITER = "\t".freeze

  DEFAULT_COLUMNS = [:name, :starting, :ending].freeze

  DEFAULT_MANUAL_PREFIX = 'Manual-'.freeze

  # define the cli opts
  CLI_OPTS = GetoptLong.new(
    ['--help', '-H', GetoptLong::NO_ARGUMENT],
    ['--delimiter', '--delim', '-d', GetoptLong::REQUIRED_ARGUMENT],
    ['--header', '-h', GetoptLong::NO_ARGUMENT],
    ['--columns', '-c', GetoptLong::OPTIONAL_ARGUMENT],
    ['--manual-prefix', '-m', GetoptLong::OPTIONAL_ARGUMENT],
    ['--cache', GetoptLong::REQUIRED_ARGUMENT],
    ['--debug', GetoptLong::NO_ARGUMENT],
    ['--server', '-S', GetoptLong::OPTIONAL_ARGUMENT],
    ['--port', '-P', GetoptLong::OPTIONAL_ARGUMENT],
    ['--user', '-U', GetoptLong::OPTIONAL_ARGUMENT],
    ['--no-verify-cert', '-V', GetoptLong::NO_ARGUMENT],
    ['--timeout', '-T', GetoptLong::OPTIONAL_ARGUMENT],
    ['--no-op', '-N', GetoptLong::NO_ARGUMENT]
  )

  attr_reader :debug

  def initialize(_args)
    @getpass = $stdin.tty? ? :prompt : :stdin
    set_defaults
    parse_cli
    check_opts
  end # init

  def set_defaults
    @debug = false
    @delim = DEFAULT_DELIMITER
    @header = false
    @columns = DEFAULT_COLUMNS
    @cache_file = DEFAULT_CACHE_FILE
    @manual_prefix = DEFAULT_MANUAL_PREFIX
    @user = JSS::CONFIG.api_username
    @server = JSS::CONFIG.api_server_name
  end

  def parse_cli
    # parse the cli opts
    CLI_OPTS.each do |opt, arg|
      case opt
      when '--help' then show_help
      when '--delimiter' then @delim = arg
      when '--header' then @header = true
      when '--columns' then @columns = arg.split(',').map(&:to_sym)
      when '--manual-prefix' then @manual_prefix = arg
      when '--cache' then @cache_file = Pathname.new arg
      when '--debug' then @debug = true
      when '--server' then @server = arg
      when '--port' then @port = arg
      when '--user'then @user = arg
      when '--no-verify-cert' then @verify_cert = false
      when '--timeout' then @timeout = arg
      when '--no-op' then @noop = true
      end # case
    end # each opt arg
    @columns = nil if @columns && @columns.empty?
    @file = Pathname.new ARGV.shift.to_s
  end # parse_cli

  def check_opts
    raise JSS::MissingDataError, 'No JSS Username provided or found in the JSS gem config.' unless @user
    raise JSS::MissingDataError, 'No JSS Server provided or found in the JSS gem config.' unless @server
    raise ArgumentError, "No input file specified.\n#{USAGE}" unless @file
    raise  "Input file doesn't exist or is not readable: #{@file}" unless @file.readable?
  end

  # Go!
  def run
    unless data_file_changed?
      puts "File hasn't changed since last time, no changes to make!"
      return
    end

    connect_to_jss

    @parsed_data = parse_file

    update_network_segments
    cache_latest_data
  end # run

  def connect_to_jss
    JSS.api.connect(
      server: @server,
      port: @port,
      verify_cert: @verify_cert,
      user: @user,
      pw: @getpass,
      stdin_line: 1,
      timeout: @timeout
    )
  end

  def show_help
    puts <<-FULLHELP
Update the JSS Network Segments from a delimited file of subnet information.
CAUTION: This script can delete Network Segments from your JSS.
  See the --no-op option
#{USAGE}

Options:
 -d, --delimiter        - The field delimiter in the file, defaults to tab.
 -c, --columns [col1,col2,col3]
                        - The column order in file, must include 'name', 'starting',
                            and either 'ending', 'mask', or 'cidr'
 -h, --header           - The first line of the file is a header line,
                            defining the columns
 -m, --manual-prefix    - Network Segment names in the file and the JSS with this
                            prefix are ignored. Defaults to 'Manual-'
 --cache /path/..       - Where read/save the input data for comparison between runs.
                            Defaults to ~/.last_subnet_update
 -S, --server srvr      - specify the JSS API server name
 -P, --port portnum     - specify the JSS API port
 -U, --user username    - specify the JSS API user
 -V, --no-verify-cert   - Allow self-signed, unverified SSL certificate
 -T, --timeout secs     - specify the JSS API timeout
 -N, --no-op            - Don't make any changes in the JSS, just report what would
                          have be changed.
 -H, --help             - show this help
 --debug                - show the ruby backtrace when errors occur

This program parses the input file line by line (possibly accounting for a header line).
Each line defines the name and IP-range of a network segment.

- If a segment in the file doesn't exist in the JSS, it is created in the JSS.
- If a segment's range is different in the file, it is updated in the JSS.
- If a segment in the JSS doesn't exist in the file, it is deleted from the JSS.

Any network segments with names starting with the given --manual-prefix are ignored.
The default manual-prefix is 'Manual-'  so, e.g. segments named 'Manual-isolated'
and 'Manual-special-servers' in the JSS won't be touched.

Input File:
  - The file must contain three columns, separated by the --delimiter,
    with these names, in any order:
    - 'name'  (the network segment name)
    - 'starting' (the starting IP address of the network segment)
    - ONE of:
      - 'ending' (the ending IP address of the network segment)
      - 'cidr'  (the network range of the segment as a CIDR bitmask, e.g. '24')
      - 'mask'  (the network range of the segment as an IP mask, e.g. '255.255.255.0')
Notes:
 - The --columns option is a comma-separted list of the three
   column names above indicating the column-order in the file.

 - If --columns are not provided, and --header is specified, the first line
  is assumed to contain the column names, separated by the delimiter

 - If --header is provided with --columns, the first line of the file is ignored.

 - The raw data from the file is cached and compared to the input file at
   the next run. If the data is identical, no changes are made.

 - If no API connection settings are provided, they will be read from
   /etc/ruby-jss.conf and ~/.ruby-jss.conf. See the ruby-jss docs for details.

 - The password for the connection will be read from STDIN or prompted if needed

    FULLHELP
    exit 0
  end

  # parse the incoming data file into an Hash of Hashes,
  # Top level keys are the NetSeg names,
  # Subhashes have keys :starting, and :ending
  # Exclude any with names starting with @manual_prefix
  #
  # @return [Hash<Hash>] The lines of the file, as hashes
  #
  def parse_file
    puts 'Parsing the data file'
    # split the data into an array by newline/return chars.
    # this means files saved by excel or windows will work.
    lines = @raw_data.split(/[\n\r]+/)

    # remove the first line if its a header, and parse it into the columns
    # if needed
    if @header
      header = lines.shift
      @columns ||= header.split(/\s*#{@delim}\s*/).map(&:to_sym)
    end

    check_columns

    parsed_data = {}
    lines.each do |line|
      parsed_line = parse_a_data_line line
      next unless parsed_line
      name = parsed_line.delete :name
      parsed_data[name] = parsed_line
    end
    parsed_names = parsed_data.keys
    jss_names = JSS::NetworkSegment.all_names.reject { |jss_name| jss_name.start_with? @manual_prefix }
    @segments_to_add = parsed_names - jss_names
    @segments_to_delete = jss_names - parsed_names
    @segments_to_check_for_changes = parsed_names - @segments_to_add - @segments_to_delete
    parsed_data
  end # parse_file

  def check_columns
    raise "Columns must include 'name' and 'starting'" unless \
      @columns.include?(:name) && \
      @columns.include?(:starting)
    raise "Columns must include either 'ending', 'cidr', or 'mask'" unless \
      @columns.include?(:ending) || \
      @columns.include?(:cidr) || \
      @columns.include?(:mask)
    @use_cidr = (@columns.include?(:cidr) || @columns.include?(:mask))
  end

  def parse_a_data_line(line)
    parts = line.split(@delim).map(&:strip)
    name = parts[@columns.index(:name)]
    starting = parts[@columns.index(:starting)]
    ending = parts[@columns.index(:ending)]
    unless name && starting && ending
      puts "Skipping invalid line: #{line}"
      return nil
    end
    if name.start_with? @manual_prefix
      puts "Ignoring line with manual_prefix: #{line}"
      return nil
    end
    { name: name, starting: starting, ending: ending }
  end

  def data_file_changed?
    # read in the file
    @raw_data = @file.read
    return true unless @cache_file.exist?
    @raw_data != @cache_file.read
  end

  def cache_latest_data
    return if @noop
    @cache_file.jss_save @raw_data
  end

  def update_network_segments
    puts 'Applying changes'
    add_segments
    delete_segments
    update_segments
    puts 'Done!'
  end # update_network_segments

  def add_segments
    @segments_to_add.each do |seg|
      seg_data = @parsed_data[seg]
      if @noop
        connector = @use_cidr ? '/' : '->'
        puts "Without --no-op this would: Add segment named '#{seg}', #{seg_data[:starting]}#{connector}#{seg_data[:ending]}"
        next
      end # if noop

      ender = @use_cidr ? :cidr : :ending_address
      new_seg = JSS::NetworkSegment.new(
        :id => :new,
        :name => seg,
        :starting_address => seg_data[:starting],
        ender => seg_data[:ending]
      )
      new_seg.create
      puts "Added Network Segment '#{new_seg.name}' to the JSS"
    end #  @segments_to_add.each do |seg|
  end # add_segments

  def delete_segments
    @segments_to_delete.each do |seg|
      if @noop
        puts "Without --no-op this would: Delete segment named '#{seg}',"
        next
      end # if noop
      JSS::NetworkSegment.new(name: seg).delete
      puts "Deleted Network Segment '#{seg}' from the JSS"
    end #  @segments_to_delete.each do |seg|
  end # delete_segments

  def update_segments
    @segments_to_check_for_changes.each do |seg|
      seg_data = @parsed_data[seg]
      data_start = IPAddr.new(seg_data[:starting])
      data_end = if @use_cidr
                   IPAddr.new("#{seg_data[:starting]}/#{seg_data[:ending]}").to_range.end.mask 32
                 else
                   IPAddr.new(seg_data[:ending])
                 end

      this_seg = JSS::NetworkSegment.new name: seg
      data_range = data_start..data_end
      next if this_seg.range == data_range

      if @noop
        connector = @use_cidr ? '/' : '->'
        puts "Without --no-op this would: Update segment named '#{seg}', #{seg_data[:starting]}#{connector}#{seg_data[:ending]}"
        next
      end # if noop

      this_seg.set_ip_range starting_address: data_start, ending_address: data_end
      this_seg.save
      puts "Updated Network Segment '#{seg}' to range #{data_start} - #{data_end}"
    end # @segments_to_check_for_changes.each do |seg|
  end # update segments

end # app

##############################
# create the app and go
begin
  debug = ARGV.include? '--debug'
  app = App.new(ARGV)
  app.run
rescue
  # handle exceptions not handled elsewhere
  puts "An error occurred: #{$ERROR_INFO}"
  puts 'Backtrace:' if debug
  puts $ERROR_POSITION if debug
end

Copyright 2017 Pixar

Licensed under the Apache License, Version 2.0 (the "Apache License")
with the following modification; you may not use this file except in
compliance with the Apache License and the following modification to it:
Section 6. Trademarks. is deleted and replaced with:

6. Trademarks. This License does not grant permission to use the trade
   names, trademarks, service marks, or product names of the Licensor
   and its affiliates, except as required to comply with Section 4(c) of
   the License and to reproduce the content of the NOTICE file.

You may obtain a copy of the Apache License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the Apache License with the above modification is
distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the Apache License for the specific
language governing permissions and limitations under the Apache License.
Clone this wiki locally