scaled 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e7b20aff254a7d3778443bde18004a88fe3f95d9a066a3bc04dc7965099be01
4
- data.tar.gz: 9880c240d23f84a6bf64ff40e56de7a40cfe2d8894a4a8d5c81096366c0ab6e4
3
+ metadata.gz: 2a020a9c01e0e1d4412044cbd43e4d839d125820ccaa2f5d2c47f21d0117b0ef
4
+ data.tar.gz: f1caec44fa505d4587c9cef1e18b4f337e490147c9880274bc106c31d683f2c1
5
5
  SHA512:
6
- metadata.gz: f5d23f531c3d6079363f71716064f79e7b17181f4b35c4f4cbac60d85640912e6012a1b76edee9921ee5e5692a36225b4de5a03e288a5c429645754324277c53
7
- data.tar.gz: de5e1ecab2b985a02c2a75e61e9ff316ff75031fcf2ce9eccd68d07c31944ae0758e8962a2d91d3da3cb2e42158ebd7426db3505712b3bc5e2db45b0d85a9622
6
+ metadata.gz: 6d3ed8af11e8d86bb04b59fc605522bcce575a96ff49049636953fc23c5343f45ebc0060dd0d239e3c8421f746a3112815897b3e2ce6e58087516774efe6e27d
7
+ data.tar.gz: 82440e425e2c3d25be7d43f5cb248f331fca4203535e9183fc3a8a18599b92df8fdaf4ea9cf810c322b23fc3fa1049d1d68267159e314872385afc78b8b0d033
data/README.md CHANGED
@@ -6,8 +6,10 @@
6
6
 
7
7
  Current scope of the gem:
8
8
  - devices inventory (`list`, `get`)
9
+ - device routes (`device_routes.get`) — advertised/enabled subnet routes and exit nodes
9
10
  - keys metadata (`list`, `get`)
10
11
  - logs (`configuration`, `network`)
12
+ - policy file / ACL (`acl.get`) — `acls`/`grants`, `tagOwners`, `groups`
11
13
 
12
14
  No create/update/delete actions are exposed in resource wrappers.
13
15
 
@@ -187,78 +189,66 @@ curl -sS \
187
189
  "https://api.tailscale.com/api/v2/tailnet/-/devices"
188
190
  ```
189
191
 
190
- ### Get one device
191
-
192
- ```ruby
193
- response = client.devices.get("device-id")
194
- ```
192
+ Example server response:
195
193
 
196
- ```bash
197
- curl -sS \
198
- -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
199
- "https://api.tailscale.com/api/v2/device/device-id"
194
+ ```json
195
+ {
196
+ "devices": [
197
+ {
198
+ "id": "n123456CNTRL",
199
+ "name": "macbook-pro.tailnet.ts.net",
200
+ "addresses": ["100.101.102.103", "fd7a:115c:a1e0::abcd:1234"],
201
+ "user": "user@example.com",
202
+ "os": "macOS",
203
+ "created": "2026-03-12T07:12:30Z",
204
+ "lastSeen": "2026-03-12T08:25:44Z",
205
+ "authorized": true
206
+ }
207
+ ]
208
+ }
200
209
  ```
201
210
 
202
- ### List keys
211
+ ### Get one device
203
212
 
204
213
  ```ruby
205
- response = client.keys.list
214
+ response = client.devices.get("device-id")
206
215
  ```
207
216
 
208
217
  ```bash
209
218
  curl -sS \
210
219
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
211
- "https://api.tailscale.com/api/v2/tailnet/-/keys"
220
+ "https://api.tailscale.com/api/v2/device/device-id"
212
221
  ```
213
222
 
214
- ### Read configuration logs
223
+ Example server response:
215
224
 
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"
225
+ ```json
226
+ {
227
+ "id": "n123456CNTRL",
228
+ "name": "macbook-pro.tailnet.ts.net",
229
+ "hostname": "macbook-pro",
230
+ "addresses": ["100.101.102.103", "fd7a:115c:a1e0::abcd:1234"],
231
+ "user": "user@example.com",
232
+ "os": "macOS",
233
+ "created": "2026-03-12T07:12:30Z",
234
+ "lastSeen": "2026-03-12T08:25:44Z",
235
+ "authorized": true
236
+ }
224
237
  ```
225
238
 
226
- ### Read network logs
239
+ ### List keys
227
240
 
