diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d76d4a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f280de2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binaries + run: | + GOOS=linux GOARCH=amd64 go build -o flatrun-agent-linux-amd64 ./cmd/agent + GOOS=linux GOARCH=arm64 go build -o flatrun-agent-linux-arm64 ./cmd/agent + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + flatrun-agent-linux-amd64 + flatrun-agent-linux-arm64 + generate_release_notes: true diff --git a/Makefile b/Makefile index 0a167c5..49c1709 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run test clean deps +.PHONY: help build run test clean deps lint lint-install fmt vet BINARY_NAME=flatrun-agent VERSION?=dev @@ -9,11 +9,15 @@ LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X help: @echo "FlatRun Agent - Build commands" @echo "" - @echo "make deps - Install dependencies" - @echo "make build - Build binary" - @echo "make run - Run in development mode" - @echo "make test - Run tests" - @echo "make clean - Clean build artifacts" + @echo "make deps - Install dependencies" + @echo "make build - Build binary" + @echo "make run - Run in development mode" + @echo "make test - Run tests" + @echo "make lint - Run golangci-lint" + @echo "make lint-install - Install golangci-lint" + @echo "make fmt - Format code with gofmt" + @echo "make vet - Run go vet" + @echo "make clean - Clean build artifacts" deps: go mod download @@ -28,6 +32,21 @@ run: deps test: go test -v ./... +lint: + @command -v golangci-lint > /dev/null 2>&1 && golangci-lint run ./... || \ + (test -x "$$(go env GOPATH)/bin/golangci-lint" && "$$(go env GOPATH)/bin/golangci-lint" run ./... || \ + (echo "golangci-lint not found. Run 'make lint-install' first." && exit 1)) + +lint-install: + @echo "Installing golangci-lint..." + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +fmt: + go fmt ./... + +vet: + go vet ./... + clean: rm -f $(BINARY_NAME) go clean diff --git a/go.mod b/go.mod index 80ca867..f5a2721 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.3.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -17,7 +18,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect diff --git a/internal/api/server.go b/internal/api/server.go index 528cf69..e75824c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -14,21 +14,25 @@ import ( "github.com/flatrun/agent/internal/certs" "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/networks" + "github.com/flatrun/agent/internal/proxy" "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" "github.com/flatrun/agent/pkg/plugins" + "github.com/flatrun/agent/pkg/subdomain" "github.com/gin-gonic/gin" "gopkg.in/yaml.v3" ) type Server struct { - config *config.Config - router *gin.Engine - server *http.Server - manager *docker.Manager - certsDiscovery *certs.Discovery - networksManager *networks.Manager - pluginRegistry *plugins.Registry - authMiddleware *auth.Middleware + config *config.Config + router *gin.Engine + server *http.Server + manager *docker.Manager + certsDiscovery *certs.Discovery + networksManager *networks.Manager + pluginRegistry *plugins.Registry + authMiddleware *auth.Middleware + proxyOrchestrator *proxy.Orchestrator } func New(cfg *config.Config) *Server { @@ -49,17 +53,19 @@ func New(cfg *config.Config) *Server { networksManager := networks.NewManager() pluginsDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins") pluginRegistry := plugins.NewRegistry(pluginsDir) - pluginRegistry.LoadFromDisk() + _ = pluginRegistry.LoadFromDisk() authMiddleware := auth.NewMiddleware(&cfg.Auth) + proxyOrchestrator := proxy.NewOrchestrator(cfg) s := &Server{ - config: cfg, - router: router, - manager: manager, - certsDiscovery: certsDiscovery, - networksManager: networksManager, - pluginRegistry: pluginRegistry, - authMiddleware: authMiddleware, + config: cfg, + router: router, + manager: manager, + certsDiscovery: certsDiscovery, + networksManager: networksManager, + pluginRegistry: pluginRegistry, + authMiddleware: authMiddleware, + proxyOrchestrator: proxyOrchestrator, } s.setupRoutes() @@ -93,8 +99,18 @@ func (s *Server) setupRoutes() { protected.POST("/networks/:name/connect", s.connectContainer) protected.POST("/networks/:name/disconnect", s.disconnectContainer) protected.GET("/certificates", s.listCertificates) + protected.POST("/certificates", s.requestCertificate) + protected.POST("/certificates/renew", s.renewCertificates) + protected.DELETE("/certificates/:domain", s.deleteCertificate) + + protected.GET("/proxy/status/:name", s.getProxyStatus) + protected.POST("/proxy/setup/:name", s.setupProxy) + protected.DELETE("/proxy/:name", s.teardownProxy) + protected.GET("/proxy/vhosts", s.listVirtualHosts) + protected.GET("/settings", s.getSettings) protected.PUT("/settings", s.updateSettings) + protected.GET("/subdomain/generate", s.generateSubdomain) protected.GET("/plugins", s.listPlugins) protected.GET("/plugins/:name", s.getPlugin) protected.POST("/plugins/:name/deployments", s.createPluginDeployment) @@ -113,6 +129,8 @@ func (s *Server) setupRoutes() { protected.POST("/volumes", s.createVolume) protected.DELETE("/volumes/:name", s.removeVolume) protected.POST("/volumes/prune", s.pruneVolumes) + protected.GET("/ports", s.listPorts) + protected.POST("/ports/:pid/kill", s.killProcess) } } } @@ -172,17 +190,20 @@ func (s *Server) getDeployment(c *gin.Context) { } composeContent, _ := s.manager.GetComposeFile(name) + proxyStatus := s.proxyOrchestrator.GetDeploymentProxyStatus(deployment) c.JSON(http.StatusOK, gin.H{ "deployment": deployment, "compose_content": composeContent, + "proxy_status": proxyStatus, }) } func (s *Server) createDeployment(c *gin.Context) { var req struct { - Name string `json:"name" binding:"required"` - ComposeContent string `json:"compose_content" binding:"required"` + Name string `json:"name" binding:"required"` + ComposeContent string `json:"compose_content" binding:"required"` + Metadata *models.ServiceMetadata `json:"metadata,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -199,9 +220,27 @@ func (s *Server) createDeployment(c *gin.Context) { return } + if req.Metadata != nil { + if err := s.manager.SaveMetadata(req.Name, req.Metadata); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Deployment created but failed to save metadata: " + err.Error(), + }) + return + } + } + + var proxyResult *proxy.SetupResult + if req.Metadata != nil && req.Metadata.Networking.Expose { + deployment, err := s.manager.GetDeployment(req.Name) + if err == nil { + proxyResult, _ = s.proxyOrchestrator.SetupDeployment(deployment) + } + } + c.JSON(http.StatusCreated, gin.H{ - "message": "Deployment created", - "name": req.Name, + "message": "Deployment created", + "name": req.Name, + "proxy_result": proxyResult, }) } @@ -452,13 +491,67 @@ func (s *Server) getSettings(c *gin.Context) { "api_port": s.config.API.Port, "enable_cors": s.config.API.EnableCORS, "allowed_origins": s.config.API.AllowedOrigins, + "domain": gin.H{ + "default_domain": s.config.Domain.DefaultDomain, + "auto_subdomain": s.config.Domain.AutoSubdomain, + "auto_ssl": s.config.Domain.AutoSSL, + "subdomain_style": s.config.Domain.SubdomainStyle, + }, }, }) } func (s *Server) updateSettings(c *gin.Context) { + var req struct { + Domain *struct { + DefaultDomain string `json:"default_domain"` + AutoSubdomain bool `json:"auto_subdomain"` + AutoSSL bool `json:"auto_ssl"` + SubdomainStyle string `json:"subdomain_style"` + } `json:"domain,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Domain != nil { + s.config.Domain.DefaultDomain = req.Domain.DefaultDomain + s.config.Domain.AutoSubdomain = req.Domain.AutoSubdomain + s.config.Domain.AutoSSL = req.Domain.AutoSSL + if req.Domain.SubdomainStyle != "" { + s.config.Domain.SubdomainStyle = req.Domain.SubdomainStyle + } + } + c.JSON(http.StatusOK, gin.H{ - "message": "Settings update requires agent restart", + "message": "Settings updated", + "settings": gin.H{ + "domain": gin.H{ + "default_domain": s.config.Domain.DefaultDomain, + "auto_subdomain": s.config.Domain.AutoSubdomain, + "auto_ssl": s.config.Domain.AutoSSL, + "subdomain_style": s.config.Domain.SubdomainStyle, + }, + }, + }) +} + +func (s *Server) generateSubdomain(c *gin.Context) { + gen := subdomain.NewGenerator(s.config.Domain.SubdomainStyle) + + subdomainName := gen.Generate() + fullDomain := "" + if s.config.Domain.DefaultDomain != "" { + fullDomain = gen.GenerateForDomain(s.config.Domain.DefaultDomain) + } + + c.JSON(http.StatusOK, gin.H{ + "subdomain": subdomainName, + "full_domain": fullDomain, + "default_domain": s.config.Domain.DefaultDomain, + "auto_ssl": s.config.Domain.AutoSSL, }) } @@ -586,11 +679,11 @@ func (s *Server) listTemplates(c *gin.Context) { var metadata TemplateMetadata metadataContent, err := os.ReadFile(metadataPath) if err == nil { - yaml.Unmarshal(metadataContent, &metadata) + _ = yaml.Unmarshal(metadataContent, &metadata) } if metadata.Name == "" { - metadata.Name = strings.Title(strings.ReplaceAll(templateID, "-", " ")) + metadata.Name = toTitleCase(strings.ReplaceAll(templateID, "-", " ")) } if metadata.Icon == "" { metadata.Icon = "pi pi-box" @@ -615,7 +708,7 @@ func (s *Server) listTemplates(c *gin.Context) { } func (s *Server) listCertificates(c *gin.Context) { - certificates, err := s.certsDiscovery.FindCertificates() + certificates, err := s.proxyOrchestrator.ListCertificates() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), @@ -628,6 +721,136 @@ func (s *Server) listCertificates(c *gin.Context) { }) } +func (s *Server) requestCertificate(c *gin.Context) { + var req struct { + Domain string `json:"domain" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + result, err := s.proxyOrchestrator.RequestCertificate(req.Domain) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Certificate requested", + "result": result, + }) +} + +func (s *Server) renewCertificates(c *gin.Context) { + result, err := s.proxyOrchestrator.RenewCertificates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Renewal completed", + "result": result, + }) +} + +func (s *Server) deleteCertificate(c *gin.Context) { + domain := c.Param("domain") + + if err := s.proxyOrchestrator.SSLManager().DeleteCertificate(domain); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Certificate deleted", + "domain": domain, + }) +} + +func (s *Server) getProxyStatus(c *gin.Context) { + name := c.Param("name") + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Deployment not found", + }) + return + } + + status := s.proxyOrchestrator.GetDeploymentProxyStatus(deployment) + + c.JSON(http.StatusOK, gin.H{ + "status": status, + }) +} + +func (s *Server) setupProxy(c *gin.Context) { + name := c.Param("name") + + deployment, err := s.manager.GetDeployment(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Deployment not found", + }) + return + } + + result, err := s.proxyOrchestrator.SetupDeployment(deployment) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Proxy setup completed", + "result": result, + }) +} + +func (s *Server) teardownProxy(c *gin.Context) { + name := c.Param("name") + + if err := s.proxyOrchestrator.TeardownDeployment(name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Proxy removed", + "name": name, + }) +} + +func (s *Server) listVirtualHosts(c *gin.Context) { + vhosts, err := s.proxyOrchestrator.ListVirtualHosts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "virtual_hosts": vhosts, + }) +} + func (s *Server) getSystemStats(c *gin.Context) { stats, err := s.manager.GetStats() if err != nil { @@ -881,6 +1104,53 @@ func (s *Server) pruneVolumes(c *gin.Context) { }) } +func (s *Server) listPorts(c *gin.Context) { + ports, err := s.networksManager.ListPorts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ports": ports, + }) +} + +func (s *Server) killProcess(c *gin.Context) { + pidStr := c.Param("pid") + pid, err := strconv.Atoi(pidStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid PID", + }) + return + } + + if err := s.networksManager.KillProcess(pid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Process killed", + "pid": pid, + }) +} + +func toTitleCase(s string) string { + words := strings.Fields(s) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, " ") +} + func corsMiddleware(allowedOrigins []string) gin.HandlerFunc { return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index fc237ac..54d7d08 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -168,8 +168,31 @@ func (d *Discovery) UpdateComposeFile(name string, content string) error { backup := composePath + ".bak." + time.Now().Format("20060102150405") if data, err := os.ReadFile(composePath); err == nil { - os.WriteFile(backup, data, 0644) + _ = os.WriteFile(backup, data, 0644) } return os.WriteFile(composePath, []byte(content), 0644) } + +func (d *Discovery) SaveMetadata(name string, metadata *models.ServiceMetadata) error { + dirPath := filepath.Join(d.basePath, name) + metadataPath := filepath.Join(dirPath, "service.yml") + + data, err := yaml.Marshal(metadata) + if err != nil { + return err + } + + return os.WriteFile(metadataPath, data, 0644) +} + +func (d *Discovery) DeleteMetadata(name string) error { + dirPath := filepath.Join(d.basePath, name) + metadataPath := filepath.Join(dirPath, "service.yml") + + if _, err := os.Stat(metadataPath); os.IsNotExist(err) { + return nil + } + + return os.Remove(metadataPath) +} diff --git a/internal/docker/manager.go b/internal/docker/manager.go index ee4eabc..3651f57 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -74,7 +74,7 @@ func (m *Manager) DeleteDeployment(name string) error { return err } - m.executor.Down(deployment.Path) + _, _ = m.executor.Down(deployment.Path) return m.discovery.DeleteDeployment(name) } @@ -141,6 +141,13 @@ func (m *Manager) GetComposeFile(name string) (string, error) { return m.discovery.GetComposeFile(name) } +func (m *Manager) SaveMetadata(name string, metadata *models.ServiceMetadata) error { + m.mu.Lock() + defer m.mu.Unlock() + + return m.discovery.SaveMetadata(name, metadata) +} + type DeploymentStats struct { TotalDeployments int `json:"total_deployments"` Running int `json:"running"` diff --git a/internal/networks/manager.go b/internal/networks/manager.go index b5d6341..ea91a9f 100644 --- a/internal/networks/manager.go +++ b/internal/networks/manager.go @@ -16,14 +16,14 @@ func NewManager() *Manager { } type dockerNetwork struct { - ID string `json:"Id"` - Name string `json:"Name"` - Driver string `json:"Driver"` - Scope string `json:"Scope"` - IPAM dockerIPAM `json:"IPAM"` + ID string `json:"Id"` + Name string `json:"Name"` + Driver string `json:"Driver"` + Scope string `json:"Scope"` + IPAM dockerIPAM `json:"IPAM"` Containers map[string]dockerContainer `json:"Containers"` - Labels map[string]string `json:"Labels"` - Created string `json:"Created"` + Labels map[string]string `json:"Labels"` + Created string `json:"Created"` } type dockerIPAM struct { @@ -88,13 +88,10 @@ func (m *Manager) inspectNetwork(id string) (*models.Network, error) { var containers []models.NetworkContainer for _, c := range dn.Containers { - name := c.Name - if strings.HasPrefix(name, "/") { - name = name[1:] - } + name := strings.TrimPrefix(c.Name, "/") containers = append(containers, models.NetworkContainer{ - Name: name, - IPv4: c.IPv4Address, + Name: name, + IPv4: c.IPv4Address, MacAddress: c.MacAddress, }) } @@ -441,7 +438,7 @@ func parseSize(sizeStr string) int64 { } var size float64 - fmt.Sscanf(strings.TrimSpace(sizeStr), "%f", &size) + _, _ = fmt.Sscanf(strings.TrimSpace(sizeStr), "%f", &size) return int64(size * float64(multiplier)) } @@ -574,3 +571,97 @@ func (m *Manager) PruneVolumes() (int, error) { return count, nil } + +type Port struct { + Port int `json:"port"` + Protocol string `json:"protocol"` + Process string `json:"process"` + PID int `json:"pid"` + Address string `json:"address"` + State string `json:"state"` +} + +func (m *Manager) ListPorts() ([]Port, error) { + cmd := exec.Command("ss", "-tulpn", "state", "listening") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list ports: %w", err) + } + + lines := strings.Split(string(output), "\n") + var ports []Port + + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + protocol := strings.ToUpper(fields[0]) + state := "LISTEN" + localAddr := fields[3] + + addressParts := strings.Split(localAddr, ":") + if len(addressParts) < 2 { + continue + } + + portStr := addressParts[len(addressParts)-1] + port := 0 + _, _ = fmt.Sscanf(portStr, "%d", &port) + if port == 0 { + continue + } + + address := strings.Join(addressParts[:len(addressParts)-1], ":") + if address == "" || address == "*" { + address = "0.0.0.0" + } + + process := "" + pid := 0 + + if len(fields) >= 6 { + processInfo := fields[5] + if strings.Contains(processInfo, "pid=") { + parts := strings.Split(processInfo, ",") + for _, part := range parts { + if strings.HasPrefix(part, "pid=") { + _, _ = fmt.Sscanf(part, "pid=%d", &pid) + } + } + + if pid > 0 { + procCmd := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "comm=") + if procOutput, err := procCmd.Output(); err == nil { + process = strings.TrimSpace(string(procOutput)) + } + } + } + } + + ports = append(ports, Port{ + Port: port, + Protocol: protocol, + Process: process, + PID: pid, + Address: address, + State: state, + }) + } + + return ports, nil +} + +func (m *Manager) KillProcess(pid int) error { + cmd := exec.Command("kill", "-9", fmt.Sprintf("%d", pid)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to kill process: %s", string(output)) + } + return nil +} diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go new file mode 100644 index 0000000..7d5efe8 --- /dev/null +++ b/internal/nginx/manager.go @@ -0,0 +1,322 @@ +package nginx + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "text/template" + + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" +) + +type Manager struct { + config *config.NginxConfig + basePath string + configPath string + mu sync.RWMutex +} + +func NewManager(cfg *config.NginxConfig, deploymentsPath string) *Manager { + configPath := cfg.ConfigPath + if configPath == "" { + configPath = filepath.Join(deploymentsPath, "nginx", "conf.d") + } + + return &Manager{ + config: cfg, + basePath: deploymentsPath, + configPath: configPath, + } +} + +func (m *Manager) ConfigPath() string { + return m.configPath +} + +func (m *Manager) CreateVirtualHost(deployment *models.Deployment) error { + if deployment.Metadata == nil { + return fmt.Errorf("deployment has no metadata") + } + + if !deployment.Metadata.Networking.Expose { + return nil + } + + if deployment.Metadata.Networking.Domain == "" { + return fmt.Errorf("domain is required for exposed deployments") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if err := os.MkdirAll(m.configPath, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + configContent, err := m.generateConfig(deployment) + if err != nil { + return fmt.Errorf("failed to generate nginx config: %w", err) + } + + configFile := filepath.Join(m.configPath, deployment.Name+".conf") + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + return fmt.Errorf("failed to write nginx config: %w", err) + } + + return nil +} + +func (m *Manager) DeleteVirtualHost(deploymentName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + configFile := filepath.Join(m.configPath, deploymentName+".conf") + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return nil + } + + return os.Remove(configFile) +} + +func (m *Manager) GetVirtualHost(deploymentName string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + configFile := filepath.Join(m.configPath, deploymentName+".conf") + data, err := os.ReadFile(configFile) + if err != nil { + return "", err + } + + return string(data), nil +} + +func (m *Manager) UpdateVirtualHost(deployment *models.Deployment) error { + return m.CreateVirtualHost(deployment) +} + +func (m *Manager) VirtualHostExists(deploymentName string) bool { + configFile := filepath.Join(m.configPath, deploymentName+".conf") + _, err := os.Stat(configFile) + return err == nil +} + +func (m *Manager) ListVirtualHosts() ([]VirtualHostInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var hosts []VirtualHostInfo + + entries, err := os.ReadDir(m.configPath) + if err != nil { + if os.IsNotExist(err) { + return hosts, nil + } + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") { + continue + } + + name := strings.TrimSuffix(entry.Name(), ".conf") + info, _ := entry.Info() + + hosts = append(hosts, VirtualHostInfo{ + Name: name, + ConfigFile: filepath.Join(m.configPath, entry.Name()), + ModifiedAt: info.ModTime().Unix(), + }) + } + + return hosts, nil +} + +func (m *Manager) Reload() error { + if m.config.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + reloadCmd := m.config.ReloadCommand + if reloadCmd == "" { + reloadCmd = "nginx -s reload" + } + + cmd := exec.Command("docker", "exec", m.config.ContainerName, "sh", "-c", reloadCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to reload nginx: %s - %w", string(output), err) + } + + return nil +} + +func (m *Manager) TestConfig() error { + if m.config.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + cmd := exec.Command("docker", "exec", m.config.ContainerName, "nginx", "-t") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("nginx config test failed: %s - %w", string(output), err) + } + + return nil +} + +func (m *Manager) generateConfig(deployment *models.Deployment) (string, error) { + net := deployment.Metadata.Networking + ssl := deployment.Metadata.SSL + + data := templateData{ + DeploymentName: deployment.Name, + Domain: net.Domain, + ContainerPort: net.ContainerPort, + Protocol: net.Protocol, + ProxyType: net.ProxyType, + SSLEnabled: ssl.Enabled, + HealthPath: deployment.Metadata.HealthCheck.Path, + } + + if data.ContainerPort == 0 { + data.ContainerPort = 80 + } + if data.Protocol == "" { + data.Protocol = "http" + } + if data.ProxyType == "" { + data.ProxyType = "http" + } + + var tmpl *template.Template + var err error + + if data.SSLEnabled { + tmpl, err = template.New("nginx").Parse(sslTemplate) + } else { + tmpl, err = template.New("nginx").Parse(httpTemplate) + } + + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +type VirtualHostInfo struct { + Name string `json:"name"` + ConfigFile string `json:"config_file"` + ModifiedAt int64 `json:"modified_at"` +} + +type templateData struct { + DeploymentName string + Domain string + ContainerPort int + Protocol string + ProxyType string + SSLEnabled bool + HealthPath string +} + +const httpTemplate = `upstream {{.DeploymentName}}_upstream { + server {{.DeploymentName}}:{{.ContainerPort}}; +} + +server { + listen 80; + server_name {{.Domain}}; + + location / { + proxy_pass {{.Protocol}}://{{.DeploymentName}}_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +{{if .HealthPath}} + location {{.HealthPath}} { + proxy_pass {{.Protocol}}://{{.DeploymentName}}_upstream{{.HealthPath}}; + proxy_set_header Host $host; + } +{{end}} + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } +} +` + +const sslTemplate = `upstream {{.DeploymentName}}_upstream { + server {{.DeploymentName}}:{{.ContainerPort}}; +} + +server { + listen 80; + server_name {{.Domain}}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name {{.Domain}}; + + ssl_certificate /etc/nginx/certs/live/{{.Domain}}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/{{.Domain}}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_stapling on; + ssl_stapling_verify on; + + add_header Strict-Transport-Security "max-age=63072000" always; + + location / { + proxy_pass {{.Protocol}}://{{.DeploymentName}}_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } +{{if .HealthPath}} + location {{.HealthPath}} { + proxy_pass {{.Protocol}}://{{.DeploymentName}}_upstream{{.HealthPath}}; + proxy_set_header Host $host; + } +{{end}} +} +` diff --git a/internal/proxy/orchestrator.go b/internal/proxy/orchestrator.go new file mode 100644 index 0000000..45123ec --- /dev/null +++ b/internal/proxy/orchestrator.go @@ -0,0 +1,212 @@ +package proxy + +import ( + "fmt" + "log" + + "github.com/flatrun/agent/internal/nginx" + "github.com/flatrun/agent/internal/ssl" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" +) + +type Orchestrator struct { + nginx *nginx.Manager + ssl *ssl.Manager +} + +func NewOrchestrator(cfg *config.Config) *Orchestrator { + return &Orchestrator{ + nginx: nginx.NewManager(&cfg.Nginx, cfg.DeploymentsPath), + ssl: ssl.NewManager(&cfg.Certbot, cfg.DeploymentsPath), + } +} + +func (o *Orchestrator) NginxManager() *nginx.Manager { + return o.nginx +} + +func (o *Orchestrator) SSLManager() *ssl.Manager { + return o.ssl +} + +func (o *Orchestrator) SetupDeployment(deployment *models.Deployment) (*SetupResult, error) { + result := &SetupResult{ + DeploymentName: deployment.Name, + } + + if deployment.Metadata == nil || !deployment.Metadata.Networking.Expose { + result.Skipped = true + result.Message = "deployment not configured for exposure" + return result, nil + } + + domain := deployment.Metadata.Networking.Domain + if domain == "" { + return nil, fmt.Errorf("domain is required for exposed deployments") + } + + if err := o.ssl.ValidateDomain(domain); err != nil { + return nil, fmt.Errorf("invalid domain: %w", err) + } + + result.Domain = domain + + if err := o.nginx.CreateVirtualHost(deployment); err != nil { + return nil, fmt.Errorf("failed to create virtual host: %w", err) + } + result.VirtualHostCreated = true + + if err := o.nginx.TestConfig(); err != nil { + _ = o.nginx.DeleteVirtualHost(deployment.Name) + return nil, fmt.Errorf("nginx config validation failed: %w", err) + } + + if err := o.nginx.Reload(); err != nil { + log.Printf("warning: failed to reload nginx: %v", err) + } else { + result.NginxReloaded = true + } + + if deployment.Metadata.SSL.Enabled && deployment.Metadata.SSL.AutoCert { + if !o.ssl.CertificateExists(domain) { + certResult, err := o.ssl.RequestCertificate(domain) + if err != nil { + log.Printf("warning: failed to request certificate for %s: %v", domain, err) + result.SSLError = err.Error() + } else { + result.CertificateRequested = true + result.SSLMessage = certResult.Message + } + } else { + result.CertificateExists = true + } + + if result.CertificateRequested || result.CertificateExists { + deployment.Metadata.SSL.Enabled = true + if err := o.nginx.UpdateVirtualHost(deployment); err != nil { + log.Printf("warning: failed to update virtual host with SSL: %v", err) + } + if err := o.nginx.Reload(); err != nil { + log.Printf("warning: failed to reload nginx after SSL: %v", err) + } + } + } + + result.Success = true + return result, nil +} + +func (o *Orchestrator) TeardownDeployment(deploymentName string) error { + if err := o.nginx.DeleteVirtualHost(deploymentName); err != nil { + return fmt.Errorf("failed to delete virtual host: %w", err) + } + + if err := o.nginx.Reload(); err != nil { + log.Printf("warning: failed to reload nginx after teardown: %v", err) + } + + return nil +} + +func (o *Orchestrator) UpdateDeployment(deployment *models.Deployment) (*SetupResult, error) { + if deployment.Metadata == nil || !deployment.Metadata.Networking.Expose { + if o.nginx.VirtualHostExists(deployment.Name) { + if err := o.TeardownDeployment(deployment.Name); err != nil { + return nil, err + } + } + return &SetupResult{ + DeploymentName: deployment.Name, + Skipped: true, + Message: "deployment no longer exposed", + }, nil + } + + return o.SetupDeployment(deployment) +} + +func (o *Orchestrator) RequestCertificate(domain string) (*ssl.CertificateResult, error) { + if err := o.ssl.ValidateDomain(domain); err != nil { + return nil, err + } + + return o.ssl.RequestCertificate(domain) +} + +func (o *Orchestrator) RenewCertificates() (*ssl.RenewalResult, error) { + result, err := o.ssl.RenewCertificates() + if err != nil { + return nil, err + } + + if err := o.nginx.Reload(); err != nil { + log.Printf("warning: failed to reload nginx after renewal: %v", err) + } + + return result, nil +} + +func (o *Orchestrator) GetDeploymentProxyStatus(deployment *models.Deployment) *ProxyStatus { + status := &ProxyStatus{ + DeploymentName: deployment.Name, + } + + if deployment.Metadata == nil { + return status + } + + status.Exposed = deployment.Metadata.Networking.Expose + status.Domain = deployment.Metadata.Networking.Domain + status.VirtualHostExists = o.nginx.VirtualHostExists(deployment.Name) + + if deployment.Metadata.SSL.Enabled && status.Domain != "" { + status.SSLEnabled = true + status.CertificateExists = o.ssl.CertificateExists(status.Domain) + + if status.CertificateExists { + cert, err := o.ssl.GetCertificate(status.Domain) + if err == nil { + status.Certificate = cert + } + } + } + + return status +} + +func (o *Orchestrator) ListVirtualHosts() ([]nginx.VirtualHostInfo, error) { + return o.nginx.ListVirtualHosts() +} + +func (o *Orchestrator) ListCertificates() ([]models.Certificate, error) { + return o.ssl.ListCertificates() +} + +func (o *Orchestrator) GetExpiringCertificates(days int) ([]models.Certificate, error) { + return o.ssl.GetExpiringCertificates(days) +} + +type SetupResult struct { + DeploymentName string `json:"deployment_name"` + Domain string `json:"domain,omitempty"` + Success bool `json:"success"` + Skipped bool `json:"skipped"` + Message string `json:"message,omitempty"` + VirtualHostCreated bool `json:"virtual_host_created"` + NginxReloaded bool `json:"nginx_reloaded"` + CertificateRequested bool `json:"certificate_requested"` + CertificateExists bool `json:"certificate_exists"` + SSLMessage string `json:"ssl_message,omitempty"` + SSLError string `json:"ssl_error,omitempty"` +} + +type ProxyStatus struct { + DeploymentName string `json:"deployment_name"` + Exposed bool `json:"exposed"` + Domain string `json:"domain,omitempty"` + VirtualHostExists bool `json:"virtual_host_exists"` + SSLEnabled bool `json:"ssl_enabled"` + CertificateExists bool `json:"certificate_exists"` + Certificate *models.Certificate `json:"certificate,omitempty"` +} diff --git a/internal/ssl/manager.go b/internal/ssl/manager.go new file mode 100644 index 0000000..df1aa5a --- /dev/null +++ b/internal/ssl/manager.go @@ -0,0 +1,280 @@ +package ssl + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" +) + +type Manager struct { + config *config.CertbotConfig + certsPath string + webRoot string + mu sync.RWMutex +} + +func NewManager(cfg *config.CertbotConfig, deploymentsPath string) *Manager { + return &Manager{ + config: cfg, + certsPath: filepath.Join(deploymentsPath, "nginx", "certs", "live"), + webRoot: filepath.Join(deploymentsPath, "nginx", "certbot"), + } +} + +func (m *Manager) CertsPath() string { + return m.certsPath +} + +func (m *Manager) RequestCertificate(domain string) (*CertificateResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config.ContainerName == "" { + return nil, fmt.Errorf("certbot container name not configured") + } + + if m.config.Email == "" { + return nil, fmt.Errorf("certbot email not configured") + } + + if err := os.MkdirAll(m.webRoot, 0755); err != nil { + return nil, fmt.Errorf("failed to create webroot: %w", err) + } + + args := []string{ + "exec", m.config.ContainerName, + "certbot", "certonly", + "--webroot", + "--webroot-path=/var/www/certbot", + "--email", m.config.Email, + "--agree-tos", + "--no-eff-email", + "-d", domain, + } + + if m.config.Staging { + args = append(args, "--staging") + } + + cmd := exec.Command("docker", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("certbot failed: %s - %w", string(output), err) + } + + return &CertificateResult{ + Domain: domain, + Success: true, + Message: string(output), + }, nil +} + +func (m *Manager) RenewCertificates() (*RenewalResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config.ContainerName == "" { + return nil, fmt.Errorf("certbot container name not configured") + } + + cmd := exec.Command("docker", "exec", m.config.ContainerName, "certbot", "renew") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("renewal failed: %s - %w", string(output), err) + } + + return &RenewalResult{ + Success: true, + Message: string(output), + }, nil +} + +func (m *Manager) RevokeCertificate(domain string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config.ContainerName == "" { + return fmt.Errorf("certbot container name not configured") + } + + certPath := fmt.Sprintf("/etc/letsencrypt/live/%s/cert.pem", domain) + + cmd := exec.Command("docker", "exec", m.config.ContainerName, + "certbot", "revoke", "--cert-path", certPath, "--non-interactive") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("revocation failed: %s - %w", string(output), err) + } + + return nil +} + +func (m *Manager) DeleteCertificate(domain string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.config.ContainerName == "" { + return fmt.Errorf("certbot container name not configured") + } + + cmd := exec.Command("docker", "exec", m.config.ContainerName, + "certbot", "delete", "--cert-name", domain, "--non-interactive") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("deletion failed: %s - %w", string(output), err) + } + + return nil +} + +func (m *Manager) GetCertificate(domain string) (*models.Certificate, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + certPath := filepath.Join(m.certsPath, domain, "cert.pem") + return m.parseCertificate(certPath, domain) +} + +func (m *Manager) ListCertificates() ([]models.Certificate, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var certificates []models.Certificate + + if _, err := os.Stat(m.certsPath); os.IsNotExist(err) { + return certificates, nil + } + + entries, err := os.ReadDir(m.certsPath) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + certPath := filepath.Join(m.certsPath, entry.Name(), "cert.pem") + cert, err := m.parseCertificate(certPath, entry.Name()) + if err != nil { + continue + } + + certificates = append(certificates, *cert) + } + + return certificates, nil +} + +func (m *Manager) CertificateExists(domain string) bool { + certPath := filepath.Join(m.certsPath, domain, "cert.pem") + _, err := os.Stat(certPath) + return err == nil +} + +func (m *Manager) GetExpiringCertificates(daysThreshold int) ([]models.Certificate, error) { + certs, err := m.ListCertificates() + if err != nil { + return nil, err + } + + var expiring []models.Certificate + for _, cert := range certs { + if cert.DaysLeft <= daysThreshold { + expiring = append(expiring, cert) + } + } + + return expiring, nil +} + +func (m *Manager) parseCertificate(certPath, domain string) (*models.Certificate, error) { + data, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + + now := time.Now() + daysLeft := int(cert.NotAfter.Sub(now).Hours() / 24) + + status := "valid" + if now.After(cert.NotAfter) { + status = "expired" + } else if daysLeft <= 30 { + status = "expiring" + } + + issuer := cert.Issuer.CommonName + if issuer == "" && len(cert.Issuer.Organization) > 0 { + issuer = cert.Issuer.Organization[0] + } + + return &models.Certificate{ + Domain: domain, + Issuer: issuer, + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + DaysLeft: daysLeft, + Status: status, + Path: certPath, + AutoRenew: true, + }, nil +} + +func (m *Manager) SetupAutoCertificate(domain string) error { + if m.CertificateExists(domain) { + return nil + } + + _, err := m.RequestCertificate(domain) + return err +} + +func (m *Manager) ValidateDomain(domain string) error { + if domain == "" { + return fmt.Errorf("domain cannot be empty") + } + + if strings.Contains(domain, " ") { + return fmt.Errorf("domain cannot contain spaces") + } + + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid domain format") + } + + return nil +} + +type CertificateResult struct { + Domain string `json:"domain"` + Success bool `json:"success"` + Message string `json:"message"` +} + +type RenewalResult struct { + Success bool `json:"success"` + Message string `json:"message"` + RenewedDomains []string `json:"renewed_domains,omitempty"` +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index f43b504..fff6fd1 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -50,8 +50,8 @@ func (w *Watcher) Start() { func (w *Watcher) handleEvent(event fsnotify.Event) { if filepath.Base(event.Name) == "docker-compose.yml" || - filepath.Base(event.Name) == "docker-compose.yaml" || - filepath.Base(event.Name) == "service.yml" { + filepath.Base(event.Name) == "docker-compose.yaml" || + filepath.Base(event.Name) == "service.yml" { switch event.Op { case fsnotify.Create: diff --git a/pkg/config/config.go b/pkg/config/config.go index 2b05503..6ec3ba5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,12 +12,20 @@ type Config struct { DockerSocket string `yaml:"docker_socket"` API APIConfig `yaml:"api"` Auth AuthConfig `yaml:"auth"` + Domain DomainConfig `yaml:"domain"` Nginx NginxConfig `yaml:"nginx"` Certbot CertbotConfig `yaml:"certbot"` Logging LoggingConfig `yaml:"logging"` Health HealthConfig `yaml:"health"` } +type DomainConfig struct { + DefaultDomain string `yaml:"default_domain"` + AutoSubdomain bool `yaml:"auto_subdomain"` + AutoSSL bool `yaml:"auto_ssl"` + SubdomainStyle string `yaml:"subdomain_style"` +} + type APIConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` @@ -26,9 +34,9 @@ type APIConfig struct { } type AuthConfig struct { - Enabled bool `yaml:"enabled"` - APIKeys []string `yaml:"api_keys"` - JWTSecret string `yaml:"jwt_secret"` + Enabled bool `yaml:"enabled"` + APIKeys []string `yaml:"api_keys"` + JWTSecret string `yaml:"jwt_secret"` } type NginxConfig struct { @@ -96,4 +104,7 @@ func setDefaults(cfg *Config) { if cfg.Auth.JWTSecret == "" { cfg.Auth.JWTSecret = "default-secret-change-me" } + if cfg.Domain.SubdomainStyle == "" { + cfg.Domain.SubdomainStyle = "words" + } } diff --git a/pkg/models/certificate.go b/pkg/models/certificate.go index b75b287..7598890 100644 --- a/pkg/models/certificate.go +++ b/pkg/models/certificate.go @@ -3,11 +3,13 @@ package models import "time" type Certificate struct { - Domain string `json:"domain"` - Issuer string `json:"issuer"` - NotBefore time.Time `json:"not_before"` - NotAfter time.Time `json:"not_after"` - DaysLeft int `json:"days_left"` - Status string `json:"status"` - Path string `json:"path"` + Domain string `json:"domain"` + Issuer string `json:"issuer"` + NotBefore time.Time `json:"not_before"` + NotAfter time.Time `json:"not_after"` + DaysLeft int `json:"days_left"` + Status string `json:"status"` + Path string `json:"path"` + AutoRenew bool `json:"auto_renew"` + DeploymentID string `json:"deployment_id,omitempty"` } diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index 27e2197..2d2bbb3 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -3,12 +3,12 @@ package models import "time" type Deployment struct { - Name string `json:"name"` - Path string `json:"path"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Services []Service `json:"services,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Services []Service `json:"services,omitempty"` Metadata *ServiceMetadata `json:"metadata,omitempty"` } @@ -24,10 +24,10 @@ type Service struct { } type ServiceMetadata struct { - Name string `yaml:"name" json:"name"` - Type string `yaml:"type" json:"type"` - Networking NetworkingConfig `yaml:"networking" json:"networking"` - SSL SSLConfig `yaml:"ssl" json:"ssl"` + Name string `yaml:"name" json:"name"` + Type string `yaml:"type" json:"type"` + Networking NetworkingConfig `yaml:"networking" json:"networking"` + SSL SSLConfig `yaml:"ssl" json:"ssl"` HealthCheck HealthCheckConfig `yaml:"healthcheck" json:"healthcheck"` } diff --git a/pkg/models/network.go b/pkg/models/network.go index 91c7fea..cf82291 100644 --- a/pkg/models/network.go +++ b/pkg/models/network.go @@ -1,15 +1,15 @@ package models type Network struct { - ID string `json:"id"` - Name string `json:"name"` - Driver string `json:"driver"` - Scope string `json:"scope"` - Subnet string `json:"subnet"` - Gateway string `json:"gateway"` - Containers []NetworkContainer `json:"containers"` - Labels map[string]string `json:"labels"` - Created string `json:"created"` + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` + Subnet string `json:"subnet"` + Gateway string `json:"gateway"` + Containers []NetworkContainer `json:"containers"` + Labels map[string]string `json:"labels"` + Created string `json:"created"` } type NetworkContainer struct { diff --git a/pkg/plugins/registry.go b/pkg/plugins/registry.go index a4146cf..b1cb6d8 100644 --- a/pkg/plugins/registry.go +++ b/pkg/plugins/registry.go @@ -97,11 +97,11 @@ func (r *Registry) LoadFromDisk() error { } plugin := &ExternalPlugin{ - info: *info, + info: *info, basePath: filepath.Join(r.pluginsDir, entry.Name()), } - r.Register(plugin) + _ = r.Register(plugin) } return nil diff --git a/pkg/plugins/types.go b/pkg/plugins/types.go index 3526438..3bb7d77 100644 --- a/pkg/plugins/types.go +++ b/pkg/plugins/types.go @@ -22,22 +22,22 @@ const ( ) type PluginInfo struct { - Name string `json:"name" yaml:"name"` - Version string `json:"version" yaml:"version"` - DisplayName string `json:"display_name" yaml:"display_name"` - Description string `json:"description" yaml:"description"` - Author string `json:"author" yaml:"author"` - Type PluginType `json:"type" yaml:"type"` - Category string `json:"category" yaml:"category"` - Enabled bool `json:"enabled" yaml:"enabled"` - Capabilities []string `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` - Widget *WidgetConfig `json:"widget,omitempty" yaml:"widget,omitempty"` - ConfigSchema map[string]interface{} `json:"config_schema,omitempty" yaml:"config_schema,omitempty"` - Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"` - Resources *ResourceRequirements `json:"resources,omitempty" yaml:"resources,omitempty"` - DashboardExtensions []DashboardExtension `json:"dashboard_extensions,omitempty" yaml:"dashboard_extensions,omitempty"` - APIEndpoints []APIEndpoint `json:"api,omitempty" yaml:"api,omitempty"` - Hooks map[string]string `json:"hooks,omitempty" yaml:"hooks,omitempty"` + Name string `json:"name" yaml:"name"` + Version string `json:"version" yaml:"version"` + DisplayName string `json:"display_name" yaml:"display_name"` + Description string `json:"description" yaml:"description"` + Author string `json:"author" yaml:"author"` + Type PluginType `json:"type" yaml:"type"` + Category string `json:"category" yaml:"category"` + Enabled bool `json:"enabled" yaml:"enabled"` + Capabilities []string `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + Widget *WidgetConfig `json:"widget,omitempty" yaml:"widget,omitempty"` + ConfigSchema map[string]interface{} `json:"config_schema,omitempty" yaml:"config_schema,omitempty"` + Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"` + Resources *ResourceRequirements `json:"resources,omitempty" yaml:"resources,omitempty"` + DashboardExtensions []DashboardExtension `json:"dashboard_extensions,omitempty" yaml:"dashboard_extensions,omitempty"` + APIEndpoints []APIEndpoint `json:"api,omitempty" yaml:"api,omitempty"` + Hooks map[string]string `json:"hooks,omitempty" yaml:"hooks,omitempty"` } type DashboardExtension struct { @@ -52,11 +52,11 @@ type APIEndpoint struct { } type WidgetConfig struct { - Enabled bool `json:"enabled" yaml:"enabled"` - Position string `json:"position" yaml:"position"` - Size string `json:"size" yaml:"size"` - RefreshInterval int `json:"refresh_interval" yaml:"refresh_interval"` - Actions []WidgetAction `json:"actions,omitempty" yaml:"actions,omitempty"` + Enabled bool `json:"enabled" yaml:"enabled"` + Position string `json:"position" yaml:"position"` + Size string `json:"size" yaml:"size"` + RefreshInterval int `json:"refresh_interval" yaml:"refresh_interval"` + Actions []WidgetAction `json:"actions,omitempty" yaml:"actions,omitempty"` } type WidgetAction struct { @@ -92,10 +92,10 @@ type DeploymentPlugin interface { } type DeploymentResult struct { - Name string `json:"name"` - Path string `json:"path"` - Containers []string `json:"containers"` - URLs []string `json:"urls"` + Name string `json:"name"` + Path string `json:"path"` + Containers []string `json:"containers"` + URLs []string `json:"urls"` Credentials map[string]string `json:"credentials,omitempty"` } diff --git a/pkg/subdomain/generator.go b/pkg/subdomain/generator.go new file mode 100644 index 0000000..cbea143 --- /dev/null +++ b/pkg/subdomain/generator.go @@ -0,0 +1,107 @@ +package subdomain + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" +) + +var adjectives = []string{ + "swift", "bright", "calm", "bold", "cool", "eager", "fair", "glad", + "happy", "keen", "lively", "merry", "neat", "proud", "quick", "rare", + "sharp", "sunny", "warm", "wise", "agile", "brave", "clever", "daring", + "fleet", "golden", "humble", "jolly", "kind", "lucky", "noble", "polite", + "quiet", "royal", "steady", "tender", "upbeat", "vivid", "witty", "zesty", + "azure", "coral", "crimson", "dusty", "emerald", "frosty", "gentle", "hazy", +} + +var nouns = []string{ + "river", "cloud", "leaf", "stone", "wind", "flame", "wave", "star", + "moon", "sun", "tree", "bird", "lake", "peak", "vale", "grove", + "brook", "meadow", "shore", "ridge", "frost", "dawn", "dusk", "mist", + "rain", "snow", "spring", "forest", "ocean", "island", "canyon", "delta", + "harbor", "lagoon", "marsh", "oasis", "plain", "prairie", "reef", "summit", + "tundra", "valley", "willow", "aurora", "breeze", "crystal", "ember", "glacier", +} + +type Generator struct { + style string +} + +func NewGenerator(style string) *Generator { + if style == "" { + style = "words" + } + return &Generator{style: style} +} + +func (g *Generator) Generate() string { + switch g.style { + case "words": + return g.generateWords() + case "hex": + return g.generateHex() + case "short": + return g.generateShort() + default: + return g.generateWords() + } +} + +func (g *Generator) generateWords() string { + adj := randomChoice(adjectives) + noun := randomChoice(nouns) + num := randomNumber(100, 999) + return fmt.Sprintf("%s-%s-%d", adj, noun, num) +} + +func (g *Generator) generateHex() string { + bytes := make([]byte, 4) + _, _ = rand.Read(bytes) + return fmt.Sprintf("%x", bytes) +} + +func (g *Generator) generateShort() string { + adj := randomChoice(adjectives) + noun := randomChoice(nouns) + return fmt.Sprintf("%s-%s", adj[:3], noun[:3]) +} + +func (g *Generator) GenerateForDomain(baseDomain string) string { + subdomain := g.Generate() + if baseDomain == "" { + return subdomain + } + return fmt.Sprintf("%s.%s", subdomain, baseDomain) +} + +func randomChoice(list []string) string { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(list)))) + return list[n.Int64()] +} + +func randomNumber(min, max int) int { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) + return int(n.Int64()) + min +} + +func IsValidSubdomain(subdomain string) bool { + if len(subdomain) == 0 || len(subdomain) > 63 { + return false + } + + subdomain = strings.ToLower(subdomain) + + for _, c := range subdomain { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { + return false + } + } + + if subdomain[0] == '-' || subdomain[len(subdomain)-1] == '-' { + return false + } + + return true +}