Files

188 lines
6.1 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
type serverDialer func(context.Context, string, string) (net.Conn, error)
// Automatic connections prefer the user's server, then the system service.
// Preserve the user's endpoint error if neither server is available so that
// autospawn still targets the user's socket, never the system socket.
func dial_with_system_fallback(dial serverDialer) serverDialer {
return func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dial(ctx, network, address)
if ctx.Err() != nil || network != "unix" || (!errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED)) {
return conn, err
}
systemConn, systemErr := dial(ctx, "unix", "/run/speech-dispatcher/speechd.sock")
if systemErr == nil {
return systemConn, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, err
}
}
// Like Python speechd, try the existing server first, then ask the daemon to
// spawn according to its own configuration. Never start a local daemon for a
// remote TCP endpoint, and never stop a daemon owned by the session.
func connect_with_autospawn(ctx context.Context, endpoint serverAddress, dial serverDialer, spawn func(context.Context, serverAddress) error) (net.Conn, error) {
conn, err := dial(ctx, endpoint.network, endpoint.address)
if ctx.Err() != nil {
if conn != nil {
_ = conn.Close()
}
return nil, ctx.Err()
}
if err == nil {
return conn, err
}
// Only an absent or stale endpoint warrants starting a daemon.
if !errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED) {
return nil, err
}
if spawnErr := spawn(ctx, endpoint); spawnErr != nil {
return nil, fmt.Errorf("%w; autostart Speech Dispatcher: %v", err, spawnErr)
}
retryContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
for {
conn, err = dial(retryContext, endpoint.network, endpoint.address)
if err == nil {
return conn, nil
}
timer := time.NewTimer(50 * time.Millisecond)
select {
case <-retryContext.Done():
timer.Stop()
return nil, fmt.Errorf("after Speech Dispatcher autostart: %w", err)
case <-timer.C:
}
}
}
func spawn_arguments(ctx context.Context, endpoint serverAddress) ([]string, error) {
args := []string{"--spawn"}
if endpoint.network == "unix" {
return append(args, "--communication-method", "unix_socket", "--socket-path", endpoint.address), nil
}
host, port, err := net.SplitHostPort(endpoint.address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, fmt.Errorf("no addresses for %s", host)
}
for _, address := range addresses {
if !address.IP.IsLoopback() {
return nil, fmt.Errorf("cannot autostart a remote Speech Dispatcher at %s", host)
}
}
return append(args, "--communication-method", "inet_socket", "--port", port), nil
}
func spawn_server(ctx context.Context, endpoint serverAddress) error {
spawnContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
args, err := spawn_arguments(spawnContext, endpoint)
if err != nil {
return err
}
command := os.Getenv("SPEECHD_CMD")
if command == "" {
command = "speech-dispatcher"
}
process := exec.CommandContext(spawnContext, command, args...)
if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) {
// Drop credentials only in the child. Never run a root-owned daemon in
// another user's session or change the reader's device privileges.
if os.Geteuid() != 0 {
return fmt.Errorf("cannot start Speech Dispatcher as the invoking user without root privileges")
}
account, err := user.LookupId(uid)
if err != nil {
return fmt.Errorf("look up invoking user for Speech Dispatcher: %w", err)
}
groups, err := account.GroupIds()
if err != nil {
return fmt.Errorf("look up invoking user's groups: %w", err)
}
if err := configure_sudo_spawn(process, account, groups); err != nil {
return err
}
}
process.WaitDelay = 100 * time.Millisecond
output, err := process.CombinedOutput()
if err != nil {
return fmt.Errorf("%s --spawn: %w: %s", command, err, strings.TrimSpace(string(output)))
}
return nil
}
func configure_sudo_spawn(process *exec.Cmd, account *user.User, groups []string) error {
uid, uidErr := strconv.ParseUint(account.Uid, 10, 32)
gid, gidErr := strconv.ParseUint(account.Gid, 10, 32)
if uidErr != nil || gidErr != nil || uid == 0 || !filepath.IsAbs(account.HomeDir) {
return fmt.Errorf("invalid invoking user for Speech Dispatcher")
}
credentials := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
for _, group := range groups {
id, err := strconv.ParseUint(group, 10, 32)
if err != nil {
return fmt.Errorf("invalid invoking user's group: %w", err)
}
credentials.Groups = append(credentials.Groups, uint32(id))
}
process.SysProcAttr = &syscall.SysProcAttr{Credential: credentials}
process.Dir = account.HomeDir
// Do not pass root's HOME, XDG paths, D-Bus address, or unrelated secrets
// into a user-owned daemon. Preserve explicit audio choices and locale.
process.Env = []string{"HOME=" + account.HomeDir, "USER=" + account.Username,
"LOGNAME=" + account.Username, "PATH=/usr/local/bin:/usr/bin:/bin"}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if key == "LANG" || strings.HasPrefix(key, "LC_") || key == "PULSE_SERVER" || key == "PULSE_COOKIE" {
process.Env = append(process.Env, entry)
}
}
runtimeDir := filepath.Join("/run/user", account.Uid)
if candidate := os.Getenv("XDG_RUNTIME_DIR"); owned_directory(candidate, uint32(uid)) {
runtimeDir = candidate
}
if owned_directory(runtimeDir, uint32(uid)) {
process.Env = append(process.Env, "XDG_RUNTIME_DIR="+runtimeDir)
}
return nil
}
func owned_directory(path string, uid uint32) bool {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && stat.Uid == uid
}