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:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user