Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ea7f369f1 | |||
| 2166a483ca | |||
| f9c3fc5379 | |||
| f62b0054db | |||
| de83b54be6 | |||
| aaf154168e | |||
| e215ccc979 | |||
| 7f5f082dad | |||
| abbde30b47 | |||
| 91b355cd3e | |||
| 6fa50eb2d1 | |||
| 679aa91bd0 | |||
| a0813b6e84 |
@@ -1,50 +1,37 @@
|
|||||||
name: Server Deploy
|
name: Server Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- "server/**"
|
|
||||||
- "web/**"
|
|
||||||
- "proto/**"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-docker
|
||||||
steps:
|
container: docker:dind
|
||||||
- name: Checkout
|
steps:
|
||||||
uses: actions/checkout@v4
|
- name: Setup Node
|
||||||
|
run: apk add --update nodejs npm
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to registry
|
- name: Log in to registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
echo "${{ secrets.RELEASE_TOKEN }}" | \
|
||||||
docker login ${{ vars.GITEA_HOST }} \
|
docker login ${{ vars.DOCKER_HOST }} \
|
||||||
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
- name: Build and push server image
|
- name: Build and push server image
|
||||||
run: |
|
run: |
|
||||||
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
|
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
|
||||||
docker build -t "$IMAGE" -f server/Dockerfile server/
|
docker build -t "$IMAGE" -f server/Dockerfile server/
|
||||||
docker push "$IMAGE"
|
docker push "$IMAGE"
|
||||||
|
|
||||||
- name: Build and push web image
|
- name: Build and push web image
|
||||||
run: |
|
run: |
|
||||||
IMAGE="${{ vars.GITEA_HOST }}/${{ github.repository_owner }}/keymanager/web:latest"
|
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/keymanager/web:latest"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg NEXT_PUBLIC_API_URL="https://${{ vars.GITEA_HOST }}" \
|
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
|
||||||
-t "$IMAGE" \
|
-t "$IMAGE" \
|
||||||
-f web/Dockerfile web/
|
-f web/Dockerfile web/
|
||||||
docker push "$IMAGE"
|
docker push "$IMAGE"
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: https://github.com/appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
script: |
|
|
||||||
cd /opt/keymanager
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d --remove-orphans
|
|
||||||
docker image prune -f
|
|
||||||
|
|||||||
+7
-1
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/mrhid6/keymanager/agent/internal/config"
|
"github.com/mrhid6/keymanager/agent/internal/config"
|
||||||
agentsync "github.com/mrhid6/keymanager/agent/internal/sync"
|
agentsync "github.com/mrhid6/keymanager/agent/internal/sync"
|
||||||
@@ -26,8 +29,11 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
|
||||||
if err := agentsync.Run(cfg); err != nil {
|
if err := agentsync.Run(ctx, cfg); err != nil {
|
||||||
log.Fatalf("agent error: %v", err)
|
log.Fatalf("agent error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package grpcclient
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
|
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
|
||||||
@@ -22,6 +23,9 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(serverURL string, useTLS bool) (*Client, error) {
|
func New(serverURL string, useTLS bool) (*Client, error) {
|
||||||
|
serverURL = strings.TrimPrefix(serverURL, "https://")
|
||||||
|
serverURL = strings.TrimPrefix(serverURL, "http://")
|
||||||
|
|
||||||
var dialOpts []grpc.DialOption
|
var dialOpts []grpc.DialOption
|
||||||
|
|
||||||
if useTLS {
|
if useTLS {
|
||||||
@@ -98,3 +102,9 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label strin
|
|||||||
}
|
}
|
||||||
return resp.KeyId, nil
|
return resp.KeyId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||||
|
// The caller controls the stream lifetime via ctx.
|
||||||
|
func (c *Client) CommandStream(ctx context.Context) (pb.KeyManager_CommandStreamClient, error) {
|
||||||
|
return c.client.CommandStream(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,10 +42,89 @@ type UploadKeyResponse struct {
|
|||||||
KeyId string `json:"key_id"`
|
KeyId string `json:"key_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandStream message types
|
||||||
|
|
||||||
|
type ServerCommand struct {
|
||||||
|
CommandId string `json:"command_id"`
|
||||||
|
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateKeyCmd struct {
|
||||||
|
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 {
|
||||||
|
ServerId string `json:"server_id"`
|
||||||
|
AgentToken string `json:"agent_token"`
|
||||||
|
Ready *AgentReady `json:"ready,omitempty"`
|
||||||
|
Result *CommandResult `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentReady struct{}
|
||||||
|
|
||||||
|
type CommandResult struct {
|
||||||
|
CommandId string `json:"command_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandStream client-side interface
|
||||||
|
|
||||||
|
type KeyManager_CommandStreamClient interface {
|
||||||
|
Send(*AgentMessage) error
|
||||||
|
Recv() (*ServerCommand, error)
|
||||||
|
grpc.ClientStream
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyManagerCommandStreamClient struct {
|
||||||
|
grpc.ClientStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerCommandStreamClient) Send(m *AgentMessage) error {
|
||||||
|
return c.ClientStream.SendMsg(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||||
|
m := new(ServerCommand)
|
||||||
|
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandStream server-side interface (included for completeness)
|
||||||
|
|
||||||
|
type KeyManager_CommandStreamServer interface {
|
||||||
|
Send(*ServerCommand) error
|
||||||
|
Recv() (*AgentMessage, error)
|
||||||
|
grpc.ServerStream
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyManagerCommandStreamServer struct {
|
||||||
|
grpc.ServerStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||||
|
return s.ServerStream.SendMsg(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||||
|
m := new(AgentMessage)
|
||||||
|
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
type KeyManagerClient interface {
|
type KeyManagerClient interface {
|
||||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||||
|
CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnimplementedKeyManagerServer struct{}
|
type UnimplementedKeyManagerServer struct{}
|
||||||
@@ -91,3 +170,12 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error) {
|
||||||
|
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||||
|
stream, err := c.cc.NewStream(ctx, desc, "/keymanager.v1.KeyManager/CommandStream", opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &keyManagerCommandStreamClient{stream}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+113
-7
@@ -1,6 +1,7 @@
|
|||||||
package agentsync
|
package agentsync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -11,10 +12,11 @@ import (
|
|||||||
|
|
||||||
"github.com/mrhid6/keymanager/agent/internal/config"
|
"github.com/mrhid6/keymanager/agent/internal/config"
|
||||||
grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc"
|
grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc"
|
||||||
|
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
|
||||||
"github.com/mrhid6/keymanager/agent/internal/keys"
|
"github.com/mrhid6/keymanager/agent/internal/keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run(cfg *config.Config) error {
|
func Run(ctx context.Context, cfg *config.Config) error {
|
||||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dial grpc: %w", err)
|
return fmt.Errorf("dial grpc: %w", err)
|
||||||
@@ -40,7 +42,6 @@ func Run(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
log.Println("registration successful")
|
log.Println("registration successful")
|
||||||
|
|
||||||
// Reconnect with potentially updated state
|
|
||||||
client.Close()
|
client.Close()
|
||||||
client, err = grpcclient.New(cfg.ServerURL, cfg.TLS)
|
client, err = grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,6 +53,9 @@ func Run(cfg *config.Config) error {
|
|||||||
return fmt.Errorf("no agent token available — registration required")
|
return fmt.Errorf("no agent token available — registration required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the command stream alongside the poll loop.
|
||||||
|
go runCommandStream(ctx, cfg)
|
||||||
|
|
||||||
ticker := time.NewTicker(cfg.PollInterval)
|
ticker := time.NewTicker(cfg.PollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -60,12 +64,16 @@ func Run(cfg *config.Config) error {
|
|||||||
log.Printf("poll error: %v", err)
|
log.Printf("poll error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
if err := poll(client, cfg); err != nil {
|
select {
|
||||||
log.Printf("poll error: %v", err)
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := poll(client, cfg); err != nil {
|
||||||
|
log.Printf("poll error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func poll(client *grpcclient.Client, cfg *config.Config) error {
|
func poll(client *grpcclient.Client, cfg *config.Config) error {
|
||||||
@@ -91,6 +99,104 @@ func poll(client *grpcclient.Client, cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runCommandStream maintains a persistent bidirectional stream with the server
|
||||||
|
// for instant command delivery. Reconnects with exponential backoff on failure.
|
||||||
|
func runCommandStream(ctx context.Context, cfg *config.Config) {
|
||||||
|
backoff := time.Second
|
||||||
|
const maxBackoff = 2 * time.Minute
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connectAndHandleStream(ctx, cfg); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("command stream error: %v, reconnecting in %s", err, backoff)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < maxBackoff {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
backoff = time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||||
|
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
stream, err := client.CommandStream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stream.Send(&pb.AgentMessage{
|
||||||
|
ServerId: cfg.ServerID,
|
||||||
|
AgentToken: cfg.AgentToken,
|
||||||
|
Ready: &pb.AgentReady{},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("send auth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("command stream connected")
|
||||||
|
|
||||||
|
for {
|
||||||
|
cmd, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("recv: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.GenerateKey != nil {
|
||||||
|
go handleGenerateKey(cfg, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||||
|
g := cmd.GenerateKey
|
||||||
|
label := g.Label
|
||||||
|
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(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 {
|
||||||
|
log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId)
|
||||||
|
}
|
||||||
|
|
||||||
func localIP() string {
|
func localIP() string {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
@@ -21,13 +32,21 @@ services:
|
|||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
environment:
|
environment:
|
||||||
MONGO_URI: mongodb://mongo:27017/keymanager
|
MONGO_URI: mongodb://mongo:27017/keymanager
|
||||||
|
REDIS_ADDR: redis:6379
|
||||||
GITEA_HOST: ${GITEA_HOST}
|
GITEA_HOST: ${GITEA_HOST}
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
|
GRPC_HOST: ${GRPC_HOST}
|
||||||
GRPC_PORT: "9090"
|
GRPC_PORT: "9090"
|
||||||
HTTP_PORT: "8080"
|
HTTP_PORT: "8080"
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -43,3 +62,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo_data:
|
mongo_data:
|
||||||
|
redis_data:
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ package keymanager.v1;
|
|||||||
option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb";
|
option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb";
|
||||||
|
|
||||||
service KeyManager {
|
service KeyManager {
|
||||||
rpc Register(RegisterRequest) returns (RegisterResponse);
|
rpc Register(RegisterRequest) returns (RegisterResponse);
|
||||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||||
|
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||||
|
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
message RegisterRequest {
|
message RegisterRequest {
|
||||||
@@ -41,3 +43,37 @@ message UploadKeyRequest {
|
|||||||
message UploadKeyResponse {
|
message UploadKeyResponse {
|
||||||
string key_id = 1;
|
string key_id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandStream messages
|
||||||
|
|
||||||
|
message AgentMessage {
|
||||||
|
string server_id = 1;
|
||||||
|
string agent_token = 2;
|
||||||
|
oneof payload {
|
||||||
|
AgentReady ready = 3;
|
||||||
|
CommandResult result = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message AgentReady {}
|
||||||
|
|
||||||
|
message CommandResult {
|
||||||
|
string command_id = 1;
|
||||||
|
bool success = 2;
|
||||||
|
string message = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServerCommand {
|
||||||
|
string command_id = 1;
|
||||||
|
oneof command {
|
||||||
|
GenerateKeyCmd generate_key = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message GenerateKeyCmd {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/mrhid6/keymanager/server/internal/api"
|
"github.com/mrhid6/keymanager/server/internal/api"
|
||||||
|
"github.com/mrhid6/keymanager/server/internal/auth"
|
||||||
"github.com/mrhid6/keymanager/server/internal/db"
|
"github.com/mrhid6/keymanager/server/internal/db"
|
||||||
grpcserver "github.com/mrhid6/keymanager/server/internal/grpc"
|
grpcserver "github.com/mrhid6/keymanager/server/internal/grpc"
|
||||||
"github.com/mrhid6/keymanager/server/internal/services"
|
"github.com/mrhid6/keymanager/server/internal/services"
|
||||||
@@ -21,6 +23,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("connected to MongoDB")
|
log.Println("connected to MongoDB")
|
||||||
|
|
||||||
|
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||||
|
if err := auth.InitRedis(redisAddr); err != nil {
|
||||||
|
log.Fatalf("failed to connect to Redis: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("connected to Redis")
|
||||||
|
|
||||||
|
if err := auth.InitOIDC(context.Background()); err != nil {
|
||||||
|
log.Fatalf("failed to initialise OIDC: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Background goroutine to mark offline servers
|
// Background goroutine to mark offline servers
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(2 * time.Minute)
|
ticker := time.NewTicker(2 * time.Minute)
|
||||||
|
|||||||
+7
-1
@@ -3,19 +3,24 @@ module github.com/mrhid6/keymanager/server
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/redis/go-redis/v9 v9.20.1
|
||||||
go.mongodb.org/mongo-driver/v2 v2.2.2
|
go.mongodb.org/mongo-driver/v2 v2.2.2
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
google.golang.org/grpc v1.64.0
|
google.golang.org/grpc v1.64.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
@@ -23,7 +28,7 @@ require (
|
|||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.16.7 // indirect
|
github.com/klauspost/compress v1.16.7 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
@@ -35,6 +40,7 @@ require (
|
|||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
|||||||
+20
-3
@@ -1,11 +1,19 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -15,6 +23,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -37,8 +47,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
|
|||||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
@@ -53,6 +63,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
|
||||||
|
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -78,8 +90,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
|
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
|
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
@@ -93,6 +109,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
@@ -102,7 +120,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
+154
-39
@@ -6,28 +6,39 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mrhid6/keymanager/server/internal/auth"
|
||||||
"github.com/mrhid6/keymanager/server/internal/models"
|
"github.com/mrhid6/keymanager/server/internal/models"
|
||||||
"github.com/mrhid6/keymanager/server/internal/services"
|
"github.com/mrhid6/keymanager/server/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(r *gin.Engine) {
|
func RegisterRoutes(r *gin.Engine) {
|
||||||
r.GET("/install", handleInstallScript)
|
r.GET("/install", handleInstallScript)
|
||||||
|
r.GET("/update", handleUpdateScript)
|
||||||
|
|
||||||
api := r.Group("/api")
|
// Auth endpoints (no session required)
|
||||||
|
r.GET("/auth/login", auth.HandleLogin)
|
||||||
|
r.GET("/auth/callback", auth.HandleCallback)
|
||||||
|
r.GET("/auth/logout", auth.HandleLogout)
|
||||||
|
r.GET("/auth/me", auth.HandleMe)
|
||||||
|
|
||||||
|
// API endpoints protected by session middleware
|
||||||
|
apiGroup := r.Group("/api")
|
||||||
|
apiGroup.Use(auth.Middleware())
|
||||||
{
|
{
|
||||||
api.GET("/servers", listServers)
|
apiGroup.GET("/servers", listServers)
|
||||||
api.POST("/servers", createServer)
|
apiGroup.POST("/servers", createServer)
|
||||||
api.GET("/servers/new", newServer)
|
apiGroup.GET("/servers/new", newServer)
|
||||||
api.POST("/servers/new", newServer)
|
apiGroup.POST("/servers/new", newServer)
|
||||||
api.GET("/servers/:id", getServer)
|
apiGroup.GET("/servers/:id", getServer)
|
||||||
api.DELETE("/servers/:id", deleteServer)
|
apiGroup.DELETE("/servers/:id", deleteServer)
|
||||||
api.POST("/servers/:id/generate-key", generateKey)
|
apiGroup.POST("/servers/:id/generate-key", generateKey)
|
||||||
|
|
||||||
api.GET("/keys", listKeys)
|
apiGroup.GET("/keys", listKeys)
|
||||||
api.POST("/keys", createKey)
|
apiGroup.POST("/keys", createKey)
|
||||||
api.GET("/keys/:id", getKey)
|
apiGroup.GET("/keys/:id", getKey)
|
||||||
api.POST("/keys/:id/assign", assignKey)
|
apiGroup.DELETE("/keys/:id", deleteKey)
|
||||||
api.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
apiGroup.POST("/keys/:id/assign", assignKey)
|
||||||
|
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +77,11 @@ func newServer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
host := os.Getenv("PUBLIC_HOST")
|
host := os.Getenv("PUBLIC_HOST")
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "keymanager.example.com"
|
host = "https://keymanager.example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
installCmd := fmt.Sprintf(
|
installCmd := fmt.Sprintf(
|
||||||
`curl -fsSL "https://%s/install?server_id=%s&token=%s" | bash`,
|
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
|
||||||
host, s.ServerID, token,
|
host, s.ServerID, token,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,18 +123,42 @@ func deleteServer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateKey(c *gin.Context) {
|
func generateKey(c *gin.Context) {
|
||||||
// The agent triggers key generation itself; this endpoint signals
|
|
||||||
// the intent by returning the server so the caller knows to wait
|
|
||||||
// for the agent to upload via gRPC UploadGeneratedKey.
|
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
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)
|
||||||
|
if body.Label == "" {
|
||||||
|
body.Label = "generated"
|
||||||
|
}
|
||||||
|
|
||||||
s, err := services.GetServer(id)
|
s, err := services.GetServer(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "agent will generate and upload key on next poll",
|
cmdID, err := services.DispatchGenerateKey(s.ServerID, services.KeyGenParams{
|
||||||
"server_id": s.ServerID,
|
Label: body.Label,
|
||||||
|
KeyType: body.KeyType,
|
||||||
|
KeySize: body.KeySize,
|
||||||
|
Passphrase: body.Passphrase,
|
||||||
|
Comment: body.Comment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"message": "key generation command sent to agent",
|
||||||
|
"command_id": cmdID,
|
||||||
|
"server_id": s.ServerID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +198,26 @@ func getKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignments, _ := services.GetAssignmentsWithServers(id)
|
assignments, _ := services.GetAssignmentsWithServers(id)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"key": key,
|
type keyResponse struct {
|
||||||
"assignments": assignments,
|
*models.Key
|
||||||
|
Assignments any `json:"assignments"`
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, keyResponse{
|
||||||
|
Key: key,
|
||||||
|
Assignments: assignments,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deleteKey(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := services.DeleteKey(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
func assignKey(c *gin.Context) {
|
func assignKey(c *gin.Context) {
|
||||||
keyID := c.Param("id")
|
keyID := c.Param("id")
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -198,26 +247,16 @@ func revokeAssignment(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
c.JSON(http.StatusOK, gin.H{"revoked": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleInstallScript(c *gin.Context) {
|
func handleUpdateScript(c *gin.Context) {
|
||||||
serverID := c.Query("server_id")
|
|
||||||
token := c.Query("token")
|
|
||||||
|
|
||||||
giteaHost := os.Getenv("GITEA_HOST")
|
giteaHost := os.Getenv("GITEA_HOST")
|
||||||
if giteaHost == "" {
|
if giteaHost == "" {
|
||||||
giteaHost = "gitea.example.com"
|
giteaHost = "gitea.example.com"
|
||||||
}
|
}
|
||||||
publicHost := os.Getenv("PUBLIC_HOST")
|
|
||||||
if publicHost == "" {
|
|
||||||
publicHost = "keymanager.example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`#!/usr/bin/env bash
|
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVER_ID="%s"
|
|
||||||
TOKEN="%s"
|
|
||||||
GITEA_HOST="%s"
|
GITEA_HOST="%s"
|
||||||
KM_HOST="%s"
|
|
||||||
|
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
@@ -236,8 +275,84 @@ if [ -z "$LATEST" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${LATEST#agent/}"
|
VERSION="${LATEST#agent/}"
|
||||||
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/keymanager-agent-linux-${ARCH}"
|
LATEST_ENCODED="${LATEST/\//%%2F}"
|
||||||
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST}/checksums.txt"
|
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) {
|
||||||
|
serverID := c.Query("server_id")
|
||||||
|
token := c.Query("token")
|
||||||
|
|
||||||
|
giteaHost := os.Getenv("GITEA_HOST")
|
||||||
|
if giteaHost == "" {
|
||||||
|
giteaHost = "gitea.example.com"
|
||||||
|
}
|
||||||
|
publicHost := os.Getenv("PUBLIC_HOST")
|
||||||
|
if publicHost == "" {
|
||||||
|
publicHost = "keymanager.example.com"
|
||||||
|
}
|
||||||
|
grpcHost := os.Getenv("GRPC_HOST")
|
||||||
|
if grpcHost == "" {
|
||||||
|
grpcHost = publicHost
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVER_ID="%s"
|
||||||
|
TOKEN="%s"
|
||||||
|
GITEA_HOST="%s"
|
||||||
|
KM_HOST="%s"
|
||||||
|
KM_HOST="${KM_HOST#https://}"
|
||||||
|
KM_HOST="${KM_HOST#http://}"
|
||||||
|
GRPC_HOST="%s"
|
||||||
|
GRPC_HOST="${GRPC_HOST#https://}"
|
||||||
|
GRPC_HOST="${GRPC_HOST#http://}"
|
||||||
|
|
||||||
|
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 "Installing keymanager-agent ${VERSION} (${ARCH})..."
|
echo "Installing keymanager-agent ${VERSION} (${ARCH})..."
|
||||||
|
|
||||||
@@ -258,7 +373,7 @@ mkdir -p /etc/keymanager
|
|||||||
chmod 0700 /etc/keymanager
|
chmod 0700 /etc/keymanager
|
||||||
|
|
||||||
cat > /etc/keymanager/config.yaml <<EOF
|
cat > /etc/keymanager/config.yaml <<EOF
|
||||||
server_url: "${KM_HOST}:9090"
|
server_url: "${GRPC_HOST}"
|
||||||
server_id: "${SERVER_ID}"
|
server_id: "${SERVER_ID}"
|
||||||
pre_reg_token: "${TOKEN}"
|
pre_reg_token: "${TOKEN}"
|
||||||
agent_token: ""
|
agent_token: ""
|
||||||
@@ -286,7 +401,7 @@ systemctl daemon-reload
|
|||||||
systemctl enable --now keymanager-agent
|
systemctl enable --now keymanager-agent
|
||||||
|
|
||||||
echo "keymanager-agent installed and started."
|
echo "keymanager-agent installed and started."
|
||||||
`, serverID, token, giteaHost, publicHost)
|
`, serverID, token, giteaHost, publicHost, grpcHost)
|
||||||
|
|
||||||
c.Header("Content-Type", "text/x-shellscript")
|
c.Header("Content-Type", "text/x-shellscript")
|
||||||
c.String(http.StatusOK, script)
|
c.String(http.StatusOK, script)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctxSessionKey = "km_session"
|
||||||
|
|
||||||
|
func GetSessionFromContext(c *gin.Context) *Session {
|
||||||
|
v, _ := c.Get(ctxSessionKey)
|
||||||
|
sess, _ := v.(*Session)
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func Middleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !authEnabled {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(ctxSessionKey, sess)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
oidcProvider *oidc.Provider
|
||||||
|
oauth2Cfg *oauth2.Config
|
||||||
|
authEnabled bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitOIDC(ctx context.Context) error {
|
||||||
|
issuer := os.Getenv("OIDC_ISSUER")
|
||||||
|
if issuer == "" {
|
||||||
|
log.Println("OIDC_ISSUER not set; authentication disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := oidc.NewProvider(ctx, issuer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oidcProvider = p
|
||||||
|
oauth2Cfg = &oauth2.Config{
|
||||||
|
ClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
|
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
||||||
|
Endpoint: p.Endpoint(),
|
||||||
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
|
}
|
||||||
|
authEnabled = true
|
||||||
|
log.Println("OIDC authentication enabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enabled() bool { return authEnabled }
|
||||||
|
|
||||||
|
func HandleLogin(c *gin.Context) {
|
||||||
|
state, err := randomHex(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := SaveState(c.Request.Context(), state); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleCallback(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
if !ConsumeState(ctx, c.Query("state")) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID})
|
||||||
|
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := SaveSession(ctx, &Session{
|
||||||
|
UserID: claims.Sub,
|
||||||
|
Email: claims.Email,
|
||||||
|
Name: claims.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
|
||||||
|
frontendURL := os.Getenv("PUBLIC_HOST")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "/"
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, frontendURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleLogout(c *gin.Context) {
|
||||||
|
if cookie, err := c.Request.Cookie(sessionCookieName); err == nil {
|
||||||
|
_ = DeleteSession(c.Request.Context(), cookie.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleMe(c *gin.Context) {
|
||||||
|
if !authEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"auth_enabled": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, err := GetSession(c.Request.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, sess)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 24 * time.Hour
|
||||||
|
const sessionCookieName = "km_session"
|
||||||
|
const sessionPrefix = "km:session:"
|
||||||
|
const statePrefix = "km:state:"
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rdb *redis.Client
|
||||||
|
|
||||||
|
func InitRedis(addr string) error {
|
||||||
|
rdb = redis.NewClient(&redis.Options{Addr: addr})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return rdb.Ping(ctx).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveSession(ctx context.Context, sess *Session) (string, error) {
|
||||||
|
id, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(sess)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := rdb.Set(ctx, sessionPrefix+id, data, sessionTTL).Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(ctx context.Context, id string) (*Session, error) {
|
||||||
|
data, err := rdb.Get(ctx, sessionPrefix+id).Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sess Session
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteSession(ctx context.Context, id string) error {
|
||||||
|
return rdb.Del(ctx, sessionPrefix+id).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveState(ctx context.Context, state string) error {
|
||||||
|
return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConsumeState(ctx context.Context, state string) bool {
|
||||||
|
n, err := rdb.Del(ctx, statePrefix+state).Result()
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
@@ -45,12 +45,91 @@ type UploadKeyResponse struct {
|
|||||||
KeyId string `json:"key_id"`
|
KeyId string `json:"key_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandStream message types
|
||||||
|
|
||||||
|
type ServerCommand struct {
|
||||||
|
CommandId string `json:"command_id"`
|
||||||
|
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateKeyCmd struct {
|
||||||
|
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 {
|
||||||
|
ServerId string `json:"server_id"`
|
||||||
|
AgentToken string `json:"agent_token"`
|
||||||
|
Ready *AgentReady `json:"ready,omitempty"`
|
||||||
|
Result *CommandResult `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentReady struct{}
|
||||||
|
|
||||||
|
type CommandResult struct {
|
||||||
|
CommandId string `json:"command_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandStream server-side interface
|
||||||
|
|
||||||
|
type KeyManager_CommandStreamServer interface {
|
||||||
|
Send(*ServerCommand) error
|
||||||
|
Recv() (*AgentMessage, error)
|
||||||
|
grpc.ServerStream
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyManagerCommandStreamServer struct {
|
||||||
|
grpc.ServerStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
|
||||||
|
return s.ServerStream.SendMsg(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
|
||||||
|
m := new(AgentMessage)
|
||||||
|
if err := s.ServerStream.RecvMsg(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandStream client-side interface
|
||||||
|
|
||||||
|
type KeyManager_CommandStreamClient interface {
|
||||||
|
Send(*AgentMessage) error
|
||||||
|
Recv() (*ServerCommand, error)
|
||||||
|
grpc.ClientStream
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyManagerCommandStreamClient struct {
|
||||||
|
grpc.ClientStream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerCommandStreamClient) Send(m *AgentMessage) error {
|
||||||
|
return c.ClientStream.SendMsg(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerCommandStreamClient) Recv() (*ServerCommand, error) {
|
||||||
|
m := new(ServerCommand)
|
||||||
|
if err := c.ClientStream.RecvMsg(m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Server interface
|
// Server interface
|
||||||
|
|
||||||
type KeyManagerServer interface {
|
type KeyManagerServer interface {
|
||||||
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
|
||||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||||
|
CommandStream(KeyManager_CommandStreamServer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnimplementedKeyManagerServer struct{}
|
type UnimplementedKeyManagerServer struct{}
|
||||||
@@ -67,12 +146,17 @@ func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *Upload
|
|||||||
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (UnimplementedKeyManagerServer) CommandStream(KeyManager_CommandStreamServer) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
// Client interface
|
// Client interface
|
||||||
|
|
||||||
type KeyManagerClient interface {
|
type KeyManagerClient interface {
|
||||||
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
|
||||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||||
|
CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type keyManagerClient struct {
|
type keyManagerClient struct {
|
||||||
@@ -107,6 +191,14 @@ func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKey
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error) {
|
||||||
|
stream, err := c.cc.NewStream(ctx, &KeyManager_ServiceDesc.Streams[0], "/keymanager.v1.KeyManager/CommandStream", opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &keyManagerCommandStreamClient{stream}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Server registration
|
// Server registration
|
||||||
|
|
||||||
func RegisterKeyManagerServer(s grpc.ServiceRegistrar, srv KeyManagerServer) {
|
func RegisterKeyManagerServer(s grpc.ServiceRegistrar, srv KeyManagerServer) {
|
||||||
@@ -121,7 +213,14 @@ var KeyManager_ServiceDesc = grpc.ServiceDesc{
|
|||||||
{MethodName: "SyncKeys", Handler: _KeyManager_SyncKeys_Handler},
|
{MethodName: "SyncKeys", Handler: _KeyManager_SyncKeys_Handler},
|
||||||
{MethodName: "UploadGeneratedKey", Handler: _KeyManager_UploadGeneratedKey_Handler},
|
{MethodName: "UploadGeneratedKey", Handler: _KeyManager_UploadGeneratedKey_Handler},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "CommandStream",
|
||||||
|
Handler: _KeyManager_CommandStream_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
ClientStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
Metadata: "keymanager/v1/keymanager.proto",
|
Metadata: "keymanager/v1/keymanager.proto",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +268,7 @@ func _KeyManager_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context
|
|||||||
}
|
}
|
||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _KeyManager_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(KeyManagerServer).CommandStream(&keyManagerCommandStreamServer{stream})
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,59 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
|
|||||||
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
|
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServer) error {
|
||||||
|
// First message authenticates the agent and signals readiness.
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := services.ValidateAgentToken(msg.ServerId, msg.AgentToken)
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := services.UpdateServerLastSeen(srv.ServerID); err != nil {
|
||||||
|
log.Printf("update last seen %s: %v", srv.ServerID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := services.Dispatcher.Connect(srv.ServerID)
|
||||||
|
defer services.Dispatcher.Disconnect(srv.ServerID)
|
||||||
|
|
||||||
|
log.Printf("agent %s connected command stream", srv.ServerID)
|
||||||
|
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
|
||||||
|
|
||||||
|
// Drain inbound results in the background so client Send calls never block.
|
||||||
|
// UploadGeneratedKey handles the real storage; these are just confirmation logs.
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
m, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Result != nil {
|
||||||
|
r := m.Result
|
||||||
|
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx := stream.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case cmd, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := stream.Send(cmd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func StartGRPC(port int) error {
|
func StartGRPC(port int) error {
|
||||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mrhid6/keymanager/server/internal/grpc/pb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type commandDispatcher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
channels map[string]chan *pb.ServerCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatcher is the singleton command dispatcher used by both the gRPC server
|
||||||
|
// and the REST API to push commands to connected agents.
|
||||||
|
var Dispatcher = &commandDispatcher{
|
||||||
|
channels: make(map[string]chan *pb.ServerCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect registers an agent's command channel. Returns the channel to drain.
|
||||||
|
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
|
||||||
|
ch := make(chan *pb.ServerCommand, 16)
|
||||||
|
d.mu.Lock()
|
||||||
|
d.channels[serverID] = ch
|
||||||
|
d.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect removes the agent's channel on stream close.
|
||||||
|
func (d *commandDispatcher) Disconnect(serverID string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
delete(d.channels, serverID)
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnected reports whether an agent is currently holding a CommandStream.
|
||||||
|
func (d *commandDispatcher) IsConnected(serverID string) bool {
|
||||||
|
d.mu.RLock()
|
||||||
|
_, ok := d.channels[serverID]
|
||||||
|
d.mu.RUnlock()
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) error {
|
||||||
|
d.mu.RLock()
|
||||||
|
ch, ok := d.channels[serverID]
|
||||||
|
d.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("agent for server %s is not connected", serverID)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- cmd:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("command queue full for server %s", serverID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Returns the command ID that can be used to correlate the agent's result.
|
||||||
|
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
|
||||||
|
if !Dispatcher.IsConnected(serverID) {
|
||||||
|
return "", fmt.Errorf("agent is not connected to the command stream")
|
||||||
|
}
|
||||||
|
cmdID := uuid.New().String()
|
||||||
|
cmd := &pb.ServerCommand{
|
||||||
|
CommandId: cmdID,
|
||||||
|
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 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cmdID, nil
|
||||||
|
}
|
||||||
@@ -63,7 +63,12 @@ func GetKey(keyID string) (*models.Key, error) {
|
|||||||
return &key, nil
|
return &key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListKeys() ([]models.Key, error) {
|
type KeyWithCount struct {
|
||||||
|
models.Key `bson:",inline"`
|
||||||
|
AssignedCount int `bson:"-" json:"assigned_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListKeys() ([]KeyWithCount, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -77,14 +82,26 @@ func ListKeys() ([]models.Key, error) {
|
|||||||
if err := cursor.All(ctx, &keys); err != nil {
|
if err := cursor.All(ctx, &keys); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return keys, nil
|
|
||||||
|
result := make([]KeyWithCount, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
|
||||||
|
"key_id": k.KeyID,
|
||||||
|
"revoked_at": nil,
|
||||||
|
})
|
||||||
|
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteKey(keyID string) error {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,12 +215,11 @@ 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)
|
result = append(result, AssignmentWithKey{Assignment: a, Key: &key})
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "@/components/Providers";
|
import { Providers } from "@/components/Providers";
|
||||||
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -17,12 +18,14 @@ export default function RootLayout({
|
|||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className="bg-background text-text-primary">
|
<body className="bg-background text-text-primary">
|
||||||
<Providers>
|
<Providers>
|
||||||
<div className="flex h-screen overflow-hidden">
|
<AuthProvider>
|
||||||
<Sidebar />
|
<div className="flex h-screen overflow-hidden">
|
||||||
<main className="flex-1 overflow-y-auto">
|
<Sidebar />
|
||||||
{children}
|
<main className="flex-1 overflow-y-auto">
|
||||||
</main>
|
{children}
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
authEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({ user: null, authEnabled: false });
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/auth/me", { credentials: "include" })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/auth/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.auth_enabled === false) {
|
||||||
|
setAuthEnabled(false);
|
||||||
|
} else {
|
||||||
|
setAuthEnabled(true);
|
||||||
|
setUser(data as User);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Backend unreachable — don't block the UI
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, authEnabled }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -33,6 +34,7 @@ const navItems: NavItem[] = [
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, authEnabled } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
|
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
|
||||||
@@ -71,7 +73,23 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-border px-4 py-3">
|
<div className="border-t border-border px-4 py-3">
|
||||||
<p className="text-xs text-text-secondary">KeyManager v1.0</p>
|
{authEnabled && user && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
|
||||||
|
<p className="truncate text-xs text-text-secondary">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-text-secondary">KeyManager v1.0</p>
|
||||||
|
{authEnabled && user && (
|
||||||
|
<a
|
||||||
|
href="/auth/logout"
|
||||||
|
className="text-xs text-text-secondary transition-colors hover:text-danger"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-2
@@ -38,6 +38,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 })[];
|
||||||
}
|
}
|
||||||
@@ -58,6 +66,7 @@ class ApiError extends Error {
|
|||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
@@ -95,12 +104,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");
|
||||||
|
|||||||
+21
-13
@@ -3,19 +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: "/install",
|
source: "/auth/:path*",
|
||||||
destination: `${apiUrl}/install`,
|
destination: `${apiUrl}/auth/:path*`,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
},
|
source: "/install",
|
||||||
|
destination: `${apiUrl}/install`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/update",
|
||||||
|
destination: `${apiUrl}/update`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user