Compare commits
5 Commits
agent/v1.0.3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 407a610cfb | |||
| 4ea7f369f1 | |||
| 2166a483ca | |||
| f9c3fc5379 | |||
| f62b0054db |
@@ -50,7 +50,11 @@ type ServerCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GenerateKeyCmd struct {
|
type GenerateKeyCmd struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
KeyType string `json:"key_type,omitempty"`
|
||||||
|
KeySize int `json:"key_size,omitempty"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentMessage struct {
|
type AgentMessage struct {
|
||||||
|
|||||||
@@ -93,19 +93,36 @@ func fingerprint(pubKey string) string {
|
|||||||
return "MD5:" + strings.Join(pairs, ":")
|
return "MD5:" + strings.Join(pairs, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateKeyPair generates an ed25519 SSH keypair and returns the public key.
|
// KeyGenOptions controls how ssh-keygen is invoked.
|
||||||
|
type KeyGenOptions struct {
|
||||||
|
KeyType string // ed25519 (default), rsa, ecdsa
|
||||||
|
KeySize int // bits; used for rsa and ecdsa
|
||||||
|
Passphrase string // empty = no passphrase
|
||||||
|
Comment string // embedded in the public key
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyPair generates an SSH keypair and returns the public key.
|
||||||
// The private key is written to keyPath; keyPath+".pub" holds the public key.
|
// The private key is written to keyPath; keyPath+".pub" holds the public key.
|
||||||
func GenerateKeyPair(keyPath, comment string) (string, error) {
|
func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{
|
keyType := opts.KeyType
|
||||||
"-t", "ed25519",
|
if keyType == "" {
|
||||||
"-f", keyPath,
|
keyType = "ed25519"
|
||||||
"-N", "",
|
|
||||||
"-C", comment,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-t", keyType,
|
||||||
|
"-f", keyPath,
|
||||||
|
"-N", opts.Passphrase,
|
||||||
|
"-C", opts.Comment,
|
||||||
|
}
|
||||||
|
if opts.KeySize > 0 && keyType != "ed25519" {
|
||||||
|
args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize))
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command("ssh-keygen", args...)
|
cmd := exec.Command("ssh-keygen", args...)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -166,10 +166,17 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||||
label := cmd.GenerateKey.Label
|
g := cmd.GenerateKey
|
||||||
|
label := g.Label
|
||||||
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
|
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
|
||||||
|
|
||||||
pubKey, err := keys.GenerateKeyPair(keyPath, label)
|
opts := keys.KeyGenOptions{
|
||||||
|
KeyType: g.KeyType,
|
||||||
|
KeySize: g.KeySize,
|
||||||
|
Passphrase: g.Passphrase,
|
||||||
|
Comment: g.Comment,
|
||||||
|
}
|
||||||
|
pubKey, err := keys.GenerateKeyPair(keyPath, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
|
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
|
||||||
return
|
return
|
||||||
@@ -214,7 +221,7 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
|
|||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
|
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
|
||||||
pubKey, err := keys.GenerateKeyPair(keyPath, label)
|
pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,5 +71,9 @@ message ServerCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message GenerateKeyCmd {
|
message GenerateKeyCmd {
|
||||||
string label = 1;
|
string label = 1;
|
||||||
|
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
|
||||||
|
int32 key_size = 3; // bits; used for rsa and ecdsa
|
||||||
|
string passphrase = 4; // empty = no passphrase
|
||||||
|
string comment = 5; // embedded in public key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
func RegisterRoutes(r *gin.Engine) {
|
func RegisterRoutes(r *gin.Engine) {
|
||||||
r.GET("/install", handleInstallScript)
|
r.GET("/install", handleInstallScript)
|
||||||
|
r.GET("/update", handleUpdateScript)
|
||||||
|
|
||||||
// Auth endpoints (no session required)
|
// Auth endpoints (no session required)
|
||||||
r.GET("/auth/login", auth.HandleLogin)
|
r.GET("/auth/login", auth.HandleLogin)
|
||||||
@@ -35,6 +36,7 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
apiGroup.GET("/keys", listKeys)
|
apiGroup.GET("/keys", listKeys)
|
||||||
apiGroup.POST("/keys", createKey)
|
apiGroup.POST("/keys", createKey)
|
||||||
apiGroup.GET("/keys/:id", getKey)
|
apiGroup.GET("/keys/:id", getKey)
|
||||||
|
apiGroup.GET("/keys/:id/private-key", getPrivateKey)
|
||||||
apiGroup.DELETE("/keys/:id", deleteKey)
|
apiGroup.DELETE("/keys/:id", deleteKey)
|
||||||
apiGroup.POST("/keys/:id/assign", assignKey)
|
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||||
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||||
@@ -125,7 +127,11 @@ func generateKey(c *gin.Context) {
|
|||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
KeyType string `json:"key_type"`
|
||||||
|
KeySize int `json:"key_size"`
|
||||||
|
Passphrase string `json:"passphrase"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
_ = c.ShouldBindJSON(&body)
|
_ = c.ShouldBindJSON(&body)
|
||||||
if body.Label == "" {
|
if body.Label == "" {
|
||||||
@@ -138,7 +144,13 @@ func generateKey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cmdID, err := services.DispatchGenerateKey(s.ServerID, body.Label)
|
cmdID, err := services.DispatchGenerateKey(s.ServerID, services.KeyGenParams{
|
||||||
|
Label: body.Label,
|
||||||
|
KeyType: body.KeyType,
|
||||||
|
KeySize: body.KeySize,
|
||||||
|
Passphrase: body.Passphrase,
|
||||||
|
Comment: body.Comment,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -162,15 +174,16 @@ func listKeys(c *gin.Context) {
|
|||||||
|
|
||||||
func createKey(c *gin.Context) {
|
func createKey(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Label string `json:"label" binding:"required"`
|
Label string `json:"label" binding:"required"`
|
||||||
PublicKey string `json:"public_key" binding:"required"`
|
PublicKey string `json:"public_key" binding:"required"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "")
|
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -178,6 +191,16 @@ func createKey(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, key)
|
c.JSON(http.StatusCreated, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPrivateKey(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
plaintext, err := services.GetPrivateKey(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"private_key": plaintext})
|
||||||
|
}
|
||||||
|
|
||||||
func getKey(c *gin.Context) {
|
func getKey(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
key, err := services.GetKey(id)
|
key, err := services.GetKey(id)
|
||||||
@@ -236,6 +259,62 @@ func revokeAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleUpdateScript(c *gin.Context) {
|
||||||
|
giteaHost := os.Getenv("GITEA_HOST")
|
||||||
|
if giteaHost == "" {
|
||||||
|
giteaHost = "gitea.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_HOST="%s"
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64) ARCH="arm64" ;;
|
||||||
|
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Get latest agent release tag
|
||||||
|
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
|
||||||
|
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
|
||||||
|
|
||||||
|
if [ -z "$LATEST" ]; then
|
||||||
|
echo "Could not determine latest agent version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${LATEST#agent/}"
|
||||||
|
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||||
|
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/keymanager-agent-linux-${ARCH}"
|
||||||
|
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/checksums.txt"
|
||||||
|
|
||||||
|
echo "Updating keymanager-agent to ${VERSION} (${ARCH})..."
|
||||||
|
|
||||||
|
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
|
||||||
|
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
|
||||||
|
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
|
||||||
|
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||||
|
echo "Checksum mismatch!" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl stop keymanager-agent || true
|
||||||
|
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
|
||||||
|
systemctl start keymanager-agent
|
||||||
|
|
||||||
|
echo "keymanager-agent updated to ${VERSION} and restarted."
|
||||||
|
`, giteaHost)
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/x-shellscript")
|
||||||
|
c.String(http.StatusOK, script)
|
||||||
|
}
|
||||||
|
|
||||||
func handleInstallScript(c *gin.Context) {
|
func handleInstallScript(c *gin.Context) {
|
||||||
serverID := c.Query("server_id")
|
serverID := c.Query("server_id")
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ type ServerCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GenerateKeyCmd struct {
|
type GenerateKeyCmd struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
KeyType string `json:"key_type,omitempty"`
|
||||||
|
KeySize int `json:"key_size,omitempty"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentMessage struct {
|
type AgentMessage struct {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
|
|||||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID)
|
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
|
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
|
|
||||||
type Key struct {
|
type Key struct {
|
||||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||||
KeyID string `bson:"key_id" json:"key_id"`
|
KeyID string `bson:"key_id" json:"key_id"`
|
||||||
Label string `bson:"label" json:"label"`
|
Label string `bson:"label" json:"label"`
|
||||||
PublicKey string `bson:"public_key" json:"public_key"`
|
PublicKey string `bson:"public_key" json:"public_key"`
|
||||||
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
|
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
|
||||||
Source string `bson:"source" json:"source"` // uploaded | generated
|
Source string `bson:"source" json:"source"` // uploaded | generated
|
||||||
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
|
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
|
||||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
|
||||||
|
HasPrivateKey bool `bson:"-" json:"has_private_key"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encryptionKey() ([]byte, error) {
|
||||||
|
raw := os.Getenv("KEY_ENCRYPTION_KEY")
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY is not set")
|
||||||
|
}
|
||||||
|
key, err := hex.DecodeString(raw)
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("KEY_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)")
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptPrivateKey(plaintext string) (string, error) {
|
||||||
|
key, err := encryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return hex.EncodeToString(sealed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptPrivateKey(ciphertextHex string) (string, error) {
|
||||||
|
key, err := encryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := hex.DecodeString(ciphertextHex)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid ciphertext encoding")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decryption failed")
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
@@ -58,16 +58,31 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KeyGenParams carries all options for a generate-key command.
|
||||||
|
type KeyGenParams struct {
|
||||||
|
Label string
|
||||||
|
KeyType string
|
||||||
|
KeySize int
|
||||||
|
Passphrase string
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
// DispatchGenerateKey sends a generate-key command to the named server's agent.
|
// DispatchGenerateKey sends a generate-key command to the named server's agent.
|
||||||
// Returns the command ID that can be used to correlate the agent's result.
|
// Returns the command ID that can be used to correlate the agent's result.
|
||||||
func DispatchGenerateKey(serverID, label string) (string, error) {
|
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
|
||||||
if !Dispatcher.IsConnected(serverID) {
|
if !Dispatcher.IsConnected(serverID) {
|
||||||
return "", fmt.Errorf("agent is not connected to the command stream")
|
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||||
}
|
}
|
||||||
cmdID := uuid.New().String()
|
cmdID := uuid.New().String()
|
||||||
cmd := &pb.ServerCommand{
|
cmd := &pb.ServerCommand{
|
||||||
CommandId: cmdID,
|
CommandId: cmdID,
|
||||||
GenerateKey: &pb.GenerateKeyCmd{Label: label},
|
GenerateKey: &pb.GenerateKeyCmd{
|
||||||
|
Label: p.Label,
|
||||||
|
KeyType: p.KeyType,
|
||||||
|
KeySize: p.KeySize,
|
||||||
|
Passphrase: p.Passphrase,
|
||||||
|
Comment: p.Comment,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ func computeFingerprint(pubKey string) string {
|
|||||||
return "MD5:" + strings.Join(pairs, ":")
|
return "MD5:" + strings.Join(pairs, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Key, error) {
|
func setKeyMeta(k *models.Key) {
|
||||||
|
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
|
||||||
key := &models.Key{
|
key := &models.Key{
|
||||||
KeyID: uuid.NewString(),
|
KeyID: uuid.NewString(),
|
||||||
Label: label,
|
Label: label,
|
||||||
@@ -41,6 +45,13 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
|
|||||||
GeneratedByServerID: generatedByServerID,
|
GeneratedByServerID: generatedByServerID,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
if privateKey != "" {
|
||||||
|
enc, err := encryptPrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encrypt private key: %w", err)
|
||||||
|
}
|
||||||
|
key.PrivateKeyEncrypted = enc
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -48,6 +59,7 @@ func CreateKey(label, publicKey, source, generatedByServerID string) (*models.Ke
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
setKeyMeta(key)
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +72,24 @@ func GetKey(keyID string) (*models.Key, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
setKeyMeta(&key)
|
||||||
return &key, nil
|
return &key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPrivateKey(keyID string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var key models.Key
|
||||||
|
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if key.PrivateKeyEncrypted == "" {
|
||||||
|
return "", fmt.Errorf("no private key stored for this key")
|
||||||
|
}
|
||||||
|
return decryptPrivateKey(key.PrivateKeyEncrypted)
|
||||||
|
}
|
||||||
|
|
||||||
type KeyWithCount struct {
|
type KeyWithCount struct {
|
||||||
models.Key `bson:",inline"`
|
models.Key `bson:",inline"`
|
||||||
AssignedCount int `bson:"-" json:"assigned_count"`
|
AssignedCount int `bson:"-" json:"assigned_count"`
|
||||||
@@ -85,6 +112,7 @@ func ListKeys() ([]KeyWithCount, error) {
|
|||||||
|
|
||||||
result := make([]KeyWithCount, 0, len(keys))
|
result := make([]KeyWithCount, 0, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
setKeyMeta(&k)
|
||||||
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
||||||
"key_id": k.KeyID,
|
"key_id": k.KeyID,
|
||||||
"revoked_at": nil,
|
"revoked_at": nil,
|
||||||
@@ -98,7 +126,10 @@ func DeleteKey(keyID string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID})
|
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +243,12 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
|
|||||||
|
|
||||||
result := make([]AssignmentWithKey, 0, len(assignments))
|
result := make([]AssignmentWithKey, 0, len(assignments))
|
||||||
for _, a := range assignments {
|
for _, a := range assignments {
|
||||||
item := AssignmentWithKey{Assignment: a}
|
|
||||||
var key models.Key
|
var key models.Key
|
||||||
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err == nil {
|
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
|
||||||
item.Key = &key
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, item)
|
setKeyMeta(&key)
|
||||||
|
result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,97 @@ function AssignModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PrivateKeyCard({ keyId }: { keyId: string }) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [privateKey, setPrivateKey] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function reveal() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.getPrivateKey(keyId);
|
||||||
|
setPrivateKey(res.private_key);
|
||||||
|
setRevealed(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!privateKey) return;
|
||||||
|
const blob = new Blob([privateKey], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${keyId}.pem`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!privateKey) return;
|
||||||
|
await navigator.clipboard.writeText(privateKey);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Private Key</CardTitle>
|
||||||
|
{revealed && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{copied ? <span className="text-success">Copied!</span> : "Copy"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={download}
|
||||||
|
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Download .pem
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!revealed ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-4">
|
||||||
|
<p className="text-center text-xs text-text-tertiary">
|
||||||
|
Stored encrypted (AES-256-GCM). Click to decrypt and display.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" size="sm" loading={loading} onClick={reveal}>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.964-7.178z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Reveal Private Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
|
||||||
|
{privateKey}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function KeyDetailPage() {
|
export default function KeyDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -252,6 +343,8 @@ export default function KeyDetailPage() {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
|
|||||||
+16
-2
@@ -11,9 +11,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [publicKey, setPublicKey] = useState("");
|
const [publicKey, setPublicKey] = useState("");
|
||||||
|
const [privateKey, setPrivateKey] = useState("");
|
||||||
|
|
||||||
const { mutate: upload, isPending, error } = useMutation({
|
const { mutate: upload, isPending, error } = useMutation({
|
||||||
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()),
|
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -52,7 +53,20 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
|
|||||||
value={publicKey}
|
value={publicKey}
|
||||||
onChange={(e) => setPublicKey(e.target.value)}
|
onChange={(e) => setPublicKey(e.target.value)}
|
||||||
placeholder="ssh-ed25519 AAAA..."
|
placeholder="ssh-ed25519 AAAA..."
|
||||||
rows={4}
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Private Key{" "}
|
||||||
|
<span className="text-text-tertiary font-normal">(optional — stored AES-256-GCM encrypted)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
rows={3}
|
||||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api, ServerStatus } from "@/lib/api";
|
import { api, ServerStatus, GenerateKeyOptions } from "@/lib/api";
|
||||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||||
|
|
||||||
@@ -20,12 +20,174 @@ function formatDate(dateStr: string) {
|
|||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KEY_SIZES: Record<string, number[]> = {
|
||||||
|
rsa: [2048, 3072, 4096],
|
||||||
|
ecdsa: [256, 384, 521],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: Record<string, number> = {
|
||||||
|
rsa: 4096,
|
||||||
|
ecdsa: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
function GenerateKeyModal({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (opts: GenerateKeyOptions) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}) {
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [keyType, setKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||||
|
const [keySize, setKeySize] = useState<number>(4096);
|
||||||
|
const [passphrase, setPassphrase] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
function handleKeyTypeChange(t: "ed25519" | "rsa" | "ecdsa") {
|
||||||
|
setKeyType(t);
|
||||||
|
if (t !== "ed25519") {
|
||||||
|
setKeySize(DEFAULT_SIZE[t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label: label || "generated",
|
||||||
|
key_type: keyType,
|
||||||
|
key_size: keyType !== "ed25519" ? keySize : undefined,
|
||||||
|
passphrase: passphrase || undefined,
|
||||||
|
comment: comment || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = KEY_SIZES[keyType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-surface-1 p-6 shadow-2xl">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">Generate SSH Key</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Label <span className="text-text-tertiary">(used as the key name in KeyManager)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={e => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g. server-deploy-key"
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Type</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(["ed25519", "rsa", "ecdsa"] as const).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleKeyTypeChange(t)}
|
||||||
|
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
keyType === t
|
||||||
|
? "border-accent bg-accent/10 text-accent"
|
||||||
|
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{keyType === "ed25519" && (
|
||||||
|
<p className="mt-1.5 text-xs text-text-tertiary">Modern, fast, and secure. Recommended for new keys.</p>
|
||||||
|
)}
|
||||||
|
{keyType === "rsa" && (
|
||||||
|
<p className="mt-1.5 text-xs text-text-tertiary">Widely compatible with older systems.</p>
|
||||||
|
)}
|
||||||
|
{keyType === "ecdsa" && (
|
||||||
|
<p className="mt-1.5 text-xs text-text-tertiary">Elliptic curve — shorter keys, good compatibility.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sizes && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key Size (bits)</label>
|
||||||
|
<select
|
||||||
|
value={keySize}
|
||||||
|
onChange={e => setKeySize(Number(e.target.value))}
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
{sizes.map(s => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Comment <span className="text-text-tertiary">(embedded in the public key)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
onChange={e => setComment(e.target.value)}
|
||||||
|
placeholder="e.g. user@hostname"
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||||
|
Passphrase <span className="text-text-tertiary">(leave blank for no passphrase)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={e => setPassphrase(e.target.value)}
|
||||||
|
placeholder="Optional passphrase"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<Button type="submit" variant="primary" loading={isPending} className="flex-1">
|
||||||
|
Generate Key
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ServerDetailPage() {
|
export default function ServerDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const serverId = params.id as string;
|
const serverId = params.id as string;
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||||
|
const [copiedUpdate, setCopiedUpdate] = useState(false);
|
||||||
|
|
||||||
const { data: server, isLoading, error } = useQuery({
|
const { data: server, isLoading, error } = useQuery({
|
||||||
queryKey: ["servers", serverId],
|
queryKey: ["servers", serverId],
|
||||||
@@ -34,8 +196,9 @@ export default function ServerDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
const { mutate: generateKey, isPending: isGenerating } = useMutation({
|
||||||
mutationFn: () => api.generateKeyForServer(serverId),
|
mutationFn: (opts: GenerateKeyOptions) => api.generateKeyForServer(serverId, opts),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setShowGenerateModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
queryClient.invalidateQueries({ queryKey: ["keys"] });
|
||||||
},
|
},
|
||||||
@@ -69,6 +232,14 @@ export default function ServerDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
{showGenerateModal && (
|
||||||
|
<GenerateKeyModal
|
||||||
|
onClose={() => setShowGenerateModal(false)}
|
||||||
|
onSubmit={opts => generateKey(opts)}
|
||||||
|
isPending={isGenerating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-6 flex items-start justify-between">
|
<div className="mb-6 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -85,8 +256,7 @@ export default function ServerDetailPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
loading={isGenerating}
|
onClick={() => setShowGenerateModal(true)}
|
||||||
onClick={() => generateKey()}
|
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||||
@@ -115,6 +285,33 @@ export default function ServerDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Update Agent</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<p className="mb-4 text-sm text-text-secondary">
|
||||||
|
Run this command on the server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code> to update the agent to the latest version:
|
||||||
|
</p>
|
||||||
|
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
|
||||||
|
<span className="text-accent">$</span>{" "}
|
||||||
|
<span className="text-text-primary">{api.getUpdateCommand()}</span>
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(api.getUpdateCommand());
|
||||||
|
setCopiedUpdate(true);
|
||||||
|
setTimeout(() => setCopiedUpdate(false), 2000);
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{copiedUpdate ? <span className="text-success">Copied!</span> : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<Card className="lg:col-span-1">
|
<Card className="lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -176,7 +373,7 @@ export default function ServerDetailPage() {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{server.keys.map((assignment) => (
|
{server.keys.filter(a => a.key).map((assignment) => (
|
||||||
<Tr key={assignment.key_id}>
|
<Tr key={assignment.key_id}>
|
||||||
<Td>
|
<Td>
|
||||||
<span className="font-medium">{assignment.key.label}</span>
|
<span className="font-medium">{assignment.key.label}</span>
|
||||||
|
|||||||
+22
-4
@@ -20,6 +20,7 @@ export interface Key {
|
|||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
source: KeySource;
|
source: KeySource;
|
||||||
generated_by_server_id?: string;
|
generated_by_server_id?: string;
|
||||||
|
has_private_key: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
assigned_count?: number;
|
assigned_count?: number;
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,14 @@ export interface NewServerResponse {
|
|||||||
install_command: string;
|
install_command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateKeyOptions {
|
||||||
|
label: string;
|
||||||
|
key_type: "ed25519" | "rsa" | "ecdsa";
|
||||||
|
key_size?: number;
|
||||||
|
passphrase?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KeyWithAssignments extends Key {
|
export interface KeyWithAssignments extends Key {
|
||||||
assignments: (Assignment & { server: Server })[];
|
assignments: (Assignment & { server: Server })[];
|
||||||
}
|
}
|
||||||
@@ -96,12 +105,17 @@ export const api = {
|
|||||||
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
|
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
|
||||||
},
|
},
|
||||||
|
|
||||||
generateKeyForServer(serverId: string): Promise<{ key_id: string }> {
|
generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
|
||||||
return request<{ key_id: string }>(`/servers/${serverId}/generate-key`, {
|
return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
body: JSON.stringify(opts),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getUpdateCommand(): string {
|
||||||
|
return `curl -fsSL "${window.location.origin}/update" | bash`;
|
||||||
|
},
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
listKeys(): Promise<Key[]> {
|
listKeys(): Promise<Key[]> {
|
||||||
return request<Key[]>("/keys");
|
return request<Key[]>("/keys");
|
||||||
@@ -111,13 +125,17 @@ export const api = {
|
|||||||
return request<KeyWithAssignments>(`/keys/${keyId}`);
|
return request<KeyWithAssignments>(`/keys/${keyId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadKey(label: string, public_key: string): Promise<Key> {
|
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
|
||||||
return request<Key>("/keys", {
|
return request<Key>("/keys", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ label, public_key }),
|
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
|
||||||
|
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
|
||||||
|
},
|
||||||
|
|
||||||
deleteKey(keyId: string): Promise<void> {
|
deleteKey(keyId: string): Promise<void> {
|
||||||
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
|
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-17
@@ -3,23 +3,27 @@ import type { NextConfig } from "next";
|
|||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: `${apiUrl}/api/:path*`,
|
destination: `${apiUrl}/api/:path*`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/auth/:path*",
|
source: "/auth/:path*",
|
||||||
destination: `${apiUrl}/auth/:path*`,
|
destination: `${apiUrl}/auth/:path*`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/install",
|
source: "/install",
|
||||||
destination: `${apiUrl}/install`,
|
destination: `${apiUrl}/install`,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
},
|
source: "/update",
|
||||||
|
destination: `${apiUrl}/update`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user