Files
privatae-bastion/client-go/runholder.go
T
Privatae 2043fa5e9e
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s
bastion 0.62.0
2026-11-02 21:31:58 +00:00

142 lines
5.1 KiB
Go

package main
// runholder.go — what a second start on Windows is told about the first.
//
// A bastion that finds its run lock held (runguard_windows.go) used to say
// only that one was running, and that if it seemed dead it could be ended in
// Task Manager. On a machine where `privatae-bastion ui` then failed, that
// was the one thing to try, so a running bastion was killed for a symptom
// that was not its. The bastion that holds the lock now writes who it is
// beside it, and the refusal names it: its process, its program, how it was
// started, and the commands that open, check and restart it.
//
// And the relaunch after a self-update (relaunch_windows.go) starts the new
// binary while the old one still holds the lock, a few milliseconds from
// exiting. The new one waits for it (takeRunLock) rather than refusing and
// leaving the machine with no bastion at all.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
)
type runHolder struct {
PID int `json:"pid"`
Program string `json:"program,omitempty"`
Started string `json:"started,omitempty"` // RFC3339
Service bool `json:"service,omitempty"` // the Windows service
Console bool `json:"console,omitempty"` // started from a terminal
}
func runHolderPath(dir string) string { return filepath.Join(dir, "run.holder.json") }
// writeRunHolder records this process as the lock's holder. Best effort: a
// refusal without it is only less specific.
func writeRunHolder(dir string, console bool) {
h := runHolder{PID: os.Getpid(), Started: time.Now().UTC().Format(time.RFC3339),
Service: runningAsService, Console: console}
if exe, err := os.Executable(); err == nil {
h.Program = exe
}
if raw, err := json.Marshal(h); err == nil {
_ = atomicWrite(runHolderPath(dir), raw)
}
}
func readRunHolder(dir string) (runHolder, bool) {
var h runHolder
raw, err := os.ReadFile(runHolderPath(dir))
if err != nil || json.Unmarshal(raw, &h) != nil || h.PID <= 0 {
return runHolder{}, false
}
return h, true
}
// secondRunWords is the refusal of a second start, for the terminal and for
// the dialog a start with no console shows. known is false
// when the running bastion left no record (an older version holds the lock).
func secondRunWords(h runHolder, known bool) (console string, dialog uiNotice) {
what := "A bastion is already running on this machine"
restart := "Get-Process privatae-bastion | Format-List Id, Path, StartTime shows it; " +
"Stop-Process -Id <its Id>, then Start-ScheduledTask -TaskName " + winServiceName
if known {
how := "in the background (its sign-in task, or itself after an update)"
switch {
case h.Service:
how = "as the " + winServiceName + " service"
case h.Console:
how = "from a terminal"
}
what += " (process " + strconv.Itoa(h.PID)
if h.Program != "" {
what += ", " + h.Program
}
if t, err := time.Parse(time.RFC3339, h.Started); err == nil {
what += ", started " + t.Local().Format("2 Jan 15:04")
}
what += ", " + how + ")"
switch {
case h.Service:
restart = "Restart-Service " + winServiceName + " (from an Administrator terminal)"
default:
restart = "Stop-Process -Id " + strconv.Itoa(h.PID) + ", then start it again: Start-ScheduledTask -TaskName " +
winServiceName + " (or privatae-bastion run)"
}
}
console = fmt.Sprintf("%s.\n"+
" It is the one serving your Hosts, and a second copy would fight it for the same local ports,\n"+
" so this one is stopping. Nothing is wrong, and nothing needs ending.\n"+
" To open it: privatae-bastion ui\n"+
" To check it: privatae-bastion status\n"+
" To restart it: %s", what, restart)
dialog = uiNotice{
Title: "Privatae bastion is already running",
Body: what + ".\r\n\r\n" +
"It is the one serving your Hosts. A second copy would fight it for the same local ports, so this one " +
"is stopping. Nothing is wrong, and nothing needs ending.\r\n\r\n" +
"To open it, run: privatae-bastion ui\r\n" +
"To restart it: " + restart,
}
return console, dialog
}
// relaunchOfEnv names, in the environment of a bastion started by its
// predecessor's self-update, the predecessor's process id.
const relaunchOfEnv = "PRIVATAE_BASTION_RELAUNCH_OF"
// takeRunLock takes the run lock with try. A bastion relaunched by its
// predecessor (replaces > 0) first waits up to patience for that process to
// exit (waitExit), then tries again until patience has passed: the lock is
// released as the predecessor's handles close, a moment after it exits.
func takeRunLock(try func() error, replaces int, waitExit func(pid int, d time.Duration), patience time.Duration,
now func() time.Time, sleep func(time.Duration)) error {
if replaces <= 0 {
return try()
}
deadline := now().Add(patience)
waitExit(replaces, patience)
for {
err := try()
if err == nil || !now().Before(deadline) {
return err
}
sleep(200 * time.Millisecond)
}
}
// relaunchOf reads relaunchOfEnv once and clears it, so nothing this process
// starts inherits it.
func relaunchOf() int {
v := os.Getenv(relaunchOfEnv)
_ = os.Unsetenv(relaunchOfEnv)
pid, err := strconv.Atoi(v)
if err != nil || pid <= 0 {
return 0
}
return pid
}