mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-28 18:41:30 +02:00
220 lines
6.4 KiB
Go
220 lines
6.4 KiB
Go
// Package approval brokers per-attempt user-accept prompts for inbound
|
|
// remote access (VNC today, SSH and others in the future). A caller pushes
|
|
// a Prompt; the broker emits a SystemEvent on the daemon→UI stream and
|
|
// blocks until the UI calls the daemon's RespondApproval RPC, the per-
|
|
// request timeout fires, or no subscriber is connected. The latter case
|
|
// fails closed so a backgrounded UI cannot silently bypass the gate.
|
|
package approval
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// Metadata keys the broker reserves on the emitted SystemEvent. Callers
|
|
// should not set these themselves; values in Prompt.Metadata that collide
|
|
// are overwritten by the broker.
|
|
const (
|
|
MetaRequestID = "request_id"
|
|
MetaKind = "kind"
|
|
MetaExpiresAt = "expires_at"
|
|
)
|
|
|
|
// ShortKeyFingerprint formats a hex-encoded Noise_IK static pubkey as a
|
|
// short, eyeball-able fingerprint to display in the approval dialog.
|
|
// The dashboard-supplied display name attached to a SessionPubKey isn't
|
|
// cryptographically asserted by the connecting client, so the prompt
|
|
// must also show something that IS: the key fingerprint, a hash of
|
|
// the static public key the client just proved possession of during the
|
|
// Noise handshake. Returns the empty string when the input is too short
|
|
// to plausibly be a hex pubkey, so the row is omitted rather than
|
|
// rendered as a misleading partial.
|
|
//
|
|
// Output format: 16 hex chars grouped as XXXX-XXXX-XXXX-XXXX (64 bits of
|
|
// fingerprint, resistant to random-prefix collisions and easy for a human
|
|
// to compare with an out-of-band reference).
|
|
func ShortKeyFingerprint(hexKey string) string {
|
|
if len(hexKey) < 8 {
|
|
return ""
|
|
}
|
|
src := hexKey
|
|
if len(src) > 16 {
|
|
src = src[:16]
|
|
}
|
|
var out []byte
|
|
for i, c := range src {
|
|
if i > 0 && i%4 == 0 {
|
|
out = append(out, '-')
|
|
}
|
|
out = append(out, byte(c))
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// Kind values for the well-known prompt subjects. New subsystems should
|
|
// add a constant here so the UI can dispatch on a known string.
|
|
const (
|
|
KindVNC = "vnc"
|
|
KindSSH = "ssh"
|
|
)
|
|
|
|
// DefaultTimeout is the wall-clock window the user has to accept or deny a
|
|
// pending approval before the broker fails closed and returns ErrTimeout.
|
|
// Kept well under typical VNC client and dashboard connection timeouts so
|
|
// the RFB rejection actually reaches the browser instead of racing the
|
|
// browser's own "connection timed out" message.
|
|
const DefaultTimeout = 15 * time.Second
|
|
|
|
// timeoutValue returns the active timeout. It's a var so tests in this
|
|
// package can shorten the wait without exposing a setter on the public
|
|
// API. Production code always sees DefaultTimeout.
|
|
var timeoutValue = func() time.Duration { return DefaultTimeout }
|
|
|
|
// ErrNoSubscriber indicates no UI is connected to consume the prompt.
|
|
// The caller must reject the underlying connection (fail-closed).
|
|
var ErrNoSubscriber = errors.New("no UI subscriber connected for approval")
|
|
|
|
// ErrTimeout indicates the user did not respond within DefaultTimeout.
|
|
var ErrTimeout = errors.New("approval timed out")
|
|
|
|
// ErrDenied indicates the user explicitly denied the connection.
|
|
var ErrDenied = errors.New("approval denied")
|
|
|
|
// EventPublisher is the subset of peer.Status used to emit prompts.
|
|
type EventPublisher interface {
|
|
PublishEvent(
|
|
severity proto.SystemEvent_Severity,
|
|
category proto.SystemEvent_Category,
|
|
msg string,
|
|
userMsg string,
|
|
metadata map[string]string,
|
|
)
|
|
HasEventSubscribers() bool
|
|
}
|
|
|
|
// Prompt describes the pending request shown to the user. Kind selects
|
|
// the UI dispatch path (e.g. "vnc", "ssh"). Subject is the human-readable
|
|
// one-liner the UI may show as a title or notification body. Metadata is
|
|
// passed through verbatim and is the subsystem-specific payload (peer
|
|
// name, source IP, mode, etc.).
|
|
type Prompt struct {
|
|
Kind string
|
|
Subject string
|
|
Metadata map[string]string
|
|
}
|
|
|
|
// Decision carries the user's response to an approval prompt. ViewOnly is
|
|
// only meaningful when Accept is true; it lets the host grant the
|
|
// connection but signal the requester that input control is withheld.
|
|
type Decision struct {
|
|
Accept bool
|
|
ViewOnly bool
|
|
}
|
|
|
|
// Broker holds in-flight approval requests keyed by request ID.
|
|
type Broker struct {
|
|
pub EventPublisher
|
|
|
|
mu sync.Mutex
|
|
pending map[string]chan Decision
|
|
}
|
|
|
|
// New returns a broker that publishes prompts via pub.
|
|
func New(pub EventPublisher) *Broker {
|
|
return &Broker{
|
|
pub: pub,
|
|
pending: make(map[string]chan Decision),
|
|
}
|
|
}
|
|
|
|
// Request emits a SystemEvent for p and blocks until the UI calls Respond,
|
|
// ctx is cancelled, or DefaultTimeout elapses. Returns a Decision when
|
|
// the user replied; ErrDenied / ErrTimeout / ErrNoSubscriber / ctx.Err
|
|
// otherwise. Callers must treat any non-nil error as a deny.
|
|
func (b *Broker) Request(ctx context.Context, p Prompt) (Decision, error) {
|
|
var zero Decision
|
|
if b == nil || b.pub == nil {
|
|
return zero, fmt.Errorf("approval broker not configured")
|
|
}
|
|
if !b.pub.HasEventSubscribers() {
|
|
return zero, ErrNoSubscriber
|
|
}
|
|
|
|
id := uuid.NewString()
|
|
resp := make(chan Decision, 1)
|
|
|
|
b.mu.Lock()
|
|
b.pending[id] = resp
|
|
b.mu.Unlock()
|
|
|
|
defer b.dropPending(id)
|
|
|
|
timeout := timeoutValue()
|
|
expiresAt := time.Now().Add(timeout)
|
|
meta := make(map[string]string, len(p.Metadata)+3)
|
|
for k, v := range p.Metadata {
|
|
meta[k] = v
|
|
}
|
|
meta[MetaRequestID] = id
|
|
meta[MetaKind] = p.Kind
|
|
meta[MetaExpiresAt] = expiresAt.UTC().Format(time.RFC3339)
|
|
|
|
subject := p.Subject
|
|
if subject == "" {
|
|
subject = fmt.Sprintf("%s connection requires approval", p.Kind)
|
|
}
|
|
b.pub.PublishEvent(proto.SystemEvent_INFO, proto.SystemEvent_APPROVAL, subject, subject, meta)
|
|
log.Debugf("approval request %s (%s) emitted: %s", id, p.Kind, subject)
|
|
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case d := <-resp:
|
|
if !d.Accept {
|
|
return zero, ErrDenied
|
|
}
|
|
return d, nil
|
|
case <-timer.C:
|
|
return zero, ErrTimeout
|
|
case <-ctx.Done():
|
|
return zero, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Respond delivers the user's decision for id. Returns true when a pending
|
|
// request matched and was woken, false when id was unknown or already done.
|
|
func (b *Broker) Respond(id string, d Decision) bool {
|
|
if b == nil {
|
|
return false
|
|
}
|
|
b.mu.Lock()
|
|
ch, ok := b.pending[id]
|
|
if ok {
|
|
delete(b.pending, id)
|
|
}
|
|
b.mu.Unlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
select {
|
|
case ch <- d:
|
|
default:
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (b *Broker) dropPending(id string) {
|
|
b.mu.Lock()
|
|
delete(b.pending, id)
|
|
b.mu.Unlock()
|
|
}
|