outline_vpn_api 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a705549d6c1f842aeee03e7a6f6ded562020fc7eb946493e2eda44b0f307e95b
4
- data.tar.gz: 5ed19a71d4b574e2b4acd6e851a44d556aabc047479ef053cb3dfedb41b662a3
3
+ metadata.gz: bb2abf38d1d43c103dfd54f5bf1a77310c60e507a4c4c5f2b9bd7a314b8d1f64
4
+ data.tar.gz: a655bf162c53f6f5aed16dd51337927cf7c0e4c9858adca7803af0c83e7e5ab4
5
5
  SHA512:
6
- metadata.gz: bb864456a7a6293a966406268dd66e82e364f1f3f3cc3842a54b1463f108cb0eb3f449a547ad896e2824a3ac217ba620d7297ff543151e88709cccde60221c8f
7
- data.tar.gz: 495ee1dad45c93f6173e1a444af102526ae0a22056832bc98c5bc997067327bc745f50fea20606f31ee82985e5b74e4b088a4282ced952d3afc2e3c865713235
6
+ metadata.gz: 2194448999c1d61ce195ebf09f7c1bb844b1d4e20c8e85b515bcfafc7d871fd644a8ced697522b6b0fc639164c818704b589ac9311570332147ee17549658a90
7
+ data.tar.gz: dbdf4e26219962c39f157b2d2d405cd3176c2b50b0a228a595686e80df5aff68c0477690c671a5677221eacef28766694617e87417c3157c98b82e3bf8959320
data/.rubocop.yml CHANGED
@@ -1,8 +1,8 @@
1
- require:
1
+ plugins:
2
2
  - rubocop-rspec
3
3
 
4
4
  AllCops:
5
- TargetRubyVersion: 3.2
5
+ TargetRubyVersion: 3.4
6
6
  NewCops: enable
7
7
  SuggestExtensions: false
8
8
 
@@ -19,3 +19,13 @@ Style/StringLiteralsInInterpolation:
19
19
 
20
20
  Layout/LineLength:
21
21
  Max: 125
22
+
23
+ Metrics:
24
+ Exclude:
25
+ - "spec/**/*"
26
+
27
+ RSpec/ExampleLength:
28
+ Max: 10
29
+
30
+ RSpec/MultipleExpectations:
31
+ Max: 3
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.6
data/README.md CHANGED
@@ -5,7 +5,7 @@ It provides a simple interface to manage and retrieve information about VPN keys
5
5
 
6
6
  ## Installation
7
7
 
8
- Ensure you have the required gems installed:
8
+ Requires Ruby 3.4 or newer.
9
9
 
10
10
  ```bash
11
11
  gem install outline_vpn_api
@@ -21,6 +21,25 @@ To start using the client, initialize it with the API URL:
21
21
  client = OutlineVpnApi.new('YOUR_API_URL')
22
22
  ```
23
23
 
24
+ Outline servers present a self-signed certificate, so by default the client does
25
+ not verify it. Pass the `certSha256` fingerprint that Outline Manager shows next
26
+ to the API URL to pin the certificate instead:
27
+
28
+ ```ruby
29
+ client = OutlineVpnApi.new('YOUR_API_URL', cert_sha256: 'AB:CD:...')
30
+ ```
31
+
32
+ With a fingerprint set, a connection to any other certificate is rejected with
33
+ `OutlineVpnApi::CertificateError`. Without one the connection can be
34
+ intercepted, and since the API key is part of the URL an interceptor gains full
35
+ control of the server — pinning is strongly recommended.
36
+
37
+ Requests time out after 30 seconds by default:
38
+
39
+ ```ruby
40
+ client = OutlineVpnApi.new('YOUR_API_URL', timeout: 10)
41
+ ```
42
+
24
43
  ### Methods
25
44
 
26
45
  #### `keys_list`
@@ -31,12 +50,12 @@ Fetches a list of all keys:
31
50
  keys = client.keys_list
32
51
  ```
33
52
 
34
- #### `transfered_data_by_id`
53
+ #### `transferred_data_by_id`
35
54
 
36
55
  Fetches the transferred data metrics:
37
56
 
38
57
  ```ruby
39
- data = client.transfered_data_by_id
58
+ data = client.transferred_data_by_id
40
59
  ```
41
60
 
42
61
  #### `create_key`
@@ -49,7 +68,7 @@ new_key = client.create_key
49
68
 
50
69
  #### `set_limit(key_id, limit)`
51
70
 
52
- Sets a data limit for a specific key:
71
+ Sets a data limit for a specific key. Returns `true`:
53
72
 
54
73
  ```ruby
55
74
  client.set_limit('KEY_ID', LIMIT_IN_BYTES)
@@ -57,7 +76,7 @@ client.set_limit('KEY_ID', LIMIT_IN_BYTES)
57
76
 
58
77
  #### `rename_key(key_id, name)`
59
78
 
60
- Renames a specific key:
79
+ Renames a specific key. Returns `true`:
61
80
 
62
81
  ```ruby
63
82
  client.rename_key('KEY_ID', 'NEW_NAME')
@@ -65,15 +84,34 @@ client.rename_key('KEY_ID', 'NEW_NAME')
65
84
 
66
85
  #### `delete_key(key_id)`
67
86
 
68
- Deletes a specific key:
87
+ Deletes a specific key. Returns `true`:
69
88
 
70
89
  ```ruby
71
90
  client.delete_key('KEY_ID')
72
91
  ```
73
92
 
93
+ ## Errors
94
+
95
+ All errors inherit from `OutlineVpnApi::Error`:
96
+
97
+ - `OutlineVpnApi::ResponseError` — the server replied with a non-2xx status, or
98
+ with a body that is not valid JSON. Exposes `#code` and `#body`.
99
+ - `OutlineVpnApi::ConnectionError` — the server could not be reached, or the
100
+ request timed out.
101
+ - `OutlineVpnApi::CertificateError` — a pinned fingerprint was given and the
102
+ server's certificate did not match it. Subclass of `ConnectionError`.
103
+
104
+ ```ruby
105
+ begin
106
+ client.keys_list
107
+ rescue OutlineVpnApi::ResponseError => e
108
+ warn "Outline returned #{e.code}: #{e.body}"
109
+ end
110
+ ```
111
+
74
112
  ## Dependencies
75
113
 
76
- - `httparty`: Used for making HTTP requests.
114
+ - `httparty` (>= 0.24.0): Used for making HTTP requests.
77
115
  - `json`: Used for parsing JSON responses.
78
116
 
79
117
  ## Contributing
@@ -2,55 +2,90 @@
2
2
 
3
3
  require "httparty"
4
4
  require "json"
5
+ require "openssl"
6
+
7
+ require_relative "errors"
8
+ require_relative "pinned_connection_adapter"
5
9
 
6
10
  module OutlineVpnApi
7
11
  class Client
8
- include HTTParty
9
- base_uri ""
10
- default_options.update(verify: false)
11
- headers "Content-Type" => "application/json"
12
+ DEFAULT_TIMEOUT = 30
12
13
 
13
- def initialize(api_url)
14
- self.class.base_uri(api_url)
15
- end
14
+ CONNECTION_ERRORS = [
15
+ SocketError,
16
+ SystemCallError,
17
+ Net::OpenTimeout,
18
+ Net::ReadTimeout,
19
+ HTTParty::RedirectionTooDeep
20
+ ].freeze
16
21
 
17
- attr_reader :api_url
22
+ def initialize(api_url, cert_sha256: nil, timeout: DEFAULT_TIMEOUT)
23
+ @base_uri = api_url.to_s.chomp("/")
24
+ @options = {
25
+ headers: { "Content-Type" => "application/json" },
26
+ timeout: timeout
27
+ }.merge(ssl_options(cert_sha256))
28
+ end
18
29
 
19
30
  def keys_list
