iparty 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ module CLI
5
+ class Application
6
+ module Options
7
+ def default_options
8
+ {
9
+ debug: @argv.include?("--debug"),
10
+ stdin: false, # --stdin
11
+ colorize: true, # -m
12
+ summarize: true, # -a
13
+ resolve: false, # -r
14
+ action: :info, # -d
15
+ formatter: "pretty", # -f
16
+ lang: "en", # -l
17
+ only: [], # -o
18
+ except: [], # -e
19
+
20
+ # format latlong when summarizing (--no-a)
21
+ fmt_latlong: "https://www.google.com/maps?q=%f,%f",
22
+
23
+ # refresh stale mmdb files (:always, :missing, maxAge in seconds as Numeric)
24
+ mmdb_fetch_when: 14 * 24 * 60 * 60, # 14.days
25
+ }
26
+ end
27
+
28
+ def loadrc
29
+ if @rc_disabled
30
+ puts "[iparty-debug] skipping rc (disabled)" if @opts[:debug]
31
+ return
32
+ end
33
+
34
+ unless @config_file.exist? && @config_file.readable?
35
+ puts "[iparty-debug] skipping rc (inaccessible)" if @opts[:debug]
36
+ return
37
+ end
38
+
39
+ puts "[iparty-debug] eval'ing rc #{@config_file}" if @opts[:debug]
40
+ instance_eval @config_file.read(encoding: "utf-8"), @config_file.to_s
41
+ end
42
+
43
+ def require_resolv
44
+ require "resolv"
45
+ rescue LoadError
46
+ warn c("This iparty feature requires the resolv gem to be installed.", :red)
47
+ warn c("Resolution:", :yellow)
48
+ warn c(" gem install resolv", :blue)
49
+ exit 1
50
+ end
51
+
52
+ # rubocop:disable Layout/SpaceInsideParens, Metrics/AbcSize -- readability
53
+ def init_optparse
54
+ OptionParser.new do |opts|
55
+ opts.summary_width = 38
56
+ opts.banner = "Usage: iparty <IP|host...> [options]"
57
+
58
+ expression_help = [
59
+ c("** matches .*", :cyan),
60
+ c(" * matches [^.]*", :cyan),
61
+ ]
62
+
63
+ opts.separator("\n# Application options")
64
+ opts.on("-a", "--[no-]all", "full non-summarized output") {|v| @opts[:summarize] = !v }
65
+ opts.on("-f", "--format <FORMATTER>", String, "formatter (pretty|json|off) or template string [default: #{@opts[:formatter]}]") {|v| @opts[:formatter] = v }
66
+ opts.on("-l", "--language <LANG>", String, "limit output to language (or all) [default: #{@opts[:lang]}]") {|v| @opts[:lang] = v }
67
+ opts.on("-r", "--[no-]resolve", "resolve hosts and include hostnames in data (requires resolv)") {|v| @opts[:resolv] = v }
68
+ opts.on("-o", "--only key,deep.key,*country*", Array, "list of key expressions (grep on full key)") {|v| @opts[:only] += v }
69
+ opts.on("-e", "--except key,deep.key,sub*", Array, "list of key expressions (grep_v on full key)", *expression_help) {|v| @opts[:except] += v }
70
+ opts.on( "--[no-]stdin", "read from stdin (space/line separated IPs or hosts)") {|v| @opts[:stdin] = v }
71
+
72
+ opts.separator("\n# (Custom) actions")
73
+ opts.on("-d", "--dispatch ACTION", String, "Dispatch given action, you may add your own") {|v| @opts[:action] = v.to_sym }
74
+ opts.on( "--irb", "IRB repl with iparty context and helpers") { @opts[:action] = :irb }
75
+
76
+ opts.separator("\n# MMDB actions")
77
+ opts.on( "--mmdb-status", "Show mmdb file status") { exit(appinfo_mmdb_status ? 0 : 1) }
78
+ opts.on( "--mmdb-fetch", "Fetch missing mmdb-editions") { ensure_mmdb_files! }
79
+ opts.on( "--mmdb-update", "Update all mmdb-editions") { ensure_mmdb_files!(:always) }
80
+
81
+ opts.separator("\n# General options")
82
+ opts.on("-h", "--help", "Shows this help") { @opts[:action] = :help }
83
+ opts.on("-v", "--version", "Shows version and mmdb info (and config with --debug)") { @opts[:action] = :appinfo }
84
+ opts.on("-m", "--[no-]monochrome", "Don't or do colorize output") {|v| @opts[:colorize] = !v }
85
+ opts.on( "--[no-]debug", "Enable debug, raise exceptions and print config with -v") {|v| @opts[:debug] = v }
86
+ opts.on( "--no-rc", "Do not eval config.rb")
87
+ end
88
+ end
89
+ # rubocop:enable Layout/SpaceInsideParens, Metrics/AbcSize
90
+
91
+ def parse_options!
92
+ return @opts if @options_parsed
93
+
94
+ @options_parsed = true
95
+ @optparse.parse!(@argv)
96
+ require_resolv if @opts[:resolv]
97
+ @opts
98
+ rescue OptionParser::ParseError => ex
99
+ puts colorized_help_text, nil
100
+ @opts[:debug] ? raise(ex) : abort(c(ex.message, :red))
101
+ end
102
+
103
+ def colorized_help_text
104
+ @optparse.to_s.split("\n").map do |line|
105
+ if line.start_with?("Usage:")
106
+ words = line.split
107
+ [
108
+ colorize(words[0]),
109
+ colorize(words[1], :white),
110
+ colorize(words[2], :yellow),
111
+ colorize(words[3..].join(" "), :cyan),
112
+ ].join(" ")
113
+ elsif line.start_with?("#")
114
+ colorize(line, :blue)
115
+ elsif line.strip.start_with?("-")
116
+ summary_width = @optparse.summary_indent.length + @optparse.summary_width
117
+ optstr = line[...summary_width]
118
+ optdesc = line[summary_width..]
119
+ optdesc&.gsub!(/(\[default: [^\]]+\])/){ colorize(_1, :black) }
120
+
121
+ [
122
+ colorize(optstr, :cyan),
123
+ colorize(optdesc),
124
+ ].join
125
+ else
126
+ colorize(line)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "formatter"
6
+ require_relative "colorize"
7
+ require_relative "application/options"
8
+ require_relative "application/actions"
9
+ require_relative "application/appinfo"
10
+ require_relative "application/irb_context"
11
+
12
+ module IParty
13
+ module CLI
14
+ class Application
15
+ class Error < ArgumentError; end
16
+ class ActionNotFound < Application::Error; end
17
+ class UnknownFormatter < Application::Error; end
18
+
19
+ include Application::Options
20
+ include Application::Actions
21
+ include Application::Appinfo
22
+ include Colorize
23
+
24
+ class DefaultOut
25
+ def push *args
26
+ puts(args.flatten.reject{ _1 == Formatter::VOID_OUTPUT })
27
+ end
28
+ alias_method :<<, :push
29
+ end
30
+
31
+ attr_reader :opts, :config_path, :config_file, :out, :env, :argv, :argf
32
+
33
+ def initialize(env:, argv:, argf:, **opts)
34
+ @env = env
35
+ @argv = argv
36
+ @argf = argf
37
+
38
+ @config_path = Pathname.new(env.fetch("IPARTY_CFGDIR", "~/.iparty")).expand_path
39
+ @config_file = @config_path.join("config.rb")
40
+ @opts = default_options.merge(opts)
41
+ @optparse = init_optparse
42
+ @options_parsed = false
43
+ @rc_disabled = @argv.delete("--no-rc")
44
+ @out = DefaultOut.new
45
+
46
+ loadrc
47
+ yield(self) if block_given?
48
+ end
49
+
50
+ def ensure_mmdb_files! fetch_when = @opts[:mmdb_fetch_when]
51
+ IParty::MaxMind.fetch_db_files!(fetch_when, verbose: true)
52
+ end
53
+
54
+ def stdin_select?
55
+ !$stdin.wait_readable(0).nil?
56
+ end
57
+
58
+ def read_from_stdin?
59
+ @opts[:stdin] || (@argv.empty? && stdin_select?)
60
+ end
61
+
62
+ def each_line_in_argf_as_addresses prompt: $stdin.tty?, ps1: "> "
63
+ index = 0
64
+ print ps1 if prompt
65
+
66
+ @argf.each_line do |line|
67
+ raise(Interrupt) if prompt && line.chomp.match?(/^(q|quit|exit)$/)
68
+
69
+ line.split(/\s+/).each do |chunk|
70
+ addresses = IParty.expand_hostnames(chunk)
71
+ yield(addresses, index)
72
+ index += addresses.length
73
+ end
74
+
75
+ if prompt
76
+ print ps1
77
+ index = 0
78
+ end
79
+ end
80
+ end
81
+
82
+ def each_address use_argf: read_from_stdin?, &block
83
+ if use_argf
84
+ each_line_in_argf_as_addresses do |addresses|
85
+ addresses.each(&block)
86
+ end
87
+ else
88
+ IParty.expand_hostnames(@argv).each(&block)
89
+ end
90
+ end
91
+
92
+ def build_formatter fmt = @opts[:formatter], **kw
93
+ if fmt.is_a?(CLI::Formatter)
94
+ fmt
95
+ elsif fmt.is_a?(Class)
96
+ fmt.new(self, **kw)
97
+ elsif fmt_class = CLI::Formatter.find_by_id(fmt)
98
+ fmt_class.new(self, argument: fmt, **kw)
99
+ else
100
+ raise UnknownFormatter, "unknown formatter: #{fmt}"
101
+ end
102
+ end
103
+
104
+ def formatter
105
+ @_formatter ||= build_formatter(@opts[:formatter], colorize: @opts[:colorize])
106
+ end
107
+
108
+ def ip_to_data ip, colorize: false
109
+ ipp = IParty(ip)
110
+
111
+ data = {
112
+ type: ipp.type,
113
+ prefix: ipp.prefix,
114
+ address: ipp.to_s,
115
+ cidr: ipp.to_cidr,
116
+ }
117
+
118
+ # -r --resolve
119
+ data[:hostname] = Resolv.getnames(ip).join(" ") if @opts[:resolv]
120
+
121
+ # merge geo data
122
+ data.merge!(ipp.as_json)
123
+
124
+ # -l --language
125
+ replace_names_with_singular_for!(@opts[:lang].to_s, data) if @opts[:lang] && @opts[:lang].to_s != "all"
126
+
127
+ # -a --all
128
+ data = summarize(data, colorize: colorize) if @opts[:summarize]
129
+
130
+ # -e --except
131
+ # -o --only
132
+ onlyexcept_data!(data)
133
+
134
+ data
135
+ rescue StandardError => ex
136
+ @opts[:debug] ? raise(ex) : { error_class: ex.class, error: ex.message }
137
+ end
138
+
139
+ def replace_names_with_singular_for!(lang, data)
140
+ case data
141
+ when Hash
142
+ if (names = data.dig(:names)) && (name = names.dig(lang.to_sym) || names.dig(:en))
143
+ data[:name] = name
144
+ data.delete(:names)
145
+ end
146
+
147
+ data.each_value { replace_names_with_singular_for!(lang, _1) }
148
+ when Array
149
+ data.each{ replace_names_with_singular_for!(lang, _1) }
150
+ end
151
+ end
152
+
153
+ def summarize data, colorize: @opts[:colorize]
154
+ with_color(colorize) do
155
+ latlong = [data.dig(:location, :latitude), data.dig(:location, :longitude)].compact
156
+
157
+ {
158
+ type: "#{data[:type]}[/#{data[:prefix]}]",
159
+ hostname: (data[:hostname] if data[:hostname] && !data[:hostname].empty?),
160
+ cidr: (data[:cidr] unless data[:cidr] == data[:address]),
161
+ network: summarize_network_detail(data),
162
+ name: data.dig(:annotations, :name),
163
+ tags: (data.dig(:annotations, :tags).join(" ") if data.dig(:annotations, :tags)&.any?),
164
+ location: summarize_location_detail(data),
165
+ time_zone: data.dig(:location, :time_zone),
166
+ latlong: (c((@opts[:fmt_latlong] || "%f, %f") % latlong, :magenta) unless latlong.empty?),
167
+ }.compact
168
+ end
169
+ end
170
+
171
+ def summarize_asn_detail data
172
+ return unless asn_number = data.dig(:autonomous_system_number)
173
+
174
+ asn_org = data.dig(:autonomous_system_organization)
175
+ asn_detail = c("AS#{asn_number} #{c(asn_org, :cyan)}")
176
+ asn_detail unless decolorize(asn_detail).empty?
177
+ end
178
+
179
+ def summarize_network_detail data
180
+ network_detail = [c(data[:network], :blue), summarize_asn_detail(data)].compact.join(c(" -- ", :black))
181
+ network_detail unless decolorize(network_detail).empty?
182
+ end
183
+
184
+ def summarize_location_detail data
185
+ continent_name = data.dig(:continent, :name) || data.dig(:continent, :names, :en)
186
+ country_name = data.dig(:country, :name) || data.dig(:country, :names, :en)
187
+ country_name ||= data.dig(:registered_country, :name) || data.dig(:registered_country, :names, :en)
188
+ city_name = data.dig(:city, :name) || data.dig(:city, :names, :en)
189
+
190
+ location_detail = [
191
+ (c(continent_name, :green) if continent_name),
192
+ (c(country_name, :yellow) if country_name),
193
+ ([c(data.dig(:postal, :code), :cyan), c(city_name, :blue)].compact.join(" ") if city_name),
194
+ ].compact.join(c(" / ", :black))
195
+
196
+ location_detail unless decolorize(location_detail).empty?
197
+ end
198
+
199
+ def onlyexcept_data! data
200
+ if @opts[:only] && matchers = create_matchers(@opts[:only])
201
+ deep_onlyexcept_data(data, matchers, keep: true)
202
+ end
203
+
204
+ if @opts[:except] && matchers = create_matchers(@opts[:except])
205
+ deep_onlyexcept_data(data, matchers, keep: false)
206
+ end
207
+
208
+ data
209
+ end
210
+
211
+ def create_matchers expressions
212
+ return if expressions.empty?
213
+
214
+ expressions.map do |exp|
215
+ /\A#{exp.gsub(/\*\*|\*/, { "**" => ".*", "*" => "[^.]*" })}\z/i
216
+ end
217
+ end
218
+
219
+ def deep_onlyexcept_data data, matchers, keep: true, keystack: []
220
+ case data
221
+ when Hash
222
+ data.delete_if {|k, v| _deep_onlyexcept_kv_match?(k, v, matchers, keep: keep, keystack: keystack) }
223
+ when Array
224
+ data.delete_if.with_index {|v, i| _deep_onlyexcept_kv_match?(i, v, matchers, keep: keep, keystack: keystack) }
225
+ end
226
+ end
227
+
228
+ def _deep_onlyexcept_kv_match? key, value, matchers, keep: true, keystack: []
229
+ fullkey = (keystack + [key]).join(".")
230
+
231
+ unless matched = matchers.any?{ fullkey.match?(_1) }
232
+ deep_onlyexcept_data(value, matchers, keep: keep, keystack: keystack + [key])
233
+ end
234
+
235
+ if keep
236
+ value.respond_to?(:each) ? !matched && value.empty? : !matched
237
+ else
238
+ matched || (value.respond_to?(:each) && value.empty?)
239
+ end
240
+ end
241
+
242
+ def dispatch action: nil
243
+ parse_options!
244
+ action ||= @opts[:action]
245
+ action_method = :"dispatch_#{action}"
246
+ raise ActionNotFound, "unknown action: #{action} (does not respond to ##{action_method})" unless respond_to?(action_method)
247
+
248
+ puts "[iparty-debug] dispatching #{action_method}" if @opts[:debug]
249
+ send(action_method)
250
+ rescue CLI::Application::Error => ex
251
+ appinfo_formatters(pad: 0) if ex.is_a?(CLI::Application::UnknownFormatter)
252
+ @opts[:debug] ? raise(ex) : abort(c(ex.message, :red))
253
+ rescue Interrupt, SystemExit => ex
254
+ raise(ex) if @opts[:debug]
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IParty
4
+ module CLI
5
+ module Colorize
6
+ class UnknownColorError < ArgumentError; end
7
+
8
+ COLORMAP = {
9
+ black: 30,
10
+ gray: 30,
11
+ red: 31,
12
+ green: 32,
13
+ yellow: 33,
14
+ blue: 34,
15
+ magenta: 35,
16
+ cyan: 36,
17
+ white: 37,
18
+ }.freeze
19
+
20
+ def colorize str, color = :yellow
21
+ ccode = COLORMAP[color.to_sym] || raise(UnknownColorError, "unknown color `#{color}'")
22
+ @opts[:colorize] ? "\e[#{ccode}m#{str}\e[0m" : str.to_s
23
+ end
24
+ alias_method :c, :colorize
25
+
26
+ def decolorize str
27
+ str.to_s.gsub(/\e\[.*?(\d)+m/, "")
28
+ end
29
+
30
+ def with_color *args
31
+ color_was = @opts[:colorize]
32
+ @opts[:colorize] = args.fetch(0, true)
33
+ yield
34
+ ensure
35
+ @opts[:colorize] = color_was
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "colorize"
4
+
5
+ module IParty
6
+ module CLI
7
+ class Formatter
8
+ VOID_OUTPUT = false
9
+
10
+ include Colorize
11
+
12
+ class << self
13
+ attr_writer :id
14
+
15
+ def id
16
+ @id || name.split("::").last
17
+ end
18
+
19
+ def descendants of: self
20
+ of.subclasses.flat_map{ [_1] + descendants(of: _1) }
21
+ end
22
+
23
+ def find_by_id input, of: self
24
+ descendants.detect do |fmt|
25
+ fmt.id === input # rubocop:disable Style/CaseEquality -- deliberate to support regex/proc also
26
+ end
27
+ end
28
+ end
29
+
30
+ def initialize(app, **opts)
31
+ @app = app
32
+ @opts = { colorize: true }.merge(opts)
33
+
34
+ setup if respond_to?(:setup)
35
+ end
36
+
37
+ def colorize?
38
+ @opts[:colorize]
39
+ end
40
+
41
+ def format_all ips, base_index: 0, **kw, &to_data
42
+ ips.map.with_index {|ip, index| format(ip, index: base_index + index, **kw, &to_data) }
43
+ end
44
+
45
+ def format ip, index: 0, **kw, &to_data
46
+ to_data.call(ip)
47
+ end
48
+
49
+ class NoOutput < Formatter
50
+ self.id = "off"
51
+
52
+ def format ip, **kw, &to_data
53
+ super # run to_data logic
54
+ VOID_OUTPUT
55
+ end
56
+ end
57
+
58
+ class ConsolePretty < Formatter
59
+ self.id = "pretty"
60
+
61
+ def setup
62
+ @opts[:align] = @app.opts[:summarize]
63
+ end
64
+
65
+ def format_all ips, base_index: 0, **kw, &to_data
66
+ ips.map.with_index do |ip, index|
67
+ [].tap do |r|
68
+ r << nil unless (base_index + index).zero?
69
+ next unless out = format(ip, index: base_index + index, **kw, &to_data)
70
+
71
+ indent_header = decolorize(out[0...(out.index(":") || 0)]).length - 1 if @opts[:align]
72
+ r << c("#{"".rjust(indent_header || 0, "=")}=> #{ip}", :red)
73
+
74
+ r << out
75
+ end
76
+ end
77
+ end
78
+
79
+ def format ip, index: 0, **kw, &to_data
80
+ out = to_indented_strings(to_data.call(ip), **kw).join("\n")
81
+ out = VOID_OUTPUT if out.empty?
82
+ out
83
+ end
84
+
85
+ def to_indented_strings value, key: nil, indent: -1, maxkeylength: 0, buf: []
86
+ indent_spaces = "".rjust(2 * indent, " ")
87
+
88
+ if value.is_a?(Hash)
89
+ buf << "#{indent_spaces}#{c(key.is_a?(Numeric) ? "[#{key}]" : "#{key}:")}" if key
90
+ maxkeylength = value.keys.map{ _1.to_s.length }.max if @opts[:align]
91
+ value.each do |k, v|
92
+ to_indented_strings(v, buf: buf, maxkeylength: maxkeylength, key: k, indent: indent + 1)
93
+ end
94
+ elsif value.is_a?(Array)
95
+ buf << "#{indent_spaces}#{c(key.is_a?(Numeric) ? "[#{key}]" : "#{key}:")}" if key
96
+ value.each_with_index do |v, i|
97
+ to_indented_strings(v, buf: buf, maxkeylength: maxkeylength, key: i, indent: indent + 1)
98
+ end
99
+ elsif key
100
+ buf << "#{indent_spaces}#{c(key.to_s.rjust(maxkeylength))}: #{c(value, :blue)}"
101
+ else
102
+ buf << "#{indent_spaces}#{c(value, :blue)}"
103
+ end
104
+
105
+ buf
106
+ end
107
+ end
108
+
109
+ class JsonFormatter < Formatter
110
+ self.id = "json"
111
+
112
+ def setup
113
+ @opts[:colorize] = false
114
+
115
+ require "json"
116
+ rescue LoadError
117
+ warn c("The iparty JSON output formatter requires the json gem to be installed.", :red)
118
+ warn c("Resolution:", :yellow)
119
+ warn c(" gem install json", :blue)
120
+ exit 1
121
+ end
122
+
123
+ def format_all ips, **kw, &to_data
124
+ data = ips.to_h {|ip| [ip, to_data.call(ip)] }
125
+ [JSON.pretty_generate(data)]
126
+ end
127
+
128
+ def format ip, **kw, &to_data
129
+ JSON.pretty_generate to_data.call(ip)
130
+ end
131
+ end
132
+
133
+ class StringFormatter < Formatter
134
+ self.id = /%{/
135
+
136
+ def format_all ips, base_index: 0, **kw, &to_data
137
+ ips.map.with_index {|ip, index| format(ip, index: base_index + index, **kw, &to_data) }
138
+ end
139
+
140
+ def format ip, index: 0, **kw, &to_data
141
+ data = to_data.call(ip)
142
+ locals = { 1 => ip }
143
+ fwd_hash = Hash.new do |_, key|
144
+ dig_loose_value(data, key, locals) || (@app.opts[:debug] ? nil : "")
145
+ end
146
+
147
+ out = @opts[:argument] % fwd_hash
148
+ out.empty? ? VOID_OUTPUT : out
149
+ end
150
+
151
+ def dig_loose_value data, key, locals = {}
152
+ key_part, def_val = key.to_s.split("|", 2)
153
+ chunks = key_part.split(".").map{ _1.match?(/\A-?\d+\z/) ? _1.to_i : _1.to_sym }
154
+
155
+ return locals.fetch(chunks[0]) if chunks.length == 1 && locals.key?(chunks[0])
156
+
157
+ dig_loose_value_in(data, chunks) || def_val
158
+ end
159
+
160
+ def dig_loose_value_in ptr, chunks
161
+ return ptr if chunks.empty?
162
+ return ptr unless ptr.is_a?(Hash) || ptr.is_a?(Array)
163
+
164
+ dig_loose_value_in(ptr[chunks[0]], chunks[1..])
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end