diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index fef0ea8b..c04f5669 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -100,7 +100,8 @@ jobs: - name: Release run: | go run ./tools/release notes > "$RUNNER_TEMP/notes.md" - for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe"; do + for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --target "$SHA" --prerelease --latest=false \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc172f64..086571f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ -# Runs the Go tests, and builds the macOS disk image (signed and notarized) and the Windows installer -# (signed with Artifact Signing) and keeps them as run artifacts. Signing takes the secrets and +# Runs the Go tests, and builds the macOS disk image (signed and notarized), the Windows installer +# (signed with Artifact Signing) and the Linux CLI archives, and keeps them as run artifacts. Signing takes the secrets and # variables of the "release" environment, and is required in the merge queue and for releases. # # - on every pull request: the Go tests only; the builds are skipped (which counts as passed) @@ -64,7 +64,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - # The Go tests on both platforms Flock supports (the race detector needs cgo, so macOS only). + # The Go tests on the platforms Flock supports (the race detector needs cgo, so not on Windows). go-test: name: Go tests (${{ matrix.name }}) needs: version @@ -74,6 +74,8 @@ jobs: include: - { name: macOS, os: macos-26, flags: -race } - { name: Windows, os: windows-2025, flags: "" } + - { name: Linux, os: ubuntu-24.04, flags: -race } + - { name: Linux arm64, os: ubuntu-24.04-arm, flags: -race } runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -272,3 +274,28 @@ jobs: name: flock-windows path: windows/dist/Flock-Setup-*-x64.exe if-no-files-found: error + + linux: + name: Linux (x64, arm64) + needs: version + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Build archives + run: make linux VERSION=${{ needs.version.outputs.version }} + + - uses: actions/upload-artifact@v7 + with: + name: flock-linux + path: | + dist/flock-*-linux-*.tar.gz + dist/flock-*-linux-*.tar.gz.sha256 + if-no-files-found: error diff --git a/.github/workflows/linux-e2e.yml b/.github/workflows/linux-e2e.yml new file mode 100644 index 00000000..61991302 --- /dev/null +++ b/.github/workflows/linux-e2e.yml @@ -0,0 +1,53 @@ +# The Linux end-to-end test (tools/linux-e2e/run.sh): Flock installed with install.sh, checked +# (DNS through flock0, NetworkManager, drift, HTTPS, a reboot) and uninstalled, in Arch, Ubuntu and +# Fedora containers booting systemd. Runs when the Linux setup changes, and by hand. +name: Linux e2e + +on: + workflow_dispatch: + pull_request: + paths: + - internal/cli/** + - internal/dns/** + - internal/certs/** + - internal/ports/** + - internal/daemon/** + - cmd/flock/** + - go.mod + - go.sum + - install.sh + - Makefile + - tools/linux-e2e/** + - .github/workflows/linux-e2e.yml + +permissions: + contents: read + +concurrency: + group: linux-e2e-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + e2e: + name: ${{ matrix.distro }} + strategy: + fail-fast: false + matrix: + distro: [arch, ubuntu, fedora] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + # flock0 is a dummy link; a rootless container can't load the module itself. + - name: Load the dummy module + run: sudo modprobe dummy numdummies=0 + + - name: Run + run: tools/linux-e2e/run.sh ${{ matrix.distro }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d71db055..a3806980 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: VERSION: ${{ needs.release-please.outputs.version }} run: | ls dist - for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe"; do + for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/Makefile b/Makefile index 0c70f37b..301bffb4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X flock/internal/cli.Version=$(VERSION) -.PHONY: build build-all test lint notices app dmg windows windows-test +.PHONY: build build-all test lint notices app dmg windows windows-test linux build: go build -ldflags "$(LDFLAGS)" -o bin/flock ./cmd/flock @@ -9,6 +9,8 @@ build: build-all: GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-darwin-arm64 ./cmd/flock GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-windows-amd64.exe ./cmd/flock + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-amd64 ./cmd/flock + GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-arm64 ./cmd/flock test: go test -race ./... @@ -33,6 +35,19 @@ windows: GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=$(VERSION)" -o dist/windows-amd64/flock-helper.exe ./cmd/flock-helper GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o dist/windows-amd64/flockd.exe ./cmd/flockd +# The Linux CLI as release archives (install.sh and the AUR package download these): +# dist/flock--linux-{x64,arm64}.tar.gz with flock-php (util-linux owns flock), each with its .sha256. +linux: + for a in x64:amd64 arm64:arm64; do \ + name=flock-$(VERSION)-linux-$${a%%:*}; \ + rm -rf dist/$$name && mkdir -p dist/$$name && \ + CGO_ENABLED=0 GOOS=linux GOARCH=$${a##*:} go build -trimpath -ldflags "$(LDFLAGS)" -o dist/$$name/flock-php ./cmd/flock && \ + cp LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ + tar -C dist/$$name -czf dist/$$name.tar.gz flock-php LICENSE NOTICE THIRD_PARTY_NOTICES && \ + rm -r dist/$$name && \ + (cd dist && sha256sum $$name.tar.gz > $$name.tar.gz.sha256) || exit 1; \ + done + # The tray app's models and API client (windows/FlockKit) run on any OS; the app itself is built on Windows (windows/build.ps1). windows-test: dotnet test windows/FlockKit.Tests diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..82d91bae --- /dev/null +++ b/install.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Installs Flock on Linux (any distribution with glibc, x86-64 or arm64): downloads the flock CLI of +# the newest release, checks it against its SHA-256 and runs `flock-php install`, which copies it to +# ~/.config/Flock/bin and sets Flock up (asks for your password once). The CLI is flock-php on Linux: +# util-linux owns flock(1). +# +# curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh +# +# FLOCK_VERSION=1.0.0 installs that release instead. Arguments go to `flock-php install`: +# +# curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh -s -- --skip-privileged +# +# Running it again updates Flock. +set -eu + +repo=flock-php/flock + +die() { + echo "flock: $*" >&2 + exit 1 +} + +[ "$(uname -s)" = Linux ] || die "this script is for Linux; for macOS and Windows get the app from https://github.com/$repo/releases" + +case $(uname -m) in +x86_64 | amd64) arch=x64 ;; +aarch64 | arm64) arch=arm64 ;; +*) die "no Flock build for $(uname -m) (there are x86-64 and arm64 ones)" ;; +esac + +for tool in curl tar sha256sum; do + command -v "$tool" >/dev/null || die "$tool is needed (Ubuntu: sudo apt install $tool)" +done + +# The tag_name of the first release in a GitHub API response, without its v. +tag() { + sed -n 's/^ *"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1 +} + +version=${FLOCK_VERSION:-} +if [ -z "$version" ]; then + # The newest stable release; until there is one, the newest beta. + version=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null | tag) + [ -n "$version" ] || version=$(curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=1" | tag) + [ -n "$version" ] || die "could not find a release of $repo" +fi +version=${version#v} + +name=flock-$version-linux-$arch.tar.gz +url=https://github.com/$repo/releases/download/v$version/$name + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading Flock $version ($arch)…" +curl -fsSL -o "$tmp/$name" "$url" || die "could not download $url" +curl -fsSL -o "$tmp/$name.sha256" "$url.sha256" || die "could not download $url.sha256" +(cd "$tmp" && sha256sum -c --status "$name.sha256") || die "$name doesn't match its SHA-256" +tar -xzf "$tmp/$name" -C "$tmp" flock-php + +# Piped into sh, stdin is this script: the password prompt and any question need the terminal. +if [ ! -t 0 ] && (: /dev/null; then + "$tmp/flock-php" install "$@" missing or overridden) nothing under the TLD does. Wildcard bool `json:"wildcard"` diff --git a/internal/binaries/binaries.go b/internal/binaries/binaries.go index 26008695..0c704aa8 100644 --- a/internal/binaries/binaries.go +++ b/internal/binaries/binaries.go @@ -130,8 +130,8 @@ func (m *Manifest) Versions(pkg, platform string) []Version { return out } -// Platform names this machine in the manifest ("macos-arm64", "windows-x64"); "" where Flock -// publishes no packages (Flock supports Apple Silicon Macs and Windows x64). +// Platform names this machine in the manifest ("macos-arm64", "windows-x64", "linux-x64", +// "linux-arm64"); "" where Flock publishes no packages. func Platform() string { return platform(runtime.GOOS, runtime.GOARCH) } @@ -142,6 +142,10 @@ func platform(goos, goarch string) string { return "macos-arm64" case goos == "windows" && goarch == "amd64": return "windows-x64" + case goos == "linux" && goarch == "amd64": + return "linux-x64" + case goos == "linux" && goarch == "arm64": + return "linux-arm64" } return "" } diff --git a/internal/binaries/binaries_test.go b/internal/binaries/binaries_test.go index e9a61b9e..36d01390 100644 --- a/internal/binaries/binaries_test.go +++ b/internal/binaries/binaries_test.go @@ -230,7 +230,7 @@ func TestEmbeddedKeyAndPlatforms(t *testing.T) { if len(PublicKey()) != ed25519.PublicKeySize { t.Fatal("embedded key") } - for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", ""}} { + for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", "linux-x64"}, {"linux", "arm64", "linux-arm64"}, {"linux", "386", ""}} { if got := platform(c[0], c[1]); got != c[2] { t.Errorf("%s/%s: %q, want %q", c[0], c[1], got, c[2]) } diff --git a/internal/certs/system_unix.go b/internal/certs/system_unix.go index 7cadc223..ce28edf1 100644 --- a/internal/certs/system_unix.go +++ b/internal/certs/system_unix.go @@ -4,8 +4,27 @@ package certs import "os" +// bundleFiles are where distributions keep the trust store as one PEM file (the list Go's +// crypto/x509 reads), tried in order. SSL_CERT_FILE, when set, comes first. +var bundleFiles = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Arch, Gentoo + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL + "/etc/ssl/ca-bundle.pem", // openSUSE + "/etc/pki/tls/cacert.pem", // OpenELEC + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS, RHEL 7 + "/etc/ssl/cert.pem", // Alpine, the BSDs +} + // SystemRoots returns the OS trust store as PEM, for tools (PHP) that cannot read it themselves. func SystemRoots() []byte { - data, _ := os.ReadFile("/etc/ssl/cert.pem") - return data + files := bundleFiles + if env := os.Getenv("SSL_CERT_FILE"); env != "" { + files = append([]string{env}, files...) + } + for _, f := range files { + if data, err := os.ReadFile(f); err == nil && len(data) > 0 { + return data + } + } + return nil } diff --git a/internal/certs/system_unix_test.go b/internal/certs/system_unix_test.go new file mode 100644 index 00000000..9d4a4a78 --- /dev/null +++ b/internal/certs/system_unix_test.go @@ -0,0 +1,38 @@ +//go:build !windows && !darwin + +package certs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSystemRootsReadsTheFirstBundleThatExists(t *testing.T) { + dir := t.TempDir() + missing, empty, debian, alpine := filepath.Join(dir, "missing"), filepath.Join(dir, "empty"), filepath.Join(dir, "debian"), filepath.Join(dir, "alpine") + for path, data := range map[string]string{empty: "", debian: "debian roots", alpine: "alpine roots"} { + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + } + old := bundleFiles + bundleFiles = []string{missing, empty, debian, alpine} + t.Cleanup(func() { bundleFiles = old }) + t.Setenv("SSL_CERT_FILE", "") + + if got := string(SystemRoots()); got != "debian roots" { + t.Errorf("SystemRoots() = %q, want the Debian bundle", got) + } + + t.Setenv("SSL_CERT_FILE", alpine) + if got := string(SystemRoots()); got != "alpine roots" { + t.Errorf("with SSL_CERT_FILE: %q, want its bundle", got) + } + + bundleFiles = []string{missing} + t.Setenv("SSL_CERT_FILE", "") + if got := SystemRoots(); got != nil { + t.Errorf("without a bundle: %q, want nil", got) + } +} diff --git a/internal/cli/install.go b/internal/cli/install.go index 50192a67..bbc1c6b7 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -14,11 +14,15 @@ import ( "flock/internal/certs" "flock/internal/node" + "flock/internal/paths" "flock/internal/php" ) const composerURL = "https://getcomposer.org/download/latest-stable/composer.phar" +// serviceNote is what `flock install` reports once installService is done. +var serviceNote = "daemon runs in the background and starts at login" + func newInstallCommand() *cobra.Command { var skipPrivileged, privilegedOnly, ignoreConflicts bool @@ -97,7 +101,7 @@ func newInstallCommand() *cobra.Command { if err := installService(s.root, bin); err != nil { return fmt.Errorf("install background service: %w", err) } - cmd.Println("✓ daemon runs in the background and starts at login") + cmd.Println("✓ " + serviceNote) if skipPrivileged || privilegedFirst { return nil @@ -146,7 +150,7 @@ func installBinary(root string) (string, error) { return "", err } - dst := filepath.Join(root, "bin", exeName("flock")) + dst := filepath.Join(root, "bin", paths.CLI()) if self == dst { return dst, nil } diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index e2ed0662..a747cff6 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strconv" "strings" "time" @@ -20,115 +19,15 @@ import ( ) const ( - launchLabel = "dev.flock.daemon" - profileMarker = "# Flock (added by `flock install`)" + launchLabel = "dev.flock.daemon" // appBundleID and appExecutable identify the menu bar app (macos/build-app.sh). appBundleID = "dev.flock.bar" appExecutable = "FlockBar" ) -// shellProfiles are the profiles addToShellPath writes to (~/.zshrc always, ~/.bash_profile if present). +// shellProfiles are the profiles addToShellPath writes to: ~/.zshrc always, ~/.bash_profile if present. var shellProfiles = []string{".zshrc", ".bash_profile"} -func exeName(name string) string { return name } - -func writeShims(root, bin string) error { - dir := filepath.Join(root, "bin") - for _, tool := range tools { - script := fmt.Sprintf("#!/bin/sh\nexec %q exec %s \"$@\"\n", bin, tool) - if err := os.WriteFile(filepath.Join(dir, tool), []byte(script), 0o755); err != nil { - return err - } - } - return nil -} - -// addToShellPath prepends dirs, in this order, in ~/.zshrc (and ~/.bash_profile if present): a -// marked export line, appended once and rewritten when the directories change. -func addToShellPath(dirs ...string) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - - quoted := make([]string, len(dirs)) - for i, dir := range dirs { - quoted[i] = strconv.Quote(dir) - } - export := fmt.Sprintf("export PATH=%s:\"$PATH\"", strings.Join(quoted, ":")) - for _, name := range shellProfiles { - path := filepath.Join(home, name) - data, err := os.ReadFile(path) - if err != nil && name != ".zshrc" { - continue - } - lines := strings.Split(string(data), "\n") - if i := slices.Index(lines, profileMarker); i >= 0 { - if i+1 < len(lines) && lines[i+1] == export { - continue - } - if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "export PATH=") { - lines[i+1] = export - } else { - lines = slices.Insert(lines, i+1, export) - } - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { - return err - } - continue - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - _, err = f.WriteString("\n" + profileMarker + "\n" + export + "\n") - f.Close() - if err != nil { - return err - } - } - return nil -} - -// removeFromShellProfiles takes out what addToShellPath added: the marker, its export line and -// the blank line before them. It reports whether a profile had them. -func removeFromShellProfiles() (bool, error) { - home, err := os.UserHomeDir() - if err != nil { - return false, err - } - removed := false - for _, name := range shellProfiles { - path := filepath.Join(home, name) - data, err := os.ReadFile(path) - if err != nil { - continue - } - lines := strings.Split(string(data), "\n") - i := slices.Index(lines, profileMarker) - if i < 0 { - continue - } - end := i + 1 - if end < len(lines) && strings.HasPrefix(lines[end], "export PATH=") { - end++ - } - if i > 0 && lines[i-1] == "" { - i-- - } - lines = slices.Delete(lines, i, end) - info, err := os.Stat(path) - if err != nil { - return removed, err - } - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil { - return removed, err - } - removed = true - } - return removed, nil -} - func plistPath() (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/cli/install_darwin_test.go b/internal/cli/install_darwin_test.go index 1d6c89c7..785ec663 100644 --- a/internal/cli/install_darwin_test.go +++ b/internal/cli/install_darwin_test.go @@ -9,50 +9,6 @@ import ( "testing" ) -// Uninstalling leaves the shell profiles as they were before `flock install`. -func TestShellProfileRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - zshrc := filepath.Join(home, ".zshrc") - before := "export EDITOR=vim\nalias ll='ls -l'\n" - if err := os.WriteFile(zshrc, []byte(before), 0o600); err != nil { - t.Fatal(err) - } - - if err := addToShellPath("/f/bin", "/f/node-global/current/bin"); err != nil { - t.Fatal(err) - } - // A second install with other directories rewrites the line instead of adding another. - if err := addToShellPath("/f/bin"); err != nil { - t.Fatal(err) - } - data, _ := os.ReadFile(zshrc) - if strings.Count(string(data), profileMarker) != 1 || !strings.Contains(string(data), `export PATH="/f/bin":"$PATH"`) { - t.Fatalf("profile after install:\n%s", data) - } - - removed, err := removeFromShellProfiles() - if err != nil || !removed { - t.Fatalf("removed %v, err %v", removed, err) - } - data, _ = os.ReadFile(zshrc) - if string(data) != before { - t.Fatalf("profile after uninstall:\n%q\nwant\n%q", data, before) - } - if info, _ := os.Stat(zshrc); info.Mode().Perm() != 0o600 { - t.Fatalf("permissions changed to %v", info.Mode().Perm()) - } - // No ~/.bash_profile existed, and none is created. - if _, err := os.Stat(filepath.Join(home, ".bash_profile")); !os.IsNotExist(err) { - t.Fatalf(".bash_profile: %v", err) - } - - if removed, err := removeFromShellProfiles(); err != nil || removed { - t.Fatalf("second removal: removed %v, err %v", removed, err) - } -} - // The command line reaches the shell of `do shell script` intact, whatever the paths contain. func TestAdministratorScriptQuoting(t *testing.T) { exe := "/Users/o'brien/Library/Application Support/Flock/bin/flock" diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go new file mode 100644 index 00000000..410e124d --- /dev/null +++ b/internal/cli/install_linux.go @@ -0,0 +1,596 @@ +package cli + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/sys/unix" + + "flock/internal/api" + "flock/internal/certs" + "flock/internal/dns" + "flock/internal/node" + "flock/internal/paths" +) + +const serviceUnit = "flock.service" + +// shellProfiles are the profiles addToShellPath writes to: ~/.bashrc always, ~/.zshrc if present. +var shellProfiles = []string{".bashrc", ".zshrc"} + +// unitPath is the systemd user unit that runs the daemon. +func unitPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "systemd", "user", serviceUnit), nil +} + +// systemdUser reports whether a systemd user manager runs for this user; containers and WSL +// without systemd have none. +func systemdUser() bool { + return exec.Command("systemctl", "--user", "show-environment").Run() == nil +} + +func systemctl(args ...string) error { + out, err := exec.Command("systemctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, bytes.TrimSpace(out)) + } + return nil +} + +// installService runs the daemon as a systemd user service, which starts it at login. Without a +// systemd user manager it starts the daemon directly, and nothing starts it after a reboot. +func installService(root, bin string) error { + logFile := filepath.Join(root, "log", "daemon.log") + if err := os.MkdirAll(filepath.Dir(logFile), 0o700); err != nil { + return err + } + // Installs from before the CLI became flock-php left a bin/flock that shadows util-linux's. + _ = os.Remove(filepath.Join(root, "bin", "flock")) + if !systemdUser() { + serviceNote = "daemon runs in the background; without a systemd user session nothing starts it at login, so run `flock-php restart` after a reboot" + stopDaemon(root) + return startDetached(root, bin) + } + + path, err := unitPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(unitFile(root, bin, logFile)), 0o644); err != nil { + return err + } + // A daemon started without systemd (by an earlier install) would keep the service from starting. + if systemctl("--user", "is-active", "--quiet", serviceUnit) != nil { + stopDaemon(root) + } + for _, args := range [][]string{{"daemon-reload"}, {"enable", serviceUnit}, {"restart", serviceUnit}} { + if err := systemctl(append([]string{"--user"}, args...)...); err != nil { + return err + } + } + return nil +} + +// unitFile is the daemon's systemd unit. A second daemon exits with exitAlreadyRunning, which +// isn't worth restarting; KillMode=mixed lets the daemon stop its services (PostgreSQL, PHP-FPM, +// ...) itself before systemd kills what is left. +func unitFile(root, bin, logFile string) string { + return fmt.Sprintf(`# Managed by Flock (flock install) +[Unit] +Description=Flock daemon + +[Service] +ExecStart=%s daemon --log-file %s +Environment=%s %s +Restart=always +RestartPreventExitStatus=%d +KillMode=mixed + +[Install] +WantedBy=default.target +`, unitArg(bin), unitArg(logFile), unitEnv("PATH="+servicePath(root)), unitEnv("FLOCK_HOME="+root), exitAlreadyRunning) +} + +// unitArg quotes s as one word of ExecStart, where systemd expands specifiers (%) and variables ($). +func unitArg(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "%", "%%", "$", "$$").Replace(s) + `"` +} + +// unitEnv quotes an assignment of Environment=, where systemd expands specifiers (%). +func unitEnv(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "%", "%%").Replace(s) + `"` +} + +// servicePath is the daemon's PATH: Flock's shims and the default Node.js version's global packages +// first, in the order `flock install` puts them on the shell's PATH, then the system's directories. +func servicePath(root string) string { + return strings.Join([]string{filepath.Join(root, "bin"), node.GlobalBin(node.CurrentGlobalDir(root)), + "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"}, ":") +} + +// startDetached starts the daemon in a session of its own, so it outlives the terminal that ran flock. +func startDetached(root, bin string) error { + cmd := exec.Command(bin, "daemon", "--log-file", filepath.Join(root, "log", "daemon.log")) + cmd.Env = append(os.Environ(), "PATH="+servicePath(root), "FLOCK_HOME="+root) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// stopDaemon ends the running daemon and waits (up to 30 s, while it stops its services) until it +// is gone. +func stopDaemon(root string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + st, err := api.NewClient(paths.Socket(root)).Status(ctx) + if err != nil || st.PID <= 0 { + return + } + _ = syscall.Kill(st.PID, syscall.SIGTERM) + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline) && !processGone(st.PID); { + time.Sleep(100 * time.Millisecond) + } +} + +// processGone reports whether pid has ended. A zombie counts as gone: a detached daemon's parent +// can be a container's init that never reaps it. +func processGone(pid int) bool { + data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return true + } + _, after, _ := strings.Cut(string(data), ") ") + return strings.HasPrefix(after, "Z") +} + +// serviceInstalled reports whether the daemon's systemd unit is installed. +func serviceInstalled() bool { + path, err := unitPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +func uninstallService() error { + path, err := unitPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + if systemdUser() { + _ = systemctl("--user", "disable", "--now", serviceUnit) + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + if systemdUser() { + _ = systemctl("--user", "daemon-reload") + } + } + // Also a daemon started without systemd. + if root, err := paths.Root(); err == nil { + stopDaemon(root) + } + return nil +} + +// restartService has systemd restart the daemon, or restarts a daemon started without systemd. +func restartService() error { + root, err := paths.Root() + if err != nil { + return err + } + if serviceInstalled() && systemdUser() { + return systemctl("--user", "restart", serviceUnit) + } + bin := filepath.Join(root, "bin", paths.CLI()) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("the background daemon is not installed — run `flock-php install`") + } + stopDaemon(root) + return startDetached(root, bin) +} + +// privilegedFirst: `flock install` runs ensurePrivileged last, as the installed flock binary. +const privilegedFirst = false + +// installHelperBinary: Linux has no hosts helper. +func installHelperBinary(string) error { return nil } + +// ensurePrivileged checks what needs root (the unit that routes the TLD to Flock through +// systemd-resolved, the CA in the system's trust store, listening on ports below 1024) and does +// what is missing in one sudo run of privileged-setup. Browsers keep their own trust stores, +// which the user can change. +func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) error { + tld, dnsPort := s.cfg.TLD, s.cfg.Ports.DNS + var args []string + resolved := dns.ResolvedRunning() + if resolved && dns.ResolvedProblem(tld, dnsPort) != "" { + args = append(args, "--tld", tld, "--dns-port", strconv.Itoa(dnsPort)) + } + if !systemTrusts(ca.CertPath()) { + args = append(args, "--ca", ca.CertPath()) + } + lowPorts := os.Geteuid() != 0 && needsLowPorts(s.cfg.Ports.HTTP, s.cfg.Ports.HTTPS) && !canBindLowPorts(bin) + if lowPorts { + args = append(args, "--bind-low-ports", bin) + } + + if len(args) > 0 { + cmd.Printf("→ privileged-setup %s (as root: sudo may ask for your password)…\n", strings.Join(args, " ")) + if err := runPrivileged(cmd, bin, append([]string{"privileged-setup"}, args...)...); err != nil { + return fmt.Errorf("privileged setup: %w", err) + } + } + switch { + case !resolved: + cmd.Printf("! systemd-resolved isn't running: for *.%s, have your DNS resolver forward .%s to 127.0.0.1 port %d\n", tld, tld, dnsPort) + case resolvesToFlock(tld): + cmd.Printf("✓ *.%s resolves via Flock\n", tld) + if problem := dns.LinkProblem(tld); problem != "" { + cmd.Printf("! but %s\n", problem) + } + default: + cmd.Printf("! *.%s doesn't resolve via Flock yet: see `systemctl status %s` and `flock-php status`\n", tld, dns.Unit) + } + cmd.Println("✓ Flock CA is trusted") + trustInBrowsers(cmd, ca.CertPath()) + + if lowPorts { + // The capability applies to processes started after it was set. + if err := restartService(); err != nil { + return fmt.Errorf("restart the daemon: %w", err) + } + cmd.Printf("✓ the daemon may listen on ports %d and %d\n", s.cfg.Ports.HTTP, s.cfg.Ports.HTTPS) + } + return nil +} + +// resolvesToFlock looks up fresh names under tld through the system resolver, as browsers and PHP +// do, until one answers 127.0.0.1 (for up to ~10 s: the daemon may still be starting its DNS server). +func resolvesToFlock(tld string) bool { + for range 10 { + nonce := make([]byte, 6) + _, _ = rand.Read(nonce) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + addrs, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, "flock-install-"+hex.EncodeToString(nonce)+"."+tld) + cancel() + if err == nil && slices.Contains(addrs, "127.0.0.1") { + return true + } + time.Sleep(500 * time.Millisecond) + } + return false +} + +// runPrivileged runs a command as root: directly when flock already runs as root, else through +// sudo, which asks for the password at the terminal. +func runPrivileged(cmd *cobra.Command, exe string, args ...string) error { + run := exec.Command("sudo", append([]string{exe}, args...)...) + if os.Geteuid() == 0 { + run = exec.Command(exe, args...) + } + run.Stdin, run.Stdout, run.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() + return run.Run() +} + +// systemTrusts reports whether the system's trust store has the CA, the way Go, curl and PHP see it. +func systemTrusts(caPath string) bool { + data, err := os.ReadFile(caPath) + if err != nil { + return false + } + block, _ := pem.Decode(data) + if block == nil { + return false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + _, err = cert.Verify(x509.VerifyOptions{}) + return err == nil +} + +// needsLowPorts reports whether listening on one of ports takes CAP_NET_BIND_SERVICE here. +func needsLowPorts(ports ...int) bool { + start := 1024 + if data, err := os.ReadFile("/proc/sys/net/ipv4/ip_unprivileged_port_start"); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + start = n + } + } + return slices.Min(ports) < start +} + +// canBindLowPorts reports whether bin's file capabilities make CAP_NET_BIND_SERVICE permitted and +// effective. +func canBindLowPorts(bin string) bool { + buf := make([]byte, 24) + n, err := unix.Getxattr(bin, "security.capability", buf) + return err == nil && bindCapability(buf[:n]) +} + +// bindCapability reads a security.capability attribute (struct vfs_cap_data): the flags, then the +// permitted set's low 32 bits. +func bindCapability(data []byte) bool { + const flagsEffective = 0x1 // VFS_CAP_FLAGS_EFFECTIVE + if len(data) < 8 || binary.LittleEndian.Uint32(data[0:4])&flagsEffective == 0 { + return false + } + return binary.LittleEndian.Uint32(data[4:8])&(1< 0 { + if tld == "" { + return fmt.Errorf("--dns-port needs --tld") + } + removeLegacyDropIn(tld) + // NetworkManager must leave the link alone before the unit creates it again. + removeLegacyNMFile() + if err := os.MkdirAll(dns.NMConfDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(dns.NMFile(), []byte(dns.NMContent), 0o644); err != nil { + return err + } + _ = runTool("systemctl", "try-reload-or-restart", "NetworkManager.service") // fails without NetworkManager + if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { + return err + } + for _, args := range [][]string{{"daemon-reload"}, {"enable", dns.Unit}, {"restart", dns.Unit}} { + if err := runTool("systemctl", args...); err != nil { + return err + } + } + } + if caPath != "" { + anchor, update, err := caAnchor() + if err != nil { + return err + } + data, err := os.ReadFile(caPath) + if err != nil { + return err + } + if err := os.WriteFile(anchor, data, 0o644); err != nil { + return err + } + if err := runTool(update[0], update[1:]...); err != nil { + return err + } + } + if lowPortsBin != "" { + if !filepath.IsAbs(lowPortsBin) { + return fmt.Errorf("invalid --bind-low-ports %q", lowPortsBin) + } + return runTool("setcap", "cap_net_bind_service=+ep", lowPortsBin) + } + return nil + }, + } + + cmd.Flags().StringVar(&tld, "tld", "", "") + cmd.Flags().IntVar(&dnsPort, "dns-port", 0, "") + cmd.Flags().StringVar(&caPath, "ca", "", "") + cmd.Flags().StringVar(&lowPortsBin, "bind-low-ports", "", "") + cmd.Flags().BoolVar(&remove, "remove", false, "") + + return cmd +} + +func newUninstallCommand() *cobra.Command { + var purge bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove Flock: daemon, PATH entry, DNS/CA trust and downloaded files (keeps your sites' settings and databases unless --purge)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := load() + if err != nil { + return err + } + purge = decidePurge(s.root, purge) + if err := uninstallService(); err != nil { + return err + } + cmd.Println("✓ background daemon removed") + + switch removed, err := removeFromShellProfiles(); { + case err != nil: + cmd.Printf("! could not update your shell profile (%v) — remove the PATH line marked %q\n", err, profileMarker) + case removed: + cmd.Println("✓ removed Flock from your PATH (open a new terminal)") + } + + caPath := filepath.Join(s.root, "certs", "ca.crt") + untrustInBrowsers(caPath) + self, _ := os.Executable() + if err := runPrivileged(cmd, self, "privileged-setup", "--remove", "--tld", s.cfg.TLD); err != nil { + return err + } + cmd.Printf("✓ removed %s and the Flock CA's trust\n", dns.ResolvedFile()) + + return finishUninstall(cmd, s.root, purge) + }, + } + uninstallFlags(cmd, &purge) + return cmd +} + +// appDirs: Flock has no app on Linux. +func appDirs() []string { return nil } + +// removeAfterExit is never needed: Linux deletes running programs. +func removeAfterExit(string) bool { return false } diff --git a/internal/cli/install_linux_test.go b/internal/cli/install_linux_test.go new file mode 100644 index 00000000..6200c639 --- /dev/null +++ b/internal/cli/install_linux_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "encoding/binary" + "os" + "os/exec" + "slices" + "strings" + "testing" + "time" +) + +// Paths with spaces, quotes, % and $ reach the daemon as they are. +func TestUnitFileQuotesPaths(t *testing.T) { + unit := unitFile(`/home/o"b/50% $x/Flock`, `/home/o"b/50% $x/Flock/bin/flock`, "/l/daemon.log") + want := `ExecStart="/home/o\"b/50%% $$x/Flock/bin/flock" daemon --log-file "/l/daemon.log"` + if !strings.Contains(unit, want+"\n") { + t.Fatalf("unit:\n%s\nwant the line\n%s", unit, want) + } + if !strings.Contains(unit, `"FLOCK_HOME=/home/o\"b/50%% $x/Flock"`) { + t.Fatalf("unit has no FLOCK_HOME:\n%s", unit) + } +} + +// The daemon finds the same commands as a terminal: Flock's shims, then global npm packages. +func TestServicePathMatchesTheShellPath(t *testing.T) { + got := strings.Split(servicePath("/f"), ":") + if !slices.Equal(got[:2], []string{"/f/bin", "/f/node-global/current/bin"}) || !slices.Contains(got, "/usr/bin") { + t.Fatalf("PATH %v", got) + } +} + +// A detached daemon that has exited counts as gone even when nobody reaps it. +func TestProcessGoneTreatsZombiesAsGone(t *testing.T) { + if processGone(os.Getpid()) { + t.Fatal("this process is gone") + } + cmd := exec.Command("true") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Wait() + for deadline := time.Now().Add(5 * time.Second); !processGone(cmd.Process.Pid); time.Sleep(10 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("an exited, unreaped process isn't gone") + } + } +} + +func TestBindCapability(t *testing.T) { + attr := func(flags, permitted uint32) []byte { + b := make([]byte, 20) + binary.LittleEndian.PutUint32(b[0:4], 0x02000000|flags) // VFS_CAP_REVISION_2 + binary.LittleEndian.PutUint32(b[4:8], permitted) + return b + } + for _, c := range []struct { + data []byte + want bool + }{ + {attr(1, 1<<10), true}, // cap_net_bind_service=+ep + {attr(0, 1<<10), false}, // +p only: not raised at exec + {attr(1, 1<<12), false}, // another capability + {attr(1, 1<<10|1<<12)[:4], false}, // truncated + {nil, false}, + } { + if got := bindCapability(c.data); got != c.want { + t.Errorf("bindCapability(%x) = %v, want %v", c.data, got, c.want) + } + } +} diff --git a/internal/cli/install_unix.go b/internal/cli/install_unix.go new file mode 100644 index 00000000..3d3bf1c0 --- /dev/null +++ b/internal/cli/install_unix.go @@ -0,0 +1,113 @@ +//go:build !windows + +package cli + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +const profileMarker = "# Flock (added by `flock install`)" + +func exeName(name string) string { return name } + +func writeShims(root, bin string) error { + dir := filepath.Join(root, "bin") + for _, tool := range tools { + script := fmt.Sprintf("#!/bin/sh\nexec %q exec %s \"$@\"\n", bin, tool) + if err := os.WriteFile(filepath.Join(dir, tool), []byte(script), 0o755); err != nil { + return err + } + } + return nil +} + +// addToShellPath prepends dirs, in this order, in the shell profiles (the first one always, the +// others if present): a marked export line, appended once and rewritten when the directories change. +func addToShellPath(dirs ...string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + quoted := make([]string, len(dirs)) + for i, dir := range dirs { + quoted[i] = strconv.Quote(dir) + } + export := fmt.Sprintf("export PATH=%s:\"$PATH\"", strings.Join(quoted, ":")) + for _, name := range shellProfiles { + path := filepath.Join(home, name) + data, err := os.ReadFile(path) + if err != nil && name != shellProfiles[0] { + continue + } + lines := strings.Split(string(data), "\n") + if i := slices.Index(lines, profileMarker); i >= 0 { + if i+1 < len(lines) && lines[i+1] == export { + continue + } + if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "export PATH=") { + lines[i+1] = export + } else { + lines = slices.Insert(lines, i+1, export) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + return err + } + continue + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + _, err = f.WriteString("\n" + profileMarker + "\n" + export + "\n") + f.Close() + if err != nil { + return err + } + } + return nil +} + +// removeFromShellProfiles takes out what addToShellPath added: the marker, its export line and +// the blank line before them. It reports whether a profile had them. +func removeFromShellProfiles() (bool, error) { + home, err := os.UserHomeDir() + if err != nil { + return false, err + } + removed := false + for _, name := range shellProfiles { + path := filepath.Join(home, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + lines := strings.Split(string(data), "\n") + i := slices.Index(lines, profileMarker) + if i < 0 { + continue + } + end := i + 1 + if end < len(lines) && strings.HasPrefix(lines[end], "export PATH=") { + end++ + } + if i > 0 && lines[i-1] == "" { + i-- + } + lines = slices.Delete(lines, i, end) + info, err := os.Stat(path) + if err != nil { + return removed, err + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil { + return removed, err + } + removed = true + } + return removed, nil +} diff --git a/internal/cli/install_unix_test.go b/internal/cli/install_unix_test.go new file mode 100644 index 00000000..6eed4b89 --- /dev/null +++ b/internal/cli/install_unix_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Uninstalling leaves the shell profiles as they were before `flock install`. +func TestShellProfileRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + profile, optional := filepath.Join(home, shellProfiles[0]), filepath.Join(home, shellProfiles[1]) + before := "export EDITOR=vim\nalias ll='ls -l'\n" + if err := os.WriteFile(profile, []byte(before), 0o600); err != nil { + t.Fatal(err) + } + + if err := addToShellPath("/f/bin", "/f/node-global/current/bin"); err != nil { + t.Fatal(err) + } + // A second install with other directories rewrites the line instead of adding another. + if err := addToShellPath("/f/bin"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(profile) + if strings.Count(string(data), profileMarker) != 1 || !strings.Contains(string(data), `export PATH="/f/bin":"$PATH"`) { + t.Fatalf("profile after install:\n%s", data) + } + + removed, err := removeFromShellProfiles() + if err != nil || !removed { + t.Fatalf("removed %v, err %v", removed, err) + } + data, _ = os.ReadFile(profile) + if string(data) != before { + t.Fatalf("profile after uninstall:\n%q\nwant\n%q", data, before) + } + if info, _ := os.Stat(profile); info.Mode().Perm() != 0o600 { + t.Fatalf("permissions changed to %v", info.Mode().Perm()) + } + // The optional profile didn't exist, and none is created. + if _, err := os.Stat(optional); !os.IsNotExist(err) { + t.Fatalf("%s: %v", shellProfiles[1], err) + } + + if removed, err := removeFromShellProfiles(); err != nil || removed { + t.Fatalf("second removal: removed %v, err %v", removed, err) + } +} diff --git a/internal/cli/status.go b/internal/cli/status.go index 79aa4202..33a62ddb 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -32,6 +32,9 @@ func newStatusCommand() *cobra.Command { switch { case st.DNS.Wildcard: fmt.Fprintf(out, "dns *.%s → 127.0.0.1, any subdomain (server on %s)\n", st.DNS.TLD, st.DNS.Addr) + if st.DNS.WildcardProblem != "" { + fmt.Fprintf(out, " ! %s\n", st.DNS.WildcardProblem) + } case st.DNS.TLD != "" && runtime.GOOS == "windows": fmt.Fprintf(out, "dns site domains and extra domains → 127.0.0.1 (hosts file); other *.%s subdomains don't resolve: %s\n", st.DNS.TLD, st.DNS.WildcardProblem) diff --git a/internal/cli/update_linux.go b/internal/cli/update_linux.go new file mode 100644 index 00000000..c2d041cd --- /dev/null +++ b/internal/cli/update_linux.go @@ -0,0 +1,8 @@ +package cli + +import "errors" + +// openUpdateLink: Flock has no app on Linux to update it. +func openUpdateLink() error { + return errors.New("there is no Flock app on Linux: get the new flock-php from https://github.com/flock-php/flock/releases and run its `flock-php install`") +} diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index faaba336..06d7a578 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -37,8 +37,10 @@ func (d *daemon) recheckWildcard() { // checkWildcard looks up a random name under the TLD through the system resolver, the way browsers // and PHP do, and checks that the query reached Flock's DNS server. On Windows that only happens -// when the NRPT rule from `flock install` is in effect, on macOS when /etc/resolver/ is; -// Group Policy, VPN clients and security software can override either without Flock noticing otherwise. +// when the NRPT rule from `flock install` is in effect, on macOS when /etc/resolver/ is, on +// Linux when the unit from `flock install` gives systemd-resolved Flock's link (or another resolver forwards +// the TLD); Group Policy, VPN clients and security software can override them without Flock +// noticing otherwise. func (d *daemon) checkWildcard() { if !d.checking.CompareAndSwap(false, true) { return @@ -64,12 +66,15 @@ func (d *daemon) checkWildcard() { state.problem = fmt.Sprintf("macOS doesn't send .%s lookups to Flock (a VPN or DNS filter may be overriding /etc/resolver)", cfg.TLD) } state.ok = state.problem == "" - case runtime.GOOS != "windows": - state.ok = true // nothing to check elsewhere (Flock runs on macOS and Windows) case reaches(srv, cfg.TLD): state.ok = true - default: + if runtime.GOOS == "linux" { + state.problem = dns.LinkProblem(cfg.TLD) // works now, but won't for long + } + case runtime.GOOS == "windows": state.problem = windowsWildcardProblem(cfg.TLD) + default: + state.problem = linuxWildcardProblem(cfg.TLD, cfg.Ports.DNS) } d.mu.Lock() @@ -93,7 +98,8 @@ func lookupReaches(srv *dns.Server, tld string) bool { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // PreferGo false: ask the system resolver (which applies NRPT rules on Windows, /etc/resolver - // on macOS), as browsers and PHP do. On macOS that is libSystem's, with or without cgo. + // on macOS, systemd-resolved's routing domains on Linux), as browsers and PHP do. On macOS that + // is libSystem's, with or without cgo. addrs, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, name) return seen() && err == nil && slices.Contains(addrs, "127.0.0.1") } @@ -108,3 +114,10 @@ func windowsWildcardProblem(tld string) string { return fmt.Sprintf("Windows doesn't send .%s lookups to Flock (a VPN or security software may be overriding DNS)", tld) } } + +func linuxWildcardProblem(tld string, port int) string { + if problem := dns.ResolvedProblem(tld, port); problem != "" { + return problem + } + return fmt.Sprintf("Linux doesn't send .%s lookups to Flock (see `systemctl status %s`; a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld, dns.Unit) +} diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index e8091d7e..cf2567ab 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -25,12 +25,14 @@ func init() { windowsDNSPort = ln.LocalAddr().(*net.UDPAddr).Port _ = ln.Close() - // The real /etc/resolver points at the real daemon, never at a test daemon's port. + // The real /etc/resolver and systemd unit point at the real daemon, never at a + // test daemon's port. dir, err := os.MkdirTemp("", "flock-resolver-") if err != nil { panic(err) } dns.ResolverDir = dir + dns.UnitDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { @@ -110,11 +112,26 @@ func TestWildcardCheckReportsWhyItDoesNotWork(t *testing.T) { client := startDaemon(t, root) st := waitStatus(t, client, func(st api.Status) bool { return st.DNS.Wildcard || st.DNS.WildcardProblem != "" }) - if runtime.GOOS == "windows" { + if runtime.GOOS != "darwin" { // Lookups never reach the test daemon's DNS server. if st.DNS.Wildcard || st.DNS.WildcardProblem == "" { t.Fatalf("want a problem, got %+v", st.DNS) } + if runtime.GOOS == "windows" { + return + } + // Linux: they do once systemd-resolved (or another resolver) sends them to Flock. + arrives.Store(true) + reloadUntil(t, client, func(st api.Status) bool { return st.DNS.Wildcard && st.DNS.WildcardProblem == "" }) + + // With Flock's unit installed but its link gone, they still arrive until resolved restarts. + if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(cfg.TLD, cfg.Ports.DNS)), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Remove(dns.ResolvedFile()) }) + reloadUntil(t, client, func(st api.Status) bool { + return st.DNS.Wildcard && strings.Contains(st.DNS.WildcardProblem, "flock0 is down") + }) return } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go new file mode 100644 index 00000000..480cb2d5 --- /dev/null +++ b/internal/dns/resolved.go @@ -0,0 +1,161 @@ +package dns + +import ( + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// systemd-resolved sends a domain's lookups to the DNS servers of the link that claims it as a +// routing domain (~test). Flock gets a link of its own for that: Link, a dummy interface a oneshot +// system unit sets up at boot. A resolved.conf drop-in would put Flock among the global DNS +// servers instead, and when there are others (DNS= in resolved.conf), resolved switches to one of +// them after Flock refuses a name outside the TLD, and the TLD stops resolving. + +// UnitDir is where `flock install` writes the unit. Tests move it. +var UnitDir = "/etc/systemd/system" + +// ResolvedDir is where systemd-resolved reads drop-ins, where installs before Link kept one. Tests move it. +var ResolvedDir = "/etc/systemd/resolved.conf.d" + +// ResolvedRuntimeDir exists while systemd-resolved runs. Tests move it. +var ResolvedRuntimeDir = "/run/systemd/resolve" + +// NMConfDir is where NetworkManager reads drop-ins. `flock install` writes one even before +// NetworkManager is installed, so Link stays out of its hands. Tests move it. +var NMConfDir = "/etc/NetworkManager/conf.d" + +const ( + // Unit is the system unit that sets up Link. + Unit = "flock-dns.service" + // Link is the dummy interface whose DNS settings route the TLD to Flock. + Link = "flock0" + // linkAddress is there because resolved ignores links without a routable address. 198.18.0.0/15 + // is set aside for benchmarks, so no network uses it. + linkAddress = "198.18.53.53/32" +) + +// ResolvedFile is the unit `flock install` writes on Linux to send the TLD to Flock. +func ResolvedFile() string { return filepath.Join(UnitDir, Unit) } + +// NMFile is the NetworkManager drop-in that keeps it off Link. NetworkManager reads drop-ins in +// name order and a later unmanaged-devices= replaces the list, so the name sorts last; LinkProblem +// notices when another drop-in wins anyway. +func NMFile() string { return filepath.Join(NMConfDir, "zz-flock.conf") } + +// LegacyNMFile is the name the drop-in had before it sorted last. +func LegacyNMFile() string { return filepath.Join(NMConfDir, "90-flock.conf") } + +// NMContent makes Link unmanaged. NetworkManager otherwise adopts it as an external device and +// flushes its address when networking goes down (nmcli networking off, suspend), after which +// systemd-resolved drops the link's scope and the TLD stops resolving. += adds to the devices +// other drop-ins leave unmanaged instead of replacing them. +const NMContent = "# Managed by Flock\n[keyfile]\nunmanaged-devices+=interface-name:" + Link + "\n" + +// LegacyDropIn is the resolved.conf drop-in installs before Link wrote for tld. +func LegacyDropIn(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } + +// ResolvedContent is the unit that sends tld lookups to Flock's DNS server on port. A link with +// only a routing domain and no explicit default route gets no other lookups. resolved learns of +// the new link through netlink, a moment after ip created it, hence the retries. +func ResolvedContent(tld string, port int) string { + return fmt.Sprintf(`# Managed by Flock +[Unit] +Description=Flock: send *.%[1]s lookups to Flock's DNS server +Wants=systemd-resolved.service +After=systemd-resolved.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=-ip link delete %[3]s +ExecStart=ip link add %[3]s type dummy +ExecStart=ip address add %[4]s dev %[3]s +ExecStart=ip link set %[3]s up +ExecStart=sh -c 'for i in 1 2 3 4 5 6 7 8 9 10; do resolvectl dns %[3]s 127.0.0.1:%[2]d && resolvectl domain %[3]s "~%[1]s" && exit 0; sleep 0.5; done; exit 1' +ExecStop=ip link delete %[3]s + +[Install] +WantedBy=multi-user.target +`, tld, port, Link, linkAddress) +} + +// ResolvedRunning reports whether systemd-resolved runs on this machine. +func ResolvedRunning() bool { + _, err := os.Stat(ResolvedRuntimeDir) + return err == nil +} + +// linkUp reports whether Link exists with its address. Tests replace it. +var linkUp = func() bool { + iface, err := net.InterfaceByName(Link) + if err != nil { + return false + } + addrs, _ := iface.Addrs() + for _, a := range addrs { + if a.String() == linkAddress { + return true + } + } + return false +} + +// nmManages reports whether NetworkManager manages Link. Without NetworkManager it doesn't. Tests +// replace it. +var nmManages = func() bool { + out, err := exec.Command("nmcli", "-t", "-f", "DEVICE,STATE", "device").Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if device, state, _ := strings.Cut(line, ":"); device == Link { + return state != "unmanaged" + } + } + return false +} + +// LinkProblem explains what will stop Link from sending tld lookups to Flock, even while they still +// arrive: systemd-resolved keeps a link's scope after it lost its address until resolved restarts. +// It returns "" when the link is fine, or isn't Flock's way of resolving the TLD on this machine. +func LinkProblem(tld string) string { + if data, _ := os.ReadFile(ResolvedFile()); !strings.HasPrefix(string(data), "# Managed by Flock") { + return "" + } + if !linkUp() { + return fmt.Sprintf("%s is down or lost its address, so systemd-resolved stops sending .%s lookups to Flock (at the latest when it restarts) — run `sudo systemctl restart %s`", Link, tld, Unit) + } + if nmManages() { + return fmt.Sprintf("NetworkManager manages %s, so it takes the link's address when networking goes down — "+ + "a drop-in read after %s probably sets unmanaged-devices= without it (see `NetworkManager --print-config`)", Link, NMFile()) + } + return "" +} + +// ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on +// port, or returns "" when the unit, the NetworkManager drop-in and Link are in place. +func ResolvedProblem(tld string, port int) string { + if !ResolvedRunning() { + return fmt.Sprintf("systemd-resolved isn't running, so Linux doesn't send .%s lookups to Flock — "+ + "have your DNS resolver (dnsmasq, NetworkManager, ...) forward .%s to 127.0.0.1 port %d", tld, tld, port) + } + path := ResolvedFile() + data, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + return fmt.Sprintf("%s is missing, so systemd-resolved doesn't send .%s lookups to Flock — run `flock-php install`", path, tld) + case err != nil: + return fmt.Sprintf("can't read %s (%v) — run `flock-php install`", path, err) + case string(data) != ResolvedContent(tld, port): + return fmt.Sprintf("%s doesn't send .%s lookups to Flock's DNS server on port %d — run `flock-php install`", path, tld, port) + } + if data, _ := os.ReadFile(NMFile()); string(data) != NMContent { + return fmt.Sprintf("%s is missing, so NetworkManager may take %s's address — run `flock-php install`", NMFile(), Link) + } + return LinkProblem(tld) +} diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go new file mode 100644 index 00000000..cb7e5e0f --- /dev/null +++ b/internal/dns/resolved_test.go @@ -0,0 +1,93 @@ +package dns + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedProblem(t *testing.T) { + oldUnit, oldRuntime, oldNM, oldUp, oldManages := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages + UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() + up, managed := true, false + linkUp = func() bool { return up } + nmManages = func() bool { return managed } + t.Cleanup(func() { + UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages = oldUnit, oldRuntime, oldNM, oldUp, oldManages + }) + + // Without Flock's unit the link isn't how the TLD resolves here. + up = false + if p := LinkProblem("test"); p != "" { + t.Fatalf("no unit: %q", p) + } + up = true + + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "systemd-resolved isn't running") || !strings.Contains(p, "port 53535") { + t.Fatalf("without systemd-resolved: %q", p) + } + + ResolvedRuntimeDir = t.TempDir() + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "missing") || !strings.Contains(p, "flock-php install") { + t.Fatalf("missing unit: %q", p) + } + + if err := os.WriteFile(ResolvedFile(), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { + t.Fatal(err) + } + // Installs from before the NetworkManager drop-in run the setup again. + if p := ResolvedProblem("test", 53535); !strings.Contains(p, NMFile()) || !strings.Contains(p, "flock-php install") { + t.Fatalf("missing NetworkManager drop-in: %q", p) + } + if err := os.WriteFile(NMFile(), []byte(NMContent), 0o644); err != nil { + t.Fatal(err) + } + if p := ResolvedProblem("test", 53535); p != "" { + t.Fatalf("current unit: %q", p) + } + // Something took the link down or flushed its address after setup. + up = false + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "flock0 is down") || !strings.Contains(p, "systemctl restart flock-dns.service") || p != LinkProblem("test") { + t.Fatalf("link without its address: %q", p) + } + up = true + // A drop-in read after Flock's replaced its unmanaged-devices list. + managed = true + if p := LinkProblem("test"); !strings.Contains(p, "NetworkManager manages flock0") || !strings.Contains(p, "zz-flock.conf") { + t.Fatalf("managed by NetworkManager: %q", p) + } + managed = false + if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock-php install") { + t.Fatalf("other port: %q", p) + } + if got := ResolvedFile(); got != filepath.Join(UnitDir, "flock-dns.service") { + t.Fatalf("file: %q", got) + } +} + +// The TLD goes to Flock through a link of its own, never through resolved's global servers. +func TestResolvedContentRoutesTheTLDThroughFlocksLink(t *testing.T) { + unit := ResolvedContent("test", 53535) + for _, want := range []string{ + "ExecStart=ip link add flock0 type dummy", + "ExecStart=ip address add 198.18.53.53/32 dev flock0", + `resolvectl dns flock0 127.0.0.1:53535 && resolvectl domain flock0 "~test"`, + "ExecStop=ip link delete flock0", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, want) { + t.Errorf("missing %q in:\n%s", want, unit) + } + } + // A literal % would be a systemd specifier. + if strings.Contains(unit, "%") || strings.Contains(unit, "[Resolve]") { + t.Errorf("unit:\n%s", unit) + } + if !strings.Contains(NMContent, "[keyfile]\nunmanaged-devices+=interface-name:flock0\n") { + t.Errorf("NetworkManager drop-in:\n%s", NMContent) + } + if got := LegacyDropIn("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + t.Errorf("legacy drop-in: %q", got) + } +} diff --git a/internal/mcpclients/mcpclients.go b/internal/mcpclients/mcpclients.go index 4ef44037..243b9750 100644 --- a/internal/mcpclients/mcpclients.go +++ b/internal/mcpclients/mcpclients.go @@ -16,6 +16,8 @@ import ( "path/filepath" "runtime" "strings" + + "flock/internal/paths" ) // Name is the server's name in the clients' configs. @@ -79,10 +81,7 @@ func DefaultEnv(root string) (Env, error) { // Executable is the flock binary clients should start: the one `flock install` copied into // /bin, which stays put across updates, or else the running one. func Executable(root string) string { - exe := "flock" - if runtime.GOOS == "windows" { - exe += ".exe" - } + exe := paths.CLI() if installed := filepath.Join(root, "bin", exe); fileExists(installed) { return installed } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index aa3a211e..b56c3f8f 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" ) // Root returns Flock's data directory: @@ -23,6 +24,19 @@ func Root() (string, error) { return filepath.Join(base, "Flock"), nil } +// CLI is the flock executable's name in /bin, the directory `flock install` puts first on +// PATH. On Linux it is flock-php: util-linux's flock(1) is in /usr/bin everywhere, and scripts +// that lock with it would get Flock's CLI instead. +func CLI() string { + switch runtime.GOOS { + case "windows": + return "flock.exe" + case "linux": + return "flock-php" + } + return "flock" +} + // Socket is the daemon's API socket. AF_UNIX works on macOS and Windows 10+. func Socket(root string) string { return filepath.Join(root, "flockd.sock") diff --git a/internal/ports/holders_linux_test.go b/internal/ports/holders_linux_test.go new file mode 100644 index 00000000..fd37d563 --- /dev/null +++ b/internal/ports/holders_linux_test.go @@ -0,0 +1,25 @@ +package ports + +import ( + "net" + "os" + "testing" +) + +// Without lsof, /proc still names the process listening on a port. +func TestProcListenersFindsThisProcess(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + found := procListeners(port) + if len(found) != 1 || found[0].PID != os.Getpid() || found[0].Name == "" { + t.Fatalf("procListeners(%d) = %+v, want this process (pid %d)", port, found, os.Getpid()) + } + if found := procListeners(port + 1); len(found) != 0 && found[0].PID == os.Getpid() { + t.Fatalf("procListeners on another port found this process: %+v", found) + } +} diff --git a/internal/ports/holders_unix.go b/internal/ports/holders_unix.go index 1422e001..56e0cdce 100644 --- a/internal/ports/holders_unix.go +++ b/internal/ports/holders_unix.go @@ -3,11 +3,69 @@ package ports import ( + "bufio" "os" + "path/filepath" "strconv" + "strings" ) -func listeners(port int) []listener { return lsofListeners(port) } +// listeners asks /proc first: lsof isn't installed everywhere on Linux (containers, minimal +// systems), and without it Flock's own daemon would look like another program. +func listeners(port int) []listener { + if found := procListeners(port); len(found) > 0 { + return found + } + return lsofListeners(port) +} + +// procListeners finds the listening TCP sockets on port in /proc/net and the processes holding +// them through /proc//fd. Like lsof, it only sees the user's own processes without root; a +// socket whose process it can't see is still reported, without a PID. +func procListeners(port int) []listener { + inodes := map[string]bool{} + for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + f, err := os.Open(table) + if err != nil { + continue + } + lines := bufio.NewScanner(f) + lines.Scan() // the header + for lines.Scan() { + // sl local_address rem_address st ... inode: "0: 0100007F:1F90 00000000:0000 0A ..." + fields := strings.Fields(lines.Text()) + if len(fields) < 10 || fields[3] != "0A" { // 0A: LISTEN + continue + } + _, hexPort, _ := strings.Cut(fields[1], ":") + if p, err := strconv.ParseInt(hexPort, 16, 32); err == nil && int(p) == port { + inodes[fields[9]] = true + } + } + f.Close() + } + if len(inodes) == 0 { + return nil + } + + var found []listener + fds, _ := filepath.Glob("/proc/[0-9]*/fd/*") + for _, fd := range fds { + target, err := os.Readlink(fd) + inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") + if err != nil || !inodes[inode] { + continue + } + delete(inodes, inode) + pid, _ := strconv.Atoi(strings.Split(fd, "/")[2]) + name, _ := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/comm") + found = append(found, listener{PID: pid, Name: strings.TrimSpace(string(name))}) + } + for range inodes { + found = append(found, listener{}) // another user's process + } + return found +} func executable(pid int) string { path, _ := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe") diff --git a/internal/ports/ports.go b/internal/ports/ports.go index 51466730..8f4e3504 100644 --- a/internal/ports/ports.go +++ b/internal/ports/ports.go @@ -2,20 +2,24 @@ package ports import ( + "errors" "fmt" "net" "runtime" "slices" "strconv" "strings" + "syscall" "unicode" ) -// Free reports whether a TCP listener can be opened on host:port right now. +// Free reports whether a TCP listener can be opened on host:port right now. On Linux, permission +// denied counts as free: it is a port below 1024 without CAP_NET_BIND_SERVICE (which `flock +// install` gives the daemon), and says nothing about another program holding it. func Free(host string, port int) bool { ln, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { - return false + return runtime.GOOS == "linux" && errors.Is(err, syscall.EACCES) } _ = ln.Close() return true @@ -141,7 +145,7 @@ func Own(h Holder, root string) bool { if h.Path != "" { name = strings.TrimSuffix(strings.ToLower(baseName(h.Path)), ".exe") } - return name == "flock" || name == "flockd" + return name == "flock" || name == "flock-php" || name == "flockd" } // within reports whether path is inside dir, comparing case-insensitively (macOS' and Windows' diff --git a/packaging/aur/flock-php-bin/PKGBUILD b/packaging/aur/flock-php-bin/PKGBUILD new file mode 100644 index 00000000..f1c97326 --- /dev/null +++ b/packaging/aur/flock-php-bin/PKGBUILD @@ -0,0 +1,28 @@ +# Flock's Linux CLI from the release archives, as /usr/bin/flock-php (util-linux owns flock). +# `flock-php install` sets Flock up for the user and copies it to ~/.config/Flock/bin. +# +# For a new release: set _tag, then `updpkgsums && makepkg --printsrcinfo > .SRCINFO`, and push this +# directory to ssh://aur@aur.archlinux.org/flock-php-bin.git. + +pkgname=flock-php-bin +_tag=1.0.0-beta.10 +pkgver=${_tag/-/} +pkgrel=1 +pkgdesc='Local PHP development: sites on .test with HTTPS, PHP versions, databases, mail and dumps' +arch=(x86_64 aarch64) +url='https://github.com/flock-php/flock' +license=(AGPL-3.0-only) +optdepends=('nss: trust Flock'"'"'s certificate authority in Chrome and Firefox (certutil)') +provides=(flock-php) +conflicts=(flock-php) +install=flock-php-bin.install +options=(!strip !debug) +source_x86_64=("$url/releases/download/v$_tag/flock-$_tag-linux-x64.tar.gz") +source_aarch64=("$url/releases/download/v$_tag/flock-$_tag-linux-arm64.tar.gz") +sha256sums_x86_64=(SKIP) +sha256sums_aarch64=(SKIP) + +package() { + install -Dm755 flock-php "$pkgdir/usr/bin/flock-php" + install -Dm644 -t "$pkgdir/usr/share/licenses/$pkgname" LICENSE NOTICE THIRD_PARTY_NOTICES +} diff --git a/packaging/aur/flock-php-bin/flock-php-bin.install b/packaging/aur/flock-php-bin/flock-php-bin.install new file mode 100644 index 00000000..7b2fd60e --- /dev/null +++ b/packaging/aur/flock-php-bin/flock-php-bin.install @@ -0,0 +1,11 @@ +post_install() { + echo '==> Set Flock up for your user (not as root): flock-php install' +} + +post_upgrade() { + echo '==> Update your Flock to this version (not as root): flock-php install' +} + +pre_remove() { + echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock-php uninstall' +} diff --git a/tools/linux-e2e/Containerfile.arch b/tools/linux-e2e/Containerfile.arch new file mode 100644 index 00000000..b4aabd2e --- /dev/null +++ b/tools/linux-e2e/Containerfile.arch @@ -0,0 +1,9 @@ +FROM docker.io/library/archlinux:latest +RUN pacman -Syu --noconfirm --needed systemd systemd-resolvconf sudo curl tar nss libcap iproute2 && pacman -Rdd --noconfirm dbus-broker-units && pacman -S --noconfirm --asdeps dbus-daemon-units && yes | pacman -Scc +RUN systemd-sysusers && systemctl mask systemd-firstboot.service systemd-homed.service \ + && mkdir -p /etc/systemd/resolved.conf.d && printf "[Resolve]\nDNS=9.9.9.9 1.1.1.1\n" > /etc/systemd/resolved.conf.d/upstream.conf \ + && systemctl enable systemd-resolved \ + && useradd -m -s /bin/bash dev && echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev \ + && mkdir -p /var/lib/systemd/linger && touch /var/lib/systemd/linger/dev +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/tools/linux-e2e/Containerfile.fedora b/tools/linux-e2e/Containerfile.fedora new file mode 100644 index 00000000..b7ba1082 --- /dev/null +++ b/tools/linux-e2e/Containerfile.fedora @@ -0,0 +1,9 @@ +FROM registry.fedoraproject.org/fedora:latest +RUN dnf install -y systemd systemd-resolved sudo curl tar nss-tools libcap iproute procps-ng && dnf clean all +RUN systemd-sysusers && systemctl mask systemd-firstboot.service systemd-homed.service \ + && mkdir -p /etc/systemd/resolved.conf.d && printf "[Resolve]\nDNS=9.9.9.9 1.1.1.1\n" > /etc/systemd/resolved.conf.d/upstream.conf \ + && systemctl enable systemd-resolved \ + && useradd -m -s /bin/bash dev && echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev \ + && mkdir -p /var/lib/systemd/linger && touch /var/lib/systemd/linger/dev +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/tools/linux-e2e/Containerfile.ubuntu b/tools/linux-e2e/Containerfile.ubuntu new file mode 100644 index 00000000..fa054a62 --- /dev/null +++ b/tools/linux-e2e/Containerfile.ubuntu @@ -0,0 +1,9 @@ +FROM docker.io/library/ubuntu:24.04 +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends systemd systemd-sysv systemd-resolved dbus dbus-user-session libpam-systemd sudo curl ca-certificates libnss3-tools libcap2-bin iproute2 && rm -rf /var/lib/apt/lists/* +RUN systemd-sysusers && systemctl mask systemd-firstboot.service systemd-homed.service \ + && mkdir -p /etc/systemd/resolved.conf.d && printf "[Resolve]\nDNS=9.9.9.9 1.1.1.1\n" > /etc/systemd/resolved.conf.d/upstream.conf \ + && systemctl enable systemd-resolved \ + && useradd -m -s /bin/bash dev && echo "dev ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/dev \ + && mkdir -p /var/lib/systemd/linger && touch /var/lib/systemd/linger/dev +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] diff --git a/tools/linux-e2e/run.sh b/tools/linux-e2e/run.sh new file mode 100755 index 00000000..93637d50 --- /dev/null +++ b/tools/linux-e2e/run.sh @@ -0,0 +1,222 @@ +#!/bin/bash +# End-to-end test of Flock on Linux: in a rootless Podman container per distribution, booting +# systemd (systemd-resolved, a user session with linger, NetworkManager on Ubuntu and Fedora), it +# installs Flock with install.sh from archives built from this tree, checks the setup, NetworkManager, +# drift, a reboot, and uninstall --purge. +# +# tools/linux-e2e/run.sh [--from ] [arch] [ubuntu] [fedora] +# +# --from installs that ref first and upgrades from it. resolved.conf has global DNS servers +# (DNS=9.9.9.9 1.1.1.1), the setup that a TLD in the global scope can't handle. +# +# Needs podman and the dummy kernel module, which a rootless container can't load itself: +# sudo modprobe dummy numdummies=0 +set -u +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) + +from="" +if [ "${1:-}" = --from ]; then + from=$2 + shift 2 +fi +distros=${*:-arch ubuntu fedora} + +lsmod | grep -qw dummy || { + echo "the dummy kernel module isn't loaded: sudo modprobe dummy numdummies=0" >&2 + exit 2 +} + +# The test's files, mounted at /test: install.sh, the archives, and a curl that serves the +# release downloads from them (everything else goes to the real curl). +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# The container's user reads it through the mount (mktemp makes it private). +chmod 755 "$work" +mkdir -p "$work/dist" +cp "$repo/install.sh" "$work/" +cat >"$work/curl" <<'EOF' +#!/bin/sh +for a; do case $prev in -o) out=$a ;; esac; prev=$a; url=$a; done +case $url in +https://github.com/flock-php/flock/releases/download/*) cp "/test/dist/${url##*/}" "$out" ;; +*) exec /usr/bin/curl "$@" ;; +esac +EOF +chmod +x "$work/curl" + +build() { # build + make -s -C "$2" -f "$repo/Makefile" linux VERSION="$1" >/dev/null && + mv "$2"/dist/flock-"$1"-linux-*.tar.gz* "$work/dist/" +} +echo "building the archives…" +build 0.0.0-e2e "$repo" || exit 1 +if [ -n "$from" ]; then + git -C "$repo" archive "$from" | (mkdir -p "$work/from" && tar -x -C "$work/from") + build 0.0.0-from "$work/from" || exit 1 +fi + +total=0 +for d in $distros; do + C=flock-e2e-$d + fails=0 + echo "######## $d" + podman build -q -t "flock-e2e-$d" -f "$here/Containerfile.$d" "$here" >/dev/null || exit 1 + nm=false + if [ "$d" != arch ]; then + # NetworkManager running; the container's uplink is unmanaged, so what NetworkManager does to + # flock0 on its own shows. The uplink's drop-in, uplink.conf, sorts after numbered ones and sets + # unmanaged-devices= (not +=), as admins' files do. + nm=true + podman build -q -t "flock-e2e-$d-nm" -f - "$here" >/dev/null </dev/null || dnf install -y NetworkManager \ + && mkdir -p /etc/NetworkManager/conf.d && printf '[keyfile]\nunmanaged-devices=interface-name:eth*;interface-name:en*\n' > /etc/NetworkManager/conf.d/uplink.conf \ + && systemctl enable NetworkManager +EOF + fi + podman rm -f "$C" >/dev/null 2>&1 + # 127.0.0.53 as the resolver, like a desktop with systemd-resolved; ports below 1024 need setcap. + podman run -d --name "$C" --systemd=always --dns 127.0.0.53 \ + --sysctl net.ipv4.ip_unprivileged_port_start=1024 --cap-add SYS_ADMIN,NET_ADMIN,NET_RAW \ + -v "$work:/test:ro,Z" "flock-e2e-$d$($nm && echo -nm)" >/dev/null || exit 1 + + root() { podman exec "$C" sh -c "$1"; } + boot() { + # Until systemd has finished booting and the system bus answers. + for _ in $(seq 60); do + case $(podman exec "$C" systemctl is-system-running 2>/dev/null) in running | degraded) break ;; esac + sleep 1 + done + uid=$(podman exec "$C" id -u dev) + # logind doesn't start lingering users' managers in a container. + podman exec "$C" systemctl start "user@$uid" + for _ in $(seq 30); do + podman exec "$C" test -S "/run/user/$uid/bus" && break + sleep 1 + done + } + user() { + podman exec --user dev -e XDG_RUNTIME_DIR="/run/user/$uid" \ + -e DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" -w /home/dev "$C" \ + bash -c "export PATH=/test:\$PATH F=\$HOME/.config/Flock/bin/flock-php; $1" + } + check() { + local name=$1 out + if out=$("${@:2}" 2>&1); then + echo "PASS $name" + else + echo "FAIL $name" + echo "$out" | sed 's/^/ /' | head -8 + fails=$((fails + 1)) + fi + } + resolves() { + check "$1: hello.test (resolvectl, getent)" root 'resolvectl query hello.test | grep -q 127.0.0.1 && getent hosts hello.test | grep -q 127.0.0.1' + check "$1: example.com, then hello.test again" root 'getent hosts example.com && for i in 1 2 3; do getent hosts hello.test >/dev/null || exit 1; done' + check "$1: HTTPS to hello.test (system trust)" user 'curl -sS -o /dev/null https://hello.test/' + } + nmstate() { root 'nmcli -t -f DEVICE,STATE device | grep "^flock0:" | cut -d: -f2'; } + is_unmanaged() { [ "$(nmstate)" = unmanaged ] || { + nmstate + return 1 + }; } + # status_says yes|no : waits for `flock-php status` to (not) report flock0. + status_says() { + local out + for _ in $(seq "$2"); do + # A failing status (the daemon isn't running) says neither. + out=$(user '$F status' 2>/dev/null) || { + sleep 1 + continue + } + if grep -q flock0 <<<"$out"; then [ "$1" = yes ] && return 0; else [ "$1" = no ] && return 0; fi + sleep 1 + done + user '$F status 2>&1 | grep -iA1 "^dns"' + return 1 + } + status_says_nm() { + for _ in $(seq "$1"); do + user '$F status' 2>/dev/null | grep -q "NetworkManager manages flock0" && return 0 + sleep 1 + done + user '$F status 2>&1 | grep -iA1 "^dns"' + return 1 + } + + boot + # Chrome's NSS database, as a browser that has run once leaves it. + user 'mkdir -p ~/.pki/nssdb && certutil -d sql:$HOME/.pki/nssdb -N --empty-password' + + if [ -n "$from" ]; then + check "install $from" user 'FLOCK_VERSION=0.0.0-from sh /test/install.sh /tmp/from.log 2>&1 || { tail -8 /tmp/from.log; exit 1; }' + fi + check "install this tree" user 'FLOCK_VERSION=0.0.0-e2e sh /test/install.sh /tmp/install.log 2>&1 || { tail -8 /tmp/install.log; exit 1; }' + user 'mkdir -p ~/Sites/hello/public && echo " ~/Sites/hello/public/index.php && cd ~/Sites && $F park >/dev/null' + sleep 3 + + check "user unit active" user 'systemctl --user is-active flock' + check "flock-php installed, no bin/flock" user 'test -x $F && ! test -e ~/.config/Flock/bin/flock' + check "util-linux flock first in a new shell" user '[ "$(readlink -f "$(bash -lic "command -v flock" 2>/dev/null)")" = "$(readlink -f /usr/bin/flock)" ]' + check "flock-php on PATH in a new shell" user 'bash -lic "command -v flock-php" 2>/dev/null | grep -q Flock/bin/flock-php' + check "flock-dns.service active and enabled" root 'systemctl is-active flock-dns.service && systemctl is-enabled flock-dns.service' + check "flock0 routes ~test" root 'resolvectl domain flock0 | grep -q "~test" && ip -br addr show flock0 | grep -q 198.18.53.53' + check "no drop-in in the global scope" root '! ls /etc/systemd/resolved.conf.d/flock-*.conf 2>/dev/null | grep -q .' + check "NetworkManager drop-in is zz-flock.conf (no 90-flock.conf)" root 'grep -q "interface-name:flock0" /etc/NetworkManager/conf.d/zz-flock.conf && ! test -e /etc/NetworkManager/conf.d/90-flock.conf' + check "setcap, listening on 80 and 443" user 'getcap $F | grep -q cap_net_bind_service && ss -ltn | grep -q ":80 " && ss -ltn | grep -q ":443 "' + check "CA in Chrome's NSS database" user 'certutil -d sql:$HOME/.pki/nssdb -L | grep -qi flock' + resolves "installed" + + if $nm; then + echo "== NetworkManager" + check "flock0 unmanaged" is_unmanaged + root 'nmcli networking off; sleep 2; nmcli networking on; sleep 5; systemctl restart systemd-resolved; sleep 4' + check "networking off/on + resolved restart: flock0 keeps its address" root 'ip -br addr show flock0 | grep -q 198.18.53.53' + resolves "networking off/on + resolved restart" + check "managed yes doesn't take flock0" root 'nmcli device set flock0 managed yes 2>/dev/null; sleep 2; nmcli -t -f DEVICE,STATE device | grep -qx "flock0:unmanaged"' + # A drop-in read after Flock's that sets unmanaged-devices= replaces its list: status says so. + root 'printf "[keyfile]\nunmanaged-devices=interface-name:eth*;interface-name:en*\n" > /etc/NetworkManager/conf.d/zzz-admin.conf && systemctl restart NetworkManager && sleep 5' + check "a drop-in after Flock's: status reports NetworkManager managing flock0 within 70 s" status_says_nm 70 + root 'rm /etc/NetworkManager/conf.d/zzz-admin.conf && systemctl restart NetworkManager && systemctl restart flock-dns.service && sleep 4' + check "drop-in removed: flock0 unmanaged again" is_unmanaged + check "status clean again" status_says no 70 + fi + + echo "== drift" + check "status clean" status_says no 5 + root 'ip address flush dev flock0' + check "status reports a flushed flock0 within 70 s" status_says yes 70 + # Older systemd-resolved keeps the stale scope: the drift must show while *.test still works. + echo " *.test while it's reported: $(root 'getent hosts hello.test >/dev/null && echo still resolves || echo fails')" + root 'systemctl restart flock-dns.service' + check "restarting flock-dns.service clears it within 70 s" status_says no 70 + resolves "repaired" + + echo "== resolved restart, reboot" + root 'systemctl restart systemd-resolved' + sleep 3 + resolves "resolved restarted" + podman restart -t 10 "$C" >/dev/null + boot + sleep 5 + check "after reboot: flock-dns.service and the user unit active" user 'systemctl --user is-active flock && systemctl is-active flock-dns.service' + resolves "rebooted" + + echo "== uninstall --purge" + user '$F uninstall --purge /dev/null 2>&1' + check "flock-dns.service and flock0 gone" root '! test -e /etc/systemd/system/flock-dns.service && ! ip link show flock0 2>/dev/null' + check "NetworkManager drop-in gone" root '! ls /etc/NetworkManager/conf.d/*flock* 2>/dev/null | grep -q .' + if $nm; then + check "other NetworkManager drop-ins kept" root 'test -f /etc/NetworkManager/conf.d/uplink.conf' + fi + check "CA gone from the system and NSS" user '! certutil -d sql:$HOME/.pki/nssdb -L | grep -qi flock && ! ls /usr/local/share/ca-certificates/flock.crt /etc/pki/ca-trust/source/anchors/flock.crt /etc/ca-certificates/trust-source/anchors/flock.crt 2>/dev/null | grep -q .' + check "PATH line, user unit and ~/.config/Flock gone" user '! grep -q Flock/bin ~/.bashrc && ! test -e ~/.config/systemd/user/flock.service && ! test -e ~/.config/Flock' + check "hello.test gone, example.com resolves" root '! getent hosts hello.test && getent hosts example.com' + + echo "== $d: $fails failed" + total=$((total + fails)) + podman rm -f "$C" >/dev/null +done +echo "== $total failed in all" +exit $((total > 0))