Files

331 lines
9.3 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
// 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"
"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 {
_ = 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
}