network-infra-utility 0.3.0 → 0.6.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +21 -0
  3. data/GUIDE.md +212 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +70 -0
  6. data/Rakefile +1 -1
  7. data/bin/dns-query +836 -0
  8. data/bin/geo-doc +135 -0
  9. data/bin/geo-get +1 -1
  10. data/bin/geo-update +429 -0
  11. data/document/ASNum/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +242 -0
  12. data/document/DNSQuery/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +248 -0
  13. data/document/Geo/345/221/275/344/273/244/345/267/245/345/205/267/344/275/277/347/224/250/346/226/271/346/263/225.md +441 -0
  14. data/document/IP/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +297 -0
  15. data/document/MAC/346/250/241/345/235/227/345/212/237/350/203/275/350/257/264/346/230/216.md +296 -0
  16. data/document/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/344/275/277/347/224/250/346/226/271/346/263/225.md +764 -0
  17. data/network-infra-utility.gemspec +4 -2
  18. data/network.rb +3 -1
  19. data/service/geodb/geodb.rb +278 -1
  20. data/service/ssh/README.md +955 -0
  21. data/service/ssh/bin/ssh-client +198 -0
  22. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/345/212/237/350/203/275/351/234/200/346/261/202/346/226/207/346/241/243.md +292 -0
  23. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/257/246/347/273/206/350/256/276/350/256/241/346/226/207/346/241/243.md +1521 -0
  24. data/service/ssh/design/SSH/350/277/236/346/216/245/345/256/242/346/210/267/347/253/257/350/275/257/344/273/266/350/256/276/350/256/241/346/226/207/346/241/243.md +2493 -0
  25. data/service/ssh/ext/ssh_core/bin/ssh_core.cmd +28 -0
  26. data/service/ssh/ext/ssh_core/config/sys.config +0 -0
  27. data/service/ssh/ext/ssh_core/config/vm.args +0 -0
  28. data/service/ssh/ext/ssh_core/local_deps/jsx/CHECKSUM +1 -0
  29. data/service/ssh/ext/ssh_core/local_deps/jsx/LICENSE +21 -0
  30. data/service/ssh/ext/ssh_core/local_deps/jsx/README.md +696 -0
  31. data/service/ssh/ext/ssh_core/local_deps/jsx/VERSION +1 -0
  32. data/service/ssh/ext/ssh_core/local_deps/jsx/contents.tar.gz +0 -0
  33. data/service/ssh/ext/ssh_core/local_deps/jsx/metadata.config +15 -0
  34. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.config +17 -0
  35. data/service/ssh/ext/ssh_core/local_deps/jsx/rebar.lock +1 -0
  36. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.app.src +10 -0
  37. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx.erl +506 -0
  38. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.erl +393 -0
  39. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_config.hrl +18 -0
  40. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_consult.erl +81 -0
  41. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_decoder.erl +1909 -0
  42. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_encoder.erl +116 -0
  43. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_parser.erl +1214 -0
  44. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_json.erl +408 -0
  45. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_to_term.erl +389 -0
  46. data/service/ssh/ext/ssh_core/local_deps/jsx/src/jsx_verify.erl +121 -0
  47. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx.erl +506 -0
  48. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.erl +393 -0
  49. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_config.hrl +18 -0
  50. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_consult.erl +81 -0
  51. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_decoder.erl +1909 -0
  52. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_encoder.erl +116 -0
  53. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_parser.erl +1214 -0
  54. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_json.erl +408 -0
  55. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_to_term.erl +389 -0
  56. data/service/ssh/ext/ssh_core/local_deps/jsx/src/src/jsx_verify.erl +121 -0
  57. data/service/ssh/ext/ssh_core/rebar.config +24 -0
  58. data/service/ssh/ext/ssh_core/rebar.lock +1 -0
  59. data/service/ssh/ext/ssh_core/src/ssh_auth_engine.erl +156 -0
  60. data/service/ssh/ext/ssh_core/src/ssh_channel_stm.erl +232 -0
  61. data/service/ssh/ext/ssh_core/src/ssh_codec.erl +83 -0
  62. data/service/ssh/ext/ssh_core/src/ssh_conn_sup.erl +48 -0
  63. data/service/ssh/ext/ssh_core/src/ssh_conn_worker.erl +535 -0
  64. data/service/ssh/ext/ssh_core/src/ssh_core.app.src +36 -0
  65. data/service/ssh/ext/ssh_core/src/ssh_core_app.erl +11 -0
  66. data/service/ssh/ext/ssh_core/src/ssh_core_sup.erl +46 -0
  67. data/service/ssh/ext/ssh_core/src/ssh_infra_sup.erl +117 -0
  68. data/service/ssh/ext/ssh_core/src/ssh_ipc.hrl +80 -0
  69. data/service/ssh/ext/ssh_core/src/ssh_ipc_coalesce.erl +94 -0
  70. data/service/ssh/ext/ssh_core/src/ssh_ipc_gateway.erl +467 -0
  71. data/service/ssh/ext/ssh_core/src/ssh_ipc_proto.erl +95 -0
  72. data/service/ssh/ext/ssh_core/src/ssh_jump_chain.erl +101 -0
  73. data/service/ssh/ext/ssh_core/src/ssh_keepalive_mgr.erl +222 -0
  74. data/service/ssh/ext/ssh_core/src/ssh_known_hosts_proxy.erl +67 -0
  75. data/service/ssh/ext/ssh_core/src/ssh_port_fwd.erl +225 -0
  76. data/service/ssh/ext/ssh_core/src/ssh_sftp_session.erl +250 -0
  77. data/service/ssh/ext/ssh_core/src/ssh_sftp_sup.erl +62 -0
  78. data/service/ssh/ext/ssh_core_rs/Cargo.lock +2345 -0
  79. data/service/ssh/ext/ssh_core_rs/Cargo.toml +30 -0
  80. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs +34 -0
  81. data/service/ssh/ext/ssh_core_rs/bin/ssh_core_rs.cmd +40 -0
  82. data/service/ssh/ext/ssh_core_rs/src/channel.rs +296 -0
  83. data/service/ssh/ext/ssh_core_rs/src/coalesce.rs +143 -0
  84. data/service/ssh/ext/ssh_core_rs/src/codec.rs +71 -0
  85. data/service/ssh/ext/ssh_core_rs/src/conn.rs +628 -0
  86. data/service/ssh/ext/ssh_core_rs/src/gateway.rs +389 -0
  87. data/service/ssh/ext/ssh_core_rs/src/handler.rs +293 -0
  88. data/service/ssh/ext/ssh_core_rs/src/keepalive.rs +194 -0
  89. data/service/ssh/ext/ssh_core_rs/src/main.rs +351 -0
  90. data/service/ssh/ext/ssh_core_rs/src/portfwd.rs +378 -0
  91. data/service/ssh/ext/ssh_core_rs/src/proto.rs +198 -0
  92. data/service/ssh/ext/ssh_core_rs/src/sftp.rs +294 -0
  93. data/service/ssh/lib/network_infra_utility/ssh/automation/macro_engine.rb +213 -0
  94. data/service/ssh/lib/network_infra_utility/ssh/client.rb +257 -0
  95. data/service/ssh/lib/network_infra_utility/ssh/config/schema.rb +90 -0
  96. data/service/ssh/lib/network_infra_utility/ssh/config/settings.rb +103 -0
  97. data/service/ssh/lib/network_infra_utility/ssh/config/store.rb +90 -0
  98. data/service/ssh/lib/network_infra_utility/ssh/ipc/coalesce.rb +83 -0
  99. data/service/ssh/lib/network_infra_utility/ssh/ipc/errors.rb +36 -0
  100. data/service/ssh/lib/network_infra_utility/ssh/ipc/router.rb +212 -0
  101. data/service/ssh/lib/network_infra_utility/ssh/ipc/transport.rb +81 -0
  102. data/service/ssh/lib/network_infra_utility/ssh/security/host_key.rb +211 -0
  103. data/service/ssh/lib/network_infra_utility/ssh/security/vault.rb +211 -0
  104. data/service/ssh/lib/network_infra_utility/ssh/session/history.rb +56 -0
  105. data/service/ssh/lib/network_infra_utility/ssh/session/manager.rb +92 -0
  106. data/service/ssh/lib/network_infra_utility/ssh/session/session.rb +109 -0
  107. data/service/ssh/lib/network_infra_utility/ssh/session/tree.rb +95 -0
  108. data/service/ssh/lib/network_infra_utility/ssh/terminal/ansi_parser.rb +435 -0
  109. data/service/ssh/lib/network_infra_utility/ssh/terminal/buffer.rb +78 -0
  110. data/service/ssh/lib/network_infra_utility/ssh/terminal/emulator.rb +159 -0
  111. data/service/ssh/lib/network_infra_utility/ssh/terminal/logger.rb +195 -0
  112. data/service/ssh/lib/network_infra_utility/ssh/terminal/screen.rb +212 -0
  113. data/service/ssh/lib/network_infra_utility/ssh/terminal/theme.rb +127 -0
  114. data/service/ssh/lib/network_infra_utility/ssh/version.rb +7 -0
  115. data/service/ssh/lib/network_infra_utility/ssh.rb +44 -0
  116. data/support/basic/as_num.rb +221 -0
  117. data/support/basic/mac_address.rb +281 -0
  118. data/version.rb +1 -1
  119. metadata +142 -1