20
- parse_response(self.class.get("/access-keys"))["accessKeys"]
31
+ request(:get, "/access-keys")["accessKeys"]
21
32
  end
22
33
 
23
- def transfered_data_by_id
24
- parse_response(self.class.get("/metrics/transfer"))
34
+ def transferred_data_by_id
35
+ request(:get, "/metrics/transfer")
25
36
  end
26
37
 
27
38
  def create_key
28
- parse_response(self.class.post("/access-keys"))
39
+ request(:post, "/access-keys")
29
40
  end
30
41
 
31
42
  def set_limit(key_id, limit)
32
- response = self.class.put("/access-keys/#{key_id}/data-limit", body: { limit: { bytes: limit } }.to_json)
33
- handle_response(response, "Limit for key_id:#{key_id} is #{limit} bytes")
43
+ request(:put, "/access-keys/#{key_id}/data-limit", body: { limit: { bytes: limit } })
34
44
  end
35
45
 
36
46
  def rename_key(key_id, name)
37
- response = self.class.put("/access-keys/#{key_id}/name", body: { name: }.to_json)
38
- handle_response(response, "key_id:#{key_id}, renamed to #{name}")
47
+ request(:put, "/access-keys/#{key_id}/name", body: { name: })
39
48
  end
40
49
 
41
50
  def delete_key(key_id)
42
- response = self.class.delete("/access-keys/#{key_id}")
43
- handle_response(response, "key_id:#{key_id}, deleted")
51
+ request(:delete, "/access-keys/#{key_id}")
44
52
  end
45
53
 
46
54
  private
47
55
 
48
- def parse_response(response)
49
- JSON.parse(response.body)
56
+ attr_reader :base_uri, :options
57
+
58
+ def request(verb, endpoint, body: nil)
59
+ request_options = body.nil? ? options : options.merge(body: body.to_json)
60
+ handle(HTTParty.public_send(verb, "#{base_uri}#{endpoint}", **request_options))
61
+ rescue OpenSSL::SSL::SSLError => e
62
+ raise CertificateError, e.message
63
+ rescue *CONNECTION_ERRORS => e
64
+ raise ConnectionError, e.message
65
+ end
66
+
67
+ def handle(response)
68
+ code = response.code.to_i
69
+ raise ResponseError.new(code, response.body) unless code.between?(200, 299)
70
+ return true if response.body.nil? || response.body.empty?
71
+
72
+ parse(response.body, code)
50
73
  end
51
74
 
52
- def handle_response(response, success_message)
53
- response.code.to_i == 204 ? puts(success_message) : parse_response(response)
75
+ def parse(body, code)
76
+ JSON.parse(body)
77
+ rescue JSON::ParserError
78
+ raise ResponseError.new(code, body, "Outline API returned malformed JSON (HTTP #{code})")
79
+ end
80
+
81
+ def ssl_options(cert_sha256)
82
+ return { verify: false } if cert_sha256.nil?
83
+
84
+ {
85
+ verify: true,
86
+ cert_sha256: cert_sha256,
87
+ connection_adapter: PinnedConnectionAdapter
88
+ }
54
89
  end
55
90
  end
56
91
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OutlineVpnApi
4
+ class Error < StandardError; end
5
+
6
+ class ConnectionError < Error; end
7
+
8
+ class CertificateError < ConnectionError; end
9
+
10
+ class ResponseError < Error
11
+ attr_reader :code, :body
12
+
13
+ def initialize(code, body, message = nil)
14
+ @code = code
15
+ @body = body
16
+ super(message || "Outline API returned HTTP #{code}")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "openssl"
5
+
6
+ module OutlineVpnApi
7
+ class PinnedConnectionAdapter < HTTParty::ConnectionAdapter
8
+ def connection
9
+ http = super
10
+ return http unless http.use_ssl?
11
+
12
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
13
+ http.verify_hostname = false
14
+ http.verify_callback = ->(_preverify_ok, store_context) { pinned?(store_context.current_cert) }
15
+ http
16
+ end
17
+
18
+ private
19
+
20
+ def pinned?(certificate)
21
+ OpenSSL::Digest::SHA256.hexdigest(certificate.to_der) == expected_fingerprint
22
+ end
23
+
24
+ def expected_fingerprint
25
+ @expected_fingerprint ||= options.fetch(:cert_sha256).to_s.delete(":").downcase
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OutlineVpnApi
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -4,10 +4,11 @@ require "httparty"
4
4
  require "json"
