logstash-filter-ipinfo-db-zuin 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTORS +11 -0
- data/Gemfile +10 -0
- data/LICENSE +11 -0
- data/README.md +120 -0
- data/docs/index.asciidoc +127 -0
- data/lib/logstash/filters/ipinfo-db.rb +268 -0
- data/logstash-filter-ipinfo-db-zuin.gemspec +28 -0
- data/spec/filters/ipinfo-db_spec.rb +379 -0
- data/spec/fixtures/generate_ipinfo_test_mmdb.py +56 -0
- data/spec/fixtures/ipinfo-test.mmdb +0 -0
- data/spec/spec_helper.rb +18 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2b36618d5861aa97840f2735fe419fd4303f858f553d883ee8f798c480153666
|
|
4
|
+
data.tar.gz: a298c76f7316d14c68d9efca5923797c7f6f231758ace241fa7a4c723344181d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7e066fcca849ed60a80c95b745756185d92f5494492df4bd3a7792d011788c2243c3d91a91ee9dd5cb85ab46917b83f38d1b42125fed45f5f7e92d9c9c953040
|
|
7
|
+
data.tar.gz: 7e38e997a2a75444dfefc2630f1632ff8a1929c5d2e66942135601d6c991e26979eb16ff2f3f3a61d61d4a681fb753026978482da72e842af9085ebb7546a272
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
## 0.2.0
|
|
2
|
+
|
|
3
|
+
- Forked as `logstash-filter-ipinfo-db-zuin` (filter name stays `ipinfo-db`)
|
|
4
|
+
- Add `token` setting; the ipinfo.io token is no longer hardcoded
|
|
5
|
+
- Add `database` setting. The database is now stored at
|
|
6
|
+
`<path.data>/plugins/filters/ipinfo-db/country_asn.mmdb` by default instead of the
|
|
7
|
+
Logstash working directory, which is `/` (not writable) under the deb/rpm systemd service
|
|
8
|
+
- Validate the downloaded database before atomically moving it into place, so an interrupted
|
|
9
|
+
or invalid download no longer leaves a broken file that blocks every later start
|
|
10
|
+
- Add `database_age_warning_days` (default 30): log a warning at startup and once a day
|
|
11
|
+
when the database is older than that
|
|
12
|
+
- Handle IPs not in the database and missing source fields without raising exceptions
|
|
13
|
+
(faster on JRuby); log unexpected lookup errors as warnings instead of swallowing them
|
|
14
|
+
- IPs that are valid but not in the database are no longer tagged with `tag_on_failure`;
|
|
15
|
+
the event is left unchanged
|
|
16
|
+
- Filters that use the same database file share one in-memory copy instead of loading one
|
|
17
|
+
each (several `ipinfo-db` blocks, pipelines, config reloads)
|
|
18
|
+
- Pin `down` to `~> 5.2` (compatible with `logstash-core`, which requires `down ~> 5.2.0`)
|
|
19
|
+
and `maxmind-db` to `~> 1.2`
|
|
20
|
+
- Docs: README now documents installation and settings; `source` docs no longer claim hostname
|
|
21
|
+
support; package `docs/` and no longer package `vendor/`
|
|
22
|
+
|
|
23
|
+
## 0.1.0
|
|
24
|
+
|
|
25
|
+
- Plugin created with the logstash plugin generator
|
|
26
|
+
- Initial devlopment
|
|
27
|
+
- Test case for plugin
|
|
28
|
+
- Documentation
|
|
29
|
+
- Review Preparation
|
|
30
|
+
|
data/CONTRIBUTORS
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
The following is a list of people who have contributed ideas, code, bug
|
|
2
|
+
reports, or in general have helped logstash along its way.
|
|
3
|
+
|
|
4
|
+
Contributors:
|
|
5
|
+
* Muhammad Awais Murad - awais@ipinfo.io
|
|
6
|
+
* Vincent Kuo - vit@zuin.ai
|
|
7
|
+
|
|
8
|
+
Note: If you've sent us patches, bug reports, or otherwise contributed to
|
|
9
|
+
Logstash, and you aren't on the list above and want to be, please let us know
|
|
10
|
+
and we'll make sure you're here. Contributions from folks like you are what make
|
|
11
|
+
open source awesome.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
source 'https://rubygems.org'
|
|
2
|
+
gemspec
|
|
3
|
+
|
|
4
|
+
logstash_path = ENV['LOGSTASH_PATH'] || '/usr/share/logstash'
|
|
5
|
+
|
|
6
|
+
if Dir.exist?(logstash_path)
|
|
7
|
+
gem 'logstash-core', :path => "#{logstash_path}/logstash-core"
|
|
8
|
+
gem 'logstash-core-plugin-api', :path => "#{logstash_path}/logstash-core-plugin-api"
|
|
9
|
+
end
|
|
10
|
+
|
data/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
you may not use this file except in compliance with the License.
|
|
3
|
+
You may obtain a copy of the License at
|
|
4
|
+
|
|
5
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
|
|
7
|
+
Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
See the License for the specific language governing permissions and
|
|
11
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# logstash-filter-ipinfo-db-zuin
|
|
2
|
+
|
|
3
|
+
A [Logstash](https://github.com/elastic/logstash) filter plugin that looks up the country and ASN of an
|
|
4
|
+
IP address in the [ipinfo.io](https://ipinfo.io) free country and ASN database (`.mmdb`), locally,
|
|
5
|
+
without calling the ipinfo API per event.
|
|
6
|
+
|
|
7
|
+
This is a fork of [`logstash-filter-ipinfo-db`](https://rubygems.org/gems/logstash-filter-ipinfo-db).
|
|
8
|
+
The gem is renamed, but the filter is still called `ipinfo-db`, so existing pipeline configs keep working.
|
|
9
|
+
Do not install both gems in the same Logstash.
|
|
10
|
+
|
|
11
|
+
The license is Apache 2.0.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bin/logstash-plugin install logstash-filter-ipinfo-db-zuin
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
filter {
|
|
23
|
+
ipinfo-db {
|
|
24
|
+
source => "ip"
|
|
25
|
+
token => "${IPINFO_TOKEN}"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
On first start, the plugin downloads the database with your ipinfo.io token to
|
|
31
|
+
`<path.data>/plugins/filters/ipinfo-db/country_asn.mmdb` (for example `/var/lib/logstash/...` for
|
|
32
|
+
deb/rpm installs). Store the token in an environment variable or the Logstash keystore:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
bin/logstash-keystore add IPINFO_TOKEN
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The lookup result is written to the `ipinfo-db` field. For `8.8.8.8` it looks like this (illustrative):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
"ipinfo-db": {
|
|
42
|
+
"country": "US",
|
|
43
|
+
"country_name": "United States",
|
|
44
|
+
"continent": "NA",
|
|
45
|
+
"continent_name": "North America",
|
|
46
|
+
"asn": "AS15169",
|
|
47
|
+
"as_name": "Google LLC",
|
|
48
|
+
"as_domain": "google.com"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Setting | Default | Description |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `source` | (required) | Field containing the IP address (IPv4 or IPv6). Hostnames are not resolved. |
|
|
55
|
+
| `target` | `ipinfo-db` | Field to write the result into. |
|
|
56
|
+
| `token` | none | ipinfo.io token, used only to download the database when the file does not exist. |
|
|
57
|
+
| `database` | `<path.data>/plugins/filters/ipinfo-db/country_asn.mmdb` | Path to the `.mmdb` file. Downloaded there if missing. |
|
|
58
|
+
| `database_age_warning_days` | `30` | Log a warning when the database is older than this. `0` disables it. |
|
|
59
|
+
| `tag_on_failure` | `["_ipinfo_db_lookup_failure"]` | Tags added when the field is missing, the value is not a valid IP, or the lookup fails. IPs that are simply not in the database are not tagged. |
|
|
60
|
+
|
|
61
|
+
The plugin does not update the database automatically. To refresh it, delete the file and restart
|
|
62
|
+
Logstash, or replace the file with a newer copy. See [docs/index.asciidoc](docs/index.asciidoc) for the
|
|
63
|
+
full reference.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
You need JRuby with Bundler, and a local Logstash for `logstash-core`. Set `LOGSTASH_PATH` if it is not
|
|
68
|
+
in `/usr/share/logstash` (see `Gemfile`).
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
bundle install
|
|
72
|
+
bundle exec rspec
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The default suite runs offline against `spec/fixtures/ipinfo-test.mmdb`, a tiny synthetic database
|
|
76
|
+
(documentation IP ranges only) generated by `spec/fixtures/generate_ipinfo_test_mmdb.py`. The live test,
|
|
77
|
+
which downloads the real database from ipinfo.io, is tagged `integration` and excluded by default:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
IPINFO_TOKEN=... bundle exec rspec --tag integration
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Verify on a real Logstash
|
|
84
|
+
|
|
85
|
+
`verify/verify.sh` builds the gem, installs it into the official Logstash Docker image, runs real
|
|
86
|
+
pipelines (download as a non-root user with working directory `/`, lookups, restart without token,
|
|
87
|
+
missing-token error) and the RSpec suite on Logstash's JRuby. It needs Docker, Ruby and Python 3:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
IPINFO_TOKEN=... verify/verify.sh 9.5.5
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Without `IPINFO_TOKEN`, the checks that download the live database (first start, restart and the
|
|
94
|
+
integration spec) are reported as `SKIP`; the install check, the missing-token error and the offline
|
|
95
|
+
specs still run.
|
|
96
|
+
|
|
97
|
+
Logs are written to `verify/out/`. GitHub Actions (`.github/workflows/verify.yml`) runs the same
|
|
98
|
+
script against Logstash 8.19.24 and 9.5.5 on pushes and pull requests to `main`, reading the token
|
|
99
|
+
from the `IPINFO_TOKEN` repository secret. Pull requests from forks don't get the secret, so they run
|
|
100
|
+
the offline checks only.
|
|
101
|
+
|
|
102
|
+
### Run the plugin in Logstash
|
|
103
|
+
|
|
104
|
+
Build and install the gem:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
gem build logstash-filter-ipinfo-db-zuin.gemspec
|
|
108
|
+
bin/logstash-plugin install /path/to/logstash-filter-ipinfo-db-zuin-<version>.gem
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or add it to the Logstash `Gemfile` from your local checkout, then run `bin/logstash-plugin install --no-verify`:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
gem "logstash-filter-ipinfo-db-zuin", :path => "/path/to/logstash-filter-ipinfo-db"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Help
|
|
118
|
+
|
|
119
|
+
Open an issue at https://github.com/reflective-technology/logstash-filter-ipinfo-db/issues, or ask in the
|
|
120
|
+
[Logstash discussion forum](https://discuss.elastic.co/c/logstash).
|
data/docs/index.asciidoc
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
:plugin: logstash-filter-ipinfo-db-zuin
|
|
2
|
+
:type: filter
|
|
3
|
+
|
|
4
|
+
///////////////////////////////////////////
|
|
5
|
+
START - GENERATED VARIABLES, DO NOT EDIT!
|
|
6
|
+
///////////////////////////////////////////
|
|
7
|
+
:version: %VERSION%
|
|
8
|
+
:release_date: %RELEASE_DATE%
|
|
9
|
+
:changelog_url: %CHANGELOG_URL%
|
|
10
|
+
:include_path: ../../../../logstash/docs/include
|
|
11
|
+
///////////////////////////////////////////
|
|
12
|
+
END - GENERATED VARIABLES, DO NOT EDIT!
|
|
13
|
+
///////////////////////////////////////////
|
|
14
|
+
|
|
15
|
+
[id="plugins-{type}s-{plugin}"]
|
|
16
|
+
|
|
17
|
+
=== logstash-filter-ipinfo-db-zuin filter plugin
|
|
18
|
+
|
|
19
|
+
include::{include_path}/plugin_header.asciidoc[]
|
|
20
|
+
|
|
21
|
+
==== Description
|
|
22
|
+
|
|
23
|
+
The ipinfo filter adds information about the geographical location and autonomous system number (ASN) of IP addresses,
|
|
24
|
+
based on data from the ipinfo.io free country and ASN database.
|
|
25
|
+
|
|
26
|
+
For example, if you have a field named `foo` which you want to lookup for location and ASN in ipinfo.io free database, and you want to store the
|
|
27
|
+
result of lookup in `bar`, do this:
|
|
28
|
+
[source,ruby]
|
|
29
|
+
filter {
|
|
30
|
+
ipinfo-db {
|
|
31
|
+
source => "foo"
|
|
32
|
+
target => "bar"
|
|
33
|
+
token => "${IPINFO_TOKEN}"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[id="plugins-{type}s-{plugin}-options"]
|
|
38
|
+
==== ipinfo-db Filter Configuration Options
|
|
39
|
+
|
|
40
|
+
This plugin supports the following configuration options plus the <<plugins-{type}s-{plugin}-common-options>> described later.
|
|
41
|
+
|
|
42
|
+
[cols="<,<,<",options="header",]
|
|
43
|
+
|=======================================================================
|
|
44
|
+
|Setting |Input type|Required
|
|
45
|
+
| <<plugins-{type}s-{plugin}-database>> |<<string,string>>|No
|
|
46
|
+
| <<plugins-{type}s-{plugin}-database_age_warning_days>> |<<number,number>>|No
|
|
47
|
+
| <<plugins-{type}s-{plugin}-source>> |<<string,string>>|Yes
|
|
48
|
+
| <<plugins-{type}s-{plugin}-tag_on_failure>> |<<array,array>>|No
|
|
49
|
+
| <<plugins-{type}s-{plugin}-target>> |<<string,string>>|No
|
|
50
|
+
| <<plugins-{type}s-{plugin}-token>> |<<password,password>>|No
|
|
51
|
+
|=======================================================================
|
|
52
|
+
|
|
53
|
+
Also see <<plugins-{type}s-{plugin}-common-options>> for a list of options supported by all
|
|
54
|
+
filter plugins.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
[id="plugins-{type}s-{plugin}-database"]
|
|
59
|
+
===== `database`
|
|
60
|
+
|
|
61
|
+
* Value type is <<string,string>>
|
|
62
|
+
* Default value is `<path.data>/plugins/filters/ipinfo-db/country_asn.mmdb`
|
|
63
|
+
|
|
64
|
+
Path to the ipinfo `.mmdb` database file. If the file exists, the plugin uses it as is. If it does not exist,
|
|
65
|
+
the plugin downloads it to this path on startup using <<plugins-{type}s-{plugin}-token>>, creating parent
|
|
66
|
+
directories as needed. `path.data` is the Logstash data directory (for example `/var/lib/logstash` for
|
|
67
|
+
deb/rpm installs and `/usr/share/logstash/data` in Docker). Use an absolute path; a relative path is resolved
|
|
68
|
+
against the Logstash working directory.
|
|
69
|
+
|
|
70
|
+
The database is loaded into memory once per file: all `ipinfo-db` filters in the Logstash process that use
|
|
71
|
+
the same path (including across pipelines and config reloads) share one copy. If the file is replaced, the
|
|
72
|
+
next filter that starts, for example after a config reload, loads the new file. When no running filter uses a
|
|
73
|
+
database any more (its pipelines stopped, or were reloaded with a different `database` path), it is released
|
|
74
|
+
from memory.
|
|
75
|
+
|
|
76
|
+
[id="plugins-{type}s-{plugin}-database_age_warning_days"]
|
|
77
|
+
===== `database_age_warning_days`
|
|
78
|
+
|
|
79
|
+
* Value type is <<number,number>>
|
|
80
|
+
* Default value is `30`
|
|
81
|
+
|
|
82
|
+
The plugin does not update the database by itself. It logs a warning when the database was built more than this
|
|
83
|
+
many days ago, using the build time stored in the `.mmdb` file. The check runs at startup and then once a day.
|
|
84
|
+
To refresh the database, delete the file so it is downloaded again on the next start, or replace it with a newer
|
|
85
|
+
copy. Set to `0` to disable the warning.
|
|
86
|
+
|
|
87
|
+
[id="plugins-{type}s-{plugin}-source"]
|
|
88
|
+
===== `source`
|
|
89
|
+
|
|
90
|
+
* This is a required setting.
|
|
91
|
+
* Value type is <<string,string>>
|
|
92
|
+
* There is no default value for this setting.
|
|
93
|
+
|
|
94
|
+
The field containing the IP address (IPv4 or IPv6) to look up in the ipinfo database. Hostnames are not resolved;
|
|
95
|
+
a value that is not a valid IP address tags the event with <<plugins-{type}s-{plugin}-tag_on_failure>>.
|
|
96
|
+
|
|
97
|
+
[id="plugins-{type}s-{plugin}-tag_on_failure"]
|
|
98
|
+
===== `tag_on_failure`
|
|
99
|
+
|
|
100
|
+
* Value type is <<array,array>>
|
|
101
|
+
* Default value is `["_ipinfo_db_lookup_failure"]`
|
|
102
|
+
|
|
103
|
+
Tags the event when the lookup cannot be done: the source field is missing or empty, the value is not a valid
|
|
104
|
+
IP address, or the lookup raises an error. This can be used in later analysis. An IP address that is valid but
|
|
105
|
+
not in the database (for example a private address) is not a failure: the event is left unchanged and not tagged.
|
|
106
|
+
|
|
107
|
+
[id="plugins-{type}s-{plugin}-target"]
|
|
108
|
+
===== `target`
|
|
109
|
+
|
|
110
|
+
* Value type is <<string,string>>
|
|
111
|
+
* Default value is `ipinfo-db`
|
|
112
|
+
|
|
113
|
+
The field to write the lookup results into.
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
[id="plugins-{type}s-{plugin}-token"]
|
|
117
|
+
===== `token`
|
|
118
|
+
|
|
119
|
+
* Value type is <<password,password>>
|
|
120
|
+
* There is no default value for this setting.
|
|
121
|
+
|
|
122
|
+
Your ipinfo.io access token, used to download the database when the file at
|
|
123
|
+
<<plugins-{type}s-{plugin}-database>> does not exist. If the file is missing and no token is set, the plugin fails to start.
|
|
124
|
+
To keep the token out of the pipeline config, use `${IPINFO_TOKEN}` with an environment variable or the Logstash keystore.
|
|
125
|
+
|
|
126
|
+
[id="plugins-{type}s-{plugin}-common-options"]
|
|
127
|
+
include::{include_path}/{type}.asciidoc[]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
require "logstash/filters/base"
|
|
3
|
+
require 'down'
|
|
4
|
+
require 'maxmind/db'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
# Looks up the country and ASN of an IP address in the ipinfo.io free country
|
|
9
|
+
# and ASN database (.mmdb) and stores the result in the event.
|
|
10
|
+
#
|
|
11
|
+
# filter {
|
|
12
|
+
# ipinfo-db {
|
|
13
|
+
# source => "ip"
|
|
14
|
+
# token => "${IPINFO_TOKEN}"
|
|
15
|
+
# }
|
|
16
|
+
# }
|
|
17
|
+
class LogStash::Filters::IpinfoDb < LogStash::Filters::Base
|
|
18
|
+
|
|
19
|
+
DOWNLOAD_URL = "https://ipinfo.io/data/free/country_asn.mmdb"
|
|
20
|
+
DEFAULT_FILE_NAME = "country_asn.mmdb"
|
|
21
|
+
AGE_CHECK_INTERVAL = 24 * 60 * 60 # seconds
|
|
22
|
+
|
|
23
|
+
config_name "ipinfo-db"
|
|
24
|
+
|
|
25
|
+
# The field containing the IP address to look up. Hostnames are not resolved.
|
|
26
|
+
config :source, :validate => :string, :required => true
|
|
27
|
+
|
|
28
|
+
# The field to write the lookup results into. If not specified, the default
|
|
29
|
+
# value will be used.
|
|
30
|
+
config :target, :validate => :string, :default => "ipinfo-db"
|
|
31
|
+
|
|
32
|
+
# Tags the event on failure to look up ipinfo information. This can be used in later analysis.
|
|
33
|
+
# If not specified, the default value will be used.
|
|
34
|
+
config :tag_on_failure, :validate => :array, :default => ["_ipinfo_db_lookup_failure"]
|
|
35
|
+
|
|
36
|
+
# ipinfo.io access token used to download the database. Only needed when
|
|
37
|
+
# the file at `database` does not exist yet. Use ${IPINFO_TOKEN} to read it
|
|
38
|
+
# from an environment variable or the Logstash keystore.
|
|
39
|
+
config :token, :validate => :password
|
|
40
|
+
|
|
41
|
+
# Path to the ipinfo .mmdb database file. If the file does not exist, it is
|
|
42
|
+
# downloaded to this path using `token`. Defaults to
|
|
43
|
+
# <path.data>/plugins/filters/ipinfo-db/country_asn.mmdb.
|
|
44
|
+
config :database, :validate => :string
|
|
45
|
+
|
|
46
|
+
# Log a warning when the database was built more than this many days ago.
|
|
47
|
+
# Checked at startup and then once a day. Set to 0 to disable.
|
|
48
|
+
config :database_age_warning_days, :validate => :number, :default => 30
|
|
49
|
+
|
|
50
|
+
# One in-memory reader per database file, shared by every filter instance in
|
|
51
|
+
# the process (several ipinfo-db blocks, pipelines, config reloads), so the
|
|
52
|
+
# database is held in the heap once. Readers are safe to use from multiple
|
|
53
|
+
# threads.
|
|
54
|
+
#
|
|
55
|
+
# - Entries record the identity (inode, size, mtime) of the file they were
|
|
56
|
+
# loaded from, verified before and after loading, so a replaced file is
|
|
57
|
+
# loaded again by the next instance that registers.
|
|
58
|
+
# - Entries are reference counted: register acquires, close releases, and the
|
|
59
|
+
# last release evicts the entry, so readers for files no filter uses any
|
|
60
|
+
# more (stopped pipelines, reloads to another database path) can be
|
|
61
|
+
# garbage collected.
|
|
62
|
+
# - Setup for a given file (existence check, download, load, cache insert)
|
|
63
|
+
# runs under a per-path lock, so pipelines starting together download and
|
|
64
|
+
# load a missing database only once, while unrelated database files still
|
|
65
|
+
# set up in parallel. @registry_mutex only guards the hashes.
|
|
66
|
+
@shared_readers = {}
|
|
67
|
+
@path_locks = {}
|
|
68
|
+
@registry_mutex = Mutex.new
|
|
69
|
+
|
|
70
|
+
LOAD_ATTEMPTS = 3
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
def with_path_lock(path)
|
|
74
|
+
lock = @registry_mutex.synchronize { @path_locks[path] ||= Mutex.new }
|
|
75
|
+
lock.synchronize { yield }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Acquire the cached reader for path if it was loaded from the file
|
|
79
|
+
# currently there; nil otherwise.
|
|
80
|
+
def acquire_reader(path)
|
|
81
|
+
identity = file_identity(path)
|
|
82
|
+
@registry_mutex.synchronize do
|
|
83
|
+
entry = @shared_readers[path]
|
|
84
|
+
next nil unless entry && entry[:identity] == identity
|
|
85
|
+
entry[:refs] += 1
|
|
86
|
+
entry[:reader]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Load path and cache the reader under the identity the file had for the
|
|
91
|
+
# whole load. If the file is replaced while loading, load it again.
|
|
92
|
+
def load_reader(path)
|
|
93
|
+
LOAD_ATTEMPTS.times do
|
|
94
|
+
before = file_identity(path)
|
|
95
|
+
reader = MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
|
|
96
|
+
return store_reader(path, before, reader) if file_identity(path) == before
|
|
97
|
+
end
|
|
98
|
+
raise LogStash::ConfigurationError, "ipinfo-db: database file #{path} kept changing while it was being loaded"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Cache reader (loaded from the file with identity) with one reference.
|
|
102
|
+
def store_reader(path, identity, reader)
|
|
103
|
+
@registry_mutex.synchronize { @shared_readers[path] = { :identity => identity, :reader => reader, :refs => 1 } }
|
|
104
|
+
reader
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Drop one reference; evict the entry when no filter uses it any more. A
|
|
108
|
+
# reader that was already superseded by a newer file is not in the cache.
|
|
109
|
+
def release_reader(path, reader)
|
|
110
|
+
@registry_mutex.synchronize do
|
|
111
|
+
entry = @shared_readers[path]
|
|
112
|
+
next unless entry && entry[:reader].equal?(reader)
|
|
113
|
+
entry[:refs] -= 1
|
|
114
|
+
@shared_readers.delete(path) if entry[:refs] <= 0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The cached reader for path, if any (no reference taken). For specs.
|
|
119
|
+
def cached_reader(path)
|
|
120
|
+
@registry_mutex.synchronize { @shared_readers[File.expand_path(path)] }&.fetch(:reader)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# For specs.
|
|
124
|
+
def clear_shared_readers
|
|
125
|
+
@registry_mutex.synchronize { @shared_readers.clear }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def file_identity(path)
|
|
129
|
+
stat = File.stat(path)
|
|
130
|
+
[stat.ino, stat.size, stat.mtime.to_r]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def register
|
|
135
|
+
setup_database
|
|
136
|
+
check_database_age
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def close
|
|
140
|
+
self.class.release_reader(@database, @db) if @db
|
|
141
|
+
@db = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def filter(event)
|
|
145
|
+
next_age_check = @next_age_check
|
|
146
|
+
check_database_age if next_age_check && Time.now >= next_age_check
|
|
147
|
+
@logger.debug? && @logger.debug("Running ipinfo-db lookup", :event => event)
|
|
148
|
+
ip = event.get(@source)
|
|
149
|
+
|
|
150
|
+
# Misses are common (private IPs, missing fields), so handle them without
|
|
151
|
+
# raising: exceptions are expensive in JRuby.
|
|
152
|
+
if ip.nil? || ip == ""
|
|
153
|
+
tag_unsuccessful_lookup(event, "source field is empty")
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
record = @db.get(ip)
|
|
159
|
+
rescue ArgumentError => e
|
|
160
|
+
# Not a valid IP address (IPAddr errors are ArgumentErrors), or an IPv6
|
|
161
|
+
# address looked up in an IPv4-only database.
|
|
162
|
+
tag_unsuccessful_lookup(event, e.message)
|
|
163
|
+
return
|
|
164
|
+
rescue => e
|
|
165
|
+
@logger.warn("Unexpected error during ipinfo lookup", :ip => ip, :exception => e.class.name, :message => e.message)
|
|
166
|
+
tag_unsuccessful_lookup(event, e.message)
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Not in the database (e.g. private ranges): not an error, leave the event as is.
|
|
171
|
+
if record.nil?
|
|
172
|
+
@logger.debug? && @logger.debug("IP not found in the ipinfo database", :ip => ip)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
begin
|
|
177
|
+
event.set(@target, record)
|
|
178
|
+
filter_matched(event)
|
|
179
|
+
rescue => e
|
|
180
|
+
@logger.warn("Unexpected error during ipinfo enrichment", :ip => ip, :exception => e.class.name, :message => e.message)
|
|
181
|
+
tag_unsuccessful_lookup(event, e.message)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def tag_unsuccessful_lookup(event, reason)
|
|
186
|
+
@logger.debug? && @logger.debug("ipinfo lookup failed", :ip => event.get(@source), :reason => reason)
|
|
187
|
+
@tag_on_failure.each{|tag| event.tag(tag)}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Download .mmdb file and load it into mmdb reader
|
|
191
|
+
def setup_database
|
|
192
|
+
# Normalize once (expands ~ and makes relative paths absolute) so the existence
|
|
193
|
+
# check, download, rename and reader cache all use the same path.
|
|
194
|
+
@database = File.expand_path(@database || default_database_path)
|
|
195
|
+
path = @database
|
|
196
|
+
|
|
197
|
+
@db = self.class.with_path_lock(path) do
|
|
198
|
+
if File.exist?(path)
|
|
199
|
+
self.class.acquire_reader(path) || self.class.load_reader(path)
|
|
200
|
+
else
|
|
201
|
+
if @token.nil? || @token.value.to_s.empty?
|
|
202
|
+
raise LogStash::ConfigurationError, "ipinfo-db: database file #{@database} not found. Set `token` to download it, or place the file there."
|
|
203
|
+
end
|
|
204
|
+
reader, identity = download_database
|
|
205
|
+
# A rename keeps inode, size and mtime, so a different identity here means
|
|
206
|
+
# another process replaced the file after our rename: load what is there.
|
|
207
|
+
if File.exist?(path) && self.class.file_identity(path) == identity
|
|
208
|
+
self.class.store_reader(path, identity, reader)
|
|
209
|
+
else
|
|
210
|
+
self.class.load_reader(path)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Warn when the loaded database is older than database_age_warning_days,
|
|
219
|
+
# based on the build time stored in the database itself.
|
|
220
|
+
def check_database_age
|
|
221
|
+
@next_age_check = nil
|
|
222
|
+
return if @database_age_warning_days <= 0
|
|
223
|
+
|
|
224
|
+
build_epoch = @db.metadata.build_epoch
|
|
225
|
+
return if build_epoch.nil?
|
|
226
|
+
|
|
227
|
+
@next_age_check = Time.now + AGE_CHECK_INTERVAL
|
|
228
|
+
age_days = ((Time.now.to_i - build_epoch) / 86400.0).floor
|
|
229
|
+
if age_days > @database_age_warning_days
|
|
230
|
+
@logger.warn("ipinfo database is outdated; lookups may be inaccurate. " \
|
|
231
|
+
"Delete the file to download a fresh copy on the next start (requires `token`), or replace it",
|
|
232
|
+
:path => @database, :age_days => age_days, :built_at => Time.at(build_epoch).utc.to_s,
|
|
233
|
+
:database_age_warning_days => @database_age_warning_days)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Download into a temp file next to @database, check that it is a readable
|
|
238
|
+
# database, then rename it into place. The rename is atomic, so @database is
|
|
239
|
+
# never a partial or invalid file, even if the download is interrupted or
|
|
240
|
+
# several pipelines download at the same time. Returns the reader and the
|
|
241
|
+
# identity of the file it was loaded from.
|
|
242
|
+
def download_database
|
|
243
|
+
@logger.info("Downloading ipinfo database", :path => @database)
|
|
244
|
+
dir = File.dirname(@database)
|
|
245
|
+
FileUtils.mkdir_p(dir)
|
|
246
|
+
tmp_path = File.join(dir, ".#{File.basename(@database)}.#{SecureRandom.hex(8)}.tmp")
|
|
247
|
+
|
|
248
|
+
tempfile = Down.download("#{DOWNLOAD_URL}?token=#{@token.value}")
|
|
249
|
+
FileUtils.mv(tempfile.path, tmp_path)
|
|
250
|
+
begin
|
|
251
|
+
db = MaxMind::DB.new(tmp_path, mode: MaxMind::DB::MODE_MEMORY)
|
|
252
|
+
rescue MaxMind::DB::InvalidDatabaseError => e
|
|
253
|
+
raise LogStash::ConfigurationError, "ipinfo-db: downloaded database is not a valid .mmdb file: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
identity = self.class.file_identity(tmp_path) # only this process writes tmp_path
|
|
256
|
+
File.rename(tmp_path, @database)
|
|
257
|
+
[db, identity]
|
|
258
|
+
ensure
|
|
259
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Keep the downloaded database under Logstash's data directory, which is
|
|
263
|
+
# writable by the logstash user and persists across restarts.
|
|
264
|
+
def default_database_path
|
|
265
|
+
File.join(LogStash::SETTINGS.get_value("path.data"), "plugins", "filters", "ipinfo-db", DEFAULT_FILE_NAME)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
end # class LogStash::Filters::IpinfoDb
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Gem::Specification.new do |s|
|
|
2
|
+
s.name = 'logstash-filter-ipinfo-db-zuin'
|
|
3
|
+
s.version = '0.2.0'
|
|
4
|
+
s.licenses = ['Apache-2.0']
|
|
5
|
+
s.summary = 'Logstash Filter Plugin for IPinfoDb'
|
|
6
|
+
s.description = 'This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline ' \
|
|
7
|
+
'using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program. ' \
|
|
8
|
+
'It looks up the country and ASN of IP addresses in the ipinfo.io free country and ASN database.'
|
|
9
|
+
s.homepage = 'https://github.com/reflective-technology/logstash-filter-ipinfo-db'
|
|
10
|
+
s.authors = ['ZUIN RD']
|
|
11
|
+
s.email = 'rd@zuin.ai'
|
|
12
|
+
s.require_paths = ['lib']
|
|
13
|
+
|
|
14
|
+
# Files
|
|
15
|
+
s.files = Dir['lib/**/*','spec/**/*','docs/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE']
|
|
16
|
+
# Tests
|
|
17
|
+
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
|
18
|
+
|
|
19
|
+
# Special flag to let us know this is actually a logstash plugin
|
|
20
|
+
s.metadata = { "logstash_plugin" => "true", "logstash_group" => "filter" }
|
|
21
|
+
|
|
22
|
+
# Gem dependencies
|
|
23
|
+
s.add_runtime_dependency "logstash-core-plugin-api", "~> 2.0"
|
|
24
|
+
# logstash-core itself depends on down ~> 5.2.0, so this must allow 5.2.x.
|
|
25
|
+
s.add_runtime_dependency "down", "~> 5.2"
|
|
26
|
+
s.add_runtime_dependency "maxmind-db", "~> 1.2"
|
|
27
|
+
s.add_development_dependency 'logstash-devutils', '~> 2.6'
|
|
28
|
+
end
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
require_relative '../spec_helper'
|
|
3
|
+
require "logstash/filters/ipinfo-db"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "ipaddr"
|
|
8
|
+
|
|
9
|
+
# Synthetic database with documentation networks only; see spec/fixtures/generate_ipinfo_test_mmdb.py.
|
|
10
|
+
FIXTURE_DATABASE = File.expand_path("../fixtures/ipinfo-test.mmdb", __dir__)
|
|
11
|
+
|
|
12
|
+
describe LogStash::Filters::IpinfoDb do
|
|
13
|
+
after { described_class.clear_shared_readers }
|
|
14
|
+
|
|
15
|
+
describe "shared database reader" do
|
|
16
|
+
let(:data_dir) { Dir.mktmpdir }
|
|
17
|
+
let(:database) { File.join(data_dir, "ipinfo.mmdb").tap { |path| FileUtils.cp(FIXTURE_DATABASE, path) } }
|
|
18
|
+
after { FileUtils.rm_rf(data_dir) }
|
|
19
|
+
|
|
20
|
+
def registered(path)
|
|
21
|
+
described_class.new("source" => "ip", "database" => path, "database_age_warning_days" => 0).tap(&:register)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reader(plugin)
|
|
25
|
+
plugin.instance_variable_get(:@db)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "loads a database file once for all filter instances" do
|
|
29
|
+
expect(MaxMind::DB).to receive(:new).once.and_call_original
|
|
30
|
+
first = registered(database)
|
|
31
|
+
second = registered(database)
|
|
32
|
+
expect(reader(second)).to equal(reader(first))
|
|
33
|
+
|
|
34
|
+
event = LogStash::Event.new("ip" => "192.0.2.10")
|
|
35
|
+
second.filter(event)
|
|
36
|
+
expect(event.get("[ipinfo-db][country]")).to eq("US")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "loads the file again after it is replaced" do
|
|
40
|
+
first = registered(database)
|
|
41
|
+
replacement = File.join(data_dir, "replacement.mmdb")
|
|
42
|
+
FileUtils.cp(FIXTURE_DATABASE, replacement)
|
|
43
|
+
File.rename(replacement, database)
|
|
44
|
+
second = registered(database)
|
|
45
|
+
expect(reader(second)).not_to equal(reader(first))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "downloads a missing database once when filters start at the same time" do
|
|
49
|
+
missing = File.join(data_dir, "downloaded.mmdb")
|
|
50
|
+
allow(Down).to receive(:download) do
|
|
51
|
+
sleep 0.5 # keep the first download in progress while the second filter registers
|
|
52
|
+
Tempfile.new("ipinfo-download").tap do |file|
|
|
53
|
+
file.binmode
|
|
54
|
+
file.write(File.binread(FIXTURE_DATABASE))
|
|
55
|
+
file.flush
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
plugins = Array.new(2) do
|
|
59
|
+
described_class.new("source" => "ip", "database" => missing, "token" => "t", "database_age_warning_days" => 0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
plugins.map { |plugin| Thread.new { plugin.register } }.each(&:join)
|
|
63
|
+
|
|
64
|
+
expect(Down).to have_received(:download).once
|
|
65
|
+
expect(reader(plugins[1])).to equal(reader(plugins[0]))
|
|
66
|
+
expect(Dir.children(data_dir)).to eq(["downloaded.mmdb"])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "evicts the reader when the last filter using it closes" do
|
|
70
|
+
first = registered(database)
|
|
71
|
+
second = registered(database)
|
|
72
|
+
first.close
|
|
73
|
+
expect(described_class.cached_reader(database)).to equal(reader(second))
|
|
74
|
+
second.close
|
|
75
|
+
expect(described_class.cached_reader(database)).to be_nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "evicts the old reader when a reload switches to another database path" do
|
|
79
|
+
old = registered(database)
|
|
80
|
+
versioned = File.join(data_dir, "ipinfo-v2.mmdb")
|
|
81
|
+
FileUtils.cp(FIXTURE_DATABASE, versioned)
|
|
82
|
+
current = registered(versioned)
|
|
83
|
+
old.close
|
|
84
|
+
expect(described_class.cached_reader(database)).to be_nil
|
|
85
|
+
expect(described_class.cached_reader(versioned)).to equal(reader(current))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "loads the file again if it is replaced while it is being loaded" do
|
|
89
|
+
path = database
|
|
90
|
+
loads = 0
|
|
91
|
+
allow(MaxMind::DB).to receive(:new).and_wrap_original do |original, *args, **kwargs|
|
|
92
|
+
loads += 1
|
|
93
|
+
loaded = original.call(*args, **kwargs)
|
|
94
|
+
if loads == 1 # an external updater replaces the file mid-load
|
|
95
|
+
replacement = File.join(data_dir, "replacement.mmdb")
|
|
96
|
+
FileUtils.cp(FIXTURE_DATABASE, replacement)
|
|
97
|
+
File.rename(replacement, path)
|
|
98
|
+
end
|
|
99
|
+
loaded
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
registered(path)
|
|
103
|
+
expect(loads).to eq(2)
|
|
104
|
+
registered(path) # the cached identity matches the file now there, so no further load
|
|
105
|
+
expect(loads).to eq(2)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "loads the file again if it is replaced right after a download is moved into place" do
|
|
109
|
+
target = File.join(data_dir, "downloaded.mmdb")
|
|
110
|
+
allow(Down).to receive(:download) do
|
|
111
|
+
Tempfile.new("ipinfo-download").tap do |file|
|
|
112
|
+
file.binmode
|
|
113
|
+
file.write(File.binread(FIXTURE_DATABASE))
|
|
114
|
+
file.flush
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
allow(File).to receive(:rename).and_wrap_original do |original, from, to|
|
|
118
|
+
result = original.call(from, to)
|
|
119
|
+
if to == target # an external updater rewrites it immediately
|
|
120
|
+
File.binwrite(to, File.binread(FIXTURE_DATABASE))
|
|
121
|
+
File.utime(Time.now, Time.now + 60, to)
|
|
122
|
+
end
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
expect(MaxMind::DB).to receive(:new).twice.and_call_original
|
|
126
|
+
|
|
127
|
+
plugin = described_class.new("source" => "ip", "database" => target, "token" => "t", "database_age_warning_days" => 0)
|
|
128
|
+
plugin.register
|
|
129
|
+
expect(described_class.cached_reader(target)).to equal(reader(plugin))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "expands ~ in the database path before downloading and loading" do
|
|
133
|
+
home = File.join(data_dir, "home")
|
|
134
|
+
cwd = File.join(data_dir, "cwd")
|
|
135
|
+
FileUtils.mkdir_p([home, cwd])
|
|
136
|
+
allow(Down).to receive(:download) do
|
|
137
|
+
Tempfile.new("ipinfo-download").tap do |file|
|
|
138
|
+
file.binmode
|
|
139
|
+
file.write(File.binread(FIXTURE_DATABASE))
|
|
140
|
+
file.flush
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
original_home = ENV["HOME"]
|
|
144
|
+
begin
|
|
145
|
+
ENV["HOME"] = home
|
|
146
|
+
plugin = described_class.new("source" => "ip", "database" => "~/ipinfo/country_asn.mmdb",
|
|
147
|
+
"token" => "t", "database_age_warning_days" => 0)
|
|
148
|
+
Dir.chdir(cwd) { plugin.register }
|
|
149
|
+
ensure
|
|
150
|
+
ENV["HOME"] = original_home
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
expanded = File.join(home, "ipinfo", "country_asn.mmdb")
|
|
154
|
+
expect(File.file?(expanded)).to be true
|
|
155
|
+
expect(Dir.children(cwd)).to be_empty # no literal "~" directory
|
|
156
|
+
expect(described_class.cached_reader(expanded)).to equal(reader(plugin))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "keeps separate readers for different files" do
|
|
160
|
+
other = File.join(data_dir, "other.mmdb")
|
|
161
|
+
FileUtils.cp(FIXTURE_DATABASE, other)
|
|
162
|
+
expect(reader(registered(other))).not_to equal(reader(registered(database)))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe "pipeline lookups against the fixture database" do
|
|
167
|
+
config <<-CONFIG
|
|
168
|
+
filter {
|
|
169
|
+
ipinfo-db {
|
|
170
|
+
source => "ip"
|
|
171
|
+
database => "#{FIXTURE_DATABASE}"
|
|
172
|
+
database_age_warning_days => 0
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
CONFIG
|
|
176
|
+
|
|
177
|
+
sample({ "ip" => "192.0.2.10" }) do
|
|
178
|
+
expect(subject.get("ipinfo-db")).to eq(
|
|
179
|
+
"country" => "US", "country_name" => "United States",
|
|
180
|
+
"continent" => "NA", "continent_name" => "North America",
|
|
181
|
+
"asn" => "AS64496", "as_name" => "Example Networks US", "as_domain" => "example.com"
|
|
182
|
+
)
|
|
183
|
+
expect(subject.get("tags")).to be_nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sample({ "ip" => "198.51.100.7" }) do
|
|
187
|
+
expect(subject.get("[ipinfo-db][country]")).to eq("JP")
|
|
188
|
+
expect(subject.get("[ipinfo-db][asn]")).to eq("AS64497")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sample({ "ip" => "2001:db8::1" }) do
|
|
192
|
+
expect(subject.get("[ipinfo-db][country]")).to eq("TW")
|
|
193
|
+
expect(subject.get("[ipinfo-db][asn]")).to eq("AS64498")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Valid but not in the database: left unchanged, not tagged.
|
|
197
|
+
sample({ "ip" => "10.0.0.1" }) do
|
|
198
|
+
expect(subject.get("ipinfo-db")).to be_nil
|
|
199
|
+
expect(subject.get("tags")).to be_nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
sample({ "ip" => "not-an-ip" }) do
|
|
203
|
+
expect(subject.get("ipinfo-db")).to be_nil
|
|
204
|
+
expect(subject.get("tags")).to eq(["_ipinfo_db_lookup_failure"])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
sample({ "other" => "no ip field" }) do
|
|
208
|
+
expect(subject.get("ipinfo-db")).to be_nil
|
|
209
|
+
expect(subject.get("tags")).to eq(["_ipinfo_db_lookup_failure"])
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
describe "pipeline with custom target and tag_on_failure" do
|
|
214
|
+
config <<-CONFIG
|
|
215
|
+
filter {
|
|
216
|
+
ipinfo-db {
|
|
217
|
+
source => "[client][ip]"
|
|
218
|
+
target => "[client][ipinfo]"
|
|
219
|
+
tag_on_failure => ["no_ipinfo"]
|
|
220
|
+
database => "#{FIXTURE_DATABASE}"
|
|
221
|
+
database_age_warning_days => 0
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
CONFIG
|
|
225
|
+
|
|
226
|
+
sample({ "client" => { "ip" => "192.0.2.10" } }) do
|
|
227
|
+
expect(subject.get("[client][ipinfo][country]")).to eq("US")
|
|
228
|
+
expect(subject.get("ipinfo-db")).to be_nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sample({ "client" => { "ip" => "bogus" } }) do
|
|
232
|
+
expect(subject.get("tags")).to eq(["no_ipinfo"])
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
describe "age warning with a real database" do
|
|
237
|
+
let(:build_epoch) { MaxMind::DB.new(FIXTURE_DATABASE, mode: MaxMind::DB::MODE_MEMORY).metadata.build_epoch }
|
|
238
|
+
|
|
239
|
+
it "uses the build time stored in the database" do
|
|
240
|
+
plugin = described_class.new("source" => "ip", "database" => FIXTURE_DATABASE)
|
|
241
|
+
allow(Time).to receive(:now).and_return(Time.at(build_epoch + 40 * 86400))
|
|
242
|
+
expect(plugin.logger).to receive(:warn).with(/outdated/, hash_including(:age_days => 40))
|
|
243
|
+
plugin.register
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Downloads the real database from ipinfo.io. Excluded by default; run with
|
|
248
|
+
# IPINFO_TOKEN=... bundle exec rspec --tag integration
|
|
249
|
+
describe "live ipinfo.io database", :integration => true do
|
|
250
|
+
let(:data_dir) { Dir.mktmpdir }
|
|
251
|
+
after { FileUtils.rm_rf(data_dir) }
|
|
252
|
+
|
|
253
|
+
it "downloads the database and looks up a well-known IP" do
|
|
254
|
+
skip("set IPINFO_TOKEN to run") if ENV["IPINFO_TOKEN"].to_s.empty?
|
|
255
|
+
database = File.join(data_dir, "country_asn.mmdb")
|
|
256
|
+
plugin = described_class.new("source" => "ip", "database" => database, "token" => ENV["IPINFO_TOKEN"])
|
|
257
|
+
plugin.register
|
|
258
|
+
expect(File.size(database)).to be > 0
|
|
259
|
+
expect(Dir.children(data_dir)).to eq(["country_asn.mmdb"])
|
|
260
|
+
|
|
261
|
+
event = LogStash::Event.new("ip" => "8.8.8.8")
|
|
262
|
+
plugin.filter(event)
|
|
263
|
+
expect(event.get("[ipinfo-db][country]")).to eq("US")
|
|
264
|
+
expect(event.get("[ipinfo-db][asn]")).to eq("AS15169")
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
describe "database location" do
|
|
269
|
+
let(:data_dir) { Dir.mktmpdir }
|
|
270
|
+
after { FileUtils.rm_rf(data_dir) }
|
|
271
|
+
|
|
272
|
+
it "defaults to a file under path.data" do
|
|
273
|
+
allow(LogStash::SETTINGS).to receive(:get_value).and_call_original
|
|
274
|
+
allow(LogStash::SETTINGS).to receive(:get_value).with("path.data").and_return(data_dir)
|
|
275
|
+
expected = File.join(data_dir, "plugins", "filters", "ipinfo-db", "country_asn.mmdb")
|
|
276
|
+
plugin = described_class.new("source" => "ip")
|
|
277
|
+
expect { plugin.register }.to raise_error(LogStash::ConfigurationError, /#{Regexp.escape(expected)}/)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "uses the configured database path" do
|
|
281
|
+
missing = File.join(data_dir, "missing.mmdb")
|
|
282
|
+
plugin = described_class.new("source" => "ip", "database" => missing)
|
|
283
|
+
expect { plugin.register }.to raise_error(LogStash::ConfigurationError, /#{Regexp.escape(missing)}/)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it "does not keep an invalid download" do
|
|
287
|
+
bad = Tempfile.new("bad-mmdb")
|
|
288
|
+
bad.write("not a database")
|
|
289
|
+
bad.flush
|
|
290
|
+
allow(Down).to receive(:download).and_return(bad)
|
|
291
|
+
database = File.join(data_dir, "ipinfo.mmdb")
|
|
292
|
+
plugin = described_class.new("source" => "ip", "database" => database, "token" => "t")
|
|
293
|
+
expect { plugin.register }.to raise_error(LogStash::ConfigurationError, /not a valid \.mmdb/)
|
|
294
|
+
expect(Dir.children(data_dir)).to be_empty
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
describe "database age warning" do
|
|
299
|
+
let(:data_dir) { Dir.mktmpdir }
|
|
300
|
+
let(:database) { File.join(data_dir, "ipinfo.mmdb").tap { |path| File.write(path, "x") } }
|
|
301
|
+
after { FileUtils.rm_rf(data_dir) }
|
|
302
|
+
|
|
303
|
+
def stub_db_built_days_ago(days)
|
|
304
|
+
metadata = double("metadata", :build_epoch => Time.now.to_i - days * 86400)
|
|
305
|
+
allow(MaxMind::DB).to receive(:new).and_return(double("db", :metadata => metadata))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "warns when the database is older than database_age_warning_days" do
|
|
309
|
+
stub_db_built_days_ago(45)
|
|
310
|
+
plugin = described_class.new("source" => "ip", "database" => database)
|
|
311
|
+
expect(plugin.logger).to receive(:warn).with(/outdated/, hash_including(:age_days => 45))
|
|
312
|
+
plugin.register
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it "does not warn for a recent database" do
|
|
316
|
+
stub_db_built_days_ago(3)
|
|
317
|
+
plugin = described_class.new("source" => "ip", "database" => database)
|
|
318
|
+
expect(plugin.logger).not_to receive(:warn).with(/outdated/, anything)
|
|
319
|
+
plugin.register
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "does not warn when disabled" do
|
|
323
|
+
stub_db_built_days_ago(400)
|
|
324
|
+
plugin = described_class.new("source" => "ip", "database" => database, "database_age_warning_days" => 0)
|
|
325
|
+
expect(plugin.logger).not_to receive(:warn).with(/outdated/, anything)
|
|
326
|
+
plugin.register
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
describe "lookup results" do
|
|
331
|
+
let(:data_dir) { Dir.mktmpdir }
|
|
332
|
+
let(:database) { File.join(data_dir, "ipinfo.mmdb").tap { |path| File.write(path, "x") } }
|
|
333
|
+
let(:db) { double("db", :metadata => double("metadata", :build_epoch => Time.now.to_i)) }
|
|
334
|
+
let(:plugin) { described_class.new("source" => "ip", "database" => database) }
|
|
335
|
+
after { FileUtils.rm_rf(data_dir) }
|
|
336
|
+
|
|
337
|
+
before do
|
|
338
|
+
allow(MaxMind::DB).to receive(:new).and_return(db)
|
|
339
|
+
plugin.register
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def run(fields)
|
|
343
|
+
LogStash::Event.new(fields).tap { |event| plugin.filter(event) }
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it "sets the target field when the IP is found" do
|
|
347
|
+
allow(db).to receive(:get).with("8.8.8.8").and_return("country" => "US")
|
|
348
|
+
event = run("ip" => "8.8.8.8")
|
|
349
|
+
expect(event.get("[ipinfo-db][country]")).to eq("US")
|
|
350
|
+
expect(event.get("tags")).to be_nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "leaves the event untouched when the IP is not in the database" do
|
|
354
|
+
allow(db).to receive(:get).with("10.0.0.1").and_return(nil)
|
|
355
|
+
event = run("ip" => "10.0.0.1")
|
|
356
|
+
expect(event.get("ipinfo-db")).to be_nil
|
|
357
|
+
expect(event.get("tags")).to be_nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "tags the event without a lookup when the source field is missing" do
|
|
361
|
+
expect(db).not_to receive(:get)
|
|
362
|
+
event = run("other" => "x")
|
|
363
|
+
expect(event.get("tags")).to eq(["_ipinfo_db_lookup_failure"])
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it "tags the event when the value is not a valid IP" do
|
|
367
|
+
allow(db).to receive(:get).and_raise(IPAddr::InvalidAddressError, "invalid address: nope")
|
|
368
|
+
event = run("ip" => "nope")
|
|
369
|
+
expect(event.get("tags")).to eq(["_ipinfo_db_lookup_failure"])
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "logs unexpected errors and tags the event" do
|
|
373
|
+
allow(db).to receive(:get).and_raise(RuntimeError, "boom")
|
|
374
|
+
expect(plugin.logger).to receive(:warn).with(/Unexpected error/, hash_including(:message => "boom"))
|
|
375
|
+
event = run("ip" => "1.2.3.4")
|
|
376
|
+
expect(event.get("tags")).to eq(["_ipinfo_db_lookup_failure"])
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Generate spec/fixtures/ipinfo-test.mmdb, a tiny database for offline specs.
|
|
2
|
+
|
|
3
|
+
It uses the field layout of the ipinfo.io free country_asn database, but only documentation
|
|
4
|
+
networks (RFC 5737, RFC 3849) and documentation ASNs (RFC 5398), so its data is synthetic.
|
|
5
|
+
|
|
6
|
+
Regenerate from the repository root with:
|
|
7
|
+
|
|
8
|
+
docker run --rm -v "$PWD/spec/fixtures":/fixtures python:3.12-slim sh -c \
|
|
9
|
+
'pip install -q mmdb-writer==0.2.7 netaddr==1.3.0 && python /fixtures/generate_ipinfo_test_mmdb.py'
|
|
10
|
+
|
|
11
|
+
Keep this data in sync with spec/filters/ipinfo-db_spec.rb.
|
|
12
|
+
"""
|
|
13
|
+
from netaddr import IPSet
|
|
14
|
+
from mmdb_writer import MMDBWriter
|
|
15
|
+
|
|
16
|
+
RECORDS = {
|
|
17
|
+
"192.0.2.0/24": {
|
|
18
|
+
"country": "US",
|
|
19
|
+
"country_name": "United States",
|
|
20
|
+
"continent": "NA",
|
|
21
|
+
"continent_name": "North America",
|
|
22
|
+
"asn": "AS64496",
|
|
23
|
+
"as_name": "Example Networks US",
|
|
24
|
+
"as_domain": "example.com",
|
|
25
|
+
},
|
|
26
|
+
"198.51.100.0/24": {
|
|
27
|
+
"country": "JP",
|
|
28
|
+
"country_name": "Japan",
|
|
29
|
+
"continent": "AS",
|
|
30
|
+
"continent_name": "Asia",
|
|
31
|
+
"asn": "AS64497",
|
|
32
|
+
"as_name": "Example Networks JP",
|
|
33
|
+
"as_domain": "example.jp",
|
|
34
|
+
},
|
|
35
|
+
"2001:db8::/32": {
|
|
36
|
+
"country": "TW",
|
|
37
|
+
"country_name": "Taiwan",
|
|
38
|
+
"continent": "AS",
|
|
39
|
+
"continent_name": "Asia",
|
|
40
|
+
"asn": "AS64498",
|
|
41
|
+
"as_name": "Example Networks TW",
|
|
42
|
+
"as_domain": "example.tw",
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
writer = MMDBWriter(
|
|
47
|
+
ip_version=6,
|
|
48
|
+
ipv4_compatible=True,
|
|
49
|
+
database_type="ipinfo country_asn.mmdb",
|
|
50
|
+
languages=["en"],
|
|
51
|
+
description="Synthetic test database for logstash-filter-ipinfo-db-zuin specs",
|
|
52
|
+
)
|
|
53
|
+
for network, record in RECORDS.items():
|
|
54
|
+
writer.insert_network(IPSet([network]), record)
|
|
55
|
+
writer.to_db_file("/fixtures/ipinfo-test.mmdb")
|
|
56
|
+
print("wrote /fixtures/ipinfo-test.mmdb")
|
|
Binary file
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Licensed to Elasticsearch B.V. under one or more contributor
|
|
2
|
+
# license agreements. See the NOTICE file distributed with
|
|
3
|
+
# this work for additional information regarding copyright
|
|
4
|
+
# ownership. Elasticsearch B.V. licenses this file to you under
|
|
5
|
+
# the Apache License, Version 2.0 (the "License"); you may
|
|
6
|
+
# not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing,
|
|
12
|
+
# software distributed under the License is distributed on an
|
|
13
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
14
|
+
# KIND, either express or implied. See the License for the
|
|
15
|
+
# specific language governing permissions and limitations
|
|
16
|
+
# under the License.
|
|
17
|
+
|
|
18
|
+
require "logstash/devutils/rspec/spec_helper"
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: logstash-filter-ipinfo-db-zuin
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ZUIN RD
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: logstash-core-plugin-api
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: down
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.2'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.2'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: maxmind-db
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.2'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.2'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: logstash-devutils
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '2.6'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '2.6'
|
|
69
|
+
description: This gem is a Logstash plugin required to be installed on top of the
|
|
70
|
+
Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This
|
|
71
|
+
gem is not a stand-alone program. It looks up the country and ASN of IP addresses
|
|
72
|
+
in the ipinfo.io free country and ASN database.
|
|
73
|
+
email: rd@zuin.ai
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- CONTRIBUTORS
|
|
80
|
+
- Gemfile
|
|
81
|
+
- LICENSE
|
|
82
|
+
- README.md
|
|
83
|
+
- docs/index.asciidoc
|
|
84
|
+
- lib/logstash/filters/ipinfo-db.rb
|
|
85
|
+
- logstash-filter-ipinfo-db-zuin.gemspec
|
|
86
|
+
- spec/filters/ipinfo-db_spec.rb
|
|
87
|
+
- spec/fixtures/generate_ipinfo_test_mmdb.py
|
|
88
|
+
- spec/fixtures/ipinfo-test.mmdb
|
|
89
|
+
- spec/spec_helper.rb
|
|
90
|
+
homepage: https://github.com/reflective-technology/logstash-filter-ipinfo-db
|
|
91
|
+
licenses:
|
|
92
|
+
- Apache-2.0
|
|
93
|
+
metadata:
|
|
94
|
+
logstash_plugin: 'true'
|
|
95
|
+
logstash_group: filter
|
|
96
|
+
post_install_message:
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 3.5.22
|
|
112
|
+
signing_key:
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: Logstash Filter Plugin for IPinfoDb
|
|
115
|
+
test_files:
|
|
116
|
+
- spec/filters/ipinfo-db_spec.rb
|
|
117
|
+
- spec/fixtures/generate_ipinfo_test_mmdb.py
|
|
118
|
+
- spec/fixtures/ipinfo-test.mmdb
|
|
119
|
+
- spec/spec_helper.rb
|