diff --git a/cmd/olliesrv/internal/bypass/broker.go b/cmd/olliesrv/internal/bypass/broker.go index d9eb3e8..90b90ab 100644 --- a/cmd/olliesrv/internal/bypass/broker.go +++ b/cmd/olliesrv/internal/bypass/broker.go @@ -177,7 +177,7 @@ func (b *Broker) recordDenial(sessionID string) { // EvaluateRequest evaluates a bypass request from toolsrv and returns the decision. // 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) { +func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]string) (bool, error) { // Validate session if sessionID == "" { return false, fmt.Errorf("no session identity") @@ -206,7 +206,6 @@ func (b *Broker) EvaluateRequest(sessionID, cmd, cwd string, env map[string]stri Cwd: cwd, Env: env, SessionID: sessionID, - Sudo: sudo, CreatedAt: time.Now(), resolved: make(chan Resolution, 1), } diff --git a/cmd/olliesrv/internal/bypass/broker_test.go b/cmd/olliesrv/internal/bypass/broker_test.go index 6cc1d75..3589c5a 100644 --- a/cmd/olliesrv/internal/bypass/broker_test.go +++ b/cmd/olliesrv/internal/bypass/broker_test.go @@ -31,7 +31,7 @@ func TestBrokerAutoApprove(t *testing.T) { b.GlobalPolicy().AddGlobal(Rule{Cmd: "echo *"}) // Evaluate a matching command - approved, err := b.EvaluateRequest("test-session", "echo hello", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("test-session", "echo hello", os.TempDir(), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -44,7 +44,7 @@ func TestBrokerDenyNoSession(t *testing.T) { b := startTestBroker(t) // Evaluate with no session ID - approved, err := b.EvaluateRequest("", "echo hello", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("", "echo hello", os.TempDir(), nil) if err == nil { t.Fatal("expected error for empty session") } @@ -60,7 +60,7 @@ func TestBrokerDenyInvalidSession(t *testing.T) { b := startTestBroker(t) // Evaluate with a session ID that doesn't exist - approved, err := b.EvaluateRequest("nonexistent-session", "echo hello", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("nonexistent-session", "echo hello", os.TempDir(), nil) if err == nil { t.Fatal("expected error for invalid session") } @@ -81,7 +81,7 @@ func TestBrokerPendingAndResolve(t *testing.T) { err error }) go func() { - approved, err := b.EvaluateRequest("test-session", "rm -rf /", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("test-session", "rm -rf /", os.TempDir(), nil) resultCh <- struct { approved bool err error @@ -116,7 +116,7 @@ func TestBrokerApprove(t *testing.T) { err error }) go func() { - approved, err := b.EvaluateRequest("test-session", "echo approved", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("test-session", "echo approved", os.TempDir(), nil) resultCh <- struct { approved bool err error @@ -149,7 +149,7 @@ func TestBrokerPersist(t *testing.T) { // First request: no policy, goes to pending resultCh := make(chan bool) go func() { - approved, _ := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false) + approved, _ := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil) resultCh <- approved }() @@ -167,7 +167,7 @@ func TestBrokerPersist(t *testing.T) { } // Second request with same command: should auto-approve (no pending) - approved, err := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil, false) + approved, err := b.EvaluateRequest("test-session", "echo persist-me", os.TempDir(), nil) if err != nil { t.Fatalf("second request error: %v", err) } diff --git a/cmd/olliesrv/internal/bypass/request.go b/cmd/olliesrv/internal/bypass/request.go index b570a46..b1ee38f 100644 --- a/cmd/olliesrv/internal/bypass/request.go +++ b/cmd/olliesrv/internal/bypass/request.go @@ -39,7 +39,6 @@ type Request struct { Cwd string Env map[string]string SessionID string - Sudo bool CreatedAt time.Time // Resolution channel — exactly one value sent when resolved. diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index 01335d7..df0e802 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -121,7 +121,7 @@ func (s *Session) StartBypassLoop(broker BypassEvaluator) { // BypassEvaluator evaluates bypass requests. Implemented by bypass.Broker. type BypassEvaluator interface { - EvaluateRequest(sessionID, cmd, cwd string, env map[string]string, sudo bool) (bool, error) + EvaluateRequest(sessionID, cmd, cwd string, env map[string]string) (bool, error) } func (s *Session) runBypassLoop(broker BypassEvaluator) { @@ -151,7 +151,7 @@ func (s *Session) runBypassLoop(broker BypassEvaluator) { } // Evaluate the request - approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env, req.Sudo) + approved, evalErr := broker.EvaluateRequest(s.ID, req.Cmd, req.Cwd, req.Env) // Send resolution back to toolsrv resolveConn := s.DialToolServer() diff --git a/cmd/toolsrv/internal/bypass/bypass.go b/cmd/toolsrv/internal/bypass/bypass.go index 7dfb62e..7e88547 100644 --- a/cmd/toolsrv/internal/bypass/bypass.go +++ b/cmd/toolsrv/internal/bypass/bypass.go @@ -12,11 +12,10 @@ import ( // 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"` + ID string `json:"id"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env,omitempty"` // Resolution state (not serialized) done chan struct{} @@ -33,13 +32,12 @@ var ( // 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) { +func Submit(cmd, cwd string, env map[string]string) (bool, error) { req := &Request{ ID: nextID(), Cmd: cmd, Cwd: cwd, Env: env, - Sudo: sudo, done: make(chan struct{}), } diff --git a/cmd/toolsrv/internal/exec/exec.go b/cmd/toolsrv/internal/exec/exec.go index 7e4295a..e2fb2fd 100644 --- a/cmd/toolsrv/internal/exec/exec.go +++ b/cmd/toolsrv/internal/exec/exec.go @@ -84,16 +84,6 @@ func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessag args, _ = json.Marshal(argMap) } - // Check if tool requires sudo - needsSudo := false - if m, err := toolsrv.LoadMetaFile(info.Name); err == nil && m != nil { - resolved := m.Resolve() - if resolved != nil && resolved.Sudo { - needsSudo = true - bypassed = true - } - } - stdinData := string(args) cwd := cfg.CWD if cwd == "" { @@ -101,12 +91,9 @@ func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessag } var result string - if needsSudo { - sudoCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData) - result, err = executeBypassDirect(ctx, sudoCode, cwd, cfg.Env, timeout, true) - } else if bypassed { + if bypassed { bypassCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", toolPath, stdinData) - result, err = executeBypassDirect(ctx, bypassCode, cwd, cfg.Env, timeout, false) + result, err = executeBypassDirect(ctx, bypassCode, cwd, cfg.Env, timeout) } else { result, err = executeSandboxed(ctx, toolPath, stdinData, cwd, cfg.Env, timeout, cfg.Yolo, cfg.Output, cfg.Started) } @@ -240,7 +227,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE // 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) (string, error) { // Build environment map envMap := make(map[string]string) for _, kv := range os.Environ() { @@ -253,7 +240,7 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri } // Submit bypass request and wait for approval - approved, err := bypass.Submit(cmd, cwd, envMap, sudo) + approved, err := bypass.Submit(cmd, cwd, envMap) if err != nil { return "", fmt.Errorf("bypass request failed: %w", err) } @@ -268,17 +255,12 @@ func executeBypassDirect(ctx context.Context, cmd, cwd string, envExtra map[stri defer cancel() } - return executeDirectUnsandboxed(ctx, cmd, cwd, envMap, sudo) + return executeDirectUnsandboxed(ctx, cmd, cwd, envMap) } // 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) - } +func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[string]string) (string, error) { + execCmd := osExec.CommandContext(ctx, "bash", "-c", cmd) execCmd.Dir = cwd if len(env) > 0 { execCmd.Env = make([]string, 0, len(env)) diff --git a/cmd/toolsrv/internal/server/server.go b/cmd/toolsrv/internal/server/server.go index 910cbec..f889641 100644 --- a/cmd/toolsrv/internal/server/server.go +++ b/cmd/toolsrv/internal/server/server.go @@ -133,7 +133,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl { ), virtfs.DirNode("bypass", virtfs.FileNode("pending", 0444, - virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env, sudo}"), + virtfs.Doc("Blocks until bypass request; returns JSON {id, cmd, cwd, env}"), virtfs.BlockOnce(func(_ context.Context, _ string) ([]byte, string, error) { req := bypass.NextPending() data, err := json.Marshal(req) diff --git a/data/tools/system_logs.meta b/data/tools/system_logs.meta index 7394ae5..42f7c69 100644 --- a/data/tools/system_logs.meta +++ b/data/tools/system_logs.meta @@ -16,8 +16,7 @@ "grep": {"type": "string", "description": "Filter output by pattern"} } }, - "cmd": "system_logs_journald", - "sudo": true + "cmd": "system_logs_journald" }, { "match": {"binary": "dmesg"}, @@ -31,8 +30,7 @@ "grep": {"type": "string", "description": "Filter output by pattern"} } }, - "cmd": "system_logs_dmesg", - "sudo": true + "cmd": "system_logs_dmesg" } ] } diff --git a/doc/core.md b/doc/core.md index b11756b..a25a0e1 100644 --- a/doc/core.md +++ b/doc/core.md @@ -426,7 +426,7 @@ Runs a single bash command in a sandbox. Before execution, inline code is checked against dangerous patterns: -- **Universal**: `mkfs`, `dd if=/dev/`, `sudo/su`, `/etc/shadow` +- **Universal**: `mkfs`, `dd if=/dev/`, `/etc/shadow` - **Bash**: recursive force-delete of system paths, fork bombs, `/dev/sd` writes, eval injection - **Python**: `shutil.rmtree("/")`, subprocess rm -rf, os.remove of system paths - **Perl**: system/exec rm -rf, backtick rm, unlink system paths diff --git a/doc/resources/evolution.md b/doc/resources/evolution.md index 7a4db55..29dd829 100644 --- a/doc/resources/evolution.md +++ b/doc/resources/evolution.md @@ -347,13 +347,6 @@ executable. Enables one `.meta` to work across heterogeneous hosts — the contract adapts to capabilities. Critical for `ollie-remote` deployments where the remote host may have different tools, package managers, or init systems. -### Sudo credential broker -Tools declare `"sudo": true` in their `.meta` to require root privileges. -Dispatch becomes a two-gate chain: bypass approval → credential prompt → `sudo -S`. -Credentials are requested via `kdialog`/`zenity` on the local desktop, or forwarded -over SSH for remote execution. The tool itself has no knowledge of sudo — the -privilege wrapping is entirely in the dispatch layer. - ## Principles That Emerged 1. **Filesystem-as-API** — everything is read/write on synthetic files. No custom protocols needed. @@ -987,7 +980,7 @@ Agent ctl commands: `stop`, `compact`, `clear`, `inject`, `agent`, `model`, `mod ### elevate → bypass -The sandbox escape mechanism was renamed from `elevate` to `bypass` throughout (package, namespace, env vars, tool args, all docs). Eliminates confusion with sudo. +The sandbox escape mechanism was renamed from `elevate` to `bypass` throughout (package, namespace, env vars, tool args, all docs). ### Streaming Fix diff --git a/doc/resources/misc.md b/doc/resources/misc.md index 119cbae..6fcc569 100644 --- a/doc/resources/misc.md +++ b/doc/resources/misc.md @@ -55,7 +55,6 @@ Each tool has a `.meta` JSON sidecar file alongside the executable: | `tier` | Result cacheability: `cold`, `warm`, or `hot` (default: hot) | | `readOnly` | Safe for parallel execution with other read tools | | `cmd` | Executable path or name override | -| `sudo` | Requires root privileges; implies bypass | | `variants` | Conditional definitions for heterogeneous hosts | ### Runtime access diff --git a/doc/tool-registry.md b/doc/tool-registry.md index d20a549..0a26def 100644 --- a/doc/tool-registry.md +++ b/doc/tool-registry.md @@ -56,27 +56,6 @@ The `.meta` file (`file_glob.meta`): Everything is sandboxed by default via Landlock. Tools that need to escape request bypass explicitly (`"bypass": true`). -### Privileged tools: `sudo` - -Tools that need root privileges declare `"sudo": true` in their `.meta`. The -dispatch chain becomes a two-gate sequence: bypass approval (escape sandbox) -→ credential prompt → `sudo -S` execution. The tool itself has no knowledge of -sudo — the privilege wrapping is entirely in the dispatch layer. - -```json -{ - "description": "Read system logs (dmesg).", - "sudo": true, - "cmd": "system_logs_dmesg", - "args": {"type":"object","properties":{"lines":{"type":"string"}}}, - "readOnly": true -} -``` - -Credentials are prompted via `kdialog`/`zenity` on desktop, or forwarded over -SSH for remote execution. See [`doc/writing-tools.md`](doc/writing-tools.md) for -details. - ### Host-conditional variants A single `.meta` file can describe a tool that works differently on different diff --git a/doc/writing-tools.md b/doc/writing-tools.md index bad8c42..7a91d59 100644 --- a/doc/writing-tools.md +++ b/doc/writing-tools.md @@ -8,7 +8,6 @@ tool, you don't edit config files, you don't restart a server. You write a - **What** the tool does (description, prompt, args schema) - **Where** to find it (cmd, co-located executable, or PATH) - **When** it's available (match conditions per variant) -- **What privileges** it needs (sudo) The registry reads the `.meta` file, presents the tool to the model, and executes the binary when called. The model sees a typed function. The runtime @@ -60,7 +59,6 @@ reads only `.meta` files — it never inspects the executable itself. | `tier` | `"hot"\|"warm"\|"cold"` | Context retention tier (default: hot) | | `readOnly` | bool | Safe for parallel execution with other read tools | | `cmd` | string | Executable path or name (see Resolution below) | -| `sudo` | bool | Requires root privileges; implies bypass | | `variants` | array | Conditional definitions for heterogeneous hosts (see Variants below) | ## Executable resolution @@ -248,68 +246,6 @@ Landlock but still as the current user. } ``` -### Sudo: run as root - -Tools that need root privileges declare `"sudo": true` in their `.meta`. -This **implies bypass** — can't sudo inside a sandbox. The dispatch chain -becomes two sequential gates: - -``` -tool call → bypass gate → credential gate → sudo -S tool -``` - -1. **Elevation gate** — broker asks: "approve escaping the sandbox?" User - approves or denies. If denied, the credential gate is never reached. -2. **Credential gate** — broker prompts for sudo password (via `kdialog`, - `zenity`, or terminal). The password is sent over an encrypted channel - (SSH for remote) and piped to `sudo -S`. -3. **Execution** — tool runs as root, output streams back to the agent. - -The tool itself has **no knowledge of sudo**. It reads JSON from stdin and -writes to stdout as always. The privilege wrapping is entirely in the dispatch -layer. - -```json -{ - "description": "Read system logs (dmesg).", - "sudo": true, - "cmd": "system_logs_dmesg", - "args": { - "type": "object", - "properties": { - "lines": {"type": "string", "description": "Number of lines"}, - "source": {"type": "string", "description": "Log source"} - } - }, - "readOnly": true -} -``` - -### Network transparency for privileges - -The same `.meta` works on any host: -- **Local desktop**: kdialog or zenity prompts for password -- **Remote (ollie-remote)**: credential request travels back over the - encrypted SSH tunnel to the local broker, which prompts the user -- **Headless**: terminal-based fallback (or `sudo -n` if NOPASSWD is - configured) - -No assumptions about GUI availability, init system, or sudoers configuration. -The tool adapts to what's available. - -### Example: full privilege chain - -```bash -# system_logs calls dmesg with sudo -system_logs(source="dmesg", lines="10") - -# Behind the scenes: -# 1. Elevation approved → "escape sandbox" -# 2. kdialog prompts for sudo password → "credentials accepted" -# 3. sudo -S dmesg --time-format iso | tail -10 -# 4. Output streams to agent -``` - ## Environment Variables Tools receive: diff --git a/experiments/auth9p/client b/experiments/auth9p/client new file mode 100755 index 0000000..43b47bd Binary files /dev/null and b/experiments/auth9p/client differ diff --git a/experiments/auth9p/client.go b/experiments/auth9p/client.go new file mode 100644 index 0000000..ee516f4 --- /dev/null +++ b/experiments/auth9p/client.go @@ -0,0 +1,125 @@ +// Client: 9P client that authenticates via Tauth +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "net" + "os" + + "9fans.net/go/plan9" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: client ") + fmt.Println(" client new (generate new secret)") + os.Exit(1) + } + + secret := os.Args[1] + if secret == "new" { + b := make([]byte, 16) + rand.Read(b) + secret = hex.EncodeToString(b) + fmt.Printf("Generated secret: %s\n", secret) + } + + sockPath := "/tmp/auth9p-test.sock" + + conn, err := net.Dial("unix", sockPath) + if err != nil { + log.Fatalf("dial: %v", err) + } + defer conn.Close() + + fmt.Println("Connected to server") + + // Tversion + tag := uint16(1) + if err := plan9.WriteFcall(conn, &plan9.Fcall{ + Type: plan9.Tversion, + Tag: plan9.NOTAG, + Msize: 8192, + Version: "9P2000", + }); err != nil { + log.Fatal(err) + } + resp, _ := plan9.ReadFcall(conn) + fmt.Printf("Version: %v\n", resp) + + // Tauth + afid := uint32(1) + if err := plan9.WriteFcall(conn, &plan9.Fcall{ + Type: plan9.Tauth, + Tag: tag, + Afid: afid, + Uname: "agent", + Aname: "", + }); err != nil { + log.Fatal(err) + } + tag++ + resp, _ = plan9.ReadFcall(conn) + fmt.Printf("Auth: %v\n", resp) + if resp.Type == plan9.Rerror { + log.Fatalf("Tauth failed: %s", resp.Ename) + } + + // Write secret to auth fid + if err := plan9.WriteFcall(conn, &plan9.Fcall{ + Type: plan9.Twrite, + Tag: tag, + Fid: afid, + Data: []byte(secret), + Count: uint32(len(secret)), + }); err != nil { + log.Fatal(err) + } + tag++ + resp, _ = plan9.ReadFcall(conn) + fmt.Printf("Write secret: %v\n", resp) + if resp.Type == plan9.Rerror { + log.Fatalf("Auth failed: %s", resp.Ename) + } + + // Read token from auth fid + if err := plan9.WriteFcall(conn, &plan9.Fcall{ + Type: plan9.Tread, + Tag: tag, + Fid: afid, + Offset: 0, + Count: 1024, + }); err != nil { + log.Fatal(err) + } + tag++ + resp, _ = plan9.ReadFcall(conn) + fmt.Printf("Read token: %v\n", resp) + token := string(resp.Data) + fmt.Printf("Session token: %s", token) + + // Tattach + fid := uint32(2) + if err := plan9.WriteFcall(conn, &plan9.Fcall{ + Type: plan9.Tattach, + Tag: tag, + Fid: fid, + Afid: afid, + Uname: "agent", + Aname: "", + }); err != nil { + log.Fatal(err) + } + tag++ + resp, _ = plan9.ReadFcall(conn) + fmt.Printf("Attach: %v\n", resp) + if resp.Type == plan9.Rerror { + log.Fatalf("Attach failed: %s", resp.Ename) + } + + fmt.Println("\n✓ Successfully authenticated and attached!") + fmt.Printf("Use this secret to reconnect: %s\n", secret) +} diff --git a/experiments/auth9p/go.mod b/experiments/auth9p/go.mod new file mode 100644 index 0000000..1f99ca4 --- /dev/null +++ b/experiments/auth9p/go.mod @@ -0,0 +1,5 @@ +module auth9p + +go 1.23 + +require 9fans.net/go v0.0.5 diff --git a/experiments/auth9p/go.sum b/experiments/auth9p/go.sum new file mode 100644 index 0000000..5089ea2 --- /dev/null +++ b/experiments/auth9p/go.sum @@ -0,0 +1,34 @@ +9fans.net/go v0.0.5 h1:u0H3Et5NowdVm56FDD+aWcDOao9zEo+zolNFsr8qyQk= +9fans.net/go v0.0.5/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/experiments/auth9p/server b/experiments/auth9p/server new file mode 100755 index 0000000..68a5967 Binary files /dev/null and b/experiments/auth9p/server differ diff --git a/experiments/auth9p/server.go b/experiments/auth9p/server.go new file mode 100644 index 0000000..2e93ec7 --- /dev/null +++ b/experiments/auth9p/server.go @@ -0,0 +1,216 @@ +// Server: 9P server with Tauth-based authentication +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "os" + "sync" + + "9fans.net/go/plan9" +) + +type Server struct { + mu sync.Mutex + secret string // set on first auth, verified on subsequent + token string // session token returned after auth +} + +func main() { + sockPath := "/tmp/auth9p-test.sock" + os.Remove(sockPath) + + ln, err := net.Listen("unix", sockPath) + if err != nil { + log.Fatal(err) + } + defer ln.Close() + defer os.Remove(sockPath) + + fmt.Printf("Server listening on %s\n", sockPath) + fmt.Println("Waiting for connections...") + + srv := &Server{} + + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("accept: %v", err) + continue + } + go srv.handle(conn) + } +} + +func (s *Server) handle(conn net.Conn) { + defer conn.Close() + fmt.Printf("New connection from %v\n", conn.RemoteAddr()) + + // Track fids + fids := make(map[uint32]*Fid) + authFids := make(map[uint32]*AuthFid) + + for { + // Read 9P message + fc, err := plan9.ReadFcall(conn) + if err != nil { + if err != io.EOF { + log.Printf("read: %v", err) + } + return + } + + fmt.Printf("← %v\n", fc) + + var resp *plan9.Fcall + switch fc.Type { + case plan9.Tversion: + resp = &plan9.Fcall{ + Type: plan9.Rversion, + Tag: fc.Tag, + Msize: fc.Msize, + Version: "9P2000", + } + + case plan9.Tauth: + // Create auth fid + authFids[fc.Afid] = &AuthFid{ + uname: fc.Uname, + aname: fc.Aname, + } + resp = &plan9.Fcall{ + Type: plan9.Rauth, + Tag: fc.Tag, + Qid: plan9.Qid{Type: plan9.QTAUTH, Vers: 0, Path: uint64(fc.Afid)}, + } + + case plan9.Tattach: + // Check auth + af, ok := authFids[fc.Afid] + if !ok || !af.authenticated { + resp = errorResp(fc.Tag, "not authenticated") + break + } + fids[fc.Fid] = &Fid{path: "/"} + resp = &plan9.Fcall{ + Type: plan9.Rattach, + Tag: fc.Tag, + Qid: plan9.Qid{Type: plan9.QTDIR, Vers: 0, Path: 0}, + } + fmt.Printf("✓ Client attached successfully (token: %s)\n", af.token) + + case plan9.Tread: + // Reading from auth fid returns the token + if af, ok := authFids[fc.Fid]; ok { + if !af.authenticated { + resp = errorResp(fc.Tag, "write secret first") + } else { + data := []byte(af.token + "\n") + resp = &plan9.Fcall{ + Type: plan9.Rread, + Tag: fc.Tag, + Data: data, + Count: uint32(len(data)), + } + } + } else { + resp = &plan9.Fcall{ + Type: plan9.Rread, + Tag: fc.Tag, + Data: []byte("hello from server\n"), + Count: 18, + } + } + + case plan9.Twrite: + // Writing to auth fid sets/verifies secret + if af, ok := authFids[fc.Fid]; ok { + clientSecret := string(fc.Data) + resp = s.handleAuthWrite(fc.Tag, af, clientSecret) + } else { + resp = &plan9.Fcall{ + Type: plan9.Rwrite, + Tag: fc.Tag, + Count: fc.Count, + } + } + + case plan9.Tclunk: + delete(fids, fc.Fid) + delete(authFids, fc.Fid) + resp = &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag} + + default: + resp = errorResp(fc.Tag, fmt.Sprintf("unhandled message type %d", fc.Type)) + } + + fmt.Printf("→ %v\n", resp) + if err := plan9.WriteFcall(conn, resp); err != nil { + log.Printf("write: %v", err) + return + } + } +} + +func (s *Server) handleAuthWrite(tag uint16, af *AuthFid, clientSecret string) *plan9.Fcall { + s.mu.Lock() + defer s.mu.Unlock() + + if s.secret == "" { + // First auth - set the secret + s.secret = clientSecret + s.token = randomToken() + af.authenticated = true + af.token = s.token + fmt.Printf("✓ Secret established (first connection)\n") + return &plan9.Fcall{ + Type: plan9.Rwrite, + Tag: tag, + Count: uint32(len(clientSecret)), + } + } + + // Subsequent auth - verify secret + if clientSecret != s.secret { + fmt.Printf("✗ Secret mismatch!\n") + return errorResp(tag, "authentication failed") + } + + af.authenticated = true + af.token = s.token + fmt.Printf("✓ Secret verified (reconnection)\n") + return &plan9.Fcall{ + Type: plan9.Rwrite, + Tag: tag, + Count: uint32(len(clientSecret)), + } +} + +type Fid struct { + path string +} + +type AuthFid struct { + uname string + aname string + authenticated bool + token string +} + +func errorResp(tag uint16, msg string) *plan9.Fcall { + return &plan9.Fcall{ + Type: plan9.Rerror, + Tag: tag, + Ename: msg, + } +} + +func randomToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/experiments/auth9p/test.sh b/experiments/auth9p/test.sh new file mode 100755 index 0000000..1c09f98 --- /dev/null +++ b/experiments/auth9p/test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" + +rm -f /tmp/auth9p-test.sock + +echo "Starting server..." +./server > /tmp/server.log 2>&1 & +SERVER_PID=$! +sleep 0.5 + +if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "Server failed to start:" + cat /tmp/server.log + exit 1 +fi + +cleanup() { + kill $SERVER_PID 2>/dev/null || true + rm -f /tmp/auth9p-test.sock +} +trap cleanup EXIT + +echo "" +echo "=== Test 1: First client establishes secret ===" +./client new | tee /tmp/client1.log +SECRET=$(grep "Use this secret" /tmp/client1.log | awk '{print $NF}') +echo "Captured secret: $SECRET" + +echo "" +echo "=== Test 2: Reconnect with same secret ===" +./client "$SECRET" + +echo "" +echo "=== Test 3: Wrong secret (should fail) ===" +./client wrongsecret 2>&1 || echo "(expected failure)" + +echo "" +echo "=== Server log ===" +cat /tmp/server.log diff --git a/toolsrv/client9p.go b/toolsrv/client9p.go index 8e8e44a..21fbba0 100644 --- a/toolsrv/client9p.go +++ b/toolsrv/client9p.go @@ -526,11 +526,10 @@ func escapeValue(s string) string { // 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"` + ID string `json:"id"` + Cmd string `json:"cmd"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env,omitempty"` } // ReadBypassPending blocks until a bypass request is available. diff --git a/toolsrv/meta.go b/toolsrv/meta.go index 243de1e..e6584d7 100644 --- a/toolsrv/meta.go +++ b/toolsrv/meta.go @@ -18,7 +18,6 @@ type MetaFile struct { Scope string `json:"scope,omitempty"` OutputFormat string `json:"outputFormat,omitempty"` Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` ResetsCounter bool `json:"resetsCounter,omitempty"` Variants []Variant `json:"variants,omitempty"` } @@ -35,7 +34,6 @@ type Variant struct { Scope string `json:"scope,omitempty"` OutputFormat string `json:"outputFormat,omitempty"` Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` ResetsCounter bool `json:"resetsCounter,omitempty"` } @@ -87,9 +85,6 @@ func (m *MetaFile) Resolve() *MetaFile { if v.Scope != "" { resolved.Scope = v.Scope } - if v.Sudo { - resolved.Sudo = true - } if v.ResetsCounter { resolved.ResetsCounter = true }