Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to MageBox will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.1] - 2026-09-23

### Fixed

- **Passwordless Sudo Broken on Ubuntu 26.04** - Ubuntu 26.04 ships sudo-rs, which refuses to parse wildcards in command arguments. MageBox wrote rules such as `systemctl start php*-fpm`, `cp /tmp/magebox-* /etc/nginx/nginx.conf` and `apt install -y blackfire*`, so `/etc/sudoers.d/magebox` failed to parse entirely and every MageBox operation, including `sudo -s`, printed parse errors and asked for a password. Rules are now generated as complete commands, one line per PHP version and action, and are identical whether they come from the Go installers or the YAML installer definitions. Bootstrap validates the file with `visudo` before installing it and replaces an existing file that the local sudo rejects, so upgrading and re-running `magebox bootstrap` repairs a broken system.
- **PHP 8.1 to 8.4 Could Not Be Installed on Ubuntu 26.04** - Ondrej Sury's PPA publishes nothing for Ubuntu releases it has not caught up with, so on 26.04 apt had no `php8.1` … `php8.4` packages and only Ubuntu's own PHP 8.5 could be installed. Bootstrap now checks which suites the PPA publishes, pins the newest one available, and says so. Packages built for the previous release normally install cleanly; a version that fails is reported and bootstrap continues.

### Changed

- **Ubuntu 26.04 is recognised as a supported release** - It no longer triggers the "not officially tested" warning.
- **Passwordless sudo is limited to service control** - Only starting, stopping, reloading and restarting nginx, PHP-FPM and the Blackfire agent, plus `nginx -t` and `nginx -s reload`, run without a password. Commands that run during bootstrap or an explicit install ask for one, as do Xdebug and Blackfire configuration edits. This also removes the previous `sed -i *` and `ln -s *` rules, which allowed arbitrary arguments and amounted to unrestricted root for the MageBox user.

## [2.1.0] - 2026-09-22

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.0
2.1.1
50 changes: 1 addition & 49 deletions internal/bootstrap/installer/arch.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,55 +233,7 @@ func (a *ArchInstaller) ConfigureNginx() error {

// ConfigureSudoers sets up passwordless sudo for services
func (a *ArchInstaller) ConfigureSudoers() error {
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return fmt.Errorf("could not determine current user")
}

sudoersFile := "/etc/sudoers.d/magebox"
if a.FileExists(sudoersFile) {
return nil // Already configured
}

sudoersContent := fmt.Sprintf(`# MageBox - Allow %[1]s to control nginx and php-fpm without password
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/nginx -s reload
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/nginx -t
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-* /etc/nginx/nginx.conf
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/rm /etc/nginx/*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/ln -s *
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/sed -i *
# Blackfire profiler
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/pacman -S --noconfirm *blackfire*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/pacman -S --noconfirm *tideways*
`, currentUser)

// Write sudoers file
if err := a.WriteFile(sudoersFile, sudoersContent); err != nil {
return fmt.Errorf("failed to write sudoers file: %w", err)
}

// Set correct permissions
if err := a.RunSudo("chmod", "0440", sudoersFile); err != nil {
return fmt.Errorf("failed to set sudoers permissions: %w", err)
}

return nil
return ConfigureSudoersFor(&a.BaseInstaller, ArchSudoersSpec())
}