data/bin/geo-doc ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # coding: utf-8
4
+ #
5
+ # geo-doc — network-infra-utility 的 GeoLite2 JSON → 大表导出工具
6
+ #
7
+ # 把 geo-load 生成的 JSON 数据文件(city-IPv4/IPv6、country-IPv4/IPv6、
8
+ # asn、geo-city、geo-country)拼接成一张完整大表,按 IPv4 / IPv6 分别
9
+ # 输出为 CSV 和/或 TXT 文件。
10
+ #
11
+ # 用法:
12
+ # geo-doc # 从 ./geodb/ 读取, 输出到 ./geodb/
13
+ # geo-doc -d /data/geodb # 指定数据目录
14
+ # geo-doc -d /data/geodb -o /output # 指定数据目录 + 输出目录
15
+ # geo-doc -d /data/geodb --format csv # 仅输出 CSV
16
+ # geo-doc -d /data/geodb --format txt # 仅输出 TXT
17
+ # geo-doc -d /data/geodb -o /out -f csv # 指定输出目录 + 仅 CSV
18
+ #
19
+ # 参数规则:
20
+ # -d, --data-dir DIR 数据文件目录(含 JSON),默认 ./geodb/
21
+ # -o, --output-dir DIR 输出目录,默认与数据目录相同
22
+ # -f, --format FORMAT 输出格式: csv / txt / both(默认 both)
23
+ # -h, --help 显示帮助
24
+ # -v, --version 显示版本
25
+
26
+ require "optparse"
27
+
28
+ # ---- 命令行解析 -------------------------------------------------------------
29
+ options = { data_dir: nil, output_dir: nil, format: :both }
30
+ parser = OptionParser.new do |opts|
31
+ opts.banner = <<~BAN
32
+ geo-doc — GeoLite2 JSON → 大表导出工具 (network-infra-utility)
33
+
34
+ 将 geo-load 生成的 JSON 拼接为完整 IP 归属大表,按 IPv4/IPv6 分别输出。
35
+
36
+ 用法:
37
+ geo-doc # 从 ./geodb/ 读取并输出
38
+ geo-doc -d /data/geodb # 指定数据目录
39
+ geo-doc -d /data/geodb -o /output # 指定数据目录 + 输出目录
40
+ geo-doc -d /data/geodb -f csv # 仅输出 CSV
41
+ geo-doc -d /data/geodb -f txt # 仅输出 TXT
42
+
43
+ 参数:
44
+ -d, --data-dir DIR 数据文件目录 (含 JSON),默认 ./geodb/
45
+ -o, --output-dir DIR 输出目录,默认与数据目录相同
46
+ -f, --format FORMAT 输出格式: csv / txt / both (默认 both)
47
+
48
+ 选项:
49
+ BAN
50
+ opts.on("-d", "--data-dir DIR", "数据文件目录,默认 ./geodb/") { |v| options[:data_dir] = v }
51
+ opts.on("-o", "--output-dir DIR", "输出目录,默认与数据目录相同") { |v| options[:output_dir] = v }
52
+ opts.on("-f", "--format FORMAT", %w[csv txt both], "输出格式: csv / txt / both (默认 both)") { |v| options[:format] = v.to_sym }
53
+ opts.on("-v", "--version", "显示版本") do
54
+ require_relative "../version"
55
+ puts "geo-doc #{NetworkInfraUtility::VERSION}"
56
+ exit 0
57
+ end
58
+ opts.on("-h", "--help", "显示帮助") do
59
+ puts opts
60
+ exit 0
61
+ end
62
+ end
63
+ parser.parse!
64
+
65
+ # ---- 确定数据目录 -----------------------------------------------------------
66
+ data_dir = options[:data_dir] || ENV["GEODB_DATA_DIR"] || "geodb"
67
+ data_dir = File.expand_path(data_dir)
68
+
69
+ unless File.directory?(data_dir)
70
+ warn "✗ 数据目录不存在: #{data_dir}"
71
+ warn " 请用 -d/--data-dir 指定包含 JSON 数据文件的目录。"
72
+ warn " 期望目录下含: asn.json / city-IPv4.json / city-IPv6.json /"
73
+ warn " country-IPv4.json / country-IPv6.json /"
74
+ warn " geo-city.json / geo-country.json"
75
+ exit 1
76
+ end
77
+
78
+ required = %w[asn.json geo-city.json geo-country.json]
79
+ missing = required.reject { |f| File.exist?(File.join(data_dir, f)) }
80
+ unless missing.empty?
81
+ warn "✗ 数据目录 #{data_dir} 缺少必要文件: #{missing.join(', ')}"
82
+ warn " 请先运行 geo-load 生成 JSON 数据文件。"
83
+ exit 1
84
+ end
85
+
86
+ # ---- 确定输出目录 -----------------------------------------------------------
87
+ if options[:output_dir]
88
+ output_dir = File.expand_path(options[:output_dir])
89
+ unless File.directory?(output_dir)
90
+ begin
91
+ require "fileutils"
92
+ FileUtils.mkdir_p(output_dir)
93
+ rescue SystemCallError => e
94
+ abort "✗ 无法创建输出目录 #{output_dir}: #{e.message}"
95
+ end
96
+ end
97
+ else
98
+ output_dir = nil # nil → 与数据目录相同
99
+ end
100
+
101
+ # ---- 解析 format 参数 -------------------------------------------------------
102
+ case options[:format]
103
+ when :csv then format = [:csv]
104
+ when :txt then format = [:txt]
105
+ when :both then format = [:csv, :txt]
106
+ else
107
+ abort "✗ 不支持的格式: #{options[:format]} (可选: csv / txt / both)"
108
+ end
109
+
110
+ # ---- 加载 GeoDB 模块 --------------------------------------------------------
111
+ ROOT = File.expand_path("..", __dir__)
112
+ $LOAD_PATH.unshift File.join(ROOT, "service", "geodb")
113
+
114
+ require "cc"
115
+ require "network"
116
+ CC.use "file", "enum", "shell-tools"
117
+ require "geodb"
118
+
119
+ # ---- 执行导出 ---------------------------------------------------------------
120
+ require_relative "../version"
121
+ puts "╭─ geo-doc #{NetworkInfraUtility::VERSION} ───────────────────────────────"
122
+ puts "│ 数据目录: #{data_dir}"
123
+ puts "│ 输出目录: #{output_dir || data_dir}"
124
+ puts "│ 输出格式: #{format.join(' + ')}"
125
+ puts "╰─ 开始导出 ─────────────────────────────────────"
126
+ $stdout.flush
127
+
128
+ GeoDB.output(data_dir + "/", format: format, output_path: output_dir ? output_dir + "/" : nil)
129
+
130
+ puts "╭─ 完成 ───────────────────────────────────────"
131
+ out_target = output_dir || data_dir
132
+ puts "│ 大表文件已生成到: #{out_target}"
133
+ puts "│ 文件列表:"
134
+ Dir.glob(File.join(out_target, "output-*.{csv,txt}")).each { |f| puts "│ #{File.basename(f)}" }
135
+ puts "╰─ 可用 geo-get 查询单个 IP 归属信息 ──────────"
data/bin/geo-get CHANGED
@@ -82,7 +82,7 @@ def fetch(base, path, params)
82
82
  res = Net::HTTP.get_response(uri)
83
83
  { code: res.code.to_i, body: (JSON.parse(res.body) rescue res.body) }
84
84
  rescue Errno::ECONNREFUSED
85
- abort "✗ 无法连接 #{base} —— geo-api 服务未启动。\n 启动命令: geo-api -d E:/workspace/momentum/twinklite/data/geodb"
85
+ abort "✗ 无法连接 #{base} —— geo-api 服务未启动。\n 启动命令: geo-api -d ./geodb"
86
86
  rescue => e
87
87
  abort "✗ 请求失败: #{e.class}: #{e.message}"
88
88
  end
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