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
1 change: 1 addition & 0 deletions cmd/lerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func main() {

// Register all subcommands
root.AddCommand(cli.NewInstallCmd())
root.AddCommand(cli.NewBootstrapCmd())
root.AddCommand(cli.NewStartCmd())
root.AddCommand(cli.NewStopCmd())
root.AddCommand(cli.NewQuitCmd())
Expand Down
22 changes: 22 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ bash install.sh --local ./build/lerd

---

### Install via apt (Ubuntu/Debian)

Lerd is published to a Launchpad PPA, so you can install and update it with your package manager:

```bash
sudo add-apt-repository ppa:lerd/lerd
sudo apt update
sudo apt install lerd
```

The package installs the binary to `/usr/bin/lerd` and finishes setup automatically: its maintainer script enables the unprivileged-port sysctl and systemd linger, then runs `lerd install` for your user, so the stack comes up straight away and again at every boot. `.test` DNS and HTTPS are configured with no prompt because the package sets up the sudoers rule and trusts the mkcert CA as root.

Updates come through apt like any other package:

```bash
sudo apt upgrade
```

An apt-installed lerd lives under `/usr`, so `lerd update` (which self-replaces a `~/.local/bin` install) detects it and defers to `sudo apt upgrade` instead of fighting the package manager.

---

### Update

```bash
Expand Down
41 changes: 41 additions & 0 deletions internal/certs/system_trust_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build linux

package certs

import (
"bytes"
"os"
"os/exec"
"path/filepath"
)

// systemStoreCAFile is where the mkcert root CA is dropped for the system trust
// store; update-ca-certificates picks up .crt files under this directory. A var
// so tests can point it at a temp path.
var systemStoreCAFile = "/usr/local/share/ca-certificates/lerd-mkcert-rootCA.crt"

// updateSystemTrust refreshes the aggregated trust bundle. A var so tests can
// stub out the real command.
var updateSystemTrust = func() error {
return exec.Command("update-ca-certificates").Run()
}

// TrustCAInSystemStore installs the given mkcert root CA PEM into the system
// trust store. The caller must be root. Idempotent: a byte-identical CA already
// in place is a no-op, so re-running on every package upgrade is cheap.
//
// mkcert normally does this itself via an interactive sudo, which a package
// maintainer script cannot answer. `lerd bootstrap --trust-ca` runs as root and
// installs the user-generated CA directly instead.
func TrustCAInSystemStore(caPEM []byte) error {
if existing, err := os.ReadFile(systemStoreCAFile); err == nil && bytes.Equal(existing, caPEM) {
return nil
}
if err := os.MkdirAll(filepath.Dir(systemStoreCAFile), 0755); err != nil {
return err
}
if err := os.WriteFile(systemStoreCAFile, caPEM, 0644); err != nil {
return err
}
return updateSystemTrust()
}
41 changes: 41 additions & 0 deletions internal/certs/system_trust_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build linux

package certs

import (
"os"
"path/filepath"
"testing"
)

func TestTrustCAInSystemStore(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lerd-mkcert-rootCA.crt")

origPath, origUpdate := systemStoreCAFile, updateSystemTrust
t.Cleanup(func() { systemStoreCAFile, updateSystemTrust = origPath, origUpdate })
systemStoreCAFile = path

var updates int
updateSystemTrust = func() error { updates++; return nil }

ca := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
if err := TrustCAInSystemStore(ca); err != nil {
t.Fatalf("first install: %v", err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != string(ca) {
t.Fatalf("CA not written: got %q err %v", got, err)
}
if updates != 1 {
t.Errorf("update-ca-certificates called %d times, want 1", updates)
}

// Idempotent: same CA already in place must not rewrite or re-run update.
if err := TrustCAInSystemStore(ca); err != nil {
t.Fatalf("second install: %v", err)
}
if updates != 1 {
t.Errorf("idempotent install re-ran update (%d times)", updates)
}
}
95 changes: 95 additions & 0 deletions internal/cli/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package cli

import (
"fmt"
"os"
"os/exec"

"github.com/spf13/cobra"
)

// The machine-global, user-independent steps that `lerd install` would
// otherwise perform through interactive sudo. A package maintainer script runs
// as root but cannot prompt, so it calls `lerd bootstrap --system` for the
// prerequisites and `lerd bootstrap --trust-ca` after the per-user install, so
// `lerd install --unattended` in between needs no sudo. See lerd-env/lerd#979.
const (
unprivPortDropIn = "/etc/sysctl.d/99-lerd-ports.conf"
unprivPortSetting = "net.ipv4.ip_unprivileged_port_start=80"
)

// cmdRunner runs an external command. A seam so the bootstrap steps can be
// tested without touching the real system.
type cmdRunner func(name string, args ...string) error

func execRunner(name string, args ...string) error {
c := exec.Command(name, args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}

// writePortDropIn lets rootless Podman bind 80/443 by lowering
// ip_unprivileged_port_start, both live (sysctl -w) and persistently (a
// sysctl.d drop-in). Idempotent.
func writePortDropIn(path string, run cmdRunner) error {
if err := os.WriteFile(path, []byte(unprivPortSetting+"\n"), 0644); err != nil {
return err
}
return run("sysctl", "-w", unprivPortSetting)
}

// enableLinger keeps the user's rootless containers alive across screen blank,
// lock, and logout. A blank user is a no-op.
func enableLinger(user string, run cmdRunner) error {
if user == "" {
return nil
}
return run("loginctl", "enable-linger", user)
}

// bootstrapTargetUser resolves whose session the per-user half of setup belongs
// to. An explicit --user wins; otherwise SUDO_USER (the human who invoked the
// package manager) is preferred over the ambient USER, and a root SUDO_USER is
// ignored since it carries no per-user meaning.
func bootstrapTargetUser(flagUser string) string {
if flagUser != "" {
return flagUser
}
if u := os.Getenv("SUDO_USER"); u != "" && u != "root" {
return u
}
if u := os.Getenv("USER"); u != "" {
return u
}
return os.Getenv("LOGNAME")
}

// NewBootstrapCmd returns the bootstrap command. It performs the root-level,
// non-interactive halves of setup so a deb/rpm postinst can finish the install
// without prompting, pairing with `lerd install --unattended`.
func NewBootstrapCmd() *cobra.Command {
var system, trustCA bool
var user string
cmd := &cobra.Command{
Use: "bootstrap",
Short: "Apply the root-level system setup (for package maintainer scripts)",
RunE: func(_ *cobra.Command, _ []string) error {
if !system && !trustCA {
return fmt.Errorf("nothing to do: pass --system or --trust-ca")
}
if os.Geteuid() != 0 {
return fmt.Errorf("lerd bootstrap configures system-level settings and must run as root")
}
target := bootstrapTargetUser(user)
if trustCA {
return runBootstrapTrustCA(target)
}
return runBootstrapSystem(target)
},
}
cmd.Flags().BoolVar(&system, "system", false, "Apply root-level setup: unprivileged ports, linger, DNS sudoers")
cmd.Flags().BoolVar(&trustCA, "trust-ca", false, "Trust the user's mkcert CA in the system store (run after install)")
cmd.Flags().StringVar(&user, "user", "", "Target user for per-user settings (defaults to SUDO_USER)")
return cmd
}
67 changes: 67 additions & 0 deletions internal/cli/bootstrap_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//go:build linux

package cli

import (
"fmt"
"os"
"os/user"
"path/filepath"

"github.com/geodro/lerd/internal/certs"
"github.com/geodro/lerd/internal/dns"
"github.com/geodro/lerd/internal/feedback"
)

// runBootstrapSystem performs the root prerequisites a later unattended install
// relies on: the unprivileged-port sysctl, systemd linger, and the DNS sudoers
// rule that lets the install configure the resolver without prompting.
func runBootstrapSystem(target string) error {
feedback.Header("Bootstrapping system for lerd")

if err := writePortDropIn(unprivPortDropIn, execRunner); err != nil {
feedback.Warn("enabling unprivileged ports: %v", err)
} else {
feedback.Done("unprivileged ports enabled for 80/443")
}

if target == "" {
return nil
}
if err := enableLinger(target, execRunner); err != nil {
feedback.Warn("enabling linger for %s: %v", target, err)
} else {
feedback.Done("systemd linger enabled for " + target)
}
if err := dns.WriteSudoersForUser(target); err != nil {
feedback.Warn("installing DNS sudoers rule: %v", err)
} else {
feedback.Done("DNS sudoers rule installed for " + target)
}
return nil
}

// runBootstrapTrustCA installs the user-generated mkcert root CA into the system
// trust store, the one managed-DNS step the per-user install cannot do without
// an interactive sudo. Run after `lerd install --unattended` has generated the
// CA. A missing CA is not an error (localhost-mode installs have none).
func runBootstrapTrustCA(target string) error {
if target == "" {
return fmt.Errorf("--trust-ca needs a target user (pass --user)")
}
u, err := user.Lookup(target)
if err != nil {
return fmt.Errorf("looking up user %s: %w", target, err)
}
caPath := filepath.Join(u.HomeDir, ".local", "share", "mkcert", "rootCA.pem")
pem, err := os.ReadFile(caPath)
if err != nil {
feedback.Note("no mkcert CA at " + caPath + " yet, skipping system trust")
return nil
}
if err := certs.TrustCAInSystemStore(pem); err != nil {
return fmt.Errorf("trusting CA in system store: %w", err)
}
feedback.Done("mkcert CA trusted in the system store")
return nil
}
13 changes: 13 additions & 0 deletions internal/cli/bootstrap_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !linux

package cli

import "fmt"

func runBootstrapSystem(string) error {
return fmt.Errorf("lerd bootstrap is only supported on Linux")
}

func runBootstrapTrustCA(string) error {
return fmt.Errorf("lerd bootstrap is only supported on Linux")
}
77 changes: 77 additions & 0 deletions internal/cli/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cli

import (
"os"
"path/filepath"
"testing"
)

func TestWritePortDropIn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "99-lerd-ports.conf")

var gotName string
var gotArgs []string
run := func(name string, args ...string) error {
gotName = name
gotArgs = args
return nil
}

if err := writePortDropIn(path, run); err != nil {
t.Fatalf("writePortDropIn: %v", err)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("drop-in not written: %v", err)
}
if string(data) != unprivPortSetting+"\n" {
t.Errorf("drop-in content = %q, want %q", string(data), unprivPortSetting+"\n")
}
if gotName != "sysctl" || len(gotArgs) != 2 || gotArgs[0] != "-w" || gotArgs[1] != unprivPortSetting {
t.Errorf("runner called with %q %v, want sysctl -w %s", gotName, gotArgs, unprivPortSetting)
}
}

func TestEnableLinger(t *testing.T) {
var gotArgs []string
run := func(_ string, args ...string) error {
gotArgs = args
return nil
}

// A blank user is a no-op: nothing to enable, no runner call.
if err := enableLinger("", run); err != nil {
t.Fatalf("enableLinger blank: %v", err)
}
if gotArgs != nil {
t.Errorf("blank user still called runner: %v", gotArgs)
}

if err := enableLinger("george", run); err != nil {
t.Fatalf("enableLinger: %v", err)
}
if len(gotArgs) != 2 || gotArgs[0] != "enable-linger" || gotArgs[1] != "george" {
t.Errorf("runner args = %v, want enable-linger george", gotArgs)
}
}

func TestBootstrapTargetUser(t *testing.T) {
t.Setenv("SUDO_USER", "sudoer")
t.Setenv("USER", "current")

if got := bootstrapTargetUser("explicit"); got != "explicit" {
t.Errorf("flag user ignored: got %q", got)
}
if got := bootstrapTargetUser(""); got != "sudoer" {
t.Errorf("SUDO_USER not preferred: got %q", got)
}

// root as SUDO_USER is meaningless (it means apt was run by root directly),
// so fall back to USER.
t.Setenv("SUDO_USER", "root")
if got := bootstrapTargetUser(""); got != "current" {
t.Errorf("root SUDO_USER not skipped: got %q", got)
}
}
Loading
Loading