228
241
  ```ruby
229
- response = client.logs.network(query: { limit: 100 })
242
+ response = client.keys.list
230
243
  ```
231
244
 
232
245
  ```bash
233
246
  curl -sS \
234
247
  -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
- }
248
+ "https://api.tailscale.com/api/v2/tailnet/-/keys"
259
249
  ```
260
250
 
261
- ### Keys list (`client.keys.list`)
251
+ Example server response:
262
252
 
263
253
  ```json
264
254
  {
@@ -281,7 +271,19 @@ Response shapes vary by account features and scopes. Examples:
281
271
  }
282
272
  ```
283
273
 
284
- ### Configuration logs (`client.logs.configuration`)
274
+ ### Read configuration logs
275
+
276
+ ```ruby
277
+ response = client.logs.configuration(query: { limit: 100 })
278
+ ```
279
+
280
+ ```bash
281
+ curl -sS \
282
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
283
+ "https://api.tailscale.com/api/v2/tailnet/-/logging/configuration?limit=100"
284
+ ```
285
+
286
+ Example server response:
285
287
 
286
288
  ```json
287
289
  {
@@ -299,24 +301,109 @@ Response shapes vary by account features and scopes. Examples:
299
301
  }
300
302
  ```
301
303
 
302
- ### Network logs (`client.logs.network`)
304
+ ### Read network logs
305
+
306
+ Network flow logs require an RFC 3339 `start` and `end` time window (there is no
307
+ `limit` parameter). Source and destination are reported as `addr:port` strings.
308
+
309
+ ```ruby
310
+ response = client.logs.network(
311
+ query: { start: "2026-03-12T00:00:00Z", end: "2026-03-12T23:59:59Z" }
312
+ )
313
+ ```
314
+
315
+ ```bash
316
+ curl -sS \
317
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
318
+ "https://api.tailscale.com/api/v2/tailnet/-/logging/network?start=2026-03-12T00:00:00Z&end=2026-03-12T23:59:59Z"
319
+ ```
320
+
321
+ Example server response (`logs` is an array of `NetworkFlowLog` objects):
303
322
 
304
323
  ```json
305
324
  {
306
- "events": [
325
+ "logs": [
307
326
  {
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"
327
+ "logged": "2026-03-12T08:15:26Z",
328
+ "nodeId": "n123456CNTRL",
329
+ "start": "2026-03-12T08:15:25Z",
330
+ "end": "2026-03-12T08:15:26Z",
331
+ "virtualTraffic": [
332
+ {
333
+ "proto": "tcp",
334
+ "src": "100.101.102.103:52343",
335
+ "dst": "100.110.120.130:443",
336
+ "txPkts": 10,
337
+ "txBytes": 1200,
338
+ "rxPkts": 8,
339
+ "rxBytes": 900
340
+ }
341
+ ],
342
+ "subnetTraffic": [],
343
+ "exitTraffic": [],
344
+ "physicalTraffic": []
315
345
  }
316
346
  ]
317
347
  }
318
348
  ```
319
349
 
350
+ Traffic is split into `virtualTraffic` (tailnet peer-to-peer), `subnetTraffic`
351
+ (through subnet routers), `exitTraffic` (through exit nodes), and
352
+ `physicalTraffic` (underlying physical endpoints).
353
+
354
+ ### Read device routes
355
+
356
+ ```ruby
357
+ response = client.device_routes.get("device-id")
358
+ ```
359
+
360
+ ```bash
361
+ curl -sS \
362
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
363
+ "https://api.tailscale.com/api/v2/device/device-id/routes"
364
+ ```
365
+
366
+ Example server response:
367
+
368
+ ```json
369
+ {
370
+ "advertisedRoutes": ["10.0.0.0/24", "0.0.0.0/0", "::/0"],
371
+ "enabledRoutes": ["10.0.0.0/24"]
372
+ }
373
+ ```
374
+
375
+ ### Read policy file (ACL)
376
+
377
+ By default the policy file is returned as JSON. Pass `details: true` to get a
378
+ JSON object with a base64-encoded huJSON `acl` plus `warnings` and `errors`.
379
+
380
+ ```ruby
381
+ policy = client.acl.get
382
+ detailed = client.acl.get(query: { details: true })
383
+ ```
384
+
385
+ ```bash
386
+ curl -sS \
387
+ -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
388
+ -H "Accept: application/json" \
389
+ "https://api.tailscale.com/api/v2/tailnet/-/acl"
390
+ ```
391
+
392
+ Example server response:
393
+
394
+ ```json
395
+ {
396
+ "groups": { "group:eng": ["alice@example.com"] },
397
+ "tagOwners": { "tag:server": ["group:eng"] },
398
+ "acls": [
399
+ { "action": "accept", "src": ["group:eng"], "dst": ["tag:server:443"] }
400
+ ]
401
+ }
402
+ ```
403
+
404
+ OAuth note: reading routes needs `devices:routes:read`; reading the ACL needs an
405
+ ACL read scope (`acl:read`). Add them to your OAuth scopes when using those calls.
406
+
320
407
  ## Integration smoke tests
