From 949174f6b505e9f72f2802ba3457049f86bb98f4 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 4 May 2025 08:08:23 +0200 Subject: [PATCH] feat: add SSH key management --- README.md | 35 ++++++++++++---- api/rcond.yaml | 62 ++++++++++++++++++++++++++++ go.mod | 3 ++ go.sum | 6 +++ pkg/http/handlers.go | 49 ++++++++++++++++++++++ pkg/http/server.go | 2 + pkg/user/ssh.go | 98 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 pkg/user/ssh.go diff --git a/README.md b/README.md index dcba914..1b3ec17 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,9 @@ # rcond -A simple daemon to manage +A simple daemon and REST API to manage: - network connections through NetworkManager's D-Bus interface - system hostname through the hostname1 service - -It provides a REST API to: -- Create and activate WiFi connections -- Deactivate WiFi connections -- Remove stored connection profiles -- Get and set the system hostname +- authorized SSH keys through the user's authorized_keys file ## Build and Run @@ -31,6 +26,8 @@ The full API specification can be found in [api/rcond.yaml](api/rcond.yaml). | POST | `/network/remove` | Remove the stored connection profile | | GET | `/hostname` | Get the hostname | | POST | `/hostname` | Set the hostname | +| POST | `/authorized-key` | Add an authorized SSH key | +| DELETE | `/authorized-key` | Remove an authorized SSH key | ### Response Codes @@ -92,4 +89,28 @@ curl -v -X POST http://localhost:8080/hostname \ -d '{ "hostname": "MyHostname" }' +``` + +### Add an authorized SSH key + +```bash +curl -v -X POST http://localhost:8080/authorized-key \ + -H "Content-Type: application/json" \ + -H "X-API-Token: 1234567890" \ + -d '{ + "user": "pi", + "pubkey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1234567890" + }' +``` + +### Remove an authorized SSH key + +```bash +curl -v -X DELETE http://localhost:8080/authorized-key \ + -H "Content-Type: application/json" \ + -H "X-API-Token: 1234567890" \ + -d '{ + "user": "pi", + "pubkey": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1234567890" + }' ``` \ No newline at end of file diff --git a/api/rcond.yaml b/api/rcond.yaml index 009c0f7..a3fe3bd 100644 --- a/api/rcond.yaml +++ b/api/rcond.yaml @@ -154,3 +154,65 @@ paths: description: Unauthorized - invalid or missing API token '500': description: Internal server error + /authorized-key: + post: + summary: Add SSH authorized key + description: Adds an SSH public key to a user's authorized_keys file + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user + - pubkey + properties: + user: + type: string + description: Username to add key for + example: "pi" + pubkey: + type: string + description: SSH public key to add + example: "ssh-rsa AAAAB3NzaC1yc2E... user@host" + responses: + '200': + description: Key added successfully + '400': + description: Invalid request payload or SSH key format + '401': + description: Unauthorized - invalid or missing API token + '500': + description: Internal server error + delete: + summary: Remove SSH authorized key + description: Removes an SSH public key from a user's authorized_keys file + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user + - pubkey + properties: + user: + type: string + description: Username to remove key for + example: "pi" + pubkey: + type: string + description: SSH public key to remove + example: "ssh-rsa AAAAB3NzaC1yc2E... user@host" + responses: + '200': + description: Key removed successfully + '400': + description: Invalid request payload or SSH key format + '401': + description: Unauthorized - invalid or missing API token + '500': + description: Internal server error + diff --git a/go.mod b/go.mod index 41585a9..cd63a87 100644 --- a/go.mod +++ b/go.mod @@ -9,4 +9,7 @@ replace github.com/0x1d/rcond/pkg => ./pkg require ( github.com/godbus/dbus/v5 v5.1.0 github.com/gorilla/mux v1.8.1 + golang.org/x/crypto v0.37.0 ) + +require golang.org/x/sys v0.32.0 // indirect diff --git a/go.sum b/go.sum index 337a340..311e408 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,9 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= diff --git a/pkg/http/handlers.go b/pkg/http/handlers.go index a980f39..bd5f50c 100644 --- a/pkg/http/handlers.go +++ b/pkg/http/handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/0x1d/rcond/pkg/network" + "github.com/0x1d/rcond/pkg/user" ) const ( @@ -108,3 +109,51 @@ func HandleSetHostname(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +func HandleAddAuthorizedKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + User string `json:"user"` + PubKey string `json:"pubkey"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := user.AddAuthorizedKey(req.User, req.PubKey); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func HandleRemoveAuthorizedKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + User string `json:"user"` + PubKey string `json:"pubkey"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := user.RemoveAuthorizedKey(req.User, req.PubKey); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/http/server.go b/pkg/http/server.go index e9665f1..40a0e71 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -58,6 +58,8 @@ func (s *Server) RegisterRoutes() { s.router.HandleFunc("/network/remove", s.verifyToken(HandleNetworkRemove)).Methods(http.MethodPost) s.router.HandleFunc("/hostname", s.verifyToken(HandleGetHostname)).Methods(http.MethodGet) s.router.HandleFunc("/hostname", s.verifyToken(HandleSetHostname)).Methods(http.MethodPost) + s.router.HandleFunc("/authorized-key", s.verifyToken(HandleAddAuthorizedKey)).Methods(http.MethodPost) + s.router.HandleFunc("/authorized-key", s.verifyToken(HandleRemoveAuthorizedKey)).Methods(http.MethodDelete) } func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/user/ssh.go b/pkg/user/ssh.go new file mode 100644 index 0000000..1e765b1 --- /dev/null +++ b/pkg/user/ssh.go @@ -0,0 +1,98 @@ +package user + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/crypto/ssh" +) + +// AddAuthorizedKey verifies and adds an SSH public key to /home//.ssh/authorized_keys +// if it doesn't already exist +func AddAuthorizedKey(user string, pubKey string) error { + // Verify the public key format + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKey)) + if err != nil { + return fmt.Errorf("invalid SSH public key: %v", err) + } + + // Ensure .ssh directory exists + sshDir := fmt.Sprintf("/home/%s/.ssh", user) + if err := os.MkdirAll(sshDir, 0700); err != nil { + return fmt.Errorf("failed to create .ssh directory: %v", err) + } + + // Check if key already exists + keyFile := fmt.Sprintf("%s/authorized_keys", sshDir) + if _, err := os.Stat(keyFile); err == nil { + existingKeys, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("failed to read authorized_keys: %v", err) + } + if string(existingKeys) != "" { + for _, line := range strings.Split(string(existingKeys), "\n") { + if line == pubKey { + // Key already exists, nothing to do + return nil + } + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check authorized_keys: %v", err) + } + + // Open authorized_keys file in append mode + f, err := os.OpenFile(keyFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open authorized_keys: %v", err) + } + defer f.Close() + + // Write the public key + if _, err := f.WriteString(pubKey + "\n"); err != nil { + return fmt.Errorf("failed to write public key: %v", err) + } + + return nil +} + +// RemoveAuthorizedKey removes an authorized SSH key from /home//.ssh/authorized_keys +func RemoveAuthorizedKey(user string, pubKey string) error { + // Verify the public key format + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKey)) + if err != nil { + return fmt.Errorf("invalid SSH public key: %v", err) + } + + // Check if authorized_keys file exists + keyFile := fmt.Sprintf("/home/%s/.ssh/authorized_keys", user) + if _, err := os.Stat(keyFile); err != nil { + if os.IsNotExist(err) { + return nil // Nothing to remove + } + return fmt.Errorf("failed to check authorized_keys: %v", err) + } + + // Read existing keys + existingKeys, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("failed to read authorized_keys: %v", err) + } + + // Filter out the key to remove + var newLines []string + for _, line := range strings.Split(string(existingKeys), "\n") { + if line != "" && line != pubKey { + newLines = append(newLines, line) + } + } + + // Write back the filtered keys + err = os.WriteFile(keyFile, []byte(strings.Join(newLines, "\n")+"\n"), 0600) + if err != nil { + return fmt.Errorf("failed to write authorized_keys: %v", err) + } + + return nil +}