Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/tailscale/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
DNSNameservers,
DNSPreferences,
DNSSearchPaths,
KeyCapabilities,
KeyCapabilitiesCreate,
KeyCapabilitiesDevices,
Latency,
TailnetSettings,
TailscaleKey,
TailscaleUser,
)
from .storage import TokenStorage
Expand All @@ -30,12 +34,16 @@
"Device",
"DeviceRoutes",
"Devices",
"KeyCapabilities",
"KeyCapabilitiesCreate",
"KeyCapabilitiesDevices",
"Latency",
"TailnetSettings",
"Tailscale",
"TailscaleAuthenticationError",
"TailscaleConnectionError",
"TailscaleError",
"TailscaleKey",
"TailscaleUser",
"TokenStorage",
]
98 changes: 98 additions & 0 deletions src/tailscale/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,68 @@ def _bool(val: bool) -> str:
console.print(table)


@cli.command("keys")
async def keys_command(
tailnet: Tailnet = "-",
api_key: ApiKey = None,
oauth_client_id: OAuthClientId = None,
oauth_client_secret: OAuthClientSecret = None,
) -> None:
"""List all keys in the tailnet."""
client = _build_client(tailnet, api_key, oauth_client_id, oauth_client_secret)
async with client:
keys = await client.keys()

table = Table(title="Keys", show_header=True, border_style="dim")
table.add_column("Key ID", style="cyan")
table.add_column("Type")
table.add_column("Description", style="bold")
table.add_column("Reusable")
table.add_column("Ephemeral")
table.add_column("Expires")

for k in keys:
reusable = (
"[green]Yes[/green]"
if k.capabilities.devices.create.reusable
else "[dim]No[/dim]"
)
ephemeral = (
"[green]Yes[/green]"
if k.capabilities.devices.create.ephemeral
else "[dim]No[/dim]"
)
expires = str(k.expires) if k.expires else "[dim]-[/dim]"
table.add_row(
k.key_id,
k.key_type or "",
k.description,
reusable,
ephemeral,
expires,
)

console.print(table)


@cli.command("delete-key")
async def delete_key_command(
key_id: Annotated[
str,
typer.Argument(help="Key ID"),
],
tailnet: Tailnet = "-",
api_key: ApiKey = None,
oauth_client_id: OAuthClientId = None,
oauth_client_secret: OAuthClientSecret = None,
) -> None:
"""Delete a key from the tailnet."""
client = _build_client(tailnet, api_key, oauth_client_id, oauth_client_secret)
async with client:
await client.delete_key(key_id)
console.print(f"[red]Key {key_id} deleted.[/red]")


dump = AsyncTyper(
help="Dump raw API responses as JSON (useful for debugging/fixtures).",
no_args_is_help=True,
Expand Down Expand Up @@ -906,6 +968,42 @@ async def dump_user_command(
typer.echo(json.dumps(json.loads(data), indent=2, default=str))


@dump.command("keys")
async def dump_keys_command(
tailnet: Tailnet = "-",
api_key: ApiKey = None,
oauth_client_id: OAuthClientId = None,
oauth_client_secret: OAuthClientSecret = None,
) -> None:
"""Dump all keys as raw JSON."""
client = _build_client(tailnet, api_key, oauth_client_id, oauth_client_secret)
async with client:
data = await client._request( # noqa: SLF001
f"tailnet/{client.tailnet}/keys?all=true"
)
typer.echo(json.dumps(json.loads(data), indent=2, default=str))


@dump.command("key")
async def dump_key_command(
key_id: Annotated[
str,
typer.Argument(help="Key ID"),
],
tailnet: Tailnet = "-",
api_key: ApiKey = None,
oauth_client_id: OAuthClientId = None,
oauth_client_secret: OAuthClientSecret = None,
) -> None:
"""Dump a single key as raw JSON."""
client = _build_client(tailnet, api_key, oauth_client_id, oauth_client_secret)
async with client:
data = await client._request( # noqa: SLF001
f"tailnet/{client.tailnet}/keys/{key_id}"
)
typer.echo(json.dumps(json.loads(data), indent=2, default=str))


@dump.command("settings")
async def dump_settings_command(
tailnet: Tailnet = "-",
Expand Down
40 changes: 40 additions & 0 deletions src/tailscale/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,46 @@ class DNSSearchPaths(DataClassORJSONMixin):
)


@dataclass
class KeyCapabilitiesCreate(DataClassORJSONMixin):
"""Object holding key device creation capabilities."""

reusable: bool = False
ephemeral: bool = False
preauthorized: bool = False
tags: list[str] = field(default_factory=list)


@dataclass
class KeyCapabilitiesDevices(DataClassORJSONMixin):
"""Object holding key device capabilities."""

create: KeyCapabilitiesCreate = field(default_factory=KeyCapabilitiesCreate)


@dataclass
class KeyCapabilities(DataClassORJSONMixin):
"""Object holding key capabilities."""

devices: KeyCapabilitiesDevices = field(default_factory=KeyCapabilitiesDevices)


@dataclass
# pylint: disable-next=too-many-instance-attributes
class TailscaleKey(DataClassORJSONMixin):
"""Object holding Tailscale auth/API key information."""

key_id: str = field(metadata=field_options(alias="id"))
description: str = ""
key: str = ""
created: datetime | None = None
expires: datetime | None = None
revoked: datetime | None = None
invalid: bool = False
capabilities: KeyCapabilities = field(default_factory=KeyCapabilities)
key_type: str | None = field(default=None, metadata=field_options(alias="keyType"))


@dataclass
# pylint: disable-next=too-many-instance-attributes
class TailnetSettings(DataClassORJSONMixin):
Expand Down
91 changes: 91 additions & 0 deletions src/tailscale/tailscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
DNSPreferences,
DNSSearchPaths,
TailnetSettings,
TailscaleKey,
TailscaleUser,
)

Expand Down Expand Up @@ -635,6 +636,96 @@ async def update_tailnet_settings( # noqa: PLR0913 # pylint: disable=too-many-
data=payload,
)

async def keys(self) -> list[TailscaleKey]:
"""Get all keys in the tailnet.

Returns
-------
A list of Tailscale keys.

"""
data = await self._request(f"tailnet/{self.tailnet}/keys?all=true")
raw: list[dict[str, Any]] = json.loads(data).get("keys", [])
return [TailscaleKey.from_dict(key) for key in raw]

async def key(self, key_id: str) -> TailscaleKey:
"""Get a single key by ID.

Args:
----
key_id: The ID of the key to retrieve.

Returns:
-------
The key information.

"""
data = await self._request(f"tailnet/{self.tailnet}/keys/{key_id}")
return TailscaleKey.from_json(data)

async def create_key( # noqa: PLR0913 # pylint: disable=too-many-arguments
self,
*,
key_type: str = "auth",
description: str = "",
expiry_seconds: int = 86400,
reusable: bool = False,
ephemeral: bool = False,
preauthorized: bool = False,
tags: list[str] | None = None,
) -> TailscaleKey:
"""Create a new auth key.

Args:
----
key_type: The type of key ("auth" or "client").
description: A description for the key.
expiry_seconds: Expiry time in seconds (default: 86400 / 24h).
reusable: Whether the key can be used multiple times.
ephemeral: Whether devices using this key are ephemeral.
preauthorized: Whether devices are pre-authorized.
tags: ACL tags to assign to devices using this key.

Returns:
-------
The created key, including the secret key value.

"""
payload: dict[str, Any] = {
"keyType": key_type,
"description": description,
"expirySeconds": expiry_seconds,
"capabilities": {
"devices": {
"create": {
"reusable": reusable,
"ephemeral": ephemeral,
"preauthorized": preauthorized,
"tags": tags or [],
},
},
},
}
data = await self._request(
f"tailnet/{self.tailnet}/keys",
method=METH_POST,
data=payload,
)
return TailscaleKey.from_json(data)

async def delete_key(self, key_id: str) -> None:
"""Delete a key from the tailnet.

Args:
----
key_id: The ID of the key to delete.

"""
await self._request(
f"tailnet/{self.tailnet}/keys/{key_id}",
method=METH_DELETE,
)

async def close(self) -> None:
"""Close open client session and cancel background tasks."""
if self.session and self._close_session:
Expand Down
9 changes: 9 additions & 0 deletions tests/__snapshots__/test_tailscale.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
# name: test_dns_search_paths_snapshot
DNSSearchPaths(search_paths=['corp.example.com', 'internal.example.com'])
# ---
# name: test_key_snapshot
TailscaleKey(key_id='k1234567890abcdef', description='CI deploy key', key='tskey-auth-k1234567890abcdef-abcdef1234567890abcdef1234567890', created=datetime.datetime(2026, 1, 15, 8, 0, tzinfo=datetime.timezone.utc), expires=datetime.datetime(2026, 4, 15, 8, 0, tzinfo=datetime.timezone.utc), revoked=None, invalid=False, capabilities=KeyCapabilities(devices=KeyCapabilitiesDevices(create=KeyCapabilitiesCreate(reusable=True, ephemeral=False, preauthorized=True, tags=['tag:ci', 'tag:deploy']))), key_type='auth')
# ---
# name: test_keys_snapshot
list([
TailscaleKey(key_id='k1234567890abcdef', description='CI deploy key', key='', created=datetime.datetime(2026, 1, 15, 8, 0, tzinfo=datetime.timezone.utc), expires=datetime.datetime(2026, 4, 15, 8, 0, tzinfo=datetime.timezone.utc), revoked=None, invalid=False, capabilities=KeyCapabilities(devices=KeyCapabilitiesDevices(create=KeyCapabilitiesCreate(reusable=True, ephemeral=False, preauthorized=True, tags=['tag:ci', 'tag:deploy']))), key_type='auth'),
TailscaleKey(key_id='kfedcba0987654321', description='Ephemeral test nodes', key='', created=datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc), expires=datetime.datetime(2026, 6, 1, 12, 0, tzinfo=datetime.timezone.utc), revoked=None, invalid=False, capabilities=KeyCapabilities(devices=KeyCapabilitiesDevices(create=KeyCapabilitiesCreate(reusable=True, ephemeral=True, preauthorized=True, tags=['tag:test']))), key_type='auth'),
])
# ---
# name: test_tailnet_settings_snapshot
TailnetSettings(devices_approval_on=True, devices_auto_updates_on=True, devices_key_duration_days=90, users_approval_on=False, users_role_allowed_to_join_external_tailnets='admin', network_flow_logging_on=True, regional_routing_on=False, posture_identity_collection_on=True)
# ---
Expand Down
46 changes: 46 additions & 0 deletions tests/cli/__snapshots__/test_cli.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
'oauth_client_secret',
'tailnet',
]),
'delete-key': list([
'api_key',
'key_id',
'oauth_client_id',
'oauth_client_secret',
'tailnet',
]),
'device': list([
'api_key',
'device_id',
Expand Down Expand Up @@ -137,6 +144,19 @@
'oauth_client_secret',
'tailnet',
]),
'key': list([
'api_key',
'key_id',
'oauth_client_id',
'oauth_client_secret',
'tailnet',
]),
'keys': list([
'api_key',
'oauth_client_id',
'oauth_client_secret',
'tailnet',
]),
'routes': list([
'api_key',
'device_id',
Expand Down Expand Up @@ -171,6 +191,12 @@
'oauth_client_secret',
'tailnet',
]),
'keys': list([
'api_key',
'oauth_client_id',
'oauth_client_secret',
'tailnet',
]),
'rename': list([
'api_key',
'device_id',
Expand Down Expand Up @@ -262,6 +288,12 @@

'''
# ---
# name: test_delete_key_command
'''
Key k1234567890abcdef deleted.

'''
# ---
# name: test_device_command
'''
╭────────────────────────────────────────── exit-node-us ──────────────────────────────────────────╮
Expand Down Expand Up @@ -406,6 +438,20 @@

'''
# ---
# name: test_keys_command
'''
Keys
┏━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Key ID ┃ Type ┃ Description ┃ Reusable ┃ Ephemeral ┃ Expires ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ k1234567890abcdef │ auth │ CI deploy key │ Yes │ No │ 2026-04-15 │
│ │ │ │ │ │ 08:00:00+00:00 │
│ kfedcba0987654321 │ auth │ Ephemeral test nodes │ Yes │ Yes │ 2026-06-01 │
│ │ │ │ │ │ 12:00:00+00:00 │
└───────────────────┴──────┴──────────────────────┴──────────┴───────────┴─────────────────────────┘

'''
# ---
# name: test_missing_auth
'''
Authentication required.
Expand Down
Loading
Loading