mirror of
https://github.com/0x1d/rcond.git
synced 2025-12-14 18:25:21 +01:00
feat: add SSH key management
This commit is contained in:
35
README.md
35
README.md
@@ -1,14 +1,9 @@
|
|||||||
# rcond
|
# rcond
|
||||||
|
|
||||||
A simple daemon to manage
|
A simple daemon and REST API to manage:
|
||||||
- network connections through NetworkManager's D-Bus interface
|
- network connections through NetworkManager's D-Bus interface
|
||||||
- system hostname through the hostname1 service
|
- system hostname through the hostname1 service
|
||||||
|
- authorized SSH keys through the user's authorized_keys file
|
||||||
It provides a REST API to:
|
|
||||||
- Create and activate WiFi connections
|
|
||||||
- Deactivate WiFi connections
|
|
||||||
- Remove stored connection profiles
|
|
||||||
- Get and set the system hostname
|
|
||||||
|
|
||||||
## Build and Run
|
## 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 |
|
| POST | `/network/remove` | Remove the stored connection profile |
|
||||||
| GET | `/hostname` | Get the hostname |
|
| GET | `/hostname` | Get the hostname |
|
||||||
| POST | `/hostname` | Set 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
|
### Response Codes
|
||||||
|
|
||||||
@@ -92,4 +89,28 @@ curl -v -X POST http://localhost:8080/hostname \
|
|||||||
-d '{
|
-d '{
|
||||||
"hostname": "MyHostname"
|
"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"
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
@@ -154,3 +154,65 @@ paths:
|
|||||||
description: Unauthorized - invalid or missing API token
|
description: Unauthorized - invalid or missing API token
|
||||||
'500':
|
'500':
|
||||||
description: Internal server error
|
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
|
||||||
|
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -9,4 +9,7 @@ replace github.com/0x1d/rcond/pkg => ./pkg
|
|||||||
require (
|
require (
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.32.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
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/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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
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=
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/0x1d/rcond/pkg/network"
|
"github.com/0x1d/rcond/pkg/network"
|
||||||
|
"github.com/0x1d/rcond/pkg/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -108,3 +109,51 @@ func HandleSetHostname(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func (s *Server) RegisterRoutes() {
|
|||||||
s.router.HandleFunc("/network/remove", s.verifyToken(HandleNetworkRemove)).Methods(http.MethodPost)
|
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(HandleGetHostname)).Methods(http.MethodGet)
|
||||||
s.router.HandleFunc("/hostname", s.verifyToken(HandleSetHostname)).Methods(http.MethodPost)
|
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) {
|
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
98
pkg/user/ssh.go
Normal file
98
pkg/user/ssh.go
Normal file
@@ -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/<user>/.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/<user>/.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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user