321
408
 
322
409
  Integration tests are opt-in and run only when `RUN_INTEGRATION=1`.
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Build a network-topology graph of a Tailscale tailnet using the read-only
4
+ # Scaled client and export it as Graphviz DOT + JSON.
5
+ #
6
+ # Будує граф топології мережі tailnet за допомогою read-only клієнта Scaled
7
+ # та експортує його у Graphviz DOT + JSON.
8
+ #
9
+ # Endpoints used (verified against https://tailscale.com/api OpenAPI):
10
+ # 1. GET /tailnet/{tailnet}/devices?fields=all -> вузли + tags, routes, addresses
11
+ # 2. GET /tailnet/{tailnet}/logging/network -> NetworkFlowLog[] (virtualTraffic)
12
+ # requires RFC3339 `start` & `end` (NOT `limit`); src/dst are "ip:port".
13
+ # 3. GET /tailnet/{tailnet}/acl?details=true -> policy (tagOwners, groups) context
14
+ #
15
+ # Usage / Запуск:
16
+ # TAILSCALE_API_TOKEN=tskey-api-... TAILNET=- \
17
+ # LOG_START=2026-06-20T00:00:00Z LOG_END=2026-06-21T00:00:00Z \
18
+ # ruby examples/topology_graph.rb
19
+ # # -> tmp/topology.dot, tmp/topology.json
20
+ # # dot -Tsvg tmp/topology.dot -o tmp/topology.svg
21
+ #
22
+ # OAuth scopes needed: devices:core:read, logs:network:read (+ acl:read for ACL).
23
+
24
+ require "json"
25
+ require "fileutils"
26
+ require_relative "../lib/scaled"
27
+
28
+ module TopologyGraph
29
+ module_function
30
+
31
+ def build_client
32
+ if ENV["TAILSCALE_API_TOKEN"]
33
+ Scaled.client(api_token: ENV.fetch("TAILSCALE_API_TOKEN"), tailnet: ENV.fetch("TAILNET", "-"))
34
+ else
35
+ Scaled.client(
36
+ oauth: {
37
+ client_id: ENV.fetch("TAILSCALE_OAUTH_CLIENT_ID"),
38
+ client_secret: ENV.fetch("TAILSCALE_OAUTH_CLIENT_SECRET"),
39
+ scopes: %w[devices:core:read logs:network:read acl:read]
40
+ },
41
+ tailnet: ENV.fetch("TAILNET", "-")
42
+ )
43
+ end
44
+ end
45
+
46
+ # Vertices: one node per device, enriched with tags / routes / addresses.
47
+ # Also build an IP -> deviceId index to resolve flow-log endpoints.
48
+ # Вершини: вузол на кожен device + індекс IP -> deviceId для зіставлення логів.
49
+ def collect_nodes(client)
50
+ # `fields=all` returns tags, enabledRoutes, advertisedRoutes, addresses, clientConnectivity.
51
+ devices = client.devices.list(query: { fields: "all" }).fetch("devices", [])
52
+ ip_index = {}
53
+
54
+ nodes = devices.each_with_object({}) do |device, acc|
55
+ id = device.fetch("id")
56
+ (device["addresses"] || []).each { |addr| ip_index[addr] = id }
57
+ acc[id] = {
58
+ id: id,
59
+ name: device["name"] || device["hostname"],
60
+ user: device["user"],
61
+ os: device["os"],
62
+ addresses: device["addresses"] || [],
63
+ tags: device["tags"] || [], # ACL-теги вузла
64
+ advertised_routes: device["advertisedRoutes"] || [], # subnet routes / exit node
65
+ enabled_routes: device["enabledRoutes"] || []
66
+ }
67
+ end
68
+
69
+ [nodes, ip_index]
70
+ end
71
+
72
+ # Edges: aggregate tailnet peer-to-peer flows (virtualTraffic) from network logs.
73
+ # src/dst are "ip:port"; the ip is resolved back to a device via ip_index.
74
+ # Ребра: агрегує tailnet-потоки (virtualTraffic) у зв'язки device -> device.
75
+ def collect_edges(client, ip_index)
76
+ logs = client.logs.network(query: { start: log_start, end: log_end }).fetch("logs", [])
77
+ edges = Hash.new { |hash, key| hash[key] = { pkts: 0, bytes: 0 } }
78
+
79
+ logs.each do |entry|
80
+ Array(entry["virtualTraffic"]).each do |flow|
81
+ src = ip_index[host(flow["src"])]
82
+ dst = ip_index[host(flow["dst"])]
83
+ next unless src && dst
84
+
85
+ agg = edges[[src, dst, flow["proto"], port(flow["dst"])]]
86
+ agg[:pkts] += flow["txPkts"].to_i + flow["rxPkts"].to_i
87
+ agg[:bytes] += flow["txBytes"].to_i + flow["rxBytes"].to_i
88
+ end
89
+ end
90
+
91
+ edges
92
+ end
93
+
94
+ # Optional: tagOwners / groups from the policy file for legend context.
95
+ # Note: `details=true` returns { acl: <base64 huJSON>, warnings, errors }.
96
+ def collect_policy(client)
97
+ client.acl.get(query: { details: true })
98
+ rescue Scaled::Error => e
99
+ warn "ACL fetch skipped (#{e.class}: #{e.message})"
100
+ nil
101
+ end
102
+
103
+ def to_dot(nodes, edges)
104
+ lines = ["digraph tailnet {", " rankdir=LR;", " node [shape=box, style=rounded];"]
105
+
106
+ nodes.each_value do |node|
107
+ label = [node[:name], node[:os], (node[:tags].join(",") unless node[:tags].empty?)].compact.join("\\n")
108
+ color = node[:tags].empty? ? "black" : "darkgreen" # tagged hosts highlighted
109
+ lines << %( "#{node[:id]}" [label="#{label}", color="#{color}"];)
110
+ end
111
+
112
+ edges.each do |(src, dst, proto, dport), agg|
113
+ lines << %( "#{src}" -> "#{dst}" [label="#{proto}:#{dport} #{agg[:bytes]}B"];)
114
+ end
115
+
116
+ "#{lines.join("\n")}\n}\n"
117
+ end
118
+
119
+ def host(addr_port)
120
+ return nil unless addr_port
121
+
122
+ addr_port.rpartition(":").first # strips :port, keeps IPv4/IPv6 host
123
+ end
124
+
125
+ def port(addr_port)
126
+ addr_port&.rpartition(":")&.last
127
+ end
128
+
129
+ def log_start
130
+ ENV.fetch("LOG_START") { raise "set LOG_START (RFC3339), e.g. 2026-06-20T00:00:00Z" }
131
+ end
132
+
133
+ def log_end
134
+ ENV.fetch("LOG_END") { raise "set LOG_END (RFC3339), e.g. 2026-06-21T00:00:00Z" }
135
+ end
136
+
137
+ def run
138
+ client = build_client
139
+ nodes, ip_index = collect_nodes(client)
140
+ edges = collect_edges(client, ip_index)
141
+ policy = collect_policy(client)
142
+
143
+ FileUtils.mkdir_p("tmp")
144
+ File.write("tmp/topology.dot", to_dot(nodes, edges))
145
+ File.write("tmp/topology.json", JSON.pretty_generate(
146
+ nodes: nodes.values,
147
+ edges: edges.map do |(s, d, pr, dp), a|
148
+ { src: s, dst: d, proto: pr, dst_port: dp, **a }
149
+ end,
150
+ tag_owners: policy.is_a?(Hash) ? policy["tagOwners"] : nil
151
+ ))
152
+
153
+ puts "nodes=#{nodes.size} edges=#{edges.size} -> tmp/topology.dot, tmp/topology.json"
154
+ end
155
+ end
156
+
157
+ TopologyGraph.run if $PROGRAM_NAME == __FILE__
data/lib/scaled/client.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "http"
6
6
  require_relative "resources/devices"
