network-infra-utility 0.1.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -4
- data/README.md +0 -12
- data/bin/geo-api +114 -0
- data/bin/geo-get +135 -0
- data/bin/geo-load +190 -0
- data/network-infra-utility.gemspec +9 -2
- data/network.rb +1 -1
- data/service/geodb/GeoAPI.md +228 -0
- data/service/geodb/api.rb +276 -0
- data/service/geodb/config.ru +15 -0
- data/service/geodb/geodb.rb +97 -0
- data/support/basic/ip.rb +88 -0
- data/support/basic/ipv4_address.rb +336 -0
- data/support/basic/ipv6_address.rb +244 -0
- data/version.rb +1 -1
- metadata +59 -4
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
|
|
2
|
+
# GeoAPI 测试用例
|
|
3
|
+
|
|
4
|
+
> 接口服务文件:`api.rb`
|
|
5
|
+
> 默认地址:`http://localhost:9292`(实际使用时替换 `localhost:9292` 为你的 `HOST:PORT`)
|
|
6
|
+
|
|
7
|
+
## 返回约定
|
|
8
|
+
|
|
9
|
+
| 状态码 | 含义 | 响应体 |
|
|
10
|
+
|--------|------|--------|
|
|
11
|
+
| 200 | 成功 | 各接口对应的数据结构 |
|
|
12
|
+
| 400 | 参数不合法 | `{"error":"IP不合法"}` / `{"error":"id不合法"}` / `{"error":"缺少参数 ..."}` |
|
|
13
|
+
| 404 | 无结果 | `{"error":"无结果"}` |
|
|
14
|
+
|
|
15
|
+
IP 合法性说明:含 `:` 按 IPv6 解析,否则按 IPv4 解析;非法地址统一返回 `IP不合法`。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## (1) ASN — `/geo/asn`
|
|
20
|
+
|
|
21
|
+
### 1.1 `num` 查询(按 ASN 编号查所有地址段)
|
|
22
|
+
|
|
23
|
+
| # | curl 命令 | 预期结果 |
|
|
24
|
+
|---|-----------|----------|
|
|
25
|
+
| 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)|
|
|
26
|
+
| 1.1.2 | `curl -s "localhost:9292/geo/asn?num=38803"` | 200,含 `1.0.4.0/22`(Gtelecom)|
|
|
27
|
+
| 1.1.3 | `curl -s "localhost:9292/geo/asn?num=999999999"` | 200,`count:0`,`results:[]`(无此 ASN)|
|
|
28
|
+
| 1.1.4 | `curl -s "localhost:9292/geo/asn?num=abc"` | 200,`count:0`,`results:[]`(非数字按空结果返回)|
|
|
29
|
+
|
|
30
|
+
### 1.2 `addr` 查询(按 IP 查所属 AS)
|
|
31
|
+
|
|
32
|
+
| # | curl 命令 | 预期结果 |
|
|
33
|
+
|---|-----------|----------|
|
|
34
|
+
| 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"` |
|
|
35
|
+
| 1.2.2 | `curl -s "localhost:9292/geo/asn?addr=2606:4700:4700::1111"` | 200,命中 Cloudflare AS13335 的 IPv6 段 |
|
|
36
|
+
| 1.2.3 | `curl -s "localhost:9292/geo/asn?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
|
|
37
|
+
| 1.2.4 | `curl -s "localhost:9292/geo/asn?addr=999.1.1.1"` | 400,`{"error":"IP不合法"}` |
|
|
38
|
+
| 1.2.5 | `curl -s "localhost:9292/geo/asn?addr=nonip"` | 400,`{"error":"IP不合法"}` |
|
|
39
|
+
|
|
40
|
+
### 1.3 缺参
|
|
41
|
+
|
|
42
|
+
| # | curl 命令 | 预期结果 |
|
|
43
|
+
|---|-----------|----------|
|
|
44
|
+
| 1.3.1 | `curl -s "localhost:9292/geo/asn"` | 400,`{"error":"缺少参数 num 或 addr"}` |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## (2) City — `/geo/city?id=`
|
|
49
|
+
|
|
50
|
+
### 2.1 id 命中
|
|
51
|
+
|
|
52
|
+
| # | curl 命令 | 预期结果 |
|
|
53
|
+
|---|-----------|----------|
|
|
54
|
+
| 2.1.1 | `curl -s "localhost:9292/geo/city?id=11797"` | 200,伊朗(`country_iso_code:"IR"`,`time_zone:"Asia/Tehran"`)|
|
|
55
|
+
| 2.1.2 | `curl -s "localhost:9292/geo/city?id=1814991"` | 200,中国(`country_iso_code:"CN"`,`time_zone:"Asia/Shanghai"`)|
|
|
56
|
+
|
|
57
|
+
### 2.2 id 不合法
|
|
58
|
+
|
|
59
|
+
| # | curl 命令 | 预期结果 |
|
|
60
|
+
|---|-----------|----------|
|
|
61
|
+
| 2.2.1 | `curl -s "localhost:9292/geo/city?id=abc"` | 400,`{"error":"id不合法"}` |
|
|
62
|
+
| 2.2.2 | `curl -s "localhost:9292/geo/city?id=12a3"` | 400,`{"error":"id不合法"}` |
|
|
63
|
+
| 2.2.3 | `curl -s "localhost:9292/geo/city?id="` | 400,`{"error":"id不合法"}`(空串不匹配 `\d+`)|
|
|
64
|
+
|
|
65
|
+
### 2.3 id 无结果
|
|
66
|
+
|
|
67
|
+
| # | curl 命令 | 预期结果 |
|
|
68
|
+
|---|-----------|----------|
|
|
69
|
+
| 2.3.1 | `curl -s "localhost:9292/geo/city?id=999999999"` | 404,`{"error":"无结果"}` |
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## (3) City — `/geo/city?addr=`
|
|
74
|
+
|
|
75
|
+
### 3.1 IPv4 查询
|
|
76
|
+
|
|
77
|
+
| # | curl 命令 | 预期结果 |
|
|
78
|
+
|---|-----------|----------|
|
|
79
|
+
| 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 |
|
|
80
|
+
| 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` 关联出注册国 |
|
|
81
|
+
| 3.1.3 | `curl -s "localhost:9292/geo/city?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
|
|
82
|
+
| 3.1.4 | `curl -s "localhost:9292/geo/city?addr=999.1.1.1"` | 400,`{"error":"IP不合法"}` |
|
|
83
|
+
|
|
84
|
+
### 3.2 IPv6 查询
|
|
85
|
+
|
|
86
|
+
| # | curl 命令 | 预期结果 |
|
|
87
|
+
|---|-----------|----------|
|
|
88
|
+
| 3.2.1 | `curl -s "localhost:9292/geo/city?addr=2606:4700:4700::1111"` | 200,命中 Cloudflare IPv6 段,`registered_country` 关联"美国"(US),`geoname`:null |
|
|
89
|
+
|
|
90
|
+
### 3.3 缺参
|
|
91
|
+
|
|
92
|
+
| # | curl 命令 | 预期结果 |
|
|
93
|
+
|---|-----------|----------|
|
|
94
|
+
| 3.3.1 | `curl -s "localhost:9292/geo/city"` | 400,`{"error":"缺少参数 id 或 addr"}` |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## (4) Country — `/geo/country?id=`
|
|
99
|
+
|
|
100
|
+
### 4.1 id 命中
|
|
101
|
+
|
|
102
|
+
| # | curl 命令 | 预期结果 |
|
|
103
|
+
|---|-----------|----------|
|
|
104
|
+
| 4.1.1 | `curl -s "localhost:9292/geo/country?id=49518"` | 200,`country_iso_code:"RW"`,`country_name:"卢旺达"` |
|
|
105
|
+
| 4.1.2 | `curl -s "localhost:9292/geo/country?id=6252001"` | 200,`country_iso_code:"US"`,`country_name:"美国"` |
|
|
106
|
+
| 4.1.3 | `curl -s "localhost:9292/geo/country?id=1814991"` | 200,`country_iso_code:"CN"`,`country_name:"中国"` |
|
|
107
|
+
|
|
108
|
+
### 4.2 id 不合法
|
|
109
|
+
|
|
110
|
+
| # | curl 命令 | 预期结果 |
|
|
111
|
+
|---|-----------|----------|
|
|
112
|
+
| 4.2.1 | `curl -s "localhost:9292/geo/country?id=abc"` | 400,`{"error":"id不合法"}` |
|
|
113
|
+
| 4.2.2 | `curl -s "localhost:9292/geo/country?id=12.3"` | 400,`{"error":"id不合法"}` |
|
|
114
|
+
| 4.2.3 | `curl -s "localhost:9292/geo/country?id="` | 400,`{"error":"id不合法"}` |
|
|
115
|
+
|
|
116
|
+
### 4.3 id 无结果
|
|
117
|
+
|
|
118
|
+
| # | curl 命令 | 预期结果 |
|
|
119
|
+
|---|-----------|----------|
|
|
120
|
+
| 4.3.1 | `curl -s "localhost:9292/geo/country?id=1"` | 404,`{"error":"无结果"}` |
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## (5) Country — `/geo/country?addr=`
|
|
125
|
+
|
|
126
|
+
### 5.1 IPv4 命中
|
|
127
|
+
|
|
128
|
+
| # | curl 命令 | 预期结果 |
|
|
129
|
+
|---|-----------|----------|
|
|
130
|
+
| 5.1.1 | `curl -s "localhost:9292/geo/country?addr=1.0.1.1"` | 200,`network:"1.0.1.0/24"`,`geoname`+`registered_country` 均关联"中国" |
|
|
131
|
+
|
|
132
|
+
### 5.2 IPv6 命中
|
|
133
|
+
|
|
134
|
+
| # | curl 命令 | 预期结果 |
|
|
135
|
+
|---|-----------|----------|
|
|
136
|
+
| 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 |
|
|
137
|
+
| 5.2.2 | `curl -s "localhost:9292/geo/country?addr=2001:200::1"` | 200,`network:"2001:200::/32"`,`geoname`+`registered_country`="日本"(JP)|
|
|
138
|
+
|
|
139
|
+
### 5.3 无结果
|
|
140
|
+
|
|
141
|
+
| # | curl 命令 | 预期结果 |
|
|
142
|
+
|---|-----------|----------|
|
|
143
|
+
| 5.3.1 | `curl -s "localhost:9292/geo/country?addr=::1"` | 404,`{"error":"无结果"}` |
|
|
144
|
+
| 5.3.2 | `curl -s "localhost:9292/geo/country?addr=0.0.0.0"` | 404,`{"error":"无结果"}` |
|
|
145
|
+
|
|
146
|
+
### 5.4 IP 不合法
|
|
147
|
+
|
|
148
|
+
| # | curl 命令 | 预期结果 |
|
|
149
|
+
|---|-----------|----------|
|
|
150
|
+
| 5.4.1 | `curl -s "localhost:9292/geo/country?addr=gggg::1"` | 400,`{"error":"IP不合法"}` |
|
|
151
|
+
| 5.4.2 | `curl -s "localhost:9292/geo/country?addr=nonip"` | 400,`{"error":"IP不合法"}` |
|
|
152
|
+
|
|
153
|
+
### 5.5 容错与缺参
|
|
154
|
+
|
|
155
|
+
| # | curl 命令 | 预期结果 |
|
|
156
|
+
|---|-----------|----------|
|
|
157
|
+
| 5.5.1 | `curl -s "localhost:9292/geo/country?addr=%201.0.1.1"` | 200,命中"中国"(strip 容错)|
|
|
158
|
+
| 5.5.2 | `curl -s "localhost:9292/geo/country"` | 400,`{"error":"缺少参数 id 或 addr"}` |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 附:响应结构示例
|
|
163
|
+
|
|
164
|
+
### ASN `num` 成功响应
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"asn": "13335",
|
|
168
|
+
"count": 2480,
|
|
169
|
+
"results": [
|
|
170
|
+
{
|
|
171
|
+
"network": "1.0.0.0/24",
|
|
172
|
+
"autonomous_system_number": "13335",
|
|
173
|
+
"autonomous_system_organization": "Cloudflare, Inc."
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### ASN `addr` / IP 查所属 AS
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"network": "1.0.0.0/24",
|
|
183
|
+
"autonomous_system_number": "13335",
|
|
184
|
+
"autonomous_system_organization": "Cloudflare, Inc."
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### City / Country `addr` 成功响应(含 geo 关联)
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"network": "1.0.1.0/24",
|
|
192
|
+
"geoname_id": "1814991",
|
|
193
|
+
"registered_country_geoname_id": "1814991",
|
|
194
|
+
"represented_country_geoname_id": null,
|
|
195
|
+
"is_anonymous_proxy": "0",
|
|
196
|
+
"is_satellite_provider": "0",
|
|
197
|
+
"is_anycast": null,
|
|
198
|
+
"geoname": {
|
|
199
|
+
"geoname_id": "1814991",
|
|
200
|
+
"locale_code": "zh-CN",
|
|
201
|
+
"continent_code": "AS",
|
|
202
|
+
"continent_name": "亚洲",
|
|
203
|
+
"country_iso_code": "CN",
|
|
204
|
+
"country_name": "中国",
|
|
205
|
+
"is_in_european_union": "0"
|
|
206
|
+
},
|
|
207
|
+
"registered_country": {
|
|
208
|
+
"geoname_id": "1814991",
|
|
209
|
+
"locale_code": "zh-CN",
|
|
210
|
+
"continent_code": "AS",
|
|
211
|
+
"continent_name": "亚洲",
|
|
212
|
+
"country_iso_code": "CN",
|
|
213
|
+
"country_name": "中国",
|
|
214
|
+
"is_in_european_union": "0"
|
|
215
|
+
},
|
|
216
|
+
"represented_country": null
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 备注
|
|
223
|
+
|
|
224
|
+
1. **ASN 的 `num` 非数字**:需求第(1)点只要求 city/country 的 id 做数字校验,未规定 asn 的 num。当前实现在 `num=abc` 时返回 `count:0`(空结果,状态 200)。如需返回 `400 ASN编号不合法`,可在 `api.rb` 中加一行校验。
|
|
225
|
+
|
|
226
|
+
2. **city-IPv6.json**:当前 `geodb/` 目录下已生成此文件,IPv6 city 查询可正常命中。文件缺失时服务端会容错返回 `无结果`(404),不影响其他接口。
|
|
227
|
+
|
|
228
|
+
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
|