Scaled

Scaled is a read-only Ruby client for the Tailscale API.

Scaled це read-only Ruby клієнт для Tailscale API.

Current scope of the gem:

  • devices inventory (list, get)
  • device routes (device_routes.get) — advertised/enabled subnet routes and exit nodes
  • keys metadata (list, get)
  • logs (configuration, network)
  • policy file / ACL (acl.get) — acls/grants, tagOwners, groups

No create/update/delete actions are exposed in resource wrappers.

Installation

Add to your Gemfile:

gem "scaled"

Or install directly:

gem install scaled

Usage

Environment variables are documented in .env.example. Змінні середовища задокументовані в .env.example.

API token auth

require "scaled"

client = Scaled.client(
  api_token: ENV.fetch("TAILSCALE_API_TOKEN"),
  tailnet: ENV.fetch("TAILNET", "-")
)

devices = client.devices.list
key = client.keys.get("key-id")
logs = client.logs.configuration(query: { limit: 100 })

OAuth client credentials auth

require "scaled"

client = Scaled.client(
  oauth: {
    client_id: ENV.fetch("TAILSCALE_OAUTH_CLIENT_ID"),
    client_secret: ENV.fetch("TAILSCALE_OAUTH_CLIENT_SECRET"),
    scopes: %w[devices:core:read auth_keys:read logs:configuration:read logs:network:read]
  },
  tailnet: ENV.fetch("TAILNET", "-")
)

devices = client.devices.list

Notes:

  • OAuth access tokens are fetched from https://api.tailscale.com/api/v2/oauth/token.
  • Tokens are cached and refreshed automatically before expiration.

Rails integration

1. Add gem to Rails app

# Gemfile
gem "scaled"
bundle install

2. Configure credentials or env

Store secrets in Rails credentials (recommended) or environment variables.

Example credentials keys:

tailscale:
  api_token: tskey-api-...
  tailnet: "-"

For OAuth mode:

tailscale:
  oauth_client_id: ...
  oauth_client_secret: ...
  oauth_scopes: "devices:core:read auth_keys:read logs:configuration:read logs:network:read"
  tailnet: "-"

3. Create initializer

# config/initializers/scaled.rb
Rails.application.config.x.scaled_client =
  if Rails.application.credentials.dig(:tailscale, :api_token).present?
    Scaled.client(
      api_token: Rails.application.credentials.dig(:tailscale, :api_token),
      tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
    )
  else
    Scaled.client(
      oauth: {
        client_id: Rails.application.credentials.dig(:tailscale, :oauth_client_id),
        client_secret: Rails.application.credentials.dig(:tailscale, :oauth_client_secret),
        scopes: Rails.application.credentials.dig(:tailscale, :oauth_scopes).to_s.split
      },
      tailnet: Rails.application.credentials.dig(:tailscale, :tailnet) || "-"
    )
  end

4. Add service object

# app/services/tailscale_client.rb
class TailscaleClient
  def self.client
    Rails.configuration.x.scaled_client
  end

  def self.devices
    client.devices.list
  end

  def self.keys
    client.keys.list
  end

  def self.configuration_logs(limit: 100)
    client.logs.configuration(query: { limit: limit })
  end
end

5. Use in Rails code

# rails console
TailscaleClient.devices
TailscaleClient.keys
# app/jobs/sync_tailscale_devices_job.rb
class SyncTailscaleDevicesJob < ApplicationJob
  queue_as :default

  def perform
    devices = TailscaleClient.devices
    Rails.logger.info("tailscale_devices_count=#{devices.fetch('devices', []).size}")
  end
end

Ready-to-copy templates are included:

  • examples/rails/scaled_initializer.rb
  • examples/rails/tailscale_client.rb

Gem and curl examples

Examples below do the same read-only operations via gem and curl.

List devices

client = Scaled.client(api_token: ENV.fetch("TAILSCALE_API_TOKEN"), tailnet: "-")
response = client.devices.list
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/tailnet/-/devices"

Example server response:

{
  "devices": [
    {
      "id": "n123456CNTRL",
      "name": "macbook-pro.tailnet.ts.net",
      "addresses": ["100.101.102.103", "fd7a:115c:a1e0::abcd:1234"],
      "user": "user@example.com",
      "os": "macOS",
      "created": "2026-03-12T07:12:30Z",
      "lastSeen": "2026-03-12T08:25:44Z",
      "authorized": true
    }
  ]
}

Get one device

response = client.devices.get("device-id")
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/device/device-id"

Example server response:

{
  "id": "n123456CNTRL",
  "name": "macbook-pro.tailnet.ts.net",
  "hostname": "macbook-pro",
  "addresses": ["100.101.102.103", "fd7a:115c:a1e0::abcd:1234"],
  "user": "user@example.com",
  "os": "macOS",
  "created": "2026-03-12T07:12:30Z",
  "lastSeen": "2026-03-12T08:25:44Z",
  "authorized": true
}

List keys

response = client.keys.list
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/tailnet/-/keys"

Example server response:

