Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

195 lines
5.7 KiB
Go

//go:build windows
package main
// tray_pipe_windows.go — `privatae-bastion tray`: the signed-in person's icon
// beside a bastion that runs as the machine's service.
//
// The service runs in session 0 and has no desktop (tray_windows.go), so the
// icon is a separate process: this verb, started at every sign-in by the
// machine-wide logon entry `service install` writes (tray.go trayRunValue),
// running as whoever signed in, unelevated. It holds nothing of the
// service's: it asks over the approvals pipe (approvalpipe_windows.go) what is
// waiting, shows it, and sends the person's code for one request. The secret
// the code is checked against stays in the service.
//
// ONE PER SESSION. The logon entry and `npx cseries` may both start it; a
// session-local mutex makes the second one exit at once.
//
// THE CONSOLE. The bastion is one console program for the command line, the
// service and this, so Windows gives a process started from the logon entry a
// console window of its own. The tray lets go of it at once when it is the
// only process on it (started that way, not from a terminal); the window may
// show for a moment at sign-in.
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"runtime"
"time"
"unsafe"
"fyne.io/systray"
"golang.org/x/sys/windows"
)
var (
modKernel32Tray = windows.NewLazySystemDLL("kernel32.dll")
procGetConsoleProcessList = modKernel32Tray.NewProc("GetConsoleProcessList")
procFreeConsole = modKernel32Tray.NewProc("FreeConsole")
)
// letGoOfOwnConsole frees a console that only this process is on.
func letGoOfOwnConsole() {
var pids [2]uint32
n, _, _ := procGetConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), 2)
if n == 1 {
procFreeConsole.Call()
}
}
func cmdTray() error {
if trayFor(runtime.GOOS, runningAsService, systemService.installed, "tray") != trayPipe {
return errors.New("this machine's bastion is not a system service, so it has no separate tray: " +
"its icon shows while `privatae-bastion run` runs")
}
name, err := windows.UTF16PtrFromString(`Local\PrivataeBastionTray`)
if err != nil {
return err
}
m, err := windows.CreateMutex(nil, false, name)
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
if m != 0 {
_ = windows.CloseHandle(m)
}
return nil // a tray already runs in this session
}
if err != nil {
return fmt.Errorf("cannot start the tray: %w", err)
}
defer windows.CloseHandle(m)
letGoOfOwnConsole()
page, err := startTrayPage()
if err != nil {
return err
}
defer page.close()
approvalsOpener = page.open
// systray's own init locked the main goroutine to the main thread, and
// main runs this verb on it, so Run is called right here (tray_windows.go
// explains why that matters).
systray.Run(func() { trayPipeReady(page) }, func() {})
return nil
}
// trayPage is the tray's approvals window (uitray.go), served on loopback by
// this process.
type trayPage struct {
srv *uiServer
addr string
h *http.Server
}
func startTrayPage() (*trayPage, error) {
// LOOPBACK ONLY, any free port: the page draws what a Host asks of this
// machine, and nothing off it has any business reading that.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("the tray cannot open its approvals window: %w", err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
secret, err := genSecret(32)
if err != nil {
_ = ln.Close()
return nil, err
}
srv, err := newUIServer(port, secret)
if err != nil {
_ = ln.Close()
return nil, err
}
srv.trayPipe = true
srv.pipe = pipeCall
h := &http.Server{Handler: srv.handler(), ReadHeaderTimeout: 5 * time.Second}
go func() { _ = h.Serve(ln) }()
return &trayPage{srv: srv, addr: ln.Addr().String(), h: h}, nil
}
// open shows the approvals window, signed in by a ticket minted in-process.
func (p *trayPage) open(from string) {
tok, err := p.srv.tickets.mint()
if err != nil {
uilogf("%s: %v\n", from, err)
return
}
if err := openPopupWindow("http://" + p.addr + "/?k=" + tok); err != nil {
uilogf("%s: %v\n", from, err)
}
}
func (p *trayPage) close() {
sh, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = p.h.Shutdown(sh)
}
func trayPipeReady(page *trayPage) {
systray.SetIcon(trayIcon("bad"))
systray.SetTooltip("Privatae bastion")
mState := systray.AddMenuItem("Checking…", "")
mState.Disable()
mApprovals := systray.AddMenuItem(trayApprovalsLine(0), "Open what your hosts are waiting on you for")
mApprovals.Disable()
systray.AddSeparator()
// NOT "Stop the bastion": the bastion is the machine's service, and it is
// whoever administers the machine who stops it. This closes the icon only.
mQuit := systray.AddMenuItem("Close this icon",
"The bastion keeps running as this machine's service; the icon comes back at your next sign-in")
ctx, cancel := context.WithCancel(context.Background())
notes := &approvalNotifier{}
sink := startApprovalNotifier(ctx)
var last string
refresh := func() {
s := trayPipeStatus(pipeCall)
if s.StateClass != last {
if b := trayIcon(s.StateClass); b != nil {
systray.SetIcon(b)
}
last = s.StateClass
}
line := trayStateLineFrom(s)
systray.SetTooltip(clipUTF16("Privatae bastion — "+line, 127))
mState.SetTitle(line)
pending := approvalsForNotice(s)
mApprovals.SetTitle(trayApprovalsLine(len(pending)))
if len(pending) > 0 {
mApprovals.Enable()
} else {
mApprovals.Disable()
}
notes.poll(time.Now(), pending, sink)
}
refresh()
go func() {
t := time.NewTicker(trayRefresh)
defer t.Stop()
for {
select {
case <-t.C:
refresh()
case <-mApprovals.ClickedCh:
go page.open("tray")
case <-mQuit.ClickedCh:
cancel()
systray.Quit()
return
}
}
}()
}