188 lines
6.1 KiB
Go
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
|
|
}
|