5
5
 
6
6
  require_relative "outline_vpn_api/version"
7
+ require_relative "outline_vpn_api/errors"
7
8
  require_relative "outline_vpn_api/client"
8
9
 
9
10
  module OutlineVpnApi
10
- def self.new(api_url)
11
- OutlineVpnApi::Client.new(api_url)
11
+ def self.new(api_url, **)
12
+ OutlineVpnApi::Client.new(api_url, **)
12
13
  end
13
14
  end
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = "Ruby API wrapper for Outline VPN Server https://getoutline.org/"
13
13
  spec.homepage = "https://github.com/vlasikhin/outline_vpn_api"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.2.0"
15
+ spec.required_ruby_version = ">= 3.4.0"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
18
  spec.metadata["source_code_uri"] = spec.homepage
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
20
20
  spec.files = Dir.chdir(__dir__) do
21
21
  `git ls-files -z`.split("\x0").reject do |f|
22
22
  (File.expand_path(f) == __FILE__) || f.start_with?(
23
- *%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]
23
+ *%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile CLAUDE.md]
24
24
  )
25
25
  end
26
26
  end
@@ -29,6 +29,6 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
 
32
- spec.add_dependency("httparty")
32
+ spec.add_dependency("httparty", ">= 0.24.0")
33
33
  spec.metadata["rubygems_mfa_required"] = "true"
34
34
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: outline_vpn_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pavel Vlasikhin
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-09-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: httparty
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: 0.24.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
25
+ version: 0.24.0
27
26
  description: Ruby API wrapper for Outline VPN Server https://getoutline.org/
28
27
  email:
29
28
  - pavel.vlasikhin@gmail.com
@@ -33,12 +32,15 @@ extra_rdoc_files: []
33
32
  files:
34
33
  - ".rspec"
35
34
  - ".rubocop.yml"
35
+ - ".ruby-version"
36
36
  - CODE_OF_CONDUCT.md
37
37
  - LICENSE
38
38
  - README.md
39
39
  - Rakefile
40
40
  - lib/outline_vpn_api.rb
41
41
  - lib/outline_vpn_api/client.rb
42
+ - lib/outline_vpn_api/errors.rb
43
+ - lib/outline_vpn_api/pinned_connection_adapter.rb
42
44
  - lib/outline_vpn_api/version.rb
43
45
  - outline_vpn_api.gemspec
44
46
  homepage: https://github.com/vlasikhin/outline_vpn_api
@@ -48,7 +50,6 @@ metadata:
48
50
  homepage_uri: https://github.com/vlasikhin/outline_vpn_api
49
51
  source_code_uri: https://github.com/vlasikhin/outline_vpn_api
50
52
  rubygems_mfa_required: 'true'
51
- post_install_message:
52
53
  rdoc_options: []
53
54
  require_paths:
54
55
  - lib
@@ -56,15 +57,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
56
57
  requirements:
57
58
  - - ">="
58
59
  - !ruby/object:Gem::Version
59
- version: 3.2.0
60
+ version: 3.4.0
60
61
  required_rubygems_version: !ruby/object:Gem::Requirement
61
62
  requirements:
62
63
  - - ">="
63
64
  - !ruby/object:Gem::Version
64
65
  version: '0'
65
66
  requirements: []
66
- rubygems_version: 3.4.14
67
- signing_key:
67
+ rubygems_version: 4.0.10
68
68
  specification_version: 4
69
69
  summary: Ruby API wrapper for Outline VPN Server
70
70
  test_files: []