diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..aee25bc9e96c9d7d4152c0bd80eaeb8f5cc42011 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,14 @@ +stages: + - build + +build-segment-viewer: + stage: build + image: docker:latest + services: + - docker:dind + script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + - docker build --pull -t "$CI_REGISTRY_IMAGE/segment_viewer" . + - docker push "$CI_REGISTRY_IMAGE/segment_viewer" + only: + - master diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..f5d6b6c242942a52d44e597e792930ae529d69e3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM ruby:2.5.3-slim-stretch + +LABEL Description="segment_viewer" + +RUN apt-get update \ + && apt-get install -y build-essential \ + && rm -rf /var/lib/apt/lists/* + + +RUN useradd -ms /bin/bash segment_viewer +USER segment_viewer +WORKDIR /home/segment_viewer +RUN chmod 755 /home/segment_viewer + +COPY Gemfile . +COPY Gemfile.lock . + +RUN bundle install +RUN mkdir /home/segment_viewer/.segment_viewer + +COPY segment_viewer.rb . + +ENTRYPOINT /home/segment_viewer/segment_viewer.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000000000000000000000000000000000..fa91cb98810175b5bba7d701e77f22f185198909 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source ENV['GEM_SOURCE'] || 'https://rubygems.org' + +gem 'pmap', '~> 1.1', '>= 1.1.1' +gem 'curses', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000000000000000000000000000000000000..ce9749502bd62e60db7b74457e3951f062b0e918 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,15 @@ +GEM + remote: https://rubygems.org/ + specs: + curses (1.2.5) + pmap (1.1.1) + +PLATFORMS + ruby + +DEPENDENCIES + curses (~> 1.2) + pmap (~> 1.1, >= 1.1.1) + +BUNDLED WITH + 1.16.2 diff --git a/README.md b/README.md index bcb3144880ee3b0e3832956ad50cea0ac6afb87e..a6ccde2ee03d2945caa358dcc759e9192cbce6b1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ # segment_viewer +DNS PTR resolver for segments defined in `networks.yml`. + +## Usage +``` +docker run -it --rm \ + -v <ABSOLUTE_PATH>/networks.yml:/home/segment_viewer/home/segment_viewer/.segment_viewer/.networks.yml \ + -v segment_viewer:/home/segment_viewer/.segment_viewer:Z \ + test +``` diff --git a/networks.yml b/networks.yml new file mode 100644 index 0000000000000000000000000000000000000000..8be4fa21933d219ac749ac2bad9219e846e45d7b --- /dev/null +++ b/networks.yml @@ -0,0 +1,47 @@ +--- +############################################################################## +## Expected format: +## +## firewall_groups: +## - name: String +## in: +## - network: CIDR +## policy: allow | deny +## protocols: [String, String, ...] # or `~` for any +## ports: [Integer, Integer, ...] # or `~` for any +## out: +## - network: CIDR +## policy: allow | deny +## protocols: [String, String, ...] # or `~` for any +## ports: [Integer, Integer, ...] # or `~` for any +## +## trunks: +## - name: String +## vlans: [Integer, Integer, ...] +## env: [String, String, ...] +## +## networks: +## - name: String +## network: CIDR +## label: String +## vlan: Integer +## gw: IPv4 +## nat: IPv4 +## dhcp: IPv4 +## trunk: String +## firewall_group: String +## env: [String, String, ...] +## +############################################################################## + +networks: +- name: Example + network: 10.0.1.0/28 + label: Example-label + vlan: ~ + gw: 10.0.1.1 + nat: ~ + dhcp: ~ + trunk: ~ + firewall_group: ~ + env: Example-environment diff --git a/segment_viewer.rb b/segment_viewer.rb new file mode 100755 index 0000000000000000000000000000000000000000..a424ecadf6fda4ed81905687121bef1d3ae36cda --- /dev/null +++ b/segment_viewer.rb @@ -0,0 +1,314 @@ +#!/usr/bin/env ruby +require 'curses' +require 'resolv' +require 'ipaddr' +require 'yaml' +require 'pmap' +require 'fileutils' + +NS='ns.muni.cz'.freeze +CONF_DIR="#{ENV['HOME']}/.segment_viewer".freeze +DNS_CACHE="#{CONF_DIR}/.cache.yml".freeze +NETWORKS_FILE="#{CONF_DIR}/.networks.yml".freeze +RESOLV_TIMEOUT=5.freeze +THREAD_COUNT=128.freeze + +class ItemStore + def initialize(items, window_size ,first = 0) + @items = items + @item_selected_index = 0 + @first_visible = first + @window_size = window_size > 0 ? window_size : 0 + end + + def move_up + @item_selected_index -= 1 unless @item_selected_index.zero? + scroll_up if @item_selected_index < @first_visible + end + + def move_down + @item_selected_index += 1 unless @item_selected_index >= last_item_index + scroll_down if @item_selected_index > last_visible + end + + def visible_items + @items[@first_visible, @window_size] + end + + def selected_visible_item_index + raise "Selected item index (#{@item_selected_index}) under visible windows (first: #{@first_visible})" if @item_selected_index < @first_visible + raise "Selected item index (#{@item_selected_index}) above visible windows (last: #{last_visible})" if @item_selected_index > last_item_index + (@item_selected_index - @first_visible) + end + + def selected_item + @items[@item_selected_index] + end + + def resize(window_size) + raise "Window size is 0 :-(" if window_size < 0 + @window_size = window_size + if @item_selected_index < @first_visible + @first_visible = @item_selected_index + elsif @item_selected_index > last_visible + @first_visible += 1 while @item_selected_index != last_visible + elsif last_visible > last_item_index + scroll_up until @first_visible.zero? or last_item_index == last_visible + end + end + + private + + def last_visible + @first_visible + @window_size - 1 + end + + def last_item_index + @items.size - 1 + end + + def scroll_up + @first_visible -= 1 unless @first_visible.zero? + end + + def scroll_down + @first_visible += 1 unless @item_selected_index > last_item_index + end +end + + +class Panel + def initialize(side) + @height = Curses.lines + @width = Curses.cols / 2 + @top = 0 + @left = side == 'left' ? 0 : Curses.cols / 2 + @win = Curses::Window.new(@height, @width, @top ,@left) + @win.box('|', '-') + @win.refresh + @active = false + end + + def activate + @active = true + end + + def deactivate + @active = false + end + + def clear + @win.box('|', '-') + (@height - 2).times do |index| + @win.setpos(index + 1, 1) + @win.addstr ' ' * (@width - 2) + end + end + + def draw_menu + clear + @item_store.visible_items.each_with_index do |item, index| + @win.setpos(index + 1, 2) + @win.attrset((@active and @item_store.selected_visible_item_index == index) ? Curses::A_STANDOUT : Curses::A_NORMAL) + @win.addstr item.ljust(@width - 4, ' ') + end + @win.attrset(Curses::A_NORMAL) + @win.refresh + end + + def draw_message(msg) + @item_store = ItemStore.new(msg, @height - 2) + deactivate + draw_menu + end + + def load_items(data) + @item_store = ItemStore.new(data, @height - 2) + draw_menu + end + + def resize + @height = Curses.lines + @width = Curses.cols / 2 + @item_store.resize (@height - 2) + draw_menu + end + + def selected_item + @item_store.selected_item + end + + def select_item + @win.keypad = true + ch = @win.getch + case ch + when Curses::KEY_UP then @item_store.move_up + when Curses::KEY_DOWN then @item_store.move_down + end + draw_menu + @win.keypad = false + return ch + end +end + +class Resolver + def initialize(cache = DNS_CACHE) + @cache = cache + @resolver = Resolv::DNS.new(:nameserver => NS) + @resolver.timeouts = RESOLV_TIMEOUT + if File.file?(@cache) + @dns = YAML.load_file(@cache) + else + FileUtils.mkdir_p(CONF_DIR) unless File.directory?(CONF_DIR) + @dns = {} + end + end + + def refresh_range(cidr) + resolve_range(cidr, true) + end + + def resolve_range_l(cidr, refresh = false) + resolve_range(cidr, refresh) + @dns[cidr].map { |record| "#{record['ip'].to_s.ljust(16, ' ')} #{record['names'].join(' ')}" } + end + + def resolve_range_s(cidr, refresh = false) + resolve_range(cidr, refresh) + dns_short = @dns[cidr].chunk { |record| record['names'] }.map do |el| + if el.first.none? && el.last.size > 2 + [false, [el.last.first, { 'ip' => ' ...', 'names' => [] }, el.last.last]] + else + el + end + end.flat_map { |el| el.last } + dns_short.map { |record| "#{record['ip'].to_s.ljust(16, ' ')} #{record['names'].join(' ')}" } + end + + private + + def resolve_range(cidr, refresh = false) + if refresh or not @dns.key? cidr + @dns[cidr] = IPAddr.new(cidr).to_range.pmap(THREAD_COUNT) do |ip| + { + 'ip' => ip.to_s, + 'names' => @resolver.getnames(ip.to_s).map{ |dns| dns.to_s } + } + end + File.write(@cache, @dns.to_yaml) + end + end +end + +def network_details (networks, name) + networks.select{|net| net['name'] == name }[0].map{|k,v| "#{k}: #{v}"} +end + +def resolve_dns (panel, resolver, networks, name, force = false, long = false) + cidr = networks.select{|net| net['name'] == name }[0]['network'] + panel.draw_message ["Resolving dns for #{cidr}"] + panel.activate + if long + panel.load_items resolver.resolve_range_l(cidr, force) + else + panel.load_items resolver.resolve_range_s(cidr, force) + end +end + +def resolve_dns_all (panel, resolver, networks) + networks.each do |net| + cidr = net['network'] + panel.draw_message ["Resolving dns for all networks. Current: #{cidr}"] + resolver.refresh_range cidr + end + panel.draw_message ["Resolving dns for all networks finished."] +end + +def show_help + if not defined? @helppane + @helppane = Panel.new('right') + help = ["Segment_viewer help, YAY!"] + help << "" + help << "* use arrows to navigate/select segments" + help << "* use 'l' to show all IPs" + help << "* use 'r' to reload dns data for current or all networks" + help << " app uses cache, so refreshing is encouraged" + help << "* use 'h' to show this help" + help << "* use 'q' to exit" + help << "" + help << "WARNING: private segments are resolved" + help << " only inside MU network!" + @helppane.draw_message help + else + @helppane.resize + end +end + +Curses.init_screen +Curses.start_color +Curses.noecho +Curses.curs_set 0 +begin + lpane = Panel.new('left') + networks = YAML::load_file(File.join(__dir__, NETWORKS_FILE))['networks'].select { |net| !net['network'].nil? } + network_names = networks.map{ |net| net['name'] }.sort + menu = networks.map { |net| net['env'] }.flatten.uniq + resolver = Resolver.new() + lpane.activate + lpane.load_items menu + rpane = Panel.new('right') + show_help + active_panel = lpane + mode = 'main_menu' + while true + + key_pressed = active_panel.select_item + case key_pressed + when 'q' then exit + when 'h' then show_help + when Curses::KEY_RESIZE + lpane.resize + rpane.resize + end + + if mode == 'main_menu' + case key_pressed + when Curses::KEY_RIGHT + network_subset = networks.select{ |net| net['env'].include? lpane.selected_item } + lpane.load_items network_subset.map{ |net| net['name'] }.sort + rpane.load_items network_details(networks, lpane.selected_item) + mode = 'segment' + when 'r' then resolve_dns_all(rpane, resolver, networks) + + end + elsif mode == 'segment' + if active_panel == lpane + case key_pressed + when Curses::KEY_UP then rpane.load_items network_details(networks, lpane.selected_item) + when Curses::KEY_DOWN then rpane.load_items network_details(networks, lpane.selected_item) + when Curses::KEY_LEFT + lpane.load_items menu + mode = 'main_menu' + when Curses::KEY_RIGHT + active_panel = rpane + resolve_dns(rpane, resolver, networks, lpane.selected_item) + when 'r' then resolve_dns_all(rpane, resolver, networks) + end + + elsif active_panel == rpane + case key_pressed + when 'r' then resolve_dns(rpane, resolver, networks, lpane.selected_item, true) + when 'l' then resolve_dns(rpane, resolver, networks, lpane.selected_item, false, true) + when Curses::KEY_LEFT + active_panel = lpane + rpane.deactivate + rpane.load_items network_details(networks, lpane.selected_item) + end + end + end + end +rescue => ex + Curses.close_screen + puts ex + puts ex.backtrace +end