More fixes for fenrir's self-voice and notification system. Make sure proper sockets can be accessed.
This commit is contained in:
1 parent
c4e9cb4b75
commit
69b94d14c6
9 files changed
+514
-16
No files matched your search
@@ -330,8 +330,11 @@ echo "ls" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
|
|||||||
```
|
```
|
||||||
|
|
||||||
The standard socket routes reader descendants to their ancestor reader. For
|
The standard socket routes reader descendants to their ancestor reader. For
|
||||||
reattached terminal sessions or detached helpers, it matches the caller's
|
GNU Screen sessions, it follows the server's current display terminals and
|
||||||
`WINDOWID` and `DISPLAY` to a unique terminal reader owned by the same user.
|
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
|
Requests from a Linux virtual console reach the console reader, subject to
|
||||||
its usual authorization. Missing or ambiguous identity produces a routing
|
its usual authorization. Missing or ambiguous identity produces a routing
|
||||||
error; a sole unrelated reader is never selected as a default. Instance
|
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.
|
to include messages received while it was open.
|
||||||
|
|
||||||
Detached notifiers must identify the intended reader, using its private socket
|
Detached notifiers must identify the intended reader, using its private socket
|
||||||
when needed. Check socket replies for routing errors; changing `say` to `notify`
|
when needed. Check socket replies for routing errors. GNU Screen callers use
|
||||||
does not repair an outdated `WINDOWID` in a long-running notifier.
|
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,
|
`command application ID` selects an override for the reader's active terminal,
|
||||||
like Fenrir+A. IDs are `default`, `networkmanager`, `ninjam`, `rogue`, and `codex`.
|
like Fenrir+A. IDs are `default`, `networkmanager`, `ninjam`, `rogue`, and `codex`.
|
||||||
|
|||||||
+11
-4
@@ -1006,10 +1006,17 @@ self\-voicing also allows an account logged into the active console.
|
|||||||
.PP
|
.PP
|
||||||
Notifications use the same routing and command authorization as
|
Notifications use the same routing and command authorization as
|
||||||
self\-voicing.
|
self\-voicing.
|
||||||
A detached notifier with an outdated \f[CR]WINDOWID\f[R] may fail to
|
For GNU Screen callers, Fenrir finds the session\(cqs current attached
|
||||||
identify a reader.
|
reader instead of relying on the server\(cqs original
|
||||||
Target the intended private socket in that case and check returned
|
\f[CR]WINDOWID\f[R].
|
||||||
errors; successful delivery returns an empty reply.
|
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.
|
Do not select an arbitrary socket when several readers are running.
|
||||||
.SS Command\-line options
|
.SS Command\-line options
|
||||||
Fenrir runs as \f[CR]fenrir [options]\f[R].
|
Fenrir runs as \f[CR]fenrir [options]\f[R].
|
||||||
|
|||||||
+6
-2
@@ -707,8 +707,12 @@ requests have separate switches, `enable_command_remote` and
|
|||||||
console self-voicing also allows an account logged into the active console.
|
console self-voicing also allows an account logged into the active console.
|
||||||
|
|
||||||
Notifications use the same routing and command authorization as self-voicing.
|
Notifications use the same routing and command authorization as self-voicing.
|
||||||
A detached notifier with an outdated `WINDOWID` may fail to identify a reader.
|
For GNU Screen callers, Fenrir finds the session's current attached reader
|
||||||
Target the intended private socket in that case and check returned errors;
|
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
|
successful delivery returns an empty reply. Do not select an arbitrary socket
|
||||||
when several readers are running.
|
when several readers are running.
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -731,10 +731,15 @@ account; console self-voicing also allows an account logged into the
|
|||||||
active console.
|
active console.
|
||||||
|
|
||||||
Notifications use the same routing and command authorization as
|
Notifications use the same routing and command authorization as
|
||||||
self-voicing. A detached notifier with an outdated WINDOWID may fail to
|
self-voicing. For GNU Screen callers, Fenrir finds the session’s current
|
||||||
identify a reader. Target the intended private socket in that case and
|
attached reader instead of relying on the server’s original WINDOWID.
|
||||||
check returned errors; successful delivery returns an empty reply. Do
|
Detached Screen sessions and sessions attached to multiple readers
|
||||||
not select an arbitrary socket when several readers are running.
|
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
|
Command-line options
|
||||||
|
|
||||||
|
|||||||
@@ -74,19 +74,35 @@ func (s *Server) route_target(pid, uid int) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
seen := map[int]bool{}
|
seen := map[int]bool{}
|
||||||
|
var ancestors []int
|
||||||
|
var ancestorReader *instance
|
||||||
|
ancestry:
|
||||||
for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) {
|
for ancestor := pid; ancestor > 1 && !seen[ancestor]; ancestor = parent_pid(ancestor) {
|
||||||
seen[ancestor] = true
|
seen[ancestor] = true
|
||||||
|
ancestors = append(ancestors, ancestor)
|
||||||
for _, data := range terminals {
|
for _, data := range terminals {
|
||||||
if data.PID == ancestor {
|
if data.PID == ancestor {
|
||||||
return s.destination(data)
|
ancestorReader = &data
|
||||||
|
break ancestry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, data := range consoles {
|
for _, data := range consoles {
|
||||||
if data.PID == ancestor {
|
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)
|
identity := process_identity(pid, uid)
|
||||||
return s.route_identity(identity, uid, terminals, consoles)
|
return s.route_identity(identity, uid, terminals, consoles)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -295,6 +296,7 @@ func (s *Server) handle(connection *net.UnixConn, path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if routingErr != nil {
|
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))
|
_ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond))
|
||||||
_, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n")
|
_, _ = io.WriteString(connection, "Remote routing failed: "+routingErr.Error()+"\n")
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in new issue
Block a user