diff --git a/cmd/client_node_user_ssh_key.go b/cmd/client_node_user_ssh_key.go new file mode 100644 index 000000000..db933c872 --- /dev/null +++ b/cmd/client_node_user_ssh_key.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// clientNodeUserSSHKeyCmd represents the user ssh-key command. +var clientNodeUserSSHKeyCmd = &cobra.Command{ + Use: "ssh-key", + Short: "Manage SSH authorized keys", +} + +func init() { + clientNodeUserCmd.AddCommand(clientNodeUserSSHKeyCmd) +} diff --git a/cmd/client_node_user_ssh_key_add.go b/cmd/client_node_user_ssh_key_add.go new file mode 100644 index 000000000..e389e8239 --- /dev/null +++ b/cmd/client_node_user_ssh_key_add.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" + "github.com/retr0h/osapi/pkg/sdk/client" +) + +// clientNodeUserSSHKeyAddCmd represents the user ssh-key add command. +var clientNodeUserSSHKeyAddCmd = &cobra.Command{ + Use: "add", + Short: "Add an SSH authorized key", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + key, _ := cmd.Flags().GetString("key") + + resp, err := sdkClient.User.AddKey(ctx, host, name, client.SSHKeyAddOpts{Key: key}) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.MutationResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + changed := r.Changed + results = append(results, cli.MutationResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Changed: &changed, + Error: errPtr, + Fields: []string{fmt.Sprintf("%t", r.Changed)}, + }) + } + headers, rows := cli.BuildMutationTable(results, []string{"CHANGED"}) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyAddCmd) + + clientNodeUserSSHKeyAddCmd.PersistentFlags(). + String("name", "", "Username to add SSH key for (required)") + clientNodeUserSSHKeyAddCmd.PersistentFlags(). + String("key", "", "Full SSH public key line (required)") + + _ = clientNodeUserSSHKeyAddCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSSHKeyAddCmd.MarkPersistentFlagRequired("key") +} diff --git a/cmd/client_node_user_ssh_key_list.go b/cmd/client_node_user_ssh_key_list.go new file mode 100644 index 000000000..fa3af807a --- /dev/null +++ b/cmd/client_node_user_ssh_key_list.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" +) + +// clientNodeUserSSHKeyListCmd represents the user ssh-key list command. +var clientNodeUserSSHKeyListCmd = &cobra.Command{ + Use: "list", + Short: "List SSH authorized keys", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + + resp, err := sdkClient.User.ListKeys(ctx, host, name) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.ResultRow, 0) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + if errPtr != nil || len(r.Keys) == 0 { + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Error: errPtr, + Fields: []string{"", "", ""}, + }) + + continue + } + for _, k := range r.Keys { + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Fields: []string{ + k.Type, + k.Fingerprint, + k.Comment, + }, + }) + } + } + headers, rows := cli.BuildBroadcastTable(results, []string{ + "TYPE", "FINGERPRINT", "COMMENT", + }) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyListCmd) + + clientNodeUserSSHKeyListCmd.PersistentFlags(). + String("name", "", "Username to list SSH keys for (required)") + + _ = clientNodeUserSSHKeyListCmd.MarkPersistentFlagRequired("name") +} diff --git a/cmd/client_node_user_ssh_key_remove.go b/cmd/client_node_user_ssh_key_remove.go new file mode 100644 index 000000000..460d762ab --- /dev/null +++ b/cmd/client_node_user_ssh_key_remove.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" +) + +// clientNodeUserSSHKeyRemoveCmd represents the user ssh-key remove command. +var clientNodeUserSSHKeyRemoveCmd = &cobra.Command{ + Use: "remove", + Short: "Remove an SSH authorized key", + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + name, _ := cmd.Flags().GetString("name") + fingerprint, _ := cmd.Flags().GetString("fingerprint") + + resp, err := sdkClient.User.RemoveKey(ctx, host, name, fingerprint) + if err != nil { + cli.HandleError(err, logger) + return + } + + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + results := make([]cli.MutationResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + changed := r.Changed + results = append(results, cli.MutationResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Changed: &changed, + Error: errPtr, + Fields: []string{fmt.Sprintf("%t", r.Changed)}, + }) + } + headers, rows := cli.BuildMutationTable(results, []string{"CHANGED"}) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeUserSSHKeyCmd.AddCommand(clientNodeUserSSHKeyRemoveCmd) + + clientNodeUserSSHKeyRemoveCmd.PersistentFlags(). + String("name", "", "Username to remove SSH key from (required)") + clientNodeUserSSHKeyRemoveCmd.PersistentFlags(). + String("fingerprint", "", "Fingerprint of the SSH key to remove (required)") + + _ = clientNodeUserSSHKeyRemoveCmd.MarkPersistentFlagRequired("name") + _ = clientNodeUserSSHKeyRemoveCmd.MarkPersistentFlagRequired("fingerprint") +} diff --git a/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..e2a32d75e --- /dev/null +++ b/docs/docs/gen/api/delete-node-user-ssh-key.api.mdx @@ -0,0 +1,530 @@ +--- +id: delete-node-user-ssh-key +title: "Remove SSH authorized key" +description: "Remove an SSH authorized key by fingerprint for a user on the target node." +sidebar_label: "Remove SSH authorized key" +hide_title: true +hide_table_of_contents: true +api: eJztV0tv20YQ/iuLOTkA9XIktyXQg4s4jZukCGwHObiCsOKOyLXIXWZ3qFgV+N+LWVIyLSlo4yRADzmJpHbn8X3z3IBCnzhdkrYGYrjCwq5QSCOur18JWVFmnf4blVjiWszXYqFNiq502pBYWCekqDw6YY2gDAVJlyIJYxX2/zIQAcnUQ3wL7z26mTRq9ruzVTl7K41MsUBDs/N3lzMWMbMlOslWeJhGsHu7VBDDC8yR8E+rkAVdX796jWuIwGNSOU1riG838BtKh+68oowVssj4k9OEMK2nEZTSyQIJnQ+HjSwQYsisp/AYgWbvS0kZRODwY6UdKojJVRjtQXTTOClTNCS2EiLh0KNboRLOVqRNKlYyr1CczKRZR2Im8/xZJKwTuZxjLjzmmJB14mSJ6zgcfdZAdt+zstS9xCpM0fTwnpzsNThuYCVzrSSx7Vsjo0KbX0dR+GfWEAB1BD7JsJB8h9Yln/fktEkhgkKbN2hSxmlU19EOjC8GgrkQMklsZUjw7a9x4Gk2d8Lxi0zn4OaIvn51fjo5exTVJ9hP+1H7Tyznyej0eb/f/yp2vsS5KdvuS2s8BqGnwyH/PLb/Na6FC6mq+hBBYg2hIT4nyzLXSUidwZ3nw5tD1XZ+hwkjVjpONNKNqjs7n2l1zMSFdYUkiKGqtIKDjMhQ3Nm5uHzB1UAJsqJ0NkHvBWXaCwYDPbGleC+LMmfZk8kQfx4Phz08/WXeG4/UuCd/Gp31xuOzs8lkPB4Oh0PGzaGvcvIdq6RzktNfExb+mFfHuS4qCrCIRmJbvrw2aY5NNvcPENkViCOYHGKwPS3sIpTDViiTT5Iqf0wKmqrggmWXDLPUOTK+fqnLEhVXwkM1jbCtkl2lDA4FvNmOoDbJpEmxy+jc2hylOTD/Q4aUoduTWFilFxqV8GtPWATNGCSjc9b9OyoXfEwU6L1MUegOLKLxtQ913U3W225NblGbRkCaQtA0pf9ty+SFIbeGel/CNmI+e++qzS++WkcwHo4OM+y96TS/njh/dxmCaKfnmyXdf0TyXHTet9yHu4IyScImSeVcUw4ekuxlAJkT0iE5jatt9AQSFZLU+dGw3FOulOZHmYv2jpBzW9GDEUfVqgpZtUH6ZN1SkC7QVm1kWtVNKm0IU3RHs6pxki88UjIZDpm8LcMh0g6IfX5I7Evr5lopNKInLo2vFgudaA7IEl2hvQ8TyA92///sTo41xqbghNbIM1hb+79hj/xB6HcitI6gQMosz/sqzPuMPQ9zMQx4oxhstq2hHvB4P9g0z95nvSWuB5vOFFeH7cCtmnl/+rAqXDPNDZPdhWHnWUZUQjushX4ZDkHUPrzczkF/fLgJnUubhQ3XW7/OQ2972G+4cUAEbEgD0ag/7Ie5prSeChlirx1m293rcPHax3nzEMzffGFrYCC8p0GZS23Y1MrlrLQh4xb4NEQQdzo1y+RP28bdUMJfuhP6NArdnWVsNnPp8b3L65o/f6zQrRumVtJpOWcwbzegtOdnBfFC5n5/jO/icHLVduZn4jsvaEch2o6mhp0OpyEGiIBR6OyZvLo8yafP7FpPsOXr7Hjy4vQES7uhU0/rCDKUCl0IjObEeZJgSZ27B5Wd16ldWXlx8ebi5gIikI+Tfi/Jg4Kjpm02zYkbu0RT1ztLid/Zxrr+B0Rp9O4= +sidebar_class_name: "delete api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Remove an SSH authorized key by fingerprint for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+ +
+
+
+ + +
+ + + Key removed. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error removing SSH key. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/get-node-user-ssh-key.api.mdx b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..d2c85cfbe --- /dev/null +++ b/docs/docs/gen/api/get-node-user-ssh-key.api.mdx @@ -0,0 +1,593 @@ +--- +id: get-node-user-ssh-key +title: "List SSH authorized keys" +description: "List SSH authorized keys for a user on the target node." +sidebar_label: "List SSH authorized keys" +hide_title: true +hide_table_of_contents: true +api: eJztV21v2zYQ/ivEfUoA2rFTO1sFDKi3JW3WbiiaFPuQGQYtnS3GEqmSpyyeof8+HCU7iq2u78A+9JMl+V6fe3jH20CCPna6IG0NRPBKexJXVy+EKim1Tv+DiVjh2ouFdUKJ0qMT1ghKUZBySyRhbIL9vwxIILX0EN3AW49upkwye+5sWcx+V0YtMUdDs8nryxmbmNkCnWKXHqYSdm+XCUTwHOkPmyBbubp68RLXIMFjXDpNa4huNvAzKoduUlLK3the5FAlMK2mEgrlVI6EzgdZo3KECFLrKTxK0JxmoSgFCQ7fldphAhG5EuUeFtd1gmqJhsTWghQOPbo7TISzJWmzFHcqK1EczZRZSzFTWXYshXUiU3PMhMcMY7JOHK1wHQXR4xqu+55Vhe7FNsElmh7ek1O9GsMN3KlMJ4o49m2QMtfmp6EM/8xq8KGS4OMUc8U6tC5Y3pPTZgkScm1eoVkyTMOqkjswPhkILoVQcWxLQ4K1vySBT4l5ypH5whqPwejpYMA/HZS1iy7W9kFCbA2hIdZTRZHpODDt5Naz8uYwFDu/xZhAQuGYl6Rr17d2PtNJV8gL63JFEEFZ6gQOSJSiuLVzcfkrH55EkBWFszF6LyjVXjA46IkjxXuVFxnbHo8H+ONoMOjh6dN5bzRMRj31w/CsNxqdnY3Ho9FgMBgwjg59mZFvRaWcU3xgNGHuu7J6HB1jtsK1yBjD2lpz0r02ywxr8vcP0Nidpw48DvPfSnOVuHM0RpkIpKj0XVbQlDkfb7tiiJXOkLH1K10UmHDTOHRTG9s62TWVkFDAmuMIbpkbHaAdYrPfBUPr0/4Blo/EeWI66CnQkFsfglub+hCwL3EtWEIcYX/Zl8L7tOe8qh8wOR2Ph0+PH9Oq9Q+jsNBmia5wuj4d/+3u6sXkdHwmWjpboFe4fuymFo3UPB6ePun3A+KxzXP8GD+cViMsjmhd6Fhl2ToMnmdcv72Ubm1qnmWqIFtAVUkgTXUMYXJcmoUNn9E56z7s/JzFRI7eqyUK3WKrqCnYD9YeuuVNe7I0ZJ7uR3HOZYZqX3N7eA/kf7EZzwxtzZum+bFyJWE0GB62v7emxaqemLy+DNzaefpqHfAjMZyI1vuWI0FXUKpI2DgunWMo23W8CPByd3RITuPd9jgH9iRISmedfWLPeZJoflSZaHSEmtuSHoLodJuUyK4N0t/WrQTpHG1JDXGT9mHUhnCJrrPN1UmywiMn48GgzczAsYPCPjks7IV1c50kaERPXBpfLhY61kzFAl2uvQ+3p+/V/f9Xd9x1a6lbDc9dvkI2c/hrXli+F/QbFbSSkCOllncVvoPL+hIdwQmvQieb7USoTnhonWzqZ569q2aXcXf1ejJ9WGyuuKp14drrzS6RlKiA5uLM7/MgBLJ5uNjeQX/78zqMKM2Tj9WbNCZhiD2sYjwnQAIHUiMy7A/64U5ZWE+5ClRr9oX37YT7qG4eqPtle2SdMuE9nRSZ0obDKl3GHmqob4ClQULUGr9skz9tp3ED+FSGGc1Km81ceXzrsqriz+9KdOu6DHfKaTVnpG42kGjPzwlEC5X5/W2oneXRm2bKHotvvCx2YrK9vhqmVZCGCCBcb9s7L69+n5XTe/a+z4iljmNaSUhRJegCzvVfkzjGglpKB02P18DdiXt+fg0S1OPTsXcagvXOgDabWuLartBU1S4+4ncOsKr+BWM63eE= +sidebar_class_name: "get api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +List SSH authorized keys for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+ +
+
+
+ + +
+ + + List of SSH authorized keys. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + +
    + + + + keys + + object[] + + +
    +
    + + + SSH authorized keys on this agent. + + +
  • +
    + Array [ +
    +
  • + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error listing SSH keys. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/post-node-user-ssh-key.api.mdx b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx new file mode 100644 index 000000000..a9b5346a8 --- /dev/null +++ b/docs/docs/gen/api/post-node-user-ssh-key.api.mdx @@ -0,0 +1,670 @@ +--- +id: post-node-user-ssh-key +title: "Add SSH authorized key" +description: "Add an SSH authorized key for a user on the target node." +sidebar_label: "Add SSH authorized key" +hide_title: true +hide_table_of_contents: true +api: eJztWG1v2zYQ/isEP6WA7Did3a0CBszdmjVruwVJin5IDeMsniXGEqmSVBJN0H8fjpRsx3bXtd2ADug3vRzv5bnnTndquECbGFk6qRWP+VQIBopdXr5gULlMG/knCrbCmi21YcAqi4ZpxVyGzIFJ0TGlBQ7fKR5xB6nl8TV/Y9HMQYn5r0ZX5fw1KEixQOXm0/OzOamY6xINkE3LZxFf350JHvNzbd3vWiCpubx88RJrHnGLSWWkq3l83fBnCAbNtHIZmSOF8Z2RDvmsnUW8BAMFOjTWCysokMc809b5y4hLirQEl/GIG3xfSYOCx85UGO3AcRVChBSVY72GiBm0aG5RMKMrJ1XKbiGvkB3NQdURm0OeP4qYNiyHBebMYo6J04YdrbCOveijANj9QEMpB4kWmKIa4L0zMAgoNvwWcinAke+9k1Eh1Y8nkX8zD/DzNuI2ybAAOuPqkuStM1KlPOKFVK9QpYTTSdtGazA+GQjKBYMk0ZVyjE5/SQCf4vMseIbWPdOiJvmHjhFTy2qRy8Sz1GkGQgwPxJNo5VA50gBlmcvEE+74xpKaZt8fvbjBxD1QdM1XWBNfS0OMdRJ9nPTwQBwP/Tyt8pztOJtLhewIh+kwYu+4tdkAxePJ5OQpm06n0+Fw6MvtJ+LdO/5FlOFt20bcSZdjwOwl1lMhLgKy9LalQG2plQ1BPR6N9sF+iTXBix7gzwb0IXo3ejGX4hCAS20KcDzmVSXFHqBXGbIbvWBnvxBKgjJfGp2gtcxl0rKONeQp3kNR+sgnkxH+MB6NBvj46WIwPhHjAXx/8mQwHj95MpmMx6PRaMQDFFXu7JZXYAxQH5IOC3soqn1aUoqLynlYWNDYdVErVZpjaCvDPUTWneqjpCIMemmml74rd0qpxBy4yh7SgqoqiM56RTCDzJHwtStZliiI4PtmgrLeyLph+4A83uSHN5tkoFLczuhC6xxB7bn/NkOXodnRWGghlxIFs7V1WHjL6DWjMdp8HJXnJMYKtBZSZHILFhZiHfJA901db30cOtRmu+Xyusvkc+VMzdtdDT1jPnjuoquurtjGh+rrTPkK7rnLSqhzDf9mtf1DCKds675Puj/LXAaO6SSpjAl9YFNdpx5dqkSDzki87WnjsyfQgcwP8nFvBpF0CTnrzjBY6MptnDhoVlRIphW6O21WzMkCddVRUovtapLKYYrmYDmFIOnAAyOT0Wi7g3qK7WX0ZD+jb9TWGDVg0/Mz3xbWzPmW2P9DYr/bT+ypNgspBCo2YGfKVsulTCS1mBJNIa31o+237H792Z0casThEwJC0HDffcu/deGvP51txAt0maY1stTWI0/rTcyPaU09bvoPfXtMo/VxE65p9F51a6a5DYvjbLNzXlJaQ+a2N891JJlzJe9WGj/veCEedRen/Rz729srP3kQXS42S83zPrxulegz0tKCttTeUBfx1E8xm4WaPig84uRyAO9kOBr6CZaiL8Czstv5aLXf3+t34W82HP/CnwEBG4f37rjMQSryqjI5mQg5ueYkzSMeb41fpJMe9dNYl5lZ5Gc0OtQ0C7D4xuRtS4/fV2jqkK9bMBIWBNR1w4W0dC14vITc7i6022EeXXRf40fsP973D2LSLxiKcuGlecx51HFhDQ1t758V0wdW98/wJfgxayOeIQg0Hufw6udgeXBFCjZH95okRRFOTJMES/e3srOtaj7/4/KKKqr7CVD4XsIN3NGGDnfBTV2GX0pxE541PAeVVpCSbNBJ9QcPy3enXH1UB4FomiBxpVeo2naNi6N7AqZt/wKpPaky +sidebar_class_name: "post api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Add an SSH authorized key for a user on the target node. + + + + + +
+ +

+ Path Parameters +

+
+ +
+
+ +
+ +

+ Body +

+ required + +
+
+ + + SSH public key to add. + + +
+
    + + + +
+
+
+
+
+ + +
+ + + Key added. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Invalid request payload. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error adding SSH key. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts index 8ce332416..f2a2052b7 100644 --- a/docs/docs/gen/api/sidebar.ts +++ b/docs/docs/gen/api/sidebar.ts @@ -784,6 +784,24 @@ const sidebar: SidebarsConfig = { label: "Change user password", className: "api-method post", }, + { + type: "doc", + id: "gen/api/get-node-user-ssh-key", + label: "List SSH authorized keys", + className: "api-method get", + }, + { + type: "doc", + id: "gen/api/post-node-user-ssh-key", + label: "Add SSH authorized key", + className: "api-method post", + }, + { + type: "doc", + id: "gen/api/delete-node-user-ssh-key", + label: "Remove SSH authorized key", + className: "api-method delete", + }, ], }, { diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md index 5b3a7e8da..355c88faa 100644 --- a/docs/docs/sidebar/architecture/api-guidelines.md +++ b/docs/docs/sidebar/architecture/api-guidelines.md @@ -38,38 +38,40 @@ selectors (`key:value`). Sub-resources represent distinct capabilities of the node: -| Path Pattern | Domain | -| ---------------------------------------------- | ----------- | -| `/node/{hostname}` | Status | -| `/node/{hostname}/disk` | Node | -| `/node/{hostname}/memory` | Node | -| `/node/{hostname}/network/dns/{interfaceName}` | Network | -| `/node/{hostname}/command/exec` | Command | -| `/node/{hostname}/schedule/cron` | Schedule | -| `/node/{hostname}/schedule/cron/{name}` | Schedule | -| `/node/{hostname}/sysctl` | Sysctl | -| `/node/{hostname}/sysctl/{key}` | Sysctl | -| `/node/{hostname}/ntp` | NTP | -| `/node/{hostname}/timezone` | Timezone | -| `/node/{hostname}/power/reboot` | Power | -| `/node/{hostname}/power/shutdown` | Power | -| `/node/{hostname}/process` | Process | -| `/node/{hostname}/process/{pid}` | Process | -| `/node/{hostname}/process/{pid}/signal` | Process | -| `/node/{hostname}/user` | User | -| `/node/{hostname}/user/{name}` | User | -| `/node/{hostname}/user/{name}/password` | User | -| `/node/{hostname}/group` | Group | -| `/node/{hostname}/group/{name}` | Group | -| `/node/{hostname}/package` | Package | -| `/node/{hostname}/package/{name}` | Package | -| `/node/{hostname}/package/update` | Package | -| `/node/{hostname}/package/updates` | Package | -| `/node/{hostname}/log` | Log | -| `/node/{hostname}/log/source` | Log | -| `/node/{hostname}/log/unit/{name}` | Log | -| `/node/{hostname}/certificate/ca` | Certificate | -| `/node/{hostname}/certificate/ca/{name}` | Certificate | +| Path Pattern | Domain | +| ---------------------------------------------------- | ----------- | +| `/node/{hostname}` | Status | +| `/node/{hostname}/disk` | Node | +| `/node/{hostname}/memory` | Node | +| `/node/{hostname}/network/dns/{interfaceName}` | Network | +| `/node/{hostname}/command/exec` | Command | +| `/node/{hostname}/schedule/cron` | Schedule | +| `/node/{hostname}/schedule/cron/{name}` | Schedule | +| `/node/{hostname}/sysctl` | Sysctl | +| `/node/{hostname}/sysctl/{key}` | Sysctl | +| `/node/{hostname}/ntp` | NTP | +| `/node/{hostname}/timezone` | Timezone | +| `/node/{hostname}/power/reboot` | Power | +| `/node/{hostname}/power/shutdown` | Power | +| `/node/{hostname}/process` | Process | +| `/node/{hostname}/process/{pid}` | Process | +| `/node/{hostname}/process/{pid}/signal` | Process | +| `/node/{hostname}/user` | User | +| `/node/{hostname}/user/{name}` | User | +| `/node/{hostname}/user/{name}/password` | User | +| `/node/{hostname}/user/{name}/ssh-key` | User | +| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | +| `/node/{hostname}/group` | Group | +| `/node/{hostname}/group/{name}` | Group | +| `/node/{hostname}/package` | Package | +| `/node/{hostname}/package/{name}` | Package | +| `/node/{hostname}/package/update` | Package | +| `/node/{hostname}/package/updates` | Package | +| `/node/{hostname}/log` | Log | +| `/node/{hostname}/log/source` | Log | +| `/node/{hostname}/log/unit/{name}` | Log | +| `/node/{hostname}/certificate/ca` | Certificate | +| `/node/{hostname}/certificate/ca/{name}` | Certificate | 6. **Path Parameters Over Query Parameters** diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md deleted file mode 100644 index 028e23395..000000000 --- a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-ssh-key-management.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: SSH key and access management -status: backlog -created: 2026-02-15 -updated: 2026-02-15 ---- - -## Objective - -Add SSH authorized key management. Appliances typically use SSH for emergency -access, and managing keys programmatically avoids manual file editing. - -## API Endpoints - -``` -GET /ssh/key/{user} - List authorized keys for user -POST /ssh/key/{user} - Add authorized key -DELETE /ssh/key/{user}/{fingerprint} - Remove authorized key - -GET /ssh/config - Get SSH server config summary -PUT /ssh/config - Update SSH server settings -``` - -## Operations - -- `ssh.key.list.get` (query) -- `ssh.key.add.execute`, `ssh.key.remove.execute` (modify) -- `ssh.config.get` (query) -- `ssh.config.update` (modify) - -## Provider - -- `internal/provider/security/ssh/` -- Manage `~/.ssh/authorized_keys` files -- Parse and manage `/etc/ssh/sshd_config` (port, auth methods, etc.) - -## Notes - -- SSH key changes are security-sensitive -- Scopes: `ssh:read`, `ssh:write` -- sshd_config changes require service restart — coordinate with service - management feature diff --git a/docs/docs/sidebar/features/user-management.md b/docs/docs/sidebar/features/user-management.md index beb4f9fa2..3bd0ede5b 100644 --- a/docs/docs/sidebar/features/user-management.md +++ b/docs/docs/sidebar/features/user-management.md @@ -28,6 +28,20 @@ User operations manage local accounts: - **Password** -- change a user's password (plaintext input, hashed by the agent) +### SSH Keys + +SSH key operations manage the `~/.ssh/authorized_keys` file for a given user: + +- **ListKeys** -- enumerate all authorized keys with type, fingerprint, and + comment +- **AddKey** -- append a public key to the authorized_keys file (idempotent -- + duplicate keys are not added) +- **RemoveKey** -- remove a key by its SHA256 fingerprint + +The provider reads and writes the user's `~/.ssh/authorized_keys` file directly. +It creates the `~/.ssh` directory and `authorized_keys` file with correct +permissions (`700` and `600`) if they do not exist. + ### Groups Group operations manage local groups: @@ -64,6 +78,21 @@ $ osapi client node user password --target web-01 \ $ osapi client node user delete --target web-01 --name deploy ``` +### SSH Keys + +```bash +# List SSH keys for a user +$ osapi client node user ssh-key list --target web-01 --name deploy + +# Add an SSH key +$ osapi client node user ssh-key add --target web-01 \ + --name deploy --key 'ssh-ed25519 AAAA... user@laptop' + +# Remove an SSH key by fingerprint +$ osapi client node user ssh-key remove --target web-01 \ + --name deploy --fingerprint 'SHA256:abc123...' +``` + ### Groups ```bash @@ -99,12 +128,14 @@ $ osapi client node user create --target group:web \ ## Permissions -| Operation | Permission | -| --------------- | ------------ | -| User list/get | `user:read` | -| User mutations | `user:write` | -| Group list/get | `user:read` | -| Group mutations | `user:write` | +| Operation | Permission | +| ------------------ | ------------ | +| User list/get | `user:read` | +| User mutations | `user:write` | +| SSH key list | `user:read` | +| SSH key add/remove | `user:write` | +| Group list/get | `user:read` | +| Group mutations | `user:write` | ## Platform Support @@ -120,6 +151,7 @@ message. ## Further Reading - [CLI Reference -- User](../usage/cli/client/node/user/user.md) +- [CLI Reference -- SSH Key](../usage/cli/client/node/user/ssh-key.md) - [CLI Reference -- Group](../usage/cli/client/node/group/group.md) - [SDK -- User](../sdk/client/management/user.md) - [SDK -- Group](../sdk/client/management/group.md) diff --git a/docs/docs/sidebar/sdk/client/management/user.md b/docs/docs/sidebar/sdk/client/management/user.md index 6e85a2c28..77c621c64 100644 --- a/docs/docs/sidebar/sdk/client/management/user.md +++ b/docs/docs/sidebar/sdk/client/management/user.md @@ -8,14 +8,17 @@ User account management on target hosts. ## Methods -| Method | Description | -| ----------------------------------------------- | ------------------------ | -| `List(ctx, hostname)` | List all user accounts | -| `Get(ctx, hostname, name)` | Get a user by name | -| `Create(ctx, hostname, opts)` | Create a user account | -| `Update(ctx, hostname, name, opts)` | Update a user account | -| `Delete(ctx, hostname, name)` | Delete a user account | -| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | +| Method | Description | +| ----------------------------------------------- | -------------------------------- | +| `List(ctx, hostname)` | List all user accounts | +| `Get(ctx, hostname, name)` | Get a user by name | +| `Create(ctx, hostname, opts)` | Create a user account | +| `Update(ctx, hostname, name, opts)` | Update a user account | +| `Delete(ctx, hostname, name)` | Delete a user account | +| `ChangePassword(ctx, hostname, name, password)` | Change a user's password | +| `ListKeys(ctx, hostname, name)` | List SSH authorized keys | +| `AddKey(ctx, hostname, name, opts)` | Add an SSH authorized key | +| `RemoveKey(ctx, hostname, name, fingerprint)` | Remove an SSH key by fingerprint | ## Usage @@ -53,6 +56,22 @@ resp, err := c.User.ChangePassword(ctx, "web-01", "deploy", "newpass123") // Delete a user resp, err := c.User.Delete(ctx, "web-01", "deploy") + +// List SSH keys for a user +keysResp, err := c.User.ListKeys(ctx, "web-01", "deploy") +for _, r := range keysResp.Data.Results { + for _, k := range r.Keys { + fmt.Printf("%s %s %s\n", k.Type, k.Fingerprint, k.Comment) + } +} + +// Add an SSH key +addResp, err := c.User.AddKey(ctx, "web-01", "deploy", client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAA... user@laptop", +}) + +// Remove an SSH key by fingerprint +removeResp, err := c.User.RemoveKey(ctx, "web-01", "deploy", "SHA256:abc123...") ``` ## Example @@ -61,7 +80,41 @@ See [`examples/sdk/client/user.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/user.go) for a complete working example. +## SSH Key Types + +### `SSHKeyInfoResult` + +| Field | Type | Description | +| ---------- | -------------- | ----------------------- | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | +| `Keys` | `[]SSHKeyInfo` | List of authorized keys | +| `Error` | `string` | Error message (if any) | + +### `SSHKeyInfo` + +| Field | Type | Description | +| ------------- | -------- | ------------------------------ | +| `Type` | `string` | Key type (e.g., `ssh-ed25519`) | +| `Fingerprint` | `string` | SHA256 fingerprint | +| `Comment` | `string` | Key comment | + +### `SSHKeyMutationResult` + +| Field | Type | Description | +| ---------- | -------- | ----------------------------------- | +| `Hostname` | `string` | Target hostname | +| `Status` | `string` | Operation status | +| `Changed` | `bool` | Whether the operation changed state | +| `Error` | `string` | Error message (if any) | + +### `SSHKeyAddOpts` + +| Field | Type | Description | +| ----- | -------- | ------------------------------------------------------ | +| `Key` | `string` | Full SSH public key line (e.g., `ssh-ed25519 AAAA...`) | + ## Permissions -Requires `user:read` for List and Get. Create, Update, Delete, and -ChangePassword require `user:write`. +Requires `user:read` for List, Get, and ListKeys. Create, Update, Delete, +ChangePassword, AddKey, and RemoveKey require `user:write`. diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md new file mode 100644 index 000000000..f8d3ed5c6 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md @@ -0,0 +1,24 @@ +# Add + +Add an SSH authorized key for a user: + +```bash +$ osapi client node user ssh-key add --target web-01 \ + --name deploy --key 'ssh-ed25519 AAAA... user@laptop' + + HOSTNAME CHANGED STATUS + web-01 true ok +``` + +The key is appended to the user's `~/.ssh/authorized_keys` file. If the file or +`~/.ssh` directory does not exist, it is created with correct permissions (`700` +for the directory, `600` for the file). Duplicate keys are not added. + +## Flags + +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to add SSH key for (required) | | +| `--key` | Full SSH public key line (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md new file mode 100644 index 000000000..c99d669ec --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md @@ -0,0 +1,28 @@ +# List + +List SSH authorized keys for a user: + +```bash +$ osapi client node user ssh-key list --target web-01 --name deploy + + HOSTNAME TYPE FINGERPRINT COMMENT STATUS + web-01 ssh-ed25519 SHA256:abc123... user@laptop ok + web-01 ssh-rsa SHA256:def456... deploy-ci ok +``` + +## JSON Output + +Use `--json` to get the full API response: + +```bash +$ osapi client node user ssh-key list --target web-01 --name deploy --json +{"results":[{"hostname":"web-01","keys":[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123...","comment":"user@laptop"}],"status":"ok"}],"job_id":"..."} +``` + +## Flags + +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to list SSH keys for (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md new file mode 100644 index 000000000..631684db2 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md @@ -0,0 +1,24 @@ +# Remove + +Remove an SSH authorized key by fingerprint: + +```bash +$ osapi client node user ssh-key remove --target web-01 \ + --name deploy --fingerprint 'SHA256:abc123...' + + HOSTNAME CHANGED STATUS + web-01 true ok +``` + +The key matching the given SHA256 fingerprint is removed from the user's +`~/.ssh/authorized_keys` file. Returns `changed: false` if the fingerprint is +not found. + +## Flags + +| Flag | Description | Default | +| --------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_all` | +| `--name` | Username to remove SSH key from (required) | | +| `--fingerprint` | SHA256 fingerprint of the key to remove (required) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md new file mode 100644 index 000000000..aaf5b99d4 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md @@ -0,0 +1,5 @@ +# SSH Key + +Manage SSH authorized keys for user accounts on target hosts. + + diff --git a/docs/plans/2026-04-01-ssh-key-management-provider-design.md b/docs/plans/2026-04-01-ssh-key-management-provider-design.md new file mode 100644 index 000000000..8744bc569 --- /dev/null +++ b/docs/plans/2026-04-01-ssh-key-management-provider-design.md @@ -0,0 +1,158 @@ +# SSH Key Management Provider Design + +## Overview + +Add SSH authorized key management to OSAPI. List, add, and remove SSH public +keys in a user's `~/.ssh/authorized_keys` file. Extends the existing user +provider — no new provider package or permissions. Manages any key regardless of +who added it. + +## Architecture + +Extends the existing user provider at `internal/provider/node/user/`. + +- **Category**: `node` +- **Path prefix**: `/node/{hostname}/user/{name}/ssh-key` +- **Permissions**: `user:read` (list), `user:write` (add, remove) +- **Provider type**: direct-write (avfs.VFS) + +No state tracking, no file.Deployer. The provider reads and writes +`authorized_keys` directly. The orchestrator is responsible for desired-state +management. + +## Provider Interface Additions + +Added to the existing `user.Provider` interface: + +```go +ListKeys(ctx context.Context, username string) ([]SSHKey, error) +AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) +RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) +``` + +## Data Types + +```go +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` +} + +type SSHKeyResult struct { + Changed bool `json:"changed"` +} +``` + +## Debian Implementation + +The provider resolves the user's home directory from `/etc/passwd` (already +parsed by the user provider), then operates on `~/.ssh/authorized_keys`. + +- **ListKeys**: Read `authorized_keys`, parse each non-empty, non-comment line + into type + base64 key + comment. Compute SHA256 fingerprint from decoded key + bytes. Return all entries. +- **AddKey**: Check if key already exists by fingerprint. If present, return + `changed: false`. Otherwise append the raw public key line. Create `~/.ssh/` + (mode `0700`) and `authorized_keys` (mode `0600`) if they don't exist. Set + ownership to the target user via `exec.Manager` (`chown user:user`). +- **RemoveKey**: Read file, filter out the line matching the fingerprint, + rewrite file. Return `changed: false` if fingerprint not found. Return error + if file doesn't exist. + +## Platform Implementations + +| Platform | Implementation | +| -------- | ---------------------- | +| Debian | Direct file read/write | +| Darwin | ErrUnsupported | +| Linux | ErrUnsupported | + +## Container Behavior + +No container check — SSH key management works in containers. + +## API Endpoints + +| Method | Path | Permission | Description | +| -------- | ---------------------------------------------------- | ------------ | -------------------- | +| `GET` | `/node/{hostname}/user/{name}/ssh-key` | `user:read` | List authorized keys | +| `POST` | `/node/{hostname}/user/{name}/ssh-key` | `user:write` | Add a key | +| `DELETE` | `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | `user:write` | Remove a key | + +All endpoints support broadcast targeting. + +### POST Request Body + +```json +{ + "key": "ssh-ed25519 AAAA... user@host" +} +``` + +The full public key line as it would appear in `authorized_keys`. + +### Response Shape (List) + +```json +{ + "job_id": "...", + "results": [ + { + "hostname": "web-01", + "status": "ok", + "keys": [ + { + "type": "ssh-ed25519", + "fingerprint": "SHA256:abc123...", + "comment": "john@laptop" + } + ] + } + ] +} +``` + +### Response Shape (Add/Remove) + +```json +{ + "job_id": "...", + "results": [ + { + "hostname": "web-01", + "status": "ok", + "changed": true + } + ] +} +``` + +## SDK + +```go +client.User.ListKeys(ctx, host, username) +client.User.AddKey(ctx, host, username, opts) +client.User.RemoveKey(ctx, host, username, fingerprint) +``` + +`SSHKeyAddOpts` struct with `Key` field (the full public key string). + +## CLI + +```bash +osapi client node user ssh-key list --target web-01 --name john +osapi client node user ssh-key add --target web-01 --name john \ + --key "ssh-ed25519 AAAA... john@laptop" +osapi client node user ssh-key remove --target web-01 --name john \ + --fingerprint "SHA256:abc123..." +``` + +## Permissions + +Reuses existing permissions — no new permissions needed. + +- `user:read` — list keys +- `user:write` — add and remove keys + +These are already in all built-in roles. diff --git a/docs/plans/2026-04-01-ssh-key-management-provider.md b/docs/plans/2026-04-01-ssh-key-management-provider.md new file mode 100644 index 000000000..3c49f5931 --- /dev/null +++ b/docs/plans/2026-04-01-ssh-key-management-provider.md @@ -0,0 +1,1223 @@ +# SSH Key Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add SSH authorized key management to OSAPI — list, add, and remove SSH +public keys in a user's `~/.ssh/authorized_keys` file by extending the existing +user provider. + +**Architecture:** Extends `internal/provider/node/user/` with three new methods +(ListKeys, AddKey, RemoveKey). New SSH key endpoints added to the existing user +OpenAPI spec. Operations dispatched via a new `sshKey` case in the node +processor. Reuses existing `user:read`/`user:write` permissions. No new provider +package, agent category, or permissions needed. + +**Tech Stack:** Go, avfs.VFS, crypto/sha256 for fingerprints, encoding/base64 +for key decoding, oapi-codegen strict-server + +--- + +## File Structure + +### Provider Layer + +- Modify: `internal/provider/node/user/types.go` — add SSHKey, SSHKeyResult + types + 3 methods to Provider interface +- Create: `internal/provider/node/user/debian_ssh_key.go` — Debian + implementation (list/add/remove authorized_keys) +- Modify: `internal/provider/node/user/darwin.go` — add 3 stub methods +- Modify: `internal/provider/node/user/linux.go` — add 3 stub methods +- Test: `internal/provider/node/user/debian_ssh_key_public_test.go` +- Modify: `internal/provider/node/user/darwin_public_test.go` — add stub tests +- Modify: `internal/provider/node/user/linux_public_test.go` — add stub tests + +### Agent Layer + +- Create: `internal/agent/processor_ssh_key.go` — SSH key operation dispatcher +- Modify: `internal/agent/processor.go` — add `sshKey` case to NewNodeProcessor +- Test: `internal/agent/processor_ssh_key_public_test.go` + +### Operations + +- Modify: `pkg/sdk/client/operations.go` — add SSH key operation constants +- Modify: `internal/job/types.go` — add SSH key operation aliases + +### API Layer + +- Modify: `internal/controller/api/node/user/gen/api.yaml` — add 3 ssh-key + endpoints + schemas +- Create: `internal/controller/api/node/user/ssh_key_list_get.go` — list handler +- Create: `internal/controller/api/node/user/ssh_key_add_post.go` — add handler +- Create: `internal/controller/api/node/user/ssh_key_remove_delete.go` — remove + handler +- Test: `internal/controller/api/node/user/ssh_key_list_get_public_test.go` +- Test: `internal/controller/api/node/user/ssh_key_add_post_public_test.go` +- Test: `internal/controller/api/node/user/ssh_key_remove_delete_public_test.go` + +### SDK Layer + +- Modify: `pkg/sdk/client/user.go` — add ListKeys, AddKey, RemoveKey methods +- Modify: `pkg/sdk/client/user_types.go` — add SSHKey result types + conversions +- Modify: `pkg/sdk/client/user_public_test.go` — add tests +- Modify: `pkg/sdk/client/user_types_public_test.go` — add conversion tests + +### CLI Layer + +- Create: `cmd/client_node_user_ssh_key.go` — parent command +- Create: `cmd/client_node_user_ssh_key_list.go` — list subcommand +- Create: `cmd/client_node_user_ssh_key_add.go` — add subcommand +- Create: `cmd/client_node_user_ssh_key_remove.go` — remove subcommand + +### Documentation + +- Modify: `docs/docs/sidebar/features/user-management.md` — add SSH key section +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key.md` — CLI + landing +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-list.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-add.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/user/ssh-key-remove.md` +- Modify: `docs/docs/sidebar/sdk/client/management/user.md` — add SSH key + methods +- Modify: `examples/sdk/client/user.go` — add SSH key demo +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add endpoints + +--- + +### Task 1: Provider Types and Stubs + +**Files:** + +- Modify: `internal/provider/node/user/types.go` +- Modify: `internal/provider/node/user/darwin.go` +- Modify: `internal/provider/node/user/linux.go` +- Modify: `internal/provider/node/user/darwin_public_test.go` +- Modify: `internal/provider/node/user/linux_public_test.go` + +- [ ] **Step 1: Add types to types.go** + +Add to `internal/provider/node/user/types.go`: + +```go +// SSHKey represents an SSH authorized key entry. +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` +} + +// SSHKeyResult represents the result of an SSH key mutation. +type SSHKeyResult struct { + Changed bool `json:"changed"` +} +``` + +Add 3 methods to the Provider interface: + +```go + // ListKeys returns SSH authorized keys for a user. + ListKeys(ctx context.Context, username string) ([]SSHKey, error) + // AddKey adds an SSH authorized key for a user. + AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) + // RemoveKey removes an SSH authorized key by fingerprint. + RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) +``` + +- [ ] **Step 2: Add stub methods to darwin.go and linux.go** + +Add to both `darwin.go` and `linux.go`: + +```go +// ListKeys returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on Darwin/Linux. +func (d *Darwin) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} +``` + +(Same for Linux struct.) + +- [ ] **Step 3: Add stub tests** + +Add test cases to the existing test tables in `darwin_public_test.go` and +`linux_public_test.go` for ListKeys, AddKey, and RemoveKey all returning +ErrUnsupported. + +- [ ] **Step 4: Regenerate mocks** + +Run: `go generate ./internal/provider/node/user/mocks/...` + +- [ ] **Step 5: Run tests** + +Run: `go test -v ./internal/provider/node/user/...` Expected: all pass, new stub +tests included + +- [ ] **Step 6: Commit** + +```bash +git add internal/provider/node/user/ +git commit -m "feat(user): add SSH key types and platform stubs" +``` + +--- + +### Task 2: Debian SSH Key Implementation + +**Files:** + +- Create: `internal/provider/node/user/debian_ssh_key.go` +- Test: `internal/provider/node/user/debian_ssh_key_public_test.go` + +- [ ] **Step 1: Write tests** + +Create `debian_ssh_key_public_test.go` with testify/suite. Use `memfs.New()` for +filesystem and gomock for exec.Manager. + +Set up a memfs with `/etc/passwd` containing: + +``` +root:x:0:0:root:/root:/bin/bash +john:x:1000:1000:John:/home/john:/bin/bash +``` + +**TestListKeys** — table-driven: + +- success (authorized_keys with 2 keys, verify type + fingerprint + - comment) +- user not found in /etc/passwd → error +- no authorized_keys file → empty list, no error +- empty authorized_keys → empty list +- lines with comments and blank lines skipped +- malformed key line skipped (logged as debug) + +**TestAddKey** — table-driven: + +- success (creates .ssh dir + file, appends key) +- key already exists (same fingerprint) → changed: false +- user not found → error +- creates .ssh dir with 0700 if missing +- creates authorized_keys with 0600 if missing +- appends to existing file + +**TestRemoveKey** — table-driven: + +- success (rewrites file without matching key) +- fingerprint not found → changed: false +- user not found → error +- no authorized_keys file → changed: false +- file becomes empty after removal (still valid) + +- [ ] **Step 2: Implement debian_ssh_key.go** + +```go +package user + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "log/slog" + "strings" +) + +// ListKeys returns SSH authorized keys for a user. +func (d *Debian) ListKeys( + _ context.Context, + username string, +) ([]SSHKey, error) { + d.logger.Debug("executing user.ListKeys", + slog.String("username", username), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: list: %w", err) + } + + authKeysPath := home + "/.ssh/authorized_keys" + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + // No file = no keys, not an error. + return []SSHKey{}, nil + } + + return parseAuthorizedKeys(string(content), d.logger), nil +} + +// AddKey adds an SSH authorized key for a user. +func (d *Debian) AddKey( + _ context.Context, + username string, + key SSHKey, +) (*SSHKeyResult, error) { + d.logger.Debug("executing user.AddKey", + slog.String("username", username), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: add: %w", err) + } + + sshDir := home + "/.ssh" + authKeysPath := sshDir + "/authorized_keys" + + // Ensure .ssh directory exists. + if err := d.fs.MkdirAll(sshDir, 0o700); err != nil { + return nil, fmt.Errorf( + "ssh key: create .ssh dir: %w", err) + } + + // Read existing keys to check for duplicates. + existing, _ := d.fs.ReadFile(authKeysPath) + existingKeys := parseAuthorizedKeys( + string(existing), d.logger) + + for _, ek := range existingKeys { + if ek.Fingerprint == key.Fingerprint { + return &SSHKeyResult{Changed: false}, nil + } + } + + // Build the key line from the SSHKey fields. + keyLine := key.Type + " " + + base64.StdEncoding.EncodeToString(/* raw key bytes */) + // Actually, the API receives the full key line in a + // dedicated field. See the AddKey handler — it passes + // the raw key line. The provider should store the raw + // public key line. + + // Append key to file. + f, err := d.fs.OpenFile( + authKeysPath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0o600, + ) + if err != nil { + return nil, fmt.Errorf( + "ssh key: open authorized_keys: %w", err) + } + defer f.Close() + + if _, err := f.Write( + []byte(key.RawLine + "\n"), + ); err != nil { + return nil, fmt.Errorf( + "ssh key: write key: %w", err) + } + + // Set ownership. + if _, err := d.execManager.RunCmd( + "chown", + []string{"-R", username + ":" + username, sshDir}, + ); err != nil { + d.logger.Warn("failed to set .ssh ownership", + slog.String("error", err.Error()), + ) + } + + return &SSHKeyResult{Changed: true}, nil +} + +// RemoveKey removes an SSH authorized key by fingerprint. +func (d *Debian) RemoveKey( + _ context.Context, + username string, + fingerprint string, +) (*SSHKeyResult, error) { + d.logger.Debug("executing user.RemoveKey", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + home, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: remove: %w", err) + } + + authKeysPath := home + "/.ssh/authorized_keys" + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + return &SSHKeyResult{Changed: false}, nil + } + + lines := strings.Split(string(content), "\n") + var newLines []string + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + newLines = append(newLines, line) + continue + } + + fp := fingerprintFromLine(trimmed) + if fp == fingerprint { + found = true + continue // skip this line + } + newLines = append(newLines, line) + } + + if !found { + return &SSHKeyResult{Changed: false}, nil + } + + newContent := strings.Join(newLines, "\n") + if err := d.fs.WriteFile( + authKeysPath, []byte(newContent), 0o600, + ); err != nil { + return nil, fmt.Errorf( + "ssh key: write authorized_keys: %w", err) + } + + return &SSHKeyResult{Changed: true}, nil +} + +// userHomeDir resolves a user's home directory from +// /etc/passwd. +func (d *Debian) userHomeDir( + username string, +) (string, error) { + content, err := d.fs.ReadFile("/etc/passwd") + if err != nil { + return "", fmt.Errorf( + "read /etc/passwd: %w", err) + } + + for _, line := range strings.Split( + string(content), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 6 && fields[0] == username { + return fields[5], nil + } + } + + return "", fmt.Errorf("user %q not found", username) +} + +// parseAuthorizedKeys parses an authorized_keys file content +// into SSHKey entries. +func parseAuthorizedKeys( + content string, + logger *slog.Logger, +) []SSHKey { + var keys []SSHKey + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + logger.Debug("skipping malformed key line", + slog.String("line", line), + ) + continue + } + + keyType := parts[0] + keyData := parts[1] + comment := "" + if len(parts) >= 3 { + comment = strings.Join(parts[2:], " ") + } + + fp := computeFingerprint(keyData) + if fp == "" { + logger.Debug("skipping key with invalid base64", + slog.String("line", line), + ) + continue + } + + keys = append(keys, SSHKey{ + Type: keyType, + Fingerprint: fp, + Comment: comment, + }) + } + + return keys +} + +// computeFingerprint computes SHA256 fingerprint from base64- +// encoded key data. +func computeFingerprint( + keyData string, +) string { + decoded, err := base64.StdEncoding.DecodeString(keyData) + if err != nil { + return "" + } + + hash := sha256.Sum256(decoded) + + return "SHA256:" + + base64.RawStdEncoding.EncodeToString(hash[:]) +} + +// fingerprintFromLine extracts fingerprint from a key line. +func fingerprintFromLine( + line string, +) string { + parts := strings.Fields(line) + if len(parts) < 2 { + return "" + } + + return computeFingerprint(parts[1]) +} +``` + +**IMPORTANT**: The SSHKey type needs a `RawLine` field to store the full public +key string for AddKey. Update the types: + +```go +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` + RawLine string `json:"raw_line,omitempty"` +} +``` + +The API handler populates `RawLine` from the POST body's `key` field. The +provider uses `RawLine` to append to `authorized_keys`. ListKeys does NOT +populate `RawLine` (we don't expose raw key data in list responses — just type, +fingerprint, comment). + +- [ ] **Step 3: Run tests** + +Run: `go test -v ./internal/provider/node/user/...` Expected: all pass + +- [ ] **Step 4: Verify 100% coverage on new file** + +```bash +go test -coverprofile=/tmp/ssh.cov \ + ./internal/provider/node/user/... && \ + go tool cover -func=/tmp/ssh.cov | \ + grep "debian_ssh_key" +``` + +All functions must be 100%. + +- [ ] **Step 5: Commit** + +```bash +git add internal/provider/node/user/ +git commit -m "feat(user): add SSH key management to debian provider" +``` + +--- + +### Task 3: Operations and Agent Processor + +**Files:** + +- Modify: `pkg/sdk/client/operations.go` +- Modify: `internal/job/types.go` +- Create: `internal/agent/processor_ssh_key.go` +- Modify: `internal/agent/processor.go` — add `sshKey` case +- Test: `internal/agent/processor_ssh_key_public_test.go` + +- [ ] **Step 1: Add operation constants** + +In `pkg/sdk/client/operations.go`, add after User operations: + +```go +// SSH Key operations. +const ( + OpSSHKeyList JobOperation = "node.sshKey.list" + OpSSHKeyAdd JobOperation = "node.sshKey.add" + OpSSHKeyRemove JobOperation = "node.sshKey.remove" +) +``` + +In `internal/job/types.go`, add corresponding aliases: + +```go +// SSH Key operations. +const ( + OperationSSHKeyList = client.OpSSHKeyList + OperationSSHKeyAdd = client.OpSSHKeyAdd + OperationSSHKeyRemove = client.OpSSHKeyRemove +) +``` + +- [ ] **Step 2: Write processor tests** + +Create `internal/agent/processor_ssh_key_public_test.go`. The processor +dispatches to the existing `userProvider` (same as user/group operations). Test +via `NewNodeProcessor`. + +**TestProcessSSHKeyOperation** — dispatch-level table: + +- nil user provider → error +- invalid operation format +- unsupported sub-operation + +**TestProcessSSHKeyList** — table-driven: + +- success (returns keys) +- unmarshal error (invalid JSON) +- provider error + +**TestProcessSSHKeyAdd** — table-driven: + +- success +- unmarshal error +- provider error + +**TestProcessSSHKeyRemove** — table-driven: + +- success +- unmarshal error +- provider error + +One suite method per function, ALL scenarios as table rows. + +- [ ] **Step 3: Implement processor_ssh_key.go** + +```go +func processSshKeyOperation( + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if userProvider == nil { + return nil, fmt.Errorf( + "user provider not available") + } + + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf( + "invalid sshKey operation: %s", + jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "list": + return processSshKeyList( + ctx, userProvider, logger, jobRequest) + case "add": + return processSshKeyAdd( + ctx, userProvider, logger, jobRequest) + case "remove": + return processSshKeyRemove( + ctx, userProvider, logger, jobRequest) + default: + return nil, fmt.Errorf( + "unsupported sshKey operation: %s", + jobRequest.Operation) + } +} +``` + +Each sub-handler unmarshals username (and key data for add, fingerprint for +remove) from `jobRequest.Data`, calls the provider, and marshals the result. + +- [ ] **Step 4: Wire into node processor** + +In `internal/agent/processor.go`, add case to the `NewNodeProcessor` switch: + +```go + case "sshKey": + return processSshKeyOperation( + userProvider, logger, req) +``` + +- [ ] **Step 5: Run tests and verify coverage** + +```bash +go test -v ./internal/agent/... +go build ./... +go test -coverprofile=/tmp/ssh_proc.cov \ + ./internal/agent/... && \ + go tool cover -func=/tmp/ssh_proc.cov | \ + grep "processor_ssh_key" +``` + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sdk/client/operations.go \ + internal/job/types.go \ + internal/agent/processor_ssh_key.go \ + internal/agent/processor_ssh_key_public_test.go \ + internal/agent/processor.go +git commit -m "feat(user): add SSH key operations and agent processor" +``` + +--- + +### Task 4: OpenAPI Spec Update and Code Generation + +**Files:** + +- Modify: `internal/controller/api/node/user/gen/api.yaml` + +- [ ] **Step 1: Add endpoints to existing user OpenAPI spec** + +Add to `internal/controller/api/node/user/gen/api.yaml` after the password +endpoint section: + +```yaml +# -- SSH Key management ------------------------------------------------ + +/node/{hostname}/user/{name}/ssh-key: + get: + summary: List SSH authorized keys + description: > + List SSH authorized keys for a user on the target node. + tags: + - user_operations + operationId: GetNodeUserSshKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': ... + '403': ... + '500': ... + + post: + summary: Add SSH authorized key + description: > + Add an SSH authorized key for a user on the target node. + tags: + - user_operations + operationId: PostNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': ... + '401': ... + '403': ... + '500': ... + +/node/{hostname}/user/{name}/ssh-key/{fingerprint}: + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint. + tags: + - user_operations + operationId: DeleteNodeUserSshKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': ... + '403': ... + '500': ... +``` + +Add schemas: + +```yaml +SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: > + Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + +SSHKeyInfo: + type: object + properties: + type: + type: string + example: 'ssh-ed25519' + fingerprint: + type: string + example: 'SHA256:abc123...' + comment: + type: string + example: 'john@laptop' + +SSHKeyEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + keys: + type: array + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + required: + - hostname + - status + +SSHKeyMutationEntry: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed, skipped] + changed: + type: boolean + error: + type: string + required: + - hostname + - status + +SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + +SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results +``` + +Add parameter: + +```yaml +SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: SSH key SHA256 fingerprint. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 +``` + +- [ ] **Step 2: Generate code and rebuild** + +```bash +go generate ./internal/controller/api/node/user/gen/... +just generate +go build ./... +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/controller/api/node/user/gen/ \ + internal/controller/api/gen/ \ + pkg/sdk/client/gen/ +git commit -m "feat(user): add SSH key endpoints to OpenAPI spec" +``` + +--- + +### Task 5: API Handler Implementation + +**Files:** + +- Create: `internal/controller/api/node/user/ssh_key_list_get.go` +- Create: `internal/controller/api/node/user/ssh_key_add_post.go` +- Create: `internal/controller/api/node/user/ssh_key_remove_delete.go` +- Test: all 3 `*_public_test.go` files + +- [ ] **Step 1: Implement list handler** + +`GetNodeUserSshKey` method on the existing `User` handler struct: + +- Validate hostname +- username from `request.Name` +- Query with category `"node"`, operation `job.OperationSSHKeyList`, data + `{"username": username}` +- Parse response: unmarshal `[]userProv.SSHKey`, convert to `[]gen.SSHKeyInfo` +- Broadcast support + +- [ ] **Step 2: Implement add handler** + +`PostNodeUserSshKey`: + +- Validate hostname, body (`key` field) +- Parse the raw key line to extract type, fingerprint, comment +- Build `userProv.SSHKey{Type, Fingerprint, Comment, RawLine}` +- Modify with `job.OperationSSHKeyAdd`, data includes `username` + the SSHKey + struct +- Parse mutation response + +- [ ] **Step 3: Implement remove handler** + +`DeleteNodeUserSshKey`: + +- Validate hostname +- fingerprint from `request.Fingerprint` +- Modify with `job.OperationSSHKeyRemove`, data + `{"username": username, "fingerprint": fingerprint}` +- Parse mutation response + +- [ ] **Step 4: Write tests** + +Each handler test file needs: success, skipped, broadcast, validation error, job +error, HTTP wiring, RBAC (401/403/200). One suite method per handler, all +scenarios as table rows. + +- [ ] **Step 5: Run tests and verify coverage** + +```bash +go test -v ./internal/controller/api/node/user/... +go test -coverprofile=/tmp/ssh_h.cov \ + ./internal/controller/api/node/user/... && \ + go tool cover -func=/tmp/ssh_h.cov | \ + grep "ssh_key" | grep -v "100.0%" +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/controller/api/node/user/ +git commit -m "feat(user): add SSH key API handlers with broadcast support" +``` + +--- + +### Task 6: SDK Service Extension + +**Files:** + +- Modify: `pkg/sdk/client/user.go` +- Modify: `pkg/sdk/client/user_types.go` +- Modify: `pkg/sdk/client/user_public_test.go` +- Modify: `pkg/sdk/client/user_types_public_test.go` + +- [ ] **Step 1: Add types** + +In `user_types.go`, add: + +```go +type SSHKeyInfoResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Keys []SSHKeyInfo `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +type SSHKeyInfo struct { + Type string `json:"type,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comment string `json:"comment,omitempty"` +} + +type SSHKeyMutationResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` +} + +type SSHKeyAddOpts struct { + Key string +} +``` + +Add conversion functions. + +- [ ] **Step 2: Add methods to UserService** + +In `user.go`, add: + +```go +func (s *UserService) ListKeys( + ctx context.Context, + hostname string, + username string, +) (*Response[Collection[SSHKeyInfoResult]], error) + +func (s *UserService) AddKey( + ctx context.Context, + hostname string, + username string, + opts SSHKeyAddOpts, +) (*Response[Collection[SSHKeyMutationResult]], error) + +func (s *UserService) RemoveKey( + ctx context.Context, + hostname string, + username string, + fingerprint string, +) (*Response[Collection[SSHKeyMutationResult]], error) +``` + +- [ ] **Step 3: Regenerate SDK client** + +```bash +go generate ./pkg/sdk/client/gen/... +``` + +- [ ] **Step 4: Write tests** + +Add tests to existing test files (or create new `user_ssh_key_public_test.go` / +`user_ssh_key_types_public_test.go` if the existing files are already large). +Follow existing patterns with httptest.Server. + +- [ ] **Step 5: Verify 100% coverage** + +```bash +go test -coverprofile=/tmp/ssh_sdk.cov \ + ./pkg/sdk/client/... && \ + go tool cover -func=/tmp/ssh_sdk.cov | \ + grep "user" | grep -v "100.0%" +``` + +- [ ] **Step 6: Commit** + +```bash +git add pkg/sdk/client/ +git commit -m "feat(user): add SSH key SDK methods with tests" +``` + +--- + +### Task 7: CLI Commands + +**Files:** + +- Create: `cmd/client_node_user_ssh_key.go` +- Create: `cmd/client_node_user_ssh_key_list.go` +- Create: `cmd/client_node_user_ssh_key_add.go` +- Create: `cmd/client_node_user_ssh_key_remove.go` + +- [ ] **Step 1: Create parent command** + +```go +var clientNodeUserSshKeyCmd = &cobra.Command{ + Use: "ssh-key", + Short: "Manage SSH authorized keys", +} + +func init() { + clientNodeUserCmd.AddCommand(clientNodeUserSshKeyCmd) +} +``` + +Wait — check whether `clientNodeUserCmd` exists. Look at +`cmd/client_node_user.go` for the parent. + +- [ ] **Step 2: Create list subcommand** + +Flags: `--name` (username, required) + +- Calls `sdkClient.User.ListKeys(ctx, host, name)` +- Table headers: `TYPE`, `FINGERPRINT`, `COMMENT` +- Uses `BuildBroadcastTable` + +- [ ] **Step 3: Create add subcommand** + +Flags: `--name` (required), `--key` (required, full public key line) + +- Calls `sdkClient.User.AddKey(ctx, host, name, opts)` +- Uses `BuildMutationTable` with headers `CHANGED` + +- [ ] **Step 4: Create remove subcommand** + +Flags: `--name` (required), `--fingerprint` (required) + +- Calls `sdkClient.User.RemoveKey(ctx, host, name, fp)` +- Uses `BuildMutationTable` + +- [ ] **Step 5: Verify build** + +```bash +go build ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add cmd/client_node_user_ssh_key*.go +git commit -m "feat(user): add SSH key CLI commands" +``` + +--- + +### Task 8: Documentation + +**Files:** + +- Modify: `docs/docs/sidebar/features/user-management.md` +- Create: CLI doc pages for ssh-key commands +- Modify: `docs/docs/sidebar/sdk/client/management/user.md` +- Modify: `examples/sdk/client/user.go` +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` + +- [ ] **Step 1: Update feature page** + +Add SSH Key Management section to +`docs/docs/sidebar/features/user-management.md`: + +- How It Works (list, add, remove) +- Add to Operations table +- Add CLI examples for ssh-key subcommands +- Note: uses existing `user:read`/`user:write` permissions + +- [ ] **Step 2: Create CLI doc pages** + +Create landing page + list.md, add.md, remove.md under +`docs/docs/sidebar/usage/cli/client/node/user/`. + +- [ ] **Step 3: Update SDK doc** + +Add ListKeys, AddKey, RemoveKey to the user SDK doc page with code examples and +result type tables. + +- [ ] **Step 4: Update SDK example** + +Add SSH key demo to `examples/sdk/client/user.go`. + +- [ ] **Step 5: Update api-guidelines** + +Add endpoint rows: + +``` +| `/node/{hostname}/user/{name}/ssh-key` | User | +| `/node/{hostname}/user/{name}/ssh-key/{fingerprint}` | User | +``` + +- [ ] **Step 6: Commit** + +```bash +git add docs/ examples/ +git commit -m "docs: add SSH key management to user docs and SDK example" +``` + +--- + +### Task 9: Integration Test and Final Verification + +**Files:** + +- Modify or create: `test/integration/user_test.go` (add SSH key tests) + +- [ ] **Step 1: Add integration test** + +Add SSH key list test to the existing user integration test file (or create new +if it doesn't exist). Test: + +- `osapi client node user ssh-key list --target _any --name root --json` + +- [ ] **Step 2: Run full suite** + +```bash +just generate +go build ./... +just go::unit +just go::vet +``` + +- [ ] **Step 3: Verify coverage** + +```bash +go test -coverprofile=/tmp/ssh_all.cov \ + ./internal/provider/node/user/... \ + ./internal/agent/... \ + ./internal/controller/api/node/user/... \ + ./pkg/sdk/client/... +go tool cover -func=/tmp/ssh_all.cov | \ + grep "ssh_key\|ssh_key" | grep -v "100.0%" | \ + grep -v "mocks\|gen/" +``` + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "chore(user): fix formatting and lint" +``` diff --git a/examples/sdk/client/user.go b/examples/sdk/client/user.go index 09b6f87c1..b6b871669 100644 --- a/examples/sdk/client/user.go +++ b/examples/sdk/client/user.go @@ -18,8 +18,9 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -// Package main demonstrates user account management: list, get, and create -// user accounts on managed hosts using the OSAPI SDK. +// Package main demonstrates user account management: list, get, create +// user accounts, and manage SSH authorized keys on managed hosts using +// the OSAPI SDK. // // Run with: OSAPI_TOKEN="" go run user.go package main @@ -99,4 +100,39 @@ func main() { r.Hostname, r.Name, r.Changed, r.Error) } } + + // List SSH authorized keys for root. + fmt.Println("\n=== Listing SSH keys for root ===") + keysResp, err := c.User.ListKeys(ctx, target, "root") + if err != nil { + log.Fatalf("list keys failed: %v", err) + } + for _, r := range keysResp.Data.Results { + if r.Error != "" { + fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error) + continue + } + if len(r.Keys) == 0 { + fmt.Printf(" %s: no authorized keys\n", r.Hostname) + continue + } + for _, k := range r.Keys { + fmt.Printf(" %s: type=%s fingerprint=%s comment=%s\n", + r.Hostname, k.Type, k.Fingerprint, k.Comment) + } + } + + // Add an SSH key (may fail on non-Debian platforms). + fmt.Println("\n=== Adding SSH key for testuser ===") + addResp, err := c.User.AddKey(ctx, target, "testuser", client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample test@example", + }) + if err != nil { + fmt.Printf("add key failed (may be unsupported on this platform): %v\n", err) + } else { + for _, r := range addResp.Data.Results { + fmt.Printf(" %s: changed=%v error=%s\n", + r.Hostname, r.Changed, r.Error) + } + } } diff --git a/internal/agent/processor.go b/internal/agent/processor.go index 66e9ca749..a2bdb62af 100644 --- a/internal/agent/processor.go +++ b/internal/agent/processor.go @@ -107,6 +107,8 @@ func NewNodeProcessor( return processUserOperation(userProvider, logger, req) case "group": return processGroupOperation(userProvider, logger, req) + case "sshKey": + return processSSHKeyOperation(userProvider, logger, req) case "package": return processPackageOperation(packageProvider, logger, req) case "log": diff --git a/internal/agent/processor_ssh_key.go b/internal/agent/processor_ssh_key.go new file mode 100644 index 000000000..df9368eb2 --- /dev/null +++ b/internal/agent/processor_ssh_key.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/user" +) + +// processSSHKeyOperation dispatches SSH key sub-operations. +func processSSHKeyOperation( + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if userProvider == nil { + return nil, fmt.Errorf("user provider not available") + } + + // Extract sub-operation: "sshKey.list" -> "list" + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid sshKey operation: %s", jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "list": + return processSSHKeyList(ctx, userProvider, logger, jobRequest) + case "add": + return processSSHKeyAdd(ctx, userProvider, logger, jobRequest) + case "remove": + return processSSHKeyRemove(ctx, userProvider, logger, jobRequest) + default: + return nil, fmt.Errorf("unsupported sshKey operation: %s", jobRequest.Operation) + } +} + +// processSSHKeyList lists SSH keys for a user. +func processSSHKeyList( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey list data: %w", err) + } + + logger.Debug("executing sshKey.List", + slog.String("username", data.Username), + ) + + keys, err := userProvider.ListKeys(ctx, data.Username) + if err != nil { + return nil, err + } + + return json.Marshal(keys) +} + +// processSSHKeyAdd adds an SSH key for a user. +func processSSHKeyAdd( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + Key user.SSHKey `json:"key"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey add data: %w", err) + } + + logger.Debug("executing sshKey.Add", + slog.String("username", data.Username), + ) + + result, err := userProvider.AddKey(ctx, data.Username, data.Key) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} + +// processSSHKeyRemove removes an SSH key for a user. +func processSSHKeyRemove( + ctx context.Context, + userProvider user.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + var data struct { + Username string `json:"username"` + Fingerprint string `json:"fingerprint"` + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal sshKey remove data: %w", err) + } + + logger.Debug("executing sshKey.Remove", + slog.String("username", data.Username), + slog.String("fingerprint", data.Fingerprint), + ) + + result, err := userProvider.RemoveKey(ctx, data.Username, data.Fingerprint) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} diff --git a/internal/agent/processor_ssh_key_public_test.go b/internal/agent/processor_ssh_key_public_test.go new file mode 100644 index 000000000..e5d9d1aa8 --- /dev/null +++ b/internal/agent/processor_ssh_key_public_test.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent_test + +import ( + "encoding/json" + "errors" + "log/slog" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/agent" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/user" + userMocks "github.com/retr0h/osapi/internal/provider/node/user/mocks" +) + +type ProcessorSSHKeyPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller +} + +func (s *ProcessorSSHKeyPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) +} + +func (s *ProcessorSSHKeyPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *ProcessorSSHKeyPublicTestSuite) newProcessor( + userProvider user.Provider, +) agent.ProcessorFunc { + return agent.NewNodeProcessor( + nil, nil, nil, nil, + nil, nil, nil, nil, + nil, + userProvider, + nil, + nil, + config.Config{}, + slog.Default(), + ) +} + +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyOperation() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + }{ + { + name: "nil provider returns error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"john"}`), + }, + setupMock: nil, + expectError: true, + errorMsg: "user provider not available", + }, + { + name: "invalid sshKey operation missing sub-operation", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey", + Data: json.RawMessage(`{}`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "invalid sshKey operation: sshKey", + }, + { + name: "unsupported sshKey sub-operation", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.invalid", + Data: json.RawMessage(`{}`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unsupported sshKey operation: sshKey.invalid", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + var userProvider user.Provider + if tt.setupMock != nil { + userProvider = tt.setupMock() + } + + processor := s.newProcessor(userProvider) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + } + }) + } +} + +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyList() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key list", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"john"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().ListKeys(gomock.Any(), "john").Return([]user.SSHKey{ + { + Type: "ssh-ed25519", + Fingerprint: "SHA256:abc123", + Comment: "john@laptop", + }, + { + Type: "ssh-rsa", + Fingerprint: "SHA256:def456", + Comment: "john@desktop", + }, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var keys []user.SSHKey + err := json.Unmarshal(result, &keys) + s.NoError(err) + s.Len(keys, 2) + s.Equal("ssh-ed25519", keys[0].Type) + s.Equal("SHA256:abc123", keys[0].Fingerprint) + s.Equal("ssh-rsa", keys[1].Type) + }, + }, + { + name: "ssh key list with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey list data", + }, + { + name: "ssh key list provider error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "sshKey.list", + Data: json.RawMessage(`{"username":"missing"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + ListKeys(gomock.Any(), "missing"). + Return(nil, errors.New("user not found")) + return m + }, + expectError: true, + errorMsg: "user not found", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyAdd() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key add", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage( + `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"john@laptop","raw_line":"ssh-ed25519 AAAA... john@laptop"}}`, + ), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().AddKey(gomock.Any(), "john", user.SSHKey{ + Type: "ssh-ed25519", + Fingerprint: "SHA256:abc123", + Comment: "john@laptop", + RawLine: "ssh-ed25519 AAAA... john@laptop", + }).Return(&user.SSHKeyResult{ + Changed: true, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var r user.SSHKeyResult + err := json.Unmarshal(result, &r) + s.NoError(err) + s.True(r.Changed) + }, + }, + { + name: "ssh key add with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey add data", + }, + { + name: "ssh key add provider error", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.add", + Data: json.RawMessage( + `{"username":"john","key":{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","raw_line":"ssh-ed25519 AAAA..."}}`, + ), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + AddKey(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("permission denied")) + return m + }, + expectError: true, + errorMsg: "permission denied", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorSSHKeyPublicTestSuite) TestProcessSSHKeyRemove() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() user.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "successful ssh key remove", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`{"username":"john","fingerprint":"SHA256:abc123"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + RemoveKey(gomock.Any(), "john", "SHA256:abc123"). + Return(&user.SSHKeyResult{ + Changed: true, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var r user.SSHKeyResult + err := json.Unmarshal(result, &r) + s.NoError(err) + s.True(r.Changed) + }, + }, + { + name: "ssh key remove with invalid JSON data", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() user.Provider { + return userMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal sshKey remove data", + }, + { + name: "ssh key remove provider error", + jobRequest: job.Request{ + Type: job.TypeModify, + Category: "node", + Operation: "sshKey.remove", + Data: json.RawMessage(`{"username":"john","fingerprint":"SHA256:missing"}`), + }, + setupMock: func() user.Provider { + m := userMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + RemoveKey(gomock.Any(), "john", "SHA256:missing"). + Return(nil, errors.New("key not found")) + return m + }, + expectError: true, + errorMsg: "key not found", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func TestProcessorSSHKeyPublicTestSuite(t *testing.T) { + suite.Run(t, new(ProcessorSSHKeyPublicTestSuite)) +} diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml index 2cb82a506..c7e17eab8 100644 --- a/internal/controller/api/gen/api.yaml +++ b/internal/controller/api/gen/api.yaml @@ -4292,6 +4292,139 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/user/{name}/ssh-key: + servers: [] + get: + summary: List SSH authorized keys + description: | + List SSH authorized keys for a user on the target node. + tags: + - User_and_Group_Management_API_user_operations + operationId: GetNodeUserSSHKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error listing SSH keys. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + summary: Add SSH authorized key + description: | + Add an SSH authorized key for a user on the target node. + tags: + - User_and_Group_Management_API_user_operations + operationId: PostNodeUserSSHKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + description: SSH public key to add. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error adding SSH key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/user/{name}/ssh-key/{fingerprint}: + servers: [] + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint for a user on the target + node. + tags: + - User_and_Group_Management_API_user_operations + operationId: DeleteNodeUserSSHKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error removing SSH key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /node/{hostname}/group: servers: [] get: @@ -8198,6 +8331,109 @@ components: $ref: '#/components/schemas/UserMutationResult' required: - results + SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: | + Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + SSHKeyInfo: + type: object + description: An SSH authorized key entry. + properties: + type: + type: string + description: Key type (e.g., ssh-rsa, ssh-ed25519). + example: ssh-ed25519 + fingerprint: + type: string + description: SHA256 fingerprint of the key. + example: SHA256:abc123... + comment: + type: string + description: Key comment (typically user@host). + example: john@laptop + SSHKeyEntry: + type: object + description: SSH key list result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + keys: + type: array + description: SSH authorized keys on this agent. + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + SSHKeyMutationEntry: + type: object + description: SSH key mutation result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + changed: + type: boolean + description: Whether the operation modified system state. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results GroupInfo: type: object description: A group on the target node. @@ -8424,6 +8660,17 @@ components: schema: type: string minLength: 1 + SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: | + SSH key SHA256 fingerprint (e.g., SHA256:abc123...). + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 x-tagGroups: - name: Agent Management API tags: diff --git a/internal/controller/api/node/user/gen/api.yaml b/internal/controller/api/node/user/gen/api.yaml index c3e3d3fe1..e215349a8 100644 --- a/internal/controller/api/node/user/gen/api.yaml +++ b/internal/controller/api/node/user/gen/api.yaml @@ -336,6 +336,142 @@ paths: schema: $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + # -- SSH Key management ------------------------------------------------ + + /node/{hostname}/user/{name}/ssh-key: + get: + summary: List SSH authorized keys + description: > + List SSH authorized keys for a user on the target node. + tags: + - user_operations + operationId: GetNodeUserSSHKey + security: + - BearerAuth: + - user:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + responses: + '200': + description: List of SSH authorized keys. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error listing SSH keys. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + post: + summary: Add SSH authorized key + description: > + Add an SSH authorized key for a user on the target node. + tags: + - user_operations + operationId: PostNodeUserSSHKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + requestBody: + description: SSH public key to add. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyAddRequest' + responses: + '200': + description: Key added. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error adding SSH key. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + /node/{hostname}/user/{name}/ssh-key/{fingerprint}: + delete: + summary: Remove SSH authorized key + description: > + Remove an SSH authorized key by fingerprint for a user on the + target node. + tags: + - user_operations + operationId: DeleteNodeUserSSHKey + security: + - BearerAuth: + - user:write + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UserName' + - $ref: '#/components/parameters/SSHKeyFingerprint' + responses: + '200': + description: Key removed. + content: + application/json: + schema: + $ref: '#/components/schemas/SSHKeyMutationResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error removing SSH key. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + # -- Group collection ---------------------------------------------------- /node/{hostname}/group: @@ -629,6 +765,21 @@ components: type: string minLength: 1 + SSHKeyFingerprint: + name: fingerprint + in: path + required: true + description: > + SSH key SHA256 fingerprint (e.g., SHA256:abc123...). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in the handler. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 + securitySchemes: BearerAuth: type: http @@ -837,6 +988,112 @@ components: required: - results + # -- SSH Key schemas -- + + SSHKeyAddRequest: + type: object + required: + - key + properties: + key: + type: string + description: > + Full SSH public key line (e.g., + "ssh-ed25519 AAAA... user@host"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + + SSHKeyInfo: + type: object + description: An SSH authorized key entry. + properties: + type: + type: string + description: Key type (e.g., ssh-rsa, ssh-ed25519). + example: "ssh-ed25519" + fingerprint: + type: string + description: SHA256 fingerprint of the key. + example: "SHA256:abc123..." + comment: + type: string + description: Key comment (typically user@host). + example: "john@laptop" + + SSHKeyEntry: + type: object + description: SSH key list result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + keys: + type: array + description: SSH authorized keys on this agent. + items: + $ref: '#/components/schemas/SSHKeyInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + SSHKeyMutationEntry: + type: object + description: SSH key mutation result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + changed: + type: boolean + description: Whether the operation modified system state. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + SSHKeyCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyEntry' + required: + - results + + SSHKeyMutationResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/SSHKeyMutationEntry' + required: + - results + GroupInfo: type: object description: A group on the target node. diff --git a/internal/controller/api/node/user/gen/user.gen.go b/internal/controller/api/node/user/gen/user.gen.go index 78af53950..ff1b9b731 100644 --- a/internal/controller/api/node/user/gen/user.gen.go +++ b/internal/controller/api/node/user/gen/user.gen.go @@ -34,6 +34,20 @@ const ( GroupMutationResultStatusSkipped GroupMutationResultStatus = "skipped" ) +// Defines values for SSHKeyEntryStatus. +const ( + SSHKeyEntryStatusFailed SSHKeyEntryStatus = "failed" + SSHKeyEntryStatusOk SSHKeyEntryStatus = "ok" + SSHKeyEntryStatusSkipped SSHKeyEntryStatus = "skipped" +) + +// Defines values for SSHKeyMutationEntryStatus. +const ( + SSHKeyMutationEntryStatusFailed SSHKeyMutationEntryStatus = "failed" + SSHKeyMutationEntryStatusOk SSHKeyMutationEntryStatus = "ok" + SSHKeyMutationEntryStatusSkipped SSHKeyMutationEntryStatus = "skipped" +) + // Defines values for UserEntryStatus. const ( UserEntryStatusFailed UserEntryStatus = "failed" @@ -134,6 +148,74 @@ type GroupUpdateRequest struct { Members *[]string `json:"members,omitempty"` } +// SSHKeyAddRequest defines model for SSHKeyAddRequest. +type SSHKeyAddRequest struct { + // Key Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string `json:"key" validate:"required,min=1"` +} + +// SSHKeyCollectionResponse defines model for SSHKeyCollectionResponse. +type SSHKeyCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyEntry `json:"results"` +} + +// SSHKeyEntry SSH key list result for a single agent. +type SSHKeyEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Keys SSH authorized keys on this agent. + Keys *[]SSHKeyInfo `json:"keys,omitempty"` + + // Status The status of the operation for this host. + Status SSHKeyEntryStatus `json:"status"` +} + +// SSHKeyEntryStatus The status of the operation for this host. +type SSHKeyEntryStatus string + +// SSHKeyInfo An SSH authorized key entry. +type SSHKeyInfo struct { + // Comment Key comment (typically user@host). + Comment *string `json:"comment,omitempty"` + + // Fingerprint SHA256 fingerprint of the key. + Fingerprint *string `json:"fingerprint,omitempty"` + + // Type Key type (e.g., ssh-rsa, ssh-ed25519). + Type *string `json:"type,omitempty"` +} + +// SSHKeyMutationEntry SSH key mutation result for a single agent. +type SSHKeyMutationEntry struct { + // Changed Whether the operation modified system state. + Changed *bool `json:"changed,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status SSHKeyMutationEntryStatus `json:"status"` +} + +// SSHKeyMutationEntryStatus The status of the operation for this host. +type SSHKeyMutationEntryStatus string + +// SSHKeyMutationResponse defines model for SSHKeyMutationResponse. +type SSHKeyMutationResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyMutationEntry `json:"results"` +} + // UserCollectionResponse defines model for UserCollectionResponse. type UserCollectionResponse struct { // JobId The job ID used to process this request. @@ -265,6 +347,9 @@ type GroupName = string // Hostname defines model for Hostname. type Hostname = string +// SSHKeyFingerprint defines model for SSHKeyFingerprint. +type SSHKeyFingerprint = string + // UserName defines model for UserName. type UserName = string @@ -283,6 +368,9 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest +// PostNodeUserSSHKeyJSONRequestBody defines body for PostNodeUserSSHKey for application/json ContentType. +type PostNodeUserSSHKeyJSONRequestBody = SSHKeyAddRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // List all groups @@ -318,6 +406,15 @@ type ServerInterface interface { // Change user password // (POST /node/{hostname}/user/{name}/password) PostNodeUserPassword(ctx echo.Context, hostname Hostname, name UserName) error + // List SSH authorized keys + // (GET /node/{hostname}/user/{name}/ssh-key) + GetNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error + // Add SSH authorized key + // (POST /node/{hostname}/user/{name}/ssh-key) + PostNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error + // Remove SSH authorized key + // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) + DeleteNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -579,6 +676,92 @@ func (w *ServerInterfaceWrapper) PostNodeUserPassword(ctx echo.Context) error { return err } +// GetNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeUserSSHKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:read"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetNodeUserSSHKey(ctx, hostname, name) + return err +} + +// PostNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) PostNodeUserSSHKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:write"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PostNodeUserSSHKey(ctx, hostname, name) + return err +} + +// DeleteNodeUserSSHKey converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteNodeUserSSHKey(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UserName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + // ------------- Path parameter "fingerprint" ------------- + var fingerprint SSHKeyFingerprint + + err = runtime.BindStyledParameterWithOptions("simple", "fingerprint", ctx.Param("fingerprint"), &fingerprint, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter fingerprint: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"user:write"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteNodeUserSSHKey(ctx, hostname, name, fingerprint) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -618,6 +801,9 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/node/:hostname/user/:name", wrapper.GetNodeUserByName) router.PUT(baseURL+"/node/:hostname/user/:name", wrapper.PutNodeUser) router.POST(baseURL+"/node/:hostname/user/:name/password", wrapper.PostNodeUserPassword) + router.GET(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.GetNodeUserSSHKey) + router.POST(baseURL+"/node/:hostname/user/:name/ssh-key", wrapper.PostNodeUserSSHKey) + router.DELETE(baseURL+"/node/:hostname/user/:name/ssh-key/:fingerprint", wrapper.DeleteNodeUserSSHKey) } @@ -1225,6 +1411,152 @@ func (response PostNodeUserPassword500JSONResponse) VisitPostNodeUserPasswordRes return json.NewEncoder(w).Encode(response) } +type GetNodeUserSSHKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` +} + +type GetNodeUserSSHKeyResponseObject interface { + VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error +} + +type GetNodeUserSSHKey200JSONResponse SSHKeyCollectionResponse + +func (response GetNodeUserSSHKey200JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSSHKey401JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSSHKey403JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse + +func (response GetNodeUserSSHKey500JSONResponse) VisitGetNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSSHKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` + Body *PostNodeUserSSHKeyJSONRequestBody +} + +type PostNodeUserSSHKeyResponseObject interface { + VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error +} + +type PostNodeUserSSHKey200JSONResponse SSHKeyMutationResponse + +func (response PostNodeUserSSHKey200JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSSHKey400JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSSHKey400JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSSHKey401JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSSHKey403JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type PostNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse + +func (response PostNodeUserSSHKey500JSONResponse) VisitPostNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSSHKeyRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UserName `json:"name"` + Fingerprint SSHKeyFingerprint `json:"fingerprint"` +} + +type DeleteNodeUserSSHKeyResponseObject interface { + VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error +} + +type DeleteNodeUserSSHKey200JSONResponse SSHKeyMutationResponse + +func (response DeleteNodeUserSSHKey200JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSSHKey401JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSSHKey401JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSSHKey403JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSSHKey403JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteNodeUserSSHKey500JSONResponse externalRef0.ErrorResponse + +func (response DeleteNodeUserSSHKey500JSONResponse) VisitDeleteNodeUserSSHKeyResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // List all groups @@ -1260,6 +1592,15 @@ type StrictServerInterface interface { // Change user password // (POST /node/{hostname}/user/{name}/password) PostNodeUserPassword(ctx context.Context, request PostNodeUserPasswordRequestObject) (PostNodeUserPasswordResponseObject, error) + // List SSH authorized keys + // (GET /node/{hostname}/user/{name}/ssh-key) + GetNodeUserSSHKey(ctx context.Context, request GetNodeUserSSHKeyRequestObject) (GetNodeUserSSHKeyResponseObject, error) + // Add SSH authorized key + // (POST /node/{hostname}/user/{name}/ssh-key) + PostNodeUserSSHKey(ctx context.Context, request PostNodeUserSSHKeyRequestObject) (PostNodeUserSSHKeyResponseObject, error) + // Remove SSH authorized key + // (DELETE /node/{hostname}/user/{name}/ssh-key/{fingerprint}) + DeleteNodeUserSSHKey(ctx context.Context, request DeleteNodeUserSSHKeyRequestObject) (DeleteNodeUserSSHKeyResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -1585,3 +1926,88 @@ func (sh *strictHandler) PostNodeUserPassword(ctx echo.Context, hostname Hostnam } return nil } + +// GetNodeUserSSHKey operation middleware +func (sh *strictHandler) GetNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request GetNodeUserSSHKeyRequestObject + + request.Hostname = hostname + request.Name = name + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetNodeUserSSHKey(ctx.Request().Context(), request.(GetNodeUserSSHKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNodeUserSSHKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitGetNodeUserSSHKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// PostNodeUserSSHKey operation middleware +func (sh *strictHandler) PostNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName) error { + var request PostNodeUserSSHKeyRequestObject + + request.Hostname = hostname + request.Name = name + + var body PostNodeUserSSHKeyJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostNodeUserSSHKey(ctx.Request().Context(), request.(PostNodeUserSSHKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostNodeUserSSHKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(PostNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitPostNodeUserSSHKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// DeleteNodeUserSSHKey operation middleware +func (sh *strictHandler) DeleteNodeUserSSHKey(ctx echo.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) error { + var request DeleteNodeUserSSHKeyRequestObject + + request.Hostname = hostname + request.Name = name + request.Fingerprint = fingerprint + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteNodeUserSSHKey(ctx.Request().Context(), request.(DeleteNodeUserSSHKeyRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteNodeUserSSHKey") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(DeleteNodeUserSSHKeyResponseObject); ok { + return validResponse.VisitDeleteNodeUserSSHKeyResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/internal/controller/api/node/user/ssh_key_create.go b/internal/controller/api/node/user/ssh_key_create.go new file mode 100644 index 000000000..eea99047c --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_create.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + userProv "github.com/retr0h/osapi/internal/provider/node/user" + "github.com/retr0h/osapi/internal/validation" +) + +// PostNodeUserSSHKey adds an SSH authorized key for a user on a target node. +func (u *User) PostNodeUserSSHKey( + ctx context.Context, + request gen.PostNodeUserSSHKeyRequestObject, +) (gen.PostNodeUserSSHKeyResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.PostNodeUserSSHKey400JSONResponse{Error: &errMsg}, nil + } + + if errMsg, ok := validation.Struct(request.Body); !ok { + return gen.PostNodeUserSSHKey400JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + + u.logger.Debug("ssh key add", + slog.String("target", hostname), + slog.String("username", username), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + "raw_line": request.Body.Key, + } + + if job.IsBroadcastTarget(hostname) { + return u.postNodeUserSSHKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Modify( + ctx, + hostname, + "user", + job.OperationSSHKeyAdd, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.PostNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + jobUUID := uuid.MustParse(jobID) + e := resp.Error + return gen.PostNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyMutationEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + var result userProv.SSHKeyResult + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &result) + } + + jobUUID := uuid.MustParse(jobID) + changed := resp.Changed + agentHostname := resp.Hostname + + return gen.PostNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: agentHostname, + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: changed, + }, + }, + }, nil +} + +// postNodeUserSSHKeyBroadcast handles broadcast targets for SSH key add. +func (u *User) postNodeUserSSHKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.PostNodeUserSSHKeyResponseObject, error) { + jobID, responses, err := u.JobClient.ModifyBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyAdd, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.PostNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + var apiResponses []gen.SSHKeyMutationEntry + for host, resp := range responses { + item := gen.SSHKeyMutationEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.SSHKeyMutationEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.SSHKeyMutationEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.SSHKeyMutationEntryStatusOk + item.Changed = resp.Changed + } + apiResponses = append(apiResponses, item) + } + + jobUUID := uuid.MustParse(jobID) + + return gen.PostNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: apiResponses, + }, nil +} diff --git a/internal/controller/api/node/user/ssh_key_create_public_test.go b/internal/controller/api/node/user/ssh_key_create_public_test.go new file mode 100644 index 000000000..e6b3d3e1e --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_create_public_test.go @@ -0,0 +1,493 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyCreatePublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyCreatePublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyCreatePublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyCreatePublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKey() { + tests := []struct { + name string + request gen.PostNodeUserSSHKeyRequestObject + setupMock func() + validateFunc func(resp gen.PostNodeUserSSHKeyResponseObject) + }{ + { + name: "success", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + map[string]string{ + "username": "testuser", + "raw_line": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Changed) + s.True(*r.Results[0].Changed) + }, + }, + { + name: "validation error empty key", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "", + }, + }, + setupMock: func() {}, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey400JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() {}, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey400JSONResponse) + s.True(ok) + }, + }, + { + name: "when job skipped", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) + }, + }, + { + name: "job client error", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast target _all", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast with failed and skipped agents", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, nil) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.PostNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + + byHost := make(map[string]gen.SSHKeyMutationEntry) + for _, res := range r.Results { + byHost[res.Hostname] = res + } + + s.Equal(gen.SSHKeyMutationEntryStatusOk, byHost["server1"].Status) + s.Equal(gen.SSHKeyMutationEntryStatusFailed, byHost["server2"].Status) + s.Contains(*byHost["server2"].Error, "connection timeout") + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, byHost["server3"].Status) + }, + }, + { + name: "broadcast job client error", + request: gen.PostNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Body: &gen.SSHKeyAddRequest{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.PostNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.PostNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + resp, err := s.handler.PostNodeUserSSHKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKeyValidationHTTP() { + tests := []struct { + name string + body string + wantCode int + }{ + { + name: "when valid request", + body: `{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"}`, + wantCode: http.StatusOK, + }, + { + name: "when missing key", + body: `{}`, + wantCode: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := jobmocks.NewMockJobClient(s.mockCtrl) + if tc.wantCode == http.StatusOK { + jobMock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + } + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/user/testuser/ssh-key", + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +const rbacSSHKeyCreateTestSigningKey = "test-signing-key-for-rbac-ssh-key-create" + +func (s *SSHKeyCreatePublicTestSuite) TestPostNodeUserSSHKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) {}, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyCreateTestSigningKey, + []string{"read"}, + "test-user", + []string{"user:read"}, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyCreateTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyAdd, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + Data: json.RawMessage(`{"changed":true}`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{SigningKey: rbacSSHKeyCreateTestSigningKey}, + }, + }, + } + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/user/testuser/ssh-key", + strings.NewReader(`{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +func TestSSHKeyCreatePublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyCreatePublicTestSuite)) +} diff --git a/internal/controller/api/node/user/ssh_key_delete.go b/internal/controller/api/node/user/ssh_key_delete.go new file mode 100644 index 000000000..ac8dcc5b5 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_delete.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" +) + +// DeleteNodeUserSSHKey removes an SSH authorized key by fingerprint for a user +// on a target node. +func (u *User) DeleteNodeUserSSHKey( + ctx context.Context, + request gen.DeleteNodeUserSSHKeyRequestObject, +) (gen.DeleteNodeUserSSHKeyResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + fingerprint := request.Fingerprint + + u.logger.Debug("ssh key remove", + slog.String("target", hostname), + slog.String("username", username), + slog.String("fingerprint", fingerprint), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + "fingerprint": fingerprint, + } + + if job.IsBroadcastTarget(hostname) { + return u.deleteNodeUserSSHKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Modify( + ctx, + hostname, + "user", + job.OperationSSHKeyRemove, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + jobUUID := uuid.MustParse(jobID) + e := resp.Error + return gen.DeleteNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyMutationEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + jobUUID := uuid.MustParse(jobID) + changed := resp.Changed + agentHostname := resp.Hostname + + return gen.DeleteNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: agentHostname, + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: changed, + }, + }, + }, nil +} + +// deleteNodeUserSSHKeyBroadcast handles broadcast targets for SSH key remove. +func (u *User) deleteNodeUserSSHKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.DeleteNodeUserSSHKeyResponseObject, error) { + jobID, responses, err := u.JobClient.ModifyBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyRemove, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.DeleteNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + var apiResponses []gen.SSHKeyMutationEntry + for host, resp := range responses { + item := gen.SSHKeyMutationEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.SSHKeyMutationEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.SSHKeyMutationEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.SSHKeyMutationEntryStatusOk + item.Changed = resp.Changed + } + apiResponses = append(apiResponses, item) + } + + jobUUID := uuid.MustParse(jobID) + + return gen.DeleteNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: apiResponses, + }, nil +} diff --git a/internal/controller/api/node/user/ssh_key_delete_public_test.go b/internal/controller/api/node/user/ssh_key_delete_public_test.go new file mode 100644 index 000000000..6f4ff71a6 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_delete_public_test.go @@ -0,0 +1,473 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyDeletePublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyDeletePublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyDeletePublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyDeletePublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKey() { + tests := []struct { + name string + request gen.DeleteNodeUserSSHKeyRequestObject + setupMock func() + validateFunc func(resp gen.DeleteNodeUserSSHKeyResponseObject) + }{ + { + name: "success", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyMutationEntryStatusOk, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Changed) + s.True(*r.Results[0].Changed) + }, + }, + { + name: "when job skipped", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, r.Results[0].Status) + }, + }, + { + name: "job client error", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() {}, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "broadcast target _all", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + }, + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast with failed and skipped agents", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Changed: boolPtr(true), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, nil) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.DeleteNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + + byHost := make(map[string]gen.SSHKeyMutationEntry) + for _, res := range r.Results { + byHost[res.Hostname] = res + } + + s.Equal(gen.SSHKeyMutationEntryStatusOk, byHost["server1"].Status) + s.Equal(gen.SSHKeyMutationEntryStatusFailed, byHost["server2"].Status) + s.Contains(*byHost["server2"].Error, "connection timeout") + s.Equal(gen.SSHKeyMutationEntryStatusSkipped, byHost["server3"].Status) + }, + }, + { + name: "broadcast job client error", + request: gen.DeleteNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + Fingerprint: "SHA256:abc123", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyRemove, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.DeleteNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.DeleteNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + resp, err := s.handler.DeleteNodeUserSSHKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKeyValidationHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/user/testuser/ssh-key/SHA256:abc123", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/user/testuser/ssh-key/SHA256:abc123", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodDelete, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacSSHKeyDeleteTestSigningKey = "test-signing-key-for-rbac-ssh-key-delete" + +func (s *SSHKeyDeletePublicTestSuite) TestDeleteNodeUserSSHKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) {}, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyDeleteTestSigningKey, + []string{"read"}, + "test-user", + []string{"user:read"}, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, _ := tokenManager.Generate( + rbacSSHKeyDeleteTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Modify( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyRemove, + map[string]string{ + "username": "testuser", + "fingerprint": "SHA256:abc123", + }, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Changed: boolPtr(true), + }, nil) + return mock + }, + wantCode: http.StatusOK, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{SigningKey: rbacSSHKeyDeleteTestSigningKey}, + }, + }, + } + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodDelete, + "/node/server1/user/testuser/ssh-key/SHA256:abc123", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + }) + } +} + +func TestSSHKeyDeletePublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyDeletePublicTestSuite)) +} diff --git a/internal/controller/api/node/user/ssh_key_list_get.go b/internal/controller/api/node/user/ssh_key_list_get.go new file mode 100644 index 000000000..aef326c13 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_list_get.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + userProv "github.com/retr0h/osapi/internal/provider/node/user" +) + +// GetNodeUserSSHKey lists SSH authorized keys for a user on a target node. +func (u *User) GetNodeUserSSHKey( + ctx context.Context, + request gen.GetNodeUserSSHKeyRequestObject, +) (gen.GetNodeUserSSHKeyResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + username := request.Name + + u.logger.Debug("ssh key list", + slog.String("target", hostname), + slog.String("username", username), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + data := map[string]string{ + "username": username, + } + + if job.IsBroadcastTarget(hostname) { + return u.getNodeUserSSHKeyBroadcast(ctx, hostname, data) + } + + jobID, resp, err := u.JobClient.Query( + ctx, + hostname, + "user", + job.OperationSSHKeyList, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: resp.Hostname, + Status: gen.SSHKeyEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + results := sshKeyInfoListFromResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: results, + }, nil +} + +// getNodeUserSSHKeyBroadcast handles broadcast targets for SSH key list. +func (u *User) getNodeUserSSHKeyBroadcast( + ctx context.Context, + target string, + data map[string]string, +) (gen.GetNodeUserSSHKeyResponseObject, error) { + jobID, responses, err := u.JobClient.QueryBroadcast( + ctx, + target, + "user", + job.OperationSSHKeyList, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeUserSSHKey500JSONResponse{Error: &errMsg}, nil + } + + allResults := make([]gen.SSHKeyEntry, 0) + for host, resp := range responses { + switch resp.Status { + case job.StatusFailed: + e := resp.Error + allResults = append(allResults, gen.SSHKeyEntry{ + Hostname: host, + Status: gen.SSHKeyEntryStatusFailed, + Error: &e, + }) + case job.StatusSkipped: + e := resp.Error + allResults = append(allResults, gen.SSHKeyEntry{ + Hostname: host, + Status: gen.SSHKeyEntryStatusSkipped, + Error: &e, + }) + default: + allResults = append(allResults, sshKeyInfoListFromResponse(resp)...) + } + } + + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeUserSSHKey200JSONResponse{ + JobId: &jobUUID, + Results: allResults, + }, nil +} + +// sshKeyInfoListFromResponse converts a job response to gen SSHKeyEntry slice. +func sshKeyInfoListFromResponse( + resp *job.Response, +) []gen.SSHKeyEntry { + var keys []userProv.SSHKey + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &keys) + } + + hostname := resp.Hostname + + keyInfos := make([]gen.SSHKeyInfo, 0, len(keys)) + for _, k := range keys { + keyInfos = append(keyInfos, sshKeyInfoToGen(k)) + } + + return []gen.SSHKeyEntry{ + { + Hostname: hostname, + Status: gen.SSHKeyEntryStatusOk, + Keys: &keyInfos, + }, + } +} + +// sshKeyInfoToGen converts a provider SSHKey to a gen SSHKeyInfo. +func sshKeyInfoToGen( + k userProv.SSHKey, +) gen.SSHKeyInfo { + keyType := k.Type + fingerprint := k.Fingerprint + + info := gen.SSHKeyInfo{ + Type: &keyType, + Fingerprint: &fingerprint, + } + + if k.Comment != "" { + comment := k.Comment + info.Comment = &comment + } + + return info +} diff --git a/internal/controller/api/node/user/ssh_key_list_get_public_test.go b/internal/controller/api/node/user/ssh_key_list_get_public_test.go new file mode 100644 index 000000000..874e02885 --- /dev/null +++ b/internal/controller/api/node/user/ssh_key_list_get_public_test.go @@ -0,0 +1,541 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + apiuser "github.com/retr0h/osapi/internal/controller/api/node/user" + "github.com/retr0h/osapi/internal/controller/api/node/user/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type SSHKeyListGetPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apiuser.User + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *SSHKeyListGetPublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *SSHKeyListGetPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apiuser.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *SSHKeyListGetPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKey() { + tests := []struct { + name string + request gen.GetNodeUserSSHKeyRequestObject + setupMock func() + validateFunc func(resp gen.GetNodeUserSSHKeyResponseObject) + }{ + { + name: "success with keys", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Require().Len(r.Results, 1) + s.Require().NotNil(r.Results[0].Keys) + s.Len(*r.Results[0].Keys, 1) + s.Equal("ssh-ed25519", *(*r.Results[0].Keys)[0].Type) + s.Equal("SHA256:abc123", *(*r.Results[0].Keys)[0].Fingerprint) + s.Equal("user@host", *(*r.Results[0].Keys)[0].Comment) + }, + }, + { + name: "success with key without comment", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage( + `[{"type":"ssh-rsa","fingerprint":"SHA256:xyz789"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Require().NotNil(r.Results[0].Keys) + keys := *r.Results[0].Keys + s.Require().Len(keys, 1) + s.Nil(keys[0].Comment) + }, + }, + { + name: "success with nil response data", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: nil, + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Require().Len(r.Results, 1) + }, + }, + { + name: "when job skipped", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "ssh key: operation not supported on this OS family", + }, nil) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.SSHKeyEntryStatusSkipped, r.Results[0].Status) + s.Contains(*r.Results[0].Error, "not supported") + }, + }, + { + name: "job client error", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "server1", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty hostname", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "", + Name: "testuser", + }, + setupMock: func() {}, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "broadcast target _all", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return( + "550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]`, + ), + }, + }, + nil, + ) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Len(r.Results, 1) + }, + }, + { + name: "broadcast includes failed and skipped", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return( + "550e8400-e29b-41d4-a716-446655440000", + map[string]*job.Response{ + "server1": { + Hostname: "server1", + Status: job.StatusCompleted, + Data: json.RawMessage( + `[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123"}]`, + ), + }, + "server2": { + Hostname: "server2", + Status: job.StatusFailed, + Error: "connection timeout", + }, + "server3": { + Hostname: "server3", + Status: job.StatusSkipped, + Error: "unsupported", + }, + }, + nil, + ) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + r, ok := resp.(gen.GetNodeUserSSHKey200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + }, + }, + { + name: "broadcast job client error", + request: gen.GetNodeUserSSHKeyRequestObject{ + Hostname: "_all", + Name: "testuser", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeUserSSHKeyResponseObject) { + _, ok := resp.(gen.GetNodeUserSSHKey500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + resp, err := s.handler.GetNodeUserSSHKey(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKeyValidationHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/user/testuser/ssh-key", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/user/testuser/ssh-key", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + userHandler := apiuser.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(userHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacSSHKeyListTestSigningKey = "test-signing-key-for-rbac-ssh-key-list" + +func (s *SSHKeyListGetPublicTestSuite) TestGetNodeUserSSHKeyRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacSSHKeyListTestSigningKey, + []string{"write"}, + "test-user", + []string{"node:write"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid admin token returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacSSHKeyListTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query( + gomock.Any(), + "server1", + "user", + job.OperationSSHKeyList, + map[string]string{"username": "testuser"}, + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{ + SigningKey: rbacSSHKeyListTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := apiuser.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/node/server1/user/testuser/ssh-key", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +func TestSSHKeyListGetPublicTestSuite(t *testing.T) { + suite.Run(t, new(SSHKeyListGetPublicTestSuite)) +} diff --git a/internal/job/types.go b/internal/job/types.go index 7e7d4036c..3786180b1 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -209,6 +209,13 @@ const ( OperationGroupDelete = client.OpGroupDelete ) +// SSH Key operations. +const ( + OperationSSHKeyList = client.OpSSHKeyList + OperationSSHKeyAdd = client.OpSSHKeyAdd + OperationSSHKeyRemove = client.OpSSHKeyRemove +) + // Package operations. const ( OperationPackageList = client.OpPackageList diff --git a/internal/provider/node/user/darwin.go b/internal/provider/node/user/darwin.go index 85240a4ef..f094a3847 100644 --- a/internal/provider/node/user/darwin.go +++ b/internal/provider/node/user/darwin.go @@ -125,3 +125,29 @@ func (d *Darwin) DeleteGroup( ) (*GroupResult, error) { return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) } + +// ListKeys returns ErrUnsupported on Darwin. +func (d *Darwin) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on Darwin. +func (d *Darwin) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on Darwin. +func (d *Darwin) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/user/darwin_public_test.go b/internal/provider/node/user/darwin_public_test.go index e17fb70d1..6bf3317dd 100644 --- a/internal/provider/node/user/darwin_public_test.go +++ b/internal/provider/node/user/darwin_public_test.go @@ -270,6 +270,68 @@ func (suite *DarwinPublicTestSuite) TestDeleteGroup() { } } +func (suite *DarwinPublicTestSuite) TestListKeys() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.ListKeys(suite.ctx, "testuser") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *DarwinPublicTestSuite) TestAddKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.AddKey(suite.ctx, "testuser", user.SSHKey{ + RawLine: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@example", + }) + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *DarwinPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.RemoveKey(suite.ctx, "testuser", "SHA256:abc123") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + func TestDarwinPublicTestSuite(t *testing.T) { suite.Run(t, new(DarwinPublicTestSuite)) } diff --git a/internal/provider/node/user/debian_ssh_key.go b/internal/provider/node/user/debian_ssh_key.go new file mode 100644 index 000000000..4185cc48f --- /dev/null +++ b/internal/provider/node/user/debian_ssh_key.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" +) + +const ( + authorizedKeysFile = "authorized_keys" + sshDirName = ".ssh" + sshDirMode = 0o700 + authorizedKeysMode = 0o600 + minKeyFields = 2 +) + +// ListKeys returns the SSH authorized keys for the given user. +func (d *Debian) ListKeys( + ctx context.Context, + username string, +) ([]SSHKey, error) { + _ = ctx + + d.logger.Debug("executing user.ListKeys", + slog.String("username", username), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: list: %w", err) + } + + authKeysPath := filepath.Join(homeDir, sshDirName, authorizedKeysFile) + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return []SSHKey{}, nil + } + + return nil, fmt.Errorf("ssh key: list: read %s: %w", authKeysPath, err) + } + + keys := d.parseAuthorizedKeys(string(content)) + + return keys, nil +} + +// AddKey adds an SSH public key to the user's authorized_keys file. +func (d *Debian) AddKey( + ctx context.Context, + username string, + key SSHKey, +) (*SSHKeyResult, error) { + _ = ctx + + d.logger.Debug("executing user.AddKey", + slog.String("username", username), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: add: %w", err) + } + + sshDir := filepath.Join(homeDir, sshDirName) + authKeysPath := filepath.Join(sshDir, authorizedKeysFile) + + // Check if key already exists by fingerprint. + content, err := d.fs.ReadFile(authKeysPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("ssh key: add: read %s: %w", authKeysPath, err) + } + + if err == nil { + newFingerprint := fingerprintFromLine(key.RawLine) + if newFingerprint != "" { + existing := d.parseAuthorizedKeys(string(content)) + for _, k := range existing { + if k.Fingerprint == newFingerprint { + return &SSHKeyResult{Changed: false}, nil + } + } + } + } + + // Create .ssh directory if missing. + if err := d.fs.MkdirAll(sshDir, sshDirMode); err != nil { + return nil, fmt.Errorf("ssh key: add: mkdir %s: %w", sshDir, err) + } + + // Append key to authorized_keys. + f, err := d.fs.OpenFile( + authKeysPath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + authorizedKeysMode, + ) + if err != nil { + return nil, fmt.Errorf("ssh key: add: open %s: %w", authKeysPath, err) + } + + _, writeErr := f.Write([]byte(key.RawLine + "\n")) + + if closeErr := f.Close(); closeErr != nil && writeErr == nil { + writeErr = closeErr + } + + if writeErr != nil { + return nil, fmt.Errorf("ssh key: add: write %s: %w", authKeysPath, writeErr) + } + + // Best-effort chown. + _, chownErr := d.execManager.RunCmd("chown", []string{ + "-R", + username + ":" + username, + sshDir, + }) + if chownErr != nil { + d.logger.Warn("chown failed for ssh directory", + slog.String("username", username), + slog.String("path", sshDir), + slog.String("error", chownErr.Error()), + ) + } + + d.logger.Info("ssh key added", + slog.String("username", username), + ) + + return &SSHKeyResult{Changed: true}, nil +} + +// RemoveKey removes an SSH public key by fingerprint from the user's +// authorized_keys file. +func (d *Debian) RemoveKey( + ctx context.Context, + username string, + fingerprint string, +) (*SSHKeyResult, error) { + _ = ctx + + d.logger.Debug("executing user.RemoveKey", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + homeDir, err := d.userHomeDir(username) + if err != nil { + return nil, fmt.Errorf("ssh key: remove: %w", err) + } + + authKeysPath := filepath.Join(homeDir, sshDirName, authorizedKeysFile) + + content, err := d.fs.ReadFile(authKeysPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &SSHKeyResult{Changed: false}, nil + } + + return nil, fmt.Errorf("ssh key: remove: read %s: %w", authKeysPath, err) + } + + lines := strings.Split(string(content), "\n") + var remaining []string + + found := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + remaining = append(remaining, line) + + continue + } + + fp := fingerprintFromLine(trimmed) + if fp == fingerprint { + found = true + + continue + } + + remaining = append(remaining, line) + } + + if !found { + return &SSHKeyResult{Changed: false}, nil + } + + output := strings.Join(remaining, "\n") + + if err := d.fs.WriteFile(authKeysPath, []byte(output), authorizedKeysMode); err != nil { + return nil, fmt.Errorf("ssh key: remove: write %s: %w", authKeysPath, err) + } + + d.logger.Info("ssh key removed", + slog.String("username", username), + slog.String("fingerprint", fingerprint), + ) + + return &SSHKeyResult{Changed: true}, nil +} + +// userHomeDir resolves a user's home directory from /etc/passwd. +func (d *Debian) userHomeDir( + username string, +) (string, error) { + f, err := d.fs.Open(passwdFile) + if err != nil { + return "", fmt.Errorf("open %s: %w", passwdFile, err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Split(line, ":") + if len(fields) < passwdFields { + continue + } + + if fields[0] == username { + return fields[5], nil + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read %s: %w", passwdFile, err) + } + + return "", fmt.Errorf("user %q not found", username) +} + +// parseAuthorizedKeys parses the content of an authorized_keys file into +// SSHKey entries. Lines that are empty, comments, or malformed are skipped. +func (d *Debian) parseAuthorizedKeys( + content string, +) []SSHKey { + var keys []SSHKey + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + fields := strings.Fields(trimmed) + if len(fields) < minKeyFields { + d.logger.Debug("skipping malformed authorized_keys line", + slog.String("line", trimmed), + ) + + continue + } + + fp := computeFingerprint(fields[1]) + if fp == "" { + d.logger.Debug("skipping line with invalid base64 key data", + slog.String("line", trimmed), + ) + + continue + } + + key := SSHKey{ + Type: fields[0], + Fingerprint: fp, + } + + if len(fields) > minKeyFields { + key.Comment = strings.Join(fields[minKeyFields:], " ") + } + + keys = append(keys, key) + } + + return keys +} + +// computeFingerprint computes the SHA256 fingerprint of base64-encoded key +// data, returning the OpenSSH format "SHA256:". +// Returns empty string if the key data is not valid base64. +func computeFingerprint( + keyData string, +) string { + decoded, err := base64.StdEncoding.DecodeString(keyData) + if err != nil { + return "" + } + + hash := sha256.Sum256(decoded) + + return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:]) +} + +// fingerprintFromLine extracts and computes the fingerprint from a full +// authorized_keys line. Returns empty string if the line is malformed or +// contains invalid key data. +func fingerprintFromLine( + line string, +) string { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < minKeyFields { + return "" + } + + return computeFingerprint(fields[1]) +} diff --git a/internal/provider/node/user/debian_ssh_key_public_test.go b/internal/provider/node/user/debian_ssh_key_public_test.go new file mode 100644 index 000000000..b47a6a689 --- /dev/null +++ b/internal/provider/node/user/debian_ssh_key_public_test.go @@ -0,0 +1,915 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package user_test + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "testing" + + "github.com/avfs/avfs" + "github.com/avfs/avfs/vfs/failfs" + "github.com/avfs/avfs/vfs/memfs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + execmocks "github.com/retr0h/osapi/internal/exec/mocks" + "github.com/retr0h/osapi/internal/provider/node/user" +) + +const ( + // Valid ed25519 key line for testing. + testKey1Line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRlc3RrZXkxZGF0YQ== user@host" + testKey1FP = "SHA256:fs7cRe+Lieb9g9TQ7a4HbYTDyVWnO8tXg6D9H2cAWIY" + + // Valid RSA key line for testing. + testKey2Line = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC7 admin@server" + testKey2FP = "SHA256:BgjHH5Pzls0x0ceexhHl0tFm6EBSFKWukOczrQrdl9Y" + + testPasswdSSH = `root:x:0:0:root:/root:/bin/bash +testuser:x:1000:1000:Test:/home/testuser:/bin/bash +` +) + +type DebianSSHKeyPublicTestSuite struct { + suite.Suite + + ctrl *gomock.Controller + ctx context.Context + logger *slog.Logger + memFs avfs.VFS + mockExec *execmocks.MockManager + provider *user.Debian +} + +func (suite *DebianSSHKeyPublicTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + suite.ctx = context.Background() + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) + suite.memFs = memfs.New() + suite.mockExec = execmocks.NewMockManager(suite.ctrl) + + suite.provider = user.NewDebianProvider( + suite.logger, + suite.memFs, + suite.mockExec, + ) +} + +func (suite *DebianSSHKeyPublicTestSuite) SetupSubTest() { + suite.SetupTest() +} + +func (suite *DebianSSHKeyPublicTestSuite) TearDownTest() { + suite.ctrl.Finish() +} + +func (suite *DebianSSHKeyPublicTestSuite) writePasswd(content string) { + _ = suite.memFs.MkdirAll("/etc", 0o755) + + f, err := suite.memFs.Create("/etc/passwd") + suite.Require().NoError(err) + + _, err = f.Write([]byte(content)) + suite.Require().NoError(err) + suite.Require().NoError(f.Close()) +} + +func (suite *DebianSSHKeyPublicTestSuite) writeAuthorizedKeys( + _ string, + homeDir string, + content string, +) { + sshDir := homeDir + "/.ssh" + _ = suite.memFs.MkdirAll(sshDir, 0o700) + + f, err := suite.memFs.Create(sshDir + "/authorized_keys") + suite.Require().NoError(err) + + _, err = f.Write([]byte(content)) + suite.Require().NoError(err) + suite.Require().NoError(f.Close()) +} + +func (suite *DebianSSHKeyPublicTestSuite) readFile(path string) string { + content, err := suite.memFs.ReadFile(path) + suite.Require().NoError(err) + + return string(content) +} + +// newFailFSProvider creates a provider backed by failfs wrapping memfs. +// The caller must write /etc/passwd and any other files to baseFs before +// setting the fail function. +func (suite *DebianSSHKeyPublicTestSuite) newFailFSProvider( + baseFs avfs.VFS, + ff failfs.FailFunc, +) *user.Debian { + ffs := failfs.New(baseFs) + _ = ffs.SetFailFunc(ff) + + return user.NewDebianProvider( + suite.logger, + ffs, + suite.mockExec, + ) +} + +func (suite *DebianSSHKeyPublicTestSuite) TestListKeys() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + setupFS func() + validateFunc func([]user.SSHKey, error) + }{ + { + name: "when successful with two keys", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n"+testKey2Line+"\n", + ) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 2) + + suite.Equal("ssh-ed25519", keys[0].Type) + suite.Equal(testKey1FP, keys[0].Fingerprint) + suite.Equal("user@host", keys[0].Comment) + suite.Empty(keys[0].RawLine) + + suite.Equal("ssh-rsa", keys[1].Type) + suite.Equal(testKey2FP, keys[1].Fingerprint) + suite.Equal("admin@server", keys[1].Comment) + suite.Empty(keys[1].RawLine) + }, + }, + { + name: "when user not found in passwd", + username: "nonexistent", + passwd: testPasswdSSH, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when passwd file missing", + username: "testuser", + skipPasswd: true, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list") + }, + }, + { + name: "when no authorized_keys file", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() {}, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Empty(keys) + }, + }, + { + name: "when authorized_keys is empty", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", "") + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Empty(keys) + }, + }, + { + name: "when comment lines and blank lines are skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "# This is a comment\n\n" + testKey1Line + "\n\n# Another comment\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when malformed line with only one field is skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "onlyonefield\n" + testKey1Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when invalid base64 in key data is skipped", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "ssh-rsa !!!invalid-base64!!! bad@key\n" + testKey1Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + passwd: testPasswdSSH, + skipPasswd: true, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list: read") + }, + }, + { + name: "when passwd read fails mid-scan", + username: "testuser", + skipPasswd: true, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte("other:x:1000:1000:Other:/home/other:/bin/bash\n")) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileRead { + return fmt.Errorf("injected read error") + } + + return nil + }) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.Error(err) + suite.Nil(keys) + suite.Contains(err.Error(), "ssh key: list") + }, + }, + { + name: "when passwd has comments and malformed lines", + username: "testuser", + passwd: "# comment\n\nshort:line\ntestuser:x:1000:1000:Test:/home/testuser:/bin/bash\n", + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal(testKey1FP, keys[0].Fingerprint) + }, + }, + { + name: "when key has no comment", + username: "testuser", + passwd: testPasswdSSH, + setupFS: func() { + content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHRlc3RrZXkxZGF0YQ==\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(keys []user.SSHKey, err error) { + suite.NoError(err) + suite.Require().Len(keys, 1) + suite.Equal("ssh-ed25519", keys[0].Type) + suite.Equal(testKey1FP, keys[0].Fingerprint) + suite.Empty(keys[0].Comment) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + + result, err := suite.provider.ListKeys(suite.ctx, tc.username) + + tc.validateFunc(result, err) + }) + } +} + +func (suite *DebianSSHKeyPublicTestSuite) TestAddKey() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + key user.SSHKey + setupFS func() + setupMock func() + validateFunc func(*user.SSHKeyResult, error) + }{ + { + name: "when successful appends key and runs chown", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, testKey1Line) + }, + }, + { + name: "when key already exists returns changed false", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", testKey1Line+"\n") + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when user not found returns error", + username: "nonexistent", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when ssh dir and file are missing creates them", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey2Line, + }, + setupFS: func() { + _ = suite.memFs.MkdirAll("/home/testuser", 0o755) + }, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, testKey2Line) + }, + }, + { + name: "when passwd file missing returns error", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add") + }, + }, + { + name: "when raw line is malformed skips duplicate check", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: "malformed-single-field", + }, + setupFS: func() { + suite.writeAuthorizedKeys("testuser", "/home/testuser", testKey1Line+"\n") + }, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", nil) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey2Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: read") + }, + }, + { + name: "when mkdir fails", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnMkdirAll { + return fmt.Errorf("injected mkdir error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: mkdir") + }, + }, + { + name: "when open file fails for append", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + openFileCalls := 0 + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnOpenFile { + openFileCalls++ + // 1st: userHomeDir Open("/etc/passwd") + // 2nd: ReadFile's internal OpenFile (not-exist) + // 3rd: OpenFile for append + if openFileCalls > 2 { + return fmt.Errorf("injected open error") + } + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: open") + }, + }, + { + name: "when write fails", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileWrite { + return fmt.Errorf("injected write error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add: write") + }, + }, + { + name: "when file close fails after write", + username: "testuser", + skipPasswd: true, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnFileClose { + return fmt.Errorf("injected close error") + } + + return nil + }) + }, + setupMock: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: add") + }, + }, + { + name: "when chown fails still returns changed true", + username: "testuser", + passwd: testPasswdSSH, + key: user.SSHKey{ + RawLine: testKey1Line, + }, + setupFS: func() {}, + setupMock: func() { + suite.mockExec.EXPECT(). + RunCmd("chown", []string{"-R", "testuser:testuser", "/home/testuser/.ssh"}). + Return("", errors.New("permission denied")) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + tc.setupMock() + + result, err := suite.provider.AddKey(suite.ctx, tc.username, tc.key) + + tc.validateFunc(result, err) + }) + } +} + +func (suite *DebianSSHKeyPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + username string + passwd string + skipPasswd bool + fingerprint string + setupFS func() + validateFunc func(*user.SSHKeyResult, error) + }{ + { + name: "when successful removes matching key", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n"+testKey2Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.NotContains(content, testKey1Line) + suite.Contains(content, testKey2Line) + }, + }, + { + name: "when fingerprint not found returns changed false", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: "SHA256:nonexistent", + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when user not found returns error", + username: "nonexistent", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "not found") + }, + }, + { + name: "when no authorized_keys file returns changed false", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.False(result.Changed) + }, + }, + { + name: "when passwd file missing returns error", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() {}, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove") + }, + }, + { + name: "when read file fails with non-NotExist error", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + if fn == avfs.FnReadFile { + return fmt.Errorf("injected I/O error") + } + + return nil + }) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove: read") + }, + }, + { + name: "when write file fails on rewrite", + username: "testuser", + skipPasswd: true, + fingerprint: testKey1FP, + setupFS: func() { + baseFs := memfs.New() + _ = baseFs.MkdirAll("/etc", 0o755) + f, _ := baseFs.Create("/etc/passwd") + _, _ = f.Write([]byte(testPasswdSSH)) + _ = f.Close() + sshDir := "/home/testuser/.ssh" + _ = baseFs.MkdirAll(sshDir, 0o700) + af, _ := baseFs.Create(sshDir + "/authorized_keys") + _, _ = af.Write([]byte(testKey1Line + "\n")) + _ = af.Close() + + openFileCalls := 0 + + suite.provider = suite.newFailFSProvider(baseFs, + func(_ avfs.VFSBase, fn avfs.FnVFS, _ *failfs.FailParam) error { + // WriteFile uses OpenFile internally. + // The first OpenFile is from ReadFile, the second is + // from WriteFile (the rewrite). Fail the second. + if fn == avfs.FnOpenFile { + openFileCalls++ + // First OpenFile: userHomeDir Open("/etc/passwd") + // Second OpenFile: ReadFile's internal OpenFile + // Third OpenFile: WriteFile's internal OpenFile + if openFileCalls > 2 { + return fmt.Errorf("injected write error") + } + } + + return nil + }) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.Error(err) + suite.Nil(result) + suite.Contains(err.Error(), "ssh key: remove: write") + }, + }, + { + name: "when preserves comment lines and blank lines", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + content := "# Header comment\n\n" + testKey1Line + "\n" + testKey2Line + "\n" + suite.writeAuthorizedKeys("testuser", "/home/testuser", content) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.Contains(content, "# Header comment") + suite.Contains(content, testKey2Line) + suite.NotContains(content, testKey1Line) + }, + }, + { + name: "when file has single key after removal", + username: "testuser", + passwd: testPasswdSSH, + fingerprint: testKey1FP, + setupFS: func() { + suite.writeAuthorizedKeys( + "testuser", + "/home/testuser", + testKey1Line+"\n", + ) + }, + validateFunc: func(result *user.SSHKeyResult, err error) { + suite.NoError(err) + suite.Require().NotNil(result) + suite.True(result.Changed) + + content := suite.readFile("/home/testuser/.ssh/authorized_keys") + suite.NotContains(content, testKey1Line) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + if !tc.skipPasswd { + suite.writePasswd(tc.passwd) + } + tc.setupFS() + + result, err := suite.provider.RemoveKey( + suite.ctx, + tc.username, + tc.fingerprint, + ) + + tc.validateFunc(result, err) + }) + } +} + +func TestDebianSSHKeyPublicTestSuite(t *testing.T) { + suite.Run(t, new(DebianSSHKeyPublicTestSuite)) +} diff --git a/internal/provider/node/user/linux.go b/internal/provider/node/user/linux.go index 52e92311d..076312645 100644 --- a/internal/provider/node/user/linux.go +++ b/internal/provider/node/user/linux.go @@ -125,3 +125,29 @@ func (l *Linux) DeleteGroup( ) (*GroupResult, error) { return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) } + +// ListKeys returns ErrUnsupported on generic Linux. +func (l *Linux) ListKeys( + _ context.Context, + _ string, +) ([]SSHKey, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// AddKey returns ErrUnsupported on generic Linux. +func (l *Linux) AddKey( + _ context.Context, + _ string, + _ SSHKey, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} + +// RemoveKey returns ErrUnsupported on generic Linux. +func (l *Linux) RemoveKey( + _ context.Context, + _ string, + _ string, +) (*SSHKeyResult, error) { + return nil, fmt.Errorf("user: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/user/linux_public_test.go b/internal/provider/node/user/linux_public_test.go index 8e66ae930..fa1a15347 100644 --- a/internal/provider/node/user/linux_public_test.go +++ b/internal/provider/node/user/linux_public_test.go @@ -270,6 +270,68 @@ func (suite *LinuxPublicTestSuite) TestDeleteGroup() { } } +func (suite *LinuxPublicTestSuite) TestListKeys() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.ListKeys(suite.ctx, "testuser") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *LinuxPublicTestSuite) TestAddKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.AddKey(suite.ctx, "testuser", user.SSHKey{ + RawLine: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@example", + }) + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *LinuxPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + }{ + { + name: "returns ErrUnsupported", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result, err := suite.provider.RemoveKey(suite.ctx, "testuser", "SHA256:abc123") + + suite.Error(err) + suite.Nil(result) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + func TestLinuxPublicTestSuite(t *testing.T) { suite.Run(t, new(LinuxPublicTestSuite)) } diff --git a/internal/provider/node/user/mocks/provider.gen.go b/internal/provider/node/user/mocks/provider.gen.go index 11aa8ca11..0a22b024a 100644 --- a/internal/provider/node/user/mocks/provider.gen.go +++ b/internal/provider/node/user/mocks/provider.gen.go @@ -35,6 +35,21 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } +// AddKey mocks base method. +func (m *MockProvider) AddKey(ctx context.Context, username string, key user.SSHKey) (*user.SSHKeyResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddKey", ctx, username, key) + ret0, _ := ret[0].(*user.SSHKeyResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddKey indicates an expected call of AddKey. +func (mr *MockProviderMockRecorder) AddKey(ctx, username, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddKey", reflect.TypeOf((*MockProvider)(nil).AddKey), ctx, username, key) +} + // ChangePassword mocks base method. func (m *MockProvider) ChangePassword(ctx context.Context, name, password string) (*user.Result, error) { m.ctrl.T.Helper() @@ -155,6 +170,21 @@ func (mr *MockProviderMockRecorder) ListGroups(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListGroups", reflect.TypeOf((*MockProvider)(nil).ListGroups), ctx) } +// ListKeys mocks base method. +func (m *MockProvider) ListKeys(ctx context.Context, username string) ([]user.SSHKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListKeys", ctx, username) + ret0, _ := ret[0].([]user.SSHKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListKeys indicates an expected call of ListKeys. +func (mr *MockProviderMockRecorder) ListKeys(ctx, username interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListKeys", reflect.TypeOf((*MockProvider)(nil).ListKeys), ctx, username) +} + // ListUsers mocks base method. func (m *MockProvider) ListUsers(ctx context.Context) ([]user.User, error) { m.ctrl.T.Helper() @@ -170,6 +200,21 @@ func (mr *MockProviderMockRecorder) ListUsers(ctx interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUsers", reflect.TypeOf((*MockProvider)(nil).ListUsers), ctx) } +// RemoveKey mocks base method. +func (m *MockProvider) RemoveKey(ctx context.Context, username, fingerprint string) (*user.SSHKeyResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveKey", ctx, username, fingerprint) + ret0, _ := ret[0].(*user.SSHKeyResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveKey indicates an expected call of RemoveKey. +func (mr *MockProviderMockRecorder) RemoveKey(ctx, username, fingerprint interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveKey", reflect.TypeOf((*MockProvider)(nil).RemoveKey), ctx, username, fingerprint) +} + // UpdateGroup mocks base method. func (m *MockProvider) UpdateGroup(ctx context.Context, name string, opts user.UpdateGroupOpts) (*user.GroupResult, error) { m.ctrl.T.Helper() diff --git a/internal/provider/node/user/types.go b/internal/provider/node/user/types.go index d2202e32c..8770e86fe 100644 --- a/internal/provider/node/user/types.go +++ b/internal/provider/node/user/types.go @@ -36,6 +36,9 @@ type Provider interface { CreateGroup(ctx context.Context, opts CreateGroupOpts) (*GroupResult, error) UpdateGroup(ctx context.Context, name string, opts UpdateGroupOpts) (*GroupResult, error) DeleteGroup(ctx context.Context, name string) (*GroupResult, error) + ListKeys(ctx context.Context, username string) ([]SSHKey, error) + AddKey(ctx context.Context, username string, key SSHKey) (*SSHKeyResult, error) + RemoveKey(ctx context.Context, username string, fingerprint string) (*SSHKeyResult, error) } // User represents a system user account. @@ -101,3 +104,16 @@ type GroupResult struct { Changed bool `json:"changed"` Error string `json:"error,omitempty"` } + +// SSHKey represents an SSH public key from authorized_keys. +type SSHKey struct { + Type string `json:"type"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment,omitempty"` + RawLine string `json:"raw_line,omitempty"` +} + +// SSHKeyResult represents the result of an SSH key mutation operation. +type SSHKeyResult struct { + Changed bool `json:"changed"` +} diff --git a/pkg/sdk/client/export_test.go b/pkg/sdk/client/export_test.go index dc3c7cecf..176704a8b 100644 --- a/pkg/sdk/client/export_test.go +++ b/pkg/sdk/client/export_test.go @@ -550,6 +550,46 @@ func UserMutationCollectionFromPassword( return userMutationCollectionFromPassword(input) } +// SSHKeyCollectionFromGen exposes the private +// sshKeyCollectionFromGen for testing. +func SSHKeyCollectionFromGen( + input *gen.SSHKeyCollectionResponse, +) Collection[SSHKeyInfoResult] { + return sshKeyCollectionFromGen(input) +} + +// SSHKeyInfoResultFromGen exposes the private +// sshKeyInfoResultFromGen for testing. +func SSHKeyInfoResultFromGen( + input gen.SSHKeyEntry, +) SSHKeyInfoResult { + return sshKeyInfoResultFromGen(input) +} + +// SSHKeyInfoFromGen exposes the private +// sshKeyInfoFromGen for testing. +func SSHKeyInfoFromGen( + input gen.SSHKeyInfo, +) SSHKeyInfo { + return sshKeyInfoFromGen(input) +} + +// SSHKeyMutationCollectionFromGen exposes the private +// sshKeyMutationCollectionFromGen for testing. +func SSHKeyMutationCollectionFromGen( + input *gen.SSHKeyMutationResponse, +) Collection[SSHKeyMutationResult] { + return sshKeyMutationCollectionFromGen(input) +} + +// SSHKeyMutationResultFromGen exposes the private +// sshKeyMutationResultFromGen for testing. +func SSHKeyMutationResultFromGen( + input gen.SSHKeyMutationEntry, +) SSHKeyMutationResult { + return sshKeyMutationResultFromGen(input) +} + // GroupInfoCollectionFromList exposes the private // groupInfoCollectionFromList for testing. func GroupInfoCollectionFromList( diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go index 557f84907..9f3f71c7b 100644 --- a/pkg/sdk/client/gen/client.gen.go +++ b/pkg/sdk/client/gen/client.gen.go @@ -344,6 +344,20 @@ const ( ProcessSignalResultStatusSkipped ProcessSignalResultStatus = "skipped" ) +// Defines values for SSHKeyEntryStatus. +const ( + SSHKeyEntryStatusFailed SSHKeyEntryStatus = "failed" + SSHKeyEntryStatusOk SSHKeyEntryStatus = "ok" + SSHKeyEntryStatusSkipped SSHKeyEntryStatus = "skipped" +) + +// Defines values for SSHKeyMutationEntryStatus. +const ( + SSHKeyMutationEntryStatusFailed SSHKeyMutationEntryStatus = "failed" + SSHKeyMutationEntryStatusOk SSHKeyMutationEntryStatus = "ok" + SSHKeyMutationEntryStatusSkipped SSHKeyMutationEntryStatus = "skipped" +) + // Defines values for SysctlEntryStatus. const ( SysctlEntryStatusFailed SysctlEntryStatus = "failed" @@ -2486,6 +2500,74 @@ type RouteResponse struct { Metric *int `json:"metric,omitempty"` } +// SSHKeyAddRequest defines model for SSHKeyAddRequest. +type SSHKeyAddRequest struct { + // Key Full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string `json:"key" validate:"required,min=1"` +} + +// SSHKeyCollectionResponse defines model for SSHKeyCollectionResponse. +type SSHKeyCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyEntry `json:"results"` +} + +// SSHKeyEntry SSH key list result for a single agent. +type SSHKeyEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Keys SSH authorized keys on this agent. + Keys *[]SSHKeyInfo `json:"keys,omitempty"` + + // Status The status of the operation for this host. + Status SSHKeyEntryStatus `json:"status"` +} + +// SSHKeyEntryStatus The status of the operation for this host. +type SSHKeyEntryStatus string + +// SSHKeyInfo An SSH authorized key entry. +type SSHKeyInfo struct { + // Comment Key comment (typically user@host). + Comment *string `json:"comment,omitempty"` + + // Fingerprint SHA256 fingerprint of the key. + Fingerprint *string `json:"fingerprint,omitempty"` + + // Type Key type (e.g., ssh-rsa, ssh-ed25519). + Type *string `json:"type,omitempty"` +} + +// SSHKeyMutationEntry SSH key mutation result for a single agent. +type SSHKeyMutationEntry struct { + // Changed Whether the operation modified system state. + Changed *bool `json:"changed,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status SSHKeyMutationEntryStatus `json:"status"` +} + +// SSHKeyMutationEntryStatus The status of the operation for this host. +type SSHKeyMutationEntryStatus string + +// SSHKeyMutationResponse defines model for SSHKeyMutationResponse. +type SSHKeyMutationResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []SSHKeyMutationEntry `json:"results"` +} + // StatusResponse defines model for StatusResponse. type StatusResponse struct { Agents *AgentStats `json:"agents,omitempty"` @@ -2910,6 +2992,9 @@ type PackageName = string // Pid defines model for Pid. type Pid = int +// SSHKeyFingerprint defines model for SSHKeyFingerprint. +type SSHKeyFingerprint = string + // SysctlKey defines model for SysctlKey. type SysctlKey = string @@ -3113,6 +3198,9 @@ type PutNodeUserJSONRequestBody = UserUpdateRequest // PostNodeUserPasswordJSONRequestBody defines body for PostNodeUserPassword for application/json ContentType. type PostNodeUserPasswordJSONRequestBody = UserPasswordRequest +// PostNodeUserSSHKeyJSONRequestBody defines body for PostNodeUserSSHKey for application/json ContentType. +type PostNodeUserSSHKeyJSONRequestBody = SSHKeyAddRequest + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -3515,6 +3603,17 @@ type ClientInterface interface { PostNodeUserPassword(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeUserSSHKey request + GetNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeUserSSHKeyWithBody request with any body + PostNodeUserSSHKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteNodeUserSSHKey request + DeleteNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetVersion request GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) } @@ -4959,6 +5058,54 @@ func (c *Client) PostNodeUserPassword(ctx context.Context, hostname Hostname, na return c.Client.Do(req) } +func (c *Client) GetNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeUserSSHKeyRequest(c.Server, hostname, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeUserSSHKeyWithBody(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSSHKeyRequestWithBody(c.Server, hostname, name, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeUserSSHKeyRequest(c.Server, hostname, name, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteNodeUserSSHKey(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteNodeUserSSHKeyRequest(c.Server, hostname, name, fingerprint) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetVersionRequest(c.Server) if err != nil { @@ -8825,6 +8972,149 @@ func NewPostNodeUserPasswordRequestWithBody(server string, hostname Hostname, na return req, nil } +// NewGetNodeUserSSHKeyRequest generates requests for GetNodeUserSSHKey +func NewGetNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostNodeUserSSHKeyRequest calls the generic PostNodeUserSSHKey builder with application/json body +func NewPostNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeUserSSHKeyRequestWithBody(server, hostname, name, "application/json", bodyReader) +} + +// NewPostNodeUserSSHKeyRequestWithBody generates requests for PostNodeUserSSHKey with any type of body +func NewPostNodeUserSSHKeyRequestWithBody(server string, hostname Hostname, name UserName, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteNodeUserSSHKeyRequest generates requests for DeleteNodeUserSSHKey +func NewDeleteNodeUserSSHKeyRequest(server string, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithLocation("simple", false, "fingerprint", runtime.ParamLocationPath, fingerprint) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/user/%s/ssh-key/%s", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetVersionRequest generates requests for GetVersion func NewGetVersionRequest(server string) (*http.Request, error) { var err error @@ -9224,6 +9514,17 @@ type ClientWithResponsesInterface interface { PostNodeUserPasswordWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserPasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserPasswordResponse, error) + // GetNodeUserSSHKeyWithResponse request + GetNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSSHKeyResponse, error) + + // PostNodeUserSSHKeyWithBodyWithResponse request with any body + PostNodeUserSSHKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) + + PostNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) + + // DeleteNodeUserSSHKeyWithResponse request + DeleteNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSSHKeyResponse, error) + // GetVersionWithResponse request GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) } @@ -11540,6 +11841,82 @@ func (r PostNodeUserPasswordResponse) StatusCode() int { return 0 } +type GetNodeUserSSHKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyCollectionResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeUserSSHKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeUserSSHKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeUserSSHKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyMutationResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeUserSSHKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeUserSSHKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteNodeUserSSHKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *SSHKeyMutationResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DeleteNodeUserSSHKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteNodeUserSSHKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetVersionResponse struct { Body []byte HTTPResponse *http.Response @@ -12611,6 +12988,41 @@ func (c *ClientWithResponses) PostNodeUserPasswordWithResponse(ctx context.Conte return ParsePostNodeUserPasswordResponse(rsp) } +// GetNodeUserSSHKeyWithResponse request returning *GetNodeUserSSHKeyResponse +func (c *ClientWithResponses) GetNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, reqEditors ...RequestEditorFn) (*GetNodeUserSSHKeyResponse, error) { + rsp, err := c.GetNodeUserSSHKey(ctx, hostname, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeUserSSHKeyResponse(rsp) +} + +// PostNodeUserSSHKeyWithBodyWithResponse request with arbitrary body returning *PostNodeUserSSHKeyResponse +func (c *ClientWithResponses) PostNodeUserSSHKeyWithBodyWithResponse(ctx context.Context, hostname Hostname, name UserName, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) { + rsp, err := c.PostNodeUserSSHKeyWithBody(ctx, hostname, name, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeUserSSHKeyResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, body PostNodeUserSSHKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeUserSSHKeyResponse, error) { + rsp, err := c.PostNodeUserSSHKey(ctx, hostname, name, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeUserSSHKeyResponse(rsp) +} + +// DeleteNodeUserSSHKeyWithResponse request returning *DeleteNodeUserSSHKeyResponse +func (c *ClientWithResponses) DeleteNodeUserSSHKeyWithResponse(ctx context.Context, hostname Hostname, name UserName, fingerprint SSHKeyFingerprint, reqEditors ...RequestEditorFn) (*DeleteNodeUserSSHKeyResponse, error) { + rsp, err := c.DeleteNodeUserSSHKey(ctx, hostname, name, fingerprint, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteNodeUserSSHKeyResponse(rsp) +} + // GetVersionWithResponse request returning *GetVersionResponse func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) { rsp, err := c.GetVersion(ctx, reqEditors...) @@ -17388,6 +17800,154 @@ func ParsePostNodeUserPasswordResponse(rsp *http.Response) (*PostNodeUserPasswor return response, nil } +// ParseGetNodeUserSSHKeyResponse parses an HTTP response from a GetNodeUserSSHKeyWithResponse call +func ParseGetNodeUserSSHKeyResponse(rsp *http.Response) (*GetNodeUserSSHKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeUserSSHKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeUserSSHKeyResponse parses an HTTP response from a PostNodeUserSSHKeyWithResponse call +func ParsePostNodeUserSSHKeyResponse(rsp *http.Response) (*PostNodeUserSSHKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeUserSSHKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyMutationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteNodeUserSSHKeyResponse parses an HTTP response from a DeleteNodeUserSSHKeyWithResponse call +func ParseDeleteNodeUserSSHKeyResponse(rsp *http.Response) (*DeleteNodeUserSSHKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteNodeUserSSHKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest SSHKeyMutationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetVersionResponse parses an HTTP response from a GetVersionWithResponse call func ParseGetVersionResponse(rsp *http.Response) (*GetVersionResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/pkg/sdk/client/operations.go b/pkg/sdk/client/operations.go index d766286d1..639d23768 100644 --- a/pkg/sdk/client/operations.go +++ b/pkg/sdk/client/operations.go @@ -145,6 +145,13 @@ const ( OpGroupDelete JobOperation = "node.group.delete" ) +// SSH Key operations. +const ( + OpSSHKeyList JobOperation = "node.sshKey.list" + OpSSHKeyAdd JobOperation = "node.sshKey.add" + OpSSHKeyRemove JobOperation = "node.sshKey.remove" +) + // Package operations. const ( OpPackageList JobOperation = "node.package.list" diff --git a/pkg/sdk/client/user.go b/pkg/sdk/client/user.go index 577c7651f..cfe2138f4 100644 --- a/pkg/sdk/client/user.go +++ b/pkg/sdk/client/user.go @@ -237,6 +237,106 @@ func (s *UserService) Delete( return NewResponse(userMutationCollectionFromDelete(resp.JSON200), resp.Body), nil } +// ListKeys returns SSH authorized keys for a user on the target host. +func (s *UserService) ListKeys( + ctx context.Context, + hostname string, + username string, +) (*Response[Collection[SSHKeyInfoResult]], error) { + resp, err := s.client.GetNodeUserSSHKeyWithResponse(ctx, hostname, username) + if err != nil { + return nil, fmt.Errorf("user list keys: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// AddKey adds an SSH authorized key for a user on the target host. +func (s *UserService) AddKey( + ctx context.Context, + hostname string, + username string, + opts SSHKeyAddOpts, +) (*Response[Collection[SSHKeyMutationResult]], error) { + body := gen.SSHKeyAddRequest{ + Key: opts.Key, + } + + resp, err := s.client.PostNodeUserSSHKeyWithResponse(ctx, hostname, username, body) + if err != nil { + return nil, fmt.Errorf("user add key: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyMutationCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// RemoveKey removes an SSH authorized key by fingerprint for a user on the +// target host. +func (s *UserService) RemoveKey( + ctx context.Context, + hostname string, + username string, + fingerprint string, +) (*Response[Collection[SSHKeyMutationResult]], error) { + resp, err := s.client.DeleteNodeUserSSHKeyWithResponse( + ctx, hostname, username, fingerprint, + ) + if err != nil { + return nil, fmt.Errorf("user remove key: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(sshKeyMutationCollectionFromGen(resp.JSON200), resp.Body), nil +} + // ChangePassword changes a user's password on the target host. func (s *UserService) ChangePassword( ctx context.Context, diff --git a/pkg/sdk/client/user_ssh_key_public_test.go b/pkg/sdk/client/user_ssh_key_public_test.go new file mode 100644 index 000000000..6e5b7c282 --- /dev/null +++ b/pkg/sdk/client/user_ssh_key_public_test.go @@ -0,0 +1,520 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" +) + +type UserSSHKeyPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *UserSSHKeyPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *UserSSHKeyPublicTestSuite) TestListKeys() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.SSHKeyInfoResult]], error) + }{ + { + name: "when listing keys returns results", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","keys":[{"type":"ssh-ed25519","fingerprint":"SHA256:abc123","comment":"user@host"}]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.Require().Len(resp.Data.Results[0].Keys, 1) + suite.Equal("ssh-ed25519", resp.Data.Results[0].Keys[0].Type) + suite.Equal("SHA256:abc123", resp.Data.Results[0].Keys[0].Fingerprint) + suite.Equal("user@host", resp.Data.Results[0].Keys[0].Comment) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user list keys") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyInfoResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.ListKeys(suite.ctx, "_any", "testuser") + tc.validateFunc(resp, err) + }) + } +} + +func (suite *UserSSHKeyPublicTestSuite) TestAddKey() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + opts client.SSHKeyAddOpts + validateFunc func(*client.Response[client.Collection[client.SSHKeyMutationResult]], error) + }{ + { + name: "when adding key returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","changed":true}]}`, + ), + ) + }, + opts: client.SSHKeyAddOpts{ + Key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest user@host", + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.True(resp.Data.Results[0].Changed) + }, + }, + { + name: "when server returns 400 returns ValidationError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"validation failed"}`)) + }, + opts: client.SSHKeyAddOpts{Key: ""}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user add key") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + opts: client.SSHKeyAddOpts{Key: "ssh-ed25519 AAAA test"}, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.AddKey(suite.ctx, "_any", "testuser", tc.opts) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *UserSSHKeyPublicTestSuite) TestRemoveKey() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.SSHKeyMutationResult]], error) + }{ + { + name: "when removing key returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","changed":true}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.True(resp.Data.Results[0].Changed) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "user remove key") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.SSHKeyMutationResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.User.RemoveKey(suite.ctx, "_any", "testuser", "SHA256:abc123") + tc.validateFunc(resp, err) + }) + } +} + +func TestUserSSHKeyPublicTestSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(UserSSHKeyPublicTestSuite)) +} diff --git a/pkg/sdk/client/user_ssh_key_types_public_test.go b/pkg/sdk/client/user_ssh_key_types_public_test.go new file mode 100644 index 000000000..43ffadc84 --- /dev/null +++ b/pkg/sdk/client/user_ssh_key_types_public_test.go @@ -0,0 +1,381 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client_test + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +type UserSSHKeyTypesPublicTestSuite struct { + suite.Suite +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *gen.SSHKeyCollectionResponse + validateFunc func(client.Collection[client.SSHKeyInfoResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.SSHKeyCollectionResponse { + keyType := "ssh-ed25519" + fingerprint := "SHA256:abc123" + comment := "user@host" + + return &gen.SSHKeyCollectionResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{ + { + Type: &keyType, + Fingerprint: &fingerprint, + Comment: &comment, + }, + }, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.Empty(r.Error) + suite.Require().Len(r.Keys, 1) + + k := r.Keys[0] + suite.Equal("ssh-ed25519", k.Type) + suite.Equal("SHA256:abc123", k.Fingerprint) + suite.Equal("user@host", k.Comment) + }, + }, + { + name: "when minimal with error", + input: func() *gen.SSHKeyCollectionResponse { + errMsg := "permission denied" + + return &gen.SSHKeyCollectionResponse{ + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusFailed, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("failed", r.Status) + suite.Equal("permission denied", r.Error) + suite.Nil(r.Keys) + }, + }, + { + name: "when multiple hosts", + input: func() *gen.SSHKeyCollectionResponse { + keyType1 := "ssh-rsa" + keyType2 := "ssh-ed25519" + + return &gen.SSHKeyCollectionResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{{Type: &keyType1}}, + }, + { + Hostname: "web-02", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{{Type: &keyType2}}, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyInfoResult]) { + suite.Require().Len(c.Results, 2) + suite.Equal("web-01", c.Results[0].Hostname) + suite.Equal("web-02", c.Results[1].Hostname) + suite.Equal("ssh-rsa", c.Results[0].Keys[0].Type) + suite.Equal("ssh-ed25519", c.Results[1].Keys[0].Type) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyInfoResultFromGen() { + tests := []struct { + name string + input gen.SSHKeyEntry + validateFunc func(client.SSHKeyInfoResult) + }{ + { + name: "when entry has keys", + input: func() gen.SSHKeyEntry { + keyType := "ssh-ed25519" + fp := "SHA256:xyz" + comment := "admin@server" + + return gen.SSHKeyEntry{ + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + Keys: &[]gen.SSHKeyInfo{ + { + Type: &keyType, + Fingerprint: &fp, + Comment: &comment, + }, + }, + } + }(), + validateFunc: func(r client.SSHKeyInfoResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.Require().Len(r.Keys, 1) + suite.Equal("ssh-ed25519", r.Keys[0].Type) + suite.Equal("SHA256:xyz", r.Keys[0].Fingerprint) + suite.Equal("admin@server", r.Keys[0].Comment) + }, + }, + { + name: "when entry has no keys", + input: gen.SSHKeyEntry{ + Hostname: "web-01", + Status: gen.SSHKeyEntryStatusOk, + }, + validateFunc: func(r client.SSHKeyInfoResult) { + suite.Equal("web-01", r.Hostname) + suite.Nil(r.Keys) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyInfoResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyInfoFromGen() { + tests := []struct { + name string + input gen.SSHKeyInfo + validateFunc func(client.SSHKeyInfo) + }{ + { + name: "when all fields populated", + input: func() gen.SSHKeyInfo { + keyType := "ssh-rsa" + fp := "SHA256:def456" + comment := "test@laptop" + + return gen.SSHKeyInfo{ + Type: &keyType, + Fingerprint: &fp, + Comment: &comment, + } + }(), + validateFunc: func(k client.SSHKeyInfo) { + suite.Equal("ssh-rsa", k.Type) + suite.Equal("SHA256:def456", k.Fingerprint) + suite.Equal("test@laptop", k.Comment) + }, + }, + { + name: "when all fields nil", + input: gen.SSHKeyInfo{}, + validateFunc: func(k client.SSHKeyInfo) { + suite.Empty(k.Type) + suite.Empty(k.Fingerprint) + suite.Empty(k.Comment) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyInfoFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyMutationCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *gen.SSHKeyMutationResponse + validateFunc func(client.Collection[client.SSHKeyMutationResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.SSHKeyMutationResponse { + changed := true + + return &gen.SSHKeyMutationResponse{ + JobId: &testUUID, + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: &changed, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyMutationResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.True(r.Changed) + suite.Empty(r.Error) + }, + }, + { + name: "when error result", + input: func() *gen.SSHKeyMutationResponse { + errMsg := "key already exists" + changed := false + + return &gen.SSHKeyMutationResponse{ + Results: []gen.SSHKeyMutationEntry{ + { + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusFailed, + Changed: &changed, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.SSHKeyMutationResult]) { + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("failed", r.Status) + suite.False(r.Changed) + suite.Equal("key already exists", r.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyMutationCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *UserSSHKeyTypesPublicTestSuite) TestSSHKeyMutationResultFromGen() { + tests := []struct { + name string + input gen.SSHKeyMutationEntry + validateFunc func(client.SSHKeyMutationResult) + }{ + { + name: "when successful mutation", + input: func() gen.SSHKeyMutationEntry { + changed := true + + return gen.SSHKeyMutationEntry{ + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusOk, + Changed: &changed, + } + }(), + validateFunc: func(r client.SSHKeyMutationResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.True(r.Changed) + suite.Empty(r.Error) + }, + }, + { + name: "when nil optional fields", + input: gen.SSHKeyMutationEntry{ + Hostname: "web-01", + Status: gen.SSHKeyMutationEntryStatusSkipped, + }, + validateFunc: func(r client.SSHKeyMutationResult) { + suite.Equal("web-01", r.Hostname) + suite.Equal("skipped", r.Status) + suite.False(r.Changed) + suite.Empty(r.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.SSHKeyMutationResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestUserSSHKeyTypesPublicTestSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(UserSSHKeyTypesPublicTestSuite)) +} diff --git a/pkg/sdk/client/user_types.go b/pkg/sdk/client/user_types.go index 4f1bfd5b1..0393a8708 100644 --- a/pkg/sdk/client/user_types.go +++ b/pkg/sdk/client/user_types.go @@ -85,6 +85,35 @@ type UserUpdateOpts struct { Lock *bool } +// SSHKeyInfoResult represents SSH key list result for one host. +type SSHKeyInfoResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Keys []SSHKeyInfo `json:"keys,omitempty"` + Error string `json:"error,omitempty"` +} + +// SSHKeyInfo represents a single SSH authorized key. +type SSHKeyInfo struct { + Type string `json:"type,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comment string `json:"comment,omitempty"` +} + +// SSHKeyMutationResult represents SSH key add/remove result for one host. +type SSHKeyMutationResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` +} + +// SSHKeyAddOpts contains options for adding an SSH key. +type SSHKeyAddOpts struct { + // Key is the full SSH public key line (e.g., "ssh-ed25519 AAAA... user@host"). + Key string +} + // userInfoCollectionFromList converts a gen.UserCollectionResponse // to a Collection[UserInfoResult]. func userInfoCollectionFromList( @@ -192,3 +221,81 @@ func userInfoFromGen( Locked: derefBool(g.Locked), } } + +// sshKeyCollectionFromGen converts a gen.SSHKeyCollectionResponse +// to a Collection[SSHKeyInfoResult]. +func sshKeyCollectionFromGen( + g *gen.SSHKeyCollectionResponse, +) Collection[SSHKeyInfoResult] { + results := make([]SSHKeyInfoResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, sshKeyInfoResultFromGen(r)) + } + + return Collection[SSHKeyInfoResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// sshKeyInfoResultFromGen converts a gen.SSHKeyEntry to an SSHKeyInfoResult. +func sshKeyInfoResultFromGen( + r gen.SSHKeyEntry, +) SSHKeyInfoResult { + result := SSHKeyInfoResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + } + + if r.Keys != nil { + keys := make([]SSHKeyInfo, 0, len(*r.Keys)) + for _, k := range *r.Keys { + keys = append(keys, sshKeyInfoFromGen(k)) + } + + result.Keys = keys + } + + return result +} + +// sshKeyInfoFromGen converts a gen.SSHKeyInfo to an SSHKeyInfo. +func sshKeyInfoFromGen( + k gen.SSHKeyInfo, +) SSHKeyInfo { + return SSHKeyInfo{ + Type: derefString(k.Type), + Fingerprint: derefString(k.Fingerprint), + Comment: derefString(k.Comment), + } +} + +// sshKeyMutationCollectionFromGen converts a gen.SSHKeyMutationResponse +// to a Collection[SSHKeyMutationResult]. +func sshKeyMutationCollectionFromGen( + g *gen.SSHKeyMutationResponse, +) Collection[SSHKeyMutationResult] { + results := make([]SSHKeyMutationResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, sshKeyMutationResultFromGen(r)) + } + + return Collection[SSHKeyMutationResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// sshKeyMutationResultFromGen converts a gen.SSHKeyMutationEntry +// to an SSHKeyMutationResult. +func sshKeyMutationResultFromGen( + r gen.SSHKeyMutationEntry, +) SSHKeyMutationResult { + return SSHKeyMutationResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Changed: derefBool(r.Changed), + Error: derefString(r.Error), + } +}