vpndetection-rails 1.0.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/LICENSE +21 -0
- data/README.md +125 -0
- data/lib/vpndetection/rails/middleware.rb +57 -0
- data/lib/vpndetection/rails/railtie.rb +24 -0
- data/lib/vpndetection/rails/selectors.rb +64 -0
- data/lib/vpndetection/rails/version.rb +7 -0
- data/lib/vpndetection/rails.rb +9 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c2ba0f47c359b22c40293f01b23af6f203f70434e34c434183ab2ad9e8055789
|
|
4
|
+
data.tar.gz: 5b9e2a4ac172e35d4f7bb3aef75effa00d1891a2e96efec6203f9621cd616a7f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 43a08675b9954f594a9b91f01d29ccaf256f3f7cdee8f3d0d92b7e353c8c6f3905ab3f5da38f3f59b7104f4127aa2887425ef31541b7b4245522d04950323950
|
|
7
|
+
data.tar.gz: 2dde83ca6fce1c93e006692a8ee5de7cd961f51775b6261351fb6d96db13cb5c0c6084aee0963ff482ee3bd3233a81c60c5b15087ae9c0148b3667d659112a43
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mslm Dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# [<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="24"/>](https://vpndetection.io/) VPNDetection Rails Middleware
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/vpndetection-rails)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
The official Rails middleware for the [VPNDetection](https://vpndetection.io) API.
|
|
7
|
+
|
|
8
|
+
It classifies the visitor behind each request — VPN, residential proxy, Tor, hosting, CDN, relay — and hands the answer to your controllers. Blocking is opt-in.
|
|
9
|
+
|
|
10
|
+
It is Rack middleware, so it works in Sinatra, Hanami, Roda or a bare Rack app too; the Railtie is only what saves a Rails user from inserting it by hand.
|
|
11
|
+
|
|
12
|
+
## Getting Started
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bundle add vpndetection-rails
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Ruby 3.1 or newer.
|
|
19
|
+
|
|
20
|
+
You need an API key. Create one in the [console](https://app.vpndetection.io); the free tier's allowance is counted per source address, and a server is a single source address, so a key is what makes this usable in production rather than optional.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# config/application.rb
|
|
24
|
+
config.vpndetection = { api_key: ENV["VPNDETECTION_API_KEY"] }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class HomeController < ApplicationController
|
|
29
|
+
def index
|
|
30
|
+
lookup = VPNDetection::Rails.lookup(request)
|
|
31
|
+
render plain: lookup&.result&.vpn? ? "Hello, VPN user" : "Hello"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
In a non-Rails Rack app, insert it yourself:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
use VPNDetection::Rails::Middleware, api_key: ENV["VPNDETECTION_API_KEY"]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
By default nothing is blocked. Every request gets an answer and your own code decides what that means — which is usually what you want, because whether a VPN visitor is a problem depends entirely on what they are doing.
|
|
43
|
+
|
|
44
|
+
## Blocking
|
|
45
|
+
|
|
46
|
+
Set a `block_condition` and a matching request is answered with `403` and never reaches your controllers.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
config.vpndetection = { api_key: ENV["VPNDETECTION_API_KEY"], block_condition: { is_vpn: true } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A condition is written in the shape of an answer, and only the members you name are considered. That lets it reach the evidence, not just the flags:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
{ is_vpn: true, vpn: { provider: "nordvpn" } } # one provider
|
|
56
|
+
{ is_resproxy: true, resproxy: { hits: { gte: 5 } } } # a numeric threshold
|
|
57
|
+
{ vpn: { confidence: %w[high medium] } } # any of these
|
|
58
|
+
[{ is_tor: true }, { is_resproxy: true }] # a list is OR
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Symbol and string keys both work. Values are matched by equality, strings without regard to case. An Array means any-of. A Hash of `gte`/`gt`/`lte`/`lt` compares numbers, and every bound you give must hold, so two of them are a range. Members set to `false` or `nil` are ignored, so a condition states the signals you act on; one that constrains nothing would match every request, and is refused when the middleware is built rather than silently blocking all your traffic.
|
|
62
|
+
|
|
63
|
+
Replace the refusal with `on_blocked`, which returns a Rack triplet:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
on_blocked: ->(request, lookup) { [303, { "location" => "/no-vpn" }, []] }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Where the client address comes from
|
|
70
|
+
|
|
71
|
+
This is the setting that decides whether any of the above works, and it is the one thing only you can get right.
|
|
72
|
+
|
|
73
|
+
**In Rails**, the default reads `env["action_dispatch.remote_ip"]` — the answer `ActionDispatch::RemoteIp` computed from `X-Forwarded-For` minus your `config.action_dispatch.trusted_proxies`. That is the right fix behind a load balancer: tell Rails which proxies are yours and it resolves the visitor for you. `Rack::Request#ip` does *not* read that entry, so reading Rails' own is what makes your `trusted_proxies` mean anything here.
|
|
74
|
+
|
|
75
|
+
**In a bare Rack app** there is no such entry and the default falls back to `Rack::Request#ip`. Be aware that Rack's `ip` **already trusts `X-Forwarded-For`**: it returns the left-most entry once private and loopback addresses are dropped, which is whatever the caller sent. That is measured, not assumed, and the test suite pins it. Behind nothing, or behind an edge that appends rather than overwrites, name your edge's header instead:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
ip_selector: VPNDetection::Rails.header_ip_selector("CF-Connecting-IP")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`VPNDetection::Rails.xff_ip_selector` reads `X-Forwarded-For` directly, and `xff_ip_selector(1)` counts one trusted hop from the right. Anything else, pass your own callable — it receives the Rack request and returns an address.
|
|
82
|
+
|
|
83
|
+
If the address resolves to a private one, the middleware says so once. That is expected locally and is the signal to fix your configuration anywhere else.
|
|
84
|
+
|
|
85
|
+
## When a lookup fails
|
|
86
|
+
|
|
87
|
+
The request is let through, and the reason is on `lookup.error`. Our outage should not become yours, so a network failure, an exhausted quota or a rejected key all fail open. Pass `fail_closed: true` to block instead. Private addresses are answered locally and never fail, so this will not lock you out in development.
|
|
88
|
+
|
|
89
|
+
## Cost and latency
|
|
90
|
+
|
|
91
|
+
Answers are cached for an hour, so a returning visitor costs nothing, and private addresses never leave the process. A cache miss is one request to our API, bounded at 2.5 seconds by default and not retried — on a request path, failing open quickly beats holding a visitor while we try again.
|
|
92
|
+
|
|
93
|
+
Skip what you do not care about:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
skip: ->(request) { request.path.start_with?("/assets") }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Beyond a few million distinct visitors a day, stop calling the API per request: [download the dataset](https://vpndetection.io/databases) and look addresses up locally instead.
|
|
100
|
+
|
|
101
|
+
## Absent is not false
|
|
102
|
+
|
|
103
|
+
Only `ip` and `is_vpn` come back on every plan. A member your plan does not include is `nil`, which means "not in your plan" rather than "checked, and no" — and Ruby makes that easy to lose, since `nil` and `false` are both falsy.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
lookup.result.hosting? # true or false, never nil
|
|
107
|
+
lookup.result.is_hosting # nil when your plan does not include it
|
|
108
|
+
lookup.result.included?(:is_hosting)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A `block_condition` naming a member your plan does not serve can never match, so the middleware warns once instead of failing silently. Pass `on_missing_field: :raise` to make it an error.
|
|
112
|
+
|
|
113
|
+
## Other Libraries
|
|
114
|
+
|
|
115
|
+
There are official VPNDetection client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/vpndetection-io for more.
|
|
116
|
+
|
|
117
|
+
## About VPNDetection
|
|
118
|
+
|
|
119
|
+
VPN Detection API: Accurate anonymity detection identifying VPNs, residential proxies, hosting servers, Tor nodes, CDNs, relays and more.
|
|
120
|
+
|
|
121
|
+
[<img src="https://s3.vpndetection.io/vpndetection-public/brand/mark.svg" alt="VPNDetection" width="96"/>](https://vpndetection.io/)
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rack'
|
|
5
|
+
|
|
6
|
+
module VPNDetection
|
|
7
|
+
module Rails
|
|
8
|
+
# Rack middleware that classifies the visitor and optionally refuses the
|
|
9
|
+
# request.
|
|
10
|
+
#
|
|
11
|
+
# Being Rack rather than Rails-specific is deliberate: the same object works
|
|
12
|
+
# in Sinatra, Hanami, Roda or a bare Rack app, and the Railtie below is only
|
|
13
|
+
# what saves a Rails user from inserting it by hand.
|
|
14
|
+
#
|
|
15
|
+
# Without a `block_condition` this only enriches the request and never
|
|
16
|
+
# refuses one, leaving the decision to your own controllers. The answer is
|
|
17
|
+
# on `request.env['vpndetection']`, or `VPNDetection::Rails.lookup(request)`.
|
|
18
|
+
class Middleware
|
|
19
|
+
ENV_KEY = 'vpndetection'
|
|
20
|
+
|
|
21
|
+
def initialize(app, **options)
|
|
22
|
+
@app = app
|
|
23
|
+
@on_blocked = options.delete(:on_blocked) || method(:refuse)
|
|
24
|
+
@core = VPNDetection::Middleware::Core.new(
|
|
25
|
+
Rails.default_ip_selector, **options
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
request = ::Rack::Request.new(env)
|
|
31
|
+
lookup = @core.evaluate(request)
|
|
32
|
+
return @app.call(env) if lookup.nil?
|
|
33
|
+
|
|
34
|
+
env[ENV_KEY] = lookup
|
|
35
|
+
return @on_blocked.call(request, lookup) if lookup.blocked?
|
|
36
|
+
|
|
37
|
+
@app.call(env)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def refuse(_request, _lookup)
|
|
43
|
+
body = JSON.generate({ 'error' => 'access denied' })
|
|
44
|
+
[403, { 'content-type' => 'application/json' }, [body]]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# What the middleware found out about this visitor, or nil when it has not
|
|
51
|
+
# run for this request or `skip` claimed it.
|
|
52
|
+
def lookup(request)
|
|
53
|
+
env = request.respond_to?(:env) ? request.env : request
|
|
54
|
+
env[Middleware::ENV_KEY]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VPNDetection
|
|
4
|
+
module Rails
|
|
5
|
+
# Inserts the middleware for a Rails app, configured from
|
|
6
|
+
# `config.vpndetection`.
|
|
7
|
+
#
|
|
8
|
+
# Placed before ActionDispatch::Executor so a refusal costs no controller
|
|
9
|
+
# work, and after Rails' own RemoteIp so `request.ip` already means what
|
|
10
|
+
# your `trusted_proxies` say it means.
|
|
11
|
+
class Railtie < ::Rails::Railtie
|
|
12
|
+
config.vpndetection = ActiveSupport::OrderedOptions.new
|
|
13
|
+
|
|
14
|
+
initializer 'vpndetection.middleware' do |app|
|
|
15
|
+
options = app.config.vpndetection.to_h
|
|
16
|
+
next if options.delete(:enabled) == false
|
|
17
|
+
|
|
18
|
+
app.middleware.insert_after(
|
|
19
|
+
ActionDispatch::RemoteIp, VPNDetection::Rails::Middleware, **options
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VPNDetection
|
|
4
|
+
module Rails
|
|
5
|
+
# The client-address selectors, bound to a Rack request.
|
|
6
|
+
#
|
|
7
|
+
# There is no portable default, so this is yours to choose - but Rails
|
|
8
|
+
# already has an answer for the common case, and it is the one to reach for
|
|
9
|
+
# first.
|
|
10
|
+
SELECTORS = VPNDetection::Middleware::Selectors.new do |request|
|
|
11
|
+
VPNDetection::Middleware::RequestView.new(
|
|
12
|
+
header: ->(name) { request.get_header("HTTP_#{name.upcase.tr('-', '_')}") },
|
|
13
|
+
framework_ip: -> { framework_ip(request) }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Rails' own answer where there is one, and Rack's otherwise.
|
|
20
|
+
#
|
|
21
|
+
# `ActionDispatch::RemoteIp` computes the visitor from `X-Forwarded-For`
|
|
22
|
+
# minus your `config.action_dispatch.trusted_proxies` and leaves it on
|
|
23
|
+
# `env['action_dispatch.remote_ip']`. That is the right fix behind a load
|
|
24
|
+
# balancer, so it is read first - `Rack::Request#ip` does NOT consult it,
|
|
25
|
+
# and using Rack's alone would silently ignore the trusted_proxies you
|
|
26
|
+
# configured.
|
|
27
|
+
#
|
|
28
|
+
# Outside Rails there is no such entry and this falls back to
|
|
29
|
+
# `Rack::Request#ip`. **Be aware that Rack's own `ip` already trusts
|
|
30
|
+
# `X-Forwarded-For`**: it returns the left-most entry after dropping
|
|
31
|
+
# private and loopback addresses, which is whatever the caller sent.
|
|
32
|
+
# Measured, not assumed. In a bare Rack app behind nothing, prefer
|
|
33
|
+
# {.header_ip_selector} or your own.
|
|
34
|
+
def default_ip_selector
|
|
35
|
+
SELECTORS.default
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @api private
|
|
39
|
+
def framework_ip(request)
|
|
40
|
+
# `to_s` because RemoteIp leaves a lazily-resolving object here, not a
|
|
41
|
+
# String, and a lookup needs the address.
|
|
42
|
+
rails = request.get_header('action_dispatch.remote_ip')
|
|
43
|
+
return rails.to_s unless rails.nil?
|
|
44
|
+
|
|
45
|
+
request.ip
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# An address from `X-Forwarded-For`, ignoring Rails' trusted-proxy list.
|
|
49
|
+
#
|
|
50
|
+
# The LEFT-MOST entry (depth 0) is whatever the caller sent, because proxies
|
|
51
|
+
# append to this header. Prefer `trusted_proxies`; reach for this only when
|
|
52
|
+
# you cannot express your topology there.
|
|
53
|
+
def xff_ip_selector(depth = 0)
|
|
54
|
+
SELECTORS.xff(depth)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An address from a single-value header your edge writes -
|
|
58
|
+
# `header_ip_selector('CF-Connecting-IP')` behind Cloudflare. Falls back to
|
|
59
|
+
# the Rails accessor when the header is absent.
|
|
60
|
+
def header_ip_selector(name)
|
|
61
|
+
SELECTORS.header(name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'vpndetection'
|
|
4
|
+
require 'vpndetection/middleware'
|
|
5
|
+
|
|
6
|
+
require_relative 'rails/version'
|
|
7
|
+
require_relative 'rails/selectors'
|
|
8
|
+
require_relative 'rails/middleware'
|
|
9
|
+
require_relative 'rails/railtie' if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vpndetection-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mslm Dev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: vpndetection
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 3.2.1
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '4'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 3.2.1
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '4'
|
|
46
|
+
description: Classifies the visitor behind each request - VPN, residential proxy,
|
|
47
|
+
Tor, hosting, CDN, relay - and hands the answer to your controllers. Rack middleware,
|
|
48
|
+
so it works in Sinatra, Hanami and Roda too; a Railtie inserts it for you in Rails.
|
|
49
|
+
email:
|
|
50
|
+
- support@vpndetection.io
|
|
51
|
+
executables: []
|
|
52
|
+
extensions: []
|
|
53
|
+
extra_rdoc_files: []
|
|
54
|
+
files:
|
|
55
|
+
- LICENSE
|
|
56
|
+
- README.md
|
|
57
|
+
- lib/vpndetection/rails.rb
|
|
58
|
+
- lib/vpndetection/rails/middleware.rb
|
|
59
|
+
- lib/vpndetection/rails/railtie.rb
|
|
60
|
+
- lib/vpndetection/rails/selectors.rb
|
|
61
|
+
- lib/vpndetection/rails/version.rb
|
|
62
|
+
homepage: https://vpndetection.io
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://vpndetection.io
|
|
67
|
+
source_code_uri: https://github.com/vpndetection-io/sdk-ruby-rails
|
|
68
|
+
rubygems_mfa_required: 'true'
|
|
69
|
+
rdoc_options: []
|
|
70
|
+
require_paths:
|
|
71
|
+
- lib
|
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '3.1'
|
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
requirements: []
|
|
83
|
+
rubygems_version: 3.6.9
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Official Rails and Rack middleware for the VPNDetection API.
|
|
86
|
+
test_files: []
|