248 lines
6.9 KiB
Go
248 lines
6.9 KiB
Go
//go:build windows
|
|
|
|
package windowstray
|
|
|
|
// This file is the small native service endpoint. It intentionally uses one
|
|
// request per pipe connection: the tray is a presentation client, not a
|
|
// long-lived command channel, and a bounded connection makes cancellation and
|
|
// peer verification straightforward. The service never hands the tray a
|
|
// store handle or command payload.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
"unsafe"
|
|
|
|
winapi "golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
pipeBufferBytes = 64 << 10
|
|
pipeInstances = 8
|
|
)
|
|
|
|
var (
|
|
ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
|
|
pipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
|
|
)
|
|
|
|
// Serve accepts bounded tray requests until ctx is cancelled. It is safe to
|
|
// run before any interactive user logs in; in that state no client can pass
|
|
// the interactive-peer authorization check.
|
|
func Serve(ctx context.Context, handler Handler) error {
|
|
if handler == nil {
|
|
return errors.New("tray handler is required")
|
|
}
|
|
for {
|
|
pipe, err := newTrayPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("create tray pipe: %w", err)
|
|
}
|
|
connected := make(chan error, 1)
|
|
go func() { connected <- winapi.ConnectNamedPipe(winapi.Handle(pipe.Fd()), nil) }()
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = pipe.Close()
|
|
return nil
|
|
case err := <-connected:
|
|
if err != nil && !errors.Is(err, winapi.ERROR_PIPE_CONNECTED) {
|
|
_ = pipe.Close()
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
continue
|
|
}
|
|
go serveTrayPipe(ctx, pipe, handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newTrayPipe() (*os.File, error) {
|
|
name, err := winapi.UTF16PtrFromString(PipeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
descriptor, err := winapi.SecurityDescriptorFromString(pipeSDDL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attributes := &winapi.SecurityAttributes{
|
|
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
|
|
SecurityDescriptor: descriptor,
|
|
}
|
|
// CreateNamedPipe has separate open-mode and pipe-mode arguments. Remote
|
|
// client rejection belongs to the latter; placing it in open mode produces
|
|
// ERROR_INVALID_PARAMETER on Windows. We inspect the client token directly
|
|
// and never impersonate it, so no SQOS/impersonation flag is needed here.
|
|
openMode := uint32(winapi.PIPE_ACCESS_DUPLEX)
|
|
pipeMode := uint32(winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS)
|
|
handle, err := winapi.CreateNamedPipe(name, openMode, pipeMode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, attributes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.NewFile(uintptr(handle), "rvbox-tray-pipe"), nil
|
|
}
|
|
|
|
func serveTrayPipe(ctx context.Context, pipe *os.File, handler Handler) {
|
|
if pipe == nil {
|
|
return
|
|
}
|
|
defer pipe.Close()
|
|
// A blocked Read must be interrupted when service shutdown cancels ctx.
|
|
readDone := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = pipe.Close()
|
|
case <-readDone:
|
|
}
|
|
}()
|
|
defer close(readDone)
|
|
peer, err := peerFromPipe(winapi.Handle(pipe.Fd()))
|
|
if err != nil {
|
|
writeTrayResponse(pipe, err)
|
|
return
|
|
}
|
|
request, err := readTrayFrame(pipe)
|
|
if err != nil {
|
|
writeTrayResponse(pipe, err)
|
|
return
|
|
}
|
|
if err := Authorize(peer, request.Action); err != nil {
|
|
writeTrayResponse(pipe, err)
|
|
return
|
|
}
|
|
response, err := handler(ctx, peer, request)
|
|
if err != nil {
|
|
writeTrayResponse(pipe, err)
|
|
return
|
|
}
|
|
if response.Action == 0 {
|
|
response.Action = ActionStatus
|
|
}
|
|
if response.Action != ActionStatus {
|
|
response = Frame{Action: ActionStatus}
|
|
}
|
|
writeTrayResponse(pipe, response)
|
|
}
|
|
|
|
func readTrayFrame(reader io.Reader) (Frame, error) {
|
|
buffer := make([]byte, maxFrameBytes)
|
|
count, err := reader.Read(buffer)
|
|
if err != nil {
|
|
return Frame{}, err
|
|
}
|
|
if count == len(buffer) {
|
|
return Frame{}, ErrFrameTooLarge
|
|
}
|
|
return Decode(buffer[:count])
|
|
}
|
|
|
|
func writeTrayResponse(writer *os.File, value any) {
|
|
frame := Frame{Action: ActionStatus}
|
|
switch response := value.(type) {
|
|
case Frame:
|
|
frame = response
|
|
case error:
|
|
message := response.Error()
|
|
if len(message) > maxPayloadBytes {
|
|
message = message[:maxPayloadBytes]
|
|
}
|
|
frame.Payload = []byte("error: " + message)
|
|
}
|
|
encoded, err := Encode(frame)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = writer.Write(encoded)
|
|
_ = winapi.FlushFileBuffers(winapi.Handle(writer.Fd()))
|
|
}
|
|
|
|
func peerFromPipe(pipe winapi.Handle) (Peer, error) {
|
|
if pipe == 0 || pipe == winapi.InvalidHandle {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
var pid uint32
|
|
if err := winapi.GetNamedPipeClientProcessId(pipe, &pid); err != nil || pid == 0 {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
|
if err != nil {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
defer winapi.CloseHandle(process)
|
|
var token winapi.Token
|
|
if err := winapi.OpenProcessToken(process, winapi.TOKEN_QUERY, &token); err != nil {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
defer token.Close()
|
|
user, err := token.GetTokenUser()
|
|
if err != nil || user.User.Sid == nil {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
var sessionID uint32
|
|
var returned uint32
|
|
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil || returned != uint32(unsafe.Sizeof(sessionID)) {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
|
|
if err != nil {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
admin, err := token.IsMember(adminSID)
|
|
if err != nil {
|
|
return Peer{}, ErrInvalidPeer
|
|
}
|
|
sid := user.User.Sid.String()
|
|
return Peer{PID: pid, SessionID: sessionID, SID: sid, TokenVerified: true, Interactive: sessionID != 0, Administrator: admin, System: sid == "S-1-5-18"}, nil
|
|
}
|
|
|
|
// Request opens exactly one local pipe connection and exchanges one frame.
|
|
// It retries only the transient ERROR_PIPE_BUSY state and never falls back to
|
|
// an arbitrary filesystem/socket path.
|
|
func Request(ctx context.Context, request Frame) (Frame, error) {
|
|
encoded, err := Encode(request)
|
|
if err != nil {
|
|
return Frame{}, err
|
|
}
|
|
var pipe *os.File
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return Frame{}, ctx.Err()
|
|
}
|
|
name, nameErr := winapi.UTF16PtrFromString(PipeName)
|
|
if nameErr != nil {
|
|
return Frame{}, nameErr
|
|
}
|
|
handle, openErr := winapi.CreateFile(name, winapi.GENERIC_READ|winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, 0, 0)
|
|
if openErr == nil {
|
|
pipe = os.NewFile(uintptr(handle), "rvbox-tray-client")
|
|
break
|
|
}
|
|
if !errors.Is(openErr, winapi.ERROR_PIPE_BUSY) {
|
|
return Frame{}, openErr
|
|
}
|
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return Frame{}, ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
defer pipe.Close()
|
|
state := uint32(winapi.PIPE_READMODE_MESSAGE)
|
|
_ = winapi.SetNamedPipeHandleState(winapi.Handle(pipe.Fd()), &state, nil, nil)
|
|
if _, err := pipe.Write(encoded); err != nil {
|
|
return Frame{}, err
|
|
}
|
|
if err := winapi.FlushFileBuffers(winapi.Handle(pipe.Fd())); err != nil {
|
|
return Frame{}, err
|
|
}
|
|
return readTrayFrame(pipe)
|
|
}
|