diff --git a/.gitignore b/.gitignore index ea4444c..4700777 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ dist/ .env .gocache + +PR_MESSAGE.md diff --git a/README.md b/README.md index c295fc7..f1ffdc6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ tscli agent update --dir . | **Policy file (ACL)** | Fetch as raw HUJSON **or** canonical JSON | | **Webhooks** | List, get, delete, **create** (generic / Slack) with subscription & provider validation | | **Posture integrations** | List, get, create, patch existing integrations | +| **Services** | List, get, create / update, delete VIP services; list & approve associated devices | | **Invites** | List / delete device- or user-invites | | **Contacts** | Get & update contact emails | | **Debug switch** | `--debug` or `TSCLI_DEBUG=1` prints full HTTP requests / responses to stderr | @@ -150,9 +151,15 @@ make docs-serve # serve docs locally with docsify | ----------------- | --------------------------------------- | ---------------- | ------- | | Tailscale API key | `--api-key`, `-k` / `TAILSCALE_API_KEY` | `api-key` | — | | Tailnet name | `--tailnet`, `-n` / `TAILSCALE_TAILNET` | `tailnet` | `-` | +| API base URL | — / `TSCLI_BASE_URL` | `base-url` | `https://api.tailscale.com` | +| User agent | — / `TSCLI_USER_AGENT` | `user-agent` | derived from build version / local git | | Active profile | — | `active-tailnet` | — | | Profile list | — | `tailnets` | `[]` | +`base-url` (and the OAuth token endpoint derived from it) must be `https://`, or `http://` restricted to a loopback host (`127.0.0.1`, `::1`, `localhost`) — any other scheme or non-loopback `http://` host is rejected, since your API key or OAuth client secret would otherwise be sent to that URL. There is no `--base-url` flag; set it via `TSCLI_BASE_URL` or a `base-url:` key in `~/.tscli.yaml`. + +By default the `User-Agent` sent with every API request is derived from the build version (or, for a locally-built binary, this process's local git repository state). If you are embedding `pkg/tscli` as a library, set `user-agent` (via `TSCLI_USER_AGENT` or your own viper config) to avoid sending your own repository's git metadata to Tailscale's API. + ```yaml # ~/.tscli.yaml output: pretty # other options are: human, json or yaml @@ -313,6 +320,10 @@ tscli delete key --key key-id | Update service | :white_check_mark: | `tscli set service --service --body ''` | | Set service approval | :white_check_mark: | `tscli set service approval --service --device --approved=true` | | Delete service | :white_check_mark: | `tscli delete service --service ` | +| **Tailnet lifecycle** | | | +| List tailnets | :white_check_mark: | `tscli list tailnets` | +| Create tailnet | :white_check_mark: | `tscli create tailnet --display-name ` | +| Delete tailnet | :white_check_mark: | `tscli delete tailnet --id ` | | **Tailnet Settings** | | | | Get tailnet settings | :white_check_mark: | `tscli get settings` | | Update tailnet settings | :white_check_mark: | `tscli set settings --devices-approval …` | diff --git a/cmd/tscli/create/tailnet/cli.go b/cmd/tscli/create/tailnet/cli.go index 281069d..a85270e 100644 --- a/cmd/tscli/create/tailnet/cli.go +++ b/cmd/tscli/create/tailnet/cli.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/jaxxstorm/tscli/pkg/config" - "github.com/jaxxstorm/tscli/pkg/oauth" "github.com/jaxxstorm/tscli/pkg/output" "github.com/jaxxstorm/tscli/pkg/tscli" "github.com/spf13/cobra" @@ -38,7 +37,7 @@ func Command() *cobra.Command { return err } - tokenResp, err := oauth.ExchangeClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) + tokenResp, err := tscli.ExchangeOAuthClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) if err != nil { return fmt.Errorf("failed to exchange OAuth credentials: %w", err) } diff --git a/cmd/tscli/create/token/cli.go b/cmd/tscli/create/token/cli.go index e58a7e5..eb030da 100644 --- a/cmd/tscli/create/token/cli.go +++ b/cmd/tscli/create/token/cli.go @@ -10,8 +10,8 @@ import ( "errors" "fmt" - "github.com/jaxxstorm/tscli/pkg/oauth" "github.com/jaxxstorm/tscli/pkg/output" + "github.com/jaxxstorm/tscli/pkg/tscli" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -37,8 +37,7 @@ func Command() *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { - // Use the OAuth library for token exchange - tokenResp, err := oauth.ExchangeClientCredentials(cmd.Context(), clientID, clientSecret) + tokenResp, err := tscli.ExchangeOAuthClientCredentials(cmd.Context(), clientID, clientSecret) if err != nil { return fmt.Errorf("failed to exchange OAuth credentials: %w", err) } diff --git a/cmd/tscli/delete/tailnet/cli.go b/cmd/tscli/delete/tailnet/cli.go index f27d240..7be2041 100644 --- a/cmd/tscli/delete/tailnet/cli.go +++ b/cmd/tscli/delete/tailnet/cli.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/jaxxstorm/tscli/pkg/config" - "github.com/jaxxstorm/tscli/pkg/oauth" "github.com/jaxxstorm/tscli/pkg/output" "github.com/jaxxstorm/tscli/pkg/tscli" "github.com/spf13/cobra" @@ -32,7 +31,7 @@ func Command() *cobra.Command { return err } - tokenResp, err := oauth.ExchangeClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) + tokenResp, err := tscli.ExchangeOAuthClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) if err != nil { return fmt.Errorf("failed to exchange OAuth credentials: %w", err) } diff --git a/cmd/tscli/delete/users/cli_test.go b/cmd/tscli/delete/users/cli_test.go index 8dd7fd5..6d986a9 100644 --- a/cmd/tscli/delete/users/cli_test.go +++ b/cmd/tscli/delete/users/cli_test.go @@ -60,7 +60,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) } func newStubClientWithUsers(users []tsapi.User, deleteError map[string]int) (*tsapi.Client, *stubRoundTripper, error) { - base, _ := url.Parse("http://fake") + base, _ := url.Parse("https://fake") rt := &stubRoundTripper{users: users, deleteError: deleteError} return &tsapi.Client{ BaseURL: base, diff --git a/cmd/tscli/list/tailnets/cli.go b/cmd/tscli/list/tailnets/cli.go index 82d388b..435a534 100644 --- a/cmd/tscli/list/tailnets/cli.go +++ b/cmd/tscli/list/tailnets/cli.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/jaxxstorm/tscli/pkg/config" - "github.com/jaxxstorm/tscli/pkg/oauth" "github.com/jaxxstorm/tscli/pkg/output" "github.com/jaxxstorm/tscli/pkg/tscli" "github.com/spf13/cobra" @@ -35,7 +34,7 @@ func Command() *cobra.Command { return err } - tokenResp, err := oauth.ExchangeClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) + tokenResp, err := tscli.ExchangeOAuthClientCredentials(cmd.Context(), creds.ClientID, creds.ClientSecret) if err != nil { return fmt.Errorf("failed to exchange OAuth credentials: %w", err) } diff --git a/coverage/coverage-gaps.json b/coverage/coverage-gaps.json index 1b7e799..a9522ad 100644 --- a/coverage/coverage-gaps.json +++ b/coverage/coverage-gaps.json @@ -1,7 +1,7 @@ { - "openapi_operations": 90, + "openapi_operations": 93, "excluded_operations": [], - "in_scope_operations": 90, + "in_scope_operations": 93, "manifest_commands": 103, "excluded_commands": [ "agent init", @@ -26,6 +26,7 @@ "delete /device/{deviceId}", "delete /device/{deviceId}/attributes/{attributeKey}", "delete /posture/integrations/{id}", + "delete /tailnet/{tailnet}", "delete /tailnet/{tailnet}/keys/{keyId}", "delete /tailnet/{tailnet}/logging/{logType}/stream", "delete /tailnet/{tailnet}/oauth-apps/{appId}", @@ -37,6 +38,7 @@ "get /device/{deviceId}/attributes", "get /device/{deviceId}/device-invites", "get /device/{deviceId}/routes", + "get /organizations/{organization}/tailnets", "get /posture/integrations/{id}", "get /tailnet/{tailnet}/acl", "get /tailnet/{tailnet}/contacts", @@ -83,6 +85,7 @@ "post /device/{deviceId}/name", "post /device/{deviceId}/routes", "post /device/{deviceId}/tags", + "post /organizations/{organization}/tailnets", "post /tailnet/{tailnet}/acl", "post /tailnet/{tailnet}/acl/preview", "post /tailnet/{tailnet}/acl/validate", @@ -213,6 +216,7 @@ "create key": "post /tailnet/{tailnet}/keys", "create oauth-app": "post /tailnet/{tailnet}/oauth-apps", "create posture-integration": "post /tailnet/{tailnet}/posture/integrations", + "create tailnet": "post /organizations/{organization}/tailnets", "create webhook": "post /tailnet/{tailnet}/webhooks", "delete device": "delete /device/{deviceId}", "delete device invite": "delete /device-invites/{deviceInviteId}", @@ -224,6 +228,7 @@ "delete oauth-app": "delete /tailnet/{tailnet}/oauth-apps/{appId}", "delete posture-integration": "delete /posture/integrations/{id}", "delete service": "delete /tailnet/{tailnet}/services/{serviceName}", + "delete tailnet": "delete /tailnet/{tailnet}", "delete user": "post /users/{userId}/delete", "delete user invite": "delete /user-invites/{userInviteId}", "delete users": "get /tailnet/{tailnet}/users, post /users/{userId}/delete", @@ -264,6 +269,7 @@ "list routes": "get /device/{deviceId}/routes", "list services": "get /tailnet/{tailnet}/services", "list services devices": "get /tailnet/{tailnet}/services/{serviceName}/devices", + "list tailnets": "get /organizations/{organization}/tailnets", "list users": "get /tailnet/{tailnet}/users", "list webhooks": "get /tailnet/{tailnet}/webhooks", "set contact": "patch /tailnet/{tailnet}/contacts/{contactType}, post /tailnet/{tailnet}/contacts/{contactType}/resend-verification-email", @@ -560,6 +566,12 @@ "get /device/{deviceId}/device-invites response [].multiUse", "get /device/{deviceId}/device-invites response [].sharerId", "get /device/{deviceId}/device-invites response [].tailnetId", + "get /organizations/{organization}/tailnets response tailnets", + "get /organizations/{organization}/tailnets response tailnets[]", + "get /organizations/{organization}/tailnets response tailnets[].createdAt", + "get /organizations/{organization}/tailnets response tailnets[].displayName", + "get /organizations/{organization}/tailnets response tailnets[].id", + "get /organizations/{organization}/tailnets response tailnets[].orgId", "get /posture/integrations/{id} response clientId", "get /posture/integrations/{id} response clientSecret", "get /posture/integrations/{id} response cloudId", @@ -779,6 +791,7 @@ "get /tailnet/{tailnet}/services response vipServices[].addrs", "get /tailnet/{tailnet}/services response vipServices[].addrs[]", "get /tailnet/{tailnet}/services response vipServices[].comment", + "get /tailnet/{tailnet}/services response vipServices[].displayName", "get /tailnet/{tailnet}/services response vipServices[].name", "get /tailnet/{tailnet}/services response vipServices[].ports", "get /tailnet/{tailnet}/services response vipServices[].ports[]", @@ -787,6 +800,7 @@ "get /tailnet/{tailnet}/services/{serviceName} response addrs", "get /tailnet/{tailnet}/services/{serviceName} response addrs[]", "get /tailnet/{tailnet}/services/{serviceName} response comment", + "get /tailnet/{tailnet}/services/{serviceName} response displayName", "get /tailnet/{tailnet}/services/{serviceName} response name", "get /tailnet/{tailnet}/services/{serviceName} response ports", "get /tailnet/{tailnet}/services/{serviceName} response ports[]", @@ -950,6 +964,16 @@ "post /device/{deviceId}/name request name", "post /device/{deviceId}/tags request tags", "post /device/{deviceId}/tags request tags[]", + "post /organizations/{organization}/tailnets request displayName", + "post /organizations/{organization}/tailnets response alreadyExists", + "post /organizations/{organization}/tailnets response createdAt", + "post /organizations/{organization}/tailnets response displayName", + "post /organizations/{organization}/tailnets response dnsName", + "post /organizations/{organization}/tailnets response id", + "post /organizations/{organization}/tailnets response oauthClient", + "post /organizations/{organization}/tailnets response oauthClient.id", + "post /organizations/{organization}/tailnets response oauthClient.secret", + "post /organizations/{organization}/tailnets response orgId", "post /tailnet/{tailnet}/acl/preview response matches", "post /tailnet/{tailnet}/acl/preview response matches[]", "post /tailnet/{tailnet}/acl/preview response matches[].lineNumber", @@ -1152,6 +1176,7 @@ "put /tailnet/{tailnet}/services/{serviceName} request addrs", "put /tailnet/{tailnet}/services/{serviceName} request addrs[]", "put /tailnet/{tailnet}/services/{serviceName} request comment", + "put /tailnet/{tailnet}/services/{serviceName} request displayName", "put /tailnet/{tailnet}/services/{serviceName} request name", "put /tailnet/{tailnet}/services/{serviceName} request ports", "put /tailnet/{tailnet}/services/{serviceName} request ports[]", @@ -1160,6 +1185,7 @@ "put /tailnet/{tailnet}/services/{serviceName} response addrs", "put /tailnet/{tailnet}/services/{serviceName} response addrs[]", "put /tailnet/{tailnet}/services/{serviceName} response comment", + "put /tailnet/{tailnet}/services/{serviceName} response displayName", "put /tailnet/{tailnet}/services/{serviceName} response name", "put /tailnet/{tailnet}/services/{serviceName} response ports", "put /tailnet/{tailnet}/services/{serviceName} response ports[]", @@ -1467,6 +1493,14 @@ "[].sharerId", "[].tailnetId" ], + "get /organizations/{organization}/tailnets response": [ + "tailnets", + "tailnets[]", + "tailnets[].createdAt", + "tailnets[].displayName", + "tailnets[].id", + "tailnets[].orgId" + ], "get /posture/integrations/{id} response": [ "clientId", "clientSecret", @@ -1713,6 +1747,7 @@ "vipServices[].addrs", "vipServices[].addrs[]", "vipServices[].comment", + "vipServices[].displayName", "vipServices[].name", "vipServices[].ports", "vipServices[].ports[]", @@ -1723,6 +1758,7 @@ "addrs", "addrs[]", "comment", + "displayName", "name", "ports", "ports[]", @@ -1939,6 +1975,20 @@ "tags", "tags[]" ], + "post /organizations/{organization}/tailnets request": [ + "displayName" + ], + "post /organizations/{organization}/tailnets response": [ + "alreadyExists", + "createdAt", + "displayName", + "dnsName", + "id", + "oauthClient", + "oauthClient.id", + "oauthClient.secret", + "orgId" + ], "post /tailnet/{tailnet}/acl/preview response": [ "matches", "matches[]", @@ -2198,6 +2248,7 @@ "addrs", "addrs[]", "comment", + "displayName", "name", "ports", "ports[]", @@ -2208,6 +2259,7 @@ "addrs", "addrs[]", "comment", + "displayName", "name", "ports", "ports[]", diff --git a/coverage/coverage-gaps.md b/coverage/coverage-gaps.md index 5898909..ea58c61 100644 --- a/coverage/coverage-gaps.md +++ b/coverage/coverage-gaps.md @@ -1,17 +1,17 @@ # Coverage Gaps Report -- OpenAPI operations: `90` +- OpenAPI operations: `93` - Excluded operations: `0` -- In-scope operations: `90` +- In-scope operations: `93` - Manifest commands: `103` - Excluded commands: `16` -- Covered operations: `90` +- Covered operations: `93` - Uncovered operations: `0` - Covered commands: `87` - Unmapped commands: `0` - Unknown mapped commands: `0` - Covered properties: `229` -- Excluded properties: `637` +- Excluded properties: `657` - Uncovered properties: `0` ## Uncovered Operations By Domain @@ -349,6 +349,15 @@ - `[].sharerId` - `[].tailnetId` +### get /organizations/{organization}/tailnets response + +- `tailnets` +- `tailnets[]` +- `tailnets[].createdAt` +- `tailnets[].displayName` +- `tailnets[].id` +- `tailnets[].orgId` + ### get /posture/integrations/{id} response - `clientId` @@ -609,6 +618,7 @@ - `vipServices[].addrs` - `vipServices[].addrs[]` - `vipServices[].comment` +- `vipServices[].displayName` - `vipServices[].name` - `vipServices[].ports` - `vipServices[].ports[]` @@ -620,6 +630,7 @@ - `addrs` - `addrs[]` - `comment` +- `displayName` - `name` - `ports` - `ports[]` @@ -862,6 +873,22 @@ - `tags` - `tags[]` +### post /organizations/{organization}/tailnets request + +- `displayName` + +### post /organizations/{organization}/tailnets response + +- `alreadyExists` +- `createdAt` +- `displayName` +- `dnsName` +- `id` +- `oauthClient` +- `oauthClient.id` +- `oauthClient.secret` +- `orgId` + ### post /tailnet/{tailnet}/acl/preview response - `matches` @@ -1150,6 +1177,7 @@ - `addrs` - `addrs[]` - `comment` +- `displayName` - `name` - `ports` - `ports[]` @@ -1161,6 +1189,7 @@ - `addrs` - `addrs[]` - `comment` +- `displayName` - `name` - `ports` - `ports[]` diff --git a/internal/cli/root.go b/internal/cli/root.go index 1a134da..b62f400 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -73,6 +73,7 @@ func Configure() *cobra.Command { v.BindEnv("tailnet", "TAILSCALE_TAILNET") v.BindEnv("output", "TSCLI_OUTPUT") v.BindEnv("base-url", "TSCLI_BASE_URL") + v.BindEnv("user-agent", "TSCLI_USER_AGENT") v.BindPFlag("api-key", root.PersistentFlags().Lookup("api-key")) v.BindPFlag("tailnet", root.PersistentFlags().Lookup("tailnet")) v.BindPFlag("output", root.PersistentFlags().Lookup("output")) diff --git a/pkg/contract/openapi/command-operation-map.yaml b/pkg/contract/openapi/command-operation-map.yaml index 2a27997..1934c6b 100644 --- a/pkg/contract/openapi/command-operation-map.yaml +++ b/pkg/contract/openapi/command-operation-map.yaml @@ -9,6 +9,8 @@ commands: - post /tailnet/{tailnet}/oauth-apps create posture-integration: - post /tailnet/{tailnet}/posture/integrations + create tailnet: + - post /organizations/{organization}/tailnets create webhook: - post /tailnet/{tailnet}/webhooks @@ -32,6 +34,8 @@ commands: - delete /posture/integrations/{id} delete service: - delete /tailnet/{tailnet}/services/{serviceName} + delete tailnet: + - delete /tailnet/{tailnet} delete user: - post /users/{userId}/delete delete users: @@ -116,6 +120,8 @@ commands: - get /tailnet/{tailnet}/services list services devices: - get /tailnet/{tailnet}/services/{serviceName}/devices + list tailnets: + - get /organizations/{organization}/tailnets list users: - get /tailnet/{tailnet}/users list webhooks: diff --git a/pkg/contract/openapi/snapshot-metadata.yaml b/pkg/contract/openapi/snapshot-metadata.yaml index f2ebd6f..065c85b 100644 --- a/pkg/contract/openapi/snapshot-metadata.yaml +++ b/pkg/contract/openapi/snapshot-metadata.yaml @@ -1,8 +1,8 @@ source_url: https://api.tailscale.com/api/v2?outputOpenapiSchema=true -fetched_at_utc: "2026-06-30T15:57:17Z" +fetched_at_utc: "2026-08-18T20:38:28Z" openapi_version: 3.1.0 api_version: v2 -path_count: 58 -operation_count: 90 -sha256: 49e1c48f4da12cdc68a0ccd09cb901093f2032243f3cf2883589df020c11c851 +path_count: 60 +operation_count: 93 +sha256: 5afe08ee4e39fbc9c6775bdbc544d2ea469e18bfded76ee60ce925ca9b7b67b8 schema_file: tailscale-v2-openapi.yaml diff --git a/pkg/contract/openapi/tailscale-v2-openapi.yaml b/pkg/contract/openapi/tailscale-v2-openapi.yaml index b32e80d..333336e 100644 --- a/pkg/contract/openapi/tailscale-v2-openapi.yaml +++ b/pkg/contract/openapi/tailscale-v2-openapi.yaml @@ -4,7 +4,7 @@ info: description: |- ### Overview - **The API endpoints documented here are stable. However, the OpenAPI spec used to generate this documentation is unstable. It may change or break without notice.** + **The API endpoints documented here are stable unless otherwise noted. However, the OpenAPI spec used to generate this documentation is unstable. It may change or break without notice.** The Tailscale API is a (mostly) RESTful API. Typically, both POST bodies and responses are JSON-encoded. @@ -20,11 +20,11 @@ info: Access tokens can be supplied as the username portion of HTTP Basic authentication (leave the password blank) or as an OAuth Bearer token: - ``` - // passing token with basic auth + ```sh + # passing token with basic auth curl -u "tskey-api-xxxxx:" https://api.tailscale.com/api/v2/... - // passing token as bearer token + # passing token as bearer token curl -H "Authorization: Bearer tskey-api-xxxxx" https://api.tailscale.com/api/v2/... ``` @@ -38,7 +38,7 @@ info: The Tailscale API returns status codes consistent with standard HTTP conventions. In addition to the status code, errors may include additional information in the response body: - ``` + ```json { "message": "additional error information" } @@ -86,7 +86,7 @@ tags: x-displayName: User invites description: | Manage user invites. - Learn more about about [inviting users](/kb/1371/invite-users). + Learn more about [inviting users](/kb/1371/invite-users). - name: DeviceInvites x-displayName: Device invites description: | @@ -119,6 +119,9 @@ tags: description: | Manage OAuth apps for a tailnet. Learn more about [OAuth apps](/docs/features/oauth-apps). + - name: Organizations + description: | + Manage organization-level resources. paths: /tailnet/{tailnet}/devices: parameters: @@ -169,6 +172,40 @@ paths: '504': description: Request took too long to process, please try again later. $ref: '#/components/responses/504' + /tailnet/{tailnet}: + parameters: + - $ref: '#/components/parameters/tailnet' + delete: + summary: Delete a tailnet + x-badges: + - name: Alpha + position: before + description: | + Delete the specified tailnet and all of its users, devices, and configuration. + + [API-only tailnets](https://tailscale.com/docs/features/tailnet-creation-api) can be deleted using an access + token for the tailnet being deleted. You can get a tailnet-specific access token by exchanging either the OAuth + client credentials returned when the tailnet was created or an OAuth client from the creating tailnet that has + the `all` scope. + + OAuth Scope: `all`. + operationId: deleteTailnet + tags: + - Organizations + responses: + '200': + description: Successful operation. + '400': + description: Tailnet deletion could not be completed. + $ref: '#/components/responses/400' + '403': + description: Tailnet deletion is not permitted. + $ref: '#/components/responses/403' + '404': + description: Tailnet not found. + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' /tailnet/{tailnet}/device-attributes: parameters: - $ref: '#/components/parameters/tailnet' @@ -3902,6 +3939,82 @@ paths: $ref: '#/components/responses/404' '500': $ref: '#/components/responses/500' + /organizations/{organization}/tailnets: + parameters: + - $ref: '#/components/parameters/organization' + get: + summary: List tailnets + x-badges: + - name: Alpha + position: before + description: | + List all tailnets in the organization, including the original tailnet and any + [API-only tailnets](https://tailscale.com/docs/features/tailnet-creation-api) created with the tailnet creation + API. + + OAuth Scope: `tailnets:read`. + operationId: listOrganizationTailnets + tags: + - Organizations + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/ListOrganizationTailnetsResponse' + '403': + description: User does not have sufficient access to list tailnets for this organization. + $ref: '#/components/responses/403' + '404': + description: Organization not found. + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + post: + summary: Create a tailnet + x-badges: + - name: Alpha + position: before + description: | + Create an [API-only tailnet](https://tailscale.com/docs/features/tailnet-creation-api) in the organization. + API-only tailnets have no human users and do not appear in the admin console. Use them for applications and + infrastructure that manage tailnets entirely through the API. + + All plans can create a maximum of 10 tailnets, including their original tailnet. If your use case requires + more than 10 tailnets, [contact sales](https://tailscale.com/contact/sales). + + The response includes an OAuth client for the new tailnet. The client secret is returned only in this response + and cannot be retrieved later, so be sure to record it. + + OAuth Scope: `tailnets`. + operationId: createOrganizationTailnet + tags: + - Organizations + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrganizationTailnetRequest' + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrganizationTailnetResponse' + '400': + description: Display name is missing, invalid, or already in use. + $ref: '#/components/responses/400' + '403': + description: User does not have sufficient access to create a tailnet for this organization. + $ref: '#/components/responses/403' + '404': + description: Organization not found. + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' components: securitySchemes: bearerAuth: @@ -4191,6 +4304,28 @@ components: - WEBHOOK_ENDPOINT.UPDATE.SUBSCRIBED_EVENTS - WEB_INTERFACE.LOGIN - WEB_INTERFACE.LOGOUT + - PAM_CONNECTOR.CREATE + - PAM_CONNECTOR.CREATE.ACCESS_TOKEN + - PAM_CONNECTOR.DELETE + - PAM_CONNECTOR.DISABLE.ACCESS_TOKEN + - PAM_CONNECTOR.UPDATE + - PAM_SERVICE.CREATE + - PAM_SERVICE.DELETE + - PAM_SERVICE.UPDATE + - PAM_SERVICE_ACCOUNT.CREATE + - PAM_SERVICE_ACCOUNT.CREATE.ACCESS_TOKEN + - PAM_SERVICE_ACCOUNT.DELETE + - PAM_SERVICE_ACCOUNT.UPDATE + - PAM_SETTINGS.CREATE.CUSTOM_DOMAIN + - PAM_SETTINGS.CREATE.NOTIFICATION + - PAM_SETTINGS.CREATE.RECORDING_STORAGE + - PAM_SETTINGS.DELETE.CUSTOM_DOMAIN + - PAM_SETTINGS.DELETE.NOTIFICATION + - PAM_SETTINGS.DELETE.RECORDING_STORAGE + - PAM_SETTINGS.UPDATE + - PAM_SETTINGS.UPDATE.CUSTOM_DOMAIN + - PAM_SETTINGS.UPDATE.NOTIFICATION + - PAM_SETTINGS.UPDATE.SETUP_WIZARD example: - USER.CREATE - NODE.CREATE @@ -4281,6 +4416,17 @@ components: schema: type: string example: a123456CNTRL + organization: + in: path + name: organization + description: | + The organization identifier. + + Use a dash (`-`) to reference the organization of the access token being used to make the API call. + required: true + schema: + type: string + example: '-' schemas: Device: type: object @@ -4899,6 +5045,7 @@ components: - STRIPE - SECURITY_NOTIFICATION - LEGAL_NOTIFICATION + - BORDER0_API description: The initiator of the action that generated the event, typically an API or user interface, like the Tailscale admin panel. example: ADMIN_CONSOLE actor: @@ -4920,6 +5067,8 @@ components: - MULLVAD - LOGSTREAM - SECRET_SCANNER + - PAM_CONNECTOR + - PAM_SERVICE_ACCOUNT description: The entity type of the actor. example: USER loginName: @@ -6129,6 +6278,14 @@ components: type: string description: The unique name of the Service. example: svc:example + displayName: + type: string + description: | + An optional human-readable label for the Service, shown in the Tailscale admin console + and to clients with access to the Service. + Must be 64 characters or fewer. + maxLength: 64 + example: Example Service addrs: type: array description: | @@ -6331,6 +6488,88 @@ components: - custom:myattribute description: | The list of custom node attributes that the OAuth app is allowed to set. + OrganizationTailnet: + type: object + properties: + id: + type: string + description: A stable, globally unique identifier for the tailnet. + example: T123456CNTRL + displayName: + type: string + description: The tailnet's display name. + example: Production staging + orgId: + type: string + description: A stable, globally unique identifier for the organization. + example: o123456CNTRL + createdAt: + type: string + format: date-time + description: RFC3339 timestamp of when the tailnet was created. + example: '2025-01-01T12:00:00Z' + ListOrganizationTailnetsResponse: + type: object + properties: + tailnets: + type: array + description: Tailnets in the organization. + items: + $ref: '#/components/schemas/OrganizationTailnet' + CreateOrganizationTailnetRequest: + type: object + required: + - displayName + properties: + displayName: + type: string + description: | + A custom name for the tailnet. It can contain letters, numbers, spaces, apostrophes, and hyphens, and must + be unique within the organization. + example: Production staging + TailnetOAuthClient: + type: object + description: | + OAuth client credentials with the `all` scope for the newly created tailnet. The secret is returned only once. + properties: + id: + type: string + description: A stable, globally unique identifier for the OAuth client. + example: k123456CNTRL + secret: + type: string + description: The OAuth client secret. + example: tskey-client-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + CreateOrganizationTailnetResponse: + type: object + properties: + id: + type: string + description: A stable, globally unique identifier for the tailnet. + example: T123456CNTRL + displayName: + type: string + description: The tailnet name provided in the request. + example: Production staging + orgId: + type: string + description: A stable, globally unique identifier for the organization. + example: o123456CNTRL + dnsName: + type: string + description: The DNS suffix used to construct fully qualified domain names for devices in this tailnet. + example: tail1234.ts.net + createdAt: + type: string + format: date-time + description: RFC3339 timestamp of when the tailnet was created. + example: '2025-01-01T12:00:00Z' + oauthClient: + $ref: '#/components/schemas/TailnetOAuthClient' + alreadyExists: + type: boolean + description: True if the requested tailnet already exists. + example: true responses: '400': description: Bad request. diff --git a/pkg/oauth/exchange.go b/pkg/oauth/exchange.go index ddd65cd..417d24b 100644 --- a/pkg/oauth/exchange.go +++ b/pkg/oauth/exchange.go @@ -3,12 +3,16 @@ package oauth import ( "context" "fmt" + "net" + "net/url" "os" "time" "golang.org/x/oauth2/clientcredentials" ) +const publicTokenURL = "https://api.tailscale.com/api/v2/oauth/token" + // TokenResponse represents the response from the OAuth token exchange type TokenResponse struct { AccessToken string `json:"access_token"` @@ -17,17 +21,32 @@ type TokenResponse struct { ExpiresAt time.Time `json:"expires_at"` } -// ExchangeClientCredentials exchanges OAuth client credentials for an access token -func ExchangeClientCredentials(ctx context.Context, clientID, clientSecret string) (*TokenResponse, error) { - tokenURL := os.Getenv("TSCLI_OAUTH_TOKEN_URL") - if tokenURL == "" { - tokenURL = "https://api.tailscale.com/api/v2/oauth/token" +// ExchangeClientCredentials exchanges OAuth client credentials for an +// access token at defaultTokenURL — pass "" to use Tailscale's public +// token endpoint (https://api.tailscale.com) — unless overridden by the +// TSCLI_OAUTH_TOKEN_URL environment variable. Because the client secret is +// POSTed to whatever URL is used, both defaultTokenURL and any +// TSCLI_OAUTH_TOKEN_URL override are validated by ValidateTokenURL: each +// must be an absolute https:// URL, or an absolute http:// URL restricted +// to a loopback host (for local testing). +func ExchangeClientCredentials(ctx context.Context, clientID, clientSecret, defaultTokenURL string) (*TokenResponse, error) { + if defaultTokenURL == "" { + defaultTokenURL = publicTokenURL + } + if _, err := ValidateTokenURL(defaultTokenURL); err != nil { + return nil, fmt.Errorf("defaultTokenURL: %w", err) + } + tokenURL, err := ResolveTokenURL(defaultTokenURL) + if err != nil { + return nil, err } return ExchangeClientCredentialsAtURL(ctx, clientID, clientSecret, tokenURL) } +// ExchangeClientCredentialsAtURL exchanges OAuth client credentials at a +// caller-selected token endpoint. The caller is responsible for trusting the +// endpoint because the client credentials are sent to it. func ExchangeClientCredentialsAtURL(ctx context.Context, clientID, clientSecret, tokenURL string) (*TokenResponse, error) { - config := &clientcredentials.Config{ ClientID: clientID, ClientSecret: clientSecret, @@ -58,3 +77,56 @@ func ExchangeClientCredentialsAtURL(ctx context.Context, clientID, clientSecret, ExpiresAt: expiresAt, }, nil } + +// ValidateTokenURL parses raw and requires it to be an absolute https:// +// URL, or an absolute http:// URL restricted to a loopback host +// (127.0.0.0/8, ::1, or "localhost"). It is used to validate any +// caller- or environment-configured OAuth token/base URL, since OAuth +// client credentials (and, via pkg/tscli's base-url, API keys) are sent to +// that URL — an unrestricted http:// endpoint would send them in +// cleartext to whatever host is configured. +func ValidateTokenURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || !u.IsAbs() || u.Host == "" || u.Opaque != "" { + return nil, fmt.Errorf("must be an absolute URL with scheme and host: %q", raw) + } + switch u.Scheme { + case "https": + return u, nil + case "http": + if isLoopbackHost(u.Hostname()) { + return u, nil + } + return nil, fmt.Errorf("http:// is only allowed for loopback hosts, got %q", u.Host) + default: + return nil, fmt.Errorf("scheme %q is not supported, use https://", u.Scheme) + } +} + +// ResolveTokenURL returns the TSCLI_OAUTH_TOKEN_URL environment variable, +// validated via ValidateTokenURL, if it is set and non-empty; otherwise it +// returns fallback unchanged. Centralizing this here means the override +// behavior (and its validation) is defined in exactly one place, reused by +// both ExchangeClientCredentials and pkg/tscli's OAuth token-URL +// derivation. +func ResolveTokenURL(fallback string) (string, error) { + override := os.Getenv("TSCLI_OAUTH_TOKEN_URL") + if override == "" { + return fallback, nil + } + u, err := ValidateTokenURL(override) + if err != nil { + return "", fmt.Errorf("TSCLI_OAUTH_TOKEN_URL: %w", err) + } + return u.String(), nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} diff --git a/pkg/oauth/exchange_test.go b/pkg/oauth/exchange_test.go new file mode 100644 index 0000000..ff8b2c4 --- /dev/null +++ b/pkg/oauth/exchange_test.go @@ -0,0 +1,128 @@ +package oauth + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +func TestExchangeClientCredentialsDefaultsToPublicTokenEndpoint(t *testing.T) { + var requestedURL string + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requestedURL = req.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"access_token":"token","token_type":"Bearer","expires_in":3600}`)), + Request: req, + }, nil + })} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + if _, err := ExchangeClientCredentials(ctx, "client-id", "client-secret", ""); err != nil { + t.Fatalf("exchange client credentials: %v", err) + } + if requestedURL != publicTokenURL { + t.Fatalf("expected token request to %q, got %q", publicTokenURL, requestedURL) + } +} + +func TestExchangeClientCredentialsHonorsValidatedTokenURLOverride(t *testing.T) { + t.Setenv("TSCLI_OAUTH_TOKEN_URL", "http://127.0.0.1:1/oauth/token") + + var requestedURL string + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requestedURL = req.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"access_token":"token","token_type":"Bearer","expires_in":3600}`)), + Request: req, + }, nil + })} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + if _, err := ExchangeClientCredentials(ctx, "client-id", "client-secret", ""); err != nil { + t.Fatalf("exchange client credentials: %v", err) + } + if requestedURL != "http://127.0.0.1:1/oauth/token" { + t.Fatalf("expected token request to overridden loopback URL, got %q", requestedURL) + } +} + +func TestExchangeClientCredentialsRejectsUnsafeTokenURLOverride(t *testing.T) { + t.Setenv("TSCLI_OAUTH_TOKEN_URL", "http://attacker.invalid/collect") + + var requested bool + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requested = true + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{}")), Request: req}, nil + })} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + if _, err := ExchangeClientCredentials(ctx, "client-id", "client-secret", ""); err == nil { + t.Fatal("expected an error for an unvalidated http:// TSCLI_OAUTH_TOKEN_URL") + } + if requested { + t.Fatal("expected no request to be sent to the rejected token URL") + } +} + +func TestValidateTokenURL(t *testing.T) { + tests := []struct { + name string + raw string + wantErr bool + }{ + {name: "https", raw: "https://api.tailscale.com/api/v2/oauth/token"}, + {name: "http loopback IPv4", raw: "http://127.0.0.1:8080/oauth/token"}, + {name: "http loopback IPv6", raw: "http://[::1]:8080/oauth/token"}, + {name: "http localhost", raw: "http://localhost:8080/oauth/token"}, + {name: "http non-loopback rejected", raw: "http://attacker.invalid/collect", wantErr: true}, + {name: "unsupported scheme rejected", raw: "ftp://api.tailscale.com/token", wantErr: true}, + {name: "relative URL rejected", raw: "/oauth/token", wantErr: true}, + {name: "malformed URL rejected", raw: "://bad-url", wantErr: true}, + {name: "empty host rejected", raw: "https:///oauth/token", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ValidateTokenURL(tt.raw) + if tt.wantErr && err == nil { + t.Fatalf("expected an error for %q, got none", tt.raw) + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error for %q, got %v", tt.raw, err) + } + }) + } +} + +func TestResolveTokenURL(t *testing.T) { + t.Run("no override returns fallback", func(t *testing.T) { + if got, err := ResolveTokenURL("https://fallback.example/token"); err != nil || got != "https://fallback.example/token" { + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, "https://fallback.example/token") + } + }) + t.Run("valid override replaces fallback", func(t *testing.T) { + t.Setenv("TSCLI_OAUTH_TOKEN_URL", "https://override.example/token") + if got, err := ResolveTokenURL("https://fallback.example/token"); err != nil || got != "https://override.example/token" { + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, "https://override.example/token") + } + }) + t.Run("unsafe override is rejected", func(t *testing.T) { + t.Setenv("TSCLI_OAUTH_TOKEN_URL", "http://attacker.invalid/token") + if _, err := ResolveTokenURL("https://fallback.example/token"); err == nil { + t.Fatal("expected an error for an unsafe override") + } + }) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/pkg/tscli/client.go b/pkg/tscli/client.go index fa93805..7671369 100644 --- a/pkg/tscli/client.go +++ b/pkg/tscli/client.go @@ -31,11 +31,23 @@ const ( defaultContentType = "application/json" ) -// getUserAgent returns the properly formatted user agent string +// getUserAgent returns the default user agent string, derived from the +// build version (or, if unset, this process's local git repository state). +// Callers that want a fixed value — e.g. a library consumer who should not +// leak their own repo's git metadata — should set the "user-agent" viper +// key (flag/env/config) instead of relying on this default. func getUserAgent() string { return fmt.Sprintf("tscli/%s (Go client)", version.GetVersion()) } +func configuredUserAgent() string { + userAgent := viper.GetString("user-agent") + if userAgent == "" { + userAgent = getUserAgent() + } + return userAgent +} + func New() (*tsapi.Client, error) { tailnet := viper.GetString("tailnet") apiKey := viper.GetString("api-key") @@ -49,7 +61,7 @@ func New() (*tsapi.Client, error) { return nil, fmt.Errorf("either api-key or both oauth-client-id and oauth-client-secret are required") } - userAgent := getUserAgent() + userAgent := configuredUserAgent() // Create a custom transport that ensures UserAgent is always set transport := &userAgentTransport{ @@ -64,6 +76,14 @@ func New() (*tsapi.Client, error) { httpClient := &http.Client{ Transport: transport, + // Tailscale's API never legitimately redirects. Refusing to follow + // redirects prevents oauthBearerTransport's Authorization header + // (re-attached on every RoundTrip call, including the request the + // stdlib http.Client builds for a followed redirect) from ever being + // sent to a host other than the one this client was configured for. + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, } client := &tsapi.Client{ @@ -71,17 +91,20 @@ func New() (*tsapi.Client, error) { UserAgent: userAgent, HTTP: httpClient, } + resolvedBaseURL, err := resolveConfiguredBaseURL(baseURL) + if err != nil { + return nil, err + } + if baseURL != "" { + client.BaseURL = resolvedBaseURL + } if apiKey != "" { client.APIKey = apiKey } else { - tokenURL := os.Getenv("TSCLI_OAUTH_TOKEN_URL") - if tokenURL == "" { - resolvedBaseURL := baseURL - if resolvedBaseURL == "" { - resolvedBaseURL = defaultBaseURL - } - tokenURL = strings.TrimRight(resolvedBaseURL, "/") + "/api/v2/oauth/token" + tokenURL, err := oauthTokenURL(resolvedBaseURL) + if err != nil { + return nil, err } httpClient.Transport = &oauthBearerTransport{ rt: httpClient.Transport, @@ -91,15 +114,31 @@ func New() (*tsapi.Client, error) { } } - if baseURL != "" { - parsed, err := parseBaseURL(baseURL) - if err != nil { - return nil, err - } - client.BaseURL = parsed + return client, nil +} + +// ExchangeOAuthClientCredentials exchanges OAuth client credentials via +// oauth.ExchangeClientCredentials, passing the token endpoint derived from +// the same tailnet/base-url configuration New() reads from viper (flag, +// environment, or config file) as the default. TSCLI_OAUTH_TOKEN_URL still +// overrides that derived default; see oauth.ExchangeClientCredentials for +// the validation applied to both. +func ExchangeOAuthClientCredentials(ctx context.Context, clientID, clientSecret string) (*oauth.TokenResponse, error) { + baseURL, err := resolveBaseURL(nil) + if err != nil { + return nil, err } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return oauth.ExchangeClientCredentials(ctx, clientID, clientSecret, joinAPIV2Path(baseURL, "/oauth/token").String()) +} - return client, nil +// oauthTokenURL returns the OAuth client-credentials token endpoint: the +// TSCLI_OAUTH_TOKEN_URL environment variable if it is set and passes +// oauth.ValidateTokenURL, otherwise baseURL's "/api/v2/oauth/token" path +// (preserving any path prefix baseURL has, e.g. behind a reverse proxy). +func oauthTokenURL(baseURL *url.URL) (string, error) { + return oauth.ResolveTokenURL(joinAPIV2Path(baseURL, "/oauth/token").String()) } type oauthBearerTransport struct { @@ -190,10 +229,8 @@ func Do( u.Path = strings.ReplaceAll(u.Path, "{tailnet}", url.PathEscape(c.Tailnet)) - full := base.ResolveReference(&url.URL{ - Path: "/api/v2" + u.Path, - RawQuery: u.RawQuery, - }) + full := joinAPIV2Path(base, u.Path) + full.RawQuery = u.RawQuery var rdr io.Reader if body != nil { @@ -244,10 +281,8 @@ func DoBearer( return nil, fmt.Errorf("invalid path: %w", err) } - full := base.ResolveReference(&url.URL{ - Path: "/api/v2" + u.Path, - RawQuery: u.RawQuery, - }) + full := joinAPIV2Path(base, u.Path) + full.RawQuery = u.RawQuery var rdr io.Reader if body != nil { @@ -269,7 +304,8 @@ func DoBearer( if err != nil { return nil, err } - req.Header.Set("User-Agent", getUserAgent()) + userAgent := configuredUserAgent() + req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", defaultContentType) if body != nil { req.Header.Set("Content-Type", defaultContentType) @@ -278,7 +314,7 @@ func DoBearer( transport := &userAgentTransport{ rt: http.DefaultTransport, - userAgent: getUserAgent(), + userAgent: userAgent, } return doRequest(&http.Client{Transport: transport}, req, method, path, out) @@ -292,20 +328,25 @@ func resolveBaseURL(current *url.URL) (*url.URL, error) { return current, nil } - baseURL := viper.GetString("base-url") - if baseURL != "" { - parsed, err := parseBaseURL(baseURL) - if err != nil { - return nil, err - } - return parsed, nil + return resolveConfiguredBaseURL(viper.GetString("base-url")) +} + +func resolveConfiguredBaseURL(baseURL string) (*url.URL, error) { + if baseURL == "" { + baseURL = defaultBaseURL } + return parseBaseURL(baseURL) +} - b, err := parseBaseURL(defaultBaseURL) - if err != nil { - return nil, err +// joinAPIV2Path appends endpointPath below the API v2 root. A configured +// base URL may either identify a proxy prefix or the API root itself, as in +// the server URL published by the OpenAPI document. +func joinAPIV2Path(baseURL *url.URL, endpointPath string) *url.URL { + apiBaseURL := baseURL + if !strings.HasSuffix(strings.TrimRight(baseURL.Path, "/"), "/api/v2") { + apiBaseURL = baseURL.JoinPath("api/v2") } - return b, nil + return apiBaseURL.JoinPath(endpointPath) } func parseBaseURL(raw string) (*url.URL, error) { @@ -320,8 +361,8 @@ func parseBaseURL(raw string) (*url.URL, error) { } func validateBaseURL(u *url.URL) error { - if u == nil || !u.IsAbs() || u.Scheme == "" || u.Host == "" || u.Opaque != "" { - return fmt.Errorf("invalid base-url: must be an absolute URL with scheme and host") + if _, err := oauth.ValidateTokenURL(u.String()); err != nil { + return fmt.Errorf("invalid base-url: %w", err) } return nil } diff --git a/pkg/tscli/client_test.go b/pkg/tscli/client_test.go index 4014951..cd6e71d 100644 --- a/pkg/tscli/client_test.go +++ b/pkg/tscli/client_test.go @@ -3,13 +3,136 @@ package tscli import ( "context" "fmt" + "io" "net/http" "net/http/httptest" + "net/url" + "strings" "sync" + "sync/atomic" "testing" "time" + + "github.com/spf13/viper" + "golang.org/x/oauth2" + tsapi "tailscale.com/client/tailscale/v2" ) +func TestNewOAuthClientRejectsUnsafeBaseURL(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("oauth-client-id", "client-id") + viper.Set("oauth-client-secret", "client-secret") + viper.Set("base-url", "http://attacker.invalid") + + if _, err := New(); err == nil { + t.Fatal("expected New() to reject a non-loopback http:// base-url") + } +} + +func TestNewAPIKeyClientRejectsUnsafeBaseURL(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("api-key", "tskey-test") + viper.Set("base-url", "http://attacker.invalid") + + if _, err := New(); err == nil { + t.Fatal("expected New() to reject a non-loopback http:// base-url on the api-key auth path") + } +} + +func TestNewOAuthClientRejectsUnsafeOAuthTokenURLOverride(t *testing.T) { + t.Setenv("TSCLI_OAUTH_TOKEN_URL", "http://attacker.invalid/collect") + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("oauth-client-id", "client-id") + viper.Set("oauth-client-secret", "client-secret") + viper.Set("base-url", "https://api.tailscale.com") + + if _, err := New(); err == nil { + t.Fatal("expected New() to reject a non-loopback http:// TSCLI_OAUTH_TOKEN_URL") + } +} + +func TestNewOAuthClientHonorsValidatedOAuthTokenURLOverride(t *testing.T) { + var tokenRequests atomic.Int32 + override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + tokenRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"overridden","token_type":"Bearer","expires_in":3600}`)) + })) + defer override.Close() + + configured := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer overridden" { + t.Errorf("expected overridden bearer token, got %q", got) + } + w.WriteHeader(http.StatusNoContent) + })) + defer configured.Close() + + t.Setenv("TSCLI_OAUTH_TOKEN_URL", override.URL+"/oauth/token") + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("oauth-client-id", "client-id") + viper.Set("oauth-client-secret", "client-secret") + viper.Set("base-url", configured.URL) + + client, err := New() + if err != nil { + t.Fatalf("new client: %v", err) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, configured.URL+"/resource", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.HTTP.Do(req) + if err != nil { + t.Fatalf("request with oauth client: %v", err) + } + _ = resp.Body.Close() + + if got := tokenRequests.Load(); got != 1 { + t.Fatalf("expected one token request to the overridden endpoint, got %d", got) + } +} + +func TestOauthTokenURLPreservesBaseURLPathPrefix(t *testing.T) { + tests := []struct { + name string + baseURL string + want string + }{ + {name: "bare host", baseURL: "https://api.tailscale.com", want: "https://api.tailscale.com/api/v2/oauth/token"}, + {name: "trailing slash", baseURL: "https://api.tailscale.com/", want: "https://api.tailscale.com/api/v2/oauth/token"}, + {name: "path prefix", baseURL: "https://proxy.example.com/tailscale", want: "https://proxy.example.com/tailscale/api/v2/oauth/token"}, + {name: "path prefix trailing slash", baseURL: "https://proxy.example.com/tailscale/", want: "https://proxy.example.com/tailscale/api/v2/oauth/token"}, + {name: "versioned API root", baseURL: "https://proxy.example.com/api/v2", want: "https://proxy.example.com/api/v2/oauth/token"}, + {name: "versioned API root trailing slash", baseURL: "https://proxy.example.com/api/v2/", want: "https://proxy.example.com/api/v2/oauth/token"}, + {name: "prefixed versioned API root", baseURL: "https://proxy.example.com/tailscale/api/v2", want: "https://proxy.example.com/tailscale/api/v2/oauth/token"}, + {name: "host with port", baseURL: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080/api/v2/oauth/token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse(tt.baseURL) + if err != nil { + t.Fatalf("parse base url: %v", err) + } + got, err := oauthTokenURL(u) + if err != nil { + t.Fatalf("oauthTokenURL: %v", err) + } + if got != tt.want { + t.Fatalf("oauthTokenURL(%q) = %q, want %q", tt.baseURL, got, tt.want) + } + }) + } +} + func TestOAuthBearerTransportRefreshesExpiredTokenAndRetries401(t *testing.T) { var ( mu sync.Mutex @@ -79,3 +202,286 @@ func TestOAuthBearerTransportRefreshesExpiredTokenAndRetries401(t *testing.T) { t.Fatalf("expected two resource requests, got %d", resourceHits) } } + +func TestExchangeOAuthClientCredentialsRejectsUnsafeBaseURL(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("base-url", "http://attacker.invalid") + + var requested bool + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requested = true + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{}")), Request: req}, nil + })} + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient) + + _, err := ExchangeOAuthClientCredentials(ctx, "client-id", "client-secret") + if err == nil { + t.Fatal("expected ExchangeOAuthClientCredentials to reject a non-loopback http:// base-url") + } + if !strings.Contains(err.Error(), "invalid base-url") { + t.Fatalf("expected an invalid base-url validation error, got %v", err) + } + if requested { + t.Fatal("expected no request to be sent to the rejected base-url") + } +} + +func TestExchangeOAuthClientCredentialsSucceedsAgainstConfiguredBaseURL(t *testing.T) { + var tokenRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/oauth/token" { + http.NotFound(w, r) + return + } + tokenRequests.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"tok","token_type":"Bearer","expires_in":3600}`)) + })) + defer server.Close() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("base-url", server.URL) + + resp, err := ExchangeOAuthClientCredentials(context.Background(), "client-id", "client-secret") + if err != nil { + t.Fatalf("exchange oauth client credentials: %v", err) + } + if resp.AccessToken != "tok" { + t.Fatalf("expected access token %q, got %q", "tok", resp.AccessToken) + } + if got := tokenRequests.Load(); got != 1 { + t.Fatalf("expected one token request, got %d", got) + } +} + +func TestExchangeOAuthClientCredentialsAcceptsVersionedBaseURL(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"tok","token_type":"Bearer","expires_in":3600}`)) + })) + defer server.Close() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("base-url", server.URL+"/api/v2") + + if _, err := ExchangeOAuthClientCredentials(context.Background(), "client-id", "client-secret"); err != nil { + t.Fatalf("exchange oauth client credentials: %v", err) + } + if want := "/api/v2/oauth/token"; gotPath != want { + t.Fatalf("expected token request path %q, got %q", want, gotPath) + } +} + +func TestNewOAuthClientDoesNotFollowRedirectsAcrossHosts(t *testing.T) { + var evilRequests atomic.Int32 + evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + evilRequests.Add(1) + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("expected no Authorization header sent to redirect target, got %q", got) + } + w.WriteHeader(http.StatusOK) + })) + defer evil.Close() + + configured := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/oauth/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"secret-token","token_type":"Bearer","expires_in":3600}`)) + case "/resource": + http.Redirect(w, r, evil.URL+"/resource", http.StatusFound) + default: + http.NotFound(w, r) + } + })) + defer configured.Close() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("oauth-client-id", "client-id") + viper.Set("oauth-client-secret", "client-secret") + viper.Set("base-url", configured.URL) + + client, err := New() + if err != nil { + t.Fatalf("new client: %v", err) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, configured.URL+"/resource", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.HTTP.Do(req) + if err != nil { + t.Fatalf("request with oauth client: %v", err) + } + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("expected the client to NOT follow the redirect (status 302 returned as-is), got %d", resp.StatusCode) + } + if got := evilRequests.Load(); got != 0 { + t.Fatalf("expected no request to the redirect target, got %d", got) + } +} + +func TestNewUsesConfiguredUserAgentOverride(t *testing.T) { + var gotUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("api-key", "tskey-test") + viper.Set("base-url", server.URL) + viper.Set("user-agent", "my-app/1.0") + + client, err := New() + if err != nil { + t.Fatalf("new client: %v", err) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/resource", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.HTTP.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + _ = resp.Body.Close() + + if gotUserAgent != "my-app/1.0" { + t.Fatalf("expected overridden User-Agent %q, got %q", "my-app/1.0", gotUserAgent) + } +} + +func TestNewDefaultsUserAgentWhenNotConfigured(t *testing.T) { + var gotUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("tailnet", "example.com") + viper.Set("api-key", "tskey-test") + viper.Set("base-url", server.URL) + + client, err := New() + if err != nil { + t.Fatalf("new client: %v", err) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/resource", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := client.HTTP.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + _ = resp.Body.Close() + + if gotUserAgent == "" || gotUserAgent == "my-app/1.0" { + t.Fatalf("expected a non-empty default User-Agent, got %q", gotUserAgent) + } +} + +func TestDoPreservesBaseURLPathPrefix(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + base, err := url.Parse(server.URL + "/proxy-prefix") + if err != nil { + t.Fatalf("parse base url: %v", err) + } + client := &tsapi.Client{ + Tailnet: "example.com", + UserAgent: "test", + BaseURL: base, + HTTP: server.Client(), + APIKey: "tskey-test", + } + + if _, err := Do(context.Background(), client, http.MethodGet, "/tailnet/{tailnet}/devices", nil, nil); err != nil { + t.Fatalf("Do: %v", err) + } + + want := "/proxy-prefix/api/v2/tailnet/example.com/devices" + if gotPath != want { + t.Fatalf("expected request path %q (preserving base-url prefix), got %q", want, gotPath) + } +} + +func TestDoAcceptsVersionedBaseURL(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + base, err := url.Parse(server.URL + "/api/v2") + if err != nil { + t.Fatalf("parse base url: %v", err) + } + client := &tsapi.Client{ + Tailnet: "example.com", + UserAgent: "test", + BaseURL: base, + HTTP: server.Client(), + APIKey: "tskey-test", + } + + if _, err := Do(context.Background(), client, http.MethodGet, "/tailnet/{tailnet}/devices", nil, nil); err != nil { + t.Fatalf("Do: %v", err) + } + if want := "/api/v2/tailnet/example.com/devices"; gotPath != want { + t.Fatalf("expected request path %q, got %q", want, gotPath) + } +} + +func TestDoBearerHonorsUserAgentEnvironmentOverride(t *testing.T) { + var gotUserAgent string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv("TSCLI_USER_AGENT", "my-bearer-app/1.0") + viper.Reset() + t.Cleanup(viper.Reset) + if err := viper.BindEnv("user-agent", "TSCLI_USER_AGENT"); err != nil { + t.Fatalf("bind user-agent environment variable: %v", err) + } + viper.Set("base-url", server.URL) + + if _, err := DoBearer(context.Background(), http.MethodGet, "/organizations/-/tailnets", "tok-123", nil, nil); err != nil { + t.Fatalf("DoBearer: %v", err) + } + if want := "my-bearer-app/1.0"; gotUserAgent != want { + t.Fatalf("expected overridden User-Agent %q, got %q", want, gotUserAgent) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/test/cli/example_output_test.go b/test/cli/example_output_test.go index 9406ff6..c14e079 100644 --- a/test/cli/example_output_test.go +++ b/test/cli/example_output_test.go @@ -203,9 +203,8 @@ func TestTailnetListRenderedOutput(t *testing.T) { t.Run(tc.name, func(t *testing.T) { mock := apimock.New(t) env := map[string]string{ - "TSCLI_OUTPUT": tc.mode, - "TSCLI_BASE_URL": mock.URL(), - "TSCLI_OAUTH_TOKEN_URL": mock.URL() + "/api/v2/oauth/token", + "TSCLI_OUTPUT": tc.mode, + "TSCLI_BASE_URL": mock.URL(), } mock.AddRaw(http.MethodPost, "/api/v2/oauth/token", http.StatusOK, `{"access_token":"tok-123","token_type":"Bearer","expires_in":3600}`) @@ -523,7 +522,7 @@ func oauthObjectCase(command string, args []string, keys ...string) exampleOutpu TopLevel: jsonTopLevelObject, ObjectKeys: keys, }, true, func(t *testing.T, mock *apimock.Server, env map[string]string) { - env["TSCLI_OAUTH_TOKEN_URL"] = mock.URL() + "/api/v2/oauth/token" + env["TSCLI_BASE_URL"] = mock.URL() mock.AddRaw(http.MethodPost, "/api/v2/oauth/token", http.StatusOK, `{"access_token":"tok-123","token_type":"Bearer","expires_in":3600}`) }) } @@ -534,7 +533,6 @@ func lifecycleObjectCase(command string, args []string, body any, keys ...string ObjectKeys: keys, }, true, func(t *testing.T, mock *apimock.Server, env map[string]string) { env["TSCLI_BASE_URL"] = mock.URL() - env["TSCLI_OAUTH_TOKEN_URL"] = mock.URL() + "/api/v2/oauth/token" mock.AddRaw(http.MethodPost, "/api/v2/oauth/token", http.StatusOK, `{"access_token":"tok-123","token_type":"Bearer","expires_in":3600}`) mock.AddJSON(http.MethodGet, "/api/v2/organizations/-/tailnets", http.StatusOK, body) mock.AddJSON(http.MethodPost, "/api/v2/organizations/-/tailnets", http.StatusOK, body) @@ -547,7 +545,6 @@ func summaryLifecycleCase(command string, args []string, keys ...string) example ObjectKeys: keys, }, true, func(t *testing.T, mock *apimock.Server, env map[string]string) { env["TSCLI_BASE_URL"] = mock.URL() - env["TSCLI_OAUTH_TOKEN_URL"] = mock.URL() + "/api/v2/oauth/token" mock.AddRaw(http.MethodPost, "/api/v2/oauth/token", http.StatusOK, `{"access_token":"tok-123","token_type":"Bearer","expires_in":3600}`) mock.AddRaw(http.MethodDelete, "/api/v2/tailnet/T123", http.StatusOK, `{}`) }) diff --git a/test/cli/tailnet_lifecycle_integration_test.go b/test/cli/tailnet_lifecycle_integration_test.go index 2abc587..3d56dd9 100644 --- a/test/cli/tailnet_lifecycle_integration_test.go +++ b/test/cli/tailnet_lifecycle_integration_test.go @@ -19,10 +19,7 @@ func TestTailnetLifecycleCommands(t *testing.T) { mock.AddRaw(http.MethodGet, "/api/v2/organizations/-/tailnets", http.StatusOK, `{"tailnets":[{"id":"T123","displayName":"Sandbox","orgId":"o123","createdAt":"2025-01-01T12:00:00Z"}]}`) mock.AddRaw(http.MethodDelete, "/api/v2/tailnet/T123", http.StatusOK, `{}`) - env := map[string]string{ - "TSCLI_BASE_URL": mock.URL(), - "TSCLI_OAUTH_TOKEN_URL": mock.URL() + "/api/v2/oauth/token", - } + env := map[string]string{"TSCLI_BASE_URL": mock.URL()} res := executeCLINoDefaults(t, []string{"create", "tailnet", "--display-name", "Sandbox", "--oauth-client-id", "cid", "--oauth-client-secret", "secret"}, env) if res.err != nil { @@ -98,9 +95,8 @@ func TestTailnetLifecycleCommandsUseActiveOAuthProfile(t *testing.T) { mock.AddRaw(http.MethodGet, "/api/v2/organizations/-/tailnets", http.StatusOK, `{"tailnets":[]}`) res := executeCLINoDefaults(t, []string{"list", "tailnets"}, map[string]string{ - "HOME": home, - "TSCLI_BASE_URL": mock.URL(), - "TSCLI_OAUTH_TOKEN_URL": mock.URL() + "/api/v2/oauth/token", + "HOME": home, + "TSCLI_BASE_URL": mock.URL(), }) if res.err != nil { t.Fatalf("list tailnets with oauth profile: %v\nstderr:\n%s", res.err, res.stderr) @@ -135,7 +131,7 @@ func TestTailnetLifecycleCommandErrorsAreActionable(t *testing.T) { t.Run("lifecycle commands bypass api-key pre-run", func(t *testing.T) { res := executeCLINoDefaults(t, []string{"list", "tailnets", "--oauth-client-id", "cid", "--oauth-client-secret", "secret"}, map[string]string{ - "TSCLI_OAUTH_TOKEN_URL": "http://127.0.0.1:1/api/v2/oauth/token", + "TSCLI_BASE_URL": "http://127.0.0.1:1", }) if res.err == nil { t.Fatalf("expected oauth exchange to fail") @@ -151,8 +147,7 @@ func TestTailnetLifecycleCommandErrorsAreActionable(t *testing.T) { mock.AddRaw(http.MethodGet, "/api/v2/organizations/-/tailnets", http.StatusForbidden, `{"message":"forbidden"}`) res := executeCLINoDefaults(t, []string{"list", "tailnets", "--oauth-client-id", "cid", "--oauth-client-secret", "secret"}, map[string]string{ - "TSCLI_BASE_URL": mock.URL(), - "TSCLI_OAUTH_TOKEN_URL": mock.URL() + "/api/v2/oauth/token", + "TSCLI_BASE_URL": mock.URL(), }) if res.err == nil { t.Fatalf("expected lifecycle API error") @@ -163,12 +158,8 @@ func TestTailnetLifecycleCommandErrorsAreActionable(t *testing.T) { }) t.Run("invalid base-url fails instead of defaulting", func(t *testing.T) { - mock := apimock.New(t) - mock.AddRaw(http.MethodPost, "/api/v2/oauth/token", http.StatusOK, `{"access_token":"tok-123","token_type":"Bearer","expires_in":3600}`) - res := executeCLINoDefaults(t, []string{"list", "tailnets", "--oauth-client-id", "cid", "--oauth-client-secret", "secret"}, map[string]string{ - "TSCLI_BASE_URL": "://bad-url", - "TSCLI_OAUTH_TOKEN_URL": mock.URL() + "/api/v2/oauth/token", + "TSCLI_BASE_URL": "://bad-url", }) if res.err == nil { t.Fatalf("expected invalid base-url error") diff --git a/test/cli/unauthenticated_commands_test.go b/test/cli/unauthenticated_commands_test.go index 8b11d37..82015e0 100644 --- a/test/cli/unauthenticated_commands_test.go +++ b/test/cli/unauthenticated_commands_test.go @@ -7,7 +7,7 @@ import ( func TestCreateTokenDoesNotRequireAPIKeyConfig(t *testing.T) { res := executeCLINoDefaults(t, []string{"create", "token", "--client-id", "cid", "--client-secret", "secret"}, map[string]string{ - "TSCLI_OAUTH_TOKEN_URL": "http://127.0.0.1:1/api/v2/oauth/token", + "TSCLI_BASE_URL": "http://127.0.0.1:1", }) if res.err == nil {