7
7
  require_relative "resources/keys"
8
8
  require_relative "resources/logs"
9
+ require_relative "resources/acl"
10
+ require_relative "resources/device_routes"
9
11
 
10
12
  module Scaled
11
13
  # Main entry point for interacting with Tailscale API.
@@ -82,6 +84,16 @@ module Scaled
82
84
  @logs ||= Resources::Logs.new(self)
83
85
  end
84
86
 
87
+ # @return [Scaled::Resources::Acl]
88
+ def acl
89
+ @acl ||= Resources::Acl.new(self)
90
+ end
91
+
92
+ # @return [Scaled::Resources::DeviceRoutes]
93
+ def device_routes
94
+ @device_routes ||= Resources::DeviceRoutes.new(self)
95
+ end
96
+
85
97
  private
86
98
 
87
99
  # @param api_token [String, nil]
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Resources
5
+ # API wrapper for the tailnet policy file (ACL).
6
+ # API-обгортка для policy-файлу tailnet (ACL).
7
+ class Acl
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 (e.g. { details: 1 })
15
+ # @return [Hash, Array, String, nil] parsed policy file (acls/grants, tagOwners, groups, ...)
16
+ # Note: read-only; returns the current ACL/policy document for the tailnet.
17
+ # Нотатка: read-only; повертає поточний policy-файл (ACL) для tailnet.
18
+ def get(query: nil)
19
+ @client.get("/tailnet/#{@client.tailnet}/acl", query: query)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scaled
4
+ module Resources
5
+ # API wrapper for per-device subnet routes.
6
+ # API-обгортка для маршрутів (subnet routes) окремого device.
7
+ class DeviceRoutes
8
+ # @param client [Scaled::Client] configured API client
9
+ # @return [void]
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # @param device_id [String] Tailscale device identifier
15
+ # @return [Hash, Array, String, nil] parsed response with advertisedRoutes/enabledRoutes
16
+ # Note: read-only; reveals subnet routers and exit nodes for the device.
17
+ # Нотатка: read-only; показує subnet-routers та exit-nodes для device.
18
+ def get(device_id)
19
+ @client.get("/device/#{device_id}/routes")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Scaled
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/sig/scaled.rbs CHANGED
@@ -39,6 +39,8 @@ module Scaled
39
39
  def devices: () -> Resources::Devices
