Skip to content

Commit 6166f4e

Browse files
authored
Merge pull request #109 from flatrun/fix/certbot-non-interactive
fix(ssl): Add --non-interactive flag to certbot commands
2 parents 2e1d8fd + 82f1d57 commit 6166f4e

File tree

10 files changed

+336
-24
lines changed

10 files changed

+336
-24
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.55
1+
0.1.56

internal/api/ssl_deletion_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func TestDeleteCertificate_BlockedWhenInUse(t *testing.T) {
5454
}
5555

5656
nginxMgr := nginx.NewManager(&cfg.Nginx, tmpDir, "")
57-
sslMgr := ssl.NewManager(&cfg.Certbot, tmpDir)
57+
sslMgr := ssl.NewManager(&cfg.Certbot, tmpDir, nil)
5858

5959
orchestrator := proxy.NewOrchestratorWithManagers(nginxMgr, sslMgr)
6060

internal/docker/discovery.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type composeFile struct {
2828
type composeService struct {
2929
Image string `yaml:"image"`
3030
Ports []interface{} `yaml:"ports"`
31+
Expose []interface{} `yaml:"expose"`
3132
Networks []string `yaml:"networks"`
3233
Volumes []string `yaml:"volumes"`
3334
}
@@ -196,6 +197,12 @@ func (d *Discovery) parseComposeServices(composePath string) ([]models.Service,
196197
service.Ports = append(service.Ports, portStr)
197198
}
198199
}
200+
for _, p := range svc.Expose {
201+
portStr := d.parsePort(p)
202+
if portStr != "" {
203+
service.Ports = append(service.Ports, portStr)
204+
}
205+
}
199206

200207
services = append(services, service)
201208
}
@@ -538,7 +545,25 @@ func (d *Discovery) UpdateComposeFile(name string, content string) error {
538545
_ = os.WriteFile(backup, data, 0644)
539546
}
540547

541-
return os.WriteFile(composePath, []byte(content), 0644)
548+
if err := os.WriteFile(composePath, []byte(content), 0644); err != nil {
549+
return err
550+
}
551+
552+
metadataPath := filepath.Join(dirPath, "service.yml")
553+
if _, err := os.Stat(metadataPath); err == nil {
554+
if newMeta := d.generateMetadataFromCompose(composePath, name); newMeta != nil {
555+
existing, err := d.loadMetadata(metadataPath)
556+
if err == nil {
557+
existing.Networking.ContainerPort = newMeta.Networking.ContainerPort
558+
if newMeta.Networking.Service != "" {
559+
existing.Networking.Service = newMeta.Networking.Service
560+
}
561+
d.SaveMetadata(name, existing)
562+
}
563+
}
564+
}
565+
566+
return nil
542567
}
543568

544569
func (d *Discovery) SaveMetadata(name string, metadata *models.ServiceMetadata) error {
@@ -602,6 +627,12 @@ func (d *Discovery) generateMetadataFromCompose(composePath, name string) *model
602627
metadata.Networking.ContainerPort = port
603628
}
604629
}
630+
} else if len(svc.Expose) > 0 {
631+
if portStr := d.parsePort(svc.Expose[0]); portStr != "" {
632+
if port := d.extractContainerPort(portStr); port > 0 {
633+
metadata.Networking.ContainerPort = port
634+
}
635+
}
605636
}
606637
}
607638

@@ -618,7 +649,7 @@ func (d *Discovery) pickPrimaryService(services map[string]composeService) (stri
618649
}
619650
}
620651
for name, svc := range services {
621-
if len(svc.Ports) > 0 {
652+
if len(svc.Ports) > 0 || len(svc.Expose) > 0 {
622653
return name, svc
623654
}
624655
}

internal/docker/discovery_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"path/filepath"
77
"sort"
88
"testing"
9+
10+
"github.com/flatrun/agent/pkg/models"
11+
"gopkg.in/yaml.v3"
912
)
1013

1114
func TestExtractBindMountPath(t *testing.T) {
@@ -408,6 +411,138 @@ func TestGenerateMetadataFromCompose_ServiceName(t *testing.T) {
408411
}
409412
}
410413

