network-infra-utility 0.1.0 → 0.2.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,227 @@
1
+ # GeoAPI 测试用例
2
+
3
+ > 接口服务文件:`api.rb`
4
+ > 默认地址:`http://localhost:9292`(实际使用时替换 `localhost:9292` 为你的 `HOST:PORT`)
5
+
6
+ ## 返回约定
7
+
8
+ | 状态码 | 含义 | 响应体 |
9
+ |--------|------|--------|
10
+ | 200 | 成功 | 各接口对应的数据结构 |
11
+ | 400 | 参数不合法 | `{"error":"IP不合法"}` / `{"error":"id不合法"}` / `{"error":"缺少参数 ..."}` |
12
+ | 404 | 无结果 | `{"error":"无结果"}` |
13
+
14
+ IP 合法性说明:含 `:` 按 IPv6 解析,否则按 IPv4 解析;非法地址统一返回 `IP不合法`。
15
+
16
+ ---
17
+
18
+ ## (1) ASN — `/geo/asn`
19
+
20
+ ### 1.1 `num` 查询(按 ASN 编号查所有地址段)
21
+
22
+ | # | curl 命令 | 预期结果 |
23
+ |---|-----------|----------|
24
+ | 1.1.1 | `curl -s "localhost:9292/geo/asn?num=13335"` | 200,`asn:"13335"`,`count:2480`,含 `1.0.0.0/24`、`1.1.1.0/24` 等(Cloudflare)|
25
+ | 1.1.2 | `curl -s "localhost:9292/geo/asn?num=38803"` | 200,含 `1.0.4.0/22`(Gtelecom)|
26
+ | 1.1.3 | `curl -s "localhost:9292/geo/asn?num=999999999"` | 200,`count:0`,`results:[]`(无此 ASN)|
27
+ | 1.1.4 | `curl -s "localhost:9292/geo/asn?num=abc"` | 200,`count:0`,`results:[]`(非数字按空结果返回)|
28
+
29
+ ### 1.2 `addr` 查询(按 IP 查所属 AS)
30
+
31
+ | # | curl 命令 | 预期结果 |
32
+ |---|-----------|----------|
33
+ | 1.2.1 | `curl -s "localhost:9292/geo/asn?addr=1.0.0.5"` | 200,`network:"1.0.0.0/24"`,`autonomous_system_number:"13335"` |
34
+ | 1.2.2 | `curl -s "localhost:9292/geo/asn?addr=2606:4700:4700::1111"` | 200,命中 Cloudflare AS13335 的 IPv6 段 |
35
+ | 1.2.3 | `curl -s "localhost:9292/geo/asn?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
36
+ | 1.2.4 | `curl -s "localhost:9292/geo/asn?addr=999.1.1.1"` | 400,`{"error":"IP不合法"}` |
37
+ | 1.2.5 | `curl -s "localhost:9292/geo/asn?addr=nonip"` | 400,`{"error":"IP不合法"}` |
38
+
39
+ ### 1.3 缺参
40
+
41
+ | # | curl 命令 | 预期结果 |
42
+ |---|-----------|----------|
43
+ | 1.3.1 | `curl -s "localhost:9292/geo/asn"` | 400,`{"error":"缺少参数 num 或 addr"}` |
44
+
45
+ ---
46
+
47
+ ## (2) City — `/geo/city?id=`
48
+
49
+ ### 2.1 id 命中
50
+
51
+ | # | curl 命令 | 预期结果 |
52
+ |---|-----------|----------|
53
+ | 2.1.1 | `curl -s "localhost:9292/geo/city?id=11797"` | 200,伊朗(`country_iso_code:"IR"`,`time_zone:"Asia/Tehran"`)|
54
+ | 2.1.2 | `curl -s "localhost:9292/geo/city?id=1814991"` | 200,中国(`country_iso_code:"CN"`,`time_zone:"Asia/Shanghai"`)|
55
+
56
+ ### 2.2 id 不合法
57
+
58
+ | # | curl 命令 | 预期结果 |
59
+ |---|-----------|----------|
60
+ | 2.2.1 | `curl -s "localhost:9292/geo/city?id=abc"` | 400,`{"error":"id不合法"}` |
61
+ | 2.2.2 | `curl -s "localhost:9292/geo/city?id=12a3"` | 400,`{"error":"id不合法"}` |
62
+ | 2.2.3 | `curl -s "localhost:9292/geo/city?id="` | 400,`{"error":"id不合法"}`(空串不匹配 `\d+`)|
63
+
64
+ ### 2.3 id 无结果
65
+
66
+ | # | curl 命令 | 预期结果 |
67
+ |---|-----------|----------|
68
+ | 2.3.1 | `curl -s "localhost:9292/geo/city?id=999999999"` | 404,`{"error":"无结果"}` |
69
+
70
+ ---
71
+
72
+ ## (3) City — `/geo/city?addr=`
73
+
74
+ ### 3.1 IPv4 查询
75
+
76
+ | # | curl 命令 | 预期结果 |
77
+ |---|-----------|----------|
78
+ | 3.1.1 | `curl -s "localhost:9292/geo/city?addr=1.0.1.1"` | 200,`network:"1.0.1.0/24"`,`geoname` 嵌套"中国",含 lat/lng/accuracy_radius |
79
+ | 3.1.2 | `curl -s "localhost:9292/geo/city?addr=1.0.0.5"` | 200,`1.0.0.0/24`,`geoname_id:null`→`geoname:null`,`registered_country` 关联出注册国 |
80
+ | 3.1.3 | `curl -s "localhost:9292/geo/city?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
81
+ | 3.1.4 | `curl -s "localhost:9292/geo/city?addr=999.1.1.1"` | 400,`{"error":"IP不合法"}` |
82
+
83
+ ### 3.2 IPv6 查询
84
+
85
+ | # | curl 命令 | 预期结果 |
86
+ |---|-----------|----------|
87
+ | 3.2.1 | `curl -s "localhost:9292/geo/city?addr=2606:4700:4700::1111"` | 200,命中 Cloudflare IPv6 段,`registered_country` 关联"美国"(US),`geoname`:null |
88
+
89
+ ### 3.3 缺参
90
+
91
+ | # | curl 命令 | 预期结果 |
92
+ |---|-----------|----------|
93
+ | 3.3.1 | `curl -s "localhost:9292/geo/city"` | 400,`{"error":"缺少参数 id 或 addr"}` |
94
+
95
+ ---
96
+
97
+ ## (4) Country — `/geo/country?id=`
98
+
99
+ ### 4.1 id 命中
100
+
101
+ | # | curl 命令 | 预期结果 |
102
+ |---|-----------|----------|
103
+ | 4.1.1 | `curl -s "localhost:9292/geo/country?id=49518"` | 200,`country_iso_code:"RW"`,`country_name:"卢旺达"` |
104
+ | 4.1.2 | `curl -s "localhost:9292/geo/country?id=6252001"` | 200,`country_iso_code:"US"`,`country_name:"美国"` |
105
+ | 4.1.3 | `curl -s "localhost:9292/geo/country?id=1814991"` | 200,`country_iso_code:"CN"`,`country_name:"中国"` |
106
+
107
+ ### 4.2 id 不合法
108
+
109
+ | # | curl 命令 | 预期结果 |
110
+ |---|-----------|----------|
111
+ | 4.2.1 | `curl -s "localhost:9292/geo/country?id=abc"` | 400,`{"error":"id不合法"}` |
112
+ | 4.2.2 | `curl -s "localhost:9292/geo/country?id=12.3"` | 400,`{"error":"id不合法"}` |
113
+ | 4.2.3 | `curl -s "localhost:9292/geo/country?id="` | 400,`{"error":"id不合法"}` |
114
+
115
+ ### 4.3 id 无结果
116
+
117
+ | # | curl 命令 | 预期结果 |
118
+ |---|-----------|----------|
119
+ | 4.3.1 | `curl -s "localhost:9292/geo/country?id=1"` | 404,`{"error":"无结果"}` |
120
+
121
+ ---
122
+
123
+ ## (5) Country — `/geo/country?addr=`
124
+
125
+ ### 5.1 IPv4 命中
126
+
127
+ | # | curl 命令 | 预期结果 |
128
+ |---|-----------|----------|
129
+ | 5.1.1 | `curl -s "localhost:9292/geo/country?addr=1.0.1.1"` | 200,`network:"1.0.1.0/24"`,`geoname`+`registered_country` 均关联"中国" |
130
+
131
+ ### 5.2 IPv6 命中
132
+
133
+ | # | curl 命令 | 预期结果 |
134
+ |---|-----------|----------|
135
+ | 5.2.1 | `curl -s "localhost:9292/geo/country?addr=2606:4700:4700::1111"` | 200,`network:"2606:4700:4700::/48"`,`registered_country`="美国"(US),`geoname`:null |
136
+ | 5.2.2 | `curl -s "localhost:9292/geo/country?addr=2001:200::1"` | 200,`network:"2001:200::/32"`,`geoname`+`registered_country`="日本"(JP)|
137
+
138
+ ### 5.3 无结果
139
+
140
+ | # | curl 命令 | 预期结果 |
141
+ |---|-----------|----------|
142
+ | 5.3.1 | `curl -s "localhost:9292/geo/country?addr=::1"` | 404,`{"error":"无结果"}` |
143
+ | 5.3.2 | `curl -s "localhost:9292/geo/country?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
144
+
145
+ ### 5.4 IP 不合法
146
+
147
+ | # | curl 命令 | 预期结果 |
148
+ |---|-----------|----------|
149
+ | 5.4.1 | `curl -s "localhost:9292/geo/country?addr=gggg::1"` | 400,`{"error":"IP不合法"}` |
150
+ | 5.4.2 | `curl -s "localhost:9292/geo/country?addr=nonip"` | 400,`{"error":"IP不合法"}` |
151
+
152
+ ### 5.5 容错与缺参
153
+
154
+ | # | curl 命令 | 预期结果 |
155
+ |---|-----------|----------|
156
+ | 5.5.1 | `curl -s "localhost:9292/geo/country?addr=%201.0.1.1"` | 200,命中"中国"(strip 容错)|
157
+ | 5.5.2 | `curl -s "localhost:9292/geo/country"` | 400,`{"error":"缺少参数 id 或 addr"}` |
158
+
159
+ ---
160
+
161
+ ## 附:响应结构示例
162
+
163
+ ### ASN `num` 成功响应
164
+ ```json
165
+ {
166
+ "asn": "13335",
167
+ "count": 2480,
168
+ "results": [
169
+ {
170
+ "network": "1.0.0.0/24",
171
+ "autonomous_system_number": "13335",
172
+ "autonomous_system_organization": "Cloudflare, Inc."
173
+ }
174
+ ]
175
+ }
176
+ ```
177
+
178
+ ### ASN `addr` / IP 查所属 AS
179
+ ```json
180
+ {
181
+ "network": "1.0.0.0/24",
182
+ "autonomous_system_number": "13335",
183
+ "autonomous_system_organization": "Cloudflare, Inc."
184
+ }
185
+ ```
186
+
187
+ ### City / Country `addr` 成功响应(含 geo 关联)
188
+ ```json
189
+ {
190
+ "network": "1.0.1.0/24",
191
+ "geoname_id": "1814991",
192
+ "registered_country_geoname_id": "1814991",
193
+ "represented_country_geoname_id": null,
194
+ "is_anonymous_proxy": "0",
195
+ "is_satellite_provider": "0",
196
+ "is_anycast": null,
197
+ "geoname": {
198
+ "geoname_id": "1814991",
199
+ "locale_code": "zh-CN",
200
+ "continent_code": "AS",
201
+ "continent_name": "亚洲",
202
+ "country_iso_code": "CN",
203
+ "country_name": "中国",
204
+ "is_in_european_union": "0"
205
+ },
206
+ "registered_country": {
207
+ "geoname_id": "1814991",
208
+ "locale_code": "zh-CN",
209
+ "continent_code": "AS",
210
+ "continent_name": "亚洲",
211
+ "country_iso_code": "CN",
212
+ "country_name": "中国",
213
+ "is_in_european_union": "0"
214
+ },
215
+ "represented_country": null
216
+ }
217
+ ```
218
+
219
+ ---
220
+
221
+ ## 备注
222
+
223
+ 1. **ASN 的 `num` 非数字**:需求第(1)点只要求 city/country 的 id 做数字校验,未规定 asn 的 num。当前实现在 `num=abc` 时返回 `count:0`(空结果,状态 200)。如需返回 `400 ASN编号不合法`,可在 `api.rb` 中加一行校验。
224
+
225
+ 2. **city-IPv6.json**:当前 `geodb/` 目录下已生成此文件,IPv6 city 查询可正常命中。文件缺失时服务端会容错返回 `无结果`(404),不影响其他接口。
226
+
227
+ 3. **加载耗时(首次,含 JSON 解析)**:asn 约 3.5s / country-IPv4 约 2.9s / country-IPv6 约 3.6s / city-IPv4 约 22.8s,之后常驻内存走缓存,查询耗时约 0ms。
@@ -0,0 +1,276 @@
1
+ #coding:utf-8
2
+ # GeoDB Roda API — IP 归属信息查询服务
3
+ #
4
+ # 作为 network-infra-utility 的命令行服务 (geo-api) 运行,
5
+ # 亦可作为 rackup 配置由 config.ru 启动。
6
+ #
7
+ # 数据文件目录通过环境变量 GEODB_DATA_DIR 指定,默认 ./geodb/
8
+ # 用 -d/--data-dir 参数 (命令行) 或设置该环境变量可指向任意位置。
9
+ ['cc','CasetDown/casetdown','network','roda'].each{|mod| require mod}
10
+
11
+ module GeoDB
12
+ module_function
13
+
14
+ # 数据文件所在目录。
15
+ # 优先级: ENV['GEODB_DATA_DIR'] > ./geodb/
16
+ # 返回绝对路径并以分隔符结尾,便于直接拼接文件名。
17
+ # 与 geodb.rb 的 $prefix 含义一致,但此处只读不写。
18
+ def data_dir
19
+ d = ENV['GEODB_DATA_DIR'].to_s
20
+ d = 'geodb' if d.empty?
21
+ File.expand_path(d) + File::SEPARATOR
22
+ end
23
+
24
+ # 缓存: name => 已加载并处理好的结构
25
+ @store = {}
26
+ # asn 反向索引: autonomous_system_number => [record, ...]
27
+ @asn_index = {}
28
+ @lock = Mutex.new
29
+
30
+ # ---- 加载 ---------------------------------------------------------------
31
+
32
+ # 加载范围型 JSON (asn / city-IPv4 / city-IPv6 / country-IPv4 / country-IPv6)
33
+ # 返回按 start 升序的数组: [[start_num, end_num, record], ...]
34
+ # JSON 的键形如 "[16777217, 16777471]" (Ruby 数组键序列化后的字符串),
35
+ # 用 JSON.parse 还原为 [start, end] 两个整数.
36
+ def ranges(name)
37
+ @lock.synchronize do
38
+ return @store[name] if @store.key?(name)
39
+ path = "#{data_dir}#{name}.json"
40
+ unless File.exist?(path)
41
+ @store[name] = nil
42
+ return nil
43
+ end
44
+ raw = JSON.parse(File.binread(path))
45
+ arr = raw.map do |key, record|
46
+ start_num, end_num = JSON.parse(key)
47
+ [start_num.to_i, end_num.to_i, record]
48
+ end
49
+ arr.sort_by! { |s, _, _| s }
50
+ @store[name] = arr
51
+ # 为 asn 建立反向索引, num 查询 O(1)
52
+ if name == 'asn'
53
+ @asn_index.clear
54
+ arr.each do |_, _, r|
55
+ asn = r['autonomous_system_number']
56
+ (@asn_index[asn] ||= []) << r
57
+ end
58
+ end
59
+ arr
60
+ end
61
+ end
62
+
63
+ # 加载地理位置型 JSON (geo-city / geo-country)
64
+ # 返回扁平 Hash: { geoname_id_string => record }
65
+ def geo(name)
66
+ @lock.synchronize do
67
+ return @store[name] if @store.key?(name)
68
+ path = "#{data_dir}#{name}.json"
69
+ unless File.exist?(path)
70
+ @store[name] = nil
71
+ return nil
72
+ end
73
+ @store[name] = JSON.parse(File.binread(path))
74
+ end
75
+ end
76
+
77
+ # ---- IP 解析 ------------------------------------------------------------
78
+
79
+ # 把 IP 地址转为数字. 合法返回 Integer, 非法返回 nil.
80
+ # 自有库: IP.v4("1.2.3.4").number / IP.v6("::1").number
81
+ # 含冒号按 IPv6 处理, 否则按 IPv4 处理.
82
+ # 说明: 自有库对非法地址通常不抛异常而是返回非 Integer(nil/空串),
83
+ # 仅部分 IPv6 非法输入会抛 IPAddr::InvalidAddressError, 故双重兜底.
84
+ def ip_number(addr)
85
+ s = addr.to_s.strip
86
+ return nil if s.empty?
87
+ begin
88
+ n = s.include?(':') ? IP.v6(s).number : IP.v4(s).number
89
+ n.is_a?(Integer) ? n : nil
90
+ rescue
91
+ nil
92
+ end
93
+ end
94
+
95
+ # 判断地址族: 含冒号为 IPv6, 否则 IPv4
96
+ def ipv6?(addr)
97
+ addr.to_s.include?(':')
98
+ end
99
+
100
+ # ---- 范围二分查找 -------------------------------------------------------
101
+
102
+ # 在 ranges(name) 中查找包含 number 的范围, 返回对应 record, 未命中返回 nil.
103
+ # 范围互不重叠且按 start 升序, 用 bsearch_index 找最后一个 start<=number
104
+ # 的元素, 再校验 number<=end.
105
+ def find_range(name, number)
106
+ arr = ranges(name)
107
+ return nil unless arr && !arr.empty?
108
+ idx = arr.bsearch_index { |s, _, _| s > number }
109
+ cand = idx.nil? ? arr.last : (idx > 0 ? arr[idx - 1] : nil)
110
+ return nil unless cand
111
+ s, e, r = cand
112
+ (number >= s && number <= e) ? r : nil
113
+ end
114
+
115
+ # ---- ASN 接口 (1) -------------------------------------------------------
116
+
117
+ # /geo/asn?num=XXX 查出该 AS 的所有地址段
118
+ def asn_by_num(num)
119
+ ranges('asn') # 触发加载并建立反向索引
120
+ @asn_index[num.to_s] || []
121
+ end
122
+
123
+ # /geo/asn?addr=X.X.X.X 按 IP 查所属 AS
124
+ # 返回: :invalid(IP不合法) / nil(无结果) / record
125
+ def asn_by_addr(addr)
126
+ num = ip_number(addr)
127
+ return :invalid unless num
128
+ find_range('asn', num)
129
+ end
130
+
131
+ # ---- City 接口 (2)(3) ---------------------------------------------------
132
+
133
+ # /geo/city?id=XXX
134
+ # 返回: :invalid(id不合法) / nil(无结果) / record
135
+ def city_by_id(id)
136
+ return :invalid unless id.to_s.match?(/\A\d+\z/)
137
+ g = geo('geo-city')
138
+ g ? g[id.to_s] : nil
139
+ end
140
+
141
+ # /geo/city?addr=X.X.X.X 先查 city-IPv4/city-IPv6, 再关联 geo-city
142
+ # 返回: :invalid / nil / enriched_record
143
+ def city_by_addr(addr)
144
+ num = ip_number(addr)
145
+ return :invalid unless num
146
+ name = ipv6?(addr) ? 'city-IPv6' : 'city-IPv4'
147
+ r = find_range(name, num)
148
+ return nil unless r
149
+ enrich(r, 'geo-city')
150
+ end
151
+
152
+ # ---- Country 接口 (4)(5) ------------------------------------------------
153
+
154
+ # /geo/country?id=XXX
155
+ def country_by_id(id)
156
+ return :invalid unless id.to_s.match?(/\A\d+\z/)
157
+ g = geo('geo-country')
158
+ g ? g[id.to_s] : nil
159
+ end
160
+
161
+ # /geo/country?addr=X.X.X.X 先查 country-IPv4/country-IPv6, 再关联 geo-country
162
+ def country_by_addr(addr)
163
+ num = ip_number(addr)
164
+ return :invalid unless num
165
+ name = ipv6?(addr) ? 'country-IPv6' : 'country-IPv4'
166
+ r = find_range(name, num)
167
+ return nil unless r
168
+ enrich(r, 'geo-country')
169
+ end
170
+
171
+ # ---- 关联聚合 -----------------------------------------------------------
172
+
173
+ # 把范围型 record 与地理位置详情聚合:
174
+ # 保留 record 原字段, 并按 geoname_id / registered_country_geoname_id /
175
+ # represented_country_geoname_id 三个字段分别关联 geo 表, 输出为
176
+ # geoname / registered_country / represented_country 三个嵌套对象.
177
+ def enrich(record, geo_name)
178
+ g = geo(geo_name)
179
+ result = {}
180
+ record.each { |k, v| result[k] = v }
181
+ {
182
+ 'geoname_id' => 'geoname',
183
+ 'registered_country_geoname_id' => 'registered_country',
184
+ 'represented_country_geoname_id'=> 'represented_country'
185
+ }.each do |id_key, out_key|
186
+ id = record[id_key]
187
+ result[out_key] = (id && g) ? g[id] : nil
188
+ end
189
+ result
190
+ end
191
+
192
+ # 预热: 启动时后台加载指定文件, 避免首次查询卡顿. 不调用则惰性加载.
193
+ def preload(*names)
194
+ names.each { |n| Thread.new { ranges(n) rescue geo(n) rescue nil } }
195
+ end
196
+ end
197
+
198
+
199
+ class GeoAPI < Roda
200
+ plugin :json # 返回 Hash/Array 自动转 JSON
201
+
202
+ IDS = %w[geoname_id registered_country_geoname_id represented_country_geoname_id].freeze
203
+
204
+ route do |r|
205
+ r.root do
206
+ {
207
+ service: 'GeoDB API',
208
+ data_dir: GeoDB.data_dir,
209
+ endpoints: {
210
+ 'asn' => '/geo/asn?num=XXX | /geo/asn?addr=X.X.X.X',
211
+ 'city' => '/geo/city?id=XXX | /geo/city?addr=X.X.X.X',
212
+ 'country' => '/geo/country?id=XXX | /geo/country?addr=X.X.X.X'
213
+ }
214
+ }
215
+ end
216
+
217
+ r.on 'geo' do
218
+ r.on 'asn' do
219
+ if r.params.key?('num')
220
+ res = GeoDB.asn_by_num(r.params['num'])
221
+ { asn: r.params['num'], count: res.length, results: res }
222
+ elsif r.params.key?('addr')
223
+ case (res = GeoDB.asn_by_addr(r.params['addr']))
224
+ when :invalid then response.status = 400; { error: 'IP不合法' }
225
+ when nil then response.status = 404; { error: '无结果' }
226
+ else res
227
+ end
228
+ else
229
+ response.status = 400
230
+ { error: '缺少参数 num 或 addr' }
231
+ end
232
+ end
233
+
234
+ r.on 'city' do
235
+ if r.params.key?('id')
236
+ case (res = GeoDB.city_by_id(r.params['id']))
237
+ when :invalid then response.status = 400; { error: 'id不合法' }
238
+ when nil then response.status = 404; { error: '无结果' }
239
+ else res
240
+ end
241
+ elsif r.params.key?('addr')
242
+ case (res = GeoDB.city_by_addr(r.params['addr']))
243
+ when :invalid then response.status = 400; { error: 'IP不合法' }
244
+ when nil then response.status = 404; { error: '无结果' }
245
+ else res
246
+ end
247
+ else
248
+ response.status = 400
249
+ { error: '缺少参数 id 或 addr' }
250
+ end
251
+ end
252
+
253
+ r.on 'country' do
254
+ if r.params.key?('id')
255
+ case (res = GeoDB.country_by_id(r.params['id']))
256
+ when :invalid then response.status = 400; { error: 'id不合法' }
257
+ when nil then response.status = 404; { error: '无结果' }
258
+ else res
259
+ end
260
+ elsif r.params.key?('addr')
261
+ case (res = GeoDB.country_by_addr(r.params['addr']))
262
+ when :invalid then response.status = 400; { error: 'IP不合法' }
263
+ when nil then response.status = 404; { error: '无结果' }
264
+ else res
265
+ end
266
+ else
267
+ response.status = 400
268
+ { error: '缺少参数 id 或 addr' }
269
+ end
270
+ end
271
+ end
272
+
273
+ response.status = 404
274
+ { error: 'not found' }
275
+ end
276
+ end
@@ -0,0 +1,15 @@
1
+ #coding:utf-8
2
+ # GeoDB Roda API — rackup 配置入口
3
+ #
4
+ # 启动方式:
5
+ # rackup -o 0.0.0.0 -p 9292 service/geodb/config.ru
6
+ # rackup -o 0.0.0.0 -p 9292 -E production service/geodb/config.ru
7
+ #
8
+ # 也可通过 gem 命令行服务启动 (推荐):
9
+ # geo-api # 默认 ./geodb/ + 0.0.0.0:9292
10
+ # geo-api -d /data/ipdb -p 8080 # 指定数据目录与端口
11
+ #
12
+ # 数据目录通过环境变量 GEODB_DATA_DIR 指定,默认 ./geodb/
13
+ require_relative 'api'
14
+
15
+ run GeoAPI.app
@@ -0,0 +1,97 @@
1
+ #coding:utf-8
2
+ ['cc','CasetDown/casetdown','network'].each{|mod|require mod}
3
+ CC.use 'file','enum','shell-tools'
4
+
5
+ # USAGE>
6
+ # GeoDB.load_asn("GeoLite2-ASN-CSV_*/*.csv")
7
+ # GeoDB.load_city("GeoLite2-City-CSV_*/*.csv")
8
+ # GeoDB.load_country("GeoLite2-Country-CSV_*/*.csv")
9
+
10
+ module GeoDB
11
+ module_function
12
+
13
+ def load_asn dir_path="GeoLite2-ASN-CSV_*/*.csv", out_path=nil
14
+ out_path = "geodb/" unless out_path
15
+ Dir.mkdir(out_path) unless File.exist?(out_path)
16
+ asn = {}
17
+ Dir[dir_path].each do|path|
18
+ table = CSV.parse File.read(path)
19
+ head = table.first
20
+ bar = ProgressBar.new(table.length-1, title: "#{path.split("/").last}", theme: :classic, color: true)
21
+ table[1..-1].mapping(head).each do|record|
22
+ range = IP.range(record['network']).map{|ip|ip.number}
23
+ asn[range] = record
24
+ bar.update
25
+ end
26
+ bar.finish(message: "✨ 处理了 #{table.length-1} 条记录.")
27
+ end
28
+ File.write "#{out_path}asn.json", JSON.pretty_generate(asn)
29
+ return asn
30
+ end
31
+
32
+ def load_city dir_path="GeoLite2-City-CSV_*/*.csv", out_path=nil
33
+ out_path = "geodb/" unless out_path
34
+ Dir.mkdir(out_path) unless File.exist?(out_path)
35
+ city = {};geo = {}
36
+ Dir[dir_path].each do|path|
37
+ next unless path.include?("City-Locations-zh-CN")
38
+ table = CSV.parse File.read(path)
39
+ bar = ProgressBar.new(table.length-1, title: "#{path.split("/").last}", theme: :classic, color: true)
40
+ table[1..-1].mapping(table.first).each do|record|
41
+ geo[record['geoname_id']] = record
42
+ bar.update
43
+ end
44
+ bar.finish(message: "✨ 处理了 #{table.length-1} 条记录.")
45
+ end
46
+ File.write "#{out_path}geo-city.json", JSON.pretty_generate(geo)
47
+
48
+ Dir[dir_path].each do|path|
49
+ next unless path.include?("City-Blocks")
50
+ postfix = 'IPv4' if path.include?('IPv4')
51
+ postfix = 'IPv6' if path.include?('IPv6')
52
+ table = CSV.parse File.read(path)
53
+ bar = ProgressBar.new(table.length-1, title: "#{path.split("/").last}", theme: :classic, color: true)
54
+ table[1..-1].mapping(table.first).each do|record|
55
+ range = IP.range(record['network']).map{|ip|ip.number}
56
+ city[range] = record
57
+ bar.update
58
+ end
59
+ bar.finish(message: "✨ 处理了 #{table.length-1} 条记录.")
60
+ File.write "#{out_path}city-#{postfix}.json", city.to_json # JSON.pretty_generate(city)
61
+ end
62
+ return city,geo
63
+ end
64
+
65
+ def load_country dir_path="GeoLite2-Country-CSV_*/*.csv", out_path=nil
66
+ out_path = "geodb/" unless out_path
67
+ Dir.mkdir(out_path) unless File.exist?(out_path)
68
+ country = {};geo = {}
69
+ Dir[dir_path].each do|path|
70
+ next unless path.include?("Country-Locations-zh-CN")
71
+ table = CSV.parse File.read(path)
72
+ bar = ProgressBar.new(table.length-1, title: "#{path.split("/").last}", theme: :classic, color: true)
73
+ table[1..-1].mapping(table.first).each do|record|
74
+ geo[record['geoname_id']] = record
75
+ bar.update
76
+ end
77
+ bar.finish(message: "✨ 处理了 #{table.length-1} 条记录.")
78
+ end
79
+ File.write "#{out_path}geo-country.json", JSON.pretty_generate(geo)
80
+
81
+ Dir[dir_path].each do|path|
82
+ next unless path.include?("Country-Blocks")
83
+ postfix = 'IPv4' if path.include?('IPv4')
84
+ postfix = 'IPv6' if path.include?('IPv6')
85
+ table = CSV.parse File.read(path)
86
+ bar = ProgressBar.new(table.length-1, title: "#{path.split("/").last}", theme: :classic, color: true)
87
+ table[1..-1].mapping(table.first).each do|record|
88
+ range = IP.range(record['network']).map{|ip|ip.number}
89
+ country[range] = record
90
+ bar.update
91
+ end
92
+ bar.finish(message: "✨ 处理了 #{table.length-1} 条记录.")
93
+ File.write "#{out_path}country-#{postfix}.json", JSON.pretty_generate(country)
94
+ end
95
+ return country,geo
96
+ end
97
+ end