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(sophia): support bucket forks per connection

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

Xe Iaso 84d4f093 5c3aabb3

+179 -44
+1
.gitignore
··· 1 + .env
+32 -11
cmd/s3fs-test/main.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io/fs" 6 8 "os" 7 9 8 10 "github.com/aws/aws-sdk-go-v2/config" ··· 19 21 20 22 func main() { 21 23 pflag.Parse() 22 - 23 - fmt.Println(*bucket) 24 + fmt.Println("bucket:", *bucket) 24 25 25 26 cfg, err := config.LoadDefaultConfig(context.Background()) 26 27 if err != nil { ··· 28 29 } 29 30 client := s3.NewFromConfig(cfg) 30 31 31 - s3fs, err := s3fs.NewS3FS(client, *bucket) 32 + fsys, err := s3fs.NewS3FS(client, *bucket) 32 33 if err != nil { 33 34 panic(err) 34 35 } 35 - fmt.Printf("s3fs.Root() = %q\n", s3fs.Root()) 36 - fmt.Println(s3fs.Join(s3fs.Root(), "hello/", "/")) 37 36 38 - files, err := s3fs.ReadDir("foo/") 39 - if err != nil { 40 - panic(err) 37 + stat := func(p string) { 38 + info, err := fsys.Stat(p) 39 + if err != nil { 40 + fmt.Printf("Stat(%q) -> err: %v (is fs.ErrNotExist=%v)\n", p, err, errors.Is(err, fs.ErrNotExist)) 41 + return 42 + } 43 + fmt.Printf("Stat(%q) -> name=%q dir=%v size=%d mtime=%s\n", 44 + p, info.Name(), info.IsDir(), info.Size(), info.ModTime().Format("2006-01-02T15:04:05")) 41 45 } 42 - fmt.Printf("Found %d files\n", len(files)) 43 - for _, file := range files { 44 - fmt.Println(file.Name()) 46 + 47 + readdir := func(p string) { 48 + entries, err := fsys.ReadDir(p) 49 + if err != nil { 50 + fmt.Printf("ReadDir(%q) -> err: %v\n", p, err) 51 + return 52 + } 53 + fmt.Printf("ReadDir(%q) -> %d entries:\n", p, len(entries)) 54 + for _, e := range entries { 55 + fmt.Printf(" %s (dir=%v size=%d)\n", e.Name(), e.IsDir(), e.Size()) 56 + } 45 57 } 58 + 59 + stat(".") 60 + stat("etc") 61 + stat("moby-dick.txt") 62 + stat("etc/motd") 63 + stat("does-not-exist") 64 + 65 + readdir(".") 66 + readdir("etc") 46 67 }
+55 -8
cmd/sophia/main.go
··· 11 11 "strings" 12 12 "time" 13 13 14 + "github.com/aws/aws-sdk-go-v2/service/s3" 15 + smithyhttp "github.com/aws/smithy-go/transport/http" 14 16 "github.com/gliderlabs/ssh" 15 - "github.com/go-git/go-billy/v5/osfs" 17 + "github.com/google/uuid" 16 18 "github.com/spf13/pflag" 19 + "github.com/tigrisdata/storage-go" 17 20 "golang.org/x/term" 18 21 "mvdan.cc/sh/v3/expand" 19 22 "mvdan.cc/sh/v3/interp" ··· 22 25 "tangled.org/xeiaso.net/kefka/command/registry/coreutils" 23 26 "tangled.org/xeiaso.net/kefka/command/registry/wasmprog" 24 27 "tangled.org/xeiaso.net/kefka/internal/billysh" 28 + "tangled.org/xeiaso.net/kefka/internal/s3fs" 29 + 30 + _ "embed" 31 + 32 + _ "github.com/joho/godotenv/autoload" 25 33 ) 26 34 27 35 var ( 28 36 bind = pflag.StringP("bind", "b", ":2222", "host:port to bind SSH to") 37 + bucket = pflag.StringP("bucket", "B", os.Getenv("BUCKET_NAME"), "the bucket name to constrain sessions to") 29 38 timeout = pflag.DurationP("timeout", "T", 5*time.Minute, "the total time a command can run for") 39 + 40 + //go:embed static/motd 41 + motd []byte 30 42 ) 31 43 32 44 func main() { ··· 59 71 } 60 72 61 73 func (s *Server) HandleSSH(sess ssh.Session) { 62 - if err := s.runKefka(sess); err != nil { 74 + sess.Write(motd) 75 + 76 + lg := slog.With("remoteAddr", sess.RemoteAddr().String(), "user", sess.User()) 77 + lg.Info("got connection") 78 + 79 + if err := s.runKefka(sess, lg); err != nil { 63 80 fmt.Fprintln(sess, "internal server error:", err) 64 - slog.Error("error serving Kefka session", "err", err) 81 + slog.Error("error serving Kefka session", "err", err, "remoteAddr", sess.RemoteAddr().String()) 65 82 return 66 83 } 67 84 } 68 85 69 - func (s *Server) runKefka(sess ssh.Session) error { 70 - tempDir, err := os.MkdirTemp("", "sophia-"+sess.RemoteAddr().String()+"-*") 86 + func (s *Server) runKefka(sess ssh.Session, lg *slog.Logger) error { 87 + client, err := storage.New(sess.Context()) 71 88 if err != nil { 72 - return fmt.Errorf("can't make chroot jail: %w", err) 89 + return fmt.Errorf("can't make storage client: %w", err) 90 + } 91 + 92 + sessID := uuid.Must(uuid.NewV7()).String() 93 + sessBucket := *bucket + "-" + sessID 94 + lg = lg.With("sessionBucket", sessBucket) 95 + 96 + fmt.Fprintln(sess) 97 + fmt.Fprintf(sess, "You are isolated to the bucket %s, which was automatically forked from %s on connection.\n", sessBucket, *bucket) 98 + fmt.Fprintln(sess) 99 + 100 + if _, err := client.CreateBucketFork(sess.Context(), *bucket, sessBucket); err != nil { 101 + return fmt.Errorf("can't create per-session bucket fork: %w", err) 73 102 } 74 - defer os.RemoveAll(tempDir) 103 + lg.Info("made bucket fork", "source", *bucket, "dest", sessBucket) 104 + 105 + defer func() { 106 + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 107 + defer cancel() 108 + 109 + withForce := func(opts *s3.Options) { 110 + opts.APIOptions = append(opts.APIOptions, smithyhttp.AddHeaderValue("Tigris-Force-Delete", "true")) 111 + } 112 + 113 + if _, err := client.DeleteBucket(ctx, &s3.DeleteBucketInput{ 114 + Bucket: new(sessBucket), 115 + }, withForce); err != nil { 116 + lg.Error("can't delete session bucket", "err", err) 117 + } 118 + }() 75 119 76 - fsys := osfs.New(tempDir) 120 + fsys, err := s3fs.NewS3FS(client.Client, sessBucket) 121 + if err != nil { 122 + return fmt.Errorf("can't setup s3fs: %w", err) 123 + } 77 124 78 125 t := term.NewTerminal(sess, "$ ") 79 126
+6
cmd/sophia/static/motd
··· 1 + # Welcome to Tigris! 2 + 3 + Your current working directory is a Tigris bucket. Common Linux shell commands work 4 + as expected. Python is runnable via the `python` command. 5 + 6 + Have fun!
+3 -1
go.mod
··· 6 6 github.com/aws/aws-sdk-go-v2 v1.41.7 7 7 github.com/aws/aws-sdk-go-v2/config v1.32.17 8 8 github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 9 + github.com/aws/smithy-go v1.25.1 9 10 github.com/gliderlabs/ssh v0.3.8 10 11 github.com/go-git/go-billy/v5 v5.8.0 12 + github.com/google/uuid v1.6.0 11 13 github.com/joho/godotenv v1.5.1 12 14 github.com/pborman/getopt/v2 v2.1.0 13 15 github.com/pmezard/go-difflib v1.0.0 14 16 github.com/spf13/pflag v1.0.10 15 17 github.com/tetratelabs/wazero v1.11.0 18 + github.com/tigrisdata/storage-go v0.6.0 16 19 go.uber.org/atomic v1.11.0 17 20 golang.org/x/term v0.41.0 18 21 golang.org/x/text v0.29.0 ··· 35 38 github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect 36 39 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect 37 40 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect 38 - github.com/aws/smithy-go v1.25.1 // indirect 39 41 github.com/cyphar/filepath-securejoin v0.3.6 // indirect 40 42 golang.org/x/crypto v0.31.0 // indirect 41 43 golang.org/x/sys v0.42.0 // indirect
+4
go.sum
··· 50 50 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= 51 51 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 52 52 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 53 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 54 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 53 55 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 54 56 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 55 57 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 70 72 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 71 73 github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= 72 74 github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= 75 + github.com/tigrisdata/storage-go v0.6.0 h1:1Rxzxp/GAUqwFln8qMXYOQIa47IsT2aBNuxYc55o6D4= 76 + github.com/tigrisdata/storage-go v0.6.0/go.mod h1:l3u7N9LDIhv4lfpkEBJYzolWJ/SBb6WiBexgy/uq6iQ= 73 77 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 74 78 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 75 79 golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+49 -1
internal/s3fs/basic.go
··· 6 6 "context" 7 7 "errors" 8 8 "fmt" 9 + "io/fs" 9 10 "os" 10 11 "path" 12 + "strings" 11 13 14 + "github.com/aws/aws-sdk-go-v2/aws" 12 15 "github.com/aws/aws-sdk-go-v2/service/s3" 16 + "github.com/aws/smithy-go" 13 17 "github.com/go-git/go-billy/v5" 14 18 ) 15 19 ··· 70 74 71 75 // Stat returns a FileInfo describing the named file. 72 76 func (fs3 *S3FS) Stat(filename string) (os.FileInfo, error) { 73 - return nil, errors.New("not implemented") 77 + key := strings.TrimPrefix(fs3.cleanPath(filename), "/") 78 + if key == "" || key == "." { 79 + return newDirInfo("/"), nil 80 + } 81 + 82 + ctx := context.TODO() 83 + 84 + head, err := fs3.client.HeadObject(ctx, &s3.HeadObjectInput{ 85 + Bucket: &fs3.bucket, 86 + Key: &key, 87 + }) 88 + if err == nil { 89 + return newFileInfo( 90 + path.Base(key), 91 + aws.ToInt64(head.ContentLength), 92 + aws.ToTime(head.LastModified), 93 + ), nil 94 + } 95 + 96 + var apiErr smithy.APIError 97 + if !errors.As(err, &apiErr) { 98 + return nil, err 99 + } 100 + switch apiErr.ErrorCode() { 101 + case "NotFound", "NoSuchKey": 102 + // fall through to directory probe below 103 + default: 104 + return nil, err 105 + } 106 + 107 + prefix := key + "/" 108 + maxKeys := int32(1) 109 + list, lerr := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 110 + Bucket: &fs3.bucket, 111 + Prefix: &prefix, 112 + Delimiter: &fs3.separator, 113 + MaxKeys: &maxKeys, 114 + }) 115 + if lerr != nil { 116 + return nil, lerr 117 + } 118 + if len(list.Contents) > 0 || len(list.CommonPrefixes) > 0 { 119 + return newDirInfo(path.Base(key)), nil 120 + } 121 + return nil, &os.PathError{Op: "stat", Path: filename, Err: fs.ErrNotExist} 74 122 } 75 123 76 124 // Rename renames (moves) oldpath to newpath. If newpath already exists and
+29 -23
internal/s3fs/dir.go
··· 6 6 "context" 7 7 "errors" 8 8 "os" 9 + pathpkg "path" 10 + "strings" 9 11 10 12 "github.com/aws/aws-sdk-go-v2/aws" 11 13 "github.com/aws/aws-sdk-go-v2/service/s3" ··· 13 15 14 16 // ReadDir reads the directory named by dirname and returns a list of 15 17 // directory entries sorted by filename. 16 - func (fs3 *S3FS) ReadDir(path string) ([]os.FileInfo, error) { 17 - // p := fs3.cleanPath(fs3.root, path) 18 - // if p != "" { 19 - // p += "/" 20 - // } 21 - // fmt.Println("ReadDir:", p) 22 - p := path 18 + func (fs3 *S3FS) ReadDir(dir string) ([]os.FileInfo, error) { 19 + key := strings.TrimPrefix(fs3.cleanPath(dir), "/") 20 + var prefix string 21 + if key != "" && key != "." { 22 + prefix = key + "/" 23 + } 23 24 24 - // Create a context with a timeout 25 - ctx := context.TODO() // TODO: Get user context? 25 + ctx := context.TODO() 26 26 27 27 var ct *string 28 28 var dirs []os.FileInfo ··· 30 30 for { 31 31 res, err := fs3.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ 32 32 Bucket: &fs3.bucket, 33 - Prefix: &p, 33 + Prefix: &prefix, 34 34 ContinuationToken: ct, 35 35 Delimiter: &fs3.separator, 36 36 }) ··· 38 38 return nil, err 39 39 } 40 40 41 - // Add the directories to the list 42 41 for _, d := range res.CommonPrefixes { 43 - dirs = append(dirs, newDirInfo(*d.Prefix)) 42 + name := strings.TrimSuffix(strings.TrimPrefix(aws.ToString(d.Prefix), prefix), "/") 43 + if name == "" { 44 + continue 45 + } 46 + dirs = append(dirs, newDirInfo(name)) 44 47 } 45 48 46 - // Add the files to the list 47 49 for _, f := range res.Contents { 50 + full := aws.ToString(f.Key) 51 + if full == prefix { 52 + // zero-byte directory placeholder; skip 53 + continue 54 + } 55 + name := strings.TrimPrefix(full, prefix) 56 + if name == "" { 57 + continue 58 + } 48 59 files = append(files, newFileInfo( 49 - aws.ToString(f.Key), 50 - *f.Size, 60 + pathpkg.Base(name), 61 + aws.ToInt64(f.Size), 51 62 aws.ToTime(f.LastModified), 52 63 )) 53 64 } 54 65 55 - // Set the last key 56 - ct = res.NextContinuationToken 57 - 58 - // If there are no more keys, break 59 - if !*res.IsTruncated { 66 + if !aws.ToBool(res.IsTruncated) { 60 67 break 61 68 } 69 + ct = res.NextContinuationToken 62 70 } 63 71 64 - // Join the directories and files & return 65 - res := append(dirs, files...) 66 - return res, nil 72 + return append(dirs, files...), nil 67 73 } 68 74 69 75 // MkdirAll creates a directory named path, along with any necessary