diff --git a/cmd/ollie-9p-mount/main.go b/cmd/ollie-9p-mount/main.go new file mode 100644 index 0000000..bf2e81e --- /dev/null +++ b/cmd/ollie-9p-mount/main.go @@ -0,0 +1,97 @@ +// ollie-9p-mount — per-session FUSE proxy that attaches to olliesrv with a custom uname. +// +// Each agent session gets its own mount. Tools running inside the session see +// the full ollie namespace through $OLLIE, but all 9P operations carry the +// session's identity (Uname). The server can then enforce access control +// (e.g. rejecting self-prompts). +// +// Usage: +// +// ollie-9p-mount [-u uname] [-s service] [-addr address] +// +// If -u is not given, $OLLIE_SESSION_ID is used. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "9fans.net/go/plan9/client" + "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" +) + +var ( + userFlag = flag.String("u", "", "uname for 9P attach (default: $OLLIE_SESSION_ID)") + serviceFlag = flag.String("s", "ollie", "9P service name") + netFlag = flag.String("net", "", "network type (unix, tcp); empty = namespace") + addrFlag = flag.String("addr", "", "9P address; empty = namespace service") +) + +func main() { + flag.Parse() + if flag.NArg() != 1 { + fmt.Fprintf(os.Stderr, "usage: ollie-9p-mount [-u uname] [-s service] \n") + os.Exit(1) + } + mnt := flag.Arg(0) + + uname := *userFlag + if uname == "" { + uname = os.Getenv("OLLIE_SESSION_ID") + } + if uname == "" { + fmt.Fprintf(os.Stderr, "ollie-9p-mount: no uname (-u or $OLLIE_SESSION_ID)\n") + os.Exit(1) + } + + fsys, err := dial(uname) + if err != nil { + log.Fatalf("dial: %v", err) + } + + os.MkdirAll(mnt, 0755) + root := &p9Dir{fsys: fsys, path: ""} + server, err := fs.Mount(mnt, root, &fs.Options{ + MountOptions: fuse.MountOptions{ + FsName: "ollie-9p", + Name: "ollie", + DisableXAttrs: true, + }, + }) + if err != nil { + log.Fatalf("mount: %v", err) + } + + // Unmount on signal. + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sig + server.Unmount() + }() + + server.Wait() +} + +func dial(uname string) (*client.Fsys, error) { + var conn *client.Conn + var err error + if *addrFlag != "" { + n := *netFlag + if n == "" { + n = "tcp" + } + conn, err = client.Dial(n, *addrFlag) + } else { + conn, err = client.DialService(*serviceFlag) + } + if err != nil { + return nil, err + } + return conn.Attach(nil, uname, "") +} diff --git a/cmd/ollie-9p-mount/node.go b/cmd/ollie-9p-mount/node.go new file mode 100644 index 0000000..2e93e9b --- /dev/null +++ b/cmd/ollie-9p-mount/node.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "sync" + "syscall" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" + "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" +) + +// p9Dir is a FUSE directory node backed by a 9P path. +type p9Dir struct { + fs.Inode + fsys *client.Fsys + path string // 9P path (empty string = root) +} + +// p9File is a FUSE file node backed by a 9P path. +type p9File struct { + fs.Inode + fsys *client.Fsys + path string + mu sync.Mutex +} + +var _ = (fs.NodeGetattrer)((*p9Dir)(nil)) +var _ = (fs.NodeReaddirer)((*p9Dir)(nil)) +var _ = (fs.NodeLookuper)((*p9Dir)(nil)) + +var _ = (fs.NodeGetattrer)((*p9File)(nil)) +var _ = (fs.NodeOpener)((*p9File)(nil)) +var _ = (fs.NodeReader)((*p9File)(nil)) +var _ = (fs.NodeWriter)((*p9File)(nil)) +var _ = (fs.NodeSetattrer)((*p9File)(nil)) + +func p9path(base, name string) string { + if base == "" { + return name + } + return base + "/" + name +} + +// --- p9Dir --- + +func (d *p9Dir) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { + out.Mode = 0555 | syscall.S_IFDIR + return 0 +} + +func (d *p9Dir) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) { + path := d.path + if path == "" { + path = "/" + } + fid, err := d.fsys.Open(path, plan9.OREAD) + if err != nil { + return nil, syscall.EIO + } + defer fid.Close() + dirs, err := fid.Dirreadall() + if err != nil { + return nil, syscall.EIO + } + entries := make([]fuse.DirEntry, len(dirs)) + for i, dir := range dirs { + m := uint32(dir.Mode) & 0777 + if dir.Mode&plan9.DMDIR != 0 { + m |= syscall.S_IFDIR + } + entries[i] = fuse.DirEntry{Name: dir.Name, Mode: m} + } + return fs.NewListDirStream(entries), 0 +} + +func (d *p9Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { + child := p9path(d.path, name) + dir, err := d.fsys.Stat(child) + if err != nil { + return nil, syscall.ENOENT + } + if dir.Mode&plan9.DMDIR != 0 { + out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR + node := &p9Dir{fsys: d.fsys, path: child} + return d.NewInode(ctx, node, fs.StableAttr{Mode: syscall.S_IFDIR}), 0 + } + out.Mode = uint32(dir.Mode) & 0777 + out.Size = dir.Length + node := &p9File{fsys: d.fsys, path: child} + return d.NewInode(ctx, node, fs.StableAttr{}), 0 +} + +// --- p9File --- + +func (f *p9File) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { + dir, err := f.fsys.Stat(f.path) + if err != nil { + return syscall.ENOENT + } + // Report all files as rw so the kernel doesn't block writes; + // the 9P server enforces actual permissions. + out.Mode = 0666 + out.Size = dir.Length + return 0 +} + +func (f *p9File) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno { + // Accept truncate silently — 9P files are append/overwrite on write. + out.Mode = 0666 + return 0 +} + +func (f *p9File) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) { + return nil, fuse.FOPEN_DIRECT_IO, 0 +} + +func (f *p9File) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) { + f.mu.Lock() + defer f.mu.Unlock() + fid, err := f.fsys.Open(f.path, plan9.OREAD) + if err != nil { + return nil, syscall.EIO + } + defer fid.Close() + // The 9fans client doesn't support Seek; read from offset by reading and discarding. + // For small offsets this is fine; large seeks on big files would need a smarter approach. + if off > 0 { + buf := make([]byte, min(off, 8192)) + remaining := off + for remaining > 0 { + n := int64(len(buf)) + if n > remaining { + n = remaining + } + nr, err := fid.Read(buf[:n]) + if err != nil || nr == 0 { + break + } + remaining -= int64(nr) + } + } + n, err := fid.Read(dest) + if err != nil && n == 0 { + return nil, syscall.EIO + } + return fuse.ReadResultData(dest[:n]), 0 +} + +func (f *p9File) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) { + f.mu.Lock() + defer f.mu.Unlock() + fid, err := f.fsys.Open(f.path, plan9.OWRITE) + if err != nil { + return 0, syscall.EACCES + } + n, err := fid.Write(data) + if err != nil { + fid.Close() + return 0, syscall.EIO + } + if err := fid.Close(); err != nil { + return 0, syscall.EACCES + } + return uint32(n), 0 +} + +func min(a, b int64) int64 { + if a < b { + return a + } + return b +} diff --git a/go.mod b/go.mod index dedf87b..e0b21d7 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,13 @@ go 1.25.6 require ( 9fans.net/go v0.0.7 + github.com/hanwen/go-fuse/v2 v2.10.1 ollie v0.0.0-00010101000000-000000000000 ) -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + golang.org/x/sys v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) replace ollie => ../core diff --git a/go.sum b/go.sum index 1137047..9deb133 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,12 @@ 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= +github.com/hanwen/go-fuse/v2 v2.10.1 h1:QAqZuc9+aBtTou+OPruU/hkYQYCkgPtQd2QaepHkTTs= +github.com/hanwen/go-fuse/v2 v2.10.1/go.mod h1:aU7NkGYZUmuJrZapoI3mEcNve7PZTySUOLBuch/vR6U= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= 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= @@ -25,6 +31,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h 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/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= diff --git a/mkfile b/mkfile index 0228b6c..e331eaa 100644 --- a/mkfile +++ b/mkfile @@ -4,8 +4,9 @@ all:V: install build:V: go build -o $INSTALL_PATH/olliesrv . + go build -o $INSTALL_PATH/ollie-9p-mount ./cmd/ollie-9p-mount/ install:V: build clean:V: - rm -f $INSTALL_PATH/olliesrv + rm -f $INSTALL_PATH/olliesrv $INSTALL_PATH/ollie-9p-mount diff --git a/server.go b/server.go index b737d50..abb43e3 100644 --- a/server.go +++ b/server.go @@ -207,6 +207,7 @@ type connState struct { ctx context.Context cancel context.CancelFunc pending map[uint16]context.CancelFunc // in-flight request cancels, keyed by tag + uname string // user principal from Tattach } // Server is the 9P server for ollie sessions. @@ -228,6 +229,7 @@ type Server struct { tmpStore Store strict bool yolo bool + groups map[string]map[string]bool // group → set of members } // ServerOption configures the 9P server. @@ -260,6 +262,7 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server { skillStore: NewSkillStore(), transcriptStore: NewFlatDirStore(transcriptDir, 0444), tmpStore: NewFlatDirStore(tmpDir, 0600), + groups: make(map[string]map[string]bool), } for _, o := range opts { o(s) @@ -293,6 +296,35 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server { return s } +// AddGroup adds a user to a group. +func (s *Server) AddGroup(group, user string) { + s.mu.Lock() + if s.groups[group] == nil { + s.groups[group] = make(map[string]bool) + } + s.groups[group][user] = true + s.mu.Unlock() +} + +// RemoveGroup removes a user from a group. +func (s *Server) RemoveGroup(group, user string) { + s.mu.Lock() + if m := s.groups[group]; m != nil { + delete(m, user) + if len(m) == 0 { + delete(s.groups, group) + } + } + s.mu.Unlock() +} + +// InGroup returns true if user is a member of group. +func (s *Server) InGroup(group, user string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.groups[group][user] +} + // defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default. func defaultTranscriptDir() string { @@ -675,8 +707,10 @@ func boolToDir(isDir bool) uint32 { func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() defer cs.mu.Unlock() + cs.uname = fc.Uname qid := plan9.Qid{Type: QTDir, Path: 0} cs.fids[fc.Fid] = &fid{path: "/", qid: qid} + s.log.Debug("Tattach uname=%q", fc.Uname) return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} } @@ -1181,13 +1215,21 @@ func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { } delete(cs.fids, fc.Fid) } + uname := cs.uname cs.mu.Unlock() if writable { - s.log.Debug("Tclunk flush path=%q writeBuf=%d", path, len(data)) + s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname) input := strings.TrimSpace(string(data)) + // Reject self-prompt before async dispatch so the error reaches the client. + if uname != "" && strings.HasPrefix(path, "/s/") { + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) == 3 && parts[2] == "prompt" && uname == parts[1] { + return errFcall(fc, "self-prompt rejected") + } + } if s.isAsyncWrite(path) { - go s.handleWrite(path, input) //nolint:errcheck - } else if err := s.handleWrite(path, input); err != nil { + go s.handleWrite(path, input, uname) //nolint:errcheck + } else if err := s.handleWrite(path, input, uname); err != nil { s.log.Debug("Tclunk handleWrite err=%v", err) return errFcall(fc, err.Error()) } @@ -1269,8 +1311,8 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // handleWrite processes a fully-assembled write payload for the given path. // Called synchronously from clunk; prompt writes are the exception (spawned // as a goroutine because they block for the entire agent turn). -func (s *Server) handleWrite(path, input string) error { - s.log.Debug("handleWrite path=%q input_len=%d", path, len(input)) +func (s *Server) handleWrite(path, input, uname string) error { + s.log.Debug("handleWrite path=%q input_len=%d uname=%q", path, len(input), uname) if path == "/s/new" { if input == "" {