Files
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

195 lines
7.8 KiB
Go

package main
// consolelane.go — the machine owner's own account opens the console of the
// bastion installed for the whole machine, without sudo on Linux and macOS
// and without an Administrator terminal on Windows.
//
// THE PROBLEM. Installed for the whole machine, the bastion runs as root and
// its console signs in with root's ui.secret. `privatae-bastion ui` run by the
// person reads their own account's state, and the console refuses that
// account's key, so the person reached their own machine's console only
// through sudo, and a browser started by sudo is root's, not theirs.
//
// THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe
// (asklane_windows.go), already tells the daemon, from the operating system,
// which account connected. Asked for "console" by the account recorded as the
// machine's owner (owner_account.go), the daemon mints a one-use ticket of a
// LIMITED kind and answers with the link; `ui` opens it in the person's own
// browser. Any other account is refused, and no request text is trusted.
//
// WHAT A LIMITED SESSION MAY DO. Everything a person can see, everything that
// only narrows reach (deny, switch off, disconnect, unmark), and everything
// that takes the owner's code, which an agent running as the person cannot
// read on this install. It is refused what widens access with no code today:
// pairing a Host, setting up or rotating the owner's code, choosing the Host
// in charge, proposing a folder, and the approval device's approvals. Those
// stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an
// Administrator terminal on Windows). Codes typed for
// a Host's requests in a limited session are throttled as the owner's are
// (machineCodeSpend), because guessing is the only attack left there.
import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"sync/atomic"
)
// consoleLaneTicketsMax is how many lane links may wait unopened at once.
const consoleLaneTicketsMax = 16
// consoleLaneServer is the running console, for ask.sock to mint a ticket
// on. nil until the console listens, and again after it stops.
var consoleLaneServer atomic.Pointer[uiServer]
// consoleLaneRefusal says why account uid may not open the console through
// the lane, or "" when it may.
func consoleLaneRefusal(uid uint32, who string) string {
return consoleLaneRefusalFor(askPeer{UID: uid, Name: who})
}
// consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform.
func consoleLaneRefusalFor(p askPeer) string {
admin := adminCommandFor(p.windows(), "ui")
match, ownerName, present, err := laneOwnerMatch(p)
switch {
case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
"the console with: " + admin
case !present:
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
case !match:
without := "without sudo"
if p.windows() {
without = "without an Administrator terminal"
}
return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " +
p.Name + ". The administrator opens it with: " + admin
}
return ""
}
func accountForHint(who string) string {
if who == "" || len(who) > 64 {
return "<your account>"
}
return who
}
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
// the link that redeems a fresh limited ticket.
func consoleLaneEntry(uid uint32, who string) (string, error) {
return consoleLaneEntryFor(askPeer{UID: uid, Name: who})
}
func consoleLaneEntryFor(p askPeer) (string, error) {
if why := consoleLaneRefusalFor(p); why != "" {
return "", errors.New(why)
}
u := consoleLaneServer.Load()
if u == nil || u.addr == "" {
return "", errors.New("the bastion's console is not listening (no free port, or it is still starting)")
}
// A cap, so asking over and over cannot grow root's memory: a link lasts a
// minute, and a person opens one at a time.
tok, ok, err := u.limitedTickets.mintCapped(consoleLaneTicketsMax)
if err != nil {
return "", errors.New("could not mint a ticket")
}
if !ok {
return "", errors.New("too many console links are waiting to be opened; open one, or wait a minute")
}
return "http://" + u.addr + "/?k=" + tok, nil
}
// limitedSession reports whether the request's session came through the lane.
func (u *uiServer) limitedSession(r *http.Request) bool {
return u.limitedSessions != nil && u.limitedSessions.valid(uiSessionFrom(r))
}
// signedIn reports whether the request carries either kind of session.
func (u *uiServer) signedIn(r *http.Request) bool {
tok := uiSessionFrom(r)
return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok))
}
var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
"with no code, or reads beyond your own account. The machine's administrator does it: " +
adminCommandFor(runtime.GOOS == "windows", "ui")
// adminOnly wraps a route a limited session may not use.
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
return u.guarded(func(w http.ResponseWriter, r *http.Request) {
if u.limitedSession(r) {
http.Error(w, consoleLaneAdminOnly, http.StatusForbidden)
return
}
h(w, r)
})
}
// consoleEntryForThisAccount is the link `ui` opens. An account with a
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
// account beside the machine's bastion asks the lane, and is told why not
// when it is not the owner's.
func consoleEntryForThisAccount() (entry, addr string, err error) {
if runtime.GOOS == "windows" {
if systemService.installed && !machineAuthorityHeld() {
return consoleEntryThroughLane()
}
return localUIEntryURL()
}
if os.Geteuid() != 0 && !uiDaemonListening() {
p := askSockPath()
if why := contextSockDialable(p); why != "" {
return "", "", errors.New(why)
}
if _, statErr := os.Stat(p); statErr == nil {
return consoleEntryThroughLane()
}
}
return localUIEntryURL()
}
// consoleEntryThroughLane asks the machine's bastion for the owner's link.
func consoleEntryThroughLane() (entry, addr string, err error) {
p := askLaneAddr(askSockPath())
r, err := askViaSock(agentAskRequest{Op: "console"})
if err != nil {
if runtime.GOOS == "windows" {
// The service is there (systemService.installed) but its pipe is
// not: a service older than this command, or one not running.
return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+
"account's request for its console on %s (%v).\n"+
" If the service is not running, start it: %s\n"+
" If it is older than this command, update it by running the installer again.\n"+
" Either way the administrator can open the console now: %s",
winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui"))
}
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
}
if !r.OK {
return "", "", errors.New(r.Why)
}
u, err := url.Parse(r.URL)
if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) {
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
}
return r.URL, u.Host, nil
}
// uiLimitedWriter marks a response as drawn for a limited session, so a page
// shows the administrator's command in place of a form the session would be
// refused (Settings, Approvals). It changes what is drawn, never what is
// allowed: adminOnly decides that, from the session itself.
type uiLimitedWriter struct{ http.ResponseWriter }
func limitedWriter(w http.ResponseWriter) bool {
_, ok := w.(uiLimitedWriter)
return ok
}