elevate: broker credential support for privileged tool execution
This commit is contained in:
parent
5955562c1d
commit
3d65d787a2
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -239,11 +240,31 @@ func runServer(sockPath string) {
|
|||
notifyFn := func(req *elevate.Request) {
|
||||
notifyElevation(req)
|
||||
}
|
||||
credentialPrompt := func(req *elevate.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
|
||||
elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{
|
||||
SocketPath: elevateSocket,
|
||||
PolicyPath: policyPath,
|
||||
Notify: notifyFn,
|
||||
Credential: credentialPrompt,
|
||||
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
|
||||
SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,14 +23,11 @@ const (
|
|||
RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full)
|
||||
)
|
||||
|
||||
// NotifyFunc is called when a new request needs human attention.
|
||||
// Implementations should show a notification or log the request.
|
||||
type NotifyFunc func(req *Request)
|
||||
|
||||
// Broker manages elevation requests.
|
||||
type Broker struct {
|
||||
policy *PolicyStore
|
||||
notify NotifyFunc
|
||||
credential CredentialFunc
|
||||
logf func(string, ...any)
|
||||
sessionValid func(string) bool
|
||||
|
||||
|
|
@ -49,10 +46,18 @@ 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
||||
if cfg.Logf == nil {
|
||||
|
|
@ -79,6 +84,7 @@ func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
|||
b := &Broker{
|
||||
policy: NewPolicyStore(cfg.PolicyPath),
|
||||
notify: cfg.Notify,
|
||||
credential: cfg.Credential,
|
||||
logf: cfg.Logf,
|
||||
sessionValid: cfg.SessionValid,
|
||||
pending: make(map[string]*Request),
|
||||
|
|
@ -262,6 +268,7 @@ func (b *Broker) handleConn(conn net.Conn) {
|
|||
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)))
|
||||
|
|
@ -302,7 +309,11 @@ func (b *Broker) handleConn(conn net.Conn) {
|
|||
effective := b.SessionPolicy(sessionID)
|
||||
if effective.Matches(msg.Cmd, "") {
|
||||
b.logf("elevate: auto-approved cmd=%q session=%s", msg.Cmd, sessionID)
|
||||
b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -313,6 +324,7 @@ func (b *Broker) handleConn(conn net.Conn) {
|
|||
Cwd: msg.Cwd,
|
||||
Env: msg.Env,
|
||||
SessionID: sessionID,
|
||||
Sudo: msg.Sudo,
|
||||
CreatedAt: time.Now(),
|
||||
resolved: make(chan Resolution, 1),
|
||||
conn: conn,
|
||||
|
|
@ -344,7 +356,11 @@ func (b *Broker) handleConn(conn net.Conn) {
|
|||
switch res {
|
||||
case ResolveApprove, ResolvePersist:
|
||||
b.logf("elevate: approved id=%s cmd=%q", req.ID, msg.Cmd)
|
||||
b.executeAndStream(conn, msg.Cmd, msg.Cwd, msg.Env)
|
||||
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)
|
||||
|
|
@ -353,6 +369,89 @@ func (b *Broker) handleConn(conn net.Conn) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type Request struct {
|
|||
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
|
||||
CreatedAt time.Time
|
||||
|
||||
// Resolution channel — exactly one value sent when resolved.
|
||||
|
|
|
|||
|
|
@ -338,9 +338,8 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra
|
|||
e.wdMu.RLock()
|
||||
workDir := e.cwd
|
||||
e.wdMu.RUnlock()
|
||||
// sudo -n: non-interactive (NOPASSWD). Full credential broker TBD.
|
||||
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | sudo -n %s\n%s\nOLLIE_EOF", code, stdinData)
|
||||
result, err = e.executeElevated(ctx, sudoCode, workDir, 30)
|
||||
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData)
|
||||
result, err = e.executeElevatedSudo(ctx, sudoCode, workDir, 30)
|
||||
} else if elevated {
|
||||
e.wdMu.RLock()
|
||||
workDir := e.cwd
|
||||
|
|
|
|||
|
|
@ -117,6 +117,14 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) {
|
|||
// Connects to the broker socket, sends the request with the current env,
|
||||
// and streams the framed response back.
|
||||
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
|
||||
return e.executeElevatedOpts(ctx, cmd, dir, timeout, false, doDetach...)
|
||||
}
|
||||
|
||||
func (e *Server) executeElevatedSudo(ctx context.Context, cmd, dir string, timeout int) (string, error) {
|
||||
return e.executeElevatedOpts(ctx, cmd, dir, timeout, true)
|
||||
}
|
||||
|
||||
func (e *Server) executeElevatedOpts(ctx context.Context, cmd, dir string, timeout int, sudo bool, doDetach ...bool) (string, error) {
|
||||
sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET")
|
||||
if sockPath == "" {
|
||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
||||
|
|
@ -159,7 +167,8 @@ func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout i
|
|||
Cwd string `json:"cwd"`
|
||||
Env map[string]string `json:"env"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}{Cmd: cmd, Cwd: dir, Env: envMap, Session: e.sessionID})
|
||||
Sudo bool `json:"sudo,omitempty"`
|
||||
}{Cmd: cmd, Cwd: dir, Env: envMap, Session: e.sessionID, Sudo: sudo})
|
||||
reqJSON = append(reqJSON, '\n')
|
||||
if _, err := conn.Write(reqJSON); err != nil {
|
||||
conn.Close()
|
||||
|
|
|
|||
Loading…
Reference in New Issue