Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions cmd/magebox/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,14 @@ func runCheck(cmd *cobra.Command, args []string) error {
message: "Running",
})
} else {
nginxStartHint := "magebox global start"
if runtime.GOOS == "darwin" {
nginxStartHint = "magebox global start (or: nginx)"
}
results = append(results, checkResult{
name: "Nginx",
status: "warning",
message: "Not running - run 'brew services start nginx'",
message: "Not running - run '" + nginxStartHint + "'",
})
}
printCheckResult(results[len(results)-1])
Expand All @@ -209,20 +213,40 @@ func runCheck(cmd *cobra.Command, args []string) error {
}
printCheckResult(results[len(results)-1])

// Check vhost exists (check for upstream file which is unique per project)
// Check domain vhost exists (upstream alone is not enough for HTTPS / SNI)
if cfg != nil {
upstreamPath := filepath.Join(p.MageBoxDir(), "nginx", "vhosts", cfg.Name+"-upstream.conf")
if _, err := os.Stat(upstreamPath); err == nil {
vhostsDir := filepath.Join(p.MageBoxDir(), "nginx", "vhosts")
hasDomainVhost := false
if entries, err := os.ReadDir(vhostsDir); err == nil {
prefix := cfg.Name + "-"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".conf") {
continue
}
if strings.HasSuffix(name, "-upstream.conf") || strings.HasPrefix(name, "000-magebox-default-ssl") {
continue
}
if strings.HasPrefix(name, prefix) {
hasDomainVhost = true
break
}
}
}
if hasDomainVhost {
results = append(results, checkResult{
name: "Project Vhost",
status: "ok",
message: filepath.Join(p.MageBoxDir(), "nginx", "vhosts", cfg.Name+"-*.conf"),
message: filepath.Join(vhostsDir, cfg.Name+"-<domain>.conf"),
})
} else {
results = append(results, checkResult{
name: "Project Vhost",
status: "warning",
message: "Not found - run 'magebox start'",
message: "No HTTPS vhost — run 'magebox start' (wrong certificate in browser until then)",
})
}
printCheckResult(results[len(results)-1])
Expand Down
10 changes: 10 additions & 0 deletions internal/nginx/templates/default-ssl.conf.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# MageBox default SSL server — unknown hostnames must not receive another project's certificate.
# Regenerated by MageBox — do not edit manually.
server {
listen {{.HTTPSPort}} ssl default_server;
{{- if .EnableIPv6}}
listen [::]:{{.HTTPSPort}} ssl default_server;
{{- end}}
server_name _;
ssl_reject_handshake on;
}
112 changes: 96 additions & 16 deletions internal/nginx/vhost.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ var proxyTemplateEmbed string
//go:embed templates/upstream.conf.tmpl
var upstreamTemplateEmbed string

//go:embed templates/default-ssl.conf.tmpl
var defaultSSLTemplateEmbed string

const defaultSSLVhostFile = "000-magebox-default-ssl.conf"

func init() {
// Register embedded templates as fallbacks
lib.RegisterFallbackTemplate(lib.TemplateNginx, "vhost.conf.tmpl", vhostTemplateEmbed)
lib.RegisterFallbackTemplate(lib.TemplateNginx, "vhost-laravel.conf.tmpl", vhostLaravelTemplateEmbed)
lib.RegisterFallbackTemplate(lib.TemplateNginx, "proxy.conf.tmpl", proxyTemplateEmbed)
lib.RegisterFallbackTemplate(lib.TemplateNginx, "upstream.conf.tmpl", upstreamTemplateEmbed)
lib.RegisterFallbackTemplate(lib.TemplateNginx, "default-ssl.conf.tmpl", defaultSSLTemplateEmbed)
}

// Template variables available in vhost.conf.tmpl:
Expand Down Expand Up @@ -123,23 +129,27 @@ func (g *VhostGenerator) Generate(cfg *config.Config, projectPath string) error
return fmt.Errorf("failed to create nginx logs directory: %w", err)
}

// Generate upstream config (once per project, not per domain)
upstreamCfg := UpstreamConfig{
ProjectName: cfg.Name,
PHPSocketPath: g.getPHPSocketPath(cfg.Name, cfg.PHP),
}
if err := g.generateUpstream(upstreamCfg); err != nil {
return fmt.Errorf("failed to generate upstream config: %w", err)
}

// Determine ports based on platform
// macOS uses port forwarding (80->8080, 443->8443), Linux uses standard ports
httpPort := 80
httpsPort := 443
enableIPv6 := true
if g.platform.Type == platform.Darwin {
httpPort = 8080
httpsPort = 8443
enableIPv6 = false
}

if err := g.ensureDefaultSSLCatchAll(httpsPort, enableIPv6); err != nil {
return fmt.Errorf("default ssl vhost: %w", err)
}

// Generate upstream config (once per project, not per domain)
upstreamCfg := UpstreamConfig{
ProjectName: cfg.Name,
PHPSocketPath: g.getPHPSocketPath(cfg.Name, cfg.PHP),
}
if err := g.generateUpstream(upstreamCfg); err != nil {
return fmt.Errorf("failed to generate upstream config: %w", err)
}

for _, domain := range cfg.Domains {
Expand Down Expand Up @@ -371,6 +381,38 @@ func (g *VhostGenerator) renderProxyVhost(cfg ProxyConfig) (string, error) {
return buf.String(), nil
}

// ensureDefaultSSLCatchAll installs a default_server block so HTTPS without a matching vhost
// does not present another project's certificate (common after Valet → MageBox migration).
func (g *VhostGenerator) ensureDefaultSSLCatchAll(httpsPort int, enableIPv6 bool) error {
tmplContent, err := lib.GetTemplate(lib.TemplateNginx, "default-ssl.conf.tmpl")
if err != nil {
tmplContent = defaultSSLTemplateEmbed
}

tmpl, err := template.New("default-ssl").Parse(tmplContent)
if err != nil {
return fmt.Errorf("parse default ssl template: %w", err)
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct {
HTTPSPort int
EnableIPv6 bool
}{HTTPSPort: httpsPort, EnableIPv6: enableIPv6}); err != nil {
return fmt.Errorf("render default ssl template: %w", err)
}

vhostFile := filepath.Join(g.vhostsDir, defaultSSLVhostFile)
if existing, err := os.ReadFile(vhostFile); err == nil && bytes.Equal(existing, buf.Bytes()) {
return nil
}

if err := os.WriteFile(vhostFile, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("write default ssl vhost: %w", err)
}
return nil
}

// sanitizeDomain converts a domain to a safe filename
func sanitizeDomain(domain string) string {
return domain // Domains are already safe for filenames
Expand Down Expand Up @@ -429,8 +471,7 @@ func (c *Controller) Test() error {
func (c *Controller) Start() error {
switch c.platform.Type {
case platform.Darwin:
cmd := exec.Command("brew", "services", "start", "nginx")
return cmd.Run()
return c.startDarwin()
case platform.Linux:
cmd := exec.Command("sudo", "systemctl", "start", "nginx")
return cmd.Run()
Expand All @@ -442,8 +483,7 @@ func (c *Controller) Start() error {
func (c *Controller) Stop() error {
switch c.platform.Type {
case platform.Darwin:
cmd := exec.Command("brew", "services", "stop", "nginx")
return cmd.Run()
return c.stopDarwin()
case platform.Linux:
cmd := exec.Command("sudo", "systemctl", "stop", "nginx")
return cmd.Run()
Expand All @@ -455,15 +495,39 @@ func (c *Controller) Stop() error {
func (c *Controller) Restart() error {
switch c.platform.Type {
case platform.Darwin:
cmd := exec.Command("brew", "services", "restart", "nginx")
return cmd.Run()
if err := c.stopDarwin(); err != nil {
return err
}
return c.startDarwin()
case platform.Linux:
cmd := exec.Command("sudo", "systemctl", "restart", "nginx")
return cmd.Run()
}
return fmt.Errorf("unsupported platform")
}

// startDarwin starts Homebrew nginx using the MageBox config (bootstrap uses `nginx` directly).
func (c *Controller) startDarwin() error {
if c.IsRunning() {
return c.Reload()
}
cmd := exec.Command("nginx")
if err := cmd.Run(); err == nil {
return nil
}
return exec.Command("brew", "services", "start", "nginx").Run()
}

// stopDarwin stops the MageBox nginx master (brew services alone does not stop a direct `nginx` process).
func (c *Controller) stopDarwin() error {
stopCmd := exec.Command("nginx", "-s", "stop")
if err := stopCmd.Run(); err == nil {
return nil
}
// Fallback for installs managed only via brew services.
return exec.Command("brew", "services", "stop", "nginx").Run()
}

// IsRunning checks if Nginx is running
func (c *Controller) IsRunning() bool {
cmd := exec.Command("pgrep", "nginx")
Expand All @@ -490,6 +554,22 @@ func (c *Controller) SetupNginxConfig() error {
fmt.Printf(" [warn] Could not set server_names_hash_bucket_size: %v\n", err)
}

sslMgr := ssl.NewManager(c.platform)
vhostGen := NewVhostGenerator(c.platform, sslMgr)
if err := os.MkdirAll(vhostGen.vhostsDir, 0755); err != nil {
return fmt.Errorf("failed to create vhosts directory: %w", err)
}

httpsPort := 443
enableIPv6 := true
if c.platform.Type == platform.Darwin {
httpsPort = 8443
enableIPv6 = false
}
if err := vhostGen.ensureDefaultSSLCatchAll(httpsPort, enableIPv6); err != nil {
return err
}

switch c.platform.Type {
case platform.Darwin:
// macOS: add explicit include to nginx.conf
Expand Down
36 changes: 36 additions & 0 deletions internal/nginx/vhost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ func TestVhostGenerator_VhostsDir(t *testing.T) {
}
}

func TestEnsureDefaultSSLCatchAll(t *testing.T) {
g, _ := setupTestGenerator(t)

if err := g.ensureDefaultSSLCatchAll(443, true); err != nil {
t.Fatalf("ensureDefaultSSLCatchAll failed: %v", err)
}

vhostFile := filepath.Join(g.vhostsDir, defaultSSLVhostFile)
content, err := os.ReadFile(vhostFile)
if err != nil {
t.Fatalf("default ssl vhost not written: %v", err)
}

contentStr := string(content)
for _, want := range []string{
"listen 443 ssl default_server",
"listen [::]:443 ssl default_server",
"ssl_reject_handshake on",
"server_name _",
} {
if !strings.Contains(contentStr, want) {
t.Errorf("default ssl vhost should contain %q", want)
}
}

// Idempotent: second call should not error
if err := g.ensureDefaultSSLCatchAll(443, true); err != nil {
t.Fatalf("second ensureDefaultSSLCatchAll failed: %v", err)
}
}

func TestVhostGenerator_Generate(t *testing.T) {
g, tmpDir := setupTestGenerator(t)

Expand All @@ -62,6 +93,11 @@ func TestVhostGenerator_Generate(t *testing.T) {
t.Fatalf("Generate failed: %v", err)
}

// Default SSL catch-all is created on every Generate
if _, err := os.Stat(filepath.Join(g.vhostsDir, defaultSSLVhostFile)); os.IsNotExist(err) {
t.Error("Default SSL vhost should have been created")
}

// Check that vhost file was created
vhostFile := filepath.Join(g.vhostsDir, "mystore-mystore.test.conf")
if _, err := os.Stat(vhostFile); os.IsNotExist(err) {
Expand Down
Loading
Loading