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 +7 -0
- data/.env.example +31 -0
- data/AGENTS.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +393 -0
- data/Rakefile +12 -0
- data/examples/rails/scaled_initializer.rb +20 -0
- data/examples/rails/tailscale_client.rb +74 -0
- data/lib/scaled/auth/api_token.rb +29 -0
- data/lib/scaled/auth/oauth_client_credentials.rb +130 -0
- data/lib/scaled/client.rb +113 -0
- data/lib/scaled/errors.rb +47 -0
- data/lib/scaled/http.rb +189 -0
- data/lib/scaled/resources/devices.rb +29 -0
- data/lib/scaled/resources/keys.rb +29 -0
- data/lib/scaled/resources/logs.rb +31 -0
- data/lib/scaled/version.rb +5 -0
- data/lib/scaled.rb +17 -0
- data/sig/scaled.rbs +63 -0
- metadata +63 -0
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,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
|
data/lib/scaled/http.rb
ADDED
|
@@ -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
|
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: []
|