331 lines
9.3 KiB
Go
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
|
|
}
|