bypass: remove socket-based execution (dead code)
The old bypass broker used a Unix socket to receive requests and execute commands locally. This was replaced by the 9P-based flow where toolsrv executes commands after receiving approval from olliesrv. Removed: - acceptLoop, handleConn, executeAndStream, executeWithSudo - sendFrame, indexOf, socketDir helpers - SocketPath and Credential config options - Frame constants (FrameData, FrameExit) - net.Conn field on Request The broker now only handles: - Policy management (global + per-session) - Request evaluation (EvaluateRequest) - Rate limiting - User notification callbacks Execution happens in toolsrv, not olliesrv.
This commit is contained in:
parent
8bb5c098cc
commit
62a7d0d13f
|
|
@ -1,33 +1,24 @@
|
|||
package bypass
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
RequestTTL = 300 * time.Second
|
||||
FrameData = 'd'
|
||||
FrameExit = 'x'
|
||||
BurstLimit = 3 // max denied/unanswered requests in burst window
|
||||
RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full)
|
||||
RequestTTL = 300 * time.Second
|
||||
BurstLimit = 3 // max denied/unanswered requests in burst window
|
||||
RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full)
|
||||
)
|
||||
|
||||
// Broker manages elevation requests.
|
||||
// Broker manages bypass request evaluation and policy.
|
||||
// It does NOT execute commands - that happens in toolsrv.
|
||||
type Broker struct {
|
||||
policy *PolicyStore
|
||||
notify NotifyFunc
|
||||
credential CredentialFunc
|
||||
logf func(string, ...any)
|
||||
sessionValid func(string) bool
|
||||
|
||||
|
|
@ -37,16 +28,12 @@ type Broker struct {
|
|||
|
||||
rateMu sync.Mutex
|
||||
limiters map[string]*rate.Limiter // sessionID -> denial rate limiter
|
||||
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// BrokerConfig configures the broker.
|
||||
type BrokerConfig struct {
|
||||
SocketPath string
|
||||
PolicyPath string // disk path for global policy YAML
|
||||
Notify NotifyFunc
|
||||
Credential CredentialFunc // prompts user for sudo password; nil = sudo unsupported
|
||||
Logf func(string, ...any)
|
||||
SessionValid func(sessionID string) bool // returns true if session exists
|
||||
}
|
||||
|
|
@ -54,11 +41,7 @@ type BrokerConfig struct {
|
|||
// NotifyFunc is called when a new elevation request needs user attention.
|
||||
type NotifyFunc func(*Request)
|
||||
|
||||
// CredentialFunc prompts the user for a sudo password. Returns the password
|
||||
// or an error if the user cancels. Called only after elevation is approved.
|
||||
type CredentialFunc func(req *Request) (string, error)
|
||||
|
||||
// NewBroker creates and starts the elevation broker.
|
||||
// NewBroker creates the bypass broker.
|
||||
func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = func(string, ...any) {}
|
||||
|
|
@ -67,40 +50,23 @@ func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
|||
cfg.Notify = func(*Request) {}
|
||||
}
|
||||
|
||||
// Ensure socket directory exists
|
||||
dir := socketDir(cfg.SocketPath)
|
||||
os.MkdirAll(dir, 0700) //nolint:errcheck
|
||||
|
||||
// Remove stale socket
|
||||
os.Remove(cfg.SocketPath) //nolint:errcheck
|
||||
|
||||
ln, err := net.Listen("unix", cfg.SocketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("elevate: listen %s: %w", cfg.SocketPath, err)
|
||||
}
|
||||
// Restrict socket permissions
|
||||
os.Chmod(cfg.SocketPath, 0600) //nolint:errcheck
|
||||
|
||||
b := &Broker{
|
||||
policy: NewPolicyStore(cfg.PolicyPath),
|
||||
notify: cfg.Notify,
|
||||
credential: cfg.Credential,
|
||||
logf: cfg.Logf,
|
||||
sessionValid: cfg.SessionValid,
|
||||
pending: make(map[string]*Request),
|
||||
sessions: make(map[string]*Policy),
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
listener: ln,
|
||||
}
|
||||
|
||||
go b.acceptLoop()
|
||||
cfg.Logf("elevate: listening on %s", cfg.SocketPath)
|
||||
cfg.Logf("elevate: broker ready")
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Close shuts down the broker.
|
||||
func (b *Broker) Close() {
|
||||
b.listener.Close()
|
||||
// No resources to clean up now that socket is gone
|
||||
}
|
||||
|
||||
// Pending returns all pending requests.
|
||||
|
|
@ -184,9 +150,6 @@ func (b *Broker) RemoveSession(sessionID string) {
|
|||
}
|
||||
|
||||
// limiterFor returns (or creates) the denial rate limiter for a session.
|
||||
// The limiter starts full (BurstLimit tokens available). Tokens are only
|
||||
// consumed on denial/timeout, not on approval. One token refills every
|
||||
// RefillInterval (10s by default), so a full burst window is 30s.
|
||||
func (b *Broker) limiterFor(sessionID string) *rate.Limiter {
|
||||
b.rateMu.Lock()
|
||||
defer b.rateMu.Unlock()
|
||||
|
|
@ -204,7 +167,6 @@ func (b *Broker) checkRateLimit(sessionID string) bool {
|
|||
}
|
||||
|
||||
// recordDenial consumes a token from the session's denial budget.
|
||||
// Called when a request is denied or times out (user not responding).
|
||||
func (b *Broker) recordDenial(sessionID string) {
|
||||
if sessionID == "" {
|
||||
return
|
||||
|
|
@ -213,7 +175,7 @@ func (b *Broker) recordDenial(sessionID string) {
|
|||
}
|
||||
|
||||
// EvaluateRequest evaluates a bypass request from toolsrv and returns the decision.
|
||||
// This is the 9P-based flow where execution happens in toolsrv, not here.
|
||||
// Execution happens in toolsrv, not here.
|
||||
// Returns (approved, error). If needs user approval, blocks until resolved or timeout.
|
||||
func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error) {
|
||||
// Validate session
|
||||
|
|
@ -300,304 +262,3 @@ func (b *Broker) addSessionRule(sessionID string, r Rule) {
|
|||
}
|
||||
p.Add(r)
|
||||
}
|
||||
|
||||
func (b *Broker) acceptLoop() {
|
||||
for {
|
||||
conn, err := b.listener.Accept()
|
||||
if err != nil {
|
||||
return // listener closed
|
||||
}
|
||||
go b.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
// Read request (JSON terminated by newline)
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
|
||||
buf := make([]byte, 0, 4096)
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, err := conn.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil || len(buf) > 1024*1024 {
|
||||
break
|
||||
}
|
||||
if idx := indexOf(buf, '\n'); idx >= 0 {
|
||||
buf = buf[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.SetReadDeadline(time.Time{}) //nolint:errcheck
|
||||
|
||||
var msg struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Cwd string `json:"cwd"`
|
||||
Env map[string]string `json:"env"`
|
||||
Session string `json:"session"`
|
||||
Sudo bool `json:"sudo"`
|
||||
}
|
||||
if err := json.Unmarshal(buf, &msg); err != nil {
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: bad request: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Cmd == "" {
|
||||
sendFrame(conn, FrameData, []byte("elevate: empty command\n"))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Identify caller session from request payload
|
||||
sessionID := msg.Session
|
||||
if sessionID == "" {
|
||||
b.logf("elevate: denied cmd=%q (no session identity)", msg.Cmd)
|
||||
sendFrame(conn, FrameData, []byte("elevate-broker: denied (no session identity)\n"))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
if b.sessionValid != nil && !b.sessionValid(sessionID) {
|
||||
b.logf("elevate: denied cmd=%q session=%s (session not found)", msg.Cmd, sessionID)
|
||||
sendFrame(conn, FrameData, []byte("elevate-broker: denied (session not found)\n"))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit: leaky bucket — 3 denied/unanswered requests per 30s window
|
||||
if !b.checkRateLimit(sessionID) {
|
||||
b.logf("elevate: rate-limited session=%s cmd=%q", sessionID, msg.Cmd)
|
||||
sendFrame(conn, FrameData, []byte("elevate-broker: rate-limited (too many denied/unanswered requests)\n"))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Check policy (session policy includes global)
|
||||
effective := b.SessionPolicy(sessionID)
|
||||
if effective.Matches(msg.Cmd, "") {
|
||||
b.logf("elevate: auto-approved cmd=%q session=%s", msg.Cmd, sessionID)
|
||||
if msg.Sudo {
|
||||
b.executeWithSudo(conn, msg.Cmd, msg.Cwd, msg.Env, &Request{Cmd: msg.Cmd, SessionID: sessionID})
|
||||
} else {
|
||||
b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create pending request
|
||||
req := &Request{
|
||||
ID: nextRequestID(),
|
||||
Cmd: msg.Cmd,
|
||||
Cwd: msg.Cwd,
|
||||
Env: msg.Env,
|
||||
SessionID: sessionID,
|
||||
Sudo: msg.Sudo,
|
||||
CreatedAt: time.Now(),
|
||||
resolved: make(chan Resolution, 1),
|
||||
conn: conn,
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.pending[req.ID] = req
|
||||
b.mu.Unlock()
|
||||
|
||||
// Notify (D-Bus notification, etc.)
|
||||
b.notify(req)
|
||||
b.logf("elevate: pending id=%s cmd=%q session=%s", req.ID, msg.Cmd, sessionID)
|
||||
|
||||
// Wait for resolution or timeout
|
||||
timer := time.NewTimer(RequestTTL)
|
||||
defer timer.Stop()
|
||||
|
||||
var res Resolution
|
||||
select {
|
||||
case res = <-req.Resolved():
|
||||
case <-timer.C:
|
||||
// Timeout - remove from pending and deny
|
||||
b.mu.Lock()
|
||||
delete(b.pending, req.ID)
|
||||
b.mu.Unlock()
|
||||
res = ResolveTimeout
|
||||
}
|
||||
|
||||
switch res {
|
||||
case ResolveApprove, ResolvePersist:
|
||||
b.logf("elevate: approved id=%s cmd=%q", req.ID, msg.Cmd)
|
||||
if req.Sudo {
|
||||
b.executeWithSudo(conn, msg.Cmd, msg.Cwd, msg.Env, req)
|
||||
} else {
|
||||
b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env)
|
||||
}
|
||||
default:
|
||||
b.recordDenial(sessionID)
|
||||
b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, msg.Cmd, res)
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate-broker: request %s by user\n", res)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) executeWithSudo(conn net.Conn, cmd, cwd string, env map[string]string, req *Request) {
|
||||
if b.credential == nil {
|
||||
sendFrame(conn, FrameData, []byte("elevate-broker: sudo requested but no credential provider configured\n"))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
password, err := b.credential(req)
|
||||
if err != nil {
|
||||
b.logf("elevate: credential denied for cmd=%q: %v", cmd, err)
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate-broker: credential denied: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Build: printf '%s\n' '<password>' | sudo -S bash -c '<cmd>'
|
||||
// Password is first line on stdin; sudo -S reads it then runs the command.
|
||||
c := exec.Command("sudo", "-S", "bash", "-c", cmd)
|
||||
c.Dir = cwd
|
||||
if len(env) > 0 {
|
||||
c.Env = make([]string, 0, len(env))
|
||||
for k, v := range env {
|
||||
c.Env = append(c.Env, k+"="+v)
|
||||
}
|
||||
}
|
||||
c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
// Pipe password to stdin
|
||||
stdinPipe, err := c.StdinPipe()
|
||||
if err != nil {
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: stdin pipe: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Pipe stdout+stderr to the connection via frames
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: pipe: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
c.Stdout = pw
|
||||
c.Stderr = pw
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: exec sudo: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send password to sudo -S
|
||||
fmt.Fprintf(stdinPipe, "%s\n", password)
|
||||
stdinPipe.Close()
|
||||
pw.Close()
|
||||
|
||||
// Stream output
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := pr.Read(buf)
|
||||
if n > 0 {
|
||||
sendFrame(conn, FrameData, buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
pr.Close()
|
||||
|
||||
err = c.Wait()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
sendFrame(conn, FrameExit, []byte(fmt.Sprintf("%d", exitCode)))
|
||||
}
|
||||
|
||||
func (b *Broker) executeAndStream(conn net.Conn, cmd, cwd string, env map[string]string) {
|
||||
c := exec.Command("bash", "-c", cmd)
|
||||
c.Dir = cwd
|
||||
if len(env) > 0 {
|
||||
c.Env = make([]string, 0, len(env))
|
||||
for k, v := range env {
|
||||
c.Env = append(c.Env, k+"="+v)
|
||||
}
|
||||
}
|
||||
c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
// Pipe stdout+stderr to the connection via frames
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: pipe: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
c.Stdout = pw
|
||||
c.Stderr = pw
|
||||
|
||||
if err := c.Start(); err != nil {
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: exec: %v\n", err)))
|
||||
sendFrame(conn, FrameExit, []byte("1"))
|
||||
return
|
||||
}
|
||||
pw.Close()
|
||||
|
||||
// Stream output
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := pr.Read(buf)
|
||||
if n > 0 {
|
||||
sendFrame(conn, FrameData, buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
pr.Close()
|
||||
|
||||
err = c.Wait()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
sendFrame(conn, FrameExit, []byte(fmt.Sprintf("%d", exitCode)))
|
||||
}
|
||||
|
||||
func sendFrame(conn net.Conn, frameType byte, payload []byte) {
|
||||
header := make([]byte, 5)
|
||||
header[0] = frameType
|
||||
binary.BigEndian.PutUint32(header[1:], uint32(len(payload)))
|
||||
conn.Write(header) //nolint:errcheck
|
||||
conn.Write(payload) //nolint:errcheck
|
||||
}
|
||||
|
||||
func indexOf(b []byte, c byte) int {
|
||||
for i, v := range b {
|
||||
if v == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func socketDir(path string) string {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
return "."
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,18 @@
|
|||
package bypass
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func startTestBroker(t *testing.T) (*Broker, string) {
|
||||
func startTestBroker(t *testing.T) *Broker {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
sockPath := filepath.Join(dir, "elevate.sock")
|
||||
policyPath := filepath.Join(dir, "policy.yaml")
|
||||
|
||||
b, err := NewBroker(BrokerConfig{
|
||||
SocketPath: sockPath,
|
||||
PolicyPath: policyPath,
|
||||
Logf: t.Logf,
|
||||
SessionValid: func(id string) bool { return id == "test-session" },
|
||||
|
|
@ -28,152 +21,107 @@ func startTestBroker(t *testing.T) (*Broker, string) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(b.Close)
|
||||
return b, sockPath
|
||||
}
|
||||
|
||||
func sendRequest(t *testing.T, sockPath, cmd, cwd string) net.Conn {
|
||||
return sendRequestWithSession(t, sockPath, cmd, cwd, "test-session")
|
||||
}
|
||||
|
||||
func sendRequestWithSession(t *testing.T, sockPath, cmd, cwd, session string) net.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := json.Marshal(struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Cwd string `json:"cwd"`
|
||||
Env map[string]string `json:"env"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}{Cmd: cmd, Cwd: cwd, Env: map[string]string{"PATH": "/usr/bin"}, Session: session})
|
||||
req = append(req, '\n')
|
||||
conn.Write(req) //nolint:errcheck
|
||||
return conn
|
||||
}
|
||||
|
||||
func readFrames(t *testing.T, conn net.Conn) (string, int) {
|
||||
t.Helper()
|
||||
var output string
|
||||
exitCode := -1
|
||||
header := make([]byte, 5)
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second)) //nolint:errcheck
|
||||
_, err := conn.Read(header)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[1:5])
|
||||
payload := make([]byte, length)
|
||||
if length > 0 {
|
||||
conn.Read(payload) //nolint:errcheck
|
||||
}
|
||||
switch header[0] {
|
||||
case 'd':
|
||||
output += string(payload)
|
||||
case 'x':
|
||||
fmt.Sscanf(string(payload), "%d", &exitCode)
|
||||
return output, exitCode
|
||||
}
|
||||
}
|
||||
return output, exitCode
|
||||
return b
|
||||
}
|
||||
|
||||
func TestBrokerAutoApprove(t *testing.T) {
|
||||
b, sockPath := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
// Add a policy rule
|
||||
b.GlobalPolicy().AddGlobal(Rule{Cmd: "echo *"})
|
||||
|
||||
// Send a matching command
|
||||
conn := sendRequest(t, sockPath, "echo hello", os.TempDir())
|
||||
defer conn.Close()
|
||||
|
||||
output, exitCode := readFrames(t, conn)
|
||||
if exitCode != 0 {
|
||||
t.Errorf("expected exit 0, got %d; output: %s", exitCode, output)
|
||||
// Evaluate a matching command
|
||||
approved, err := b.EvaluateRequest("test-session", "echo hello", os.TempDir(), nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if output != "hello\n" {
|
||||
t.Errorf("expected 'hello\n', got %q", output)
|
||||
if !approved {
|
||||
t.Error("expected auto-approval for matching policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerDenyNoSession(t *testing.T) {
|
||||
_, sockPath := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
// Send a request with no session ID
|
||||
conn := sendRequestWithSession(t, sockPath, "echo hello", os.TempDir(), "")
|
||||
defer conn.Close()
|
||||
|
||||
output, exitCode := readFrames(t, conn)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("expected exit 1, got %d", exitCode)
|
||||
// Evaluate with no session ID
|
||||
approved, err := b.EvaluateRequest("", "echo hello", os.TempDir(), nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty session")
|
||||
}
|
||||
if !strings.Contains(output, "no session identity") {
|
||||
t.Errorf("expected 'no session identity' in output, got %q", output)
|
||||
if approved {
|
||||
t.Error("should not approve with no session")
|
||||
}
|
||||
if err.Error() != "no session identity" {
|
||||
t.Errorf("expected 'no session identity', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerDenyInvalidSession(t *testing.T) {
|
||||
_, sockPath := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
// Send a request with a session ID that doesn't exist
|
||||
conn := sendRequestWithSession(t, sockPath, "echo hello", os.TempDir(), "nonexistent-session")
|
||||
defer conn.Close()
|
||||
|
||||
output, exitCode := readFrames(t, conn)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("expected exit 1, got %d", exitCode)
|
||||
// Evaluate with a session ID that doesn't exist
|
||||
approved, err := b.EvaluateRequest("nonexistent-session", "echo hello", os.TempDir(), nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid session")
|
||||
}
|
||||
if !strings.Contains(output, "session not found") {
|
||||
t.Errorf("expected 'session not found' in output, got %q", output)
|
||||
if approved {
|
||||
t.Error("should not approve with invalid session")
|
||||
}
|
||||
if err.Error() != "session not found" {
|
||||
t.Errorf("expected 'session not found', got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerDenyOnTimeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping timeout test in short mode")
|
||||
}
|
||||
func TestBrokerPendingAndResolve(t *testing.T) {
|
||||
b := startTestBroker(t)
|
||||
|
||||
// Use a very short TTL for testing
|
||||
dir := t.TempDir()
|
||||
sockPath := filepath.Join(dir, "elevate.sock")
|
||||
policyPath := filepath.Join(dir, "policy.yaml")
|
||||
|
||||
b, err := NewBroker(BrokerConfig{
|
||||
SocketPath: sockPath,
|
||||
PolicyPath: policyPath,
|
||||
Logf: t.Logf,
|
||||
// Start evaluation in a goroutine (it will block waiting for resolution)
|
||||
resultCh := make(chan struct {
|
||||
approved bool
|
||||
err error
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
go func() {
|
||||
approved, err := b.EvaluateRequest("test-session", "rm -rf /", os.TempDir(), nil, false)
|
||||
resultCh <- struct {
|
||||
approved bool
|
||||
err error
|
||||
}{approved, err}
|
||||
}()
|
||||
|
||||
// No policy match → goes to pending
|
||||
conn := sendRequest(t, sockPath, "rm -rf /", os.TempDir())
|
||||
defer conn.Close()
|
||||
|
||||
// Manually deny after a short wait
|
||||
// Wait for it to appear in pending
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
pending := b.Pending()
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("expected 1 pending, got %d", len(pending))
|
||||
}
|
||||
|
||||
// Deny it
|
||||
b.Resolve(pending[0].ID, ResolveDeny)
|
||||
|
||||
output, exitCode := readFrames(t, conn)
|
||||
if exitCode != 1 {
|
||||
t.Errorf("expected exit 1 on deny, got %d", exitCode)
|
||||
// Check result
|
||||
result := <-resultCh
|
||||
if result.err != nil {
|
||||
t.Errorf("unexpected error: %v", result.err)
|
||||
}
|
||||
if result.approved {
|
||||
t.Error("expected denial")
|
||||
}
|
||||
_ = output
|
||||
}
|
||||
|
||||
func TestBrokerApproveAndExecute(t *testing.T) {
|
||||
b, sockPath := startTestBroker(t)
|
||||
func TestBrokerApprove(t *testing.T) {
|
||||
b := startTestBroker(t)
|
||||
|
||||
conn := sendRequest(t, sockPath, "echo approved", os.TempDir())
|
||||
defer conn.Close()
|
||||
resultCh := make(chan struct {
|
||||
approved bool
|
||||
err error
|
||||
})
|
||||
go func() {
|
||||
approved, err := b.EvaluateRequest("test-session", "echo approved", os.TempDir(), nil, false)
|
||||
resultCh <- struct {
|
||||
approved bool
|
||||
err error
|
||||
}{approved, err}
|
||||
}()
|
||||
|
||||
// Wait for it to appear in pending
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
|
@ -185,21 +133,25 @@ func TestBrokerApproveAndExecute(t *testing.T) {
|
|||
// Approve it
|
||||
b.Resolve(pending[0].ID, ResolveApprove)
|
||||
|
||||
output, exitCode := readFrames(t, conn)
|
||||
if exitCode != 0 {
|
||||
t.Errorf("expected exit 0, got %d; output: %s", exitCode, output)
|
||||
// Check result
|
||||
result := <-resultCh
|
||||
if result.err != nil {
|
||||
t.Errorf("unexpected error: %v", result.err)
|
||||
}
|
||||
if output != "approved\n" {
|
||||
t.Errorf("expected 'approved\n', got %q", output)
|
||||
if !result.approved {
|
||||
t.Error("expected approval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokerPersist(t *testing.T) {
|
||||
b, sockPath := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
// First request: no policy, goes to pending
|
||||
conn1 := sendRequest(t, sockPath, "echo persist-me", os.TempDir())
|
||||
defer conn1.Close()
|
||||
resultCh := make(chan bool)
|
||||
go func() {
|
||||
approved, _ := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false)
|
||||
resultCh <- approved
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
pending := b.Pending()
|
||||
|
|
@ -210,24 +162,17 @@ func TestBrokerPersist(t *testing.T) {
|
|||
// Persist it
|
||||
b.Resolve(pending[0].ID, ResolvePersist)
|
||||
|
||||
output, exitCode := readFrames(t, conn1)
|
||||
if exitCode != 0 {
|
||||
t.Errorf("first request: expected exit 0, got %d", exitCode)
|
||||
}
|
||||
if output != "persist-me\n" {
|
||||
t.Errorf("first request: expected 'persist-me\n', got %q", output)
|
||||
if !<-resultCh {
|
||||
t.Error("first request should be approved")
|
||||
}
|
||||
|
||||
// Second request with same command: should auto-approve (no pending)
|
||||
conn2 := sendRequest(t, sockPath, "echo persist-me", os.TempDir())
|
||||
defer conn2.Close()
|
||||
|
||||
output2, exitCode2 := readFrames(t, conn2)
|
||||
if exitCode2 != 0 {
|
||||
t.Errorf("second request: expected exit 0, got %d", exitCode2)
|
||||
approved, err := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("second request error: %v", err)
|
||||
}
|
||||
if output2 != "persist-me\n" {
|
||||
t.Errorf("second request: expected 'persist-me\n', got %q", output2)
|
||||
if !approved {
|
||||
t.Error("second request should auto-approve")
|
||||
}
|
||||
|
||||
// Verify no pending requests
|
||||
|
|
@ -237,7 +182,7 @@ func TestBrokerPersist(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBrokerRateLimitLeakyBucket(t *testing.T) {
|
||||
b, _ := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
sessionID := "test-session"
|
||||
|
||||
|
|
@ -260,9 +205,9 @@ func TestBrokerRateLimitLeakyBucket(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBrokerRateLimitRefill(t *testing.T) {
|
||||
b, _ := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
sid := "refill-test"
|
||||
sid := "test-session"
|
||||
|
||||
// Exhaust the bucket
|
||||
for i := 0; i < BurstLimit; i++ {
|
||||
|
|
@ -272,8 +217,7 @@ func TestBrokerRateLimitRefill(t *testing.T) {
|
|||
t.Fatal("should be rate-limited")
|
||||
}
|
||||
|
||||
// Manually set the limiter to allow (simulates time passing)
|
||||
// We can't easily wait 10s in a unit test, so verify RemoveSession resets.
|
||||
// RemoveSession resets the limiter
|
||||
b.RemoveSession(sid)
|
||||
if !b.checkRateLimit(sid) {
|
||||
t.Fatal("should be allowed after RemoveSession (fresh limiter)")
|
||||
|
|
@ -281,9 +225,9 @@ func TestBrokerRateLimitRefill(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBrokerRemoveSessionCleansUpLimiter(t *testing.T) {
|
||||
b, _ := startTestBroker(t)
|
||||
b := startTestBroker(t)
|
||||
|
||||
sid := "cleanup-test"
|
||||
sid := "test-session"
|
||||
|
||||
// Record some denials
|
||||
b.recordDenial(sid)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package bypass
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -33,19 +32,18 @@ func (r Resolution) String() string {
|
|||
}
|
||||
}
|
||||
|
||||
// Request represents a pending elevation request.
|
||||
// Request represents a pending bypass request.
|
||||
type Request struct {
|
||||
ID string
|
||||
Cmd string
|
||||
Cwd string
|
||||
Env map[string]string
|
||||
SessionID string // ollie session that made the request (from SO_PEERCRED mapping)
|
||||
Sudo bool // requires sudo credential after approval
|
||||
SessionID string
|
||||
Sudo bool
|
||||
CreatedAt time.Time
|
||||
|
||||
// Resolution channel — exactly one value sent when resolved.
|
||||
resolved chan Resolution
|
||||
conn net.Conn // the elevate client connection (held open until resolved)
|
||||
}
|
||||
|
||||
// Resolved returns the channel that receives the resolution.
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"9fans.net/go/plan9/client"
|
||||
|
|
@ -84,39 +82,16 @@ func runServer(sockPath string) {
|
|||
// Server creation deferred until after bypass broker is ready (see below)
|
||||
|
||||
// Start bypass broker
|
||||
bypassSocket := filepath.Join(paths.RuntimeDir(), "ollie", "bypass.sock")
|
||||
os.Setenv("OLLIE_BYPASS_SOCKET", bypassSocket) //nolint:errcheck
|
||||
policyPath := filepath.Join(paths.DataDir(), "bypass-policy.yaml")
|
||||
|
||||
{
|
||||
notifyFn := func(req *bypass.Request) {
|
||||
notifyBypass(req)
|
||||
}
|
||||
credentialPrompt := func(req *bypass.Request) (string, error) {
|
||||
// Try kdialog (KDE), then zenity (GTK), then terminal fallback
|
||||
title := fmt.Sprintf("sudo password for: %s", req.Cmd)
|
||||
if path, err := exec.LookPath("kdialog"); err == nil {
|
||||
out, err := exec.Command(path, "--password", title).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user cancelled")
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), nil
|
||||
}
|
||||
if path, err := exec.LookPath("zenity"); err == nil {
|
||||
out, err := exec.Command(path, "--password", "--title", title).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user cancelled")
|
||||
}
|
||||
return strings.TrimRight(string(out), "\n"), nil
|
||||
}
|
||||
return "", fmt.Errorf("no credential provider available (install kdialog or zenity)")
|
||||
}
|
||||
var err error
|
||||
bypassBroker, err = bypass.NewBroker(bypass.BrokerConfig{
|
||||
SocketPath: bypassSocket,
|
||||
PolicyPath: policyPath,
|
||||
Notify: notifyFn,
|
||||
Credential: credentialPrompt,
|
||||
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
|
||||
SessionValid: func(id string) bool { return session.Lookup(id) != nil },
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue