From 08dc172ca7bd983f120dbe6feeebaa1655d17331 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:58:45 +0100 Subject: [PATCH] first commit --- cmd/main.go | 33 ++++++++ go.mod | 16 ++++ go.sum | 18 +++++ internal/config/config.go | 45 +++++++++++ internal/grpc/client.go | 100 +++++++++++++++++++++++ internal/grpc/codec.go | 17 ++++ internal/grpc/pb/keymanager.pb.go | 93 +++++++++++++++++++++ internal/keys/keys.go | 120 +++++++++++++++++++++++++++ internal/sync/sync.go | 129 ++++++++++++++++++++++++++++++ 9 files changed, 571 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/grpc/client.go create mode 100644 internal/grpc/codec.go create mode 100644 internal/grpc/pb/keymanager.pb.go create mode 100644 internal/keys/keys.go create mode 100644 internal/sync/sync.go diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..0bda460 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "log" + + "github.com/mrhid6/keymanager/agent/internal/config" + agentsync "github.com/mrhid6/keymanager/agent/internal/sync" +) + +var Version = "dev" + +func main() { + genKey := flag.String("generate-key", "", "Generate SSH keypair and upload with this label") + flag.Parse() + + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if *genKey != "" { + if err := agentsync.GenerateAndUpload(cfg, *genKey); err != nil { + log.Fatalf("key generation failed: %v", err) + } + return + } + + log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval) + if err := agentsync.Run(cfg); err != nil { + log.Fatalf("agent error: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..321268f --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/mrhid6/keymanager/agent + +go 1.26 + +require ( + google.golang.org/grpc v1.64.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect + google.golang.org/protobuf v1.34.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c66072 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..47d0ba8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +const ConfigPath = "/etc/keymanager/config.yaml" + +type Config struct { + ServerURL string `yaml:"server_url"` + ServerID string `yaml:"server_id"` + PreRegToken string `yaml:"pre_reg_token"` + AgentToken string `yaml:"agent_token"` + PollInterval time.Duration `yaml:"poll_interval"` + TLS bool `yaml:"tls"` +} + +func Load() (*Config, error) { + data, err := os.ReadFile(ConfigPath) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.PollInterval == 0 { + cfg.PollInterval = 30 * time.Second + } + return &cfg, nil +} + +func Save(cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.MkdirAll("/etc/keymanager", 0700); err != nil { + return err + } + return os.WriteFile(ConfigPath, data, 0600) +} diff --git a/internal/grpc/client.go b/internal/grpc/client.go new file mode 100644 index 0000000..e6ee16c --- /dev/null +++ b/internal/grpc/client.go @@ -0,0 +1,100 @@ +package grpcclient + +import ( + "context" + "crypto/tls" + "time" + + "github.com/mrhid6/keymanager/agent/internal/grpc/pb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/encoding" +) + +func init() { + encoding.RegisterCodec(JSONCodec{}) +} + +type Client struct { + conn *grpc.ClientConn + client pb.KeyManagerClient +} + +func New(serverURL string, useTLS bool) (*Client, error) { + var dialOpts []grpc.DialOption + + if useTLS { + tlsCfg := &tls.Config{ + InsecureSkipVerify: false, + } + creds := credentials.NewTLS(tlsCfg) + dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds)) + } else { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + conn, err := grpc.DialContext(ctx, serverURL, dialOpts...) + if err != nil { + return nil, err + } + + return &Client{ + conn: conn, + client: pb.NewKeyManagerClient(conn), + }, nil +} + +func (c *Client) Close() error { + return c.conn.Close() +} + +func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.Register(ctx, &pb.RegisterRequest{ + ServerId: serverID, + PreRegToken: preRegToken, + Hostname: hostname, + IpAddress: ipAddress, + OsInfo: osInfo, + }) + if err != nil { + return "", err + } + return resp.AgentToken, nil +} + +func (c *Client) SyncKeys(serverID, agentToken string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{ + ServerId: serverID, + AgentToken: agentToken, + }) + if err != nil { + return nil, err + } + return resp.PublicKeys, nil +} + +func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, label string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := c.client.UploadGeneratedKey(ctx, &pb.UploadKeyRequest{ + ServerId: serverID, + AgentToken: agentToken, + PublicKey: publicKey, + Label: label, + }) + if err != nil { + return "", err + } + return resp.KeyId, nil +} diff --git a/internal/grpc/codec.go b/internal/grpc/codec.go new file mode 100644 index 0000000..478474b --- /dev/null +++ b/internal/grpc/codec.go @@ -0,0 +1,17 @@ +package grpcclient + +import "encoding/json" + +type JSONCodec struct{} + +func (JSONCodec) Marshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} + +func (JSONCodec) Unmarshal(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +func (JSONCodec) Name() string { + return "proto" +} diff --git a/internal/grpc/pb/keymanager.pb.go b/internal/grpc/pb/keymanager.pb.go new file mode 100644 index 0000000..3a209af --- /dev/null +++ b/internal/grpc/pb/keymanager.pb.go @@ -0,0 +1,93 @@ +// Hand-written gRPC bindings for keymanager.proto (agent side, JSON codec). + +package pb + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type RegisterRequest struct { + ServerId string `json:"server_id"` + PreRegToken string `json:"pre_reg_token"` + Hostname string `json:"hostname"` + IpAddress string `json:"ip_address"` + OsInfo string `json:"os_info"` +} + +type RegisterResponse struct { + AgentToken string `json:"agent_token"` +} + +type SyncRequest struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` +} + +type SyncResponse struct { + PublicKeys []string `json:"public_keys"` +} + +type UploadKeyRequest struct { + ServerId string `json:"server_id"` + AgentToken string `json:"agent_token"` + PublicKey string `json:"public_key"` + Label string `json:"label"` +} + +type UploadKeyResponse struct { + KeyId string `json:"key_id"` +} + +type KeyManagerClient interface { + Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) + SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) + UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) +} + +type UnimplementedKeyManagerServer struct{} + +func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} +func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} +func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "not implemented") +} + +type keyManagerClient struct { + cc grpc.ClientConnInterface +} + +func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient { + return &keyManagerClient{cc} +} + +func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) { + out := new(RegisterResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) { + out := new(SyncResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} + +func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) { + out := new(UploadKeyResponse) + if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..e9a8cc4 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,120 @@ +package keys + +import ( + "crypto/md5" + "encoding/base64" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const authorizedKeysPath = "/root/.ssh/authorized_keys" + +func ReadAuthorizedKeys() ([]string, error) { + data, err := os.ReadFile(authorizedKeysPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var lines []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + lines = append(lines, line) + } + } + return lines, nil +} + +func WriteAuthorizedKeys(keys []string) error { + dir := filepath.Dir(authorizedKeysPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + + content := strings.Join(keys, "\n") + if len(keys) > 0 { + content += "\n" + } + + tmpPath := authorizedKeysPath + ".tmp" + if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + + if err := os.Rename(tmpPath, authorizedKeysPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("rename: %w", err) + } + + return os.Chmod(authorizedKeysPath, 0600) +} + +func FingerprintLines(lines []string) map[string]bool { + fp := make(map[string]bool, len(lines)) + for _, line := range lines { + fp[fingerprint(line)] = true + } + return fp +} + +func StateChanged(current, desired []string) bool { + if len(current) != len(desired) { + return true + } + cur := FingerprintLines(current) + for _, line := range desired { + if !cur[fingerprint(line)] { + return true + } + } + return false +} + +func fingerprint(pubKey string) string { + parts := strings.Fields(pubKey) + if len(parts) < 2 { + return pubKey + } + raw, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return pubKey + } + sum := md5.Sum(raw) + var pairs []string + for _, b := range sum { + pairs = append(pairs, fmt.Sprintf("%02x", b)) + } + return "MD5:" + strings.Join(pairs, ":") +} + +// GenerateKeyPair generates an ed25519 SSH keypair and returns the public key. +// The private key is written to keyPath; keyPath+".pub" holds the public key. +func GenerateKeyPair(keyPath, comment string) (string, error) { + if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { + return "", err + } + + args := []string{ + "-t", "ed25519", + "-f", keyPath, + "-N", "", + "-C", comment, + } + cmd := exec.Command("ssh-keygen", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("ssh-keygen: %w: %s", err, out) + } + + pubData, err := os.ReadFile(keyPath + ".pub") + if err != nil { + return "", fmt.Errorf("read pubkey: %w", err) + } + return strings.TrimSpace(string(pubData)), nil +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..9402318 --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,129 @@ +package agentsync + +import ( + "fmt" + "log" + "net" + "os" + "runtime" + "strings" + "time" + + "github.com/mrhid6/keymanager/agent/internal/config" + grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc" + "github.com/mrhid6/keymanager/agent/internal/keys" +) + +func Run(cfg *config.Config) error { + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return fmt.Errorf("dial grpc: %w", err) + } + defer client.Close() + + // Register if we have a pre-reg token + if cfg.PreRegToken != "" { + log.Println("registering with server...") + hostname, _ := os.Hostname() + ipAddress := localIP() + osInfo := fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) + + agentToken, err := client.Register(cfg.ServerID, cfg.PreRegToken, hostname, ipAddress, osInfo) + if err != nil { + return fmt.Errorf("registration failed: %w", err) + } + + cfg.AgentToken = agentToken + cfg.PreRegToken = "" + if err := config.Save(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Println("registration successful") + + // Reconnect with potentially updated state + client.Close() + client, err = grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return fmt.Errorf("reconnect: %w", err) + } + } + + if cfg.AgentToken == "" { + return fmt.Errorf("no agent token available — registration required") + } + + ticker := time.NewTicker(cfg.PollInterval) + defer ticker.Stop() + + // Run immediately on startup + if err := poll(client, cfg); err != nil { + log.Printf("poll error: %v", err) + } + + for range 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 { + desired, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken) + if err != nil { + return fmt.Errorf("SyncKeys: %w", err) + } + + current, err := keys.ReadAuthorizedKeys() + if err != nil { + return fmt.Errorf("read authorized_keys: %w", err) + } + + if !keys.StateChanged(current, desired) { + log.Println("authorized_keys unchanged, skipping write") + return nil + } + + if err := keys.WriteAuthorizedKeys(desired); err != nil { + return fmt.Errorf("write authorized_keys: %w", err) + } + log.Printf("authorized_keys updated (%d keys)", len(desired)) + return nil +} + +func localIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + return ipNet.IP.String() + } + } + } + return "" +} + +// GenerateAndUpload generates an SSH keypair and uploads the public key to the server. +func GenerateAndUpload(cfg *config.Config, label string) error { + client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) + if err != nil { + return err + } + defer client.Close() + + keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_")) + pubKey, err := keys.GenerateKeyPair(keyPath, label) + if err != nil { + return err + } + + keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, label) + if err != nil { + return err + } + log.Printf("uploaded generated key %s (key_id=%s)", label, keyID) + return nil +}