diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ecb11..ab53869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 7ec1d6d..3e3c2f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.1.1 diff --git a/internal/bootstrap/installer/arch.go b/internal/bootstrap/installer/arch.go index 838aaf3..81e7fca 100644 --- a/internal/bootstrap/installer/arch.go +++ b/internal/bootstrap/installer/arch.go @@ -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) diff --git a/internal/bootstrap/installer/fedora.go b/internal/bootstrap/installer/fedora.go index 58ee8b7..eb59229 100644 --- a/internal/bootstrap/installer/fedora.go +++ b/internal/bootstrap/installer/fedora.go @@ -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 diff --git a/internal/bootstrap/installer/ppa.go b/internal/bootstrap/installer/ppa.go new file mode 100644 index 0000000..5cdd358 --- /dev/null +++ b/internal/bootstrap/installer/ppa.go @@ -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) +} diff --git a/internal/bootstrap/installer/ppa_test.go b/internal/bootstrap/installer/ppa_test.go new file mode 100644 index 0000000..a0cab7a --- /dev/null +++ b/internal/bootstrap/installer/ppa_test.go @@ -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 + } +} diff --git a/internal/bootstrap/installer/sudoers.go b/internal/bootstrap/installer/sudoers.go new file mode 100644 index 0000000..59ea475 --- /dev/null +++ b/internal/bootstrap/installer/sudoers.go @@ -0,0 +1,255 @@ +package installer + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" +) + +// SudoersFile is where MageBox drops its passwordless-sudo rules. +const SudoersFile = "/etc/sudoers.d/magebox" + +// sudoersMarker identifies files written by this generation of the generator. +// Older MageBox releases wrote wildcard rules such as "systemctl start php*-fpm", +// which sudo-rs (the default sudo on Ubuntu 26.04) refuses to parse — one such +// rule invalidates the whole file, so every MageBox rule stops working. The +// marker lets bootstrap recognize an outdated file and replace it. +const sudoersMarker = "# MageBox sudoers format: 2 (exact commands only)" + +// serviceActions are the systemctl verbs MageBox issues while running. +var serviceActions = []string{"start", "stop", "reload", "restart"} + +// FPMServiceNamer maps a PHP version such as "8.3" to its systemd unit name. +type FPMServiceNamer func(version string) string + +// SudoersSpec describes the distribution-specific names behind the rules. +type SudoersSpec struct { + // NginxBinary is the absolute path sudo resolves "nginx" to. + NginxBinary string + // PHPVersions are the versions MageBox may manage on this system. + PHPVersions []string + // FPMService names the PHP-FPM unit for a version. + FPMService FPMServiceNamer + // ExtraServices are additional units controlled without a password. + ExtraServices []string +} + +// UbuntuSudoersSpec describes Ubuntu and Debian, where units are php8.3-fpm. +func UbuntuSudoersSpec() SudoersSpec { + return SudoersSpec{ + NginxBinary: "/usr/sbin/nginx", + PHPVersions: PHPVersions, + FPMService: func(v string) string { return "php" + v + "-fpm" }, + ExtraServices: []string{"blackfire-agent"}, + } +} + +// FedoraSudoersSpec describes Fedora and RHEL, where Remi units are php83-php-fpm. +func FedoraSudoersSpec() SudoersSpec { + return SudoersSpec{ + NginxBinary: "/usr/sbin/nginx", + PHPVersions: PHPVersions, + FPMService: func(v string) string { return "php" + strings.ReplaceAll(v, ".", "") + "-php-fpm" }, + ExtraServices: []string{"blackfire-agent"}, + } +} + +// ArchSudoersSpec describes Arch, which ships a single php-fpm unit. +func ArchSudoersSpec() SudoersSpec { + return SudoersSpec{ + NginxBinary: "/usr/bin/nginx", + PHPVersions: PHPVersions, + FPMService: func(string) string { return "php-fpm" }, + ExtraServices: []string{"blackfire-agent"}, + } +} + +// SudoersRules returns one rule per command MageBox runs unattended. +// +// Every rule names an exact command: sudo-rs rejects wildcards in arguments, +// and a bare command path (which would allow any argument) is a root +// escalation. Commands that only run during bootstrap or an explicit install +// are deliberately absent — asking for a password there is expected. +func SudoersRules(user string, spec SudoersSpec) []string { + var commands []string + + for _, action := range serviceActions { + commands = append(commands, "/usr/bin/systemctl "+action+" nginx") + } + commands = append(commands, spec.NginxBinary+" -s reload", spec.NginxBinary+" -t") + + seen := make(map[string]bool) + for _, version := range spec.PHPVersions { + unit := spec.FPMService(version) + if seen[unit] { + continue + } + seen[unit] = true + for _, action := range serviceActions { + commands = append(commands, "/usr/bin/systemctl "+action+" "+unit) + } + } + + for _, service := range spec.ExtraServices { + for _, action := range append(serviceActions, "enable") { + commands = append(commands, "/usr/bin/systemctl "+action+" "+service) + } + } + + rules := make([]string, 0, len(commands)) + for _, command := range commands { + rules = append(rules, fmt.Sprintf("%s ALL=(ALL) NOPASSWD: %s", user, command)) + } + return rules +} + +// SudoersContent renders the file MageBox installs. +func SudoersContent(user string, spec SudoersSpec) string { + var b strings.Builder + b.WriteString("# MageBox - passwordless control of nginx and PHP-FPM for " + user + "\n") + b.WriteString(sudoersMarker + "\n") + b.WriteString("# Generated by 'magebox bootstrap' - do not edit manually\n\n") + for _, rule := range SudoersRules(user, spec) { + b.WriteString(rule + "\n") + } + return b.String() +} + +// needsSudoersRewrite decides whether bootstrap has to write the file. +// +// The presence of a file proves nothing: every install upgraded from an older +// MageBox carries wildcard rules that sudo now rejects. +func needsSudoersRewrite(exists, valid, current bool) bool { + return !exists || !valid || !current +} + +// visudoBinary finds a syntax checker, preferring the one from the sudo +// package. Returns "" when none is installed. +func visudoBinary() string { + for _, candidate := range []string{"/usr/sbin/visudo", "/usr/bin/visudo", "/usr/bin/visudo-rs"} { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + if path, err := exec.LookPath("visudo"); err == nil { + return path + } + return "" +} + +// validateSudoersContent checks content with visudo before it is installed, so +// a file the local sudo cannot parse never reaches /etc/sudoers.d. +// +// A missing visudo is not an error: the content is then installed unchecked, +// exactly as before. +func validateSudoersContent(content string) error { + visudo := visudoBinary() + if visudo == "" { + return nil + } + + dir, err := os.MkdirTemp("", "magebox-sudoers") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() + + candidate := filepath.Join(dir, "magebox") + if err := os.WriteFile(candidate, []byte(content), 0600); err != nil { + return err + } + + output, err := exec.Command(visudo, "-c", "-f", candidate).CombinedOutput() + if err != nil { + return fmt.Errorf("generated sudoers rules are invalid for this system's sudo: %w\n%s", err, strings.TrimSpace(string(output))) + } + return nil +} + +// readInstalledSudoers returns the installed file's content. It needs sudo +// because the file is only readable by root. +func readInstalledSudoers() (string, bool) { + output, err := exec.Command("sudo", "-n", "cat", SudoersFile).Output() + if err != nil { + return "", false + } + return string(output), true +} + +// installedSudoersState reports whether the file exists, parses, and already +// holds the content MageBox wants. +func installedSudoersState(want string) (exists, valid, current bool) { + if _, err := os.Stat(SudoersFile); err != nil { + return false, false, false + } + exists = true + + got, readable := readInstalledSudoers() + if !readable { + // Cannot compare without a password prompt; rewriting is cheap and safe. + return true, false, false + } + current = got == want + + if visudo := visudoBinary(); visudo != "" { + valid = exec.Command("sudo", "-n", visudo, "-c", "-f", SudoersFile).Run() == nil + } else { + valid = true + } + return exists, valid, current +} + +// sudoersUserFromEnv picks the invoking account out of the environment, +// returning "" when only root is named. +func sudoersUserFromEnv(lookup func(string) string) string { + for _, key := range []string{"SUDO_USER", "USER", "LOGNAME"} { + if value := lookup(key); value != "" && value != "root" { + return value + } + } + return "" +} + +// sudoersUser returns the account the rules should grant to. Bootstrap is often +// started with sudo, where USER is root; SUDO_USER then names the human who +// invoked it, and rules for root would be pointless. +func sudoersUser() (string, error) { + if account := sudoersUserFromEnv(os.Getenv); account != "" { + return account, nil + } + if current, err := user.Current(); err == nil && current.Username != "" && current.Username != "root" { + return current.Username, nil + } + return "", fmt.Errorf("could not determine which user to grant passwordless sudo to") +} + +// ConfigureSudoersFor installs the rules for spec, replacing a file the local +// sudo cannot parse — which is every file written by MageBox before 2.1.1 on a +// system using sudo-rs. +func ConfigureSudoersFor(b *BaseInstaller, spec SudoersSpec) error { + account, err := sudoersUser() + if err != nil { + return err + } + + content := SudoersContent(account, spec) + if err := validateSudoersContent(content); err != nil { + return err + } + + exists, valid, current := installedSudoersState(content) + if !needsSudoersRewrite(exists, valid, current) { + return nil + } + + if err := b.WriteFile(SudoersFile, content); err != nil { + return fmt.Errorf("failed to write sudoers file: %w", err) + } + if err := b.RunSudo("chmod", "0440", SudoersFile); err != nil { + return fmt.Errorf("failed to set sudoers permissions: %w", err) + } + return nil +} diff --git a/internal/bootstrap/installer/sudoers_test.go b/internal/bootstrap/installer/sudoers_test.go new file mode 100644 index 0000000..275caa2 --- /dev/null +++ b/internal/bootstrap/installer/sudoers_test.go @@ -0,0 +1,144 @@ +package installer + +import ( + "strings" + "testing" +) + +// sudo-rs, the default sudo on Ubuntu 26.04, refuses to parse wildcards in +// command arguments. A single such rule makes the whole file invalid, so every +// MageBox rule breaks and the user is asked for a password on every operation. +func TestSudoersRulesHaveNoWildcards(t *testing.T) { + specs := map[string]SudoersSpec{ + "ubuntu": UbuntuSudoersSpec(), + "fedora": FedoraSudoersSpec(), + "arch": ArchSudoersSpec(), + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + for _, rule := range SudoersRules("jakub", spec) { + if strings.Contains(rule, "*") { + t.Errorf("rule contains a wildcard, which sudo-rs rejects: %q", rule) + } + } + }) + } +} + +// Wildcards were how one rule covered every PHP version. Without them each +// version needs its own line, or MageBox asks for a password when it starts, +// stops or reloads that version's pool. +func TestSudoersRulesCoverEveryPHPVersionAndAction(t *testing.T) { + tests := []struct { + name string + spec SudoersSpec + wantService func(version string) string + }{ + {name: "ubuntu names the unit per version", spec: UbuntuSudoersSpec(), wantService: func(v string) string { return "php" + v + "-fpm" }}, + {name: "fedora drops the dot", spec: FedoraSudoersSpec(), wantService: func(v string) string { return "php" + strings.ReplaceAll(v, ".", "") + "-php-fpm" }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rules := strings.Join(SudoersRules("jakub", tt.spec), "\n") + for _, version := range PHPVersions { + for _, action := range []string{"start", "stop", "reload", "restart"} { + want := "/usr/bin/systemctl " + action + " " + tt.wantService(version) + if !strings.Contains(rules, want) { + t.Errorf("missing rule for %q", want) + } + } + } + }) + } +} + +// Arch ships a single php-fpm unit, so per-version lines would just repeat. +func TestArchSudoersRulesUseOneFPMUnit(t *testing.T) { + rules := SudoersRules("jakub", ArchSudoersSpec()) + + count := 0 + for _, rule := range rules { + if strings.HasSuffix(rule, "/usr/bin/systemctl restart php-fpm") { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly one restart rule for the single php-fpm unit, got %d", count) + } +} + +func TestSudoersRulesCoverNginxAndBlackfire(t *testing.T) { + rules := strings.Join(SudoersRules("jakub", UbuntuSudoersSpec()), "\n") + + for _, want := range []string{ + "/usr/bin/systemctl reload nginx", + "/usr/bin/systemctl restart nginx", + "/usr/sbin/nginx -s reload", + "/usr/sbin/nginx -t", + "/usr/bin/systemctl restart blackfire-agent", + } { + if !strings.Contains(rules, want) { + t.Errorf("missing rule for %q", want) + } + } +} + +func TestSudoersRulesNameTheUser(t *testing.T) { + for _, rule := range SudoersRules("someuser", UbuntuSudoersSpec()) { + if !strings.HasPrefix(rule, "someuser ALL=(ALL) NOPASSWD: ") { + t.Errorf("rule does not grant to the user: %q", rule) + } + } +} + +// An existing file is not proof of a working one: every user upgrading from an +// older MageBox carries a file full of wildcards that sudo-rs rejects. +func TestNeedsSudoersRewrite(t *testing.T) { + tests := []struct { + name string + exists bool + valid bool + current bool + want bool + }{ + {name: "missing file", exists: false, valid: false, current: false, want: true}, + {name: "present but rejected by sudo", exists: true, valid: false, current: false, want: true}, + {name: "valid but stale content", exists: true, valid: true, current: false, want: true}, + {name: "valid and current", exists: true, valid: true, current: true, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := needsSudoersRewrite(tt.exists, tt.valid, tt.current); got != tt.want { + t.Errorf("needsSudoersRewrite(%v, %v, %v) = %v, want %v", tt.exists, tt.valid, tt.current, got, tt.want) + } + }) + } +} + +// Bootstrap is often started with sudo, where USER is root. Granting rules to +// root would be useless: the rules must name the human who invoked it. +func TestSudoersUserFromEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want string + }{ + {name: "plain invocation", env: map[string]string{"USER": "jakub"}, want: "jakub"}, + {name: "run under sudo", env: map[string]string{"SUDO_USER": "jakub", "USER": "root", "LOGNAME": "root"}, want: "jakub"}, + {name: "falls back to logname", env: map[string]string{"LOGNAME": "jakub"}, want: "jakub"}, + {name: "nothing usable", env: map[string]string{"USER": "root", "LOGNAME": "root"}, want: ""}, + {name: "empty environment", env: map[string]string{}, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lookup := func(key string) string { return tt.env[key] } + if got := sudoersUserFromEnv(lookup); got != tt.want { + t.Errorf("sudoersUserFromEnv() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/bootstrap/installer/sudoers_yaml_test.go b/internal/bootstrap/installer/sudoers_yaml_test.go new file mode 100644 index 0000000..27c517f --- /dev/null +++ b/internal/bootstrap/installer/sudoers_yaml_test.go @@ -0,0 +1,57 @@ +package installer + +import ( + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// The YAML installer definitions drive the generic installer, while the Go +// specs drive the per-distro ones. They describe the same machine, so they must +// not drift — a wildcard surviving in either place breaks sudo on Ubuntu 26.04. +func TestInstallerYAMLSudoersMatchGeneratedRules(t *testing.T) { + tests := []struct { + name string + file string + spec SudoersSpec + }{ + {name: "ubuntu", file: "../../../lib/templates/installers/ubuntu.yaml", spec: UbuntuSudoersSpec()}, + {name: "fedora", file: "../../../lib/templates/installers/fedora.yaml", spec: FedoraSudoersSpec()}, + {name: "arch", file: "../../../lib/templates/installers/arch.yaml", spec: ArchSudoersSpec()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := os.ReadFile(tt.file) + if err != nil { + t.Fatalf("failed to read %s: %v", tt.file, err) + } + + var doc struct { + Sudoers struct { + Rules []string `yaml:"rules"` + } `yaml:"sudoers"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("failed to parse %s: %v", tt.file, err) + } + + got := make([]string, 0, len(doc.Sudoers.Rules)) + for _, rule := range doc.Sudoers.Rules { + got = append(got, strings.ReplaceAll(rule, "${user}", "jakub")) + } + + want := SudoersRules("jakub", tt.spec) + if len(got) != len(want) { + t.Fatalf("%s lists %d rules, generator produces %d", tt.file, len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("rule %d differs:\n yaml: %s\n got: %s", i, got[i], want[i]) + } + } + }) + } +} diff --git a/internal/bootstrap/installer/types.go b/internal/bootstrap/installer/types.go index a9dc680..f7505d5 100644 --- a/internal/bootstrap/installer/types.go +++ b/internal/bootstrap/installer/types.go @@ -14,7 +14,7 @@ var SupportedVersions = map[platform.Type]map[string][]string{ }, platform.Linux: { "fedora": {"38", "39", "40", "41", "42", "43"}, // Fedora 38-43 - "ubuntu": {"20.04", "22.04", "24.04"}, // LTS versions + "ubuntu": {"20.04", "22.04", "24.04", "26.04"}, // LTS versions "debian": {"11", "12"}, // Bullseye, Bookworm "arch": {"rolling"}, // Arch is rolling release }, diff --git a/internal/bootstrap/installer/ubuntu.go b/internal/bootstrap/installer/ubuntu.go index 504c747..a162bef 100644 --- a/internal/bootstrap/installer/ubuntu.go +++ b/internal/bootstrap/installer/ubuntu.go @@ -108,9 +108,9 @@ func (u *UbuntuInstaller) InstallPrerequisites() error { return err } - // Add Ondrej PPA for PHP - if err := u.RunCommand("sudo add-apt-repository -y ppa:ondrej/php"); err != nil { - return fmt.Errorf("failed to add Ondrej PPA: %w", err) + // Add Ondrej PPA for PHP, pinned to a suite it actually publishes + if err := u.configurePHPRepository(); err != nil { + return err } // Update after adding PPA @@ -410,55 +410,7 @@ d /var/lib/nginx/uwsgi 0755 %s %s - // ConfigureSudoers sets up passwordless sudo for services func (u *UbuntuInstaller) 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 u.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/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/apt install -y blackfire* -%[1]s ALL=(ALL) NOPASSWD: /usr/bin/apt install -y tideways* -`, currentUser) - - // Write sudoers file - if err := u.WriteFile(sudoersFile, sudoersContent); err != nil { - return fmt.Errorf("failed to write sudoers file: %w", err) - } - - // Set correct permissions - if err := u.RunSudo("chmod", "0440", sudoersFile); err != nil { - return fmt.Errorf("failed to set sudoers permissions: %w", err) - } - - return nil + return ConfigureSudoersFor(&u.BaseInstaller, UbuntuSudoersSpec()) } // ConfigureSELinux is a no-op on Ubuntu (SELinux typically not used) diff --git a/lib/templates/installers/arch.yaml b/lib/templates/installers/arch.yaml index 9c646bd..fd00852 100644 --- a/lib/templates/installers/arch.yaml +++ b/lib/templates/installers/arch.yaml @@ -132,32 +132,21 @@ sudoers: file: "/etc/sudoers.d/magebox" mode: "0440" rules: - # Nginx control - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/nginx -s reload" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/nginx -t" - # PHP-FPM control - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php-fpm" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php-fpm" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php-fpm" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php-fpm" - # Nginx config management - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-* /etc/nginx/nginx.conf" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/rm /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/ln -s *" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/sed -i *" - # Blackfire profiler - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop blackfire-agent" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable blackfire-agent" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/pacman -S --noconfirm *blackfire*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/pacman -S --noconfirm *tideways*" - selinux: enabled: false diff --git a/lib/templates/installers/fedora.yaml b/lib/templates/installers/fedora.yaml index 3a00753..ba666bc 100644 --- a/lib/templates/installers/fedora.yaml +++ b/lib/templates/installers/fedora.yaml @@ -146,39 +146,37 @@ sudoers: file: "/etc/sudoers.d/magebox" mode: "0440" rules: - # Nginx control - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload" - "${user} ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/nginx -s reload" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/nginx -t" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/nginx" - # PHP-FPM control - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php*-php-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php*-php-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php*-php-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php*-php-fpm" - # Nginx config management - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-* /etc/nginx/nginx.conf" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/rm /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/ln -s *" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/sed -i *" - # Hosts file management - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/tee -a /etc/hosts" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/sed -i * /etc/hosts" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-hosts-* /etc/hosts" - # Blackfire profiler + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php81-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php81-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php81-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php81-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php82-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php82-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php82-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php82-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php83-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php83-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php83-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php83-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php84-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php84-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php84-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php84-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php85-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php85-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php85-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php85-php-fpm" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop blackfire-agent" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable blackfire-agent" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/dnf install -y blackfire*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/dnf install -y tideways*" - selinux: enabled: true booleans: diff --git a/lib/templates/installers/ubuntu.yaml b/lib/templates/installers/ubuntu.yaml index 1040d7d..86cd5ad 100644 --- a/lib/templates/installers/ubuntu.yaml +++ b/lib/templates/installers/ubuntu.yaml @@ -17,7 +17,7 @@ meta: distro: ubuntu display_name: "Ubuntu/Debian Linux" supported_versions: - ubuntu: ["20.04", "22.04", "24.04"] + ubuntu: ["20.04", "22.04", "24.04", "26.04"] debian: ["11", "12"] package_manager: @@ -138,32 +138,37 @@ sudoers: file: "/etc/sudoers.d/magebox" mode: "0440" rules: - # Nginx control - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx" - "${user} ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload" - "${user} ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t" - # PHP-FPM control - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php*-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php*-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php*-fpm" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php*-fpm" - # Nginx config management - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/magebox-* /etc/nginx/nginx.conf" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/rm /etc/nginx/*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/ln -s *" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/sed -i *" - # Blackfire profiler + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php8.1-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php8.1-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.1-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.1-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php8.2-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php8.2-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.2-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.2-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php8.3-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php8.3-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.3-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php8.4-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php8.4-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.4-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.4-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php8.5-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop php8.5-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.5-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.5-fpm" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop blackfire-agent" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart blackfire-agent" - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl enable blackfire-agent" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/apt install -y blackfire*" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/apt install -y tideways*" - selinux: enabled: false diff --git a/vitepress/guide/bootstrap.md b/vitepress/guide/bootstrap.md index ea4c2bd..c0e158a 100644 --- a/vitepress/guide/bootstrap.md +++ b/vitepress/guide/bootstrap.md @@ -22,7 +22,7 @@ MageBox validates your OS version during bootstrap: **Linux:** - Fedora: 38, 39, 40, 41, 42 -- Ubuntu: 20.04, 22.04, 24.04 (LTS versions) +- Ubuntu: 20.04, 22.04, 24.04, 26.04 (LTS versions) - Debian: 11 (Bullseye), 12 (Bookworm) - Arch: Rolling release @@ -217,6 +217,18 @@ curl -I https://mystore.test On Linux, MageBox uses a different approach than macOS for privileged ports and service management. +### PHP Packages on Ubuntu and Debian + +PHP comes from Ondrej Sury's PPA, which lags new Ubuntu releases by months. On a release it does not cover yet, such as 26.04, that PPA holds no packages at all, so PHP 8.1 through 8.4 cannot be installed and only the PHP version shipped by Ubuntu itself is available. + +Bootstrap checks which suites the PPA publishes and pins the newest one it has, reporting what it did: + +``` +The PHP PPA does not publish packages for resolute yet; using its noble packages instead. +``` + +Those packages are built for the previous release. They normally install and run fine, but if a dependency has moved on, bootstrap reports the failure for that PHP version and continues with the rest. + ### Nginx User Configuration MageBox configures nginx to run as your user so it can access SSL certificates in `~/.magebox/certs`: @@ -262,18 +274,26 @@ MageBox uses the default PHP-FPM logging paths provided by each distribution's r ### Sudoers Configuration -Bootstrap configures passwordless sudo for specific commands: +Bootstrap configures passwordless sudo for the exact commands MageBox runs while you work: ```bash # /etc/sudoers.d/magebox YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx -YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php*-fpm YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx -YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php*-fpm +YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload +YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm +YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php8.3-fpm +# ... one line per PHP version and action ``` This allows MageBox to manage services without password prompts during daily operations. +::: warning No wildcards +Every rule names a complete command. Earlier releases used wildcards such as `systemctl reload php*-fpm`, which [sudo-rs](https://github.com/trifectatechfoundation/sudo-rs) — the default sudo on Ubuntu 26.04 — refuses to parse. A single rejected rule invalidates the whole file, so MageBox then asked for a password on every operation. Bootstrap detects such a file and replaces it, and validates the new one with `visudo` before installing it. + +Commands that only run during bootstrap or an explicit install, such as editing `/etc/nginx/nginx.conf` or toggling Xdebug, are deliberately not passwordless. Granting those without a wildcard would mean allowing any argument, which is equivalent to unrestricted root. +::: + ### DNS with systemd-resolved On modern Linux distributions using systemd-resolved, bootstrap configures: diff --git a/vitepress/guide/linux-installers.md b/vitepress/guide/linux-installers.md index a45dca0..d81c3ba 100644 --- a/vitepress/guide/linux-installers.md +++ b/vitepress/guide/linux-installers.md @@ -124,7 +124,7 @@ var SupportedVersions = map[platform.Type]map[string][]string{ }, platform.Linux: { "fedora": {"38", "39", "40", "41", "42"}, - "ubuntu": {"20.04", "22.04", "24.04"}, // LTS versions + "ubuntu": {"20.04", "22.04", "24.04", "26.04"}, // LTS versions "debian": {"11", "12"}, // Bullseye, Bookworm "arch": {"rolling"}, }, @@ -334,9 +334,11 @@ sudoers: file: "/etc/sudoers.d/magebox" rules: - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx" - - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php*-php-fpm" + - "${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start php83-php-fpm" ``` +Rules must name complete commands. Wildcards in arguments are rejected by sudo-rs, the default sudo on Ubuntu 26.04, and one rejected rule invalidates the entire file. A test keeps these lists identical to the rules the Go installers generate. + ### Available Variables | Variable | Example | Description |