// ConfigureSELinux is a no-op on Arch (SELinux typically not used)
Expand Down
57 changes: 1 addition & 56 deletions internal/bootstrap/installer/fedora.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,62 +354,7 @@ func (f *FedoraInstaller) ConfigureSELinux() error {

// ConfigureSudoers sets up passwordless sudo for services
func (f *FedoraInstaller) ConfigureSudoers() error {
currentUser := os.Getenv("USER")
if currentUser == "" {
currentUser = os.Getenv("LOGNAME")
}
if currentUser == "" {
return fmt.Errorf("could not determine current user")
}

sudoersFile := "/etc/sudoers.d/magebox"
if f.FileExists(sudoersFile) {
return nil // Already configured
}

sudoersContent := fmt.Sprintf(`# MageBox - Allow %[1]s to control nginx and php-fpm without password
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload
%[1]s ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/nginx -s reload
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/nginx -t
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/nginx
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php*-php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php*-php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php*-php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php*-php-fpm
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-* /etc/nginx/nginx.conf
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/rm /etc/nginx/*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/ln -s *
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/sed -i *
# Allow editing /etc/hosts for DNS entries
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/tee -a /etc/hosts
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/sed -i * /etc/hosts
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-hosts-* /etc/hosts
# Blackfire profiler
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl start blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable blackfire-agent
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/dnf install -y blackfire*
%[1]s ALL=(ALL) NOPASSWD: /usr/bin/dnf install -y tideways*
`, currentUser)

// Write sudoers file
if err := f.WriteFile(sudoersFile, sudoersContent); err != nil {
return fmt.Errorf("failed to write sudoers file: %w", err)
}

// Set correct permissions
if err := f.RunSudo("chmod", "0440", sudoersFile); err != nil {
return fmt.Errorf("failed to set sudoers permissions: %w", err)
}

return nil
return ConfigureSudoersFor(&f.BaseInstaller, FedoraSudoersSpec())
}

// SetupDNS configures DNS resolution for local domains
Expand Down
132 changes: 132 additions & 0 deletions internal/bootstrap/installer/ppa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package installer

import (
"fmt"
"net/http"
"os"
"strings"
"time"
)

// ubuntuCodenames lists Ubuntu releases, newest first. It is used to find the
// newest suite a PPA publishes when it has nothing for the running release.
var ubuntuCodenames = []string{
"resolute", // 26.04 LTS
"questing", // 25.10
"plucky", // 25.04
"oracular", // 24.10
"noble", // 24.04 LTS
"mantic", // 23.10
"lunar", // 23.04
"kinetic", // 22.10
"jammy", // 22.04 LTS
"focal", // 20.04 LTS
}

// ondrejPPADists is where the PHP PPA publishes its suites.
const ondrejPPADists = "https://ppa.launchpadcontent.net/ondrej/php/ubuntu/dists/"

// pickPPASuite returns the suite to configure and whether it is a fallback.
//
// Ondrej's PHP PPA lags new Ubuntu releases by months, and on a release it does
// not cover there is no php8.1 … php8.4 at all. Pinning the newest published
// suite keeps those versions installable; packages are built against an older
// but compatible Ubuntu.
func pickPPASuite(current string, published func(string) bool) (suite string, fallback bool) {
if published(current) {
return current, false
}

start := 0
for i, codename := range ubuntuCodenames {
if codename == current {
start = i + 1
break
}
}

for _, codename := range ubuntuCodenames[start:] {
if codename == current {
continue
}
if published(codename) {
return codename, true
}
}

// Nothing reachable (offline, or the PPA moved): leave the release as is.
return current, false
}

// ppaSuitePublished reports whether the PHP PPA has a Release file for a suite.
func ppaSuitePublished(suite string) bool {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Head(ondrejPPADists + suite + "/Release")
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode == http.StatusOK
}

// currentUbuntuCodename reads the running release's codename, "" if unknown.
func currentUbuntuCodename() string {
content, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
for _, line := range strings.Split(string(content), "\n") {
if value, ok := strings.CutPrefix(line, "VERSION_CODENAME="); ok {
return strings.Trim(strings.TrimSpace(value), `"`)
}
}
return ""
}

// ppaSourceFiles returns the files add-apt-repository may have written for a
// codename, newest apt format first.
func ppaSourceFiles(codename string) []string {
return []string{
fmt.Sprintf("/etc/apt/sources.list.d/ondrej-ubuntu-php-%s.sources", codename),
fmt.Sprintf("/etc/apt/sources.list.d/ondrej-ubuntu-php-%s.list", codename),
}
}

// pinPPASuite rewrites the suite in the PPA source file, leaving the signing
// key and every other line untouched.
func (u *UbuntuInstaller) pinPPASuite(from, to string) error {
for _, file := range ppaSourceFiles(from) {
if !u.FileExists(file) {
continue
}
// deb822 keeps the suite on its own "Suites:" line; the older one-line
// format has it between the URI and the component.
expression := fmt.Sprintf("/^Suites:/s/%s/%s/; /^deb /s/ %s / %s /", from, to, from, to)
if err := u.RunSudo("sed", "-i", "-e", expression, file); err != nil {
return fmt.Errorf("failed to pin the PHP PPA to %s in %s: %w", to, file, err)
}
return nil
}
return fmt.Errorf("could not find the PHP PPA source file for %s", from)
}

// configurePHPRepository adds Ondrej's PHP PPA, falling back to the newest
// suite it publishes when the running release is not covered yet.
func (u *UbuntuInstaller) configurePHPRepository() error {
if err := u.RunCommand("sudo add-apt-repository -y ppa:ondrej/php"); err != nil {
return fmt.Errorf("failed to add Ondrej PPA: %w", err)
}

codename := currentUbuntuCodename()
if codename == "" {
return nil
}

suite, fallback := pickPPASuite(codename, ppaSuitePublished)
if !fallback {
return nil
}

fmt.Printf(" The PHP PPA does not publish packages for %s yet; using its %s packages instead.\n", codename, suite)
return u.pinPPASuite(codename, suite)
}
83 changes: 83 additions & 0 deletions internal/bootstrap/installer/ppa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package installer

import "testing"

// Ondrej's PHP PPA publishes nothing for Ubuntu 26.04, so bootstrap must pin
// the newest suite it does publish. Without this, PHP 8.1 through 8.4 simply do
// not exist in apt and only Ubuntu's own PHP can be installed.
func TestPickPPASuite(t *testing.T) {
published := func(suites ...string) func(string) bool {
set := make(map[string]bool, len(suites))
for _, s := range suites {
set[s] = true
}
return func(suite string) bool { return set[suite] }
}

tests := []struct {
name string
current string
published func(string) bool
wantSuite string
wantFallback bool
}{
{
name: "current release is published",
current: "noble",
published: published("noble", "jammy"),
wantSuite: "noble",
},
{
name: "falls back to the newest published release",
current: "resolute",
published: published("noble", "jammy"),
wantSuite: "noble",
wantFallback: true,
},
{
name: "skips unpublished releases in between",
current: "questing",
published: published("jammy"),
wantSuite: "jammy",
wantFallback: true,
},
{
name: "unknown newer codename starts at the top of the list",
current: "vivacious",
published: published("noble"),
wantSuite: "noble",
wantFallback: true,
},
{
name: "nothing published keeps the current release",
current: "resolute",
published: published(),
wantSuite: "resolute",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suite, fallback := pickPPASuite(tt.current, tt.published)
if suite != tt.wantSuite || fallback != tt.wantFallback {
t.Errorf("pickPPASuite(%q) = (%q, %v), want (%q, %v)", tt.current, suite, fallback, tt.wantSuite, tt.wantFallback)
}
})
}
}

func TestUbuntuCodenamesAreOrderedNewestFirst(t *testing.T) {
if len(ubuntuCodenames) < 5 {
t.Fatalf("expected a meaningful list of codenames, got %d", len(ubuntuCodenames))
}
if ubuntuCodenames[len(ubuntuCodenames)-1] != "focal" {
t.Errorf("oldest entry = %q, want focal (20.04, the oldest supported release)", ubuntuCodenames[len(ubuntuCodenames)-1])
}
seen := make(map[string]bool)
for _, c := range ubuntuCodenames {
if seen[c] {
t.Errorf("duplicate codename %q", c)
}
seen[c] = true
}
}
Loading
Loading