diff --git a/cmd/silo-proxy-agent/main.go b/cmd/silo-proxy-agent/main.go index 2d28bdb..10e2ce8 100644 --- a/cmd/silo-proxy-agent/main.go +++ b/cmd/silo-proxy-agent/main.go @@ -51,7 +51,7 @@ func main() { MaxAge: 12 * time.Hour, })) engine.Use(gin.Recovery()) - internalhttp.SetupRoute(engine, services) + internalhttp.SetupRoute(engine, services, "") server := &http.Server{ Addr: fmt.Sprintf(":%d", config.Http.Port), diff --git a/cmd/silo-proxy-server/application.yml b/cmd/silo-proxy-server/application.yml index 5cd6e7f..8957817 100644 --- a/cmd/silo-proxy-server/application.yml +++ b/cmd/silo-proxy-server/application.yml @@ -2,6 +2,7 @@ log: level: debug http: port: 8080 + admin_api_key: "" # Required for certificate management endpoints agent_port_range: start: 8100 end: 8100 @@ -12,4 +13,8 @@ grpc: cert_file: ./certs/server/server-cert.pem key_file: ./certs/server/server-key.pem ca_file: ./certs/ca/ca-cert.pem + ca_key_file: ./certs/ca/ca-key.pem client_auth: require + domain_names: "localhost" + ip_addresses: "127.0.0.1" + agent_cert_dir: ./certs/agents diff --git a/cmd/silo-proxy-server/config.go b/cmd/silo-proxy-server/config.go index 611a06e..e4d88f3 100644 --- a/cmd/silo-proxy-server/config.go +++ b/cmd/silo-proxy-server/config.go @@ -22,11 +22,15 @@ type GrpcConfig struct { } type TLSConfig struct { - Enabled bool `mapstructure:"enabled"` - CertFile string `mapstructure:"cert_file"` - KeyFile string `mapstructure:"key_file"` - CAFile string `mapstructure:"ca_file"` - ClientAuth string `mapstructure:"client_auth"` + Enabled bool `mapstructure:"enabled"` + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` + CAFile string `mapstructure:"ca_file"` + CAKeyFile string `mapstructure:"ca_key_file"` + ClientAuth string `mapstructure:"client_auth"` + DomainNames string `mapstructure:"domain_names"` + IPAddresses string `mapstructure:"ip_addresses"` + AgentCertDir string `mapstructure:"agent_cert_dir"` } var config Config diff --git a/cmd/silo-proxy-server/main.go b/cmd/silo-proxy-server/main.go index 7c75dd0..26b9788 100644 --- a/cmd/silo-proxy-server/main.go +++ b/cmd/silo-proxy-server/main.go @@ -12,6 +12,7 @@ import ( "time" internalhttp "github.com/EternisAI/silo-proxy/internal/api/http" + "github.com/EternisAI/silo-proxy/internal/cert" grpcserver "github.com/EternisAI/silo-proxy/internal/grpc/server" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -32,6 +33,25 @@ func main() { ClientAuth: config.Grpc.TLS.ClientAuth, } + var certService *cert.Service + if config.Grpc.TLS.Enabled { + + var err error + certService, err = cert.New( + config.Grpc.TLS.CAFile, + config.Grpc.TLS.CAKeyFile, + config.Grpc.TLS.CertFile, + config.Grpc.TLS.KeyFile, + config.Grpc.TLS.AgentCertDir, + config.Grpc.TLS.DomainNames, + config.Grpc.TLS.IPAddresses, + ) + if err != nil { + slog.Error("Failed to initialize certificates", "error", err) + os.Exit(1) + } + } + grpcSrv := grpcserver.NewServer(config.Grpc.Port, tlsConfig) portManager, err := internalhttp.NewPortManager( @@ -52,7 +72,8 @@ func main() { "pool_size", config.Http.AgentPortRange.End-config.Http.AgentPortRange.Start+1) services := &internalhttp.Services{ - GrpcServer: grpcSrv, + GrpcServer: grpcSrv, + CertService: certService, } gin.SetMode(gin.ReleaseMode) @@ -60,13 +81,13 @@ func main() { engine.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"PUT", "PATCH", "GET", "POST", "DELETE"}, - AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, + AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-API-Key"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, MaxAge: 12 * time.Hour, })) engine.Use(gin.Recovery()) - internalhttp.SetupRoute(engine, services) + internalhttp.SetupRoute(engine, services, config.Http.AdminAPIKey) httpServer := &http.Server{ Addr: fmt.Sprintf(":%d", config.Http.Port), diff --git a/docs/potential_issues.md b/docs/potential_issues.md new file mode 100644 index 0000000..eba7f70 --- /dev/null +++ b/docs/potential_issues.md @@ -0,0 +1,5 @@ +Potential Issues + +1. Missing server certificate regeneration when domain/IP config changes +2. No rate limiting on certificate generation endpoints +3. Agent cert directory cleanup - DeleteAgentCert removes entire directory, which could be dangerous diff --git a/internal/api/http/handler/cert.go b/internal/api/http/handler/cert.go new file mode 100644 index 0000000..13754cc --- /dev/null +++ b/internal/api/http/handler/cert.go @@ -0,0 +1,234 @@ +package handler + +import ( + "archive/zip" + "bytes" + "crypto/rsa" + "crypto/x509" + "fmt" + "log/slog" + "net/http" + + "github.com/EternisAI/silo-proxy/internal/cert" + "github.com/gin-gonic/gin" +) + +func (h *CertHandler) validateAgentID(ctx *gin.Context) (string, bool) { + agentID := ctx.Param("id") + if err := cert.ValidateAgentID(agentID); err != nil { + slog.Warn("Invalid agent ID", "agent_id", agentID, "error", err) + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return "", false + } + return agentID, true +} + +type CertHandler struct { + certService *cert.Service +} + +func NewCertHandler(certService *cert.Service) *CertHandler { + return &CertHandler{ + certService: certService, + } +} + +func (h *CertHandler) CreateAgentCertificate(ctx *gin.Context) { + if h.certService == nil { + slog.Warn("Agent cert creation requested but TLS is disabled") + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": "TLS is not enabled on this server", + }) + return + } + + agentID, ok := h.validateAgentID(ctx) + if !ok { + return + } + + if h.certService.AgentCertExists(agentID) { + slog.Warn("Certificate already exists", "agent_id", agentID) + ctx.JSON(http.StatusConflict, gin.H{ + "error": "Certificate already exists for this agent", + }) + return + } + + slog.Info("Creating agent certificate", "agent_id", agentID) + + agentCert, agentKey, err := h.certService.GenerateAgentCert(agentID) + if err != nil { + slog.Error("Failed to generate agent certificate", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to generate agent certificate", + }) + return + } + + caCertBytes, err := h.certService.GetCACert() + if err != nil { + slog.Error("Failed to read CA certificate", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to read CA certificate", + }) + return + } + + zipBuffer, err := h.createCertZip(agentID, agentCert, agentKey, caCertBytes) + if err != nil { + slog.Error("Failed to create zip file", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create zip file", + }) + return + } + + ctx.Header("Content-Type", "application/zip") + ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-certs.zip\"", agentID)) + ctx.Data(http.StatusCreated, "application/zip", zipBuffer.Bytes()) + + slog.Info("Agent certificate created successfully", "agent_id", agentID, "zip_size", zipBuffer.Len()) +} + +func (h *CertHandler) GetAgentCertificate(ctx *gin.Context) { + if h.certService == nil { + slog.Warn("Agent cert retrieval requested but TLS is disabled") + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": "TLS is not enabled on this server", + }) + return + } + + agentID, ok := h.validateAgentID(ctx) + if !ok { + return + } + + if !h.certService.AgentCertExists(agentID) { + slog.Warn("Certificate not found", "agent_id", agentID) + ctx.JSON(http.StatusNotFound, gin.H{ + "error": "Certificate not found for this agent", + }) + return + } + + slog.Info("Retrieving agent certificate", "agent_id", agentID) + + agentCertBytes, agentKeyBytes, err := h.certService.GetAgentCert(agentID) + if err != nil { + slog.Error("Failed to read agent certificate", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to read agent certificate", + }) + return + } + + caCertBytes, err := h.certService.GetCACert() + if err != nil { + slog.Error("Failed to read CA certificate", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to read CA certificate", + }) + return + } + + zipBuffer, err := h.createCertZipFromBytes(agentID, agentCertBytes, agentKeyBytes, caCertBytes) + if err != nil { + slog.Error("Failed to create zip file", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to create zip file", + }) + return + } + + ctx.Header("Content-Type", "application/zip") + ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-certs.zip\"", agentID)) + ctx.Data(http.StatusOK, "application/zip", zipBuffer.Bytes()) + + slog.Info("Agent certificate retrieved successfully", "agent_id", agentID, "zip_size", zipBuffer.Len()) +} + +func (h *CertHandler) DeleteAgentCertificate(ctx *gin.Context) { + if h.certService == nil { + slog.Warn("Agent cert deletion requested but TLS is disabled") + ctx.JSON(http.StatusBadRequest, gin.H{ + "error": "TLS is not enabled on this server", + }) + return + } + + agentID, ok := h.validateAgentID(ctx) + if !ok { + return + } + + if !h.certService.AgentCertExists(agentID) { + slog.Warn("Certificate not found for deletion", "agent_id", agentID) + ctx.JSON(http.StatusNotFound, gin.H{ + "error": "Certificate not found for this agent", + }) + return + } + + slog.Info("Deleting agent certificate", "agent_id", agentID) + + certDir := h.certService.GetAgentCertDir(agentID) + if err := h.certService.DeleteAgentCert(agentID); err != nil { + slog.Error("Failed to delete agent certificate", "error", err, "agent_id", agentID) + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete agent certificate", + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "Successfully deleted agent certificate", + "deleted_paths": []string{certDir}, + }) + slog.Info("Agent certificate deleted successfully", "agent_id", agentID) +} + +func (h *CertHandler) createCertZip(agentID string, agentCert *x509.Certificate, agentKey *rsa.PrivateKey, caCertBytes []byte) (*bytes.Buffer, error) { + agentCertPEM, err := cert.CertToPEM(agentCert) + if err != nil { + return nil, fmt.Errorf("failed to encode agent certificate: %w", err) + } + + agentKeyPEM, err := cert.KeyToPEM(agentKey) + if err != nil { + return nil, fmt.Errorf("failed to encode agent key: %w", err) + } + + return h.createCertZipFromBytes(agentID, agentCertPEM, agentKeyPEM, caCertBytes) +} + +func (h *CertHandler) createCertZipFromBytes(agentID string, certPEM, keyPEM, caCertBytes []byte) (*bytes.Buffer, error) { + zipBuffer := new(bytes.Buffer) + zipWriter := zip.NewWriter(zipBuffer) + defer zipWriter.Close() + + files := map[string][]byte{ + fmt.Sprintf("%s-cert.pem", agentID): certPEM, + fmt.Sprintf("%s-key.pem", agentID): keyPEM, + "ca-cert.pem": caCertBytes, + } + + for filename, content := range files { + f, err := zipWriter.Create(filename) + if err != nil { + return nil, fmt.Errorf("failed to create zip entry for %s: %w", filename, err) + } + if _, err := f.Write(content); err != nil { + return nil, fmt.Errorf("failed to write zip entry for %s: %w", filename, err) + } + } + + if err := zipWriter.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize zip archive: %w", err) + } + + return zipBuffer, nil +} diff --git a/internal/api/http/http.go b/internal/api/http/http.go index 8f65cac..6d4c874 100644 --- a/internal/api/http/http.go +++ b/internal/api/http/http.go @@ -3,6 +3,7 @@ package http type Config struct { Port uint `mapstructure:"port"` AgentPortRange PortRange `mapstructure:"agent_port_range"` + AdminAPIKey string `mapstructure:"admin_api_key"` } type PortRange struct { diff --git a/internal/api/http/middleware/auth.go b/internal/api/http/middleware/auth.go new file mode 100644 index 0000000..b1cffc2 --- /dev/null +++ b/internal/api/http/middleware/auth.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "crypto/subtle" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +const ( + apiKeyHeader = "X-API-Key" +) + +func APIKeyAuth(apiKey string) gin.HandlerFunc { + return func(c *gin.Context) { + if apiKey == "" { + slog.Warn("Admin API key not configured, rejecting request", + "path", c.Request.URL.Path, + "client_ip", c.ClientIP()) + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ + "error": "Admin API is not configured", + }) + return + } + + providedKey := c.GetHeader(apiKeyHeader) + if providedKey == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Missing API key", + }) + return + } + + if subtle.ConstantTimeCompare([]byte(providedKey), []byte(apiKey)) != 1 { + slog.Warn("Invalid API key attempt", + "path", c.Request.URL.Path, + "client_ip", c.ClientIP()) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "Invalid API key", + }) + return + } + + c.Next() + } +} diff --git a/internal/api/http/router.go b/internal/api/http/router.go index 4940f19..87a9e9c 100644 --- a/internal/api/http/router.go +++ b/internal/api/http/router.go @@ -3,22 +3,36 @@ package http import ( "github.com/EternisAI/silo-proxy/internal/api/http/handler" "github.com/EternisAI/silo-proxy/internal/api/http/middleware" + "github.com/EternisAI/silo-proxy/internal/cert" grpcserver "github.com/EternisAI/silo-proxy/internal/grpc/server" "github.com/gin-gonic/gin" ) type Services struct { - GrpcServer *grpcserver.Server + GrpcServer *grpcserver.Server + CertService *cert.Service } -func SetupRoute(engine *gin.Engine, srvs *Services) { +func SetupRoute(engine *gin.Engine, srvs *Services, adminAPIKey string) { engine.Use(middleware.RequestLogger()) healthHandler := handler.NewHealthHandler() engine.GET("/health", healthHandler.Check) - if srvs.GrpcServer != nil { - adminHandler := handler.NewAdminHandler(srvs.GrpcServer) - engine.GET("/agents", adminHandler.ListAgents) + agents := engine.Group("/agents") + { + if srvs.GrpcServer != nil { + adminHandler := handler.NewAdminHandler(srvs.GrpcServer) + agents.GET("", adminHandler.ListAgents) + } + + certHandler := handler.NewCertHandler(srvs.CertService) + certRoutes := agents.Group("") + certRoutes.Use(middleware.APIKeyAuth(adminAPIKey)) + { + certRoutes.POST("/:id/certificate", certHandler.CreateAgentCertificate) + certRoutes.GET("/:id/certificate", certHandler.GetAgentCertificate) + certRoutes.DELETE("/:id/certificate", certHandler.DeleteAgentCertificate) + } } } diff --git a/internal/cert/generate.go b/internal/cert/generate.go new file mode 100644 index 0000000..51ce1f8 --- /dev/null +++ b/internal/cert/generate.go @@ -0,0 +1,159 @@ +package cert + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "log/slog" + "math/big" + "net" + "time" +) + +func (s *Service) GenerateServerCert(caCert *x509.Certificate, caKey *rsa.PrivateKey, domainNames []string, ipAddresses []net.IP) (*x509.Certificate, *rsa.PrivateKey, error) { + serverKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate server key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + commonName := "localhost" + if len(domainNames) > 0 { + commonName = domainNames[0] + } + + serverTemplate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Silo Proxy"}, + CommonName: commonName, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: domainNames, + IPAddresses: ipAddresses, + } + + serverCertBytes, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create server certificate: %w", err) + } + + serverCert, err := x509.ParseCertificate(serverCertBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse server certificate: %w", err) + } + + return serverCert, serverKey, nil +} + +func (s *Service) GenerateCA() (*x509.Certificate, *rsa.PrivateKey, error) { + caKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate CA key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + caTemplate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Silo Proxy CA"}, + CommonName: "Silo Proxy Root CA", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + + caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create CA certificate: %w", err) + } + + caCert, err := x509.ParseCertificate(caCertBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + return caCert, caKey, nil +} + +func (s *Service) GenerateAgentCert(agentID string) (*x509.Certificate, *rsa.PrivateKey, error) { + slog.Info("Generating agent certificate", "agent_id", agentID) + + caCert, caKey, err := loadCA(s.CaCertPath, s.CaKeyPath) + if err != nil { + slog.Error("Failed to load CA for agent cert generation", "error", err, "agent_id", agentID) + return nil, nil, fmt.Errorf("failed to load CA: %w", err) + } + + agentKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate agent key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + agentTemplate := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Silo Proxy"}, + CommonName: agentID, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + agentCertBytes, err := x509.CreateCertificate(rand.Reader, agentTemplate, caCert, &agentKey.PublicKey, caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create agent certificate: %w", err) + } + + agentCert, err := x509.ParseCertificate(agentCertBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse agent certificate: %w", err) + } + + certPath := s.GetAgentCertPath(agentID) + keyPath := s.GetAgentKeyPath(agentID) + + if err := s.ensureDirectory(certPath); err != nil { + return nil, nil, fmt.Errorf("failed to create agent cert directory: %w", err) + } + + if err := writeCertToFile(agentCert, certPath); err != nil { + slog.Error("Failed to write agent certificate", "error", err, "path", certPath) + return nil, nil, fmt.Errorf("failed to write agent certificate: %w", err) + } + + if err := writeKeyToFile(agentKey, keyPath); err != nil { + slog.Error("Failed to write agent key", "error", err, "path", keyPath) + return nil, nil, fmt.Errorf("failed to write agent key: %w", err) + } + + slog.Info("Generated and saved agent certificate", "agent_id", agentID, "cert_path", certPath, "key_path", keyPath) + return agentCert, agentKey, nil +} diff --git a/internal/cert/service.go b/internal/cert/service.go new file mode 100644 index 0000000..0cdbe21 --- /dev/null +++ b/internal/cert/service.go @@ -0,0 +1,231 @@ +package cert + +import ( + "crypto/rsa" + "crypto/x509" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" +) + +type Service struct { + CaCertPath string + CaKeyPath string + ServerCertPath string + ServerKeyPath string + AgentCertDir string + DomainNames []string + IPAddresses []net.IP +} + +func New(caCertPath, caKeyPath, serverCertPath, serverKeyPath, agentCertDir, domainNamesConfig, IPAddressesConfig string) (*Service, error) { + s := &Service{ + CaCertPath: caCertPath, + CaKeyPath: caKeyPath, + ServerCertPath: serverCertPath, + ServerKeyPath: serverKeyPath, + AgentCertDir: agentCertDir, + } + + domainNames := ParseCommaSeparated(domainNamesConfig) + if len(domainNames) > 0 { + s.DomainNames = domainNames + } + + ipAddresses := ParseCommaSeparated(IPAddressesConfig) + if len(ipAddresses) > 0 { + for _, ipStr := range ipAddresses { + if ip := net.ParseIP(ipStr); ip != nil { + s.IPAddresses = append(s.IPAddresses, ip) + } else { + slog.Warn("Invalid IP address in configuration, skipping", "ip", ipStr) + } + } + } + + if err := s.ensureCertificates(); err != nil { + return nil, fmt.Errorf("failed to ensure certificates: %w", err) + } + + return s, nil +} + +func (s *Service) ensureCertificates() error { + caCertExists := fileExists(s.CaCertPath) + caKeyExists := fileExists(s.CaKeyPath) + + var caCert *x509.Certificate + var caKey *rsa.PrivateKey + + if !caCertExists || !caKeyExists { + slog.Info("CA certificate not found, generating new CA", "cert_path", s.CaCertPath) + + var err error + caCert, caKey, err = s.GenerateCA() + if err != nil { + slog.Error("Failed to generate CA certificate", "error", err) + return fmt.Errorf("failed to generate CA certificate: %w", err) + } + + if err := s.ensureDirectory(s.CaCertPath); err != nil { + return err + } + + if err := writeCertToFile(caCert, s.CaCertPath); err != nil { + slog.Error("Failed to write CA certificate", "error", err, "path", s.CaCertPath) + return fmt.Errorf("failed to write CA certificate: %w", err) + } + + if err := s.ensureDirectory(s.CaKeyPath); err != nil { + return err + } + + if err := writeKeyToFile(caKey, s.CaKeyPath); err != nil { + slog.Error("Failed to write CA key", "error", err, "path", s.CaKeyPath) + return fmt.Errorf("failed to write CA key: %w", err) + } + + slog.Info("Generated CA certificate", "cert_path", s.CaCertPath, "key_path", s.CaKeyPath) + } else { + slog.Debug("Using existing CA certificate", "cert_path", s.CaCertPath) + + var err error + caCert, caKey, err = loadCA(s.CaCertPath, s.CaKeyPath) + if err != nil { + slog.Error("Failed to load existing CA certificate", "error", err) + return fmt.Errorf("failed to load existing CA certificate: %w", err) + } + } + + serverCertExists := fileExists(s.ServerCertPath) + serverKeyExists := fileExists(s.ServerKeyPath) + + if !serverCertExists || !serverKeyExists { + slog.Info("Server certificate not found, generating new server certificate", + "cert_path", s.ServerCertPath, + "domains", s.DomainNames, + "ips", s.IPAddresses) + + serverCert, serverKey, err := s.GenerateServerCert(caCert, caKey, s.DomainNames, s.IPAddresses) + if err != nil { + slog.Error("Failed to generate server certificate", "error", err) + return fmt.Errorf("failed to generate server certificate: %w", err) + } + + if err := s.ensureDirectory(s.ServerCertPath); err != nil { + return err + } + + if err := writeCertToFile(serverCert, s.ServerCertPath); err != nil { + slog.Error("Failed to write server certificate", "error", err, "path", s.ServerCertPath) + return fmt.Errorf("failed to write server certificate: %w", err) + } + + if err := s.ensureDirectory(s.ServerKeyPath); err != nil { + return err + } + + if err := writeKeyToFile(serverKey, s.ServerKeyPath); err != nil { + slog.Error("Failed to write server key", "error", err, "path", s.ServerKeyPath) + return fmt.Errorf("failed to write server key: %w", err) + } + + slog.Info("Generated server certificate", "cert_path", s.ServerCertPath, "key_path", s.ServerKeyPath) + } else { + slog.Debug("Using existing server certificate", "cert_path", s.ServerCertPath) + } + + return nil +} + +func (s *Service) ensureDirectory(filePath string) error { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Error("Failed to create directory", "error", err, "path", dir) + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + return nil +} + +func (s *Service) GetCACert() ([]byte, error) { + certBytes, err := os.ReadFile(s.CaCertPath) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + return certBytes, nil +} + +func (s *Service) GetAgentCertDir(agentID string) string { + return filepath.Join(s.AgentCertDir, agentID) +} + +func (s *Service) GetAgentCertPath(agentID string) string { + return filepath.Join(s.GetAgentCertDir(agentID), fmt.Sprintf("%s-cert.pem", agentID)) +} + +func (s *Service) GetAgentKeyPath(agentID string) string { + return filepath.Join(s.GetAgentCertDir(agentID), fmt.Sprintf("%s-key.pem", agentID)) +} + +func (s *Service) AgentCertExists(agentID string) bool { + certPath := s.GetAgentCertPath(agentID) + keyPath := s.GetAgentKeyPath(agentID) + return fileExists(certPath) && fileExists(keyPath) +} + +func (s *Service) GetAgentCert(agentID string) (certBytes, keyBytes []byte, err error) { + certPath := s.GetAgentCertPath(agentID) + keyPath := s.GetAgentKeyPath(agentID) + + certBytes, err = os.ReadFile(certPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read agent certificate: %w", err) + } + + keyBytes, err = os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read agent key: %w", err) + } + + return certBytes, keyBytes, nil +} + +func (s *Service) DeleteAgentCert(agentID string) error { + certDir := s.GetAgentCertDir(agentID) + + if !fileExists(certDir) { + return fmt.Errorf("agent certificate directory does not exist") + } + + if err := os.RemoveAll(certDir); err != nil { + return fmt.Errorf("failed to delete agent certificate directory: %w", err) + } + + slog.Info("Deleted agent certificate", "agent_id", agentID, "path", certDir) + return nil +} + +func (s *Service) ListAgentCerts() ([]string, error) { + if !fileExists(s.AgentCertDir) { + return []string{}, nil + } + + entries, err := os.ReadDir(s.AgentCertDir) + if err != nil { + return nil, fmt.Errorf("failed to read agent cert directory: %w", err) + } + + var agentIDs []string + for _, entry := range entries { + if entry.IsDir() { + agentID := entry.Name() + if s.AgentCertExists(agentID) { + agentIDs = append(agentIDs, agentID) + } + } + } + + return agentIDs, nil +} diff --git a/internal/cert/utils.go b/internal/cert/utils.go new file mode 100644 index 0000000..548c497 --- /dev/null +++ b/internal/cert/utils.go @@ -0,0 +1,138 @@ +package cert + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "regexp" + "strings" +) + +var validAgentIDRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +func ValidateAgentID(agentID string) error { + if agentID == "" { + return fmt.Errorf("agent ID cannot be empty") + } + if len(agentID) > 64 { + return fmt.Errorf("agent ID too long (max 64 characters)") + } + if !validAgentIDRegex.MatchString(agentID) { + return fmt.Errorf("agent ID contains invalid characters (allowed: alphanumeric, underscore, hyphen)") + } + return nil +} + +func ParseCommaSeparated(input string) []string { + if input == "" { + return nil + } + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func loadCA(certPath, keyPath string) (*x509.Certificate, *rsa.PrivateKey, error) { + certBytes, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + + certBlock, _ := pem.Decode(certBytes) + if certBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA certificate PEM") + } + + caCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to read CA key: %w", err) + } + + keyBlock, _ := pem.Decode(keyBytes) + if keyBlock == nil { + return nil, nil, fmt.Errorf("failed to decode CA key PEM") + } + + key, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse CA key: %w", err) + } + + caKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, nil, fmt.Errorf("CA key is not an RSA private key") + } + + return caCert, caKey, nil +} + +func CertToPEM(cert *x509.Certificate) ([]byte, error) { + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func KeyToPEM(key *rsa.PrivateKey) ([]byte, error) { + keyBytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyBytes, + }); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeCertToFile(cert *x509.Certificate, path string) error { + pemBytes, err := CertToPEM(cert) + if err != nil { + return fmt.Errorf("failed to encode certificate: %w", err) + } + + if err := os.WriteFile(path, pemBytes, 0644); err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + + return nil +} + +func writeKeyToFile(key *rsa.PrivateKey, path string) error { + pemBytes, err := KeyToPEM(key) + if err != nil { + return fmt.Errorf("failed to encode key: %w", err) + } + + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + return fmt.Errorf("failed to write key file: %w", err) + } + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/misc/create-agent-cert.sh b/misc/create-agent-cert.sh new file mode 100755 index 0000000..0e66a07 --- /dev/null +++ b/misc/create-agent-cert.sh @@ -0,0 +1,6 @@ +#!/bin/bash +AGENT_ID=${1:-agent-1} +API_KEY=${ADMIN_API_KEY:-your-api-key-here} +curl -X POST "http://localhost:8080/agents/${AGENT_ID}/certificate" \ + -H "X-API-Key: ${API_KEY}" \ + -o "${AGENT_ID}-certs.zip" diff --git a/misc/delete-agent-cert.sh b/misc/delete-agent-cert.sh new file mode 100755 index 0000000..76c4a1e --- /dev/null +++ b/misc/delete-agent-cert.sh @@ -0,0 +1,5 @@ +#!/bin/bash +AGENT_ID=${1:-agent-1} +API_KEY=${ADMIN_API_KEY:-your-api-key-here} +curl -X DELETE "http://localhost:8080/agents/${AGENT_ID}/certificate" \ + -H "X-API-Key: ${API_KEY}" diff --git a/misc/get-agent-cert.sh b/misc/get-agent-cert.sh new file mode 100755 index 0000000..c00fb22 --- /dev/null +++ b/misc/get-agent-cert.sh @@ -0,0 +1,6 @@ +#!/bin/bash +AGENT_ID=${1:-agent-1} +API_KEY=${ADMIN_API_KEY:-your-api-key-here} +curl -X GET "http://localhost:8080/agents/${AGENT_ID}/certificate" \ + -H "X-API-Key: ${API_KEY}" \ + -o "${AGENT_ID}-certs.zip" diff --git a/misc/list-connected-agents.sh b/misc/list-connected-agents.sh new file mode 100755 index 0000000..ce70643 --- /dev/null +++ b/misc/list-connected-agents.sh @@ -0,0 +1,2 @@ +#!/bin/bash +curl -X GET http://localhost:8080/agents