550 lines
21 KiB
Go
550 lines
21 KiB
Go
package main
|
|
|
|
// approvalpipe.go — the signed-in person's lane to a bastion that runs as the
|
|
// machine's system service.
|
|
//
|
|
// THE PROBLEM. On Windows a networked bastion is a LocalSystem service
|
|
// (sysservice_windows.go), and its state, the TOTP secrets included, sits in
|
|
// LocalSystem's profile. The person at the machine works in their own,
|
|
// unelevated account. Before this lane the only places to answer a Host's
|
|
// request were an Administrator terminal and the admin page, both behind a UAC
|
|
// prompt, so every approval cost a prompt.
|
|
//
|
|
// THE LANE. The service serves a named pipe. The tray, running as the person
|
|
// (tray_pipe_windows.go), connects and can do exactly three things:
|
|
//
|
|
// - list what is pending, in the operation's own words: describeRequest,
|
|
// the same text `privatae-bastion approvals` prints;
|
|
// - approve ONE request id with a code from the authenticator enrolled on
|
|
// this machine, verified by the existing step-up check (stepupApproveRequest)
|
|
// and bound to the purpose the tray showed (purpose_sha256);
|
|
// - deny one request id, which needs no code, as on every other lane.
|
|
//
|
|
// WHAT NEVER CROSSES IT. The TOTP secret, ui.secret, an approval id, a device
|
|
// approval. The secret stays in the service's state; the tray sends a code and
|
|
// gets back "approved" or why not. Device approvals (uidevice.go) are not
|
|
// listed at all: they need no code, so offering them here would let any
|
|
// process running as the person approve them.
|
|
//
|
|
// WHO MAY CONNECT. The pipe's ACL admits interactive users only. The service
|
|
// then identifies the client from the pipe itself (GetNamedPipeClientProcessId
|
|
// and that process's token) and serves only a process running as the person
|
|
// signed in to that process's own session, in a session someone is in front of
|
|
// (pipeClientRefusal). A runas process of another account inside the session,
|
|
// a service, session 0 and a disconnected session are refused. Of those people,
|
|
// it serves only the account that owns this machine's approvals, recorded in
|
|
// the service's state by the installer (approvals_owner.go).
|
|
//
|
|
// WHY A THROTTLE HERE AND NOT ON THE ADMIN PAGE. The admin page's approve has
|
|
// no attempt limit because a process that can reach it can read the seed
|
|
// anyway (ui.go). Through this lane that is no longer true: a process running
|
|
// as the person cannot read the seed, so guessing codes is its only way in.
|
|
// pipeThrottle bounds that, the way NIST SP 800-63B 5.2.2 asks: free
|
|
// attempts, then one attempt per pipeSlowEvery, and none after
|
|
// pipeLockAfter consecutive failures until a code is accepted on another lane
|
|
// (an Administrator terminal or the admin page), which resets the count.
|
|
//
|
|
// The decisions are here, untagged, so the suite checks them on every
|
|
// platform. The Windows calls that gather the client's identity and move the
|
|
// bytes are in approvalpipe_windows.go.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// approvalPipeName is the pipe the service serves and the tray dials.
|
|
approvalPipeName = `\\.\pipe\privatae-bastion-approvals`
|
|
|
|
// approvalPipeSDDL: SYSTEM and Administrators full control; Interactive
|
|
// users (IU) read, write and synchronize, and NOT FILE_CREATE_PIPE_INSTANCE.
|
|
// 0x12019b is FILE_GENERIC_READ|FILE_GENERIC_WRITE (0x12019f) without
|
|
// FILE_APPEND_DATA (0x4), which on a pipe is the right to create another
|
|
// server instance of it: granting it would let a client stand up its own
|
|
// end of this name and read the next code the tray sends. P protects the
|
|
// DACL from inheritance.
|
|
approvalPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;IU)"
|
|
|
|
// pipeMaxRequest bounds one request. A list request is a few bytes and an
|
|
// approve is under 300; anything larger is not ours.
|
|
pipeMaxRequest = 16 << 10
|
|
// pipeIOTimeout bounds every read and write on one connection, so a client
|
|
// that connects and says nothing cannot hold an instance.
|
|
pipeIOTimeout = 5 * time.Second
|
|
// pipeMaxClients bounds the connections served at once.
|
|
pipeMaxClients = 8
|
|
|
|
// The throttle (see the header). Five free attempts covers mistyped codes
|
|
// and a code typed as the window turned; after that one attempt per
|
|
// pipeSlowEvery, so an attacker gets at most pipeLockAfter guesses (a
|
|
// 3-in-a-million chance each with the drift window) before the lane closes.
|
|
pipeFreeFailures = 5
|
|
pipeSlowEvery = 30 * time.Second
|
|
pipeLockAfter = 100
|
|
)
|
|
|
|
// pipeRequest is one request from the tray.
|
|
type pipeRequest struct {
|
|
Op string `json:"op"` // list | approve | deny
|
|
// Host and ID name the request; Purpose is the sha256 of the purpose the
|
|
// tray was shown for that id, so what is approved is what was read.
|
|
Host string `json:"host,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Purpose string `json:"purpose_sha256,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
}
|
|
|
|
// pipeApproval is one pending request as the tray shows it.
|
|
type pipeApproval struct {
|
|
ID string `json:"id"`
|
|
Short string `json:"short"`
|
|
Host string `json:"host"`
|
|
At string `json:"at"`
|
|
Lines []string `json:"lines"`
|
|
Unreadable bool `json:"unreadable,omitempty"`
|
|
Purpose string `json:"purpose_sha256"`
|
|
}
|
|
|
|
// pipeResponse is the service's one answer.
|
|
type pipeResponse struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
// list only: the machine's state for the tray's icon and first line, and
|
|
// what is waiting.
|
|
State string `json:"state,omitempty"`
|
|
StateClass string `json:"state_class,omitempty"`
|
|
StateLine string `json:"state_line,omitempty"`
|
|
Approvals []pipeApproval `json:"approvals,omitempty"`
|
|
}
|
|
|
|
// pipeClient is what the service learned about the process on the other end.
|
|
type pipeClient struct {
|
|
PID uint32
|
|
UserSID string // the client process token's user
|
|
UserName string // for the log line
|
|
SessionID uint32 // the client process token's session
|
|
// SessionUserSID is the user signed in to SessionID (WTSQueryUserToken),
|
|
// "" when nobody is.
|
|
SessionUserSID string
|
|
SessionActive bool // WTSActive: the session someone is in front of
|
|
// NameTimedOut: the account directory did not answer for UserSID within
|
|
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
|
|
// account approved. pipeApprove refuses; list and deny go on, by SID.
|
|
NameTimedOut bool
|
|
}
|
|
|
|
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
|
|
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
|
|
// request is read, so the lookup gets well under that: a directory that does
|
|
// not answer costs one connection 2 s, and holds at most one goroutine for
|
|
// the pipe however many connections come.
|
|
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
|
|
|
|
// pipeClientName fills c.UserName from the account directory, or sets
|
|
// c.NameTimedOut when the directory did not answer in time. A SID with no
|
|
// account leaves UserName empty: the log shows the SID, as before.
|
|
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
|
|
acct, outcome := dir.resolve(c.UserSID, lane)
|
|
switch outcome {
|
|
case sidResolved:
|
|
c.UserName = sidAccountLabel(acct)
|
|
case sidTimedOut:
|
|
c.NameTimedOut = true
|
|
}
|
|
}
|
|
|
|
// purposeDigest is the sha256 of a purpose as the tray carries it back.
|
|
func purposeDigest(purpose string) string {
|
|
s := sha256.Sum256([]byte(purpose))
|
|
return hex.EncodeToString(s[:])
|
|
}
|
|
|
|
// winPersonSID reports whether a SID names a person's account: one issued by
|
|
// the machine or its domain (S-1-5-21-) or by Microsoft Entra ID (S-1-12-1-).
|
|
// SYSTEM, the service and virtual accounts and every group are not.
|
|
func winPersonSID(sid string) bool {
|
|
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
|
|
}
|
|
|
|
// pipeClientRefusal says why a connected process may not use the lane, or ""
|
|
// when it may: it must run as the person signed in to its own session, in a
|
|
// session someone is in front of.
|
|
func pipeClientRefusal(c pipeClient) string {
|
|
switch {
|
|
case c.UserSID == "":
|
|
return "cannot tell which account connected"
|
|
case c.SessionID == 0:
|
|
return "the connecting process runs in session 0, where services run and nobody signs in"
|
|
case !winPersonSID(c.UserSID):
|
|
return "the connecting process runs as " + pipeWho(c) + ", one of the machine's own identities, not a person"
|
|
case c.SessionUserSID == "":
|
|
return "nobody is signed in to the session the connecting process runs in"
|
|
case !strings.EqualFold(c.UserSID, c.SessionUserSID):
|
|
return "the connecting process runs as " + pipeWho(c) + ", not as the person signed in to its session"
|
|
case !c.SessionActive:
|
|
return "the connecting process runs in a session nobody is in front of"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pipeAccessRefusal is every rule on who may use the lane, for list, deny and
|
|
// approve alike: a person at their own active session (pipeClientRefusal),
|
|
// and the one account that owns this machine's approvals (approvals_owner.go).
|
|
func pipeAccessRefusal(c pipeClient) string {
|
|
if why := pipeClientRefusal(c); why != "" {
|
|
return why
|
|
}
|
|
return pipeOwnerRefusal(c, approvalsOwner())
|
|
}
|
|
|
|
func pipeWho(c pipeClient) string {
|
|
if c.UserName != "" {
|
|
return c.UserName + " (" + c.UserSID + ")"
|
|
}
|
|
return c.UserSID
|
|
}
|
|
|
|
// ── the throttle ────────────────────────────────────────────────────────────
|
|
|
|
type pipeThrottleFile struct {
|
|
Failures int `json:"failures"`
|
|
Last string `json:"last,omitempty"`
|
|
}
|
|
|
|
func pipeThrottlePath(dir string) string { return filepath.Join(dir, "pipe_code_failures.json") }
|
|
|
|
func pipeThrottleLoad(dir string) pipeThrottleFile {
|
|
var f pipeThrottleFile
|
|
if raw, err := os.ReadFile(pipeThrottlePath(dir)); err == nil {
|
|
_ = json.Unmarshal(raw, &f)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// pipeThrottleRefusal says why the lane takes no code for this pairing now, or
|
|
// "" when it does.
|
|
func pipeThrottleRefusal(f pipeThrottleFile, now time.Time) string {
|
|
if f.Failures >= pipeLockAfter {
|
|
return fmt.Sprintf("%d wrong codes in a row came through the tray, so it takes no more codes for this Host. "+
|
|
"Approve once from an Administrator terminal (privatae-bastion approvals, then privatae-bastion approve <id>) "+
|
|
"and the tray takes codes again", f.Failures)
|
|
}
|
|
if f.Failures < pipeFreeFailures {
|
|
return ""
|
|
}
|
|
last, err := time.Parse(time.RFC3339, f.Last)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if wait := pipeSlowEvery - now.Sub(last); wait > 0 {
|
|
return fmt.Sprintf("%d wrong codes in a row; try again in %d seconds", f.Failures, int(wait.Seconds()+0.999))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pipeThrottleFail records one wrong code. Under stepupMu, like every other
|
|
// write to a pairing's step-up state.
|
|
func pipeThrottleFail(dir string, now time.Time) {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
f := pipeThrottleLoad(dir)
|
|
f.Failures++
|
|
f.Last = now.UTC().Format(time.RFC3339)
|
|
if raw, err := json.MarshalIndent(f, "", " "); err == nil {
|
|
_ = atomicWrite(pipeThrottlePath(dir), raw)
|
|
}
|
|
}
|
|
|
|
// pipeThrottleReset clears the count. stepupApproveRequest calls it after any
|
|
// accepted code, on any lane, so an approval from an Administrator terminal
|
|
// re-opens a locked tray.
|
|
func pipeThrottleReset(dir string) {
|
|
_ = os.Remove(pipeThrottlePath(dir))
|
|
}
|
|
|
|
// pipeCodeError reports whether an approve failed on the code itself (and so
|
|
// counts towards the throttle), rather than on the request or the store.
|
|
func pipeCodeError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return s == "code refused" || s == "code already used"
|
|
}
|
|
|
|
// ── serving one request ─────────────────────────────────────────────────────
|
|
|
|
// pipeServe answers one request from client c. It is the whole lane apart
|
|
// from moving bytes, so the suite runs it directly.
|
|
func pipeServe(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
|
|
if why := pipeAccessRefusal(c); why != "" {
|
|
return pipeResponse{Error: "refused: " + why}
|
|
}
|
|
switch req.Op {
|
|
case "list":
|
|
return pipeList()
|
|
case "approve":
|
|
return pipeApprove(req, c, now)
|
|
case "deny":
|
|
return pipeDeny(req, c)
|
|
}
|
|
return pipeResponse{Error: "unknown request " + strconv.Quote(req.Op)}
|
|
}
|
|
|
|
// pipeList is the machine's state line and every pending step-up request, in
|
|
// the words describeRequest gives them (collectUIStatus reads them through
|
|
// pendingApprovals, which is what the admin page and its approvals popup show).
|
|
func pipeList() pipeResponse {
|
|
s := collectUIStatus()
|
|
out := pipeResponse{OK: true, State: s.State, StateClass: s.StateClass, StateLine: trayStateLine(s)}
|
|
for _, a := range s.Approvals {
|
|
out.Approvals = append(out.Approvals, pipeApproval{
|
|
ID: a.ID, Short: a.Short, Host: a.Host, At: a.At, Lines: a.Lines,
|
|
Unreadable: a.Unreadable, Purpose: a.PurposeSHA256,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pipeTarget resolves a request named by host and id to its pairing's step-up
|
|
// state, refusing unless the request is pending and its purpose is the one the
|
|
// tray showed.
|
|
func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
|
|
if strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.ID) == "" {
|
|
return "", r, errors.New("which request? the tray sends a Host and a request id")
|
|
}
|
|
cfg, err := approvalTarget(req.Host)
|
|
if err != nil {
|
|
return "", r, err
|
|
}
|
|
dir = cfg.dcnDir()
|
|
if dir == "" || !stepupEnrolled(dir) {
|
|
return "", r, fmt.Errorf("no second factor is enrolled on this machine for %s", cfg.HostName)
|
|
}
|
|
stepupMu.Lock()
|
|
r, ok := requestsLoad(dir)[req.ID]
|
|
stepupMu.Unlock()
|
|
if !ok || r.State != "pending" {
|
|
return "", r, errors.New("that request is no longer waiting (answered elsewhere, or lapsed)")
|
|
}
|
|
// BOUND TO WHAT WAS SHOWN. An id names one request, and a request's purpose
|
|
// never changes; the check makes that a property of this lane rather than
|
|
// of how ids are made, so a tray drawing one card and posting another's id
|
|
// approves nothing.
|
|
if req.Purpose == "" || req.Purpose != purposeDigest(r.Purpose) {
|
|
return "", r, errors.New("that request is not the one shown; nothing was approved — reopen the list")
|
|
}
|
|
return dir, r, nil
|
|
}
|
|
|
|
// pipeApproveMu makes the throttle's check, the attempt and the count one step:
|
|
// without it, pipeMaxClients connections could each pass the check before any
|
|
// of their failures was recorded.
|
|
var pipeApproveMu sync.Mutex
|
|
|
|
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
|
|
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
|
|
// approval's audit line names the account that gave it, and when the
|
|
// directory could not say whose account this is, nothing is approved.
|
|
// Before the throttle and the code, so this counts as no wrong code.
|
|
if c.NameTimedOut {
|
|
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
|
|
" in time; try again in a minute, or approve from an Administrator terminal"}
|
|
}
|
|
pipeApproveMu.Lock()
|
|
defer pipeApproveMu.Unlock()
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
return pipeResponse{Error: "enter the code from the authenticator enrolled on this machine"}
|
|
}
|
|
dir, _, err := pipeTarget(req)
|
|
if err != nil {
|
|
return pipeResponse{Error: err.Error()}
|
|
}
|
|
stepupMu.Lock()
|
|
thr := pipeThrottleLoad(dir)
|
|
stepupMu.Unlock()
|
|
if why := pipeThrottleRefusal(thr, now); why != "" {
|
|
return pipeResponse{Error: why}
|
|
}
|
|
if _, err := stepupApproveRequest(dir, req.ID, code); err != nil {
|
|
if pipeCodeError(err) {
|
|
pipeThrottleFail(dir, now)
|
|
dcnAudit(dir, "stepup_approve_tray", 0, "", "refused for "+pipeWho(c)+": "+err.Error())
|
|
}
|
|
return pipeResponse{Error: "not approved: " + err.Error()}
|
|
}
|
|
dcnAudit(dir, "stepup_approve_tray", 0, "", "approved by "+pipeWho(c))
|
|
return pipeResponse{OK: true, Message: "Approved. " + req.Host + " picks this up on its next check-in, within a minute."}
|
|
}
|
|
|
|
func pipeDeny(req pipeRequest, c pipeClient) pipeResponse {
|
|
dir, _, err := pipeTarget(req)
|
|
if err != nil {
|
|
return pipeResponse{Error: err.Error()}
|
|
}
|
|
// No code, as on every other lane: refusing is the safe direction.
|
|
if err := stepupDenyRequest(dir, req.ID); err != nil {
|
|
return pipeResponse{Error: "not denied: " + err.Error()}
|
|
}
|
|
dcnAudit(dir, "stepup_deny_tray", 0, "", "denied by "+pipeWho(c))
|
|
return pipeResponse{OK: true, Message: "Denied. " + req.Host + " is told on its next check-in."}
|
|
}
|
|
|
|
// ── the accept loop ─────────────────────────────────────────────────────────
|
|
|
|
// errPipeStopped: a wait ended because the service is stopping.
|
|
var errPipeStopped = errors.New("stopped")
|
|
|
|
// pipeInstances is the pipe as pipeAcceptLoop sees it. approvalPipeServer
|
|
// (approvalpipe_windows.go) is the real one; the suite drives a fake, so the
|
|
// loop's one invariant is checked off Windows.
|
|
type pipeInstances[T any] interface {
|
|
// create opens a new server instance of the pipe's name.
|
|
create() (T, error)
|
|
// connect waits for a client on t: nil once one is connected,
|
|
// errPipeStopped when the service stopped first.
|
|
connect(t T) error
|
|
// release closes t.
|
|
release(t T)
|
|
}
|
|
|
|
// pipeAcceptLoop serves the pipe until stop is closed, handing each connected
|
|
// instance to serve on its own goroutine, at most maxClients at once.
|
|
//
|
|
// NO GAP IN THE NAME. From the first create until stop, one of our instances
|
|
// is always open: a pipe's name exists only while an instance of it is, and in
|
|
// a gap any process could create the name and be the server the tray dials
|
|
// next. So a replacement is always created BEFORE the instance it replaces is
|
|
// released, after a connect that worked and after one that failed (a client
|
|
// that connects and closes at once fails the connect, and can do that on
|
|
// purpose, as often as it likes).
|
|
//
|
|
// ready, when not nil, is called once the first instance exists: from then on
|
|
// the service holds the name.
|
|
func pipeAcceptLoop[T any](p pipeInstances[T], stop <-chan struct{}, maxClients int, retry time.Duration,
|
|
logf func(string, ...any), ready func(), serve func(T)) error {
|
|
slots := make(chan struct{}, maxClients)
|
|
next, err := p.create()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ready != nil {
|
|
ready()
|
|
}
|
|
for {
|
|
c := next
|
|
select {
|
|
case slots <- struct{}{}:
|
|
case <-stop:
|
|
p.release(c)
|
|
return nil
|
|
}
|
|
if err := p.connect(c); err != nil {
|
|
<-slots
|
|
if errors.Is(err, errPipeStopped) {
|
|
p.release(c)
|
|
return nil
|
|
}
|
|
// One failed connect is that client's. Its replacement is created
|
|
// while it is still open, and only then is it closed: no gap.
|
|
fresh, cerr := p.create()
|
|
p.release(c)
|
|
if cerr != nil {
|
|
return cerr
|
|
}
|
|
next = fresh
|
|
continue
|
|
}
|
|
// The next instance first, while this one is still open: no gap.
|
|
for {
|
|
if next, err = p.create(); err == nil {
|
|
break
|
|
}
|
|
logf("approvals pipe: %v; retrying\n", err)
|
|
select {
|
|
case <-stop:
|
|
p.release(c)
|
|
return nil
|
|
case <-time.After(retry):
|
|
}
|
|
}
|
|
go func(c T) {
|
|
defer func() { <-slots }()
|
|
defer p.release(c)
|
|
serve(c)
|
|
}(c)
|
|
}
|
|
}
|
|
|
|
// pipeRetryFirst and pipeRetryMax bound the wait between attempts to serve
|
|
// the pipe after one failed.
|
|
const (
|
|
pipeRetryFirst = time.Second
|
|
pipeRetryMax = time.Minute
|
|
)
|
|
|
|
// pipeServeRetrying runs serve until stop (serve returns nil), starting it
|
|
// again whenever it fails. serve calls ready once it holds the pipe's name.
|
|
// wait sleeps, and reports false when the service stopped meanwhile.
|
|
//
|
|
// THE NAME TAKEN AT START. A process that creates the pipe's name before the
|
|
// service does (at boot, or in the five seconds of a restart) makes the
|
|
// service's first create fail, because that create insists on the name being
|
|
// new (FILE_FLAG_FIRST_PIPE_INSTANCE): the service never serves under a name it
|
|
// did not create. Rather than leaving the tray off until the next restart, the
|
|
// service tries again, waiting pipeRetryFirst and doubling to pipeRetryMax,
|
|
// and starts the wait again once it holds the name. The log says so once per
|
|
// series of failed attempts and once when serving again, not once per try.
|
|
func pipeServeRetrying(serve func(ready func()) error, wait func(time.Duration) bool, logf func(string, ...any)) {
|
|
backoff := pipeRetryFirst
|
|
failing := false
|
|
for {
|
|
err := serve(func() {
|
|
if failing {
|
|
logf("approvals pipe: serving again\n")
|
|
failing = false
|
|
}
|
|
backoff = pipeRetryFirst
|
|
})
|
|
if err == nil {
|
|
return
|
|
}
|
|
if !failing {
|
|
logf("approvals pipe: %v. Approvals are answered from an Administrator terminal meanwhile; "+
|
|
"retrying, waiting up to %s between attempts.\n", err, pipeRetryMax)
|
|
failing = true
|
|
}
|
|
if !wait(backoff) {
|
|
return
|
|
}
|
|
if backoff *= 2; backoff > pipeRetryMax {
|
|
backoff = pipeRetryMax
|
|
}
|
|
}
|
|
}
|
|
|
|
// pipeDecode reads one request, refusing anything that is not one JSON object
|
|
// within pipeMaxRequest.
|
|
func pipeDecode(raw []byte) (pipeRequest, error) {
|
|
var req pipeRequest
|
|
if len(raw) > pipeMaxRequest {
|
|
return req, errors.New("request too large")
|
|
}
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
return req, errors.New("not a request")
|
|
}
|
|
return req, nil
|
|
}
|