bypass: route approval through 9P instead of Unix socket
This refactors the bypass (sandbox escape) mechanism to work with remote
toolsrv deployments. Previously, bypass used a Unix socket which only
works when toolsrv runs locally. Now:
1. toolsrv exposes bypass/{pending,resolve} 9P files
- pending: blocking read returns next bypass request as JSON
- resolve: write JSON {id, approved, error} to complete request
2. olliesrv reads bypass/pending in a loop per session
- Evaluates requests through the existing bypass broker
- Policy check, rate limiting, user notification all stay in olliesrv
- Writes approval/denial back to bypass/resolve
3. When approved, toolsrv executes the command directly (no sandbox)
- Execution happens on toolsrv's host (local or remote)
- Output streams back through the normal tool call path
This enables bypass to work when toolsrv is remote:
- User sees the approval notification locally
- Command executes on the remote host outside its sandbox
Architecture:
toolsrv (remote) olliesrv (local)
┌─────────────────┐ ┌──────────────────┐
│ sandboxed cmd │ │ bypass broker │
│ ↓ │ │ - policy │
│ bypass.Submit() │──────│ - notification │
│ ↓ │ 9P │ - rate limit │
│ wait for result │←─────│ - user approval │
│ ↓ │ └──────────────────┘
│ execute direct │
└─────────────────┘
This commit is contained in:
parent
1814f82928
commit
8bb5c098cc
|
|
@ -212,6 +212,76 @@ func (b *Broker) recordDenial(sessionID string) {
|
||||||
b.limiterFor(sessionID).Allow()
|
b.limiterFor(sessionID).Allow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EvaluateRequest evaluates a bypass request from toolsrv and returns the decision.
|
||||||
|
// This is the 9P-based flow where 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
|
||||||
|
if sessionID == "" {
|
||||||
|
return false, fmt.Errorf("no session identity")
|
||||||
|
}
|
||||||
|
if b.sessionValid != nil && !b.sessionValid(sessionID) {
|
||||||
|
return false, fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
if !b.checkRateLimit(sessionID) {
|
||||||
|
b.logf("elevate: rate-limited session=%s cmd=%q", sessionID, cmd)
|
||||||
|
return false, fmt.Errorf("rate-limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check policy auto-approve
|
||||||
|
effective := b.SessionPolicy(sessionID)
|
||||||
|
if effective.Matches(cmd, "") {
|
||||||
|
b.logf("elevate: auto-approved cmd=%q session=%s", cmd, sessionID)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pending request for user approval
|
||||||
|
req := &Request{
|
||||||
|
ID: nextRequestID(),
|
||||||
|
Cmd: cmd,
|
||||||
|
Cwd: cwd,
|
||||||
|
Env: env,
|
||||||
|
SessionID: sessionID,
|
||||||
|
Sudo: sudo,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
resolved: make(chan Resolution, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pending[req.ID] = req
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
// Notify user (D-Bus notification, etc.)
|
||||||
|
b.notify(req)
|
||||||
|
b.logf("elevate: pending id=%s cmd=%q session=%s", req.ID, 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:
|
||||||
|
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, cmd)
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
b.recordDenial(sessionID)
|
||||||
|
b.logf("elevate: denied id=%s cmd=%q reason=%s", req.ID, cmd, res)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GlobalPolicy returns the policy store for 9P access.
|
// GlobalPolicy returns the policy store for 9P access.
|
||||||
func (b *Broker) GlobalPolicy() *PolicyStore {
|
func (b *Broker) GlobalPolicy() *PolicyStore {
|
||||||
return b.policy
|
return b.policy
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,13 @@ type Config struct {
|
||||||
func NewRoot(cfg Config) *Tree {
|
func NewRoot(cfg Config) *Tree {
|
||||||
// Initialize session package
|
// Initialize session package
|
||||||
session.Init(session.InitConfig{
|
session.Init(session.InitConfig{
|
||||||
Ctx: cfg.Ctx,
|
Ctx: cfg.Ctx,
|
||||||
Log: cfg.Log,
|
Log: cfg.Log,
|
||||||
Sink: cfg.Sink,
|
Sink: cfg.Sink,
|
||||||
AgentsDir: cfg.AgentsDir,
|
AgentsDir: cfg.AgentsDir,
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
Yolo: cfg.Yolo,
|
Yolo: cfg.Yolo,
|
||||||
|
BypassBroker: cfg.BypassBroker,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build the tree from the spec.
|
// Build the tree from the spec.
|
||||||
|
|
|
||||||
|
|
@ -29,22 +29,24 @@ var (
|
||||||
sessions = make(map[string]*Session)
|
sessions = make(map[string]*Session)
|
||||||
|
|
||||||
// Package config, set via Init.
|
// Package config, set via Init.
|
||||||
serverCtx context.Context
|
serverCtx context.Context
|
||||||
pkgLog *olog.Logger
|
pkgLog *olog.Logger
|
||||||
pkgSink *olog.Sink
|
pkgSink *olog.Sink
|
||||||
pkgAgentsDir string
|
pkgAgentsDir string
|
||||||
pkgSessionsDir string
|
pkgSessionsDir string
|
||||||
pkgYolo bool
|
pkgYolo bool
|
||||||
|
pkgBypassBroker BypassEvaluator
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitConfig configures the session package.
|
// InitConfig configures the session package.
|
||||||
type InitConfig struct {
|
type InitConfig struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Log *olog.Logger
|
Log *olog.Logger
|
||||||
Sink *olog.Sink
|
Sink *olog.Sink
|
||||||
AgentsDir string
|
AgentsDir string
|
||||||
SessionsDir string
|
SessionsDir string
|
||||||
Yolo bool
|
Yolo bool
|
||||||
|
BypassBroker BypassEvaluator // may be nil if bypass is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the session package with the given configuration.
|
// Init initializes the session package with the given configuration.
|
||||||
|
|
@ -55,6 +57,7 @@ func Init(cfg InitConfig) {
|
||||||
pkgAgentsDir = cfg.AgentsDir
|
pkgAgentsDir = cfg.AgentsDir
|
||||||
pkgSessionsDir = cfg.SessionsDir
|
pkgSessionsDir = cfg.SessionsDir
|
||||||
pkgYolo = cfg.Yolo
|
pkgYolo = cfg.Yolo
|
||||||
|
pkgBypassBroker = cfg.BypassBroker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sessions returns a snapshot of all sessions.
|
// Sessions returns a snapshot of all sessions.
|
||||||
|
|
@ -317,6 +320,11 @@ func CreateEmpty(name, remote string) (*Session, error) {
|
||||||
sess.Keeper = infra.Keeper
|
sess.Keeper = infra.Keeper
|
||||||
sess.SetToolsConn(infra.ToolsConn)
|
sess.SetToolsConn(infra.ToolsConn)
|
||||||
|
|
||||||
|
// Start bypass approval loop (if broker is configured)
|
||||||
|
if pkgBypassBroker != nil {
|
||||||
|
sess.StartBypassLoop(pkgBypassBroker)
|
||||||
|
}
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
sessions[name] = sess
|
sessions[name] = sess
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,64 @@ func (s *Session) SetToolsConn(conn *toolsrv.Conn) {
|
||||||
s.toolsConn = conn
|
s.toolsConn = conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartBypassLoop starts a goroutine that reads bypass requests from toolsrv
|
||||||
|
// and evaluates them via the bypass broker. This should be called after
|
||||||
|
// SetToolsConn when the toolsrv connection is ready.
|
||||||
|
func (s *Session) StartBypassLoop(broker BypassEvaluator) {
|
||||||
|
go s.runBypassLoop(broker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BypassEvaluator evaluates bypass requests. Implemented by bypass.Broker.
|
||||||
|
type BypassEvaluator interface {
|
||||||
|
EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) runBypassLoop(broker BypassEvaluator) {
|
||||||
|
for {
|
||||||
|
// Get a fresh connection for each request (blocking reads don't multiplex well)
|
||||||
|
conn := s.DialToolServer()
|
||||||
|
if conn == nil {
|
||||||
|
select {
|
||||||
|
case <-s.Ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := conn.ReadBypassPending()
|
||||||
|
conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-s.Ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
s.log.Debug("bypass read error: %v", err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate the request
|
||||||
|
approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env, req.Sudo)
|
||||||
|
|
||||||
|
// Send resolution back to toolsrv
|
||||||
|
resolveConn := s.DialToolServer()
|
||||||
|
if resolveConn == nil {
|
||||||
|
s.log.Warn("bypass: can't dial to resolve request %s", req.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errMsg := ""
|
||||||
|
if evalErr != nil {
|
||||||
|
errMsg = evalErr.Error()
|
||||||
|
}
|
||||||
|
if err := resolveConn.ResolveBypass(req.ID, approved, errMsg); err != nil {
|
||||||
|
s.log.Warn("bypass: resolve failed for %s: %v", req.ID, err)
|
||||||
|
}
|
||||||
|
resolveConn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsPaused returns true if the session is paused.
|
// IsPaused returns true if the session is paused.
|
||||||
func (s *Session) IsPaused() bool {
|
func (s *Session) IsPaused() bool {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Package bypass handles bypass request submission and resolution for toolsrv.
|
||||||
|
// Bypass requests are submitted by sandboxed execution, exposed via 9P for
|
||||||
|
// external approval (olliesrv), and resolved when the approver responds.
|
||||||
|
package bypass
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request represents a bypass request.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Sudo bool `json:"sudo,omitempty"`
|
||||||
|
|
||||||
|
// Resolution state (not serialized)
|
||||||
|
done chan struct{}
|
||||||
|
approved bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pending = make(chan *Request, 16)
|
||||||
|
mu sync.Mutex
|
||||||
|
requests = make(map[string]*Request)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submit submits a bypass request and blocks until resolved.
|
||||||
|
// Returns (approved, error). If denied, approved is false and error is nil.
|
||||||
|
// If there's an error (e.g., timeout, connection lost), error is non-nil.
|
||||||
|
func Submit(cmd, cwd string, env map[string]string, sudo bool) (bool, error) {
|
||||||
|
req := &Request{
|
||||||
|
ID: nextID(),
|
||||||
|
Cmd: cmd,
|
||||||
|
Cwd: cwd,
|
||||||
|
Env: env,
|
||||||
|
Sudo: sudo,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
requests[req.ID] = req
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
pending <- req
|
||||||
|
<-req.done
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
delete(requests, req.ID)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
return req.approved, req.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextPending blocks until a request is available and returns it.
|
||||||
|
// Used by the 9P pending file to expose requests to the approver.
|
||||||
|
func NextPending() *Request {
|
||||||
|
return <-pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve completes a pending request with the given decision.
|
||||||
|
// Returns false if the request ID is not found (already resolved or invalid).
|
||||||
|
func Resolve(id string, approved bool, errMsg string) bool {
|
||||||
|
mu.Lock()
|
||||||
|
req, ok := requests[id]
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
req.approved = approved
|
||||||
|
if errMsg != "" {
|
||||||
|
req.err = errors.New(errMsg)
|
||||||
|
}
|
||||||
|
close(req.done)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount returns the number of pending requests (for diagnostics).
|
||||||
|
func PendingCount() int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return len(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
@ -4,18 +4,17 @@ package exec
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
osExec "os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ollie/cmd/toolsrv/internal/bypass"
|
||||||
"ollie/cmd/toolsrv/internal/sandbox"
|
"ollie/cmd/toolsrv/internal/sandbox"
|
||||||
"ollie/paths"
|
"ollie/paths"
|
||||||
"ollie/toolsrv"
|
"ollie/toolsrv"
|
||||||
|
|
@ -165,15 +164,15 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
|
||||||
}
|
}
|
||||||
getenv := func(key string) string { return envMap[key] }
|
getenv := func(key string) string { return envMap[key] }
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *osExec.Cmd
|
||||||
if yolo {
|
if yolo {
|
||||||
cmd = exec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
|
cmd = osExec.CommandContext(ctx, interpreter[0], interpreter[1:]...)
|
||||||
} else {
|
} else {
|
||||||
wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv)
|
wrapped, wrapErr := sandbox.WrapCommand(sandboxCfg, interpreter, cwd, getenv)
|
||||||
if wrapErr != nil {
|
if wrapErr != nil {
|
||||||
return "", wrapErr
|
return "", wrapErr
|
||||||
}
|
}
|
||||||
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
|
cmd = osExec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Dir = cwd
|
cmd.Dir = cwd
|
||||||
|
|
@ -239,20 +238,9 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
|
||||||
return string(output), nil
|
return string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeBypassDirect runs a command via the bypass broker.
|
// executeBypassDirect requests bypass approval and executes the command directly.
|
||||||
|
// The approval comes from olliesrv via the 9P bypass/pending and bypass/resolve files.
|
||||||
func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int, sudo bool) (string, error) {
|
func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[string]string, timeout int, sudo bool) (string, error) {
|
||||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
|
||||||
if xdg == "" {
|
|
||||||
return "", fmt.Errorf("bypass not available: no XDG_RUNTIME_DIR")
|
|
||||||
}
|
|
||||||
sockPath := filepath.Join(xdg, "ollie", "bypass.sock")
|
|
||||||
|
|
||||||
if timeout > 0 {
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build environment map
|
// Build environment map
|
||||||
envMap := make(map[string]string)
|
envMap := make(map[string]string)
|
||||||
for _, kv := range os.Environ() {
|
for _, kv := range os.Environ() {
|
||||||
|
|
@ -264,78 +252,77 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri
|
||||||
envMap[k] = v
|
envMap[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect and send request
|
// Submit bypass request and wait for approval
|
||||||
conn, err := net.DialTimeout("unix", sockPath, 5*time.Second)
|
approved, err := bypass.Submit(cmd, cwd, envMap, sudo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("bypass not available: %w", err)
|
return "", fmt.Errorf("bypass request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
if !approved {
|
||||||
|
return "", fmt.Errorf("bypass denied")
|
||||||
reqJSON, _ := json.Marshal(struct {
|
|
||||||
Cmd string `json:"cmd"`
|
|
||||||
Cwd string `json:"cwd"`
|
|
||||||
Env map[string]string `json:"env"`
|
|
||||||
Session string `json:"session"`
|
|
||||||
Sudo bool `json:"sudo,omitempty"`
|
|
||||||
}{Cmd: cmd, Cwd: cwd, Env: envMap, Session: os.Getenv("OLLIE_SESSION_ID"), Sudo: sudo})
|
|
||||||
reqJSON = append(reqJSON, '\n')
|
|
||||||
|
|
||||||
if _, err := conn.Write(reqJSON); err != nil {
|
|
||||||
return "", fmt.Errorf("bypass write failed: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response frames
|
// Approved - execute directly without sandbox
|
||||||
var outputBuf bytes.Buffer
|
if timeout > 0 {
|
||||||
exitCode := readBypassFrames(ctx, conn, &outputBuf, StreamFunc(ctx))
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
output := outputBuf.String()
|
return executeDirectUnsandboxed(ctx, cmd, cwd, envMap, sudo)
|
||||||
if exitCode != 0 {
|
}
|
||||||
return output, fmt.Errorf("bypass execution failed (exit %d)", exitCode)
|
|
||||||
|
// executeDirectUnsandboxed runs a command without any sandbox.
|
||||||
|
func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[string]string, sudo bool) (string, error) {
|
||||||
|
var execCmd *osExec.Cmd
|
||||||
|
if sudo {
|
||||||
|
execCmd = osExec.CommandContext(ctx, "sudo", "-E", "bash", "-c", cmd)
|
||||||
|
} else {
|
||||||
|
execCmd = osExec.CommandContext(ctx, "bash", "-c", cmd)
|
||||||
|
}
|
||||||
|
execCmd.Dir = cwd
|
||||||
|
if len(env) > 0 {
|
||||||
|
execCmd.Env = make([]string, 0, len(env))
|
||||||
|
for k, v := range env {
|
||||||
|
execCmd.Env = append(execCmd.Env, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execCmd.Stdout = &stdout
|
||||||
|
execCmd.Stderr = &stderr
|
||||||
|
|
||||||
|
// Stream output if context has a stream function
|
||||||
|
if stream := StreamFunc(ctx); stream != nil {
|
||||||
|
execCmd.Stdout = io.MultiWriter(&stdout, &streamWriter{stream})
|
||||||
|
execCmd.Stderr = io.MultiWriter(&stderr, &streamWriter{stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := execCmd.Run()
|
||||||
|
output := stdout.String()
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
if output != "" {
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
output += stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*osExec.ExitError); ok {
|
||||||
|
return output, fmt.Errorf("exit %d", exitErr.ExitCode())
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
}
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readBypassFrames reads framed output from bypass broker.
|
// streamWriter adapts a stream function to io.Writer.
|
||||||
func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, stream func(string)) int {
|
type streamWriter struct {
|
||||||
header := make([]byte, 5)
|
fn func(string)
|
||||||
for {
|
}
|
||||||
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
|
||||||
_, err := io.ReadFull(conn, header)
|
|
||||||
if err != nil {
|
|
||||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return -1
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
frameType := header[0]
|
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||||
length := binary.BigEndian.Uint32(header[1:5])
|
w.fn(string(p))
|
||||||
|
return len(p), nil
|
||||||
payload := make([]byte, length)
|
|
||||||
if length > 0 {
|
|
||||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
|
||||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch frameType {
|
|
||||||
case 'd':
|
|
||||||
w.Write(payload)
|
|
||||||
if stream != nil {
|
|
||||||
stream(string(payload))
|
|
||||||
}
|
|
||||||
case 'x':
|
|
||||||
var code int
|
|
||||||
fmt.Sscanf(string(payload), "%d", &code)
|
|
||||||
return code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan9Namespace computes the Plan 9 namespace directory.
|
// Plan9Namespace computes the Plan 9 namespace directory.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"ollie/cmd/toolsrv/internal/bypass"
|
||||||
"ollie/cmd/toolsrv/internal/registry"
|
"ollie/cmd/toolsrv/internal/registry"
|
||||||
"ollie/virtfs"
|
"ollie/virtfs"
|
||||||
)
|
)
|
||||||
|
|
@ -129,6 +131,36 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
|
||||||
return []byte(srv.HostInfo()), nil
|
return []byte(srv.HostInfo()), nil
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
virtfs.DirNode("bypass",
|
||||||
|
virtfs.FileNode("pending", 0444,
|
||||||
|
virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env, sudo}"),
|
||||||
|
virtfs.BlockOnce(func(_ context.Context, _ string) ([]byte, string, error) {
|
||||||
|
req := bypass.NextPending()
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return append(data, '\n'), "", nil
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
virtfs.FileNode("resolve", 0222,
|
||||||
|
virtfs.Doc("Resolve bypass request: write JSON {id, approved} or {id, error}"),
|
||||||
|
virtfs.Write(func(data []byte) error {
|
||||||
|
var msg struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Approved bool `json:"approved"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
if !bypass.Resolve(msg.ID, msg.Approved, msg.Error) {
|
||||||
|
return fmt.Errorf("unknown request ID: %s", msg.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
virtfs.DirNode("proc",
|
virtfs.DirNode("proc",
|
||||||
virtfs.FileNode("new", 0666,
|
virtfs.FileNode("new", 0666,
|
||||||
virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"),
|
virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"),
|
||||||
|
|
|
||||||
|
|
@ -523,3 +523,69 @@ func escapeValue(s string) string {
|
||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BypassRequest represents a bypass request from toolsrv.
|
||||||
|
type BypassRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
Sudo bool `json:"sudo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBypassPending blocks until a bypass request is available.
|
||||||
|
// This should be called in a loop by the approval handler.
|
||||||
|
func (c *Conn) ReadBypassPending() (*BypassRequest, error) {
|
||||||
|
fid, err := c.fsys.Open("bypass/pending", plan9.OREAD)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open bypass/pending: %w", err)
|
||||||
|
}
|
||||||
|
defer fid.Close()
|
||||||
|
|
||||||
|
// Blocking read - will return when a request is available
|
||||||
|
var result []byte
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
for {
|
||||||
|
n, err := fid.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
result = append(result, buf[:n]...)
|
||||||
|
}
|
||||||
|
if err == io.EOF || n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read bypass/pending: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req BypassRequest
|
||||||
|
if err := json.Unmarshal(result, &req); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse bypass request: %w", err)
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBypass sends the approval/denial decision for a bypass request.
|
||||||
|
func (c *Conn) ResolveBypass(id string, approved bool, errMsg string) error {
|
||||||
|
fid, err := c.fsys.Open("bypass/resolve", plan9.OWRITE)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open bypass/resolve: %w", err)
|
||||||
|
}
|
||||||
|
defer fid.Close()
|
||||||
|
|
||||||
|
msg := struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Approved bool `json:"approved"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}{ID: id, Approved: approved, Error: errMsg}
|
||||||
|
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal resolve: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fid.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("write bypass/resolve: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue