A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add sophia, a simple ssh server backed by kefka

Step 1 to having a jailed bucket fork per connection.

Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 7a527bb9 aed2a856

+275 -85
+5 -82
cmd/kefka/main.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 - "io/fs" 9 8 "os" 10 9 "strings" 11 10 "time" 12 11 13 - "github.com/go-git/go-billy/v5" 14 12 "github.com/go-git/go-billy/v5/osfs" 15 13 "github.com/spf13/pflag" 16 14 "golang.org/x/term" ··· 20 18 "tangled.org/xeiaso.net/kefka/command/registry" 21 19 "tangled.org/xeiaso.net/kefka/command/registry/coreutils" 22 20 "tangled.org/xeiaso.net/kefka/command/registry/wasmprog" 21 + "tangled.org/xeiaso.net/kefka/internal/billysh" 23 22 ) 24 23 25 24 var ( ··· 74 73 interp.Env(env), 75 74 interp.StdIO(os.Stdin, os.Stdout, os.Stderr), 76 75 interp.ExecHandlers(middleware), 77 - interp.CallHandler(callHandler(reg, fsys, os.Stdout, os.Stderr)), 78 - interp.StatHandler(fsysStatHandler(reg, fsys)), 79 - interp.OpenHandler(fsysOpenHandler(reg, fsys)), 80 - interp.ReadDirHandler2(fsysReadDirHandler(reg, fsys)), 76 + interp.CallHandler(billysh.CallHandler(reg, fsys, os.Stdout, os.Stderr)), 77 + interp.StatHandler(billysh.FsysStatHandler(reg, fsys)), 78 + interp.OpenHandler(billysh.FsysOpenHandler(reg, fsys)), 79 + interp.ReadDirHandler2(billysh.FsysReadDirHandler(reg, fsys)), 81 80 ) 82 81 if err != nil { 83 82 return fmt.Errorf("can't make shell: %w", err) ··· 117 116 defer fin.Close() 118 117 return runReader(ctx, sh, fin, fname) 119 118 } 120 - 121 - // callHandler intercepts cd and pwd before interp's builtins handle them, 122 - // so we can route directory state through the registry's fsys-relative pwd 123 - // instead of interp's host-rooted Dir. Intercepted calls are replaced with 124 - // `:` (no-op) so interp's builtin doesn't run. 125 - func callHandler(reg *registry.Impl, fsys billy.Filesystem, stdout, stderr io.Writer) interp.CallHandlerFunc { 126 - return func(ctx context.Context, args []string) ([]string, error) { 127 - if len(args) == 0 { 128 - return args, nil 129 - } 130 - switch args[0] { 131 - case "cd": 132 - target := "" 133 - if len(args) > 1 { 134 - target = args[1] 135 - } 136 - if err := reg.Chdir(fsys, target); err != nil { 137 - fmt.Fprintln(stderr, err) 138 - return []string{"false"}, nil 139 - } 140 - return []string{":"}, nil 141 - case "pwd": 142 - pwd := reg.Pwd() 143 - if pwd == "." { 144 - fmt.Fprintln(stdout, "/") 145 - } else { 146 - fmt.Fprintln(stdout, "/"+pwd) 147 - } 148 - return []string{":"}, nil 149 - } 150 - return args, nil 151 - } 152 - } 153 - 154 - func fsysStatHandler(reg *registry.Impl, fsys billy.Filesystem) interp.StatHandlerFunc { 155 - return func(ctx context.Context, name string, followSymlinks bool) (fs.FileInfo, error) { 156 - resolved := reg.Resolve(name) 157 - if !followSymlinks { 158 - if r, ok := fsys.(billy.Symlink); ok { 159 - return r.Lstat(resolved) 160 - } 161 - } 162 - return fsys.Stat(resolved) 163 - } 164 - } 165 - 166 - func fsysOpenHandler(reg *registry.Impl, fsys billy.Filesystem) interp.OpenHandlerFunc { 167 - return func(ctx context.Context, name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { 168 - if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { 169 - return nil, &os.PathError{Op: "open", Path: name, Err: fs.ErrPermission} 170 - } 171 - f, err := fsys.Open(reg.Resolve(name)) 172 - if err != nil { 173 - return nil, err 174 - } 175 - return readOnlyFile{f}, nil 176 - } 177 - } 178 - 179 - func fsysReadDirHandler(reg *registry.Impl, fsys billy.Filesystem) interp.ReadDirHandlerFunc2 { 180 - return func(ctx context.Context, name string) ([]fs.DirEntry, error) { 181 - entries, err := fsys.ReadDir(reg.Resolve(name)) 182 - if err != nil { 183 - return nil, err 184 - } 185 - out := make([]fs.DirEntry, len(entries)) 186 - for i, e := range entries { 187 - out[i] = fs.FileInfoToDirEntry(e) 188 - } 189 - return out, nil 190 - } 191 - } 192 - 193 - type readOnlyFile struct{ billy.File } 194 - 195 - func (readOnlyFile) Write([]byte) (int, error) { return 0, fs.ErrPermission } 196 119 197 120 func runInteractive(ctx context.Context, sh *interp.Runner, stdin io.Reader, stdout, stderr io.Writer) error { 198 121 parser := syntax.NewParser()
+170
cmd/sophia/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log" 9 + "log/slog" 10 + "os" 11 + "strings" 12 + "time" 13 + 14 + "github.com/gliderlabs/ssh" 15 + "github.com/go-git/go-billy/v5/osfs" 16 + "github.com/spf13/pflag" 17 + "golang.org/x/term" 18 + "mvdan.cc/sh/v3/expand" 19 + "mvdan.cc/sh/v3/interp" 20 + "mvdan.cc/sh/v3/syntax" 21 + "tangled.org/xeiaso.net/kefka/command/registry" 22 + "tangled.org/xeiaso.net/kefka/command/registry/coreutils" 23 + "tangled.org/xeiaso.net/kefka/command/registry/wasmprog" 24 + "tangled.org/xeiaso.net/kefka/internal/billysh" 25 + ) 26 + 27 + var ( 28 + bind = pflag.StringP("bind", "b", ":2222", "host:port to bind SSH to") 29 + timeout = pflag.DurationP("timeout", "T", 5*time.Minute, "the total time a command can run for") 30 + ) 31 + 32 + func main() { 33 + pflag.Parse() 34 + 35 + if err := run(); err != nil { 36 + log.Fatal(err) 37 + } 38 + } 39 + 40 + func run() error { 41 + srv := New() 42 + 43 + slog.Info("listening", "bind", *bind, "timeout", *timeout) 44 + return ssh.ListenAndServe(*bind, srv.HandleSSH) 45 + } 46 + 47 + type Server struct { 48 + reg *registry.Impl 49 + } 50 + 51 + func New() *Server { 52 + reg := registry.New() 53 + coreutils.Register(reg) 54 + wasmprog.Register(reg) 55 + 56 + return &Server{ 57 + reg: reg, 58 + } 59 + } 60 + 61 + func (s *Server) HandleSSH(sess ssh.Session) { 62 + if err := s.runKefka(sess); err != nil { 63 + fmt.Fprintln(sess, "internal server error:", err) 64 + slog.Error("error serving Kefka session", "err", err) 65 + return 66 + } 67 + } 68 + 69 + func (s *Server) runKefka(sess ssh.Session) error { 70 + tempDir, err := os.MkdirTemp("", "sophia-"+sess.RemoteAddr().String()+"-*") 71 + if err != nil { 72 + return fmt.Errorf("can't make chroot jail: %w", err) 73 + } 74 + defer os.RemoveAll(tempDir) 75 + 76 + fsys := osfs.New(tempDir) 77 + 78 + t := term.NewTerminal(sess, "$ ") 79 + 80 + var sh *interp.Runner 81 + 82 + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 83 + return func(ctx context.Context, args []string) error { 84 + return s.reg.Exec(ctx, fsys, sh, args) 85 + } 86 + } 87 + 88 + env := expand.ListEnviron( 89 + "HOME=/", 90 + "IFS=\n", 91 + "MACHTYPE=x86_64-pc-linux-gnu", 92 + "HOSTTYPE=x86_64", 93 + "HOSTNAME=localhost", 94 + "PWD=/", 95 + "OLDPWD=/", 96 + "OPTIND=1", 97 + "KEFKA=1", 98 + "PATH=/usr/bin:/bin", 99 + ) 100 + 101 + sh, err = interp.New( 102 + interp.Interactive(true), 103 + interp.Env(env), 104 + interp.StdIO(strings.NewReader(""), t, t), 105 + interp.ExecHandlers(middleware), 106 + interp.CallHandler(billysh.CallHandler(s.reg, fsys, os.Stdout, os.Stderr)), 107 + interp.StatHandler(billysh.FsysStatHandler(s.reg, fsys)), 108 + interp.OpenHandler(billysh.FsysOpenHandler(s.reg, fsys)), 109 + interp.ReadDirHandler2(billysh.FsysReadDirHandler(s.reg, fsys)), 110 + ) 111 + if err != nil { 112 + return fmt.Errorf("can't make shell: %w", err) 113 + } 114 + 115 + parser := syntax.NewParser(syntax.Variant(syntax.LangBash)) 116 + reader := &termLineReader{t: t} 117 + for stmts, err := range parser.InteractiveSeq(reader) { 118 + if err != nil { 119 + if errors.Is(err, io.EOF) { 120 + return nil 121 + } 122 + fmt.Fprintln(t, "parse error:", err) 123 + t.SetPrompt("$ ") 124 + continue 125 + } 126 + 127 + if parser.Incomplete() { 128 + t.SetPrompt("> ") 129 + continue 130 + } 131 + 132 + ctx, cancel := context.WithTimeout(context.Background(), *timeout) 133 + for _, stmt := range stmts { 134 + runErr := sh.Run(ctx, stmt) 135 + if sh.Exited() { 136 + cancel() 137 + return runErr 138 + } 139 + if runErr != nil { 140 + fmt.Fprintln(t, runErr) 141 + } 142 + } 143 + cancel() 144 + 145 + t.SetPrompt("$ ") 146 + } 147 + 148 + return nil 149 + } 150 + 151 + // termLineReader adapts term.Terminal.ReadLine into an io.Reader that emits 152 + // one line (with a trailing '\n') per ReadLine call, so the bash parser can 153 + // consume input that's already been through the terminal's line discipline. 154 + type termLineReader struct { 155 + t *term.Terminal 156 + buf []byte 157 + } 158 + 159 + func (r *termLineReader) Read(p []byte) (int, error) { 160 + if len(r.buf) == 0 { 161 + line, err := r.t.ReadLine() 162 + if err != nil { 163 + return 0, err 164 + } 165 + r.buf = append([]byte(line), '\n') 166 + } 167 + n := copy(p, r.buf) 168 + r.buf = r.buf[n:] 169 + return n, nil 170 + }
+2 -3
command/registry/registry.go
··· 14 14 ) 15 15 16 16 var ( 17 - ErrCommandNotFound = errors.New("registry: command not found") 17 + ErrCommandNotFound = errors.New("kefka: command not found") 18 18 ) 19 19 20 20 type Impl struct { ··· 103 103 104 104 cmd, ok := i.Get(cmdName) 105 105 if !ok { 106 - fmt.Fprintf(hc.Stderr, "kefka: command not found: %s\n", cmdName) 107 - return errors.Join(interp.ExitStatus(127), ErrCommandNotFound) 106 + return errors.Join(fmt.Errorf("%w: %s", ErrCommandNotFound, cmdName), interp.ExitStatus(127)) 108 107 } 109 108 110 109 return cmd.Exec(ctx, &command.ExecContext{
+3
go.mod
··· 3 3 go 1.26.2 4 4 5 5 require ( 6 + github.com/gliderlabs/ssh v0.3.8 6 7 github.com/go-git/go-billy/v5 v5.8.0 7 8 github.com/pborman/getopt/v2 v2.1.0 8 9 github.com/pmezard/go-difflib v1.0.0 ··· 14 15 ) 15 16 16 17 require ( 18 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 17 19 github.com/cyphar/filepath-securejoin v0.3.6 // indirect 20 + golang.org/x/crypto v0.31.0 // indirect 18 21 golang.org/x/sys v0.42.0 // indirect 19 22 )
+6
go.sum
··· 1 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 2 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 1 3 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 2 4 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 3 5 github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= 4 6 github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 5 7 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 8 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 + github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 10 + github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 7 11 github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= 8 12 github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= 9 13 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= ··· 28 32 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 29 33 github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= 30 34 github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= 35 + golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 36 + golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 31 37 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 32 38 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 33 39 golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
+89
internal/billysh/billysh.go
··· 1 + package billysh 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "io/fs" 8 + "os" 9 + 10 + "github.com/go-git/go-billy/v5" 11 + "mvdan.cc/sh/v3/interp" 12 + "tangled.org/xeiaso.net/kefka/command/registry" 13 + ) 14 + 15 + func FsysStatHandler(reg *registry.Impl, fsys billy.Filesystem) interp.StatHandlerFunc { 16 + return func(ctx context.Context, name string, followSymlinks bool) (fs.FileInfo, error) { 17 + resolved := reg.Resolve(name) 18 + if !followSymlinks { 19 + if r, ok := fsys.(billy.Symlink); ok { 20 + return r.Lstat(resolved) 21 + } 22 + } 23 + return fsys.Stat(resolved) 24 + } 25 + } 26 + 27 + func FsysOpenHandler(reg *registry.Impl, fsys billy.Filesystem) interp.OpenHandlerFunc { 28 + return func(ctx context.Context, name string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { 29 + if flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_APPEND|os.O_TRUNC) != 0 { 30 + return nil, &os.PathError{Op: "open", Path: name, Err: fs.ErrPermission} 31 + } 32 + f, err := fsys.Open(reg.Resolve(name)) 33 + if err != nil { 34 + return nil, err 35 + } 36 + return readOnlyFile{f}, nil 37 + } 38 + } 39 + 40 + func FsysReadDirHandler(reg *registry.Impl, fsys billy.Filesystem) interp.ReadDirHandlerFunc2 { 41 + return func(ctx context.Context, name string) ([]fs.DirEntry, error) { 42 + entries, err := fsys.ReadDir(reg.Resolve(name)) 43 + if err != nil { 44 + return nil, err 45 + } 46 + out := make([]fs.DirEntry, len(entries)) 47 + for i, e := range entries { 48 + out[i] = fs.FileInfoToDirEntry(e) 49 + } 50 + return out, nil 51 + } 52 + } 53 + 54 + type readOnlyFile struct{ billy.File } 55 + 56 + func (readOnlyFile) Write([]byte) (int, error) { return 0, fs.ErrPermission } 57 + 58 + // CallHandler intercepts cd and pwd before interp's builtins handle them, 59 + // so we can route directory state through the registry's fsys-relative pwd 60 + // instead of interp's host-rooted Dir. Intercepted calls are replaced with 61 + // `:` (no-op) so interp's builtin doesn't run. 62 + func CallHandler(reg *registry.Impl, fsys billy.Filesystem, stdout, stderr io.Writer) interp.CallHandlerFunc { 63 + return func(ctx context.Context, args []string) ([]string, error) { 64 + if len(args) == 0 { 65 + return args, nil 66 + } 67 + switch args[0] { 68 + case "cd": 69 + target := "" 70 + if len(args) > 1 { 71 + target = args[1] 72 + } 73 + if err := reg.Chdir(fsys, target); err != nil { 74 + fmt.Fprintln(stderr, err) 75 + return []string{"false"}, nil 76 + } 77 + return []string{":"}, nil 78 + case "pwd": 79 + pwd := reg.Pwd() 80 + if pwd == "." { 81 + fmt.Fprintln(stdout, "/") 82 + } else { 83 + fmt.Fprintln(stdout, "/"+pwd) 84 + } 85 + return []string{":"}, nil 86 + } 87 + return args, nil 88 + } 89 + }