{
  "keys": [
    {
      "id": "key_abc123",
      "description": "CI read-only key",
      "created": "2026-03-11T09:00:00Z",
      "expires": "2026-06-09T09:00:00Z",
      "capabilities": {
        "devices": {
          "create": {
            "reusable": false,
            "ephemeral": true
          }
        }
      }
    }
  ]
}

Read configuration logs

response = client.logs.configuration(query: { limit: 100 })
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/tailnet/-/logging/configuration?limit=100"

Example server response:

{
  "events": [
    {
      "id": "evt_cfg_1",
      "time": "2026-03-12T08:11:00Z",
      "actor": "admin@example.com",
      "type": "policy.updated",
      "details": {
        "source": "api"
      }
    }
  ]
}

Read network logs

Network flow logs require an RFC 3339 start and end time window (there is no limit parameter). Source and destination are reported as addr:port strings.

response = client.logs.network(
  query: { start: "2026-03-12T00:00:00Z", end: "2026-03-12T23:59:59Z" }
)
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/tailnet/-/logging/network?start=2026-03-12T00:00:00Z&end=2026-03-12T23:59:59Z"

Example server response (logs is an array of NetworkFlowLog objects):

{
  "logs": [
    {
      "logged": "2026-03-12T08:15:26Z",
      "nodeId": "n123456CNTRL",
      "start": "2026-03-12T08:15:25Z",
      "end": "2026-03-12T08:15:26Z",
      "virtualTraffic": [
        {
          "proto": "tcp",
          "src": "100.101.102.103:52343",
          "dst": "100.110.120.130:443",
          "txPkts": 10,
          "txBytes": 1200,
          "rxPkts": 8,
          "rxBytes": 900
        }
      ],
      "subnetTraffic": [],
      "exitTraffic": [],
      "physicalTraffic": []
    }
  ]
}

Traffic is split into virtualTraffic (tailnet peer-to-peer), subnetTraffic (through subnet routers), exitTraffic (through exit nodes), and physicalTraffic (underlying physical endpoints).

Read device routes

response = client.device_routes.get("device-id")
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  "https://api.tailscale.com/api/v2/device/device-id/routes"

Example server response:

{
  "advertisedRoutes": ["10.0.0.0/24", "0.0.0.0/0", "::/0"],
  "enabledRoutes": ["10.0.0.0/24"]
}

Read policy file (ACL)

By default the policy file is returned as JSON. Pass details: true to get a JSON object with a base64-encoded huJSON acl plus warnings and errors.

policy = client.acl.get
detailed = client.acl.get(query: { details: true })
curl -sS \
  -H "Authorization: Bearer ${TAILSCALE_API_TOKEN}" \
  -H "Accept: application/json" \
  "https://api.tailscale.com/api/v2/tailnet/-/acl"

Example server response:

{
  "groups": { "group:eng": ["alice@example.com"] },
  "tagOwners": { "tag:server": ["group:eng"] },
  "acls": [
    { "action": "accept", "src": ["group:eng"], "dst": ["tag:server:443"] }
  ]
}

OAuth note: reading routes needs devices:routes:read; reading the ACL needs an ACL read scope (acl:read). Add them to your OAuth scopes when using those calls.

Integration smoke tests

Integration tests are opt-in and run only when RUN_INTEGRATION=1.

Environment variables

Main variables used by the gem and tests:

  • TAILSCALE_API_TOKEN - API token for Bearer auth mode.
  • TAILSCALE_OAUTH_CLIENT_ID - OAuth client ID for client credentials flow.
  • TAILSCALE_OAUTH_CLIENT_SECRET - OAuth client secret for client credentials flow.
  • TAILSCALE_OAUTH_SCOPES - space-separated OAuth scopes.
  • TAILNET - target tailnet (- means token-owned tailnet).
  • RUN_INTEGRATION - enables/disables integration smoke specs.

See full descriptions and defaults in .env.example.

API token smoke

RUN_INTEGRATION=1 \
TAILSCALE_API_TOKEN=tskey-api-... \
TAILNET=- \
bundle exec rspec spec/integration/read_only_smoke_spec.rb

OAuth smoke

RUN_INTEGRATION=1 \
TAILSCALE_OAUTH_CLIENT_ID=... \
TAILSCALE_OAUTH_CLIENT_SECRET=... \
TAILSCALE_OAUTH_SCOPES='devices:core:read auth_keys:read logs:configuration:read logs:network:read' \
TAILNET=- \
bundle exec rspec spec/integration/read_only_smoke_spec.rb

Development

bundle install
bundle exec rspec
bundle exec rubocop

GitHub push and gem release

Push to GitHub

git init
git add .
git commit -m "Initial read-only Tailscale client"
git remote add origin <YOUR_GITHUB_REPO_URL>
git push -u origin master

Publish to RubyGems

Before release:

  • update scaled.gemspec (summary, description, homepage, source_code_uri)
  • update version in lib/scaled/version.rb
  • ensure bundle exec rspec and bundle exec rubocop are green
  • configure RubyGems credentials and MFA

Release:

bundle exec rake build
bundle exec rake release

License

MIT. See LICENSE.txt.