network-infra-utility 0.5.0 → 0.8.3
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.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/CHANGELOG.md +31 -0
- data/GUIDE.md +201 -0
- data/README.md +1 -6
- data/Rakefile +1 -1
- data/bin/dns-query +4 -2
- data/bin/gen-get +144 -0
- data/bin/geo-api +40 -1
- data/bin/geo-get +18 -36
- data/bin/geo-update +429 -0
- data/bin/ngeo-get +253 -0
- data/bin/packbit +227 -0
- data/bin/probe +134 -0
- data/document/reference/DNS/346/212/200/346/234/257/346/226/207/346/241/243.md +912 -0
- data/network-infra-utility.gemspec +6 -4
- data/network.rb +19 -1
- data/service/geodb/GeoAPI.md +64 -0
- data/service/geodb/api.rb +77 -19
- data/service/geoquery/GeoQuery.md +309 -0
- data/service/geoquery/cache.rb +78 -0
- data/service/geoquery/geo_cache.rb +233 -0
- data/service/geoquery/geoquery.rb +327 -0
- data/service/geoquery/local.rb +151 -0
- data/service/geoquery/merge.rb +52 -0
- data/service/geoquery/normalize.rb +102 -0
- data/service/geoquery/online.rb +136 -0
- data/service/simlab/SimLab.md +140 -0
- data/service/simlab/bin/simctl +152 -0
- data/service/simlab/lib/simlab.rb +124 -0
- data/service/simlab/lib/simlab_engine.rb +146 -0
- data/service/simlab/lib/simlab_observer.rb +176 -0
- data/service/simlab/lib/simlab_router.rb +330 -0
- data/service/simlab/lib/simlab_scenario.rb +200 -0
- data/service/simlab/lib/simlab_switch.rb +182 -0
- data/service/simlab/lib/simlab_topology.rb +196 -0
- data/service/simlab/lib/simlab_traffic.rb +167 -0
- data/service/ssh/README.md +50 -37
- data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +14 -1
- data/support/basic/packet.rb +167 -0
- data/support/routing/bgp.rb +279 -0
- data/support/routing/lpm_trie.rb +145 -0
- data/support/routing/ospf.rb +243 -0
- data/support/routing/prefix.rb +155 -0
- data/support/routing/rib.rb +167 -0
- data/support/routing/rip.rb +223 -0
- data/support/routing/static_route.rb +102 -0
- data/support/switching/frame_forward.rb +87 -0
- data/support/switching/mac_table.rb +127 -0
- data/support/switching/stp.rb +230 -0
- data/support/switching/vlan.rb +155 -0
- data/tool/packbit/README.md +136 -0
- data/tool/packbit/config/five-tuple.yml +25 -0
- data/tool/packbit/config/flow.yml +17 -0
- data/tool/packbit/config/packbit.yml +41 -0
- data/tool/packbit/erl/packbit.erl +588 -0
- data/tool/probe/README.md +88 -0
- data/tool/probe/lib/probe.rb +54 -0
- data/tool/probe/lib/probe_base.rb +98 -0
- data/tool/probe/lib/probe_dns.rb +64 -0
- data/tool/probe/lib/probe_icmp.rb +71 -0
- data/tool/probe/lib/probe_ntp.rb +51 -0
- data/tool/probe/lib/probe_radius.rb +58 -0
- data/tool/probe/lib/probe_registry.rb +44 -0
- data/tool/probe/lib/probe_result.rb +75 -0
- data/tool/probe/lib/probe_snmp.rb +114 -0
- data/tool/probe/lib/probe_tcp.rb +82 -0
- data/tool/probe/lib/probe_version.rb +7 -0
- data/version.rb +1 -1
- metadata +78 -2
data/bin/geo-update
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# coding: utf-8
|
|
4
|
+
#
|
|
5
|
+
# geo-update — network-infra-utility 的 GeoLite2 地址库下载更新工具
|
|
6
|
+
#
|
|
7
|
+
# 从 MaxMind 官方下载 GeoLite2 CSV 数据库(ASN / City / Country),解压到本地。
|
|
8
|
+
# 可选链路: 下载 → 解压 → geo-load 转换 JSON → 重启 geo-api 服务。
|
|
9
|
+
#
|
|
10
|
+
# 默认行为:
|
|
11
|
+
# - 在命令行当前目录 (CWD) 寻找 GeoIP.conf 配置文件读取 LicenseKey
|
|
12
|
+
# - 下载的 zip 压缩包和解压后的 CSV 放置在当前目录
|
|
13
|
+
# - 仅下载+解压, 不转换不重启 (除非指定 --convert / --restart)
|
|
14
|
+
#
|
|
15
|
+
# 用法:
|
|
16
|
+
# geo-update -T # 生成 GeoIP.conf 模板到当前目录
|
|
17
|
+
# geo-update # 下载+解压 (读当前目录 GeoIP.conf)
|
|
18
|
+
# geo-update --convert # 下载+解压+转换 JSON
|
|
19
|
+
# geo-update --convert --restart # 下载+解压+转换+重启 geo-api
|
|
20
|
+
# geo-update --asn-only # 仅下载 ASN
|
|
21
|
+
# geo-update --output-dir=D:\geodata # 下载到指定目录
|
|
22
|
+
# geo-update --dry-run # 演练, 只打印动作
|
|
23
|
+
#
|
|
24
|
+
# 参数规则:
|
|
25
|
+
# geo-update [options]
|
|
26
|
+
#
|
|
27
|
+
# 选项:
|
|
28
|
+
# -T, --template 在当前目录生成 GeoIP.conf 模板
|
|
29
|
+
# --config=FILE 指定配置文件路径 (默认: ./GeoIP.conf)
|
|
30
|
+
# --license-key=KEY 指定 MaxMind 许可证密钥 (覆盖配置文件)
|
|
31
|
+
# --output-dir=DIR 下载/解压目录 (默认: 当前目录)
|
|
32
|
+
# --convert 下载后调用 geo-load 转换 CSV → JSON
|
|
33
|
+
# --restart 转换后重启 geo-api 服务 (隐含 --convert)
|
|
34
|
+
# --local-dir=DIR 使用本地已有 CSV 目录, 跳过下载
|
|
35
|
+
# --asn-only 仅处理 ASN
|
|
36
|
+
# --city-only 仅处理 City
|
|
37
|
+
# --country-only 仅处理 Country
|
|
38
|
+
# --dry-run 演练: 只打印将执行的动作
|
|
39
|
+
# -h, --help 显示帮助
|
|
40
|
+
# -v, --version 显示版本
|
|
41
|
+
|
|
42
|
+
require "net/http"
|
|
43
|
+
require "uri"
|
|
44
|
+
require "fileutils"
|
|
45
|
+
require "optparse"
|
|
46
|
+
require "time"
|
|
47
|
+
|
|
48
|
+
require_relative "../version"
|
|
49
|
+
|
|
50
|
+
# ============================================================
|
|
51
|
+
# 静态设定数据
|
|
52
|
+
# ============================================================
|
|
53
|
+
|
|
54
|
+
# GeoIP.conf 模板 (内容脱敏, 供用户填写真实信息)
|
|
55
|
+
GEOIP_CONF_TEMPLATE = <<~CONF
|
|
56
|
+
# GeoIP.conf file for `geoipupdate` program, for versions >= 3.1.1.
|
|
57
|
+
# Used to update GeoIP databases from https://www.maxmind.com.
|
|
58
|
+
# For more information about this config file, visit the docs at
|
|
59
|
+
# https://dev.maxmind.com/geoip/updating-databases.
|
|
60
|
+
|
|
61
|
+
# `AccountID` is from your MaxMind account.
|
|
62
|
+
# 注册地址: https://www.maxmind.com/en/geolite2/signup
|
|
63
|
+
AccountID YOUR_ACCOUNT_ID
|
|
64
|
+
|
|
65
|
+
# `LicenseKey` is from your MaxMind account.
|
|
66
|
+
# 登录后在 Account → GeoIP2 / GeoLite2 → Manage License Keys 生成
|
|
67
|
+
LicenseKey YOUR_LICENSE_KEY
|
|
68
|
+
|
|
69
|
+
# `EditionIDs` is from your MaxMind account.
|
|
70
|
+
EditionIDs GeoLite2-ASN GeoLite2-City GeoLite2-Country
|
|
71
|
+
CONF
|
|
72
|
+
|
|
73
|
+
# 三类数据与官方 edition_id 的对应关系
|
|
74
|
+
EDITIONS = {
|
|
75
|
+
"city" => "GeoLite2-City-CSV",
|
|
76
|
+
"country" => "GeoLite2-Country-CSV",
|
|
77
|
+
"asn" => "GeoLite2-ASN-CSV",
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
BASE_URL = "https://download.maxmind.com/app/geoip_download"
|
|
81
|
+
|
|
82
|
+
# ============================================================
|
|
83
|
+
# 处理方法
|
|
84
|
+
# ============================================================
|
|
85
|
+
|
|
86
|
+
def setup_console_encoding
|
|
87
|
+
return unless RUBY_PLATFORM =~ /mswin|mingw|cyg/
|
|
88
|
+
begin
|
|
89
|
+
system("chcp 65001 > NUL 2>&1")
|
|
90
|
+
$stdout.set_encoding(Encoding::UTF_8)
|
|
91
|
+
$stderr.set_encoding(Encoding::UTF_8)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# 忽略编码设置失败
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def log(msg)
|
|
98
|
+
puts msg
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 从 GeoIP.conf (geoipupdate 格式) 解析 LicenseKey / AccountID / EditionIDs
|
|
102
|
+
def parse_geoip_config(path)
|
|
103
|
+
return nil unless File.exist?(path)
|
|
104
|
+
conf = { license_key: nil, account_id: nil, editions: [] }
|
|
105
|
+
File.foreach(path, encoding: "UTF-8") do |line|
|
|
106
|
+
line = line.strip
|
|
107
|
+
next if line.empty? || line.start_with?("#")
|
|
108
|
+
case line
|
|
109
|
+
when /^AccountID\s+(.+)$/i then conf[:account_id] = $1.strip
|
|
110
|
+
when /^LicenseKey\s+(.+)$/i then conf[:license_key] = $1.strip
|
|
111
|
+
when /^EditionIDs\s+(.+)$/i then conf[:editions] = $1.strip.split(/\s+/)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
conf
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 生成 GeoIP.conf 模板文件
|
|
118
|
+
def generate_template(dest_path)
|
|
119
|
+
if File.exist?(dest_path)
|
|
120
|
+
abort "✗ 文件已存在: #{dest_path}\n 请先删除或重命名现有文件再生成模板。"
|
|
121
|
+
end
|
|
122
|
+
File.write(dest_path, GEOIP_CONF_TEMPLATE, encoding: "UTF-8")
|
|
123
|
+
log("已生成 GeoIP.conf 模板: #{dest_path}")
|
|
124
|
+
log("请编辑此文件, 填入真实的 AccountID 和 LicenseKey 后再执行下载。")
|
|
125
|
+
log("注册 MaxMind 账号: https://www.maxmind.com/en/geolite2/signup")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 带进度显示的 HTTP 下载 (直连优先, 代理兜底, 自动跟随重定向)
|
|
129
|
+
def download_file(url, dest, redirect_count = 0)
|
|
130
|
+
if redirect_count > 5
|
|
131
|
+
abort "✗ 重定向次数过多 (>5), 可能存在循环重定向"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
uri = URI(url)
|
|
135
|
+
log(" 下载: #{url.sub(/license_key=[^&]+/, 'license_key=***')}")
|
|
136
|
+
|
|
137
|
+
# 构建下载方式列表: 直连优先, 环境变量代理兜底
|
|
138
|
+
proxy = ENV["HTTP_PROXY"] || ENV["HTTPS_PROXY"] || ENV["http_proxy"] || ENV["https_proxy"]
|
|
139
|
+
methods = [{ proxy: nil, label: "直连" }]
|
|
140
|
+
if proxy && !proxy.empty?
|
|
141
|
+
puri = URI(proxy)
|
|
142
|
+
methods << { proxy: { addr: puri.host, port: puri.port }, label: "代理 #{puri.host}:#{puri.port}" }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
last_err = nil
|
|
146
|
+
methods.each do |m|
|
|
147
|
+
begin
|
|
148
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 30, read_timeout: 300 }
|
|
149
|
+
if m[:proxy]
|
|
150
|
+
opts[:proxy_addr] = m[:proxy][:addr]
|
|
151
|
+
opts[:proxy_port] = m[:proxy][:port]
|
|
152
|
+
end
|
|
153
|
+
log(" [#{m[:label]}] 连接中 ...")
|
|
154
|
+
Net::HTTP.start(uri.host, uri.port, **opts) do |http|
|
|
155
|
+
req = Net::HTTP::Get.new(uri)
|
|
156
|
+
http.request(req) do |res|
|
|
157
|
+
case res
|
|
158
|
+
when Net::HTTPSuccess
|
|
159
|
+
total = res["content-length"]&.to_i
|
|
160
|
+
done = 0
|
|
161
|
+
File.open(dest, "wb") do |f|
|
|
162
|
+
res.read_body do |chunk|
|
|
163
|
+
f.write(chunk)
|
|
164
|
+
done += chunk.bytesize
|
|
165
|
+
if total && total > 0
|
|
166
|
+
pct = (done.to_f / total * 100).round
|
|
167
|
+
print "\r 进度: %3d%% (%s / %s 字节) " % [pct, done, total]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
puts
|
|
172
|
+
return File.size(dest)
|
|
173
|
+
when Net::HTTPRedirection
|
|
174
|
+
new_url = res["location"]
|
|
175
|
+
if new_url && !new_url.empty?
|
|
176
|
+
new_url = URI.join(url, new_url).to_s if new_url.start_with?("/")
|
|
177
|
+
log(" 重定向 → #{new_url.sub(/license_key=[^&]+/, 'license_key=***')}")
|
|
178
|
+
return download_file(new_url, dest, redirect_count + 1)
|
|
179
|
+
end
|
|
180
|
+
abort "✗ 重定向但无 Location 头"
|
|
181
|
+
when Net::HTTPUnauthorized, Net::HTTPForbidden
|
|
182
|
+
abort "✗ 下载被拒绝 (#{res.code}): #{res.body[0, 200]}\n 请检查 license key 是否正确, 或 MaxMind 账号是否已激活 GeoLite2 下载。"
|
|
183
|
+
else
|
|
184
|
+
abort "✗ 下载失败 (HTTP #{res.code}): #{res.body[0, 200]}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED => e
|
|
189
|
+
last_err = e
|
|
190
|
+
log(" ! [#{m[:label]}] 失败 (#{e.class}): #{e.message[0, 100]}")
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
abort "✗ 所有下载方式均失败, 最后错误: #{last_err&.class}: #{last_err&.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# 解压 zip (用系统 tar)
|
|
198
|
+
def extract_file(zip_path, dest_dir)
|
|
199
|
+
FileUtils.mkdir_p(dest_dir)
|
|
200
|
+
if zip_path.end_with?(".tar.gz", ".tgz")
|
|
201
|
+
system("tar", "-xzf", zip_path, "-C", dest_dir)
|
|
202
|
+
else
|
|
203
|
+
system("tar", "-xf", zip_path, "-C", dest_dir)
|
|
204
|
+
end
|
|
205
|
+
$?.success? || abort("✗ 解压失败: #{zip_path}")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# 下载三类官方 zip (同一天已存在则跳过)
|
|
209
|
+
def download_editions(output_dir, license_key, editions)
|
|
210
|
+
today = Time.now.strftime("%Y%m%d")
|
|
211
|
+
editions.each do |name, edition_id|
|
|
212
|
+
zip_name = "#{edition_id}_#{today}.zip"
|
|
213
|
+
zip_path = File.join(output_dir, zip_name)
|
|
214
|
+
if File.exist?(zip_path) && File.size(zip_path) > 1000
|
|
215
|
+
log(" 跳过 (已存在): #{zip_name}")
|
|
216
|
+
next
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
url = "#{BASE_URL}?edition_id=#{edition_id}&license_key=#{license_key}&suffix=zip"
|
|
220
|
+
download_file(url, zip_path)
|
|
221
|
+
log(" 完成: #{zip_name} (#{format('%.2f', File.size(zip_path).to_f / 1_048_576)} MB)")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# 解压 output_dir 下所有 zip
|
|
226
|
+
def extract_all(output_dir)
|
|
227
|
+
Dir.glob(File.join(output_dir, "*.zip")).each do |zip_path|
|
|
228
|
+
next if File.size(zip_path) <= 1000
|
|
229
|
+
dest = File.join(output_dir, File.basename(zip_path, ".zip"))
|
|
230
|
+
extract_file(zip_path, dest)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# 调用 geo-load 转换 CSV → JSON
|
|
235
|
+
def run_geo_load(raw_dir, doc_dir, only = nil)
|
|
236
|
+
geo_load_script = File.expand_path("geo-load", __dir__)
|
|
237
|
+
unless File.exist?(geo_load_script)
|
|
238
|
+
abort "✗ 找不到 geo-load 脚本: #{geo_load_script}"
|
|
239
|
+
end
|
|
240
|
+
cmd = ["ruby", geo_load_script, raw_dir, doc_dir]
|
|
241
|
+
cmd << "--#{only}-only" if only
|
|
242
|
+
log(" 执行: #{cmd.join(' ')}")
|
|
243
|
+
system(*cmd)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Windows: 用 PowerShell 查找并停止旧 geo-api 进程, 再启动新服务
|
|
247
|
+
def restart_geo_api(data_dir)
|
|
248
|
+
if RUBY_PLATFORM =~ /mswin|mingw|cyg/
|
|
249
|
+
ps_cmd = 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match ''geo-api'' } | ForEach-Object { $_.ProcessId }"'
|
|
250
|
+
pids = `#{ps_cmd}`.lines.map(&:strip).reject(&:empty?)
|
|
251
|
+
unless pids.empty?
|
|
252
|
+
log(" 停止旧服务 (PID: #{pids.join(', ')})")
|
|
253
|
+
pids.each { |pid| system("taskkill", "/PID", pid, "/F") }
|
|
254
|
+
sleep 2
|
|
255
|
+
end
|
|
256
|
+
geo_api_script = File.expand_path("geo-api", __dir__)
|
|
257
|
+
log(" 启动服务: geo-api -d #{data_dir}")
|
|
258
|
+
system("powershell", "-NoProfile", "-Command",
|
|
259
|
+
"Start-Process ruby -ArgumentList '#{geo_api_script}','-d','#{data_dir}' -WindowStyle Hidden")
|
|
260
|
+
else
|
|
261
|
+
system("pkill", "-f", "geo-api") rescue nil
|
|
262
|
+
log(" 请手动启动: geo-api -d #{data_dir}")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ============================================================
|
|
267
|
+
# 业务流程实例
|
|
268
|
+
# ============================================================
|
|
269
|
+
|
|
270
|
+
setup_console_encoding
|
|
271
|
+
|
|
272
|
+
options = {
|
|
273
|
+
template: false,
|
|
274
|
+
config_file: nil,
|
|
275
|
+
license_key: nil,
|
|
276
|
+
output_dir: nil,
|
|
277
|
+
convert: false,
|
|
278
|
+
restart: false,
|
|
279
|
+
local_dir: nil,
|
|
280
|
+
only: nil,
|
|
281
|
+
dry_run: false
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
parser = OptionParser.new do |opts|
|
|
285
|
+
opts.banner = <<~BAN
|
|
286
|
+
geo-update — GeoLite2 地址库下载更新工具 (network-infra-utility)
|
|
287
|
+
|
|
288
|
+
用法:
|
|
289
|
+
geo-update -T # 生成 GeoIP.conf 模板
|
|
290
|
+
geo-update # 下载+解压 (读当前目录 GeoIP.conf)
|
|
291
|
+
geo-update --convert # 下载+解压+转换 JSON
|
|
292
|
+
geo-update --convert --restart # 下载+解压+转换+重启 geo-api
|
|
293
|
+
geo-update --asn-only # 仅下载 ASN
|
|
294
|
+
geo-update --output-dir=D:\geodata # 下载到指定目录
|
|
295
|
+
|
|
296
|
+
选项:
|
|
297
|
+
BAN
|
|
298
|
+
opts.on("-T", "--template", "在当前目录生成 GeoIP.conf 模板") { options[:template] = true }
|
|
299
|
+
opts.on("--config=FILE", "指定配置文件路径 (默认: 当前目录/GeoIP.conf)") { |v| options[:config_file] = v }
|
|
300
|
+
opts.on("--license-key=KEY", "指定 MaxMind 许可证密钥 (覆盖配置文件)") { |v| options[:license_key] = v }
|
|
301
|
+
opts.on("--output-dir=DIR", "下载/解压目录 (默认: 当前目录)") { |v| options[:output_dir] = v }
|
|
302
|
+
opts.on("--convert", "下载后调用 geo-load 转换 CSV → JSON") { options[:convert] = true }
|
|
303
|
+
opts.on("--restart", "转换后重启 geo-api 服务 (隐含 --convert)") { options[:restart] = true; options[:convert] = true }
|
|
304
|
+
opts.on("--local-dir=DIR", "使用本地已有 CSV 目录, 跳过下载") { |v| options[:local_dir] = v }
|
|
305
|
+
opts.on("--asn-only", "仅处理 ASN") { options[:only] = "asn" }
|
|
306
|
+
opts.on("--city-only", "仅处理 City") { options[:only] = "city" }
|
|
307
|
+
opts.on("--country-only", "仅处理 Country") { options[:only] = "country" }
|
|
308
|
+
opts.on("--dry-run", "演练: 只打印将执行的动作") { options[:dry_run] = true }
|
|
309
|
+
opts.on("-v", "--version", "显示版本") do
|
|
310
|
+
puts "geo-update #{NetworkInfraUtility::VERSION}"
|
|
311
|
+
exit 0
|
|
312
|
+
end
|
|
313
|
+
opts.on("-h", "--help", "显示帮助") do
|
|
314
|
+
puts opts
|
|
315
|
+
exit 0
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
parser.parse!
|
|
319
|
+
|
|
320
|
+
# ---- --template: 生成 GeoIP.conf 模板然后退出 ----
|
|
321
|
+
if options[:template]
|
|
322
|
+
template_path = File.expand_path("GeoIP.conf", Dir.pwd)
|
|
323
|
+
generate_template(template_path)
|
|
324
|
+
exit 0
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# ---- 确定输出目录 (默认: 当前目录) ----
|
|
328
|
+
output_dir = options[:output_dir] ? File.expand_path(options[:output_dir]) : Dir.pwd
|
|
329
|
+
unless File.directory?(output_dir)
|
|
330
|
+
begin
|
|
331
|
+
FileUtils.mkdir_p(output_dir)
|
|
332
|
+
rescue SystemCallError => e
|
|
333
|
+
abort "✗ 无法创建输出目录 #{output_dir}: #{e.message}"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# ---- 确定 JSON 输出目录 (convert 时使用) ----
|
|
338
|
+
doc_dir = File.expand_path("geodb", output_dir)
|
|
339
|
+
|
|
340
|
+
# ---- 确定数据源目录 ----
|
|
341
|
+
if options[:local_dir]
|
|
342
|
+
raw_dir = File.expand_path(options[:local_dir])
|
|
343
|
+
abort "✗ 本地目录不存在: #{raw_dir}" unless File.directory?(raw_dir)
|
|
344
|
+
log("数据源: 本地目录 #{raw_dir} (跳过下载)")
|
|
345
|
+
else
|
|
346
|
+
# ---- 获取 license key: --license-key > --config > 当前目录 GeoIP.conf ----
|
|
347
|
+
license = options[:license_key]
|
|
348
|
+
if license.to_s.empty?
|
|
349
|
+
conf_path = options[:config_file] || File.expand_path("GeoIP.conf", Dir.pwd)
|
|
350
|
+
conf = parse_geoip_config(conf_path)
|
|
351
|
+
if conf && !conf[:license_key].to_s.empty? && conf[:license_key] != "YOUR_LICENSE_KEY"
|
|
352
|
+
license = conf[:license_key]
|
|
353
|
+
log("从 #{conf_path} 读取 LicenseKey (AccountID: #{conf[:account_id]})")
|
|
354
|
+
else
|
|
355
|
+
abort <<~MSG
|
|
356
|
+
✗ 缺少 MaxMind license key
|
|
357
|
+
未在当前目录找到有效的 GeoIP.conf, 或文件中 LicenseKey 未填写。
|
|
358
|
+
配置文件查找位置: #{conf_path}
|
|
359
|
+
|
|
360
|
+
解决方法:
|
|
361
|
+
1. 生成模板: geo-update -T
|
|
362
|
+
2. 编辑文件: 填入真实的 AccountID 和 LicenseKey
|
|
363
|
+
3. 重新运行: geo-update
|
|
364
|
+
|
|
365
|
+
或直接指定:
|
|
366
|
+
geo-update --license-key=YOUR_KEY
|
|
367
|
+
geo-update --config=/path/to/GeoIP.conf
|
|
368
|
+
|
|
369
|
+
注册 MaxMind 账号: https://www.maxmind.com/en/geolite2/signup
|
|
370
|
+
MSG
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
options[:license_key] = license
|
|
374
|
+
raw_dir = output_dir
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
editions = options[:only] ? { options[:only] => EDITIONS[options[:only]] } : EDITIONS
|
|
378
|
+
if options[:only] && !EDITIONS[options[:only]]
|
|
379
|
+
abort "✗ 未知类型: #{options[:only]} (可用: city/country/asn)"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# ---- 打印执行计划 ----
|
|
383
|
+
log("╭─ geo-update #{NetworkInfraUtility::VERSION} ───────────────────────────────")
|
|
384
|
+
log("│ 类型: #{editions.keys.join(' / ')}")
|
|
385
|
+
log("│ 输出目录: #{output_dir}")
|
|
386
|
+
log("│ 数据源: #{options[:local_dir] ? raw_dir : 'MaxMind 官方下载'}")
|
|
387
|
+
log("│ 动作: #{options[:dry_run] ? '(演练, 不执行)' : '执行'}")
|
|
388
|
+
if options[:convert]
|
|
389
|
+
log("│ 转换: 是 (geo-load → #{doc_dir})")
|
|
390
|
+
log("│ 重启: #{options[:restart] ? '是' : '否'}")
|
|
391
|
+
end
|
|
392
|
+
log("╰──────────────────────────────────────────────────")
|
|
393
|
+
|
|
394
|
+
exit 0 if options[:dry_run]
|
|
395
|
+
|
|
396
|
+
# ---- 1. 下载 + 解压 ----
|
|
397
|
+
unless options[:local_dir]
|
|
398
|
+
log("[1/3] 下载 GeoLite2 CSV ...")
|
|
399
|
+
download_editions(output_dir, options[:license_key], editions)
|
|
400
|
+
log("")
|
|
401
|
+
log("[2/3] 解压 ...")
|
|
402
|
+
extract_all(output_dir)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# ---- 2. geo-load 转换 ----
|
|
406
|
+
if options[:convert]
|
|
407
|
+
step = options[:local_dir] ? "[1/2]" : "[3/3]"
|
|
408
|
+
log("#{step} 转换 CSV → JSON (可能需要几十分钟~数小时) ...")
|
|
409
|
+
ok = run_geo_load(raw_dir, doc_dir, options[:only])
|
|
410
|
+
unless ok
|
|
411
|
+
log("✗ geo-load 转换失败 (exit #{$?.exitstatus})")
|
|
412
|
+
exit 1
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# ---- 3. 重启 geo-api ----
|
|
417
|
+
if options[:restart]
|
|
418
|
+
log("重启 geo-api ...")
|
|
419
|
+
restart_geo_api(doc_dir)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
log("完成。")
|
|
423
|
+
if options[:convert]
|
|
424
|
+
log(" JSON 数据: #{doc_dir}")
|
|
425
|
+
log(" 验证: geo-get 8.8.8.8 -j")
|
|
426
|
+
else
|
|
427
|
+
log(" 压缩包和解压目录: #{output_dir}")
|
|
428
|
+
log(" 转换为 JSON: geo-update --convert (或 geo-load #{output_dir})")
|
|
429
|
+
end
|
data/bin/ngeo-get
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
# coding: utf-8
|
|
4
|
+
#
|
|
5
|
+
# ngeo-get — network-infra-utility 的 IP 归属综合查询工具 (geo 系列)
|
|
6
|
+
#
|
|
7
|
+
# 按 -p 顺位编排三个数据源, 满意即停, 字段叠加:
|
|
8
|
+
# cache GEO_CACHE 外带缓存目录 (geocacheYYYYMMDD.json, -a 指定)
|
|
9
|
+
# local 本地 geo-api 服务 (GeoLite2)
|
|
10
|
+
# internet 互联网 ip-api.com (限速 + 熔断保护)
|
|
11
|
+
#
|
|
12
|
+
# 结果状态可信度: unreachable < local-partial < cache-partial < empty
|
|
13
|
+
# < cache < local < online < merged
|
|
14
|
+
# unreachable 各源均无结果且有不联通 (空结果状态)
|
|
15
|
+
# local-partial 本地部分结果, 无更多补充
|
|
16
|
+
# cache-partial 缓存部分结果, 无更多补充
|
|
17
|
+
# empty 各源均确认无归属 (如私有地址)
|
|
18
|
+
# cache 缓存命中且满意
|
|
19
|
+
# local 本地结果满意 (省/市/ASN 齐全)
|
|
20
|
+
# online 纯互联网结果
|
|
21
|
+
# merged 多源叠加综合
|
|
22
|
+
#
|
|
23
|
+
# 用法:
|
|
24
|
+
# ngeo-get 111.8.44.6 # 默认顺位 cache,local,internet
|
|
25
|
+
# ngeo-get 111.8.44.6 -a ./GEO_CACHE # 指定外带缓存目录
|
|
26
|
+
# ngeo-get 111.8.44.6 -p local,internet,cache # 本地优先, 缓存兜底
|
|
27
|
+
# ngeo-get 111.8.44.6 -nc # 查询但不产出缓存文件
|
|
28
|
+
# ngeo-get 1.1.1.1 8.8.8.8 -j # 多 IP (在线部分自动限速)
|
|
29
|
+
# cat ips.txt | ngeo-get -j # 从 stdin 逐行读 IP
|
|
30
|
+
# ngeo-get -m ./GEO_CACHE # 合并缓存目录全部 geocache*.json
|
|
31
|
+
# ngeo-get --cache-stats # 查看会话缓存统计
|
|
32
|
+
#
|
|
33
|
+
# 选项:
|
|
34
|
+
# -t, --text 文字格式输出 (默认)
|
|
35
|
+
# -j, --json JSON 格式输出 (多 IP 时输出数组)
|
|
36
|
+
# -a, --cache-dir DIR GEO_CACHE 外带缓存目录 (geocache*.json 文件集)
|
|
37
|
+
# -p, --priority O 查询顺位, cache/local/internet 的排列
|
|
38
|
+
# (默认 cache,local,internet: 优先缓存, 再本地, 再互联网)
|
|
39
|
+
# -c, --cache-out 查询结果保存为 geocacheYYYYMMDD.json (默认开启)
|
|
40
|
+
# -nc, --no-cache-out 不保存查询产出
|
|
41
|
+
# -m, --merge DIR 合并 DIR 下全部 geocache*.json 为新文件后退出
|
|
42
|
+
# --refresh 忽略缓存强制重查 (会话缓存与 GEO_CACHE 均跳过)
|
|
43
|
+
# --no-local 跳过本地 geo-api
|
|
44
|
+
# --api URL 覆盖默认互联网接口 ({ip} 占位符)
|
|
45
|
+
# --geoapi URL 覆盖本地 geo-api 地址 (默认 http://127.0.0.1:9292)
|
|
46
|
+
# --timeout SEC 互联网 HTTP 超时秒数, 默认 10
|
|
47
|
+
# --cache-stats 查看缓存统计后退出
|
|
48
|
+
# -h, --help 显示帮助
|
|
49
|
+
# -v, --version 显示版本
|
|
50
|
+
#
|
|
51
|
+
# 产出说明:
|
|
52
|
+
# 每次查询的结果 (默认) 保存到缓存目录 (未指定 -a 时为当前目录,
|
|
53
|
+
# 可用环境变量 GEO_CACHE_DIR 覆盖), 命名为 geocacheYYYYMMDD.json,
|
|
54
|
+
# 同一天多次查询自动归并到同一文件, 同 IP 以新结果覆盖。
|
|
55
|
+
# 产出文件即后续 -a 查询 / -m 合并 / geo-api -a 服务端兜底的数据源。
|
|
56
|
+
#
|
|
57
|
+
# 退出码: 0 = 查询完成 (含 empty/local-partial/cache-partial);
|
|
58
|
+
# 1 = IP 不合法或参数错误; 2 = 互联网不可达且其余源无结果
|
|
59
|
+
|
|
60
|
+
require "optparse"
|
|
61
|
+
require "json"
|
|
62
|
+
require_relative "../service/geoquery/geoquery"
|
|
63
|
+
|
|
64
|
+
options = { format: :text, refresh: false, no_local: false, timeout: 10,
|
|
65
|
+
api: nil, geoapi: nil, geo_cache_dir: nil, order: nil,
|
|
66
|
+
cache_out: true, merge_dir: nil }
|
|
67
|
+
|
|
68
|
+
# "-nc" 为 "-c" 的反义短参组合, OptionParser 无法注册, 手动预提取
|
|
69
|
+
if (idx = ARGV.index("-nc"))
|
|
70
|
+
ARGV.delete_at(idx)
|
|
71
|
+
options[:cache_out] = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
parser = OptionParser.new do |opts|
|
|
75
|
+
opts.banner = <<~BAN
|
|
76
|
+
ngeo-get — IP 归属综合查询工具 (GEO_CACHE 缓存 + 本地 geo-get + 互联网 gen-get)
|
|
77
|
+
|
|
78
|
+
策略: 按顺位逐源查询 (-p, 默认 cache,local,internet), 满意即停, 字段叠加
|
|
79
|
+
用法:
|
|
80
|
+
ngeo-get <IP> [options]
|
|
81
|
+
ngeo-get 111.8.44.6 -a ./GEO_CACHE -j
|
|
82
|
+
ngeo-get -m ./GEO_CACHE
|
|
83
|
+
cat ips.txt | ngeo-get -j
|
|
84
|
+
|
|
85
|
+
选项:
|
|
86
|
+
BAN
|
|
87
|
+
opts.on("-t", "--text", "文字格式输出 (默认)") { options[:format] = :text }
|
|
88
|
+
opts.on("-j", "--json", "JSON 格式输出") { options[:format] = :json }
|
|
89
|
+
opts.on("-a", "--cache-dir DIR", "GEO_CACHE 外带缓存目录") { |v| options[:geo_cache_dir] = v }
|
|
90
|
+
opts.on("-p", "--priority ORDER", "查询顺位 cache/local/internet 排列 (默认 cache,local,internet)") { |v| options[:order] = v }
|
|
91
|
+
opts.on("-c", "--cache-out", "查询结果保存为 geocacheYYYYMMDD.json (默认开启)") { options[:cache_out] = true }
|
|
92
|
+
opts.on("--no-cache-out", "不保存查询产出 (同 -nc)") { options[:cache_out] = false }
|
|
93
|
+
opts.on("-m", "--merge DIR", "合并 DIR 下全部 geocache*.json 为新文件后退出") { |v| options[:merge_dir] = v }
|
|
94
|
+
opts.on("--refresh", "忽略缓存强制重查") { options[:refresh] = true }
|
|
95
|
+
opts.on("--no-local", "跳过本地 geo-api") { options[:no_local] = true }
|
|
96
|
+
opts.on("--api URL", "覆盖默认互联网接口 ({ip} 占位符)") { |v| options[:api] = v }
|
|
97
|
+
opts.on("--geoapi URL", "覆盖本地 geo-api 地址") { |v| options[:geoapi] = v }
|
|
98
|
+
opts.on("--timeout SEC", Integer, "互联网 HTTP 超时秒数 (默认 10)") { |v| options[:timeout] = v }
|
|
99
|
+
opts.on("--cache-stats", "查看缓存统计") do
|
|
100
|
+
cache = GeoQuery::Cache.new(File.join(GeoQuery::Cache.default_dir, "ngeo-cache.json"))
|
|
101
|
+
stats = cache.stats
|
|
102
|
+
puts "会话缓存: #{stats['file']}"
|
|
103
|
+
puts "缓存条目: #{stats['total']} 大小: #{stats['size_kb']} KB"
|
|
104
|
+
unless stats["states"].empty?
|
|
105
|
+
puts "状态分布: " + stats["states"].map { |k, v| "#{k}=#{v}" }.join(" ")
|
|
106
|
+
end
|
|
107
|
+
if options[:geo_cache_dir]
|
|
108
|
+
gstats = GeoQuery::GeoCache.new(options[:geo_cache_dir]).stats
|
|
109
|
+
puts
|
|
110
|
+
puts "GEO_CACHE: #{gstats['dir']}"
|
|
111
|
+
puts "缓存文件: #{gstats['files']} 条目: #{gstats['entries']}"
|
|
112
|
+
unless gstats["states"].empty?
|
|
113
|
+
puts "状态分布: " + gstats["states"].map { |k, v| "#{k}=#{v}" }.join(" ")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
exit 0
|
|
117
|
+
end
|
|
118
|
+
opts.on("-v", "--version", "显示版本") do
|
|
119
|
+
require_relative "../version"
|
|
120
|
+
puts "ngeo-get #{NetworkInfraUtility::VERSION}"
|
|
121
|
+
exit 0
|
|
122
|
+
end
|
|
123
|
+
opts.on("-h", "--help", "显示帮助") do
|
|
124
|
+
puts opts
|
|
125
|
+
puts <<~NOTE
|
|
126
|
+
|
|
127
|
+
顺位示例:
|
|
128
|
+
-p cache,local,internet 优先缓存 → 本地库 → 互联网 (默认)
|
|
129
|
+
-p local,internet,cache 本地优先, 本地查不到才找互联网, 互联网查不到或不通再查缓存
|
|
130
|
+
-p internet,local 纯互联网优先, 不通再查本地
|
|
131
|
+
|
|
132
|
+
产出: 默认保存查询结果为 geocacheYYYYMMDD.json (缓存目录或当前目录), -nc 关闭
|
|
133
|
+
NOTE
|
|
134
|
+
exit 0
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
parser.parse!
|
|
138
|
+
|
|
139
|
+
# ---- -m 整理模式: 合并缓存目录后退出 (优先级最高) ---------------------------
|
|
140
|
+
if options[:merge_dir]
|
|
141
|
+
gc = GeoQuery::GeoCache.new(options[:merge_dir])
|
|
142
|
+
unless File.directory?(gc.dir)
|
|
143
|
+
warn "✗ 缓存目录不存在: #{gc.dir}"
|
|
144
|
+
exit 1
|
|
145
|
+
end
|
|
146
|
+
stats = gc.merge!
|
|
147
|
+
puts "合并完成: #{stats['files']} 个缓存文件 → #{stats['entries']} 条记录"
|
|
148
|
+
puts "输出文件: #{stats['output']}"
|
|
149
|
+
exit 0
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ---- 顺位参数校验 -----------------------------------------------------------
|
|
153
|
+
order = GeoQuery::NGeo::DEFAULT_ORDER
|
|
154
|
+
if options[:order]
|
|
155
|
+
order = GeoQuery::GeoCache.parse_order(options[:order])
|
|
156
|
+
if order.nil?
|
|
157
|
+
warn "✗ 无效顺位: #{options[:order]}"
|
|
158
|
+
warn " 合法源: cache / local / internet, 逗号分隔, 不重复"
|
|
159
|
+
warn " 示例: -p cache,local,internet (默认)"
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# IP 来源: 命令行参数, 无参数且 stdin 非 TTY 时逐行读
|
|
165
|
+
ips = ARGV.reject { |a| a.start_with?("-") }.map(&:strip).reject(&:empty?)
|
|
166
|
+
if ips.empty? && !$stdin.tty?
|
|
167
|
+
ips = $stdin.each_line.map(&:strip).reject(&:empty?)
|
|
168
|
+
end
|
|
169
|
+
if ips.empty?
|
|
170
|
+
warn "用法: ngeo-get <IP> [options] (或管道输入 IP 列表, 详见 --help)"
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
ngeo = GeoQuery::NGeo.new(api: options[:api], geoapi_base: options[:geoapi],
|
|
175
|
+
timeout: options[:timeout],
|
|
176
|
+
geo_cache_dir: options[:geo_cache_dir], order: order)
|
|
177
|
+
|
|
178
|
+
results = ips.map do |ip|
|
|
179
|
+
ngeo.lookup(ip, refresh: options[:refresh], no_local: options[:no_local])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
ngeo.save_cache
|
|
183
|
+
|
|
184
|
+
# ---- 产出: 查询结果保存为 geocacheYYYYMMDD.json -----------------------------
|
|
185
|
+
# 保存目录: -a 指定目录 > ENV["GEO_CACHE_DIR"] > 当前目录; -nc 关闭
|
|
186
|
+
if options[:cache_out] && !results.empty?
|
|
187
|
+
out_dir = options[:geo_cache_dir] || GeoQuery::GeoCache.default_dir
|
|
188
|
+
out = GeoQuery::GeoCache.new(out_dir).put_batch(results)
|
|
189
|
+
# 提示走 stderr, 不污染 JSON 输出
|
|
190
|
+
warn "产出: #{out['entries']} 条查询结果 → #{out['file']}" if out
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ---- 输出 -------------------------------------------------------------------
|
|
194
|
+
STATE_LABEL = {
|
|
195
|
+
"merged" => "多源叠加综合", "online" => "纯互联网",
|
|
196
|
+
"local" => "本地满意", "local-partial" => "本地部分 (无更多补充)",
|
|
197
|
+
"cache" => "缓存命中", "cache-partial" => "缓存部分 (无更多补充)",
|
|
198
|
+
"empty" => "各源均无归属", "unreachable" => "查询不可达",
|
|
199
|
+
"invalid" => "IP 不合法",
|
|
200
|
+
}.freeze
|
|
201
|
+
|
|
202
|
+
SRC_DISPLAY = {
|
|
203
|
+
"cache" => "GEO_CACHE 缓存", "local" => "本地 geo-get", "online" => "互联网 gen-get",
|
|
204
|
+
}.freeze
|
|
205
|
+
|
|
206
|
+
ORDER_TEXT = order.join(",").freeze
|
|
207
|
+
|
|
208
|
+
case options[:format]
|
|
209
|
+
when :json
|
|
210
|
+
puts JSON.pretty_generate(results.size == 1 ? results.first : results)
|
|
211
|
+
when :text
|
|
212
|
+
results.each do |r|
|
|
213
|
+
puts "查询 IP: #{r['ip']} 顺位: #{ORDER_TEXT} (满意即停, 字段叠加)"
|
|
214
|
+
puts "-" * 64
|
|
215
|
+
case r["state"]
|
|
216
|
+
when "unreachable"
|
|
217
|
+
puts "✗ #{r['message']}"
|
|
218
|
+
puts "状态: unreachable (#{STATE_LABEL['unreachable']}) —— 各源均无结果, 且有不联通"
|
|
219
|
+
when "invalid"
|
|
220
|
+
puts "✗ #{r['message']}"
|
|
221
|
+
puts "状态: invalid"
|
|
222
|
+
else
|
|
223
|
+
country = r["country"].empty? ? "未知" : r["country"]
|
|
224
|
+
province = r["province"].empty? ? "未知" : r["province"]
|
|
225
|
+
city = r["city"].empty? ? "未知" : r["city"]
|
|
226
|
+
puts "国家: #{country} 省份: #{province} 城市: #{city}"
|
|
227
|
+
puts "运营商: #{r['isp'].empty? ? '未知' : r['isp']} 用途: #{r['usage'].empty? ? '未知' : r['usage']}"
|
|
228
|
+
asn = r["asn"].empty? ? "未知" : "AS#{r['asn']}"
|
|
229
|
+
net = r["network"].empty? ? "" : " 网段: #{r['network']}"
|
|
230
|
+
puts "ASN: #{asn} #{r['asn_org']}#{net}"
|
|
231
|
+
cached = r["cached"] ? "缓存命中" : "实时查询"
|
|
232
|
+
puts "状态: #{r['state']} (#{STATE_LABEL[r['state']]}) 来源: #{r['source']} #{cached}"
|
|
233
|
+
unless r["message"].to_s.empty?
|
|
234
|
+
puts "提示: #{r['message']}"
|
|
235
|
+
end
|
|
236
|
+
if r["sources"].is_a?(Hash)
|
|
237
|
+
r["sources"].each do |name, info|
|
|
238
|
+
next unless info.is_a?(Hash)
|
|
239
|
+
label = SRC_DISPLAY[name] || name
|
|
240
|
+
verdict = info["verdict"] ? ", #{info['verdict']}" : ""
|
|
241
|
+
msg = info["message"].to_s.empty? ? "" : " (#{info['message']})"
|
|
242
|
+
puts " · #{label}: #{info['state']}#{verdict}#{msg}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
puts
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# 退出码取最严重: unreachable(2) > invalid(1) > 其他(0)
|
|
251
|
+
exit 2 if results.any? { |r| r["state"] == "unreachable" }
|
|
252
|
+
exit 1 if results.any? { |r| r["state"] == "invalid" }
|
|
253
|
+
exit 0
|