diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b89cd..53e4d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `cb tailscale client` command to create, list, and destroy team Tailscale + OAuth clients. +- `cb tailscale connect` now accepts `--client` as an alternative to + `--authkey`. ## [3.7.2] - 2026-09-17 ### Changed diff --git a/spec/cb/tailscale_client_spec.cr b/spec/cb/tailscale_client_spec.cr new file mode 100644 index 0000000..9843805 --- /dev/null +++ b/spec/cb/tailscale_client_spec.cr @@ -0,0 +1,144 @@ +require "../spec_helper" +include CB + +TEAM_ID = "p5jflp7w3zhe7bu7c4s2t4e2wq" +CLIENT_ID = "pkdpq6yynjgjbps4otxd7il2u4" + +def oauth_client + Model::TailscaleOAuthClient.new( + id: CLIENT_ID, + name: "production", + team_id: TEAM_ID, + ) +end + +Spectator.describe TailscaleClientCreate do + subject(action) { described_class.new client: client, output: IO::Memory.new } + let(client) { Client.new TEST_TOKEN } + + mock_client + + it "validates that required arguments are present" do + expect(&.validate).to raise_error Program::Error, /Missing required argument/ + end + + it "#run sends the create fields and prints the Bridge id" do + action.team_id = TEAM_ID + action.name = "production" + action.tailscale_client_id = "k123456CNTRL" + action.tailscale_client_secret = "tskey-client-example" + action.tags << "tag:production" + action.tags << "tag:other" + + expect(client).to receive(:create_tailscale_oauth_client).with( + TEAM_ID, + client_id: "k123456CNTRL", + client_secret: "tskey-client-example", + name: "production", + tags: ["tag:production", "tag:other"], + ).and_return(oauth_client) + + action.call + + printed = action.output.to_s + expect(printed).to contain CLIENT_ID + expect(printed).to contain TEAM_ID + expect(printed).to contain "production" + expect(printed).to_not contain "tskey-client-example" + end +end + +Spectator.describe TailscaleClientList do + subject(action) { described_class.new client: client, output: IO::Memory.new } + let(client) { Client.new TEST_TOKEN } + + mock_client + + it "validates that team is present" do + expect(&.validate).to raise_error Program::Error, /Missing required argument/ + end + + it "#run prints a table of id, team, and name" do + action.team_id = TEAM_ID + expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client]) + + action.call + + printed = action.output.to_s + expect(printed).to contain "ID" + expect(printed).to contain CLIENT_ID + expect(printed).to contain TEAM_ID + expect(printed).to contain "production" + end + + it "#run can omit the table header" do + action.team_id = TEAM_ID + action.no_header = true + expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client]) + + action.call + + expect(action.output.to_s).to_not contain "ID" + expect(action.output.to_s).to contain CLIENT_ID + end + + it "#run can print json" do + action.team_id = TEAM_ID + action.format = "json" + expect(client).to receive(:get_tailscale_oauth_clients).with(TEAM_ID).and_return([oauth_client]) + + action.call + + payload = JSON.parse(action.output.to_s) + expect(payload["clients"][0]["id"]).to eq CLIENT_ID + expect(payload["clients"][0]["team_id"]).to eq TEAM_ID + expect(payload["clients"][0]["name"]).to eq "production" + expect(payload["clients"][0]["client_secret"]?).to be_nil + end +end + +Spectator.describe TailscaleClientDestroy do + subject(action) { described_class.new client: client, output: IO::Memory.new } + let(client) { Client.new TEST_TOKEN } + + mock_client + + it "validates that team and client are present" do + action.team_id = TEAM_ID + expect(&.validate).to raise_error Program::Error, /Missing required argument/ + end + + it "#run deletes by team and Bridge client id" do + action.team_id = TEAM_ID + action.client_id = CLIENT_ID + expect(client).to receive(:destroy_tailscale_oauth_client).with(TEAM_ID, CLIENT_ID).and_return(oauth_client) + + action.call + + expect(action.output.to_s).to contain CLIENT_ID + expect(action.output.to_s).to contain "production" + end +end + +Spectator.describe Completion do + it "suggests tailscale client commands and create flags" do + client = Client.new TEST_TOKEN + + commands = Completion.parse(client, "cb tailscale ") + expect(commands).to contain "client\tmanage tailscale oauth clients" + + subcommands = Completion.parse(client, "cb tailscale client ") + expect(subcommands).to contain "create\tregister a tailscale oauth client" + expect(subcommands).to contain "list\tlist tailscale oauth clients" + expect(subcommands).to contain "destroy\tremove a tailscale oauth client" + + flags = Completion.parse(client, "cb tailscale client create ") + expect(flags).to contain "--team\tchoose team" + expect(flags).to contain "--tailscale-client-id\tclient id from tailscale" + expect(flags).to contain "--tag\tacl tag" + + after_id = Completion.parse(client, "cb tailscale client create --tailscale-client-id k123 ") + expect(after_id).to_not contain "--tailscale-client-id\tclient id from tailscale" + expect(after_id).to contain "--tag\tacl tag" + end +end diff --git a/spec/cb/tailscale_oauth_client_spec.cr b/spec/cb/tailscale_oauth_client_spec.cr new file mode 100644 index 0000000..bdf6363 --- /dev/null +++ b/spec/cb/tailscale_oauth_client_spec.cr @@ -0,0 +1,95 @@ +require "../spec_helper" + +Spectator.describe CB::Model::TailscaleOAuthClient do + it "parses the fields the API returns" do + json = %({ + "id": "pkdpq6yynjgjbps4otxd7il2u4", + "name": "production", + "team_id": "p5jflp7w3zhe7bu7c4s2t4e2wq", + "created_at": "2026-10-05T00:00:00Z", + "updated_at": "2026-10-05T01:00:00Z" + }) + + client = CB::Model::TailscaleOAuthClient.from_json(json) + + expect(client.id).to eq "pkdpq6yynjgjbps4otxd7il2u4" + expect(client.name).to eq "production" + expect(client.team_id).to eq "p5jflp7w3zhe7bu7c4s2t4e2wq" + expect(client.created_at).to eq Time.utc(2026, 10, 5) + expect(client.updated_at).to eq Time.utc(2026, 10, 5, 1) + end +end + +private class RecordingTailscaleClient < CB::Client + getter calls = [] of Tuple(String, String, String?) + property responses = [] of String + + def exec(method, path, body : String? = nil) + calls << {method, path, body} + HTTP::Client::Response.new(200, body: responses.shift) + end +end + +Spectator.describe CB::Client do + let(client) { RecordingTailscaleClient.new } + let(resource) do + %({ + "id": "pkdpq6yynjgjbps4otxd7il2u4", + "name": "production", + "team_id": "p5jflp7w3zhe7bu7c4s2t4e2wq", + "created_at": "2026-10-05T00:00:00Z", + "updated_at": "2026-10-05T00:00:00Z" + }) + end + + it "posts a Tailscale OAuth client create body" do + client.responses << resource + + created = client.create_tailscale_oauth_client( + "p5jflp7w3zhe7bu7c4s2t4e2wq", + client_id: "k123456CNTRL", + client_secret: "tskey-client-example", + name: "production", + tags: ["tag:production"], + ) + + expect(created.id).to eq "pkdpq6yynjgjbps4otxd7il2u4" + method, path, body = client.calls.first + expect(method).to eq "POST" + expect(path).to eq "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients" + payload = JSON.parse(body.not_nil!) + expect(payload["client_id"]).to eq "k123456CNTRL" + expect(payload["client_secret"]).to eq "tskey-client-example" + expect(payload["name"]).to eq "production" + expect(payload["tags"]).to eq ["tag:production"] + end + + it "pages the Tailscale OAuth client list" do + client.responses << %({"clients":[#{resource}],"has_more":true,"next_cursor":"cursor-2"}) + client.responses << %({"clients":[],"has_more":false}) + + listed = client.get_tailscale_oauth_clients("p5jflp7w3zhe7bu7c4s2t4e2wq") + + expect(listed.map(&.id)).to eq ["pkdpq6yynjgjbps4otxd7il2u4"] + expect(client.calls.map(&.[1])).to eq [ + "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients?order_field=id", + "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients?order_field=id&cursor=cursor-2", + ] + expect(client.calls.map(&.[0])).to eq ["GET", "GET"] + end + + it "deletes a Tailscale OAuth client by team and id" do + client.responses << resource + + destroyed = client.destroy_tailscale_oauth_client( + "p5jflp7w3zhe7bu7c4s2t4e2wq", + "pkdpq6yynjgjbps4otxd7il2u4", + ) + + expect(destroyed.id).to eq "pkdpq6yynjgjbps4otxd7il2u4" + method, path, body = client.calls.first + expect(method).to eq "DELETE" + expect(path).to eq "teams/p5jflp7w3zhe7bu7c4s2t4e2wq/tailscale-oauth-clients/pkdpq6yynjgjbps4otxd7il2u4" + expect(body).to be_nil + end +end diff --git a/spec/cb/tailscale_spec.cr b/spec/cb/tailscale_spec.cr index 612ccd8..4542112 100644 --- a/spec/cb/tailscale_spec.cr +++ b/spec/cb/tailscale_spec.cr @@ -25,6 +25,57 @@ Spectator.describe TailscaleConnect do action.call end + + it "rejects authkey and client together" do + action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" + action.auth_key = "tskey-abcdef1432341818" + action.client_id = "n4k7q2m9p3r6s8t1u5v7w9x2y4" + + expect(&.validate).to raise_error Program::Error, /not both/ + end + + it "requires authkey or client" do + action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" + + expect(&.validate).to raise_error Program::Error, /authkey or client/ + end + + it "#run sends auth_key when --authkey is set" do + action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" + action.auth_key = "tskey-abcdef1432341818" + + expect(client).to receive(:put).with( + "clusters/pkdpq6yynjgjbps4otxd7il2u4/actions/tailscale-connect", + {"auth_key" => "tskey-abcdef1432341818"}, + ).and_return HTTP::Client::Response.new(200, body: {message: "hi"}.to_json) + + action.call + expect(action.output.to_s).to contain "hi" + end + + it "#run sends tailscale_oauth_client_id when --client is set" do + action.cluster_id = "pkdpq6yynjgjbps4otxd7il2u4" + action.client_id = "n4k7q2m9p3r6s8t1u5v7w9x2y4" + + expect(client).to receive(:put).with( + "clusters/pkdpq6yynjgjbps4otxd7il2u4/actions/tailscale-connect", + {"tailscale_oauth_client_id" => "n4k7q2m9p3r6s8t1u5v7w9x2y4"}, + ).and_return HTTP::Client::Response.new(200, body: {message: "hi"}.to_json) + + action.call + end +end + +Spectator.describe Completion do + it "offers connect --client unless --authkey is already set" do + client = Client.new TEST_TOKEN + flags = Completion.parse(client, "cb tailscale connect ") + expect(flags).to contain "--client\tbridge oauth client id" + expect(flags).to contain "--authkey\tpre-authentication key" + + with_key = Completion.parse(client, "cb tailscale connect --authkey tskey-example ") + expect(with_key).to_not contain "--client\tbridge oauth client id" + end end Spectator.describe TailscaleDisconnect do diff --git a/src/cb/completion.cr b/src/cb/completion.cr index 7391d0b..d949d82 100644 --- a/src/cb/completion.cr +++ b/src/cb/completion.cr @@ -367,18 +367,72 @@ class CB::Completion def tailscale case @args[1] + when "client" + tailscale_client when "connect" tailscale_connect when "disconnect" tailscale_disconnect else [ + "client\tmanage tailscale oauth clients", "connect\tadd a cluster to tailscale", "disconnect\tremove a cluster from tailscale", ] end end + def tailscale_client + case @args[2] + when "create" + tailscale_client_create + when "destroy" + tailscale_client_destroy + when "list" + tailscale_client_list + else + [ + "create\tregister a tailscale oauth client", + "list\tlist tailscale oauth clients", + "destroy\tremove a tailscale oauth client", + ] + end + end + + def tailscale_client_create + return team_suggestions if last_arg?("--team") + suggest_none if last_arg?("--name", "--tailscale-client-id", "--tailscale-client-secret", "--tag") + + suggest = [] of String + suggest << "--name\tname for the oauth client" unless has_full_flag? :name + suggest << "--tag\tacl tag" + suggest << "--tailscale-client-id\tclient id from tailscale" unless has_full_flag? :tailscale_client_id + suggest << "--tailscale-client-secret\tclient secret from tailscale" unless has_full_flag? :tailscale_client_secret + suggest << "--team\tchoose team" unless has_full_flag? :team + suggest + end + + def tailscale_client_list + return ["table", "json"] if last_arg?("--format") + return team_suggestions if last_arg?("--team") + + suggest = [] of String + suggest << "--format\tchoose output format" unless has_full_flag? :format + suggest << "--no-header\tdo not display table header" unless has_full_flag? :no_header + suggest << "--team\tchoose team" unless has_full_flag? :team + suggest + end + + def tailscale_client_destroy + return suggest_none if last_arg?("--client") + return team_suggestions if last_arg?("--team") + + suggest = [] of String + suggest << "--client\tbridge oauth client id" unless has_full_flag? :client + suggest << "--team\tchoose team" unless has_full_flag? :team + suggest + end + def tailscale_connect : Array(String) cluster = find_arg_value "--cluster" @@ -386,13 +440,14 @@ class CB::Completion return cluster.nil? ? cluster_suggestions : [] of String end - if last_arg?("--authkey") + if last_arg?("--authkey", "--client") suggest_none end suggest = [] of String suggest << "--cluster\tcluster id" unless has_full_flag? :cluster - suggest << "--authkey\tapreuthorization key" unless has_full_flag? :authkey + suggest << "--authkey\tpre-authentication key" unless has_full_flag?(:authkey) || has_full_flag?(:client) + suggest << "--client\tbridge oauth client id" unless has_full_flag?(:client) || has_full_flag?(:authkey) suggest end @@ -1416,6 +1471,9 @@ class CB::Completion full << :full if has_full_flag? "--full" full << :format if has_full_flag? "--format" full << :authkey if has_full_flag? "--authkey" + full << :client if has_full_flag? "--client" + full << :tailscale_client_id if has_full_flag? "--tailscale-client-id" + full << :tailscale_client_secret if has_full_flag? "--tailscale-client-secret" full << :window_start if has_full_flag? "--window-start" full << :unset if has_full_flag? "--unset" full << :starting_from if has_full_flag? "--starting-from" diff --git a/src/cb/tailscale.cr b/src/cb/tailscale.cr index be3c286..b477460 100644 --- a/src/cb/tailscale.cr +++ b/src/cb/tailscale.cr @@ -1,4 +1,5 @@ require "./action" +require "./table" abstract class CB::TailscaleAction < CB::APIAction eid_setter cluster_id @@ -11,16 +12,25 @@ end # Action to connect a cluster to a tailscale network class CB::TailscaleConnect < CB::TailscaleAction property auth_key : String? + eid_setter client_id, "client" def validate super - check_required_args { |missing| missing << "authkey" unless auth_key } + raise Error.new "Specify either authkey or client, not both." if auth_key && client_id + check_required_args { |missing| missing << "authkey or client" unless auth_key || client_id } end def run validate - response = client.put "clusters/#{cluster_id}/actions/tailscale-connect", {auth_key: auth_key} + body = Hash(String, String).new + if id = client_id + body["tailscale_oauth_client_id"] = id + elsif key = auth_key + body["auth_key"] = key + end + + response = client.put "clusters/#{cluster_id}/actions/tailscale-connect", body output.puts JSON.parse(response.body)["message"] end end @@ -34,3 +44,90 @@ class CB::TailscaleDisconnect < CB::TailscaleAction output.puts JSON.parse(response.body)["message"] end end + +abstract class CB::TailscaleClientAction < CB::APIAction + eid_setter team_id + + def validate + check_required_args { |missing| missing << "team" unless team_id } + end + + def self.render(output : IO, clients : Array(CB::Model::TailscaleOAuthClient), format : Format, no_header : Bool) + case format + when Format::Default, Format::Table + table = Table::TableBuilder.new(border: :none) do + columns do + add "ID" + add "Team" + add "Name" + end + + header unless no_header + + clients.each do |client| + row [client.id, client.team_id, client.name] + end + end + + output << table.render << '\n' + when Format::JSON + output << {clients: clients}.to_pretty_json << '\n' + end + end +end + +class CB::TailscaleClientCreate < CB::TailscaleClientAction + property tailscale_client_id : String? + property tailscale_client_secret : String? + property name : String? + property tags : Array(String) = [] of String + + def validate + super + check_required_args do |missing| + missing << "tailscale-client-id" unless tailscale_client_id + missing << "tailscale-client-secret" unless tailscale_client_secret + missing << "name" unless name + missing << "tag" if tags.empty? + end + end + + def run + validate + + created = client.create_tailscale_oauth_client( + team_id.not_nil!, + client_id: tailscale_client_id.not_nil!, + client_secret: tailscale_client_secret.not_nil!, + name: name.not_nil!, + tags: tags, + ) + self.class.render(output, [created], Format::Table, false) + end +end + +class CB::TailscaleClientList < CB::TailscaleClientAction + format_setter format + property no_header : Bool = false + + def run + validate + clients = client.get_tailscale_oauth_clients team_id.not_nil! + self.class.render(output, clients, @format, no_header) + end +end + +class CB::TailscaleClientDestroy < CB::TailscaleClientAction + eid_setter client_id, "client" + + def validate + super + check_required_args { |missing| missing << "client" unless client_id } + end + + def run + validate + destroyed = client.destroy_tailscale_oauth_client(team_id.not_nil!, client_id.not_nil!) + output.puts "Destroyed Tailscale OAuth client #{destroyed.id} (#{destroyed.name})." + end +end diff --git a/src/cli.cr b/src/cli.cr index 0a74bde..5bb5adb 100755 --- a/src/cli.cr +++ b/src/cli.cr @@ -740,13 +740,72 @@ op = OptionParser.new do |parser| # parser.on("tailscale", "Manage Tailscale") do - parser.banner = "cb tailscale " + parser.banner = "cb tailscale " + + parser.on("client", "Manage Tailscale OAuth clients") do + parser.banner = "cb tailscale client " + + parser.on("create", "Register a Tailscale OAuth client for a team") do + create = set_action TailscaleClientCreate + parser.banner = "cb tailscale client create <--team> <--name> <--tailscale-client-id> <--tailscale-client-secret> <--tag>" + parser.on("--name NAME", "Name for the OAuth client") { |arg| create.name = arg } + parser.on("--tag TAG", "ACL tag from the Tailscale client (repeat for more than one)") { |arg| create.tags << arg } + parser.on("--tailscale-client-id ID", "Client ID from Tailscale") { |arg| create.tailscale_client_id = arg } + parser.on("--tailscale-client-secret SECRET", "Client secret from Tailscale") { |arg| create.tailscale_client_secret = arg } + parser.on("--team ID", "Choose team") { |arg| create.team_id = arg } + + parser.examples = <<-EXAMPLES + Register a Tailscale OAuth client. The client must have Write permission on Devices - Core and Auth Keys. + $ cb tailscale client create --team --name production --tailscale-client-id --tailscale-client-secret --tag tag:production + EXAMPLES + end + + parser.on("list", "List Tailscale OAuth clients for a team") do + list = set_action TailscaleClientList + parser.banner = "cb tailscale client list <--team>" + parser.on("--format FORMAT", "Choose output format (default: table)") { |arg| list.format = arg } + parser.on("--no-header", "Do not display table header") { list.no_header = true } + parser.on("--team ID", "Choose team") { |arg| list.team_id = arg } + + parser.examples = <<-EXAMPLES + List Tailscale OAuth clients. Output: table + $ cb tailscale client list --team + + List Tailscale OAuth clients. Output: table without header + $ cb tailscale client list --team --no-header + + List Tailscale OAuth clients. Output: json + $ cb tailscale client list --team --format=json + EXAMPLES + end + + parser.on("destroy", "Remove a Tailscale OAuth client from a team") do + destroy = set_action TailscaleClientDestroy + parser.banner = "cb tailscale client destroy <--team> <--client>" + parser.on("--client ID", "Bridge OAuth client ID") { |arg| destroy.client_id = arg } + parser.on("--team ID", "Choose team") { |arg| destroy.team_id = arg } + + parser.examples = <<-EXAMPLES + Remove a Tailscale OAuth client. This does not disconnect clusters that already joined. + $ cb tailscale client destroy --team --client + EXAMPLES + end + end parser.on("connect", "Add a cluster to Tailscale") do connect = set_action TailscaleConnect - parser.banner = "cb tailscale connect <--cluster> <--authkey>" - parser.on("--cluster ID", "Choose cluster") { |arg| connect.cluster_id = arg } + parser.banner = "cb tailscale connect <--cluster> <--authkey|--client>" parser.on("--authkey KEY", "Pre-authentication key") { |arg| connect.auth_key = arg } + parser.on("--client ID", "Bridge OAuth client ID") { |arg| connect.client_id = arg } + parser.on("--cluster ID", "Choose cluster") { |arg| connect.cluster_id = arg } + + parser.examples = <<-EXAMPLES + Connect with a pasted auth key. + $ cb tailscale connect --cluster --authkey + + Connect with a Tailscale OAuth client registered for the cluster's team. + $ cb tailscale connect --cluster --client + EXAMPLES end parser.on("disconnect", "Remove a cluster from Tailscale") do diff --git a/src/client/tailscale_oauth_client.cr b/src/client/tailscale_oauth_client.cr new file mode 100644 index 0000000..44bb023 --- /dev/null +++ b/src/client/tailscale_oauth_client.cr @@ -0,0 +1,51 @@ +require "./client" + +module CB + class Client + # Create a Tailscale OAuth client for a team. + # + # POST /teams/{team_id}/tailscale-oauth-clients + def create_tailscale_oauth_client(team_id : String, *, client_id : String, client_secret : String, name : String, tags : Array(String)) + resp = post "teams/#{team_id}/tailscale-oauth-clients", { + client_id: client_id, + client_secret: client_secret, + name: name, + tags: tags, + } + Model::TailscaleOAuthClient.from_json resp.body + end + + # List Tailscale OAuth clients for a team, following pagination. + # + # GET /teams/{team_id}/tailscale-oauth-clients + def get_tailscale_oauth_clients(team_id : String) + clients = [] of Model::TailscaleOAuthClient + query = Hash(String, String).new + query["order_field"] = "id" + + loop do + resp = get "teams/#{team_id}/tailscale-oauth-clients?#{HTTP::Params.encode(query)}" + page = TailscaleOAuthClientListResponse.from_json resp.body + clients.concat page.clients + break unless page.has_more + query["cursor"] = page.next_cursor.to_s + end + + clients + end + + # Delete a Tailscale OAuth client. + # + # DELETE /teams/{team_id}/tailscale-oauth-clients/{id} + def destroy_tailscale_oauth_client(team_id : String, client_id : String) + resp = delete "teams/#{team_id}/tailscale-oauth-clients/#{client_id}" + Model::TailscaleOAuthClient.from_json resp.body + end + + struct TailscaleOAuthClientListResponse + include JSON::Serializable + pagination_properties + property clients : Array(Model::TailscaleOAuthClient) + end + end +end diff --git a/src/models/tailscale_oauth_client.cr b/src/models/tailscale_oauth_client.cr new file mode 100644 index 0000000..5e100d1 --- /dev/null +++ b/src/models/tailscale_oauth_client.cr @@ -0,0 +1,8 @@ +module CB::Model + jrecord TailscaleOAuthClient, + id : String, + name : String, + team_id : String, + created_at : Time = Time::ZERO, + updated_at : Time = Time::ZERO +end