- diffConfig reports the env var names (from new struct tags) instead of Go field names, so the reload-env reply names what the user can change - the monitor's GPU line now shows the game detector's last finding (external holders or none) and how long ago the check ran - control channel: 10s per-connection watchdog (abortive force-close), cap of 32 concurrent connections, reload-env rate-limited; command read was already capped at 4 KiB
118 lines
4.0 KiB
Go
118 lines
4.0 KiB
Go
// Package control exposes a local-only command channel into a running
|
|
// gpu-turnstile service: a named pipe on Windows, a unix socket on Linux.
|
|
// It lets unprivileged local users ask the service to do privileged work
|
|
// that is safe to offer — currently triggering an update check, whose
|
|
// payload is signature-verified regardless of who asks. The channel never
|
|
// accepts data beyond a one-word command, and the server rate-limits
|
|
// triggers, so the worst a local user can cause is a cheap, throttled
|
|
// check and a GPU-idle-gated restart onto a signed binary.
|
|
//
|
|
// Abuse hardening: the command read is capped (4 KiB), each connection is
|
|
// force-closed after connTimeout so a stalled client cannot pin a goroutine
|
|
// (or a Windows pipe instance) forever, and concurrently served connections
|
|
// are capped at maxConns — beyond that, connections are closed on arrival.
|
|
// On Windows the pipe's ACL additionally denies network logons, so the
|
|
// channel cannot be reached from another machine.
|
|
//
|
|
// Protocol: the client writes one command line, the server answers with
|
|
// one reply line ("OK ..." or "ERR ...") and hangs up.
|
|
package control
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CmdUpdateNow asks the service to check for, stage and (once the GPU is
|
|
// idle) restart onto a signed update immediately.
|
|
const CmdUpdateNow = "update-now"
|
|
|
|
// CmdStatus asks for a one-line JSON status snapshot (monitor mode).
|
|
const CmdStatus = "status"
|
|
|
|
// CmdReloadEnv asks the service to re-read and validate its config file,
|
|
// and to restart onto it (once the GPU is idle) when it changed.
|
|
const CmdReloadEnv = "reload-env"
|
|
|
|
// ErrUnavailable means no running service offers the control channel.
|
|
var ErrUnavailable = errors.New("control channel unavailable")
|
|
|
|
// Handler answers one command; the returned string is sent back as one
|
|
// line. It must start with "OK " or "ERR ".
|
|
type Handler func(cmd string) string
|
|
|
|
// connTimeout bounds one connection's lifetime: a client that stops
|
|
// mid-command or never reads the reply would otherwise pin its goroutine
|
|
// (and on Windows one of the pipe instances) indefinitely. A var so tests
|
|
// can shrink it.
|
|
var connTimeout = 10 * time.Second
|
|
|
|
// maxConns caps concurrently served connections; beyond it, new
|
|
// connections are closed on arrival. Bound on the goroutines a local
|
|
// flood can pile up.
|
|
const maxConns = 32
|
|
|
|
var connSem = make(chan struct{}, maxConns)
|
|
|
|
// serve dispatches connection handling under the concurrency cap. It
|
|
// returns false when the cap is reached — the caller must then close the
|
|
// connection itself.
|
|
func serve(c io.ReadWriteCloser, h Handler) bool {
|
|
select {
|
|
case connSem <- struct{}{}:
|
|
go func() {
|
|
defer func() { <-connSem }()
|
|
serveConn(c, h)
|
|
}()
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// forceCloser is implemented by connections that can be torn down
|
|
// abortively, unblocking pending reads and writes (Windows pipe:
|
|
// DisconnectNamedPipe; unix socket: a deadline in the past). The
|
|
// connection watchdog uses it; normal closes still flush the reply.
|
|
type forceCloser interface {
|
|
ForceClose() error
|
|
}
|
|
|
|
// serveConn runs the line protocol on one accepted connection.
|
|
func serveConn(c io.ReadWriteCloser, h Handler) {
|
|
defer c.Close()
|
|
if fc, ok := c.(forceCloser); ok {
|
|
timer := time.AfterFunc(connTimeout, func() { fc.ForceClose() })
|
|
defer timer.Stop()
|
|
}
|
|
line, err := bufio.NewReader(io.LimitReader(c, 4096)).ReadString('\n')
|
|
cmd := strings.TrimSpace(line)
|
|
if cmd == "" {
|
|
if err != nil {
|
|
return
|
|
}
|
|
fmt.Fprintln(c, "ERR empty command")
|
|
return
|
|
}
|
|
fmt.Fprintln(c, h(cmd))
|
|
}
|
|
|
|
// readReply writes cmd and reads the server's one-line reply.
|
|
func readReply(c io.ReadWriteCloser, cmd string) (string, error) {
|
|
if _, err := fmt.Fprintln(c, cmd); err != nil {
|
|
return "", err
|
|
}
|
|
// The server hangs up after its reply; a broken-pipe error after the
|
|
// last byte still leaves the reply in the buffer.
|
|
data, _ := io.ReadAll(io.LimitReader(c, 4096))
|
|
line := strings.TrimSpace(string(data))
|
|
if line == "" {
|
|
return "", ErrUnavailable
|
|
}
|
|
return line, nil
|
|
}
|