Files
hakurei/internal/outcome/shim.go
Ophestra 54610aaddc
All checks were successful
Test / Create distribution (push) Successful in 28s
Test / Sandbox (push) Successful in 42s
Test / Hakurei (push) Successful in 3m20s
Test / Hpkg (push) Successful in 2m13s
Test / Sandbox (race detector) (push) Successful in 4m25s
Test / Hakurei (race detector) (push) Successful in 3m21s
Test / Flake checks (push) Successful in 1m30s
internal/outcome: expose pipewire via pipewire-pulse
This no longer exposes the pipewire socket to the container, and instead mediates access via pipewire-pulse. This makes insecure parts of the protocol inaccessible as explained in the doc comment in hst.

Closes #29.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-12-15 12:57:06 +09:00

386 lines
11 KiB
Go

package outcome
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"runtime"
"sync/atomic"
"syscall"
"time"
"hakurei.app/container"
"hakurei.app/container/check"
"hakurei.app/container/fhs"
"hakurei.app/container/seccomp"
"hakurei.app/container/std"
"hakurei.app/hst"
"hakurei.app/internal/pipewire"
"hakurei.app/message"
)
//#include "shim-signal.h"
import "C"
const (
/* hakurei requests shim exit */
shimMsgExitRequested = C.HAKUREI_SHIM_EXIT_REQUESTED
/* shim orphaned before hakurei delivers a signal */
shimMsgOrphaned = C.HAKUREI_SHIM_ORPHAN
/* unreachable */
shimMsgInvalid = C.HAKUREI_SHIM_INVALID
/* unexpected si_pid */
shimMsgBadPID = C.HAKUREI_SHIM_BAD_PID
)
// setupContSignal sets up the SIGCONT signal handler for the cross-uid shim exit hack.
// The signal handler is implemented in C, signals can be processed by reading from the returned reader.
// The returned function must be called after all signal processing concludes.
func setupContSignal(pid int) (io.ReadCloser, func(), error) {
if r, w, err := os.Pipe(); err != nil {
return nil, nil, err
} else if _, err = C.hakurei_shim_setup_cont_signal(C.pid_t(pid), C.int(w.Fd())); err != nil {
_, _ = r.Close(), w.Close()
return nil, nil, err
} else {
return r, func() { runtime.KeepAlive(w) }, nil
}
}
// shimEnv is the name of the environment variable storing decimal representation of
// setup pipe fd for [container.Receive].
const shimEnv = "HAKUREI_SHIM"
// shimParams is embedded in outcomeState and transmitted from priv side to shim.
type shimParams struct {
// Priv side pid, checked against ppid in signal handler for the syscall.SIGCONT hack.
PrivPID int
// Duration to wait for after the initial process receives os.Interrupt before the container is killed.
// Limits are enforced on the priv side.
WaitDelay time.Duration
// Verbosity pass through from [message.Msg].
Verbose bool
// Outcome setup ops, contains setup state. Populated by outcome.finalise.
Ops []outcomeOp
}
// valid checks shimParams to be safe for use.
func (p *shimParams) valid() bool { return p != nil && p.PrivPID > 0 }
// shimName is the prefix used by log.std in the shim process.
const shimName = "shim"
// Shim is called by the main function of the shim process and runs as the unconstrained target user.
// Shim does not return.
func Shim(msg message.Msg) {
if msg == nil {
msg = message.New(log.Default())
}
shimEntrypoint(direct{msg})
}
// A shimPrivate holds state of the private work directory owned by shim.
type shimPrivate struct {
// Path to directory if created.
pathname *check.Absolute
k syscallDispatcher
id *stringPair[hst.ID]
}
// unwrap returns the underlying pathname.
func (sp *shimPrivate) unwrap() *check.Absolute {
if sp.pathname == nil {
if a, err := check.NewAbs(sp.k.tempdir()); err != nil {
sp.k.fatal(err)
panic("unreachable")
} else {
pathname := a.Append(".hakurei-shim-" + sp.id.String())
sp.k.getMsg().Verbosef("creating private work directory %q", pathname)
if err = sp.k.mkdir(pathname.String(), 0700); err != nil {
sp.k.fatal(err)
panic("unreachable")
}
sp.pathname = pathname
return sp.unwrap()
}
} else {
return sp.pathname
}
}
// String returns the absolute pathname to the directory held by shimPrivate.
func (sp *shimPrivate) String() string { return sp.unwrap().String() }
// destroy removes the directory held by shimPrivate.
func (sp *shimPrivate) destroy() {
defer func() { sp.pathname = nil }()
if sp.pathname != nil {
sp.k.getMsg().Verbosef("destroying private work directory %q", sp.pathname)
if err := sp.k.removeAll(sp.pathname.String()); err != nil {
sp.k.getMsg().GetLogger().Println(err)
}
}
}
const (
// shimPipeWireTimeout is the duration pipewire-pulse is allowed to run before its socket becomes available.
shimPipeWireTimeout = 5 * time.Second
)
func shimEntrypoint(k syscallDispatcher) {
msg := k.getMsg()
if msg == nil {
panic("attempting to call shimEntrypoint with nil msg")
} else if logger := msg.GetLogger(); logger != nil {
logger.SetPrefix(shimName + ": ")
logger.SetFlags(0)
}
if err := k.setDumpable(container.SUID_DUMP_DISABLE); err != nil {
k.fatalf("cannot set SUID_DUMP_DISABLE: %v", err)
}
// the Go runtime does not expose siginfo_t so SIGCONT is handled in C to check si_pid
ppid := k.getppid()
var signalPipe io.ReadCloser
if r, wKeepAlive, err := k.setupContSignal(ppid); err != nil {
switch {
case errors.As(err, new(*os.SyscallError)): // returned by os.Pipe
k.fatal(err.Error())
return
case errors.As(err, new(syscall.Errno)): // returned by hakurei_shim_setup_cont_signal
k.fatalf("cannot install SIGCONT handler: %v", err)
return
default: // unreachable
k.fatalf("cannot set up exit request: %v", err)
return
}
} else {
defer wKeepAlive()
signalPipe = r
}
var (
state outcomeState
closeSetup func() error
)
if f, err := k.receive(shimEnv, &state, nil); err != nil {
if errors.Is(err, io.EOF) {
// fallback exit request: signal handler not yet installed
k.exit(hst.ExitRequest)
}
if errors.Is(err, syscall.EBADF) {
k.fatal("invalid config descriptor")
}
if errors.Is(err, container.ErrReceiveEnv) {
k.fatal(shimEnv + " not set")
}
k.fatalf("cannot receive shim setup params: %v", err)
} else {
msg.SwapVerbose(state.Shim.Verbose)
closeSetup = f
if err = state.populateLocal(k, msg); err != nil {
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot populate local state:", err)
}
}
if state.Shim.PrivPID != ppid {
k.fatalf("unexpectedly reparented from %d to %d", state.Shim.PrivPID, ppid)
}
// pdeath_signal delivery is checked as if the dying process called kill(2), see kernel/exit.c
if err := k.prctl(syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGCONT), 0); err != nil {
k.fatalf("cannot set parent-death signal: %v", err)
}
stateParams := state.newParams()
for _, op := range state.Shim.Ops {
if err := op.toContainer(stateParams); err != nil {
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot create container state:", err)
}
}
if stateParams.params.Ops == nil { // only reachable with corrupted outcomeState
k.fatal("invalid container state")
}
// shim exit outcomes
var cancelContainer atomic.Pointer[context.CancelFunc]
k.new(func(k syscallDispatcher, msg message.Msg) {
buf := make([]byte, 1)
for {
if _, err := signalPipe.Read(buf); err != nil {
k.fatalf("cannot read from signal pipe: %v", err)
}
switch buf[0] {
case shimMsgExitRequested: // got SIGCONT from hakurei: shim exit requested
if fp := cancelContainer.Load(); stateParams.params.ForwardCancel && fp != nil && *fp != nil {
(*fp)()
// shim now bound by ShimWaitDelay, implemented below
continue
}
// setup has not completed, terminate immediately
k.exit(hst.ExitRequest)
case shimMsgOrphaned: // got SIGCONT after orphaned: hakurei died before delivering signal
k.exit(hst.ExitOrphan)
case shimMsgInvalid: // unreachable
msg.Verbose("sa_sigaction got invalid siginfo")
case shimMsgBadPID: // got SIGCONT from unexpected process: hopefully the terminal driver
msg.Verbose("got SIGCONT from unexpected process")
default: // unreachable
k.fatalf("got invalid message %d from signal handler", buf[0])
}
}
})
// close setup socket
if err := closeSetup(); err != nil {
msg.Verbosef("cannot close setup pipe: %v", err)
// not fatal
}
ctx, stop := k.notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
cancelContainer.Store(&stop)
sp := shimPrivate{k: k, id: state.id}
z := container.New(ctx, msg)
z.Params = *stateParams.params
z.Stdin, z.Stdout, z.Stderr = os.Stdin, os.Stdout, os.Stderr
// bounds and default enforced in finalise.go
z.WaitDelay = state.Shim.WaitDelay
if stateParams.pipewirePulsePath != nil {
zpw := container.NewCommand(ctx, msg, stateParams.pipewirePulsePath, pipewirePulseName)
zpw.Hostname = "hakurei-" + pipewirePulseName
zpw.SeccompFlags |= seccomp.AllowMultiarch
zpw.SeccompPresets |= std.PresetStrict
zpw.Env = []string{
// pipewire SecurityContext socket path
pipewire.Remote + "=" + stateParams.instancePath().Append("pipewire").String(),
// pipewire-pulse socket directory path
envXDGRuntimeDir + "=" + sp.String(),
}
if msg.IsVerbose() {
zpw.Stdin, zpw.Stdout, zpw.Stderr = os.Stdin, os.Stdout, os.Stderr
}
zpw.
Bind(fhs.AbsRoot, fhs.AbsRoot, 0).
Bind(sp.unwrap(), sp.unwrap(), std.BindWritable).
Proc(fhs.AbsProc).Dev(fhs.AbsDev, true)
socketPath := sp.unwrap().Append("pulse", "native")
innerSocketPath := stateParams.runtimeDir.Append("pulse", "native")
if err := k.containerStart(zpw); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot start "+pipewirePulseName+" container:", err)
}
if err := k.containerServe(zpw); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot configure "+pipewirePulseName+" container:", err)
}
done := make(chan error, 1)
k.new(func(k syscallDispatcher, msg message.Msg) { done <- k.containerWait(zpw) })
socketTimer := time.NewTimer(shimPipeWireTimeout)
for {
select {
case <-socketTimer.C:
sp.destroy()
k.fatal(pipewirePulseName + " exceeded deadline before socket appeared")
break
case err := <-done:
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
msg.Verbosef("cannot wait: %v", err)
k.exit(127)
}
sp.destroy()
k.fatal(pipewirePulseName + " " + exitError.ProcessState.String())
break
default:
if _, err := k.stat(socketPath.String()); err != nil {
if !errors.Is(err, os.ErrNotExist) {
sp.destroy()
k.fatal(err)
break
}
time.Sleep(500 * time.Microsecond)
continue
}
}
break
}
z.Bind(socketPath, innerSocketPath, 0)
z.Env = append(z.Env, "PULSE_SERVER=unix:"+innerSocketPath.String())
}
if err := k.containerStart(z); err != nil {
var f func(v ...any)
if logger := msg.GetLogger(); logger != nil {
f = logger.Println
} else {
f = func(v ...any) {
msg.Verbose(fmt.Sprintln(v...))
}
}
printMessageError(f, "cannot start container:", err)
sp.destroy()
k.exit(hst.ExitFailure)
}
if err := k.containerServe(z); err != nil {
sp.destroy()
printMessageError(func(v ...any) { k.fatal(fmt.Sprintln(v...)) },
"cannot configure container:", err)
}
if err := k.seccompLoad(
seccomp.Preset(std.PresetStrict, seccomp.AllowMultiarch),
seccomp.AllowMultiarch,
); err != nil {
sp.destroy()
k.fatalf("cannot load syscall filter: %v", err)
}
if err := k.containerWait(z); err != nil {
sp.destroy()
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
if errors.Is(err, context.Canceled) {
k.exit(hst.ExitCancel)
}
msg.Verbosef("cannot wait: %v", err)
k.exit(127)
}
k.exit(exitError.ExitCode())
}
sp.destroy()
}