414+
func TestGenerateMetadataFromCompose_Expose(t *testing.T) {
415+
tests := []struct {
416+
name string
417+
compose string
418+
wantPort int
419+
}{
420+
{
421+
name: "expose sets container port",
422+
compose: `services:
423+
app:
424+
image: myapp:latest
425+
expose:
426+
- "80"
427+
`,
428+
wantPort: 80,
429+
},
430+
{
431+
name: "ports takes precedence over expose",
432+
compose: `services:
433+
app:
434+
image: myapp:latest
435+
ports:
436+
- "8080:3000"
437+
expose:
438+
- "80"
439+
`,
440+
wantPort: 3000,
441+
},
442+
{
443+
name: "expose picks primary service in multi-service",
444+
compose: `services:
445+
app:
446+
image: myapp:latest
447+
expose:
448+
- "8080"
449+
db:
450+
image: postgres:15
451+
`,
452+
wantPort: 8080,
453+
},
454+
}
455+
456+
for _, tt := range tests {
457+
t.Run(tt.name, func(t *testing.T) {
458+
tmpDir, err := os.MkdirTemp("", "expose-test-*")
459+
if err != nil {
460+
t.Fatalf("Failed to create temp dir: %v", err)
461+
}
462+
defer os.RemoveAll(tmpDir)
463+
464+
composePath := filepath.Join(tmpDir, "docker-compose.yml")
465+
if err := os.WriteFile(composePath, []byte(tt.compose), 0644); err != nil {
466+
t.Fatalf("Failed to write compose file: %v", err)
467+
}
468+
469+
d := NewDiscovery(tmpDir)
470+
metadata := d.generateMetadataFromCompose(composePath, "test")
471+
if metadata == nil {
472+
t.Fatal("generateMetadataFromCompose returned nil")
473+
}
474+
475+
if metadata.Networking.ContainerPort != tt.wantPort {
476+
t.Errorf("ContainerPort = %d, want %d", metadata.Networking.ContainerPort, tt.wantPort)
477+
}
478+
})
479+
}
480+
}
481+
482+
func TestUpdateComposeFile_SyncsMetadata(t *testing.T) {
483+
tmpDir, err := os.MkdirTemp("", "sync-test-*")
484+
if err != nil {
485+
t.Fatalf("Failed to create temp dir: %v", err)
486+
}
487+
defer os.RemoveAll(tmpDir)
488+
489+
deployDir := filepath.Join(tmpDir, "myapp")
490+
if err := os.MkdirAll(deployDir, 0755); err != nil {
491+
t.Fatalf("Failed to create deploy dir: %v", err)
492+
}
493+
494+
compose := `services:
495+
app:
496+
image: myapp:latest
497+
expose:
498+
- "3000"
499+
`
500+
composePath := filepath.Join(deployDir, "docker-compose.yml")
501+
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
502+
t.Fatalf("Failed to write compose: %v", err)
503+
}
504+
505+
d := NewDiscovery(tmpDir)
506+
metadata := &models.ServiceMetadata{
507+
Name: "myapp",
508+
Networking: models.NetworkingConfig{
509+
ContainerPort: 3000,
510+
Expose: true,
511+
},
512+
}
513+
if err := d.SaveMetadata("myapp", metadata); err != nil {
514+
t.Fatalf("Failed to save metadata: %v", err)
515+
}
516+
517+
updatedCompose := `services:
518+
app:
519+
image: myapp:latest
520+
expose:
521+
- "8080"
522+
`
523+
if err := d.UpdateComposeFile("myapp", updatedCompose); err != nil {
524+
t.Fatalf("UpdateComposeFile failed: %v", err)
525+
}
526+
527+
metadataPath := filepath.Join(deployDir, "service.yml")
528+
data, err := os.ReadFile(metadataPath)
529+
if err != nil {
530+
t.Fatalf("Failed to read service.yml: %v", err)
531+
}
532+
533+
var updated models.ServiceMetadata
534+
if err := yaml.Unmarshal(data, &updated); err != nil {
535+
t.Fatalf("Failed to parse service.yml: %v", err)
536+
}
537+
538+
if updated.Networking.ContainerPort != 8080 {
539+
t.Errorf("ContainerPort = %d, want 8080", updated.Networking.ContainerPort)
540+
}
541+
if !updated.Networking.Expose {
542+
t.Error("Expose should be preserved as true")
543+
}
544+
}
545+
411546
func TestExtractBindMounts(t *testing.T) {
412547
tests := []struct {
413548
name string

internal/proxy/orchestrator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Orchestrator struct {
1818
func NewOrchestrator(cfg *config.Config) *Orchestrator {
1919
return &Orchestrator{
2020
nginx: nginx.NewManager(&cfg.Nginx, cfg.DeploymentsPath, cfg.Certbot.WebrootPath),
21-
ssl: ssl.NewManager(&cfg.Certbot, cfg.DeploymentsPath),
21+
ssl: ssl.NewManager(&cfg.Certbot, cfg.DeploymentsPath, nil),
2222
}
2323
}
2424

internal/setup/handlers.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package setup
33
import (
44
"net"
55
"net/http"
6+
"net/mail"
67
"time"
78

89
"github.com/flatrun/agent/internal/auth"
@@ -94,9 +95,10 @@ func (h *Handlers) VerifyDNS(c *gin.Context) {
9495

9596
func (h *Handlers) ConfigureSettings(c *gin.Context) {
9697
var req struct {
97-
Domain string `json:"domain"`
98-
AutoSSL *bool `json:"auto_ssl"`
99-
CORSOrigins []string `json:"cors_origins"`
98+
Domain string `json:"domain"`
99+
AutoSSL *bool `json:"auto_ssl"`
100+
CertbotEmail string `json:"certbot_email"`
101+
CORSOrigins []string `json:"cors_origins"`
100102
}
101103

102104
if err := c.ShouldBindJSON(&req); err != nil {
@@ -111,6 +113,13 @@ func (h *Handlers) ConfigureSettings(c *gin.Context) {
111113
if req.AutoSSL != nil {
112114
cfg.Domain.AutoSSL = *req.AutoSSL
113115
}
116+
if req.CertbotEmail != "" {
117+
if _, err := mail.ParseAddress(req.CertbotEmail); err != nil {
118+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email format"})
119+
return
120+
}
121+
cfg.Certbot.Email = req.CertbotEmail
122+
}
114123
if len(req.CORSOrigins) > 0 {
115124
originMap := make(map[string]bool)
116125
for _, e := range cfg.API.AllowedOrigins {
@@ -130,9 +139,10 @@ func (h *Handlers) ConfigureSettings(c *gin.Context) {
130139
}
131140

132141
c.JSON(http.StatusOK, gin.H{
133-
"message": "Settings configured",
134-
"domain": cfg.Domain.DefaultDomain,
135-
"auto_ssl": cfg.Domain.AutoSSL,
142+
"message": "Settings configured",
143+
"domain": cfg.Domain.DefaultDomain,
144+
"auto_ssl": cfg.Domain.AutoSSL,
145+
"certbot_email": cfg.Certbot.Email,
136146
})
137147
}
138148

internal/ssl/manager.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/x509"
55
"encoding/pem"
66
"fmt"
7+
"log"
78
"os"
89
"path/filepath"
910
"strings"
@@ -15,15 +16,26 @@ import (
1516
"github.com/flatrun/agent/pkg/models"
1617
)
1718

19+
type ServiceExecutor interface {
20+
Execute(cfg *config.ServiceExecConfig, args []string) ([]byte, error)
21+
}
22+
23+
type dockerServiceExecutor struct{}
24+
25+
func (e *dockerServiceExecutor) Execute(cfg *config.ServiceExecConfig, args []string) ([]byte, error) {
26+
return docker.ExecuteService(cfg, args)
27+
}
28+
1829
type Manager struct {
1930
config *config.CertbotConfig
2031
certsPath string
2132
webRoot string
2233
containerWebRoot string
34+
executor ServiceExecutor
2335
mu sync.RWMutex
2436
}
2537

26-
func NewManager(cfg *config.CertbotConfig, deploymentsPath string) *Manager {
38+
func NewManager(cfg *config.CertbotConfig, deploymentsPath string, executor ServiceExecutor) *Manager {
2739
certsPath := cfg.CertsPath
2840
if certsPath == "" {
2941
certsPath = filepath.Join(deploymentsPath, "nginx", "certs", "live")
@@ -39,11 +51,16 @@ func NewManager(cfg *config.CertbotConfig, deploymentsPath string) *Manager {
3951
containerWebRoot = "/var/www/certbot"
4052
}
4153

54+
if executor == nil {
55+
executor = &dockerServiceExecutor{}
56+
}
57+
4258
return &Manager{
4359
config: cfg,
4460
certsPath: certsPath,
4561
webRoot: webRoot,
4662
containerWebRoot: containerWebRoot,
63+
executor: executor,
4764
}
4865
}
4966

@@ -81,7 +98,12 @@ func (m *Manager) RequestCertificate(domain string) (*CertificateResult, error)
8198
defer m.mu.Unlock()
8299

83100
if m.config.Email == "" {
84-
return nil, fmt.Errorf("certbot email not configured")
101+
log.Printf("warning: skipping SSL for %s — certbot email not configured (set it in Settings)", domain)
102+
return &CertificateResult{
103+
Domain: domain,
104+
Success: false,
105+
Message: "certbot email not configured — configure it in Settings to enable SSL",
106+
}, nil
85107
}
86108

87109
if err := os.MkdirAll(m.webRoot, 0755); err != nil {
@@ -90,6 +112,7 @@ func (m *Manager) RequestCertificate(domain string) (*CertificateResult, error)
90112

91113
certbotArgs := []string{
92114
"certonly",
115+
"--non-interactive",
93116
"--webroot",
94117
"--webroot-path", m.containerWebRoot,
95118
"--email", m.config.Email,
@@ -135,14 +158,14 @@ func (m *Manager) getServiceExecConfig() *config.ServiceExecConfig {
135158

136159
func (m *Manager) executeCertbot(args []string) ([]byte, error) {
137160
cfg := m.getServiceExecConfig()
138-
return docker.ExecuteService(cfg, args)
161+
return m.executor.Execute(cfg, args)
139162
}
140163

141164
func (m *Manager) RenewCertificates() (*RenewalResult, error) {
142165
m.mu.Lock()
143166
defer m.mu.Unlock()
144167

145-
output, err := m.executeCertbot([]string{"renew"})
168+
output, err := m.executeCertbot([]string{"renew", "--non-interactive"})
146169
if err != nil {
147170
return nil, fmt.Errorf("renewal failed: %s - %w", string(output), err)
148171
}

0 commit comments

Comments
 (0)