elevate: broker credential support for privileged tool execution
This commit is contained in:
parent
5955562c1d
commit
3d65d787a2
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -239,11 +240,31 @@ func runServer(sockPath string) {
|
||||||
notifyFn := func(req *elevate.Request) {
|
notifyFn := func(req *elevate.Request) {
|
||||||
notifyElevation(req)
|
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
|
var err error
|
||||||
elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{
|
elevateBroker, err = elevate.NewBroker(elevate.BrokerConfig{
|
||||||
SocketPath: elevateSocket,
|
SocketPath: elevateSocket,
|
||||||
PolicyPath: policyPath,
|
PolicyPath: policyPath,
|
||||||
Notify: notifyFn,
|
Notify: notifyFn,
|
||||||
|
Credential: credentialPrompt,
|
||||||
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
|
Logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) },
|
||||||
SessionValid: func(id string) bool { return fs.Lookup(sessionTree, id) != nil },
|
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)
|
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.
|
// Broker manages elevation requests.
|
||||||
type Broker struct {
|
type Broker struct {
|
||||||
policy *PolicyStore
|
policy *PolicyStore
|
||||||
notify NotifyFunc
|
notify NotifyFunc
|
||||||
|
credential CredentialFunc
|
||||||
logf func(string, ...any)
|
logf func(string, ...any)
|
||||||
sessionValid func(string) bool
|
sessionValid func(string) bool
|
||||||
|
|
||||||
|
|
@ -49,10 +46,18 @@ type BrokerConfig struct {
|
||||||
SocketPath string
|
SocketPath string
|
||||||
PolicyPath string // disk path for global policy YAML
|
PolicyPath string // disk path for global policy YAML
|
||||||
Notify NotifyFunc
|
Notify NotifyFunc
|
||||||
|
Credential CredentialFunc // prompts user for sudo password; nil = sudo unsupported
|
||||||
Logf func(string, ...any)
|
Logf func(string, ...any)
|
||||||
SessionValid func(sessionID string) bool // returns true if session exists
|
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.
|
// NewBroker creates and starts the elevation broker.
|
||||||
func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
||||||
if cfg.Logf == nil {
|
if cfg.Logf == nil {
|
||||||
|
|
@ -79,6 +84,7 @@ func NewBroker(cfg BrokerConfig) (*Broker, error) {
|
||||||
b := &Broker{
|
b := &Broker{
|
||||||
policy: NewPolicyStore(cfg.PolicyPath),
|
policy: NewPolicyStore(cfg.PolicyPath),
|
||||||
notify: cfg.Notify,
|
notify: cfg.Notify,
|
||||||
|
credential: cfg.Credential,
|
||||||
logf: cfg.Logf,
|
logf: cfg.Logf,
|
||||||
sessionValid: cfg.SessionValid,
|
sessionValid: cfg.SessionValid,
|
||||||
pending: make(map[string]*Request),
|
pending: make(map[string]*Request),
|
||||||
|
|
@ -262,6 +268,7 @@ func (b *Broker) handleConn(conn net.Conn) {
|
||||||
Cwd string `json:"cwd"`
|
Cwd string `json:"cwd"`
|
||||||
Env map[string]string `json:"env"`
|
Env map[string]string `json:"env"`
|
||||||
Session string `json:"session"`
|
Session string `json:"session"`
|
||||||
|
Sudo bool `json:"sudo"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(buf, &msg); err != nil {
|
if err := json.Unmarshal(buf, &msg); err != nil {
|
||||||
sendFrame(conn, FrameData, []byte(fmt.Sprintf("elevate: bad request: %v\n", err)))
|
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)
|
effective := b.SessionPolicy(sessionID)
|
||||||
if effective.Matches(msg.Cmd, "") {
|
if effective.Matches(msg.Cmd, "") {
|
||||||
b.logf("elevate: auto-approved cmd=%q session=%s", msg.Cmd, sessionID)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,6 +324,7 @@ func (b *Broker) handleConn(conn net.Conn) {
|
||||||
Cwd: msg.Cwd,
|
Cwd: msg.Cwd,
|
||||||
Env: msg.Env,
|
Env: msg.Env,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
|
Sudo: msg.Sudo,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
resolved: make(chan Resolution, 1),
|
resolved: make(chan Resolution, 1),
|
||||||
conn: conn,
|
conn: conn,
|
||||||
|
|
@ -344,7 +356,11 @@ func (b *Broker) handleConn(conn net.Conn) {
|
||||||
switch res {
|
switch res {
|
||||||
case ResolveApprove, ResolvePersist:
|
case ResolveApprove, ResolvePersist:
|
||||||
b.logf("elevate: approved id=%s cmd=%q", req.ID, msg.Cmd)
|
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:
|
default:
|
||||||
b.recordDenial(sessionID)
|
b.recordDenial(sessionID)
|
||||||
b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, msg.Cmd, res)
|
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) {
|
func (b *Broker) executeAndStream(conn net.Conn, cmd, cwd string, env map[string]string) {
|
||||||
c := exec.Command("bash", "-c", cmd)
|
c := exec.Command("bash", "-c", cmd)
|
||||||
c.Dir = cwd
|
c.Dir = cwd
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ type Request struct {
|
||||||
Cwd string
|
Cwd string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
SessionID string // ollie session that made the request (from SO_PEERCRED mapping)
|
SessionID string // ollie session that made the request (from SO_PEERCRED mapping)
|
||||||
|
Sudo bool // requires sudo credential after approval
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
|
||||||
// Resolution channel — exactly one value sent when resolved.
|
// 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()
|
e.wdMu.RLock()
|
||||||
workDir := e.cwd
|
workDir := e.cwd
|
||||||
e.wdMu.RUnlock()
|
e.wdMu.RUnlock()
|
||||||
// sudo -n: non-interactive (NOPASSWD). Full credential broker TBD.
|
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData)
|
||||||
sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | sudo -n %s\n%s\nOLLIE_EOF", code, stdinData)
|
result, err = e.executeElevatedSudo(ctx, sudoCode, workDir, 30)
|
||||||
result, err = e.executeElevated(ctx, sudoCode, workDir, 30)
|
|
||||||
} else if elevated {
|
} else if elevated {
|
||||||
e.wdMu.RLock()
|
e.wdMu.RLock()
|
||||||
workDir := e.cwd
|
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,
|
// Connects to the broker socket, sends the request with the current env,
|
||||||
// and streams the framed response back.
|
// and streams the framed response back.
|
||||||
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
|
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")
|
sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET")
|
||||||
if sockPath == "" {
|
if sockPath == "" {
|
||||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
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"`
|
Cwd string `json:"cwd"`
|
||||||
Env map[string]string `json:"env"`
|
Env map[string]string `json:"env"`
|
||||||
Session string `json:"session,omitempty"`
|
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')
|
reqJSON = append(reqJSON, '\n')
|
||||||
if _, err := conn.Write(reqJSON); err != nil {
|
if _, err := conn.Write(reqJSON); err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue