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),
+ }
+}