diff --git a/README.md b/README.md index 9a3e50c9..d3585c8b 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,11 @@ echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock ``` The standard socket routes reader descendants to their ancestor reader. For -reattached terminal sessions or detached helpers, it matches the caller's -`WINDOWID` and `DISPLAY` to a unique terminal reader owned by the same user. +GNU Screen sessions, it follows the server's current display terminals and +their reader ancestry, so reattachment does not depend on Screen's inherited +`WINDOWID`. Detached sessions and sessions attached to multiple readers return +a routing error. For other detached helpers, it matches the caller's `WINDOWID` +and `DISPLAY` to a unique terminal reader owned by the same user. Requests from a Linux virtual console reach the console reader, subject to its usual authorization. Missing or ambiguous identity produces a routing error; a sole unrelated reader is never selected as a default. Instance @@ -362,8 +365,9 @@ and Escape or the Close button to return to the application. Reopen the list to include messages received while it was open. Detached notifiers must identify the intended reader, using its private socket -when needed. Check socket replies for routing errors; changing `say` to `notify` -does not repair an outdated `WINDOWID` in a long-running notifier. +when needed. Check socket replies for routing errors. GNU Screen callers use +their current attachment; other long-running notifiers must keep their terminal +identity current. Both `say` and `notify` use these routing rules. `command application ID` selects an override for the reader's active terminal, like Fenrir+A. IDs are `default`, `networkmanager`, `ninjam`, `rogue`, and `codex`. diff --git a/docs/fenrir.1 b/docs/fenrir.1 index a256c39a..cc38ebf7 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -1006,10 +1006,17 @@ self\-voicing also allows an account logged into the active console. .PP Notifications use the same routing and command authorization as self\-voicing. -A detached notifier with an outdated \f[CR]WINDOWID\f[R] may fail to -identify a reader. -Target the intended private socket in that case and check returned -errors; successful delivery returns an empty reply. +For GNU Screen callers, Fenrir finds the session\(cqs current attached +reader instead of relying on the server\(cqs original +\f[CR]WINDOWID\f[R]. +Detached Screen sessions and sessions attached to multiple readers +return a routing error. +This requires the updated Fenrir binary in the instance owning the +shared socket too. +Other detached notifiers with outdated terminal identities may still +fail to identify a reader. +Target the intended private socket and check returned errors; successful +delivery returns an empty reply. Do not select an arbitrary socket when several readers are running. .SS Command\-line options Fenrir runs as \f[CR]fenrir [options]\f[R]. diff --git a/docs/user.md b/docs/user.md index 5ee957b4..ed5a919f 100644 --- a/docs/user.md +++ b/docs/user.md @@ -707,8 +707,12 @@ requests have separate switches, `enable_command_remote` and console self-voicing also allows an account logged into the active console. Notifications use the same routing and command authorization as self-voicing. -A detached notifier with an outdated `WINDOWID` may fail to identify a reader. -Target the intended private socket in that case and check returned errors; +For GNU Screen callers, Fenrir finds the session's current attached reader +instead of relying on the server's original `WINDOWID`. Detached Screen sessions +and sessions attached to multiple readers return a routing error. This requires +the updated Fenrir binary in the instance owning the shared socket too. +Other detached notifiers with outdated terminal identities may still fail to +identify a reader. Target the intended private socket and check returned errors; successful delivery returns an empty reply. Do not select an arbitrary socket when several readers are running. diff --git a/docs/user.txt b/docs/user.txt index f54caa38..b6060773 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -731,10 +731,15 @@ account; console self-voicing also allows an account logged into the active console. Notifications use the same routing and command authorization as -self-voicing. A detached notifier with an outdated WINDOWID may fail to -identify a reader. Target the intended private socket in that case and -check returned errors; successful delivery returns an empty reply. Do -not select an arbitrary socket when several readers are running. +self-voicing. For GNU Screen callers, Fenrir finds the session’s current +attached reader instead of relying on the server’s original WINDOWID. +Detached Screen sessions and sessions attached to multiple readers +return a routing error. This requires the updated Fenrir binary in the +instance owning the shared socket too. Other detached notifiers with +outdated terminal identities may still fail to identify a reader. Target +the intended private socket and check returned errors; successful +delivery returns an empty reply. Do not select an arbitrary socket when +several readers are running. Command-line options diff --git a/internal/control/coexistence_linux.go b/internal/control/coexistence_linux.go index 9d0671fe..c22b3d7d 100644 --- a/internal/control/coexistence_linux.go +++ b/internal/control/coexistence_linux.go @@ -74,19 +74,35 @@ func (s *Server) route_target(pid, uid int) (string, error) { } } seen := map[int]bool{} + var ancestors []int + var ancestorReader *instance +ancestry: for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) { seen[ancestor] = true + ancestors = append(ancestors, ancestor) for _, data := range terminals { if data.PID == ancestor { - return s.destination(data) + ancestorReader = &data + break ancestry } } for _, data := range consoles { if data.PID == ancestor { - return s.destination(data) + ancestorReader = &data + break ancestry } } } + readers := append(slices.Clone(terminals), consoles...) + if reader, handled, err := attached_screen_reader("/proc", ancestors, uid, readers); handled { + if err != nil { + return "", err + } + return s.destination(reader) + } + if ancestorReader != nil { + return s.destination(*ancestorReader) + } identity := process_identity(pid, uid) return s.route_identity(identity, uid, terminals, consoles) } diff --git a/internal/control/screen_linux.go b/internal/control/screen_linux.go new file mode 100644 index 00000000..480cc47c --- /dev/null +++ b/internal/control/screen_linux.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package control + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// A Screen server keeps its original environment after reattachment. Locate +// its current display processes through the server's open terminal devices, +// then use their reader ancestry. No Screen commands or init services are used. +// procRoot is explicit so device, ownership and ambiguity cases can be tested. +func attached_screen_reader(procRoot string, ancestors []int, uid int, readers []instance) (instance, bool, error) { + for _, pid := range ancestors { + processPath := filepath.Join(procRoot, strconv.Itoa(pid)) + info, err := os.Stat(processPath) + if err != nil || !owned_by(info, uid) { + continue + } + command, err := os.ReadFile(filepath.Join(processPath, "cmdline")) + if err != nil || filepath.Base(strings.TrimSpace(string(bytes.SplitN(command, []byte{0}, 2)[0]))) != "SCREEN" { + continue + } + executablePath, err := os.Readlink(filepath.Join(processPath, "exe")) + name := strings.TrimSuffix(filepath.Base(executablePath), " (deleted)") + if err != nil || (name != "screen" && !strings.HasPrefix(name, "screen-")) { + continue + } + executable, err := os.Stat(filepath.Join(processPath, "exe")) + if err != nil { + continue + } + reader, err := screen_display_reader(procRoot, processPath, uid, executable, readers) + return reader, true, err + } + return instance{}, false, nil +} + +func owned_by(info os.FileInfo, uid int) bool { + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && int(stat.Uid) == uid +} + +func screen_display_reader(procRoot, serverPath string, uid int, executable os.FileInfo, readers []instance) (instance, error) { + fdPath := filepath.Join(serverPath, "fd") + files, err := os.ReadDir(fdPath) + if err != nil { + return instance{}, fmt.Errorf("cannot inspect Screen attachments: %w", err) + } + displays := map[uint32]bool{} + for _, file := range files { + path := filepath.Join(fdPath, file.Name()) + info, err := os.Stat(path) + if err != nil || info.Mode()&os.ModeCharDevice == 0 { + continue + } + // /proc/PID/stat encodes tty_nr as a signed 32-bit device number. + stat, ok := info.Sys().(*syscall.Stat_t) + if ok && stat.Rdev != 0 { + displays[uint32(stat.Rdev)] = true + } + } + processes, err := os.ReadDir(procRoot) + if err != nil { + return instance{}, fmt.Errorf("cannot inspect Screen display processes: %w", err) + } + matches := map[int]instance{} + for _, process := range processes { + pid, err := strconv.Atoi(process.Name()) + if err != nil || pid <= 1 { + continue + } + path := filepath.Join(procRoot, process.Name()) + info, err := os.Stat(path) + if err != nil || !owned_by(info, uid) { + continue + } + data, err := os.ReadFile(filepath.Join(path, "stat")) + if err != nil { + continue + } + _, tty := process_parent_tty(data) + if tty == 0 || !displays[tty] { + continue + } + info, err = os.Stat(filepath.Join(path, "exe")) + if err != nil || !os.SameFile(info, executable) { + continue + } + // Display clients and the server run the same Screen executable. + // The daemon has no controlling terminal; display clients have one. + seen := map[int]bool{} + for ancestor := pid; ancestor > 1 && !seen[ancestor]; { + seen[ancestor] = true + for _, reader := range readers { + if reader.PID == ancestor { + matches[reader.PID] = reader + ancestor = 0 + break + } + } + if ancestor == 0 { + break + } + data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(ancestor), "stat")) + if err != nil { + break + } + ancestor, _ = process_parent_tty(data) + } + } + if len(matches) == 1 { + for _, reader := range matches { + return reader, nil + } + } + return instance{}, fmt.Errorf("Screen session does not identify a unique attached reader; use its private socket") +} + +func process_parent_tty(data []byte) (int, uint32) { + end := bytes.LastIndexByte(data, ')') + if end < 0 { + return 0, 0 + } + fields := strings.Fields(string(data[end+1:])) + if len(fields) < 5 { + return 0, 0 + } + parent, _ := strconv.Atoi(fields[1]) + tty, _ := strconv.ParseInt(fields[4], 10, 64) + return parent, uint32(tty) +} diff --git a/internal/control/screen_native_test.go b/internal/control/screen_native_test.go new file mode 100644 index 00000000..782bbfa0 --- /dev/null +++ b/internal/control/screen_native_test.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package control + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + "unsafe" +) + +// The caller lives inside a private, real Screen session. A separate test +// socket coordinates requests without typing into any terminal. +func TestScreenCallerProcess(t *testing.T) { + path := os.Getenv("FENRIR_SCREEN_TEST_COORDINATOR") + if path == "" { + return + } + listener, err := net.Listen("unix", path) + if err != nil { + os.Exit(1) + } + defer listener.Close() + for { + connection, err := listener.Accept() + if err != nil { + os.Exit(1) + } + var request processCommand + if json.NewDecoder(connection).Decode(&request) != nil { + connection.Close() + continue + } + reply := processReply{PID: os.Getpid()} + target, err := net.DialTimeout("unix", request.Path, time.Second) + if err == nil { + target.SetDeadline(time.Now().Add(2 * time.Second)) + _, err = io.WriteString(target, request.Text+"\n") + if err == nil { + var data []byte + data, err = io.ReadAll(target) + reply.Response = string(data) + } + target.Close() + } + if err != nil { + reply.Error = err.Error() + } + json.NewEncoder(connection).Encode(reply) + connection.Close() + } +} + +func TestNativeScreenReattachmentRoutesSayAndNotify(t *testing.T) { + screen, err := exec.LookPath("screen") + if err != nil { + t.Skip("GNU Screen not installed") + } + options := private_options(t) + // The shared owner and an unrelated old-window reader must never receive + // messages intended for the terminal displaying the reattached session. + t.Setenv("DISPLAY", ":fixture") + t.Setenv("WINDOWID", "old") + start_reader_process(t, options.Directory, "tty") + start_reader_process(t, options.Directory, "terminal") + options.TerminalMode, options.WindowID = true, "current" + server, err := Open(context.Background(), options) + if err != nil { + t.Fatal(err) + } + defer server.Close() + go func() { + for { + select { + case request := <-server.Requests(): + data, _ := json.Marshal(requestReceipt{os.Getpid(), request.PID, request.UID, request.Text}) + request.Reply <- string(data) + case <-server.ctx.Done(): + return + } + } + }() + screenDirectory := filepath.Join(options.Directory, "screen") + if err := os.Mkdir(screenDirectory, 0700); err != nil { + t.Fatal(err) + } + coordinator := filepath.Join(options.Directory, "caller.sock") + session := fmt.Sprintf("fenrir-test-%d", os.Getpid()) + environment := append(os.Environ(), "SCREENDIR="+screenDirectory, "TERM=xterm", "STY=", "FENRIR_SCREEN_TEST_COORDINATOR="+coordinator) + start := exec.Command(screen, "-dmS", session, "-c", "/dev/null", os.Args[0], "-test.run=^TestScreenCallerProcess$") + start.Env = environment + if output, err := start.CombinedOutput(); err != nil { + t.Fatal(string(output), err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + stop := exec.CommandContext(ctx, screen, "-S", session, "-X", "quit") + stop.Env = environment + _ = stop.Run() + }) + send := func(text string) processReply { + t.Helper() + connection, err := net.DialTimeout("unix", coordinator, time.Second) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + connection.SetDeadline(time.Now().Add(3 * time.Second)) + if err := json.NewEncoder(connection).Encode(processCommand{Path: server.main_path(), Text: text}); err != nil { + t.Fatal(err) + } + var reply processReply + if err := json.NewDecoder(connection).Decode(&reply); err != nil || reply.Error != "" { + t.Fatal(reply, err) + } + return reply + } + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(coordinator); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("private Screen caller did not start") + } + time.Sleep(10 * time.Millisecond) + } + if reply := send("COMMAND SAY detached"); !strings.Contains(reply.Response, "unique attached reader") { + for pid := reply.PID; pid > 1; pid = parent_pid(pid) { + command, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + executable, _ := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + t.Logf("ancestor %d command %q executable %q", pid, command, executable) + } + t.Fatal("detached Screen used the old window", reply) + } + fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + master := os.NewFile(uintptr(fd), "private Screen display") + defer master.Close() + var unlock int32 + if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != 0 { + t.Fatal(err) + } + var number uint32 + if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); err != 0 { + t.Fatal(err) + } + slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", number), os.O_RDWR|syscall.O_NOCTTY, 0) + if err != nil { + t.Fatal(err) + } + defer slave.Close() + attach := exec.Command(screen, "-r", session) + attach.Env = append(environment, "WINDOWID=current") + attach.Stdin, attach.Stdout, attach.Stderr = slave, slave, slave + attach.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0} + if err := attach.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = attach.Process.Kill(); _ = attach.Wait() }) + deadline = time.Now().Add(3 * time.Second) + for { + reply := send("COMMAND SAY attached") + if !strings.HasPrefix(reply.Response, "Remote routing failed:") { + check_receipt(t, reply, os.Getpid(), "COMMAND SAY attached") + break + } + if time.Now().After(deadline) { + t.Fatal("reattached Screen did not find its reader", reply) + } + time.Sleep(10 * time.Millisecond) + } + check_receipt(t, send("COMMAND NOTIFY notification"), os.Getpid(), "COMMAND NOTIFY notification") +} diff --git a/internal/control/screen_test.go b/internal/control/screen_test.go new file mode 100644 index 00000000..3b295b99 --- /dev/null +++ b/internal/control/screen_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package control + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" +) + +type screenFixture struct { + root, executable string + t *testing.T +} + +func new_screen_fixture(t *testing.T) screenFixture { + t.Helper() + f := screenFixture{root: t.TempDir(), t: t} + f.executable = filepath.Join(f.root, "screen") + if err := os.WriteFile(f.executable, nil, 0700); err != nil { + t.Fatal(err) + } + f.process(100, 1, 0, "/usr/bin/SCREEN", f.executable) + f.process(120, 100, 0, "notifier", "") + f.process(300, 1, 0, "reader", "") + return f +} + +func (f screenFixture) process(pid, parent int, tty uint32, command, executable string) { + f.t.Helper() + path := filepath.Join(f.root, strconv.Itoa(pid)) + if err := os.MkdirAll(filepath.Join(path, "fd"), 0700); err != nil { + f.t.Fatal(err) + } + // A parenthesized name checks the /proc/stat field boundary too. + stat := fmt.Sprintf("%d (process (name)) S %d %d %d %d 0 0", pid, parent, pid, pid, int32(tty)) + for name, value := range map[string]string{"stat": stat, "cmdline": command + "\x00"} { + if err := os.WriteFile(filepath.Join(path, name), []byte(value), 0600); err != nil { + f.t.Fatal(err) + } + } + if executable != "" { + if err := os.Symlink(executable, filepath.Join(path, "exe")); err != nil { + f.t.Fatal(err) + } + } +} + +func (f screenFixture) attach(pid, reader int, device string, sameExecutable bool) { + f.t.Helper() + info, err := os.Stat(device) + if err != nil { + f.t.Fatal(err) + } + executable := f.executable + if !sameExecutable { + executable = filepath.Join(f.root, "other") + if err := os.WriteFile(executable, nil, 0700); err != nil { + f.t.Fatal(err) + } + } + f.process(pid, reader, uint32(info.Sys().(*syscall.Stat_t).Rdev), "screen", executable) + if err := os.Symlink(device, filepath.Join(f.root, "100", "fd", strconv.Itoa(pid))); err != nil { + f.t.Fatal(err) + } +} + +func TestScreenAttachmentRouting(t *testing.T) { + for _, scenario := range []string{"attached", "detached", "other executable", "ambiguous", "duplicate displays", "reattached", "no reader", "wrong uid"} { + t.Run(scenario, func(t *testing.T) { + f := new_screen_fixture(t) + uid := os.Geteuid() + readers := []instance{{PID: 300}, {PID: 400}} + if scenario != "detached" { + f.attach(200, 300, "/dev/null", scenario != "other executable") + } + if scenario == "ambiguous" { + f.process(400, 1, 0, "reader", "") + f.attach(201, 400, "/dev/zero", true) + } + if scenario == "duplicate displays" { + f.attach(201, 300, "/dev/zero", true) + } + if scenario == "no reader" { + readers = nil + } + if scenario == "wrong uid" { + uid++ + } + if scenario == "reattached" { + if err := os.Remove(filepath.Join(f.root, "100", "fd", "200")); err != nil { + t.Fatal(err) + } + f.process(400, 1, 0, "reader", "") + f.attach(201, 400, "/dev/zero", true) + } + reader, handled, err := attached_screen_reader(f.root, []int{120, 100}, uid, readers) + valid := scenario == "attached" || scenario == "duplicate displays" || scenario == "reattached" + if handled != (scenario != "wrong uid") || (err == nil && handled) != valid { + t.Fatal(reader, handled, err) + } + if valid { + want := 300 + if scenario == "reattached" { + want = 400 + } + if reader.PID != want { + t.Fatal("wrong reader", reader.PID, want) + } + } + }) + } +} + +func TestScreenRoutingIgnoresEnvironmentWithoutServerAncestor(t *testing.T) { + f := new_screen_fixture(t) + f.attach(200, 300, "/dev/null", true) + t.Setenv("STY", "100.session") + if _, handled, err := attached_screen_reader(f.root, []int{120}, os.Geteuid(), []instance{{PID: 300}}); handled || err != nil { + t.Fatal("environment selected an unrelated Screen session", handled, err) + } + // A broken ancestry cycle must terminate without choosing an unrelated reader. + f.process(301, 302, 0, "cycle", "") + f.process(302, 301, 0, "cycle", "") + f.attach(201, 301, "/dev/zero", true) + reader, handled, err := attached_screen_reader(f.root, []int{120, 100}, os.Geteuid(), []instance{{PID: 300}}) + if !handled || err != nil || reader.PID != 300 { + t.Fatal(reader, handled, err) + } +} diff --git a/internal/control/server_linux.go b/internal/control/server_linux.go index 0ea0cb14..3cbed3ac 100644 --- a/internal/control/server_linux.go +++ b/internal/control/server_linux.go @@ -25,6 +25,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net" "os" "path/filepath" @@ -295,6 +296,7 @@ func (s *Server) handle(connection *net.UnixConn, path string) { return } if routingErr != nil { + slog.Debug("remote routing failed", "pid", pid, "uid", uid, "error", routingErr, "at", time.Now()) _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) _, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n") return