// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . // Package control serves Python Fenrir's Unix-stream self-voicing protocol. // Socket I/O and instance routing run outside the reader's state owner. package control import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "net" "os" "path/filepath" "strings" "sync" "syscall" "time" "unicode/utf8" ) const MainSocket = "/tmp/fenrirscreenreader-daemon.sock" type Options struct { SocketFile string TerminalMode bool WindowID string // Tests use a private registry and socket directory. Production leaves both // empty to retain Python's existing paths and registry format. Directory, RegistryDirectory string } type Request struct { Text string PID, UID int Reply chan string } type endpoint struct { listener *net.UnixListener path string info os.FileInfo } type Server struct { ctx context.Context cancel context.CancelFunc options Options endpointsMu sync.RWMutex endpoints []endpoint requests chan Request workers sync.WaitGroup clients chan struct{} registryPath string stopOnce sync.Once done chan struct{} } func (s *Server) main_path() string { if s.options.Directory != "" { return filepath.Join(s.options.Directory, filepath.Base(MainSocket)) } return MainSocket } func Open(ctx context.Context, options Options) (*Server, error) { ctx, cancel := context.WithCancel(ctx) s := &Server{ctx: ctx, cancel: cancel, options: options, requests: make(chan Request, 32), clients: make(chan struct{}, 8), done: make(chan struct{})} paths := []string{filepath.Join(filepath.Dir(s.main_path()), fmt.Sprintf("fenrirscreenreader-%d.sock", os.Getpid())), s.main_path()} if options.SocketFile != "" { paths = []string{options.SocketFile} } for index, path := range paths { endpoint, err := listen(path) if err != nil { if options.SocketFile == "" && index == 1 && errors.Is(err, errSocketBusy) { continue } s.Close() return nil, err } s.endpoints = append(s.endpoints, endpoint) } if err := s.register(); err != nil { s.Close() return nil, err } for _, endpoint := range s.endpoints { s.workers.Go(func() { s.accept(endpoint) }) } s.workers.Go(s.refresh_registry) return s, nil } var errSocketBusy = errors.New("another reader owns remote socket") // Never unlink an active Python/Go reader, a symlink, or an unrelated file. func listen(path string) (endpoint, error) { if info, err := os.Lstat(path); err == nil { if info.Mode()&os.ModeSocket == 0 { return endpoint{}, fmt.Errorf("remote path is not a socket: %s", path) } connection, dialErr := net.DialTimeout("unix", path, 100*time.Millisecond) if dialErr == nil { connection.Close() return endpoint{}, fmt.Errorf("%w %s", errSocketBusy, path) } if errors.Is(dialErr, syscall.EACCES) { return endpoint{}, fmt.Errorf("%w %s: %v", errSocketBusy, path, dialErr) } if !errors.Is(dialErr, syscall.ECONNREFUSED) { return endpoint{}, fmt.Errorf("cannot establish remote socket is stale: %s: %w", path, dialErr) } if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Geteuid() { return endpoint{}, fmt.Errorf("stale socket belongs to another user: %s", path) } current, err := os.Lstat(path) if err != nil || !os.SameFile(info, current) { return endpoint{}, fmt.Errorf("remote socket changed while probing: %s", path) } if err := os.Remove(path); err != nil { return endpoint{}, err } } else if !os.IsNotExist(err) { return endpoint{}, err } listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) if err != nil { // Another starter may bind between our Lstat and ListenUnix. if errors.Is(err, syscall.EADDRINUSE) { if info, statErr := os.Lstat(path); statErr == nil && info.Mode()&os.ModeSocket != 0 { return endpoint{}, fmt.Errorf("%w %s", errSocketBusy, path) } } return endpoint{}, err } listener.SetUnlinkOnClose(false) // Keep existing unprivileged socat callers working. Peer credentials are // checked by the application before any request may mutate reader state. if err := os.Chmod(path, 0666); err != nil { listener.Close() os.Remove(path) return endpoint{}, err } info, err := os.Lstat(path) if err != nil { listener.Close() return endpoint{}, err } return endpoint{listener: listener, path: path, info: info}, nil } func (s *Server) Requests() <-chan Request { return s.requests } func (s *Server) Close() { s.stopOnce.Do(func() { s.cancel() s.endpointsMu.Lock() for _, endpoint := range s.endpoints { endpoint.listener.Close() } s.endpointsMu.Unlock() go func() { s.workers.Wait() for _, endpoint := range s.endpoints { if info, err := os.Lstat(endpoint.path); err == nil && os.SameFile(info, endpoint.info) { _ = os.Remove(endpoint.path) } } if s.registryPath != "" { _ = os.Remove(s.registryPath) } close(s.done) }() }) select { case <-s.done: case <-time.After(time.Second): } } func (s *Server) accept(endpoint endpoint) { for { connection, err := endpoint.listener.AcceptUnix() if err != nil { return } select { case s.clients <- struct{}{}: s.workers.Go(func() { defer func() { <-s.clients; connection.Close() }(); s.handle(connection, endpoint.path) }) default: connection.Close() } } } func peer(connection *net.UnixConn) (int, int, error) { raw, err := connection.SyscallConn() if err != nil { return 0, -1, err } var credentials *syscall.Ucred var failure error err = raw.Control(func(fd uintptr) { credentials, failure = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) }) if err != nil { return 0, -1, err } if failure != nil { return 0, -1, failure } return int(credentials.Pid), int(credentials.Uid), nil } func read_request(connection *net.UnixConn, data []byte) (string, error) { _ = connection.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) var buffer [8130]byte if len(data) > 8129 { return "", fmt.Errorf("remote request exceeds 8129 bytes") } for len(data) <= 8129 && !bytes.HasSuffix(data, []byte("\n")) { count, err := connection.Read(buffer[:min(len(buffer), 8130-len(data))]) data = append(data, buffer[:count]...) if len(data) > 8129 { return "", fmt.Errorf("remote request exceeds 8129 bytes") } if err != nil { var timeout net.Error if err != io.EOF && !(errors.As(err, &timeout) && timeout.Timeout() && len(data) > 0) { return "", err } break } if bytes.HasSuffix(data, []byte("\n")) { break } } if !utf8.Valid(data) { return "", fmt.Errorf("remote request is not UTF-8") } return strings.TrimSpace(string(data)), nil } func (s *Server) handle(connection *net.UnixConn, path string) { pid, uid, err := peer(connection) if err != nil { return } // Route before consuming request bytes. A handed-off descriptor carries // both the caller's kernel credentials and the caller's original request. var routingErr error if path == s.main_path() { var target string target, routingErr = s.route_target(pid, uid) if routingErr == nil && target != "" { routingErr = handoff(connection, target) if routingErr == nil { return } } } text, forwarded, err := receive_request(connection) if forwarded != nil { defer forwarded.Close() connection = forwarded pid, uid, err = peer(connection) if err == nil { // Handoffs never nest and never supply replacement request text. text, err = read_request(connection, nil) } } if err != nil || text == "" { return } if is_list(text) { _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) _, _ = io.WriteString(connection, s.list()+"\n") 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 } request := Request{Text: text, PID: pid, UID: uid, Reply: make(chan string, 1)} select { case s.requests <- request: default: _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) _, _ = io.WriteString(connection, "Remote request queue is full\n") return } response := "" select { case response = <-request.Reply: case <-s.ctx.Done(): response = "Remote request cancelled; completion is unknown" case <-time.After(time.Second): response = "Remote request timed out; completion is unknown" } if response != "" { _ = connection.SetWriteDeadline(time.Now().Add(250 * time.Millisecond)) _, _ = io.WriteString(connection, response+"\n") } } func is_list(text string) bool { switch strings.ToUpper(text) { case "LS", "LIST", "COMMAND LS", "COMMAND LIST": return true } return false }