195 lines
5.7 KiB
Go
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
|
|
}
|
|
}
|
|
}()
|
|
}
|