40
40
  def keys: () -> Resources::Keys
41
41
  def logs: () -> Resources::Logs
42
+ def acl: () -> Resources::Acl
43
+ def device_routes: () -> Resources::DeviceRoutes
42
44
  end
43
45
 
44
46
  module Resources
@@ -59,5 +61,15 @@ module Scaled
59
61
  def configuration: (?query: Hash[Symbol | String, untyped]?) -> untyped
60
62
  def network: (?query: Hash[Symbol | String, untyped]?) -> untyped
61
63
  end
64
+
65
+ class Acl
66
+ def initialize: (Client client) -> void
67
+ def get: (?query: Hash[Symbol | String, untyped]?) -> untyped
68
+ end
69
+
70
+ class DeviceRoutes
71
+ def initialize: (Client client) -> void
72
+ def get: (String device_id) -> untyped
73
+ end
62
74
  end
63
75
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scaled
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Voloshyn Ruslan
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-12 00:00:00.000000000 Z
10
+ date: 2026-06-21 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'Scaled provides a read-only Ruby SDK for Tailscale API endpoints: devices,
13
13
  keys, and logs. Supports API token and OAuth client credentials authentication.'
@@ -24,12 +24,15 @@ files:
24
24
  - Rakefile
25
25
  - examples/rails/scaled_initializer.rb
26
26
  - examples/rails/tailscale_client.rb
27
+ - examples/topology_graph.rb
27
28
  - lib/scaled.rb
28
29
  - lib/scaled/auth/api_token.rb
29
30
  - lib/scaled/auth/oauth_client_credentials.rb
30
31
  - lib/scaled/client.rb
31
32
  - lib/scaled/errors.rb
32
33
  - lib/scaled/http.rb
34
+ - lib/scaled/resources/acl.rb
35
+ - lib/scaled/resources/device_routes.rb
33
36
  - lib/scaled/resources/devices.rb
34
37
  - lib/scaled/resources/keys.rb
35
38
  - lib/scaled/resources/logs.rb