After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
// 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
}