From fb4e657844036ffc0bbd31799aebdd3f1f3b70e7 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:44:30 +0300 Subject: [PATCH 01/18] fix(certs): read the system CA bundle where Linux distributions keep it --- internal/certs/system_unix.go | 23 ++++++++++++++++-- internal/certs/system_unix_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 internal/certs/system_unix_test.go 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) + } +} From 18df29e82b41588a4bf176153790eff789562d4c Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:44:46 +0300 Subject: [PATCH 02/18] feat(binaries): name linux-x64 and linux-arm64 in the manifest --- internal/binaries/binaries.go | 8 ++++++-- internal/binaries/binaries_test.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) 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]) } From d06e02b4fe386d2e26805ff68a5615d8b7737b1e Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:46:27 +0300 Subject: [PATCH 03/18] feat(daemon): check on Linux that .test lookups reach Flock's DNS server --- internal/daemon/wildcard.go | 22 ++++++++++---- internal/daemon/wildcard_test.go | 12 ++++++-- internal/dns/resolved.go | 50 ++++++++++++++++++++++++++++++++ internal/dns/resolved_test.go | 39 +++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 internal/dns/resolved.go create mode 100644 internal/dns/resolved_test.go diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index faaba336..3930ea9f 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 systemd-resolved has the drop-in from `flock install` (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,12 @@ 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: + case runtime.GOOS == "windows": state.problem = windowsWildcardProblem(cfg.TLD) + default: + state.problem = linuxWildcardProblem(cfg.TLD, cfg.Ports.DNS) } d.mu.Lock() @@ -93,7 +95,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 +111,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 (a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld) +} diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index e8091d7e..6eb7ae9c 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-resolved drop-in 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.ResolvedDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { @@ -110,11 +112,17 @@ 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 == "" }) return } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go new file mode 100644 index 00000000..6219496f --- /dev/null +++ b/internal/dns/resolved.go @@ -0,0 +1,50 @@ +package dns + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ResolvedDir is where systemd-resolved reads drop-in configuration (resolved.conf(5)). Tests move it. +var ResolvedDir = "/etc/systemd/resolved.conf.d" + +// ResolvedRuntimeDir exists while systemd-resolved runs. Tests move it. +var ResolvedRuntimeDir = "/run/systemd/resolve" + +// ResolvedFile is the drop-in `flock install` writes on Linux to send tld to Flock. +func ResolvedFile(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } + +// ResolvedContent is what ResolvedFile holds when it sends tld lookups to Flock's DNS server on +// port. ~tld routes those lookups to Flock alone. A global DNS server also gets asked for every +// other name, which Flock refuses and the network's DNS servers answer. +func ResolvedContent(tld string, port int) string { + return fmt.Sprintf("# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:%d\nDomains=~%s\n", port, tld) +} + +// ResolvedRunning reports whether systemd-resolved runs on this machine. +func ResolvedRunning() bool { + _, err := os.Stat(ResolvedRuntimeDir) + return err == nil +} + +// ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on +// port, or returns "" when its drop-in does. +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(tld) + 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 install`", path, tld) + case err != nil: + return fmt.Sprintf("can't read %s (%v) — run `flock install`", path, err) + case string(data) != ResolvedContent(tld, port): + return fmt.Sprintf("%s doesn't point at Flock's DNS server on port %d — run `flock install`", path, port) + } + return "" +} diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go new file mode 100644 index 00000000..d59e0174 --- /dev/null +++ b/internal/dns/resolved_test.go @@ -0,0 +1,39 @@ +package dns + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedProblem(t *testing.T) { + oldDir, oldRuntime := ResolvedDir, ResolvedRuntimeDir + ResolvedDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { ResolvedDir, ResolvedRuntimeDir = oldDir, oldRuntime }) + + 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 install") { + t.Fatalf("missing drop-in: %q", p) + } + + if err := os.WriteFile(ResolvedFile("test"), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { + t.Fatal(err) + } + if p := ResolvedProblem("test", 53535); p != "" { + t.Fatalf("current drop-in: %q", p) + } + if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock install") { + t.Fatalf("other port: %q", p) + } + if got := ResolvedFile("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + t.Fatalf("file: %q", got) + } + if got := ResolvedContent("test", 53535); got != "# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:53535\nDomains=~test\n" { + t.Fatalf("content changed (must match what flock install writes): %q", got) + } +} From 8d4291e9177646d725283d8eb4c1350b86296dbf Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:47:03 +0300 Subject: [PATCH 04/18] fix(ports): a low port without permission isn't a taken one on Linux --- internal/ports/ports.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/ports/ports.go b/internal/ports/ports.go index 51466730..df2dfc56 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 From ecd6f1cd9c680b7db04aaea091c6113b43345247 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:52:09 +0300 Subject: [PATCH 05/18] refactor(cli): share the shims and shell profile code of macOS with Linux --- internal/cli/install_darwin.go | 105 +------------------------- internal/cli/install_darwin_test.go | 44 ----------- internal/cli/install_unix.go | 113 ++++++++++++++++++++++++++++ internal/cli/install_unix_test.go | 54 +++++++++++++ 4 files changed, 169 insertions(+), 147 deletions(-) create mode 100644 internal/cli/install_unix.go create mode 100644 internal/cli/install_unix_test.go 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_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) + } +} From df13d6c9cc4a19f0cc49251c1162e7ecec22996a Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:59:03 +0300 Subject: [PATCH 06/18] feat(cli): flock install, uninstall and restart on Linux --- internal/cli/install.go | 5 +- internal/cli/install_linux.go | 530 +++++++++++++++++++++++++++++ internal/cli/install_linux_test.go | 71 ++++ internal/cli/update_linux.go | 8 + 4 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 internal/cli/install_linux.go create mode 100644 internal/cli/install_linux_test.go create mode 100644 internal/cli/update_linux.go diff --git a/internal/cli/install.go b/internal/cli/install.go index 50192a67..9f239193 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -19,6 +19,9 @@ import ( 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 +100,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 diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go new file mode 100644 index 00000000..6a501dad --- /dev/null +++ b/internal/cli/install_linux.go @@ -0,0 +1,530 @@ +package cli + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "errors" + "fmt" + "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 + } + if !systemdUser() { + serviceNote = "daemon runs in the background; without a systemd user session nothing starts it at login, so run `flock 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", exeName("flock")) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("the background daemon is not installed — run `flock 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 (systemd-resolved's drop-in for the TLD, 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) + } + } + if resolved { + cmd.Printf("✓ *.%s resolves via Flock\n", tld) + } else { + 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) + } + 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 +} + +// 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") + } + if err := os.MkdirAll(dns.ResolvedDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(dns.ResolvedFile(tld), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { + return err + } + if err := runTool("systemctl", "try-restart", "systemd-resolved"); 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(s.cfg.TLD)) + + 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/update_linux.go b/internal/cli/update_linux.go new file mode 100644 index 00000000..d8cdcfc8 --- /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 from https://github.com/flock-php/flock/releases and run its `flock install`") +} From befd5e3f0f09c73719358650b64b52fa6b7d9a75 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 03:00:30 +0300 Subject: [PATCH 07/18] ci: run the Go tests on Linux and build the CLI for it --- .github/workflows/build.yml | 4 +++- Makefile | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc172f64..7fdae2cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/Makefile b/Makefile index 0c70f37b..5d682bb2 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... From c195af15a287b95acbb5329d473ad18f080a5ec6 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 06:36:06 +0300 Subject: [PATCH 08/18] fix(ports): find a port's holder through /proc on Linux, without lsof --- internal/ports/holders_linux_test.go | 25 ++++++++++++ internal/ports/holders_unix.go | 60 +++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 internal/ports/holders_linux_test.go 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") From 838ed20f83abbf0f235af46e5ed3b8bd355af9f3 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:06:21 +0300 Subject: [PATCH 09/18] fix(cli): name the Linux CLI flock-php, util-linux owns flock(1) --- internal/cli/install.go | 3 ++- internal/cli/install_linux.go | 10 ++++++---- internal/cli/update_linux.go | 2 +- internal/mcpclients/mcpclients.go | 7 +++---- internal/paths/paths.go | 14 ++++++++++++++ internal/ports/ports.go | 2 +- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/internal/cli/install.go b/internal/cli/install.go index 9f239193..bbc1c6b7 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -14,6 +14,7 @@ import ( "flock/internal/certs" "flock/internal/node" + "flock/internal/paths" "flock/internal/php" ) @@ -149,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_linux.go b/internal/cli/install_linux.go index 6a501dad..14d8a318 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -63,8 +63,10 @@ func installService(root, bin string) error { 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 restart` after a reboot" + 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) } @@ -207,9 +209,9 @@ func restartService() error { if serviceInstalled() && systemdUser() { return systemctl("--user", "restart", serviceUnit) } - bin := filepath.Join(root, "bin", exeName("flock")) + 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 install`") + return fmt.Errorf("the background daemon is not installed — run `flock-php install`") } stopDaemon(root) return startDetached(root, bin) @@ -351,7 +353,7 @@ func trustInBrowsers(cmd *cobra.Command, caPath string) { return } if _, err := exec.LookPath("certutil"); err != nil { - cmd.Println("! Chrome and Firefox don't trust Flock's CA yet: install certutil (libnss3-tools or nss-tools) and run `flock install` again") + cmd.Println("! Chrome and Firefox don't trust Flock's CA yet: install certutil (libnss3-tools or nss-tools) and run `flock-php install` again") return } nickname := nssNickname(caPath) diff --git a/internal/cli/update_linux.go b/internal/cli/update_linux.go index d8cdcfc8..c2d041cd 100644 --- a/internal/cli/update_linux.go +++ b/internal/cli/update_linux.go @@ -4,5 +4,5 @@ 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 from https://github.com/flock-php/flock/releases and run its `flock install`") + 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/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/ports.go b/internal/ports/ports.go index df2dfc56..8f4e3504 100644 --- a/internal/ports/ports.go +++ b/internal/ports/ports.go @@ -145,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' From 043d519365e28aeede5d4cd0abf65a5981df8950 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:06:28 +0300 Subject: [PATCH 10/18] fix(dns): route the TLD through a link of Flock's own on Linux --- internal/cli/install_linux.go | 70 ++++++++++++++++++++++++-------- internal/daemon/wildcard.go | 4 +- internal/daemon/wildcard_test.go | 4 +- internal/dns/resolved.go | 64 +++++++++++++++++++++++------ internal/dns/resolved_test.go | 42 ++++++++++++++----- 5 files changed, 140 insertions(+), 44 deletions(-) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 14d8a318..7b05e1c5 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -3,11 +3,14 @@ package cli import ( "bytes" "context" + "crypto/rand" "crypto/x509" "encoding/binary" + "encoding/hex" "encoding/pem" "errors" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -223,9 +226,10 @@ const privilegedFirst = false // installHelperBinary: Linux has no hosts helper. func installHelperBinary(string) error { return nil } -// ensurePrivileged checks what needs root (systemd-resolved's drop-in for the TLD, 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. +// 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 @@ -247,10 +251,13 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er return fmt.Errorf("privileged setup: %w", err) } } - if resolved { - cmd.Printf("✓ *.%s resolves via Flock\n", tld) - } else { + 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) + 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()) @@ -265,6 +272,23 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er 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 { @@ -379,7 +403,7 @@ func untrustInBrowsers(caPath string) { } } -// tldPattern is a TLD privileged-setup writes into systemd-resolved's configuration. +// tldPattern is a TLD privileged-setup writes into the unit that routes it to Flock. var tldPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,63}$`) // caAnchor is where the system's trust store takes another CA, and the command that rebuilds the @@ -405,6 +429,16 @@ func runTool(name string, args ...string) error { return nil } +// removeLegacyDropIn removes the resolved.conf drop-in of installs before dns.Link, which put Flock +// among resolved's global DNS servers. +func removeLegacyDropIn(tld string) { + file := dns.LegacyDropIn(tld) + if data, _ := os.ReadFile(file); tld != "" && strings.HasPrefix(string(data), "# Managed by Flock") { + _ = os.Remove(file) + _ = runTool("systemctl", "try-restart", "systemd-resolved") + } +} + func newPrivilegedSetupCommand() *cobra.Command { var tld, caPath, lowPortsBin string var dnsPort int @@ -424,9 +458,11 @@ func newPrivilegedSetupCommand() *cobra.Command { } if remove { - if data, _ := os.ReadFile(dns.ResolvedFile(tld)); tld != "" && strings.HasPrefix(string(data), "# Managed by Flock") { - _ = os.Remove(dns.ResolvedFile(tld)) - _ = runTool("systemctl", "try-restart", "systemd-resolved") + removeLegacyDropIn(tld) + if data, _ := os.ReadFile(dns.ResolvedFile()); strings.HasPrefix(string(data), "# Managed by Flock") { + _ = runTool("systemctl", "disable", "--now", dns.Unit) // its ExecStop deletes the link + _ = os.Remove(dns.ResolvedFile()) + _ = runTool("systemctl", "daemon-reload") } if anchor, update, err := caAnchor(); err == nil { if _, err := os.Stat(anchor); err == nil { @@ -441,14 +477,14 @@ func newPrivilegedSetupCommand() *cobra.Command { if tld == "" { return fmt.Errorf("--dns-port needs --tld") } - if err := os.MkdirAll(dns.ResolvedDir, 0o755); err != nil { - return err - } - if err := os.WriteFile(dns.ResolvedFile(tld), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { + removeLegacyDropIn(tld) + if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { return err } - if err := runTool("systemctl", "try-restart", "systemd-resolved"); 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 != "" { @@ -516,7 +552,7 @@ func newUninstallCommand() *cobra.Command { 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(s.cfg.TLD)) + cmd.Printf("✓ removed %s and the Flock CA's trust\n", dns.ResolvedFile()) return finishUninstall(cmd, s.root, purge) }, diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index 3930ea9f..059dd331 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -38,7 +38,7 @@ 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, on -// Linux when systemd-resolved has the drop-in from `flock install` (or another resolver forwards +// 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() { @@ -116,5 +116,5 @@ 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 (a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld) + 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 6eb7ae9c..bebac469 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -25,14 +25,14 @@ func init() { windowsDNSPort = ln.LocalAddr().(*net.UDPAddr).Port _ = ln.Close() - // The real /etc/resolver and systemd-resolved drop-in point at the real daemon, never at a + // 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.ResolvedDir = dir + dns.UnitDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go index 6219496f..3e0f6f69 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -7,20 +7,60 @@ import ( "path/filepath" ) -// ResolvedDir is where systemd-resolved reads drop-in configuration (resolved.conf(5)). Tests move it. +// 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" -// ResolvedFile is the drop-in `flock install` writes on Linux to send tld to Flock. -func ResolvedFile(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } +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) } -// ResolvedContent is what ResolvedFile holds when it sends tld lookups to Flock's DNS server on -// port. ~tld routes those lookups to Flock alone. A global DNS server also gets asked for every -// other name, which Flock refuses and the network's DNS servers answer. +// 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\n[Resolve]\nDNS=127.0.0.1:%d\nDomains=~%s\n", port, tld) + 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. @@ -30,21 +70,21 @@ func ResolvedRunning() bool { } // ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on -// port, or returns "" when its drop-in does. +// port, or returns "" when the unit is 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(tld) + 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 install`", path, tld) + 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 install`", path, err) + 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 point at Flock's DNS server on port %d — run `flock install`", path, 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) } return "" } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index d59e0174..f9f80947 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,32 +8,52 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldDir, oldRuntime := ResolvedDir, ResolvedRuntimeDir - ResolvedDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") - t.Cleanup(func() { ResolvedDir, ResolvedRuntimeDir = oldDir, oldRuntime }) + oldUnit, oldRuntime := UnitDir, ResolvedRuntimeDir + UnitDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { UnitDir, ResolvedRuntimeDir = oldUnit, oldRuntime }) 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 install") { - t.Fatalf("missing drop-in: %q", p) + 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("test"), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { + if err := os.WriteFile(ResolvedFile(), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { t.Fatal(err) } if p := ResolvedProblem("test", 53535); p != "" { - t.Fatalf("current drop-in: %q", p) + t.Fatalf("current unit: %q", p) } - if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock install") { + 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("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + if got := ResolvedFile(); got != filepath.Join(UnitDir, "flock-dns.service") { t.Fatalf("file: %q", got) } - if got := ResolvedContent("test", 53535); got != "# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:53535\nDomains=~test\n" { - t.Fatalf("content changed (must match what flock install writes): %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 got := LegacyDropIn("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + t.Errorf("legacy drop-in: %q", got) } } From 18d4050eeab53de9037d2c97a8409e204ff5cc34 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:40:54 +0300 Subject: [PATCH 11/18] fix(dns): keep NetworkManager off flock0, and report the link when it drifts --- internal/cli/install_linux.go | 15 ++++++++++++++ internal/dns/resolved.go | 37 ++++++++++++++++++++++++++++++++++- internal/dns/resolved_test.go | 24 ++++++++++++++++++++--- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 7b05e1c5..ce8e016a 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -464,6 +464,13 @@ func newPrivilegedSetupCommand() *cobra.Command { _ = os.Remove(dns.ResolvedFile()) _ = runTool("systemctl", "daemon-reload") } + if data, _ := os.ReadFile(dns.NMFile()); string(data) == dns.NMContent { + _ = os.Remove(dns.NMFile()) + // Install created them when NetworkManager wasn't there; with it, they aren't empty. + _ = os.Remove(dns.NMConfDir) + _ = os.Remove(filepath.Dir(dns.NMConfDir)) + _ = runTool("systemctl", "try-reload-or-restart", "NetworkManager.service") + } if anchor, update, err := caAnchor(); err == nil { if _, err := os.Stat(anchor); err == nil { _ = os.Remove(anchor) @@ -478,6 +485,14 @@ func newPrivilegedSetupCommand() *cobra.Command { return fmt.Errorf("--dns-port needs --tld") } removeLegacyDropIn(tld) + // NetworkManager must leave the link alone before the unit creates it again. + 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 } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go index 3e0f6f69..262a9a3c 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -3,6 +3,7 @@ package dns import ( "errors" "fmt" + "net" "os" "path/filepath" ) @@ -22,6 +23,10 @@ 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" @@ -35,6 +40,15 @@ const ( // 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. +func NMFile() 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") } @@ -69,8 +83,23 @@ func ResolvedRunning() bool { 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 +} + // ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on -// port, or returns "" when the unit is in place. +// 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 — "+ @@ -86,5 +115,11 @@ func ResolvedProblem(tld string, port int) string { 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) + } + if !linkUp() { + return fmt.Sprintf("%s is down or lost its address, so systemd-resolved doesn't send .%s lookups to Flock — run `sudo systemctl restart %s`", Link, tld, Unit) + } return "" } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index f9f80947..cbbe5057 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,9 +8,11 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldUnit, oldRuntime := UnitDir, ResolvedRuntimeDir - UnitDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") - t.Cleanup(func() { UnitDir, ResolvedRuntimeDir = oldUnit, oldRuntime }) + oldUnit, oldRuntime, oldNM, oldUp := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp + UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() + up := true + linkUp = func() bool { return up } + t.Cleanup(func() { UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp = oldUnit, oldRuntime, oldNM, oldUp }) 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) @@ -24,9 +26,22 @@ func TestResolvedProblem(t *testing.T) { 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") { + t.Fatalf("link without its address: %q", p) + } + up = true if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock-php install") { t.Fatalf("other port: %q", p) } @@ -53,6 +68,9 @@ func TestResolvedContentRoutesTheTLDThroughFlocksLink(t *testing.T) { 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) } From 9d631ee91f619a62303656f1b2516165cc875421 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 12:15:30 +0300 Subject: [PATCH 12/18] fix(dns): check flock0 and NetworkManager even while lookups work, sort the drop-in last --- internal/api/api.go | 3 +- internal/cli/install_linux.go | 13 +++++++++ internal/cli/status.go | 3 ++ internal/daemon/wildcard.go | 3 ++ internal/daemon/wildcard_test.go | 9 ++++++ internal/dns/resolved.go | 48 ++++++++++++++++++++++++++++---- internal/dns/resolved_test.go | 24 +++++++++++++--- 7 files changed, 92 insertions(+), 11 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 847795c4..32febda7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -163,7 +163,8 @@ type DNSStatus struct { TLD string `json:"tld"` Addr string `json:"addr"` // the embedded DNS server, when it runs // Wildcard reports whether every name under the TLD resolves (any subdomain, at any depth). - // When it doesn't, WildcardProblem says why. On Windows (the DNS rule is missing or overridden + // When it doesn't, WildcardProblem says why. On Linux it can also be set while names resolve, + // for what will stop them (Flock's link lost its address, or NetworkManager manages it). On Windows (the DNS rule is missing or overridden // by a policy) the sites' domains and extra domains still resolve, through the hosts file; on // macOS (/etc/resolver/ missing or overridden) nothing under the TLD does. Wildcard bool `json:"wildcard"` diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index ce8e016a..410e124d 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -256,6 +256,9 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er 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) } @@ -439,6 +442,14 @@ func removeLegacyDropIn(tld string) { } } +// removeLegacyNMFile removes the NetworkManager drop-in under its earlier name, which sorted before +// drop-ins that could replace its list. +func removeLegacyNMFile() { + if data, _ := os.ReadFile(dns.LegacyNMFile()); string(data) == dns.NMContent { + _ = os.Remove(dns.LegacyNMFile()) + } +} + func newPrivilegedSetupCommand() *cobra.Command { var tld, caPath, lowPortsBin string var dnsPort int @@ -464,6 +475,7 @@ func newPrivilegedSetupCommand() *cobra.Command { _ = os.Remove(dns.ResolvedFile()) _ = runTool("systemctl", "daemon-reload") } + removeLegacyNMFile() if data, _ := os.ReadFile(dns.NMFile()); string(data) == dns.NMContent { _ = os.Remove(dns.NMFile()) // Install created them when NetworkManager wasn't there; with it, they aren't empty. @@ -486,6 +498,7 @@ func newPrivilegedSetupCommand() *cobra.Command { } 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 } 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/daemon/wildcard.go b/internal/daemon/wildcard.go index 059dd331..06d7a578 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -68,6 +68,9 @@ func (d *daemon) checkWildcard() { state.ok = state.problem == "" case reaches(srv, cfg.TLD): state.ok = true + 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: diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index bebac469..cf2567ab 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -123,6 +123,15 @@ func TestWildcardCheckReportsWhyItDoesNotWork(t *testing.T) { // 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 index 262a9a3c..480cb2d5 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -5,7 +5,9 @@ import ( "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 @@ -40,8 +42,13 @@ const ( // 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. -func NMFile() string { return filepath.Join(NMConfDir, "90-flock.conf") } +// 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 @@ -98,6 +105,38 @@ var linkUp = func() bool { 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 { @@ -118,8 +157,5 @@ func ResolvedProblem(tld string, port int) string { 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) } - if !linkUp() { - return fmt.Sprintf("%s is down or lost its address, so systemd-resolved doesn't send .%s lookups to Flock — run `sudo systemctl restart %s`", Link, tld, Unit) - } - return "" + return LinkProblem(tld) } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index cbbe5057..cb7e5e0f 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,11 +8,21 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldUnit, oldRuntime, oldNM, oldUp := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp + oldUnit, oldRuntime, oldNM, oldUp, oldManages := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() - up := true + up, managed := true, false linkUp = func() bool { return up } - t.Cleanup(func() { UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp = oldUnit, oldRuntime, oldNM, oldUp }) + 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) @@ -38,10 +48,16 @@ func TestResolvedProblem(t *testing.T) { } // 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") { + 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) } From 31d195791d29d5f7712f6a39fe5676b166b80659 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 09:28:06 +0200 Subject: [PATCH 13/18] feat(linux): release archives of the Linux CLI, and install.sh that installs the newest one make linux builds flock--linux-{x64,arm64}.tar.gz with their .sha256; build.yml keeps them as artifacts and betas and releases require them. install.sh picks the newest release (the newest beta until there is a stable one), checks the archive and runs flock install. --- .github/workflows/beta.yml | 3 +- .github/workflows/build.yml | 29 ++++++++++++++-- .github/workflows/release.yml | 3 +- Makefile | 15 +++++++- install.sh | 65 +++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100755 install.sh 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 7fdae2cc..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) @@ -274,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/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 5d682bb2..00c54d4f 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 @@ -35,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, 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 ./cmd/flock && \ + cp LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ + tar -C dist/$$name -czf dist/$$name.tar.gz flock 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..fa503c60 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/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 install`, which copies it to +# ~/.flock/bin and sets Flock up (asks for your password once). +# +# 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 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 + +# Piped into sh, stdin is this script: the password prompt and any question need the terminal. +if [ ! -t 0 ] && (: /dev/null; then + "$tmp/flock" install "$@" Date: Mon, 19 Oct 2026 09:28:06 +0200 Subject: [PATCH 14/18] feat(linux): AUR package flock-php-bin util-linux owns /usr/bin/flock, so the package installs /usr/lib/flock-php/flock with a /usr/bin/flock-php link; flock-php install sets Flock up for the user. --- packaging/aur/flock-php-bin/PKGBUILD | 31 +++++++++++++++++++ .../aur/flock-php-bin/flock-php-bin.install | 11 +++++++ 2 files changed, 42 insertions(+) create mode 100644 packaging/aur/flock-php-bin/PKGBUILD create mode 100644 packaging/aur/flock-php-bin/flock-php-bin.install diff --git a/packaging/aur/flock-php-bin/PKGBUILD b/packaging/aur/flock-php-bin/PKGBUILD new file mode 100644 index 00000000..9e2870a3 --- /dev/null +++ b/packaging/aur/flock-php-bin/PKGBUILD @@ -0,0 +1,31 @@ +# Flock's Linux CLI from the release archives. util-linux owns /usr/bin/flock, so the binary lives in +# /usr/lib/flock-php with /usr/bin/flock-php pointing at it; `flock-php install` copies it to +# ~/.flock/bin, where the user's shell finds it as flock. +# +# 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 "$pkgdir/usr/lib/flock-php/flock" + install -dm755 "$pkgdir/usr/bin" + ln -s /usr/lib/flock-php/flock "$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..b41aa38a --- /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 uninstall' +} From 89d0784b7cacbd53af187b418b920963593c4802 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:22:36 +0200 Subject: [PATCH 15/18] fix(linux): ship the CLI as flock-php, as #12 now names it The archives hold flock-php, install.sh runs it, and the AUR package installs /usr/bin/flock-php directly. The comments name the right folder, ~/.config/Flock/bin. --- Makefile | 6 +++--- install.sh | 13 +++++++------ packaging/aur/flock-php-bin/PKGBUILD | 9 +++------ packaging/aur/flock-php-bin/flock-php-bin.install | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 00c54d4f..301bffb4 100644 --- a/Makefile +++ b/Makefile @@ -36,14 +36,14 @@ windows: 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, each with its .sha256. +# 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 ./cmd/flock && \ + 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 LICENSE NOTICE THIRD_PARTY_NOTICES && \ + 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 diff --git a/install.sh b/install.sh index fa503c60..82d91bae 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,12 @@ #!/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 install`, which copies it to -# ~/.flock/bin and sets Flock up (asks for your password once). +# 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 install`: +# 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 # @@ -55,11 +56,11 @@ 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 +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" install "$@" .SRCINFO`, and push this # directory to ssh://aur@aur.archlinux.org/flock-php-bin.git. @@ -24,8 +23,6 @@ sha256sums_x86_64=(SKIP) sha256sums_aarch64=(SKIP) package() { - install -Dm755 flock "$pkgdir/usr/lib/flock-php/flock" - install -dm755 "$pkgdir/usr/bin" - ln -s /usr/lib/flock-php/flock "$pkgdir/usr/bin/flock-php" + 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 index b41aa38a..7b2fd60e 100644 --- a/packaging/aur/flock-php-bin/flock-php-bin.install +++ b/packaging/aur/flock-php-bin/flock-php-bin.install @@ -7,5 +7,5 @@ post_upgrade() { } pre_remove() { - echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock uninstall' + echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock-php uninstall' } From bba647a6ec024d7b916bc1271f4396bc51e12803 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 11:23:20 +0200 Subject: [PATCH 16/18] test(linux): an end-to-end test of Flock on Arch, Ubuntu and Fedora tools/linux-e2e/run.sh installs Flock with install.sh, from archives built from the tree, in a rootless Podman container per distribution that boots systemd (systemd-resolved with global DNS servers, a user session, NetworkManager on Ubuntu and Fedora). It checks the setup (flock-php, flock-dns.service and flock0, the NetworkManager drop-in, setcap, the CA in the system and NSS, HTTPS), NetworkManager taking networking down and a drop-in read after Flock's, drift reported while lookups still work, a resolved restart and a reboot, and that uninstall --purge leaves nothing. --from installs that ref first and upgrades from it. linux-e2e.yml runs it per distribution when the Linux setup changes, and by hand. --- .github/workflows/linux-e2e.yml | 49 ++++++ tools/linux-e2e/Containerfile.arch | 9 ++ tools/linux-e2e/Containerfile.fedora | 9 ++ tools/linux-e2e/Containerfile.ubuntu | 9 ++ tools/linux-e2e/run.sh | 214 +++++++++++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 .github/workflows/linux-e2e.yml create mode 100644 tools/linux-e2e/Containerfile.arch create mode 100644 tools/linux-e2e/Containerfile.fedora create mode 100644 tools/linux-e2e/Containerfile.ubuntu create mode 100755 tools/linux-e2e/run.sh diff --git a/.github/workflows/linux-e2e.yml b/.github/workflows/linux-e2e.yml new file mode 100644 index 00000000..b1e0d301 --- /dev/null +++ b/.github/workflows/linux-e2e.yml @@ -0,0 +1,49 @@ +# 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/** + - 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/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..ea1b8115 --- /dev/null +++ b/tools/linux-e2e/run.sh @@ -0,0 +1,214 @@ +#!/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-x64.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'; } + # status_says yes|no : waits for `flock-php status` to (not) report flock0. + status_says() { + for _ in $(seq "$2"); do + if user '$F status' 2>/dev/null | grep -q flock0; 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 + echo "== install $from" + user 'FLOCK_VERSION=0.0.0-from sh /test/install.sh /tmp/from.log 2>&1' || { cat "$work/from.log" 2>/dev/null; } + fi + echo "== install this tree" + user 'FLOCK_VERSION=0.0.0-e2e sh /test/install.sh &1 | grep -E "^(✓|!|Error)" | sed "s/^/ /"' + 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" sh -c "[ \"\$(nmstate)\" = 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, status clean within 70 s" sh -c "[ \"\$(nmstate)\" = 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)) From de949b682f337b5433f09268bc65b1c5be594a0b Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 11:42:07 +0200 Subject: [PATCH 17/18] test(linux): check flock0's NetworkManager state in the test's own shell --- tools/linux-e2e/run.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/linux-e2e/run.sh b/tools/linux-e2e/run.sh index ea1b8115..3d0ee7da 100755 --- a/tools/linux-e2e/run.sh +++ b/tools/linux-e2e/run.sh @@ -117,6 +117,10 @@ EOF 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() { for _ in $(seq "$2"); do @@ -162,7 +166,7 @@ EOF if $nm; then echo "== NetworkManager" - check "flock0 unmanaged" sh -c "[ \"\$(nmstate)\" = unmanaged ]" + 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" @@ -171,7 +175,7 @@ EOF 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, status clean within 70 s" sh -c "[ \"\$(nmstate)\" = unmanaged ]" + check "drop-in removed: flock0 unmanaged again" is_unmanaged check "status clean again" status_says no 70 fi From ebfdb536c7b7bf76fdee721d43fb2a2d480fc5f6 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 13:16:17 +0200 Subject: [PATCH 18/18] test(linux): fail on a failed install, and on a dead daemon's status - Both installs are checks now: the --from log was read from the host, where it never exists, and the tree's exit status was lost in a pipe. - status_says needs a successful flock-php status, so a dead daemon no longer passes as "status clean". - Move the arm64 archives too, for arm64 hosts and runners. - Run the workflow on internal/daemon (the drift report), cmd/flock, go.mod and go.sum. --- .github/workflows/linux-e2e.yml | 4 ++++ tools/linux-e2e/run.sh | 16 ++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/linux-e2e.yml b/.github/workflows/linux-e2e.yml index b1e0d301..61991302 100644 --- a/.github/workflows/linux-e2e.yml +++ b/.github/workflows/linux-e2e.yml @@ -11,6 +11,10 @@ on: - internal/dns/** - internal/certs/** - internal/ports/** + - internal/daemon/** + - cmd/flock/** + - go.mod + - go.sum - install.sh - Makefile - tools/linux-e2e/** diff --git a/tools/linux-e2e/run.sh b/tools/linux-e2e/run.sh index 3d0ee7da..93637d50 100755 --- a/tools/linux-e2e/run.sh +++ b/tools/linux-e2e/run.sh @@ -47,7 +47,7 @@ chmod +x "$work/curl" build() { # build make -s -C "$2" -f "$repo/Makefile" linux VERSION="$1" >/dev/null && - mv "$2"/dist/flock-"$1"-linux-x64.tar.gz* "$work/dist/" + mv "$2"/dist/flock-"$1"-linux-*.tar.gz* "$work/dist/" } echo "building the archives…" build 0.0.0-e2e "$repo" || exit 1 @@ -123,8 +123,14 @@ EOF }; } # status_says yes|no : waits for `flock-php status` to (not) report flock0. status_says() { + local out for _ in $(seq "$2"); do - if user '$F status' 2>/dev/null | grep -q flock0; then [ "$1" = yes ] && return 0; else [ "$1" = no ] && return 0; fi + # 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"' @@ -144,11 +150,9 @@ EOF user 'mkdir -p ~/.pki/nssdb && certutil -d sql:$HOME/.pki/nssdb -N --empty-password' if [ -n "$from" ]; then - echo "== install $from" - user 'FLOCK_VERSION=0.0.0-from sh /test/install.sh /tmp/from.log 2>&1' || { cat "$work/from.log" 2>/dev/null; } + 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 - echo "== install this tree" - user 'FLOCK_VERSION=0.0.0-e2e sh /test/install.sh &1 | grep -E "^(✓|!|Error)" | sed "s/^/ /"' + 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