scaled 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2e7b20aff254a7d3778443bde18004a88fe3f95d9a066a3bc04dc7965099be01
4
+ data.tar.gz: 9880c240d23f84a6bf64ff40e56de7a40cfe2d8894a4a8d5c81096366c0ab6e4
5
+ SHA512:
6
+ metadata.gz: f5d23f531c3d6079363f71716064f79e7b17181f4b35c4f4cbac60d85640912e6012a1b76edee9921ee5e5692a36225b4de5a03e288a5c429645754324277c53
7
+ data.tar.gz: de5e1ecab2b985a02c2a75e61e9ff316ff75031fcf2ce9eccd68d07c31944ae0758e8962a2d91d3da3cb2e42158ebd7426db3505712b3bc5e2db45b0d85a9622
data/.env.example ADDED
@@ -0,0 +1,31 @@
1
+ # ============================================================
2
+ # Scaled environment variables / Змінні середовища для Scaled
3
+ # ============================================================
4
+
5
+ # Enable integration smoke tests:
6
+ # 1 = run integration specs, any other value = skip.
7
+ # Увімкнути інтеграційні smoke-тести:
8
+ # 1 = запускати інтеграційні спеки, будь-яке інше значення = пропускати.
9
+ RUN_INTEGRATION=0
10
+
11
+ # Tailscale API token for Bearer auth mode.
12
+ # API токен Tailscale для режиму Bearer auth.
13
+ TAILSCALE_API_TOKEN=tskey-api-xxxxxxxxxxxxxxxx
14
+
15
+ # Tailnet target for API requests.
16
+ # Use "-" to use the token-owned tailnet.
17
+ # Tailnet для API-запитів.
18
+ # Використовуйте "-" для tailnet, що прив'язаний до токена.
19
+ TAILNET=-
20
+
21
+ # OAuth client ID (client credentials flow).
22
+ # OAuth client ID (режим client credentials).
23
+ TAILSCALE_OAUTH_CLIENT_ID=
24
+
25
+ # OAuth client secret (client credentials flow).
26
+ # OAuth client secret (режим client credentials).
27
+ TAILSCALE_OAUTH_CLIENT_SECRET=
28
+
29
+ # Space-separated OAuth scopes.
30
+ # Scopes OAuth через пробіл.
31
+ TAILSCALE_OAUTH_SCOPES=devices:core:read auth_keys:read logs:configuration:read logs:network:read
data/AGENTS.md ADDED
@@ -0,0 +1,11 @@
1
+ # AGENTS Instructions
2
+
3
+ - All newly added or modified methods must include concise method documentation.
4
+ - Preferred format is YARD-style comments directly above the method.
5
+ - Minimum required doc fields for public/protected methods:
6
+ - `@param` for non-obvious inputs
7
+ - `@return` with type/shape
8
+ - one short behavior note when side effects, defaults, or fallbacks matter
9
+ - Private helper methods may skip full docs only when they are trivial and self-explanatory (for example simple value normalization or key formatting); otherwise document them too.
10
+ - After every code change, add or update automated tests that cover the changed behavior.
11
+ - Method documentation must explicitly describe method parameters (`@param`) for all non-trivial inputs.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Voloshyn Ruslan
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,393 @@
1
+ # Scaled
2
+
3
+ `Scaled` is a read-only Ruby client for the Tailscale API.
4
+
5
+ `Scaled` це read-only Ruby клієнт для Tailscale API.
6
+
7
+ Current scope of the gem:
8
+ - devices inventory (`list`, `get`)
9
+ - keys metadata (`list`, `get`)
10
+ - logs (`configuration`, `network`)
11
+
12
+ No create/update/delete actions are exposed in resource wrappers.
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "scaled"
20
+ ```
21
+
22
+ Or install directly:
23
+
24
+ ```bash
25
+ gem install scaled
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Environment variables are documented in [.env.example](./.env.example).
31
+ Змінні середовища задокументовані в [.env.example](./.env.example).
32
+
33
+ ### API token auth
34
+
35
+ ```ruby
36
+ require "scaled"
37
+
38
+ client = Scaled.client(
39
+ api_token: ENV.fetch("TAILSCALE_API_TOKEN"),
40
+ tailnet: ENV.fetch("TAILNET", "-")
41
+ )
42
+
43
+ devices = client.devices.list
44
+ key = client.keys.get("key-id")
45
+ logs = client.logs.configuration(query: { limit: 100 })
46
+ ```
47
+
48
+ ### OAuth client credentials auth
49
+
50
+ ```ruby
51
+ require "scaled"
52
+
53
+ client = Scaled.client(
54
+ oauth: {
55
+ client_id: ENV.fetch("TAILSCALE_OAUTH_CLIENT_ID"),
56
+ client_secret: ENV.fetch("TAILSCALE_OAUTH_CLIENT_SECRET"),
57
+ scopes: %w[devices:core:read auth_keys:read logs:configuration:read logs:network:read]
58
+ },
59
+ tailnet: ENV.fetch("TAILNET", "-")
60
+ )
61
+
62
+ devices = client.devices.list
63
+ ```
64
+
65
+ Notes:
66
+ - OAuth access tokens are fetched from `https://api.tailscale.com/api/v2/oauth/token`.
67
+ - Tokens are cached and refreshed automatically before expiration.
68
+
69
+ ## Rails integration
70
+
71
+ ### 1. Add gem to Rails app
72
+
73
+ ```ruby
74
+ # Gemfile
75
+ gem "scaled"
76
+ ```
77
+
78
+ ```bash
79
+ bundle install
80
+ ```
81
+
82
+ ### 2. Configure credentials or env
83
+
84
+ Store secrets in Rails credentials (recommended) or environment variables.
85
+
86
+ Example credentials keys:
87
+
88
+ ```yaml
89
+ tailscale:
90
+ api_token: tskey-api-...
91
+ tailnet: "-"
92
+ ```
93
+
94
+ For OAuth mode:
95
+
96
+ ```yaml
97
+ tailscale:
98
+ oauth_client_id: ...
99
+ oauth_client_secret: ...
100
+ oauth_scopes: "devices:core:read auth_keys:read logs:configuration:read logs:network:read"
101
+ tailnet: "-"
102
+ ```
103
+
104
+ ### 3. Create initializer
105
+
106
+ ```ruby
107
+ # config/initializers/scaled.rb
108
+ Rails.application.config.x.scaled_client =
109
+ if Rails.application.credentials.dig(:tailscale, :api_token).present?
110
+ Scaled.client(
111
+ api_token: Rails.application.credentials.dig(:tailscale, :api_token),
112
+ tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
113
+ )
114
+ else
115
+ Scaled.client(
116
+ oauth: {
117
+ client_id: Rails.application.credentials.dig(:tailscale, :oauth_client_id),
118
+ client_secret: Rails.application.credentials.dig(:tailscale, :oauth_client_secret),
119
+ scopes: Rails.application.credentials.dig(:tailscale, :oauth_scopes).to_s.split
120
+ },
121
+ tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
122
+ )
123
+ end
124
+ ```
125
+
126
+ ### 4. Add service object
127
+
128
+ ```ruby
129
+ # app/services/tailscale_client.rb
130
+ class TailscaleClient
131
+ def self.client
132
+ Rails.configuration.x.scaled_client
133
+ end
134
+
135
+ def self.devices
136
+ client.devices.list
137
+ end
138
+
139
+ def self.keys
140
+ client.keys.list
141
+ end
142
+
143
+ def self.configuration_logs(limit: 100)
144
+ client.logs.configuration(query: { limit: limit })
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### 5. Use in Rails code
150
+
151
+ ```ruby
152
+ # rails console
153
+ TailscaleClient.devices
154
+ TailscaleClient.keys
155
+ ```
156
+
157
+ ```ruby
158
+ # app/jobs/sync_tailscale_devices_job.rb
159
+ class SyncTailscaleDevicesJob < ApplicationJob
160
+ queue_as :default
161
+
162
+ def perform
163
+ devices = TailscaleClient.devices
164
+ Rails.logger.info("tailscale_devices_count=#{devices.fetch('devices', []).size}")
165
+ end
166
+ end
167
+ ```
168
+
169
+ Ready-to-copy templates are included:
170
+ - `examples/rails/scaled_initializer.rb`
171
+ - `examples/rails/tailscale_client.rb`
172
+
173
+ ## Gem and curl examples
174
+
175
+ Examples below do the same read-only operations via gem and `curl`.
176
+
177
+ ### List devices
178
+
179
+ ```ruby
180
+ client = Scaled.client(api_token: ENV.fetch("TAILSCALE_API_TOKEN"), tailnet: "-")
181
+ response = client.devices.list
182
+ ```
183
+
184
+ ```bash
185
+ curl -sS \
186
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
187
+ "https://api.tailscale.com/api/v2/tailnet/-/devices"
188
+ ```
189
+
190
+ ### Get one device
191
+
192
+ ```ruby
193
+ response = client.devices.get("device-id")
194
+ ```
195
+
196
+ ```bash
197
+ curl -sS \
198
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
199
+ "https://api.tailscale.com/api/v2/device/device-id"
200
+ ```
201
+
202
+ ### List keys
203
+
204
+ ```ruby
205
+ response = client.keys.list
206
+ ```
207
+
208
+ ```bash
209
+ curl -sS \
210
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
211
+ "https://api.tailscale.com/api/v2/tailnet/-/keys"
212
+ ```
213
+
214
+ ### Read configuration logs
215
+
216
+ ```ruby
217
+ response = client.logs.configuration(query: { limit: 100 })
218
+ ```
219
+
220
+ ```bash
221
+ curl -sS \
222
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
223
+ "https://api.tailscale.com/api/v2/tailnet/-/logging/configuration?limit=100"
224
+ ```
225
+
226
+ ### Read network logs
227
+
228
+ ```ruby
229
+ response = client.logs.network(query: { limit: 100 })
230
+ ```
231
+
232
+ ```bash
233
+ curl -sS \
234
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
235
+ "https://api.tailscale.com/api/v2/tailnet/-/logging/network?limit=100"
236
+ ```
237
+
238
+ ## Example responses
239
+
240
+ Response shapes vary by account features and scopes. Examples:
241
+
242
+ ### Devices list (`client.devices.list`)
243
+
244
+ ```json
245
+ {
246
+ "devices": [
247
+ {
248
+ "id": "n123456CNTRL",
249
+ "name": "macbook-pro.tailnet.ts.net",
250
+ "addresses": ["100.101.102.103", "fd7a:115c:a1e0::abcd:1234"],
251
+ "user": "user@example.com",
252
+ "os": "macOS",
253
+ "created": "2026-03-12T07:12:30Z",
254
+ "lastSeen": "2026-03-12T08:25:44Z",
255
+ "authorized": true
256
+ }
257
+ ]
258
+ }
259
+ ```
260
+
261
+ ### Keys list (`client.keys.list`)
262
+
263
+ ```json
264
+ {
265
+ "keys": [
266
+ {
267
+ "id": "key_abc123",
268
+ "description": "CI read-only key",
269
+ "created": "2026-03-11T09:00:00Z",
270
+ "expires": "2026-06-09T09:00:00Z",
271
+ "capabilities": {
272
+ "devices": {
273
+ "create": {
274
+ "reusable": false,
275
+ "ephemeral": true
276
+ }
277
+ }
278
+ }
279
+ }
280
+ ]
281
+ }
282
+ ```
283
+
284
+ ### Configuration logs (`client.logs.configuration`)
285
+
286
+ ```json
287
+ {
288
+ "events": [
289
+ {
290
+ "id": "evt_cfg_1",
291
+ "time": "2026-03-12T08:11:00Z",
292
+ "actor": "admin@example.com",
293
+ "type": "policy.updated",
294
+ "details": {
295
+ "source": "api"
296
+ }
297
+ }
298
+ ]
299
+ }
300
+ ```
301
+
302
+ ### Network logs (`client.logs.network`)
303
+
304
+ ```json
305
+ {
306
+ "events": [
307
+ {
308
+ "id": "evt_net_1",
309
+ "time": "2026-03-12T08:15:00Z",
310
+ "srcDeviceId": "n123456CNTRL",
311
+ "dstDeviceId": "n998877CNTRL",
312
+ "proto": "tcp",
313
+ "dstPort": 443,
314
+ "action": "accept"
315
+ }
316
+ ]
317
+ }
318
+ ```
319
+
320
+ ## Integration smoke tests
321
+
322
+ Integration tests are opt-in and run only when `RUN_INTEGRATION=1`.
323
+
324
+ ## Environment variables
325
+
326
+ Main variables used by the gem and tests:
327
+ - `TAILSCALE_API_TOKEN` - API token for Bearer auth mode.
328
+ - `TAILSCALE_OAUTH_CLIENT_ID` - OAuth client ID for client credentials flow.
329
+ - `TAILSCALE_OAUTH_CLIENT_SECRET` - OAuth client secret for client credentials flow.
330
+ - `TAILSCALE_OAUTH_SCOPES` - space-separated OAuth scopes.
331
+ - `TAILNET` - target tailnet (`-` means token-owned tailnet).
332
+ - `RUN_INTEGRATION` - enables/disables integration smoke specs.
333
+
334
+ See full descriptions and defaults in [.env.example](./.env.example).
335
+
336
+ ### API token smoke
337
+
338
+ ```bash
339
+ RUN_INTEGRATION=1 \
340
+ TAILSCALE_API_TOKEN=tskey-api-... \
341
+ TAILNET=- \
342
+ bundle exec rspec spec/integration/read_only_smoke_spec.rb
343
+ ```
344
+
345
+ ### OAuth smoke
346
+
347
+ ```bash
348
+ RUN_INTEGRATION=1 \
349
+ TAILSCALE_OAUTH_CLIENT_ID=... \
350
+ TAILSCALE_OAUTH_CLIENT_SECRET=... \
351
+ TAILSCALE_OAUTH_SCOPES='devices:core:read auth_keys:read logs:configuration:read logs:network:read' \
352
+ TAILNET=- \
353
+ bundle exec rspec spec/integration/read_only_smoke_spec.rb
354
+ ```
355
+
356
+ ## Development
357
+
358
+ ```bash
359
+ bundle install
360
+ bundle exec rspec
361
+ bundle exec rubocop
362
+ ```
363
+
364
+ ## GitHub push and gem release
365
+
366
+ ### Push to GitHub
367
+
368
+ ```bash
369
+ git init
370
+ git add .
371
+ git commit -m "Initial read-only Tailscale client"
372
+ git remote add origin <YOUR_GITHUB_REPO_URL>
373
+ git push -u origin master
374
+ ```
375
+
376
+ ### Publish to RubyGems
377
+
378
+ Before release:
379
+ - update `scaled.gemspec` (`summary`, `description`, `homepage`, `source_code_uri`)
380
+ - update version in `lib/scaled/version.rb`
381
+ - ensure `bundle exec rspec` and `bundle exec rubocop` are green
382
+ - configure RubyGems credentials and MFA
383
+
384
+ Release:
385
+
386
+ ```bash
387
+ bundle exec rake build
388
+ bundle exec rake release
389
+ ```
390
+
391
+ ## License
392
+
393
+ MIT. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example initializer for Rails apps.
4
+ # Приклад ініціалізатора для Rails-застосунків.
5
+ Rails.application.config.x.scaled_client =
6
+ if Rails.application.credentials.dig(:tailscale, :api_token).present?
7
+ Scaled.client(
8
+ api_token: Rails.application.credentials.dig(:tailscale, :api_token),
9
+ tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
10
+ )
11
+ else
12
+ Scaled.client(
13
+ oauth: {
14
+ client_id: Rails.application.credentials.dig(:tailscale, :oauth_client_id),
15
+ client_secret: Rails.application.credentials.dig(:tailscale, :oauth_client_secret),
16
+ scopes: Rails.application.credentials.dig(:tailscale, :oauth_scopes).to_s.split
17
+ },
18
+ tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
19
+ )
20
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails service wrapper for read-only Tailscale operations.
4
+ # Rails сервіс-обгортка для read-only операцій Tailscale.
5
+ class TailscaleClient
6
+ class << self
7
+ # @return [Scaled::Client]
8
+ # Note: uses client configured in Rails initializer.
9
+ # Нотатка: використовує клієнт, налаштований в Rails ініціалізаторі.
10
+ def client
11
+ Rails.configuration.x.scaled_client
12
+ end
13
+
14
+ # @param query [Hash, nil] optional API query parameters
15
+ # @return [Hash, Array, nil] devices payload
16
+ def devices(query: nil)
17
+ client.devices.list(query: query)
18
+ rescue Scaled::Error => e
19
+ handle_error("devices.list", e)
20
+ end
21
+
22
+ # @param key_id [String] key identifier
23
+ # @return [Hash, Array, nil] key payload
24
+ def key(key_id)
25
+ client.keys.get(key_id)
26
+ rescue Scaled::Error => e
27
+ handle_error("keys.get", e)
28
+ end
29
+
30
+ # @param query [Hash, nil] optional API query parameters
31
+ # @return [Hash, Array, nil] keys payload
32
+ def keys(query: nil)
33
+ client.keys.list(query: query)
34
+ rescue Scaled::Error => e
35
+ handle_error("keys.list", e)
36
+ end
37
+
38
+ # @param limit [Integer] max items from logs endpoint
39
+ # @return [Hash, Array, nil] configuration logs payload
40
+ def configuration_logs(limit: 100)
41
+ client.logs.configuration(query: { limit: limit })
42
+ rescue Scaled::Error => e
43
+ handle_error("logs.configuration", e)
44
+ end
45
+
46
+ # @param limit [Integer] max items from logs endpoint
47
+ # @return [Hash, Array, nil] network logs payload
48
+ def network_logs(limit: 100)
49
+ client.logs.network(query: { limit: limit })
50
+ rescue Scaled::Error => e
51
+ handle_error("logs.network", e)
52
+ end
53
+
54
+ private
55
+
56
+ # @param operation [String] logical operation name
57
+ # @param error [Scaled::Error] normalized SDK error
58
+ # @return [void]
59
+ # Note: logs structured metadata and re-raises for caller-level handling.
60
+ # Нотатка: логує структуровані метадані та повторно піднімає помилку для caller-рівня.
61
+ def handle_error(operation, error)
62
+ Rails.logger.error(
63
+ {
64
+ event: "tailscale_api_error",
65
+ operation: operation,
66
+ message: error.message,
67
+ status: error.status,
68
+ request_id: error.request_id
69
+ }.to_json
70
+ )
71
+ raise
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Auth
5
+ # API token authentication strategy.
6
+ # Стратегія автентифікації через API токен.
7
+ class ApiToken
8
+ # @param token [String] Tailscale API access token
9
+ # @return [void]
10
+ # Note: token is normalized with `strip` to reduce accidental whitespace issues.
11
+ # Нотатка: токен нормалізується через `strip`, щоб уникнути проблем з пробілами.
12
+ def initialize(token)
13
+ normalized = token.to_s.strip
14
+ raise ArgumentError, "api token must be provided" if normalized.empty?
15
+
16
+ @token = normalized
17
+ end
18
+
19
+ # @param headers [Hash{String => String}] mutable request headers
20
+ # @return [Hash{String => String}] same headers hash with Authorization included
21
+ # Note: mutates and returns the same hash to avoid unnecessary allocations.
22
+ # Нотатка: змінює і повертає той самий hash для уникнення зайвих алокацій.
23
+ def apply(headers)
24
+ headers["Authorization"] = "Bearer #{@token}"
25
+ headers
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Scaled
8
+ module Auth
9
+ # OAuth Client Credentials authentication strategy.
10
+ # Стратегія OAuth Client Credentials для автентифікації.
11
+ class OAuthClientCredentials
12
+ DEFAULT_TOKEN_URL = "https://api.tailscale.com/api/v2/oauth/token"
13
+ REFRESH_SAFETY_WINDOW = 30
14
+
15
+ # @param client_id [String] OAuth client identifier
16
+ # @param client_secret [String] OAuth client secret
17
+ # @param options [Hash] optional settings (:scopes, :token_url, :open_timeout, :read_timeout, :now)
18
+ # @return [void]
19
+ # Note: token refresh is automatic when expiry approaches.
20
+ # Нотатка: токен оновлюється автоматично при наближенні до завершення дії.
21
+ def initialize(client_id:, client_secret:, **options)
22
+ @client_id = normalize_required(client_id, "client_id")
23
+ @client_secret = normalize_required(client_secret, "client_secret")
24
+ @scopes = Array(options.fetch(:scopes, [])).map(&:to_s).map(&:strip).reject(&:empty?)
25
+ @token_url = options.fetch(:token_url, DEFAULT_TOKEN_URL)
26
+ @open_timeout = options.fetch(:open_timeout, 5)
27
+ @read_timeout = options.fetch(:read_timeout, 30)
28
+ @now = options.fetch(:now, -> { Time.now })
29
+ @token_data = nil
30
+ end
31
+
32
+ # @param headers [Hash{String => String}] mutable request headers
33
+ # @return [Hash{String => String}] same hash with Bearer authorization
34
+ # Note: mutates and returns the same hash for compatibility with HTTP layer.
35
+ # Нотатка: змінює і повертає той самий hash для сумісності з HTTP-шаром.
36
+ def apply(headers)
37
+ headers["Authorization"] = "Bearer #{access_token}"
38
+ headers
39
+ end
40
+
41
+ private
42
+
43
+ # @return [String]
44
+ def access_token
45
+ refresh_token! if token_missing_or_expiring?
46
+ @token_data.fetch(:access_token)
47
+ end
48
+
49
+ # @return [Boolean]
50
+ def token_missing_or_expiring?
51
+ return true if @token_data.nil?
52
+
53
+ @token_data[:expires_at] <= (@now.call + REFRESH_SAFETY_WINDOW)
54
+ end
55
+
56
+ # @return [void]
57
+ def refresh_token!
58
+ parsed = parse_token_response(fetch_token_response)
59
+ @token_data = {
60
+ access_token: parsed.fetch("access_token"),
61
+ expires_at: @now.call + parsed.fetch("expires_in").to_i
62
+ }
63
+ end
64
+
65
+ # @return [Net::HTTPResponse]
66
+ def fetch_token_response
67
+ uri = URI(@token_url)
68
+ Net::HTTP.start(
69
+ uri.host,
70
+ uri.port,
71
+ use_ssl: uri.scheme == "https",
72
+ open_timeout: @open_timeout,
73
+ read_timeout: @read_timeout
74
+ ) { |http| http.request(build_token_request(uri)) }
75
+ end
76
+
77
+ # @param uri [URI::Generic]
78
+ # @return [Net::HTTP::Post]
79
+ def build_token_request(uri)
80
+ request = Net::HTTP::Post.new(uri)
81
+ request.basic_auth(@client_id, @client_secret)
82
+ request["Content-Type"] = "application/x-www-form-urlencoded"
83
+ request["Accept"] = "application/json"
84
+ request.body = URI.encode_www_form(token_request_body)
85
+ request
86
+ end
87
+
88
+ # @return [Hash{String => String}]
89
+ def token_request_body
90
+ body = { "grant_type" => "client_credentials" }
91
+ body["scope"] = @scopes.join(" ") unless @scopes.empty?
92
+ body
93
+ end
94
+
95
+ # @param response [Net::HTTPResponse]
96
+ # @return [Hash]
97
+ def parse_token_response(response)
98
+ status = response.code.to_i
99
+ parsed = parse_json(response.body)
100
+ return parsed if status.between?(200, 299)
101
+
102
+ message = parsed["error_description"] || parsed["error"] || "oauth token request failed with status #{status}"
103
+ raise AuthenticationError.new(message, status: status, response_body: parsed)
104
+ end
105
+
106
+ # @param raw_body [String, nil]
107
+ # @return [Hash]
108
+ def parse_json(raw_body)
109
+ return {} if raw_body.nil? || raw_body.strip.empty?
110
+
111
+ value = JSON.parse(raw_body)
112
+ return value if value.is_a?(Hash)
113
+
114
+ raise AuthenticationError, "oauth token endpoint returned non-object JSON"
115
+ rescue JSON::ParserError
116
+ raise AuthenticationError, "oauth token endpoint returned invalid JSON"
117
+ end
118
+
119
+ # @param value [String]
120
+ # @param field [String]
121
+ # @return [String]
122
+ def normalize_required(value, field)
123
+ normalized = value.to_s.strip
124
+ raise ArgumentError, "#{field} must be provided" if normalized.empty?
125
+
126
+ normalized
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth/api_token"
4
+ require_relative "auth/oauth_client_credentials"
5
+ require_relative "http"
6
+ require_relative "resources/devices"
7
+ require_relative "resources/keys"
8
+ require_relative "resources/logs"
9
+
10
+ module Scaled
11
+ # Main entry point for interacting with Tailscale API.
12
+ # Основна точка входу для взаємодії з Tailscale API.
13
+ class Client
14
+ attr_reader :tailnet
15
+
16
+ # @param api_token [String, nil] Tailscale API token for Bearer auth
17
+ # @param auth [#apply, nil] custom authentication strategy
18
+ # @param oauth [Hash, nil] OAuth settings (:client_id, :client_secret, optional :scopes, :token_url)
19
+ # @param tailnet [String] tailnet name, or "-" for token-owned tailnet
20
+ # @param http_options [Hash] custom HTTP options (base_url, timeouts, user_agent)
21
+ # @return [void]
22
+ # Note: exactly one auth mode must be provided: `api_token`, `auth`, or `oauth`.
23
+ # Нотатка: потрібно передати рівно один режим auth: `api_token`, `auth` або `oauth`.
24
+ def initialize(api_token: nil, auth: nil, oauth: nil, tailnet: "-", **http_options)
25
+ @tailnet = tailnet
26
+ auth_strategy = resolve_auth(api_token: api_token, auth: auth, oauth: oauth)
27
+ @http = HTTP.new(auth: auth_strategy, **http_options)
28
+ end
29
+
30
+ # @param path [String] endpoint path
31
+ # @param query [Hash, nil] query parameters
32
+ # @return [Hash, Array, String, nil] parsed response body
33
+ # Note: use this low-level method until resource-specific clients are introduced.
34
+ # Нотатка: використовуйте цей low-level метод, доки не додані ресурсні клієнти.
35
+ def get(path, query: nil)
36
+ @http.request(method: :get, path: path, query: query)
37
+ end
38
+
39
+ # @param path [String] endpoint path
40
+ # @param body [Hash, Array, nil] JSON body
41
+ # @param query [Hash, nil] query parameters
42
+ # @return [Hash, Array, String, nil] parsed response body
43
+ def post(path, body: nil, query: nil)
44
+ @http.request(method: :post, path: path, body: body, query: query)
45
+ end
46
+
47
+ # @param path [String] endpoint path
48
+ # @param body [Hash, Array, nil] JSON body
49
+ # @param query [Hash, nil] query parameters
50
+ # @return [Hash, Array, String, nil] parsed response body
51
+ def put(path, body: nil, query: nil)
52
+ @http.request(method: :put, path: path, body: body, query: query)
53
+ end
54
+
55
+ # @param path [String] endpoint path
56
+ # @param body [Hash, Array, nil] JSON body
57
+ # @param query [Hash, nil] query parameters
58
+ # @return [Hash, Array, String, nil] parsed response body
59
+ def patch(path, body: nil, query: nil)
60
+ @http.request(method: :patch, path: path, body: body, query: query)
61
+ end
62
+
63
+ # @param path [String] endpoint path
64
+ # @param query [Hash, nil] query parameters
65
+ # @return [Hash, Array, String, nil] parsed response body
66
+ def delete(path, query: nil)
67
+ @http.request(method: :delete, path: path, query: query)
68
+ end
69
+
70
+ # @return [Scaled::Resources::Devices]
71
+ def devices
72
+ @devices ||= Resources::Devices.new(self)
73
+ end
74
+
75
+ # @return [Scaled::Resources::Keys]
76
+ def keys
77
+ @keys ||= Resources::Keys.new(self)
78
+ end
79
+
80
+ # @return [Scaled::Resources::Logs]
81
+ def logs
82
+ @logs ||= Resources::Logs.new(self)
83
+ end
84
+
85
+ private
86
+
87
+ # @param api_token [String, nil]
88
+ # @param auth [#apply, nil]
89
+ # @param oauth [Hash, nil]
90
+ # @return [#apply]
91
+ def resolve_auth(api_token:, auth:, oauth:)
92
+ present_modes = [!api_token.nil?, !auth.nil?, !oauth.nil?].count(true)
93
+ raise ArgumentError, "provide exactly one of api_token, auth, or oauth" unless present_modes == 1
94
+
95
+ return auth if auth
96
+ return Auth::ApiToken.new(api_token) if api_token
97
+
98
+ build_oauth_auth(oauth)
99
+ end
100
+
101
+ # @param oauth [Hash]
102
+ # @return [Scaled::Auth::OAuthClientCredentials]
103
+ def build_oauth_auth(oauth)
104
+ config = oauth.transform_keys(&:to_sym)
105
+ Auth::OAuthClientCredentials.new(
106
+ client_id: config.fetch(:client_id),
107
+ client_secret: config.fetch(:client_secret),
108
+ scopes: config.fetch(:scopes, []),
109
+ token_url: config.fetch(:token_url, Auth::OAuthClientCredentials::DEFAULT_TOKEN_URL)
110
+ )
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ # Base error for all library-level failures.
5
+ # Базова помилка для всіх помилок бібліотеки.
6
+ class Error < StandardError
7
+ attr_reader :status, :response_body, :request_id
8
+
9
+ # @param message [String] human-readable error message
10
+ # @param status [Integer, nil] HTTP status code when available
11
+ # @param response_body [Hash, String, nil] parsed API error body
12
+ # @param request_id [String, nil] request identifier from response headers
13
+ # @return [void]
14
+ # Note: keeps transport metadata to simplify diagnostics.
15
+ # Нотатка: зберігає метадані запиту для зручної діагностики.
16
+ def initialize(message, status: nil, response_body: nil, request_id: nil)
17
+ super(message)
18
+ @status = status
19
+ @response_body = response_body
20
+ @request_id = request_id
21
+ end
22
+ end
23
+
24
+ # Raised when credentials are missing, invalid, or expired.
25
+ # Викликається, коли облікові дані відсутні, невалідні або протерміновані.
26
+ class AuthenticationError < Error; end
27
+
28
+ # Raised when authenticated identity lacks required scope/permissions.
29
+ # Викликається, коли автентифікований суб'єкт не має потрібних прав/скоупів.
30
+ class AuthorizationError < Error; end
31
+
32
+ # Raised when requested resource does not exist.
33
+ # Викликається, коли запитаний ресурс не існує.
34
+ class NotFoundError < Error; end
35
+
36
+ # Raised when API rate limits the client.
37
+ # Викликається, коли API обмежує частоту запитів клієнта.
38
+ class RateLimitError < Error; end
39
+
40
+ # Raised when request payload or query is invalid.
41
+ # Викликається, коли payload або query некоректні.
42
+ class ValidationError < Error; end
43
+
44
+ # Raised for unexpected 5xx responses.
45
+ # Викликається для неочікуваних відповідей 5xx.
46
+ class ServerError < Error; end
47
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Scaled
8
+ # Low-level HTTP wrapper for Tailscale API requests.
9
+ # Низькорівневий HTTP-обгортка для запитів до Tailscale API.
10
+ class HTTP
11
+ DEFAULT_BASE_URL = "https://api.tailscale.com/api/v2"
12
+
13
+ # @param auth [#apply] authentication strategy object
14
+ # @param base_url [String] API base URL
15
+ # @param open_timeout [Numeric] connection timeout seconds
16
+ # @param read_timeout [Numeric] read timeout seconds
17
+ # @param user_agent [String] value for User-Agent header
18
+ # @return [void]
19
+ # Note: `auth` must respond to `apply(headers)`.
20
+ # Нотатка: `auth` має підтримувати метод `apply(headers)`.
21
+ def initialize(auth:, base_url: DEFAULT_BASE_URL, open_timeout: 5, read_timeout: 30, user_agent: "scaled-ruby")
22
+ @auth = auth
23
+ @base_url = base_url
24
+ @open_timeout = open_timeout
25
+ @read_timeout = read_timeout
26
+ @user_agent = user_agent
27
+ end
28
+
29
+ # @param method [Symbol, String] HTTP method (:get, :post, ...)
30
+ # @param path [String] endpoint path beginning with /
31
+ # @param query [Hash, nil] query params (nil values are dropped)
32
+ # @param body [Hash, Array, nil] JSON-serializable request body
33
+ # @param headers [Hash{String => String}] extra request headers
34
+ # @return [Hash, Array, String, nil] parsed response body
35
+ # Note: raises specialized Scaled errors for non-2xx responses.
36
+ # Нотатка: для не-2xx відповідей піднімає спеціалізовані помилки Scaled.
37
+ def request(method:, path:, query: nil, body: nil, headers: {})
38
+ uri = build_uri(path, query)
39
+ http_response = perform_request(method: method, uri: uri, body: body, headers: headers)
40
+ parse_or_raise(http_response)
41
+ end
42
+
43
+ private
44
+
45
+ # @param path [String]
46
+ # @param query [Hash, nil]
47
+ # @return [URI::HTTPS, URI::HTTP]
48
+ def build_uri(path, query)
49
+ base = URI.join("#{@base_url}/", path.sub(%r{^/}, ""))
50
+ return base if query.nil? || query.empty?
51
+
52
+ compacted = query.each_with_object({}) do |(key, value), memo|
53
+ memo[key] = value unless value.nil?
54
+ end
55
+ base.query = URI.encode_www_form(compacted)
56
+ base
57
+ end
58
+
59
+ # @param method [Symbol, String]
60
+ # @param uri [URI::Generic]
61
+ # @param body [Hash, Array, nil]
62
+ # @param headers [Hash{String => String}]
63
+ # @return [Net::HTTPRequest]
64
+ def build_request(method, uri, body, headers)
65
+ request = request_class_for(method).new(uri)
66
+ apply_headers(request, headers)
67
+ apply_body(request, body)
68
+ request
69
+ end
70
+
71
+ # @param method [Symbol, String]
72
+ # @param uri [URI::Generic]
73
+ # @param body [Hash, Array, nil]
74
+ # @param headers [Hash{String => String}]
75
+ # @return [Net::HTTPResponse]
76
+ def perform_request(method:, uri:, body:, headers:)
77
+ Net::HTTP.start(
78
+ uri.host,
79
+ uri.port,
80
+ use_ssl: uri.scheme == "https",
81
+ open_timeout: @open_timeout,
82
+ read_timeout: @read_timeout
83
+ ) do |http|
84
+ request = build_request(method, uri, body, headers)
85
+ http.request(request)
86
+ end
87
+ end
88
+
89
+ # @param response [Net::HTTPResponse]
90
+ # @return [Hash, Array, String, nil]
91
+ def parse_or_raise(response)
92
+ parsed_body = parse_body(response.body)
93
+ return parsed_body if success_status?(response.code)
94
+
95
+ raise map_error(response, parsed_body)
96
+ end
97
+
98
+ # @param request [Net::HTTPRequest]
99
+ # @param headers [Hash{String => String}]
100
+ # @return [void]
101
+ def apply_headers(request, headers)
102
+ merged_headers = {
103
+ "Accept" => "application/json",
104
+ "User-Agent" => @user_agent
105
+ }.merge(headers)
106
+ @auth.apply(merged_headers)
107
+ merged_headers.each { |key, value| request[key] = value }
108
+ end
109
+
110
+ # @param request [Net::HTTPRequest]
111
+ # @param body [Hash, Array, nil]
112
+ # @return [void]
113
+ def apply_body(request, body)
114
+ return if body.nil?
115
+
116
+ request["Content-Type"] ||= "application/json"
117
+ request.body = JSON.generate(body)
118
+ end
119
+
120
+ # @param method [Symbol, String]
121
+ # @return [Class]
122
+ def request_class_for(method)
123
+ case method.to_s.downcase
124
+ when "get" then Net::HTTP::Get
125
+ when "post" then Net::HTTP::Post
126
+ when "put" then Net::HTTP::Put
127
+ when "patch" then Net::HTTP::Patch
128
+ when "delete" then Net::HTTP::Delete
129
+ else
130
+ raise ArgumentError, "unsupported HTTP method: #{method}"
131
+ end
132
+ end
133
+
134
+ # @param code [String]
135
+ # @return [Boolean]
136
+ def success_status?(code)
137
+ code.to_i.between?(200, 299)
138
+ end
139
+
140
+ # @param raw_body [String, nil]
141
+ # @return [Hash, Array, String, nil]
142
+ def parse_body(raw_body)
143
+ return nil if raw_body.nil? || raw_body.strip.empty?
144
+
145
+ JSON.parse(raw_body)
146
+ rescue JSON::ParserError
147
+ raw_body
148
+ end
149
+
150
+ # @param response [Net::HTTPResponse]
151
+ # @param parsed_body [Hash, Array, String, nil]
152
+ # @return [Scaled::Error]
153
+ def map_error(response, parsed_body)
154
+ status = response.code.to_i
155
+ message = error_message_for(status, parsed_body)
156
+ request_id = response["x-request-id"]
157
+
158
+ error_class_for(status).new(
159
+ message,
160
+ status: status,
161
+ response_body: parsed_body,
162
+ request_id: request_id
163
+ )
164
+ end
165
+
166
+ # @param status [Integer]
167
+ # @return [Class]
168
+ def error_class_for(status)
169
+ case status
170
+ when 400, 422 then ValidationError
171
+ when 401 then AuthenticationError
172
+ when 403 then AuthorizationError
173
+ when 404 then NotFoundError
174
+ when 429 then RateLimitError
175
+ when 500..599 then ServerError
176
+ else Error
177
+ end
178
+ end
179
+
180
+ # @param status [Integer]
181
+ # @param parsed_body [Hash, Array, String, nil]
182
+ # @return [String]
183
+ def error_message_for(status, parsed_body)
184
+ return "HTTP #{status}" unless parsed_body.is_a?(Hash)
185
+
186
+ parsed_body["message"] || parsed_body["error"] || "HTTP #{status}"
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Resources
5
+ # API wrapper for tailnet devices.
6
+ # API-обгортка для комп'ютерів (devices) tailnet.
7
+ class Devices
8
+ # @param client [Scaled::Client] configured API client
9
+ # @return [void]
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # @param query [Hash, nil] optional query params for list endpoint
15
+ # @return [Hash, Array, String, nil] parsed response
16
+ # Note: uses current client tailnet scope.
17
+ # Нотатка: використовує поточний tailnet з client-конфігурації.
18
+ def list(query: nil)
19
+ @client.get("/tailnet/#{@client.tailnet}/devices", query: query)
20
+ end
21
+
22
+ # @param device_id [String] Tailscale device identifier
23
+ # @return [Hash, Array, String, nil] parsed response
24
+ def get(device_id)
25
+ @client.get("/device/#{device_id}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Resources
5
+ # API wrapper for tailnet keys metadata.
6
+ # API-обгортка для метаданих ключів tailnet.
7
+ class Keys
8
+ # @param client [Scaled::Client] configured API client
9
+ # @return [void]
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # @param query [Hash, nil] optional query params
15
+ # @return [Hash, Array, String, nil] parsed response
16
+ # Note: returns all visible key records for current scope.
17
+ # Нотатка: повертає всі доступні записи ключів у поточному scope.
18
+ def list(query: nil)
19
+ @client.get("/tailnet/#{@client.tailnet}/keys", query: query)
20
+ end
21
+
22
+ # @param key_id [String] Tailscale key identifier
23
+ # @return [Hash, Array, String, nil] parsed response
24
+ def get(key_id)
25
+ @client.get("/tailnet/#{@client.tailnet}/keys/#{key_id}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Resources
5
+ # API wrapper for tailnet logging endpoints.
6
+ # API-обгортка для endpoint-ів логування tailnet.
7
+ class Logs
8
+ # @param client [Scaled::Client] configured API client
9
+ # @return [void]
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # @param query [Hash, nil] optional query params
15
+ # @return [Hash, Array, String, nil] parsed response
16
+ # Note: retrieves logging configuration events.
17
+ # Нотатка: повертає події/конфігурацію логування.
18
+ def configuration(query: nil)
19
+ @client.get("/tailnet/#{@client.tailnet}/logging/configuration", query: query)
20
+ end
21
+
22
+ # @param query [Hash, nil] optional query params
23
+ # @return [Hash, Array, String, nil] parsed response
24
+ # Note: retrieves network log stream snapshots/events.
25
+ # Нотатка: повертає мережеві логи/події.
26
+ def network(query: nil)
27
+ @client.get("/tailnet/#{@client.tailnet}/logging/network", query: query)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ VERSION = "0.1.1"
5
+ end
data/lib/scaled.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scaled/version"
4
+ require_relative "scaled/errors"
5
+ require_relative "scaled/client"
6
+
7
+ # Public namespace for the Scaled gem.
8
+ # Публічний простір імен для gem Scaled.
9
+ module Scaled
10
+ # Build a configured API client instance.
11
+ # Створює налаштований екземпляр API клієнта.
12
+ # @param kwargs [Hash] forwarded options for Scaled::Client
13
+ # @return [Scaled::Client]
14
+ def self.client(**)
15
+ Client.new(**)
16
+ end
17
+ end
data/sig/scaled.rbs ADDED
@@ -0,0 +1,63 @@
1
+ module Scaled
2
+ VERSION: String
3
+
4
+ def self.client: (**untyped kwargs) -> Client
5
+
6
+ class Error < StandardError
7
+ attr_reader status: Integer?
8
+ attr_reader response_body: Hash[String, untyped] | String | nil
9
+ attr_reader request_id: String?
10
+ end
11
+
12
+ class AuthenticationError < Error
13
+ end
14
+
15
+ class AuthorizationError < Error
16
+ end
17
+
18
+ class NotFoundError < Error
19
+ end
20
+
21
+ class RateLimitError < Error
22
+ end
23
+
24
+ class ValidationError < Error
25
+ end
26
+
27
+ class ServerError < Error
28
+ end
29
+
30
+ class Client
31
+ attr_reader tailnet: String
32
+
33
+ def initialize: (?api_token: String?, ?auth: untyped, ?oauth: Hash[Symbol, untyped]?, ?tailnet: String, **untyped http_options) -> void
34
+ def get: (String path, ?query: Hash[Symbol | String, untyped]?) -> untyped
35
+ def post: (String path, ?body: untyped, ?query: Hash[Symbol | String, untyped]?) -> untyped
36
+ def put: (String path, ?body: untyped, ?query: Hash[Symbol | String, untyped]?) -> untyped
37
+ def patch: (String path, ?body: untyped, ?query: Hash[Symbol | String, untyped]?) -> untyped
38
+ def delete: (String path, ?query: Hash[Symbol | String, untyped]?) -> untyped
39
+ def devices: () -> Resources::Devices
40
+ def keys: () -> Resources::Keys
41
+ def logs: () -> Resources::Logs
42
+ end
43
+
44
+ module Resources
45
+ class Devices
46
+ def initialize: (Client client) -> void
47
+ def list: (?query: Hash[Symbol | String, untyped]?) -> untyped
48
+ def get: (String device_id) -> untyped
49
+ end
50
+
51
+ class Keys
52
+ def initialize: (Client client) -> void
53
+ def list: (?query: Hash[Symbol | String, untyped]?) -> untyped
54
+ def get: (String key_id) -> untyped
55
+ end
56
+
57
+ class Logs
58
+ def initialize: (Client client) -> void
59
+ def configuration: (?query: Hash[Symbol | String, untyped]?) -> untyped
60
+ def network: (?query: Hash[Symbol | String, untyped]?) -> untyped
61
+ end
62
+ end
63
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scaled
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Voloshyn Ruslan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-03-12 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Scaled provides a read-only Ruby SDK for Tailscale API endpoints: devices,
13
+ keys, and logs. Supports API token and OAuth client credentials authentication.'
14
+ email:
15
+ - rebisall@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".env.example"
21
+ - AGENTS.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - examples/rails/scaled_initializer.rb
26
+ - examples/rails/tailscale_client.rb
27
+ - lib/scaled.rb
28
+ - lib/scaled/auth/api_token.rb
29
+ - lib/scaled/auth/oauth_client_credentials.rb
30
+ - lib/scaled/client.rb
31
+ - lib/scaled/errors.rb
32
+ - lib/scaled/http.rb
33
+ - lib/scaled/resources/devices.rb
34
+ - lib/scaled/resources/keys.rb
35
+ - lib/scaled/resources/logs.rb
36
+ - lib/scaled/version.rb
37
+ - sig/scaled.rbs
38
+ homepage: https://github.com/bublik/scaled
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ allowed_push_host: https://rubygems.org
43
+ homepage_uri: https://github.com/bublik/scaled
44
+ source_code_uri: https://github.com/bublik/scaled
45
+ rubygems_mfa_required: 'true'
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 3.2.0
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.6.2
61
+ specification_version: 4
62
+ summary: Read-only Ruby client for Tailscale API
63
+ test_files: []