Repository: moriyoshi/s3-sftp-proxy
Branch: master
Commit: d292467c629f
Files: 20
Total size: 64.0 KB
Directory structure:
gitextract_lb8z7ih5/
├── CONTRIBUTORS
├── LICENSE
├── README.md
├── bucket.go
├── bucketio.go
├── config.go
├── fakee_unix.go
├── fakee_windows.go
├── io.go
├── logging.go
├── main.go
├── merged_context.go
├── path.go
├── path_test.go
├── phantom_object_map.go
├── phantom_object_map_test.go
├── s3-sftp-proxy.example.toml
├── server.go
├── user.go
└── utils.go
================================================
FILE CONTENTS
================================================
================================================
FILE: CONTRIBUTORS
================================================
Moriyoshi Koizumi
Dmitry Chepurovskiy
================================================
FILE: LICENSE
================================================
Copyright (c) 2018 Moriyoshi Koizumi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# s3-sftp-proxy
`s3-sftp-proxy` is a tiny program that exposes the resources on your AWS S3 buckets through SFTP protocol.
## Usage
```
Usage of s3-sftp-proxy:
-bind string
listen on addr:port
-config string
configuration file (default "s3-sftp-proxy.toml")
-debug
turn on debugging output
```
* `-bind`
Specifies the local address and port to listen on. This overrides the value of `bind` in the configuration file. If it is not present in the configuration file either, it defaults to `:10022`.
* `-config`
Specifies the path to the configuration file. It defaults to "./s3-sftp-config.toml" if not given.
* `-debug`
Turn on debug logging. The output will be more verbose.
## Configuation
The configuration file is in [TOML](https://github.com/toml-lang/toml) format. Refer to that page for the detailed explanation of the syntax.
### Top level
```toml
host_key_file = "./host_key"
bind = "localhost:10022"
banner = """
Welcome to my SFTP server
"""
reader_lookback_buffer_size = 1048576
reader_min_chunk_size = 262144
lister_lookback_buffer_size = 100
# buckets and authantication settings follow...
```
* `host_key_file` (required)
Specifies the path to the host key file (private key).
The host key can be generated with `ssh-keygen` command:
```sh
ssh-keygen -f host_key
```
* `bind` (optional, defaults to `":10022"`)
Specifies the local address and port to listen on.
* `banner` (optional, defaults to an empty string)
A banner is a message text that will be sent to the client when the connection is esablished to the server prior to any authentication steps.
* `reader_lookback_buffer_size` (optional, defaults to `1048576`)
Specifies the size of the buffer used to keep several amounts of data read from S3 for later access to it. The reason why such buffer is necessary is that SFTP protocol requires the data should be sent or retrieved on a random-access basis (i.e. each request contains an offset) while those coming from S3 is actually fetched in a streaming manner. In that we have to emulate block storage access for S3 objects, but chances are we don't need to hold the entire data with the reasonable SFTP clients.
* `reader_min_chunk_size` (optional, defaults to `262144`)
Specifies the amount of data fetched from S3 at once. Increase the value when you experience quite a poor performance.
* `lister_lookback_buffer_size` (optional, defalts to `100`)
Contrary to the people's expectation, SFTP also requires file listings to be retrieved in random-access as well.
* `buckets` (required)
`buckets` contains records for bucket declarations. See [Bucket Settings](#bucket-settings) for detail.
* `auth`
`auth` contains records for authenticator configurations. See [Authenticator Settings](#authenticator-settings) for detail.
### Bucket Settings
```toml
[buckets.test]
endpoint = "http://endpoint"
s3_force_path_style = true
disable_ssl = false
bucket = "BUCKET"
key_prefix = "PREFIX"
bucket_url = "s3://BUCKET/PREFIX"
profile = "profile"
region = "ap-northeast-1"
max_object_size = 65536
writable = false
readable = true
listable = true
auth = "test"
server_side_encryption = "kms"
sse_customer_key = ""
sse_kms_key_id = ""
keyboard_interactive_auth = false
[buckets.test.credentials]
aws_access_key_id = "aaa"
aws_secret_access_key = "bbb"
```
* `endpoint` (optional)
Specifies s3 endpoint (server) different from AWS.
* `s3_force_path_style` (optional)
This option should be set to `true` if ypu use endpount different from AWS.
Set this to `true` to force the request to use path-style addressing, i.e., `http://s3.amazonaws.com/BUCKET/KEY`. By default, the S3 client will use virtual hosted bucket addressing when possible (`http://BUCKET.s3.amazonaws.com/KEY`).
* `disable_ssl` (optional)
Set this to `true` to disable SSL when sending requests.
* `bucket` (required when `bucket_url` is unspecified)
Specifies the bucket name.
* `key_prefix` (required when `bucket_url` is unspecified)
Specifies the prefix prepended to the file path sent from the client. The key string is derived as follows:
`key` = `key_prefix` + `path`
* `bucket_url` (required when `bucket` is unspecified)
Specifies both the bucket name and prefix in the URL form. The URL's scheme must be `s3`, and the host part corresponds to `bucket` while the path part does to `key_prefix`. You may not specify `bucket_url` and either `bucket` or `key_prefix` at the same time.
* `profile` (optional, defaults to the value of `AWS_PROFILE` unless `credentials` is specified)
Specifies the credentials profile name.
* `region` (optional, defaults to the value of `AWS_REGION` environment variable)
Specifies the region of the endpoint.
* `credentials` (optional)
* `credentials.aws_access_key_id` (required)
Specifies the AWS access key.
* `credentials.aws_secret_access_key` (required)
Specifies the AWS secret access key.
* `max_object_size` (optional, defaults to unlimited)
Specifies the maximum size of an object put to S3. This actually sets the size of the in-memory buffer used to hold the entire content sent from the client, as we have to calculate a MD5 sum for it before uploading there.
* `readable` (optional, defaults to `true`)
Specifies whether to allow the client to fetch objects from S3.
* `writable` (optional, defaults to `true`)
Specifies whether to allow the client to put objects to S3.
* `listable` (optional, defaults to `true`)
Specifies whether to allow the client to list objects in S3.
* `server_side_encryption` (optional, defaults to `"none"`)
Specifies which server-side encryption scheme is applied to store the objects. Valid values are: `"aes256"` and `"kms"`.
* `sse_customer_key` (required when `server_side_encryption` is set to `"aes256"`)
Specifies the base64-encoded encryption key. As the cipher is AES256-CBC, the key must be 256-bits long (32 bytes)
* `sse_kms_key_id` (required when `server_side_encryption` is est to `"kms"`)
Specifies the CMK ID used for the server-side encryption using KMS.
* `keyboard_interactive_auth` (optional, defaults to `false`)
Enables keyboard interactive authentication if set to true.
* `auth` (required)
Specifies the name of the authenticator.
### Authenticator Settings
```toml
[auth.test]
type = "inplace"
# authenticator specific settings follow
```
* `type` (required)
Specifies the authenticator implementation type. Currently `"inplace"` is the only valid value.
* `users` (required when `type` is `"inplace"`)
Contains user records as a dictionary.
#### In-place authenticator
In-place authenticator reads the credentials directly embedded in the configuration file. The user record looks like the following:
```toml
[auth.test]
type = "inplace"
[auth.test.users.user0]
password = "test"
public_keys = """
ssh-rsa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ssh-rsa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
"""
[auth.test.users.user1]
password = "test"
public_keys = """
ssh-rsa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ssh-rsa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
"""
```
Or
```toml
[auth.test]
type = "inplace"
[auth.test.users]
user0 = { password="test", public_keys="..." }
user1 = { password="test", public_keys="..." }
```
* (key) (appears as `user0` or `user1` in the above example)
Specifies the name of the user.
* `password` (optional)
Specifies the password in a clear-text form.
* `public_keys` (optional)
Specifies the public keys authorized to use in authentication. Multiple keys can be specified by delimiting them by newlines.
================================================
FILE: bucket.go
================================================
package main
import (
"crypto"
"encoding/base64"
"fmt"
"strings"
aws "github.com/aws/aws-sdk-go/aws"
aws_creds "github.com/aws/aws-sdk-go/aws/credentials"
aws_ec2_role_creds "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
aws_ec2_meta "github.com/aws/aws-sdk-go/aws/ec2metadata"
aws_session "github.com/aws/aws-sdk-go/aws/session"
s3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/pkg/errors"
)
type ServerSideEncryptionType int
const (
ServerSideEncryptionTypeNone = iota
ServerSideEncryptionTypeAES256
ServerSideEncryptionTypeKMS
)
var sseNameToEnumMap = map[string]ServerSideEncryptionType{
"": ServerSideEncryptionTypeNone,
"none": ServerSideEncryptionTypeNone,
"aes256": ServerSideEncryptionTypeAES256,
"kms": ServerSideEncryptionTypeKMS,
}
func (v *ServerSideEncryptionType) UnmarshalText(text []byte) error {
_v, ok := sseNameToEnumMap[strings.ToLower(string(text))]
if !ok {
return fmt.Errorf("invalid value for ServerSideEncryption: %s", string(text))
}
*v = _v
return nil
}
type ServerSideEncryptionConfig struct {
Type ServerSideEncryptionType
CustomerKey string
CustomerKeyMD5 string
KMSKeyId string
}
func (cfg *ServerSideEncryptionConfig) CustomerAlgorithm() string {
if cfg.Type == ServerSideEncryptionTypeAES256 {
return "AES256"
} else {
return ""
}
}
type Perms struct {
Readable bool
Writable bool
Listable bool
}
type S3Bucket struct {
Name string
AWSConfig *aws.Config
Bucket string
KeyPrefix Path
MaxObjectSize int64
Users UserStore
Perms Perms
ServerSideEncryption ServerSideEncryptionConfig
KeyboardInteractiveAuthEnabled bool
}
type S3Buckets struct {
Buckets map[string]*S3Bucket
UserToBucketMap map[string]*S3Bucket
}
func (s3bs *S3Buckets) Get(name string) *S3Bucket {
b, _ := s3bs.Buckets[name]
return b
}
func (s3b *S3Bucket) S3(sess *aws_session.Session) *s3.S3 {
awsCfg := s3b.AWSConfig
if awsCfg.Credentials == nil {
awsCfg = s3b.AWSConfig.WithCredentials(aws_creds.NewChainCredentials(
[]aws_creds.Provider{
&aws_ec2_role_creds.EC2RoleProvider{
Client: aws_ec2_meta.New(sess),
ExpiryWindow: 0,
},
&aws_creds.EnvProvider{},
},
))
}
return s3.New(sess, awsCfg)
}
func buildS3Bucket(uStores UserStores, name string, bCfg *S3BucketConfig) (*S3Bucket, error) {
awsCfg := aws.NewConfig()
if bCfg.Credentials != nil {
awsCfg = awsCfg.WithCredentials(
aws_creds.NewStaticCredentials(
bCfg.Credentials.AWSAccessKeyID,
bCfg.Credentials.AWSSecretAccessKey,
"",
),
)
} else if bCfg.Profile != "" {
awsCfg = awsCfg.WithCredentials(
aws_creds.NewSharedCredentials(
"", // TODO: assumes default
bCfg.Profile,
),
)
} else {
// credentials are retrieved through EC2 metadata on runtime
}
if bCfg.Endpoint != "" {
awsCfg = awsCfg.WithEndpoint(bCfg.Endpoint)
}
if bCfg.S3ForcePathStyle != nil {
awsCfg = awsCfg.WithS3ForcePathStyle(*bCfg.S3ForcePathStyle)
}
if bCfg.DisableSSL != nil {
awsCfg = awsCfg.WithDisableSSL(*bCfg.DisableSSL)
}
if bCfg.Region != "" {
awsCfg = awsCfg.WithRegion(bCfg.Region)
}
users, ok := uStores[bCfg.Auth]
if !ok {
return nil, fmt.Errorf("no such auth config: %s", bCfg.Auth)
}
keyPrefix := SplitIntoPath(bCfg.KeyPrefix)
if len(keyPrefix) > 0 && keyPrefix[0] == "" {
keyPrefix = keyPrefix[1:]
}
maxObjectSize := int64(-1)
if bCfg.MaxObjectSize != nil {
maxObjectSize = *bCfg.MaxObjectSize
}
var customerKey []byte
var customerKeyMD5 string
if bCfg.SSECustomerKey != "" {
var err error
customerKey, err = base64.StdEncoding.DecodeString(bCfg.SSECustomerKey)
if err != nil {
return nil, errors.Wrapf(err, `invalid base64-encoded string specified for "sse_customer_key"`)
}
hasher := crypto.MD5.New()
hasher.Write(customerKey)
customerKeyMD5 = base64.StdEncoding.EncodeToString(hasher.Sum([]byte{}))
} else {
customerKey = []byte{}
}
return &S3Bucket{
Name: name,
AWSConfig: awsCfg,
Bucket: bCfg.Bucket,
KeyPrefix: keyPrefix,
MaxObjectSize: maxObjectSize,
Users: users,
Perms: Perms{
Readable: *bCfg.Readable,
Writable: *bCfg.Writable,
Listable: *bCfg.Listable,
},
ServerSideEncryption: ServerSideEncryptionConfig{
Type: bCfg.ServerSideEncryption,
CustomerKey: string(customerKey),
CustomerKeyMD5: customerKeyMD5,
KMSKeyId: bCfg.SSEKMSKeyId,
},
KeyboardInteractiveAuthEnabled: bCfg.KeyboardInteractiveAuthEnabled,
}, nil
}
func NewS3BucketFromConfig(uStores UserStores, cfg *S3SFTPProxyConfig) (*S3Buckets, error) {
buckets := map[string]*S3Bucket{}
userToBucketMap := map[string]*S3Bucket{}
for name, bCfg := range cfg.Buckets {
bucket, err := buildS3Bucket(uStores, name, bCfg)
if err != nil {
return nil, errors.Wrapf(err, "bucket config %s", name)
}
for _, user := range bucket.Users.Users {
_bucket, ok := userToBucketMap[user.Name]
if ok {
return nil, fmt.Errorf(`bucket config %s: user "%s" is already assigned to bucket config "%s"`, name, user.Name, _bucket.Name)
}
userToBucketMap[user.Name] = bucket
}
buckets[name] = bucket
}
return &S3Buckets{
Buckets: buckets,
UserToBucketMap: userToBucketMap,
}, nil
}
================================================
FILE: bucketio.go
================================================
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path"
"sync"
"time"
aws "github.com/aws/aws-sdk-go/aws"
aws_session "github.com/aws/aws-sdk-go/aws/session"
aws_s3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/pkg/sftp"
// s3crypto "github.com/aws/aws-sdk-go/service/s3/s3crypto"
)
var aclPrivate = "private"
type ReadDeadlineSettable interface {
SetReadDeadline(t time.Time) error
}
type WriteDeadlineSettable interface {
SetWriteDeadline(t time.Time) error
}
var sseTypes = map[ServerSideEncryptionType]*string{
ServerSideEncryptionTypeKMS: aws.String("aws:kms"),
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
} else {
return &s
}
}
type S3GetObjectOutputReader struct {
Ctx context.Context
Goo *aws_s3.GetObjectOutput
Log DebugLogger
Lookback int
MinChunkSize int
mtx sync.Mutex
spooled []byte
spoolOffset int
noMore bool
}
func (oor *S3GetObjectOutputReader) Close() error {
if oor.Goo.Body != nil {
oor.Goo.Body.Close()
oor.Goo.Body = nil
}
return nil
}
func (oor *S3GetObjectOutputReader) ReadAt(buf []byte, off int64) (int, error) {
oor.mtx.Lock()
defer oor.mtx.Unlock()
F(oor.Log.Debug, "len(buf)=%d, off=%d", len(buf), off)
_o, err := castInt64ToInt(off)
if err != nil {
return 0, err
}
if _o < oor.spoolOffset {
return 0, fmt.Errorf("supplied position is out of range")
}
s := _o - oor.spoolOffset
i := 0
r := len(buf)
if s < len(oor.spooled) {
// n = max(r, len(oor.spooled)-s)
n := r
if n > len(oor.spooled)-s {
n = len(oor.spooled) - s
}
copy(buf[i:i+n], oor.spooled[s:s+n])
i += n
s += n
r -= n
}
if r == 0 {
return i, nil
}
if oor.noMore {
if i == 0 {
return 0, io.EOF
} else {
return i, nil
}
}
F(oor.Log.Debug, "s=%d, len(oor.spooled)=%d, oor.Lookback=%d", s, len(oor.spooled), oor.Lookback)
if s <= len(oor.spooled) && s >= oor.Lookback {
oor.spooled = oor.spooled[s-oor.Lookback:]
oor.spoolOffset += s - oor.Lookback
s = oor.Lookback
}
var e int
if len(oor.spooled)+oor.MinChunkSize < s+r {
e = s + r
} else {
e = len(oor.spooled) + oor.MinChunkSize
}
if cap(oor.spooled) < e {
spooled := make([]byte, len(oor.spooled), e)
copy(spooled, oor.spooled)
oor.spooled = spooled
}
type readResult struct {
n int
err error
}
resultChan := make(chan readResult)
go func() {
n, err := io.ReadFull(oor.Goo.Body, oor.spooled[len(oor.spooled):e])
resultChan <- readResult{n, err}
}()
select {
case <-oor.Ctx.Done():
oor.Goo.Body.(ReadDeadlineSettable).SetReadDeadline(time.Unix(1, 0))
oor.Log.Debug("canceled")
return 0, fmt.Errorf("read operation canceled")
case res := <-resultChan:
if IsEOF(res.err) {
oor.noMore = true
}
e = len(oor.spooled) + res.n
oor.spooled = oor.spooled[:e]
if s < e {
be := e
if be > s+r {
be = s + r
}
copy(buf[i:], oor.spooled[s:be])
return be - s, nil
} else {
return 0, io.EOF
}
}
}
type S3PutObjectWriter struct {
Ctx context.Context
Bucket string
Key Path
S3 *aws_s3.S3
ServerSideEncryption *ServerSideEncryptionConfig
Log interface {
DebugLogger
ErrorLogger
}
MaxObjectSize int64
Info *PhantomObjectInfo
PhantomObjectMap *PhantomObjectMap
mtx sync.Mutex
writer *BytesWriter
}
func (oow *S3PutObjectWriter) Close() error {
F(oow.Log.Debug, "S3PutObjectWriter.Close")
oow.mtx.Lock()
defer oow.mtx.Unlock()
phInfo := oow.Info.GetOne()
oow.PhantomObjectMap.RemoveByInfoPtr(oow.Info)
key := phInfo.Key.String()
sse := oow.ServerSideEncryption
F(oow.Log.Debug, "PutObject(Bucket=%s, Key=%s, Sse=%v)", oow.Bucket, key, sse)
_, err := oow.S3.PutObject(
&aws_s3.PutObjectInput{
ACL: &aclPrivate,
Body: bytes.NewReader(oow.writer.Bytes()),
Bucket: &oow.Bucket,
Key: &key,
ServerSideEncryption: sseTypes[sse.Type],
SSECustomerAlgorithm: nilIfEmpty(sse.CustomerAlgorithm()),
SSECustomerKey: nilIfEmpty(sse.CustomerKey),
SSECustomerKeyMD5: nilIfEmpty(sse.CustomerKeyMD5),
SSEKMSKeyId: nilIfEmpty(sse.KMSKeyId),
},
)
if err != nil {
oow.Log.Debug("=> ", err)
F(oow.Log.Error, "failed to put object: %s", err.Error())
} else {
oow.Log.Debug("=> OK")
}
return nil
}
func (oow *S3PutObjectWriter) WriteAt(buf []byte, off int64) (int, error) {
oow.mtx.Lock()
defer oow.mtx.Unlock()
if oow.MaxObjectSize >= 0 {
if int64(len(buf))+off > oow.MaxObjectSize {
return 0, fmt.Errorf("file too large: maximum allowed size is %d bytes", oow.MaxObjectSize)
}
}
F(oow.Log.Debug, "len(buf)=%d, off=%d", len(buf), off)
n, err := oow.writer.WriteAt(buf, off)
oow.Info.SetSize(oow.writer.Size())
return n, err
}
type ObjectFileInfo struct {
_Name string
_LastModified time.Time
_Size int64
_Mode os.FileMode
}
func (ofi *ObjectFileInfo) Name() string {
return ofi._Name
}
func (ofi *ObjectFileInfo) ModTime() time.Time {
return ofi._LastModified
}
func (ofi *ObjectFileInfo) Size() int64 {
return ofi._Size
}
func (ofi *ObjectFileInfo) Mode() os.FileMode {
return ofi._Mode
}
func (ofi *ObjectFileInfo) IsDir() bool {
return (ofi._Mode & os.ModeDir) != 0
}
func (ofi *ObjectFileInfo) Sys() interface{} {
return BuildFakeFileInfoSys()
}
type S3ObjectLister struct {
DebugLogger
Ctx context.Context
Bucket string
Prefix Path
S3 *aws_s3.S3
Lookback int
PhantomObjectMap *PhantomObjectMap
spoolOffset int
spooled []os.FileInfo
continuation *string
noMore bool
}
func aclToMode(owner *aws_s3.Owner, grants []*aws_s3.Grant) os.FileMode {
var v os.FileMode
for _, g := range grants {
if g.Grantee != nil {
if g.Grantee.ID != nil && *g.Grantee.ID == *owner.ID {
switch *g.Permission {
case "READ":
v |= 0400
case "WRITE":
v |= 0200
case "FULL_CONTROL":
v |= 0600
}
} else if g.Grantee.URI != nil {
switch *g.Grantee.URI {
case "http://acs.amazonaws.com/groups/global/AuthenticatedUsers":
switch *g.Permission {
case "READ":
v |= 0440
case "WRITE":
v |= 0220
case "FULL_CONTROL":
v |= 0660
}
case "http://acs.amazonaws.com/groups/global/AllUsers":
switch *g.Permission {
case "READ":
v |= 0444
case "WRITE":
v |= 0222
case "FULL_CONTROL":
v |= 0666
}
}
}
}
}
return v
}
func (sol *S3ObjectLister) ListAt(result []os.FileInfo, o int64) (int, error) {
_o, err := castInt64ToInt(o)
if err != nil {
return 0, err
}
if _o < sol.spoolOffset {
return 0, fmt.Errorf("supplied position is out of range")
}
s := _o - sol.spoolOffset
i := 0
if s < len(sol.spooled) {
n := len(result)
if n > len(sol.spooled)-s {
n = len(sol.spooled) - s
}
copy(result[i:i+n], sol.spooled[s:s+n])
i += n
s = len(sol.spooled)
}
if i >= len(result) {
return i, nil
}
if sol.noMore {
if i == 0 {
return 0, io.EOF
} else {
return i, nil
}
}
if s <= len(sol.spooled) && s >= sol.Lookback {
sol.spooled = sol.spooled[s-sol.Lookback:]
sol.spoolOffset += s - sol.Lookback
s = sol.Lookback
}
if sol.continuation == nil {
sol.spooled = append(sol.spooled, &ObjectFileInfo{
_Name: ".",
_LastModified: time.Unix(1, 0),
_Size: 0,
_Mode: 0755 | os.ModeDir,
})
sol.spooled = append(sol.spooled, &ObjectFileInfo{
_Name: "..",
_LastModified: time.Unix(1, 0),
_Size: 0,
_Mode: 0755 | os.ModeDir,
})
phObjs := sol.PhantomObjectMap.List(sol.Prefix)
for _, phInfo := range phObjs {
_phInfo := phInfo.GetOne()
sol.spooled = append(sol.spooled, &ObjectFileInfo{
_Name: _phInfo.Key.Base(),
_LastModified: _phInfo.LastModified,
_Size: _phInfo.Size,
_Mode: 0600, // TODO
})
}
}
prefix := sol.Prefix.String()
if prefix != "" {
prefix += "/"
}
F(sol.Debug, "ListObjectsV2WithContext(Bucket=%s, Prefix=%s, Continuation=%v)", sol.Bucket, prefix, sol.continuation)
out, err := sol.S3.ListObjectsV2WithContext(
sol.Ctx,
&aws_s3.ListObjectsV2Input{
Bucket: &sol.Bucket,
Prefix: &prefix,
MaxKeys: aws.Int64(10000),
Delimiter: aws.String("/"),
ContinuationToken: sol.continuation,
},
)
if err != nil {
sol.Debug("=> ", err)
return i, err
}
F(sol.Debug, "=> { CommonPrefixes=len(%d), Contents=len(%d) }", len(out.CommonPrefixes), len(out.Contents))
if sol.continuation == nil {
for _, cPfx := range out.CommonPrefixes {
sol.spooled = append(sol.spooled, &ObjectFileInfo{
_Name: path.Base(*cPfx.Prefix),
_LastModified: time.Unix(1, 0),
_Size: 0,
_Mode: 0755 | os.ModeDir,
})
}
}
for _, obj := range out.Contents {
// if *obj.Key == sol.Prefix {
// continue
// }
sol.spooled = append(sol.spooled, &ObjectFileInfo{
_Name: path.Base(*obj.Key),
_LastModified: *obj.LastModified,
_Size: *obj.Size,
_Mode: 0644,
})
}
sol.continuation = out.NextContinuationToken
if out.NextContinuationToken == nil {
sol.noMore = true
}
var n int
if len(sol.spooled)-s > len(result)-i {
n = len(result) - i
} else {
n = len(sol.spooled) - s
if sol.noMore {
err = io.EOF
}
}
copy(result[i:i+n], sol.spooled[s:s+n])
return i + n, err
}
type S3ObjectStat struct {
DebugLogger
Ctx context.Context
Bucket string
Key Path
Root bool
S3 *aws_s3.S3
PhantomObjectMap *PhantomObjectMap
}
func (sos *S3ObjectStat) ListAt(result []os.FileInfo, o int64) (int, error) {
F(sos.Debug, "S3ObjectStat.ListAt: len(result)=%d offset=%d", len(result), o)
_o, err := castInt64ToInt(o)
if err != nil {
return 0, err
}
if len(result) == 0 {
return 0, nil
}
if _o > 0 {
return 0, fmt.Errorf("supplied position is out of range")
}
if sos.Key.IsRoot() {
result[0] = &ObjectFileInfo{
_Name: "/",
_LastModified: time.Time{},
_Size: 0,
_Mode: 0755 | os.ModeDir,
}
} else {
phInfo := sos.PhantomObjectMap.Get(sos.Key)
if phInfo != nil {
_phInfo := phInfo.GetOne()
result[0] = &ObjectFileInfo{
_Name: _phInfo.Key.Base(),
_LastModified: _phInfo.LastModified,
_Size: _phInfo.Size,
_Mode: 0600, // TODO
}
} else {
key := sos.Key.String()
F(sos.Debug, "GetObjectAclWithContext(Bucket=%s, Key=%s)", sos.Bucket, key)
out, err := sos.S3.GetObjectAclWithContext(
sos.Ctx,
&aws_s3.GetObjectAclInput{
Bucket: &sos.Bucket,
Key: &key,
},
)
if err == nil {
F(sos.Debug, "=> %v", out)
F(sos.Debug, "HeadObjectWithContext(Bucket=%s, Key=%s)", sos.Bucket, key)
headOut, err := sos.S3.HeadObjectWithContext(
sos.Ctx,
&aws_s3.HeadObjectInput{
Bucket: &sos.Bucket,
Key: &key,
},
)
objInfo := ObjectFileInfo{
_Name: sos.Key.Base(),
_Mode: aclToMode(out.Owner, out.Grants),
}
if err == nil {
F(sos.Debug, "=> { ContentLength=%d, LastModified=%v }", *headOut.ContentLength, *headOut.LastModified)
objInfo._Size = *headOut.ContentLength
objInfo._LastModified = *headOut.LastModified
} else {
sos.Debug("=> ", err)
}
result[0] = &objInfo
} else {
sos.Debug("=> ", err)
F(sos.Debug, "ListObjectsV2WithContext(Bucket=%s, Prefix=%s)", sos.Bucket, key)
out, err := sos.S3.ListObjectsV2WithContext(
sos.Ctx,
&aws_s3.ListObjectsV2Input{
Bucket: &sos.Bucket,
Prefix: &key,
MaxKeys: aws.Int64(10000),
Delimiter: aws.String("/"),
},
)
if err != nil || (!sos.Root && len(out.CommonPrefixes) == 0) {
sos.Debug("=> ", err)
return 0, os.ErrNotExist
}
F(sos.Debug, "=> { CommonPrefixes=len(%d), Contents=len(%d) }", len(out.CommonPrefixes), len(out.Contents))
result[0] = &ObjectFileInfo{
_Name: sos.Key.Base(),
_LastModified: time.Time{},
_Size: 0,
_Mode: 0755 | os.ModeDir,
}
}
}
}
return 1, nil
}
type S3BucketIO struct {
Ctx context.Context
Bucket *S3Bucket
ReaderLookbackBufferSize int
ReaderMinChunkSize int
ListerLookbackBufferSize int
PhantomObjectMap *PhantomObjectMap
Perms Perms
ServerSideEncryption *ServerSideEncryptionConfig
Now func() time.Time
Log interface {
ErrorLogger
DebugLogger
}
}
func buildKey(s3b *S3Bucket, path string) Path {
return s3b.KeyPrefix.Join(SplitIntoPath(path))
}
func buildPath(s3b *S3Bucket, key string) (string, bool) {
_key := SplitIntoPath(key)
if !_key.IsPrefixed(s3b.KeyPrefix) {
return "", false
}
return "/" + _key[len(s3b.KeyPrefix):].String(), true
}
func (s3io *S3BucketIO) Fileread(req *sftp.Request) (io.ReaderAt, error) {
if !s3io.Perms.Readable {
return nil, fmt.Errorf("read operation not allowed as per configuration")
}
sess, err := aws_session.NewSession()
if err != nil {
return nil, err
}
s3 := s3io.Bucket.S3(sess)
key := buildKey(s3io.Bucket, req.Filepath)
phInfo := s3io.PhantomObjectMap.Get(key)
if phInfo != nil {
return bytes.NewReader(phInfo.Opaque.(*S3PutObjectWriter).writer.Bytes()), nil
}
keyStr := key.String()
ctx := combineContext(s3io.Ctx, req.Context())
F(s3io.Log.Debug, "GetObject(Bucket=%s, Key=%s)", s3io.Bucket.Bucket, keyStr)
sse := s3io.ServerSideEncryption
goo, err := s3.GetObjectWithContext(
ctx,
&aws_s3.GetObjectInput{
Bucket: &s3io.Bucket.Bucket,
Key: &keyStr,
SSECustomerAlgorithm: nilIfEmpty(sse.CustomerAlgorithm()),
SSECustomerKey: nilIfEmpty(sse.CustomerKey),
SSECustomerKeyMD5: nilIfEmpty(sse.CustomerKeyMD5),
},
)
if err != nil {
return nil, err
}
return &S3GetObjectOutputReader{
Ctx: ctx,
Goo: goo,
Log: s3io.Log,
Lookback: s3io.ReaderLookbackBufferSize,
MinChunkSize: s3io.ReaderMinChunkSize,
}, nil
}
func (s3io *S3BucketIO) Filewrite(req *sftp.Request) (io.WriterAt, error) {
if !s3io.Perms.Writable {
return nil, fmt.Errorf("write operation not allowed as per configuration")
}
sess, err := aws_session.NewSession()
if err != nil {
return nil, err
}
maxObjectSize := s3io.Bucket.MaxObjectSize
if maxObjectSize < 0 {
maxObjectSize = int64(^uint(0) >> 1)
}
key := buildKey(s3io.Bucket, req.Filepath)
info := &PhantomObjectInfo{
Key: key,
Size: 0,
LastModified: s3io.Now(),
}
F(s3io.Log.Debug, "S3PutObjectWriter.New(key=%s)", key)
oow := &S3PutObjectWriter{
Ctx: combineContext(s3io.Ctx, req.Context()),
Bucket: s3io.Bucket.Bucket,
Key: key,
S3: s3io.Bucket.S3(sess),
ServerSideEncryption: s3io.ServerSideEncryption,
Log: s3io.Log,
MaxObjectSize: maxObjectSize,
PhantomObjectMap: s3io.PhantomObjectMap,
Info: info,
writer: NewBytesWriter(),
}
info.Opaque = oow
s3io.PhantomObjectMap.Add(info)
return oow, nil
}
func (s3io *S3BucketIO) Filecmd(req *sftp.Request) error {
switch req.Method {
case "Rename":
if !s3io.Perms.Writable {
return fmt.Errorf("write operation not allowed as per configuration")
}
src := buildKey(s3io.Bucket, req.Filepath)
dest := buildKey(s3io.Bucket, req.Target)
if s3io.PhantomObjectMap.Rename(src, dest) {
return nil
}
sess, err := aws_session.NewSession()
if err != nil {
return err
}
srcStr := src.String()
destStr := dest.String()
copySource := s3io.Bucket.Bucket + "/" + srcStr
sse := s3io.ServerSideEncryption
F(s3io.Log.Debug, "CopyObject(Bucket=%s, Key=%s, CopySource=%s, Sse=%v)", s3io.Bucket.Bucket, destStr, copySource, sse.Type)
_, err = s3io.Bucket.S3(sess).CopyObjectWithContext(
combineContext(s3io.Ctx, req.Context()),
&aws_s3.CopyObjectInput{
ACL: &aclPrivate,
Bucket: &s3io.Bucket.Bucket,
CopySource: ©Source,
Key: &destStr,
ServerSideEncryption: sseTypes[sse.Type],
SSECustomerAlgorithm: nilIfEmpty(sse.CustomerAlgorithm()),
SSECustomerKey: nilIfEmpty(sse.CustomerKey),
SSECustomerKeyMD5: nilIfEmpty(sse.CustomerKeyMD5),
SSEKMSKeyId: nilIfEmpty(sse.KMSKeyId),
},
)
if err != nil {
s3io.Log.Debug("=> ", err)
return err
}
F(s3io.Log.Debug, "DeleteObject(Bucket=%s, Key=%s)", s3io.Bucket.Bucket, srcStr)
_, err = s3io.Bucket.S3(sess).DeleteObjectWithContext(
combineContext(s3io.Ctx, req.Context()),
&aws_s3.DeleteObjectInput{
Bucket: &s3io.Bucket.Bucket,
Key: &srcStr,
},
)
if err != nil {
s3io.Log.Debug("=> ", err)
return err
}
case "Remove":
if !s3io.Perms.Writable {
return fmt.Errorf("write operation not allowed as per configuration")
}
key := buildKey(s3io.Bucket, req.Filepath)
if s3io.PhantomObjectMap.Remove(key) != nil {
return nil
}
sess, err := aws_session.NewSession()
if err != nil {
return err
}
keyStr := key.String()
F(s3io.Log.Debug, "DeleteObject(Bucket=%s, Key=%s)", s3io.Bucket.Bucket, key)
_, err = s3io.Bucket.S3(sess).DeleteObjectWithContext(
combineContext(s3io.Ctx, req.Context()),
&aws_s3.DeleteObjectInput{
Bucket: &s3io.Bucket.Bucket,
Key: &keyStr,
},
)
if err != nil {
s3io.Log.Debug("=> ", err)
return err
}
}
return nil
}
func (s3io *S3BucketIO) Filelist(req *sftp.Request) (sftp.ListerAt, error) {
sess, err := aws_session.NewSession()
if err != nil {
return nil, err
}
switch req.Method {
case "Stat", "ReadLink":
if !s3io.Perms.Readable && !s3io.Perms.Listable {
return nil, fmt.Errorf("stat operation not allowed as per configuration")
}
key := buildKey(s3io.Bucket, req.Filepath)
return &S3ObjectStat{
DebugLogger: s3io.Log,
Ctx: combineContext(s3io.Ctx, req.Context()),
Bucket: s3io.Bucket.Bucket,
Root: key.Equal(s3io.Bucket.KeyPrefix),
Key: key,
S3: s3io.Bucket.S3(sess),
PhantomObjectMap: s3io.PhantomObjectMap,
}, nil
case "List":
if !s3io.Perms.Listable {
return nil, fmt.Errorf("listing operation not allowed as per configuration")
}
return &S3ObjectLister{
DebugLogger: s3io.Log,
Ctx: combineContext(s3io.Ctx, req.Context()),
Bucket: s3io.Bucket.Bucket,
Prefix: buildKey(s3io.Bucket, req.Filepath),
S3: s3io.Bucket.S3(sess),
Lookback: s3io.ListerLookbackBufferSize,
PhantomObjectMap: s3io.PhantomObjectMap,
}, nil
default:
return nil, fmt.Errorf("unsupported method: %s", req.Method)
}
}
================================================
FILE: config.go
================================================
package main
import (
"fmt"
"github.com/BurntSushi/toml"
"github.com/pkg/errors"
"io/ioutil"
"net/url"
)
var (
minReaderLookbackBufferSize = 1048576
minReaderMinChunkSize = 262144
minListerLookbackBufferSize = 100
vTrue = true
)
type URL struct {
*url.URL
}
func (u *URL) UnmarshalText(text []byte) (err error) {
u.URL, err = url.Parse(string(text))
return
}
type AWSCredentialsConfig struct {
AWSAccessKeyID string `toml:"aws_access_key_id"`
AWSSecretAccessKey string `toml:"aws_secret_access_key"`
}
type S3BucketConfig struct {
Profile string `toml:"profile"`
Credentials *AWSCredentialsConfig `toml:"credentials"`
Region string `toml:"region"`
Endpoint string `toml:"endpoint"`
DisableSSL *bool `toml:"disable_ssl"`
S3ForcePathStyle *bool `toml:"s3_force_path_style"`
Bucket string `toml:"bucket"`
KeyPrefix string `toml:"key_prefix"`
BucketUrl *URL `toml:"bucket_url"`
Auth string `toml:"auth"`
MaxObjectSize *int64 `toml:"max_object_size"`
Readable *bool `toml:"readble"`
Writable *bool `toml:"writable"`
Listable *bool `toml:"listable"`
ServerSideEncryption ServerSideEncryptionType `toml:"server_side_encryption"`
SSECustomerKey string `toml:"sse_customer_key"`
SSEKMSKeyId string `toml:"sse_kms_key_id"`
KeyboardInteractiveAuthEnabled bool `toml:"keyboard_interactive_auth"`
}
type AuthUser struct {
Password string `toml:"password"`
PublicKeys string `toml:"public_keys"`
PublicKeyFile string `toml:"public_key_file"`
}
type AuthConfig struct {
Type string `toml:"type"`
UserDBFile string `toml:"user_db_file"`
Users map[string]AuthUser `toml:"users"`
}
type S3SFTPProxyConfig struct {
Bind string `toml:"bind"`
HostKeyFile string `toml:"host_key_file"`
Banner string `toml:"banner"`
ReaderLookbackBufferSize *int `toml:"reader_lookback_buffer_size"`
ReaderMinChunkSize *int `toml:"reader_min_chunk_size"`
ListerLookbackBufferSize *int `toml:"lister_lookback_buffer_size"`
Buckets map[string]*S3BucketConfig `toml:"buckets"`
AuthConfigs map[string]*AuthConfig `toml:"auth"`
}
func validateAndFixupBucketConfig(bCfg *S3BucketConfig) error {
if bCfg.Profile != "" {
if bCfg.Credentials != nil {
return fmt.Errorf("no credentials may be specified if profile is given")
}
}
if bCfg.BucketUrl != nil {
if bCfg.Bucket != "" {
return fmt.Errorf("bucket may not be specified if bucket_url is given")
}
if bCfg.KeyPrefix != "" {
return fmt.Errorf("root path may not be specified if bucket_url is given")
}
if bCfg.BucketUrl.Host == "" {
return fmt.Errorf("bucket name is empty")
}
if bCfg.BucketUrl.Scheme != "s3" {
return fmt.Errorf("bucket URL scheme must be \"s3\"")
}
bCfg.Bucket = bCfg.BucketUrl.Host
bCfg.KeyPrefix = bCfg.BucketUrl.Path
} else {
if bCfg.Bucket == "" {
return fmt.Errorf("bucket name is empty")
}
}
if bCfg.Auth == "" {
return fmt.Errorf("auth is not specified")
}
if bCfg.Readable == nil {
bCfg.Readable = &vTrue
}
if bCfg.Writable == nil {
bCfg.Writable = &vTrue
}
if bCfg.Listable == nil {
bCfg.Listable = &vTrue
}
return nil
}
func validateAndFixupAuthConfigInplace(aCfg *AuthConfig) error {
if aCfg.UserDBFile != "" {
return fmt.Errorf(`user_db_file may not be specified when auth type is "inplace"`)
}
if aCfg.Users == nil || len(aCfg.Users) == 0 {
fmt.Printf("%#v\n", aCfg.Users)
return fmt.Errorf(`no "users" present`)
}
return nil
}
func validateAndFixupAuthConfig(aCfg *AuthConfig) error {
switch aCfg.Type {
case "inplace":
return validateAndFixupAuthConfigInplace(aCfg)
default:
return fmt.Errorf("unknown auth type: %s", aCfg.Type)
}
}
func ReadConfig(tomlStr string) (*S3SFTPProxyConfig, error) {
cfg := &S3SFTPProxyConfig{
Buckets: map[string]*S3BucketConfig{},
AuthConfigs: map[string]*AuthConfig{},
}
_, err := toml.Decode(tomlStr, cfg)
if err != nil {
return nil, err
}
if len(cfg.Buckets) == 0 {
return nil, fmt.Errorf("no bucket configs are present")
}
if len(cfg.AuthConfigs) == 0 {
return nil, fmt.Errorf("no auth configs are present")
}
if cfg.HostKeyFile == "" {
return nil, fmt.Errorf("no host key file is specified")
}
if len(cfg.Banner) > 0 && cfg.Banner[len(cfg.Banner)-1] != '\n' {
cfg.Banner += "\n"
}
if cfg.ReaderLookbackBufferSize == nil {
cfg.ReaderLookbackBufferSize = &minReaderLookbackBufferSize
} else if *cfg.ReaderLookbackBufferSize < minReaderLookbackBufferSize {
return nil, fmt.Errorf("reader_lookback_buffer_size must be equal to or greater than %d", minReaderMinChunkSize)
}
if cfg.ReaderMinChunkSize == nil {
cfg.ReaderMinChunkSize = &minReaderMinChunkSize
} else if *cfg.ReaderMinChunkSize < minReaderMinChunkSize {
return nil, fmt.Errorf("reader_min_chunk_size must be equal to or greater than %d", minReaderMinChunkSize)
}
if cfg.ListerLookbackBufferSize == nil {
cfg.ListerLookbackBufferSize = &minListerLookbackBufferSize
} else if *cfg.ListerLookbackBufferSize < minListerLookbackBufferSize {
return nil, fmt.Errorf("lister_lookback_buffer_size must be equal to or greater than %d", minListerLookbackBufferSize)
}
for name, bCfg := range cfg.Buckets {
err := validateAndFixupBucketConfig(bCfg)
if err != nil {
return nil, errors.Wrapf(err, `bucket config "%s"`, name)
}
}
for name, aCfg := range cfg.AuthConfigs {
err := validateAndFixupAuthConfig(aCfg)
if err != nil {
return nil, errors.Wrapf(err, `auth config "%s"`, name)
}
}
return cfg, err
}
func ReadConfigFromFile(tomlFile string) (*S3SFTPProxyConfig, error) {
tomlStr, err := ioutil.ReadFile(tomlFile)
if err != nil {
return nil, errors.Wrapf(err, "failed to open %s", tomlFile)
}
cfg, err := ReadConfig(string(tomlStr))
if err != nil {
return nil, errors.Wrapf(err, "failed to parse %s", tomlFile)
}
return cfg, nil
}
================================================
FILE: fakee_unix.go
================================================
// +build !windows
package main
import (
"syscall"
)
func BuildFakeFileInfoSys() interface{} {
return &syscall.Stat_t{Uid: 65534, Gid: 65534}
}
================================================
FILE: fakee_windows.go
================================================
// +build windows
package main
import "syscall"
func BuildFakeFileInfoSys() interface{} {
return syscall.Win32FileAttributeData{}
}
================================================
FILE: io.go
================================================
package main
import (
"fmt"
"io"
"unsafe"
)
type BytesWriter struct {
buf []byte
pos int
}
func NewBytesWriter() *BytesWriter {
return &BytesWriter{
buf: []byte{},
}
}
func castInt64ToInt(n int64) (int, error) {
if unsafe.Sizeof(n) == unsafe.Sizeof(int(0)) {
return int(n), nil
} else {
_n := int(n)
if int64(_n) < n {
return -1, fmt.Errorf("integer overflow detected when converting %#v to int", n)
}
return _n, nil
}
}
func (bw *BytesWriter) Close() error {
return nil
}
// Resize the buffer capacity so the new size is at least the value of newCap.
func (bw *BytesWriter) grow(newCap int) {
if cap(bw.buf) >= newCap {
return
}
i := cap(bw.buf)
if i < 2 {
i = 2
}
for i < newCap {
i = i + i/2
if i < cap(bw.buf) {
panic("allocation failure")
}
}
newBuf := make([]byte, len(bw.buf), i)
copy(newBuf, bw.buf)
bw.buf = newBuf
}
func (bw *BytesWriter) Truncate(n int64) error {
_n, err := castInt64ToInt(n)
if err != nil {
return err
}
bw.buf = bw.buf[0:_n]
if bw.pos > _n {
bw.pos = _n
}
return nil
}
func (bw *BytesWriter) Seek(offset int64, whence int) (int64, error) {
_o, err := castInt64ToInt(offset)
if err != nil {
return -1, err
}
var newPos int
switch whence {
case 0:
newPos = _o
case 1:
newPos = bw.pos + _o
case 2:
newPos = len(bw.buf) + _o
}
if newPos < len(bw.buf) {
bw.grow(newPos)
bw.buf = bw.buf[0:newPos]
}
bw.pos = newPos
return int64(newPos), nil
}
func (bw *BytesWriter) Write(p []byte) (n int, err error) {
bw.grow(bw.pos + len(p))
copy(bw.buf[bw.pos:bw.pos+len(p)], p)
return len(p), nil
}
func (bw *BytesWriter) WriteAt(p []byte, offset int64) (n int, err error) {
_o, err := castInt64ToInt(offset)
if err != nil {
return -1, err
}
req := _o + len(p)
if req > len(bw.buf) {
bw.grow(req)
bw.buf = bw.buf[0:req]
}
copy(bw.buf[_o:req], p)
return len(p), nil
}
func (bw *BytesWriter) Size() int64 {
return int64(len(bw.buf))
}
func (bw *BytesWriter) Bytes() []byte {
return bw.buf
}
func IsEOF(e error) bool {
return e == io.EOF || e == io.ErrUnexpectedEOF
}
func IsTimeout(e error) bool {
t, ok := e.(interface{ Timeout() bool })
if ok {
return t.Timeout()
}
return false
}
================================================
FILE: logging.go
================================================
package main
type DebugLogger interface {
Debug(args ...interface{})
}
type InfoLogger interface {
Info(args ...interface{})
}
type ErrorLogger interface {
Error(args ...interface{})
}
================================================
FILE: main.go
================================================
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io/ioutil"
"net"
"os"
"os/signal"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/crypto/ssh"
)
var (
configFile string
bind string
debug bool
)
func init() {
flag.StringVar(&configFile, "config", "s3-sftp-proxy.toml", "configuration file")
flag.StringVar(&bind, "bind", "", "listen on addr:port")
flag.BoolVar(&debug, "debug", false, "turn on debugging output")
}
func buildSSHServerConfig(buckets *S3Buckets, cfg *S3SFTPProxyConfig) (*ssh.ServerConfig, error) {
pem, err := ioutil.ReadFile(cfg.HostKeyFile)
if err != nil {
return nil, errors.Wrapf(err, `failed to open "%s"`, cfg.HostKeyFile)
}
key, err := ssh.ParseRawPrivateKey(pem)
if err != nil {
return nil, errors.Wrapf(err, `failed to parse host key "%s"`, cfg.HostKeyFile)
}
c := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, passwd []byte) (*ssh.Permissions, error) {
bucket, ok := buckets.UserToBucketMap[c.User()]
if !ok {
return nil, fmt.Errorf("unknown user: %s", c.User())
}
u := bucket.Users.Lookup(c.User())
if u.Password != "" && u.Password == string(passwd) {
return nil, nil
}
return nil, fmt.Errorf("passwords do not match")
},
PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
bucket, ok := buckets.UserToBucketMap[c.User()]
if !ok {
return nil, fmt.Errorf("unknown user: %s", c.User())
}
u := bucket.Users.Lookup(c.User())
if u.PublicKeys != nil {
keyMarshaled := key.Marshal()
for _, herKey := range u.PublicKeys {
if herKey.Type() == key.Type() && len(herKey.Marshal()) == len(keyMarshaled) && bytes.Compare(herKey.Marshal(), keyMarshaled) == 0 {
return &ssh.Permissions{
Extensions: map[string]string{
"pubkey-fp": ssh.FingerprintSHA256(key),
},
}, nil
}
}
}
return nil, fmt.Errorf("public keys do not match")
},
KeyboardInteractiveCallback: func(c ssh.ConnMetadata, client ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) {
bucket, ok := buckets.UserToBucketMap[c.User()]
if !ok {
return nil, fmt.Errorf("unknown user: %s", c.User())
}
if !bucket.KeyboardInteractiveAuthEnabled {
return nil, fmt.Errorf("keyboard interactive authentication not enabled")
}
u := bucket.Users.Lookup(c.User())
if u.Password == "" {
return nil, fmt.Errorf("no credentials are present")
}
answers, err := client(u.Name, "", []string{"Password: "}, []bool{false})
if err != nil {
return nil, errors.Wrapf(err, "keyboard interactive conversation failed")
}
if answers[0] != u.Password {
return nil, fmt.Errorf("passwords do not match")
}
return nil, nil
},
BannerCallback: func(c ssh.ConnMetadata) string {
return cfg.Banner
},
}
sgn, err := ssh.NewSignerFromKey(key)
if err != nil {
return nil, err
}
c.AddHostKey(sgn)
return c, nil
}
func bail(msg string, status ...interface{}) {
os.Stderr.Write([]byte(msg + "\n"))
statusCode := 1
if len(status) > 0 {
var ok bool
statusCode, ok = status[0].(int)
if !ok {
panic("invalid argument for bail()")
}
}
os.Exit(statusCode)
}
func main() {
flag.Parse()
cfg, err := ReadConfigFromFile(configFile)
if err != nil {
bail(err.Error())
}
uStores, err := NewUserStoresFromConfig(cfg)
if err != nil {
bail(err.Error())
}
buckets, err := NewS3BucketFromConfig(uStores, cfg)
if err != nil {
bail(err.Error())
}
sCfg, err := buildSSHServerConfig(buckets, cfg)
if err != nil {
bail(err.Error())
}
_bind := bind
if _bind == "" {
_bind = cfg.Bind
if _bind == "" {
_bind = ":10022"
}
}
logger := logrus.New()
if debug {
logger.SetLevel(logrus.DebugLevel)
}
lsnr, err := net.Listen("tcp", _bind)
if err != nil {
bail(err.Error())
}
defer lsnr.Close()
logger.Info("Listen on ", _bind)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal)
signal.Notify(sigChan, os.Interrupt)
errChan := make(chan error)
go func() {
errChan <- (&Server{
S3Buckets: buckets,
ServerConfig: sCfg,
Log: logger,
ReaderLookbackBufferSize: *cfg.ReaderLookbackBufferSize,
ReaderMinChunkSize: *cfg.ReaderMinChunkSize,
ListerLookbackBufferSize: *cfg.ListerLookbackBufferSize,
PhantomObjectMap: NewPhantomObjectMap(),
Now: time.Now,
}).RunListenerEventLoop(ctx, lsnr.(*net.TCPListener))
}()
outer:
for {
select {
case err = <-errChan:
if err != nil {
bail(err.Error())
}
break outer
case <-sigChan:
cancel()
}
}
}
================================================
FILE: merged_context.go
================================================
package main
import (
"context"
"reflect"
"time"
)
type mergedContext struct {
ctxs []context.Context
doneChan chan struct{}
err error
}
func (ctxs *mergedContext) Deadline() (time.Time, bool) {
retval := time.Time{}
retvalAvail := false
for _, ctx := range ctxs.ctxs {
if dl, ok := ctx.Deadline(); ok {
if !retval.IsZero() || retval.After(dl) {
retval = dl
retvalAvail = true
}
}
}
return retval, retvalAvail
}
func (ctxs *mergedContext) Done() <-chan struct{} {
return ctxs.doneChan
}
func (ctxs *mergedContext) Err() error {
return ctxs.err
}
func (ctxs *mergedContext) Value(key interface{}) interface{} {
for _, ctx := range ctxs.ctxs {
v := ctx.Value(key)
if v != nil {
return v
}
}
return nil
}
func (ctxs *mergedContext) watcher() {
if len(ctxs.ctxs) == 2 {
go func() {
select {
case <-ctxs.ctxs[0].Done():
ctxs.err = ctxs.ctxs[0].Err()
case <-ctxs.ctxs[1].Done():
ctxs.err = ctxs.ctxs[0].Err()
}
close(ctxs.doneChan)
}()
} else {
cases := []reflect.SelectCase{}
for _, ctx := range ctxs.ctxs {
cases = append(cases, reflect.SelectCase{
Dir: reflect.SelectRecv,
Chan: reflect.ValueOf(ctx.Done()),
})
}
go func() {
chosen, _, _ := reflect.Select(cases)
ctxs.err = ctxs.ctxs[chosen].Err()
close(ctxs.doneChan)
}()
}
}
func combineContext(ctxs ...context.Context) context.Context {
if len(ctxs) == 1 {
return ctxs[0]
}
ctx := &mergedContext{
ctxs: ctxs,
doneChan: make(chan struct{}),
err: nil,
}
ctx.watcher()
return ctx
}
================================================
FILE: path.go
================================================
package main
import "strings"
type Path []string
func splitIntoPathInner(p Path, path string, state int) Path {
s := 0
i := 0
c := 0
for c >= 0 {
if i < len(path) {
c = int(path[i])
} else {
c = -1
}
switch state {
case 0:
if c == '/' {
i++
} else {
state = 1
s = i
}
case 1:
if c == '/' || c < 0 {
p = append(p, path[s:i])
state = 0
} else {
i++
}
}
}
return p
}
func SplitIntoPathAsAbs(path string) Path {
if path == "" {
return Path{}
}
return splitIntoPathInner(Path{""}, path, 0)
}
func SplitIntoPath(path string) Path {
if path == "" {
return Path{}
}
return splitIntoPathInner(Path{}, path, 1)
}
func (p Path) Canonicalize() Path {
retval := make(Path, 0, len(p))
for _, c := range p {
switch c {
case ".":
continue
case "..":
if len(retval) > 0 && retval[len(retval)-1] != "" {
retval = retval[:len(retval)-1]
}
default:
retval = append(retval, c)
}
}
return retval
}
func (p Path) IsEmpty() bool {
return len(p) == 0
}
func (p Path) IsRoot() bool {
return len(p) == 1 && p[0] == ""
}
func (p Path) IsAbs() bool {
return len(p) > 0 && p[0] == ""
}
func (p Path) Join(another Path) Path {
if len(another) > 0 && another[0] == "" {
return append(p, another[1:]...)
} else {
return append(p, another...)
}
}
func (p Path) String() string {
return strings.Join(p, "/")
}
func (p Path) IsPrefixed(another Path) bool {
if len(p) < len(another) {
return false
}
for i, c := range another {
if p[i] != c {
return false
}
}
return true
}
func (p Path) Prefix() Path {
if len(p) == 0 {
return p
} else if len(p) == 1 {
if p[0] == "" {
return Path{""}
} else {
return Path{}
}
} else {
return p[:len(p)-1]
}
}
func (p Path) BasePart() Path {
if len(p) == 0 {
return p
} else if len(p) == 1 {
if p[0] == "" {
return Path{""}
} else {
return Path{}
}
} else {
return p[len(p)-1:]
}
}
func (p Path) Base() string {
if len(p) == 0 {
return ""
} else if len(p) == 1 {
if p[0] == "" {
return "/"
} else {
return ""
}
} else {
return p[len(p)-1]
}
}
func (p Path) Equal(p2 Path) bool {
if len(p) != len(p2) {
return false
}
for i := 0; i < len(p); i++ {
if p[i] != p2[i] {
return false
}
}
return true
}
================================================
FILE: path_test.go
================================================
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestSplitIntoPath(t *testing.T) {
assert.Equal(t, Path{}, SplitIntoPath(""))
assert.Equal(t, Path{"abc"}, SplitIntoPath("abc"))
assert.Equal(t, Path{"abc", "bcd"}, SplitIntoPath("abc/bcd"))
assert.Equal(t, Path{"abc", "bcd"}, SplitIntoPath("abc/bcd/"))
assert.Equal(t, Path{"abc", "bcd"}, SplitIntoPath("abc/bcd///"))
assert.Equal(t, Path{"abc", "bcd", "cde"}, SplitIntoPath("abc/bcd//cde"))
assert.Equal(t, Path{""}, SplitIntoPath("/"))
assert.Equal(t, Path{""}, SplitIntoPath("//"))
assert.Equal(t, Path{"", "abc", "bcd"}, SplitIntoPath("//abc//bcd"))
}
func TestSplitIntoPathAbsolute(t *testing.T) {
assert.Equal(t, Path{}, SplitIntoPathAsAbs(""))
assert.Equal(t, Path{"", "abc"}, SplitIntoPathAsAbs("abc"))
assert.Equal(t, Path{"", "abc", "bcd"}, SplitIntoPathAsAbs("abc/bcd"))
assert.Equal(t, Path{"", "abc", "bcd"}, SplitIntoPathAsAbs("abc/bcd/"))
assert.Equal(t, Path{"", "abc", "bcd"}, SplitIntoPathAsAbs("abc/bcd///"))
assert.Equal(t, Path{"", "abc", "bcd", "cde"}, SplitIntoPathAsAbs("abc/bcd//cde"))
assert.Equal(t, Path{""}, SplitIntoPathAsAbs("/"))
assert.Equal(t, Path{""}, SplitIntoPathAsAbs("//"))
assert.Equal(t, Path{"", "abc", "bcd"}, SplitIntoPathAsAbs("//abc//bcd"))
}
================================================
FILE: phantom_object_map.go
================================================
package main
import (
"sync"
"time"
)
type PhantomObjectInfo struct {
Key Path
LastModified time.Time
Size int64
Opaque interface{}
Mtx sync.Mutex
}
func (info *PhantomObjectInfo) GetOne() PhantomObjectInfo {
info.Mtx.Lock()
defer info.Mtx.Unlock()
return *info
}
func (info *PhantomObjectInfo) setKey(v Path) {
info.Mtx.Lock()
defer info.Mtx.Unlock()
info.Key = v
}
func (info *PhantomObjectInfo) SetLastModified(v time.Time) {
info.Mtx.Lock()
defer info.Mtx.Unlock()
info.LastModified = v
}
func (info *PhantomObjectInfo) SetSize(v int64) {
info.Mtx.Lock()
defer info.Mtx.Unlock()
info.Size = v
}
type phantomObjectInfoMap map[string]*PhantomObjectInfo
type PhantomObjectMap struct {
perPrefixObjects map[string]phantomObjectInfoMap
ptrToPOIMMapMap map[*PhantomObjectInfo]phantomObjectInfoMap
mtx sync.Mutex
}
func (pom *PhantomObjectMap) add(info *PhantomObjectInfo) bool {
prefix := info.Key.Prefix().String()
m := pom.perPrefixObjects[prefix]
if m == nil {
m = phantomObjectInfoMap{}
pom.perPrefixObjects[prefix] = m
}
prevInfo := m[info.Key.Base()]
m[info.Key.Base()] = info
pom.ptrToPOIMMapMap[info] = m
if prevInfo != nil {
delete(pom.ptrToPOIMMapMap, prevInfo)
}
return prevInfo == nil
}
func (pom *PhantomObjectMap) Add(info *PhantomObjectInfo) bool {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return pom.add(info)
}
func (pom *PhantomObjectMap) remove(key Path) *PhantomObjectInfo {
prefix := key.Prefix().String()
m := pom.perPrefixObjects[prefix]
if m == nil {
return nil
}
info := m[key.Base()]
if info == nil {
return nil
}
delete(m, key.Base())
if len(m) == 0 {
delete(pom.perPrefixObjects, prefix)
}
delete(pom.ptrToPOIMMapMap, info)
return info
}
func (pom *PhantomObjectMap) Remove(key Path) *PhantomObjectInfo {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return pom.remove(key)
}
func (pom *PhantomObjectMap) removeByInfoPtr(info *PhantomObjectInfo) bool {
m := pom.ptrToPOIMMapMap[info]
if m == nil {
return false
}
delete(m, info.Key.Base())
if len(m) == 0 {
delete(pom.perPrefixObjects, info.Key.Prefix().String())
}
delete(pom.ptrToPOIMMapMap, info)
return true
}
func (pom *PhantomObjectMap) RemoveByInfoPtr(info *PhantomObjectInfo) bool {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return pom.removeByInfoPtr(info)
}
func (pom *PhantomObjectMap) rename(old, new Path) bool {
info := pom.remove(old)
if info == nil {
return false
}
info.setKey(new)
pom.add(info)
return true
}
func (pom *PhantomObjectMap) Rename(old, new Path) bool {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return pom.rename(old, new)
}
func (pom *PhantomObjectMap) get(p Path) *PhantomObjectInfo {
m := pom.perPrefixObjects[p.Prefix().String()]
if m == nil {
return nil
}
return m[p.Base()]
}
func (pom *PhantomObjectMap) Get(p Path) *PhantomObjectInfo {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return pom.get(p)
}
func (pom *PhantomObjectMap) List(p Path) []*PhantomObjectInfo {
pom.mtx.Lock()
defer pom.mtx.Unlock()
m := pom.perPrefixObjects[p.String()]
retval := make([]*PhantomObjectInfo, 0, len(m))
for _, info := range m {
retval = append(retval, info)
}
return retval
}
func (pom *PhantomObjectMap) Size() int {
pom.mtx.Lock()
defer pom.mtx.Unlock()
return len(pom.ptrToPOIMMapMap)
}
func NewPhantomObjectMap() *PhantomObjectMap {
return &PhantomObjectMap{
perPrefixObjects: map[string]phantomObjectInfoMap{},
ptrToPOIMMapMap: map[*PhantomObjectInfo]phantomObjectInfoMap{},
}
}
================================================
FILE: phantom_object_map_test.go
================================================
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestPhantomObjectMapAdd(t *testing.T) {
pom := NewPhantomObjectMap()
assert.Equal(t, true, pom.Add(&PhantomObjectInfo{Key: Path{"", "a", "b"}}))
assert.Equal(t, 1, pom.Size())
assert.Equal(t, false, pom.Add(&PhantomObjectInfo{Key: Path{"", "a", "b"}}))
assert.Equal(t, 1, pom.Size())
assert.Equal(t, true, pom.Add(&PhantomObjectInfo{Key: Path{"", "a", "c"}}))
assert.Equal(t, 2, pom.Size())
assert.Equal(t, true, pom.Add(&PhantomObjectInfo{Key: Path{"", "a", "b", "c"}}))
assert.Equal(t, 3, pom.Size())
}
func TestPhantomObjectMapRemove(t *testing.T) {
pom := NewPhantomObjectMap()
o1 := &PhantomObjectInfo{Key: Path{"", "a", "b"}}
o2 := &PhantomObjectInfo{Key: Path{"", "a", "b"}}
o3 := &PhantomObjectInfo{Key: Path{"", "a", "c"}}
o4 := &PhantomObjectInfo{Key: Path{"", "a", "b", "c"}}
assert.Equal(t, true, pom.Add(o1))
assert.Equal(t, 1, pom.Size())
assert.Equal(t, false, pom.Add(o2))
assert.Equal(t, 1, pom.Size())
assert.Equal(t, true, pom.Add(o3))
assert.Equal(t, 2, pom.Size())
assert.Equal(t, true, pom.Add(o4))
assert.Equal(t, 3, pom.Size())
assert.Equal(t, o3, pom.Remove(Path{"", "a", "c"}))
assert.Nil(t, pom.Get(Path{"", "a", "c"}))
assert.Equal(t, 2, pom.Size())
assert.Nil(t, pom.Remove(Path{"", "a", "c"}))
assert.Equal(t, 2, pom.Size())
assert.Equal(t, o2, pom.Remove(Path{"", "a", "b"}))
assert.Equal(t, 1, pom.Size())
assert.Nil(t, pom.Get(Path{"", "a", "b"}))
}
================================================
FILE: s3-sftp-proxy.example.toml
================================================
host_key_file = "./host_key"
[buckets.test]
# endpoint = "http://endpoint"
# s3_force_path_style = false
# disable_ssl = false
bucket_url = "s3://BUCKET/PREFIX"
# bucket = BUCKET
# key_prefix = PREFIX
profile = "xxx"
region = "ap-northeast-1"
auth = "test"
# [buckets.test.credentials]
# aws_access_key_id = "aaa"
# aws_secret_access_key = "bbb"
[auth.test]
type = "inplace"
[auth.test.users.user01]
password = "test"
public_keys = """
...
"""
[auth.test.users.user02]
password = "test"
public_keys = """
...
"""
================================================
FILE: server.go
================================================
package main
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
type Server struct {
*ssh.ServerConfig
*S3Buckets
*PhantomObjectMap
ReaderLookbackBufferSize int
ReaderMinChunkSize int
ListerLookbackBufferSize int
Log interface {
DebugLogger
InfoLogger
ErrorLogger
}
Now func() time.Time
}
func asHandlers(handlers interface {
sftp.FileReader
sftp.FileWriter
sftp.FileCmder
sftp.FileLister
}) sftp.Handlers {
return sftp.Handlers{handlers, handlers, handlers, handlers}
}
func (s *Server) HandleChannel(ctx context.Context, bucket *S3Bucket, sshCh ssh.Channel, reqs <-chan *ssh.Request) {
defer s.Log.Debug("HandleChannel ended")
server := sftp.NewRequestServer(
sshCh,
asHandlers(
&S3BucketIO{
Ctx: ctx,
Bucket: bucket,
ReaderLookbackBufferSize: s.ReaderLookbackBufferSize,
ReaderMinChunkSize: s.ReaderMinChunkSize,
ListerLookbackBufferSize: s.ListerLookbackBufferSize,
Log: s.Log,
PhantomObjectMap: s.PhantomObjectMap,
Perms: bucket.Perms,
ServerSideEncryption: &bucket.ServerSideEncryption,
Now: s.Now,
},
),
)
innerCtx, cancel := context.WithCancel(ctx)
defer cancel()
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer s.Log.Debug("HandleChannel.discardRequest ended")
defer wg.Done()
defer cancel()
outer:
for {
select {
case <-innerCtx.Done():
break outer
case req := <-reqs:
if req == nil {
break outer
}
ok := false
if req.Type == "subsystem" && string(req.Payload[4:]) == "sftp" {
ok = true
}
req.Reply(ok, nil)
}
}
}()
wg.Add(1)
go func() {
defer s.Log.Debug("HandleChannel.serve ended")
defer wg.Done()
defer cancel()
go func() {
<-innerCtx.Done()
server.Close()
}()
if err := server.Serve(); err != io.EOF {
s.Log.Error(err.Error())
}
}()
wg.Wait()
}
func (s *Server) HandleClient(ctx context.Context, conn *net.TCPConn) error {
defer s.Log.Debug("HandleClient ended")
defer func() {
F(s.Log.Info, "connection from client %s closed", conn.RemoteAddr().String())
conn.Close()
}()
F(s.Log.Info, "connected from client %s", conn.RemoteAddr().String())
innerCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-innerCtx.Done()
conn.SetDeadline(time.Unix(1, 0))
}()
// Before use, a handshake must be performed on the incoming net.Conn.
sconn, chans, reqs, err := ssh.NewServerConn(conn, s.ServerConfig)
if err != nil {
return err
}
F(s.Log.Info, "user %s logged in", sconn.User())
bucket, ok := s.UserToBucketMap[sconn.User()]
if !ok {
return fmt.Errorf("unknown error: no bucket designated to user %s found", sconn.User())
}
wg := sync.WaitGroup{}
wg.Add(1)
go func(reqs <-chan *ssh.Request) {
defer wg.Done()
defer s.Log.Debug("HandleClient.requestHandler ended")
for _ = range reqs {
}
}(reqs)
wg.Add(1)
go func(chans <-chan ssh.NewChannel) {
defer wg.Done()
defer cancel()
defer s.Log.Debug("HandleClient.channelHandler ended")
for newSSHCh := range chans {
if newSSHCh.ChannelType() != "session" {
newSSHCh.Reject(ssh.UnknownChannelType, "unknown channel type")
F(s.Log.Info, "unknown channel type: %s", newSSHCh.ChannelType())
continue
}
F(s.Log.Info, "channel: %s", newSSHCh.ChannelType())
sshCh, reqs, err := newSSHCh.Accept()
if err != nil {
F(s.Log.Error, "could not accept channel: %s", err.Error())
break
}
wg.Add(1)
go func() {
defer wg.Done()
s.HandleChannel(innerCtx, bucket, sshCh, reqs)
}()
}
}(chans)
wg.Wait()
return nil
}
func (s *Server) RunListenerEventLoop(ctx context.Context, lsnr *net.TCPListener) error {
defer s.Log.Debug("RunListenerEventLoop ended")
wg := sync.WaitGroup{}
connChan := make(chan *net.TCPConn)
var err error
wg.Add(1)
go func() {
defer s.Log.Debug("RunListenerEventLoop.connHandler ended")
defer wg.Done()
defer close(connChan)
outer:
for {
var conn *net.TCPConn
conn, err = lsnr.AcceptTCP()
if err != nil {
return
}
select {
case <-ctx.Done():
conn.Close()
break outer
case connChan <- conn:
}
}
}()
outer:
for {
select {
case conn := <-connChan:
wg.Add(1)
go func() {
defer wg.Done()
err := s.HandleClient(ctx, conn)
if err != nil {
s.Log.Error(err.Error())
}
}()
case <-ctx.Done():
lsnr.SetDeadline(time.Unix(1, 0))
break outer
}
}
// drain
for _ = range connChan {
}
wg.Wait()
if IsTimeout(err) {
err = nil
}
return err
}
================================================
FILE: user.go
================================================
package main
import (
"fmt"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
"io/ioutil"
)
type User struct {
Name string
Password string
PublicKeys []ssh.PublicKey
}
type UserStore struct {
Name string
Users []*User
usersMap map[string]*User
}
type UserStores map[string]UserStore
func (us *UserStore) Add(u *User) {
us.Users = append(us.Users, u)
us.usersMap[u.Name] = u
}
func (us *UserStore) Lookup(name string) *User {
u, _ := us.usersMap[name]
return u
}
func parseAuthorizedKeys(pubKeys []ssh.PublicKey, pubKeyFileContent []byte) ([]ssh.PublicKey, error) {
for len(pubKeyFileContent) > 0 {
var pubKey ssh.PublicKey
var err error
pubKey, _, _, pubKeyFileContent, err = ssh.ParseAuthorizedKey(pubKeyFileContent)
if err != nil {
return pubKeys, err
}
pubKeys = append(pubKeys, pubKey)
}
return pubKeys, nil
}
func buildUsersFromAuthConfigInplace(users []*User, aCfg *AuthConfig) ([]*User, error) {
for name, params := range aCfg.Users {
var pubKeys []ssh.PublicKey
if params.PublicKeys != "" {
var err error
pubKeys, err = parseAuthorizedKeys(pubKeys, []byte(params.PublicKeys))
if err != nil {
return users, errors.Wrapf(err, `user "%s"`, name)
}
}
if params.PublicKeyFile != "" {
var err error
pubKeysFileContent, err := ioutil.ReadFile(params.PublicKeyFile)
if err != nil {
return users, errors.Wrapf(err, `user "%s"`, name)
}
pubKeys, err = parseAuthorizedKeys(pubKeys, pubKeysFileContent)
if err != nil {
return users, errors.Wrapf(err, `user "%s"`, name)
}
}
users = append(users, &User{
Name: name,
Password: params.Password,
PublicKeys: pubKeys,
})
}
return users, nil
}
func buildUsersFromAuthConfig(users []*User, aCfg *AuthConfig) ([]*User, error) {
switch aCfg.Type {
case "inplace":
return buildUsersFromAuthConfigInplace(users, aCfg)
default:
return users, fmt.Errorf("unknown auth config type: %s", aCfg.Type)
}
}
func NewUserStoresFromConfig(cfg *S3SFTPProxyConfig) (UserStores, error) {
uStores := UserStores{}
for name, aCfg := range cfg.AuthConfigs {
var err error
var users []*User
users, err = buildUsersFromAuthConfig(users, aCfg)
if err != nil {
return nil, err
}
usersMap := map[string]*User{}
for _, u := range users {
usersMap[u.Name] = u
}
uStores[name] = UserStore{Name: name, Users: users, usersMap: usersMap}
}
return uStores, nil
}
================================================
FILE: utils.go
================================================
package main
import "fmt"
type PrintlnLike func(...interface{})
func F(p PrintlnLike, f string, args ...interface{}) {
p(fmt.Sprintf(f, args...))
}
gitextract_lb8z7ih5/ ├── CONTRIBUTORS ├── LICENSE ├── README.md ├── bucket.go ├── bucketio.go ├── config.go ├── fakee_unix.go ├── fakee_windows.go ├── io.go ├── logging.go ├── main.go ├── merged_context.go ├── path.go ├── path_test.go ├── phantom_object_map.go ├── phantom_object_map_test.go ├── s3-sftp-proxy.example.toml ├── server.go ├── user.go └── utils.go
SYMBOL INDEX (138 symbols across 16 files)
FILE: bucket.go
type ServerSideEncryptionType (line 18) | type ServerSideEncryptionType
method UnmarshalText (line 33) | func (v *ServerSideEncryptionType) UnmarshalText(text []byte) error {
constant ServerSideEncryptionTypeNone (line 21) | ServerSideEncryptionTypeNone = iota
constant ServerSideEncryptionTypeAES256 (line 22) | ServerSideEncryptionTypeAES256
constant ServerSideEncryptionTypeKMS (line 23) | ServerSideEncryptionTypeKMS
type ServerSideEncryptionConfig (line 42) | type ServerSideEncryptionConfig struct
method CustomerAlgorithm (line 49) | func (cfg *ServerSideEncryptionConfig) CustomerAlgorithm() string {
type Perms (line 57) | type Perms struct
type S3Bucket (line 63) | type S3Bucket struct
method S3 (line 85) | func (s3b *S3Bucket) S3(sess *aws_session.Session) *s3.S3 {
type S3Buckets (line 75) | type S3Buckets struct
method Get (line 80) | func (s3bs *S3Buckets) Get(name string) *S3Bucket {
function buildS3Bucket (line 101) | func buildS3Bucket(uStores UserStores, name string, bCfg *S3BucketConfig...
function NewS3BucketFromConfig (line 182) | func NewS3BucketFromConfig(uStores UserStores, cfg *S3SFTPProxyConfig) (...
FILE: bucketio.go
type ReadDeadlineSettable (line 22) | type ReadDeadlineSettable interface
type WriteDeadlineSettable (line 26) | type WriteDeadlineSettable interface
function nilIfEmpty (line 34) | func nilIfEmpty(s string) *string {
type S3GetObjectOutputReader (line 42) | type S3GetObjectOutputReader struct
method Close (line 54) | func (oor *S3GetObjectOutputReader) Close() error {
method ReadAt (line 62) | func (oor *S3GetObjectOutputReader) ReadAt(buf []byte, off int64) (int...
type S3PutObjectWriter (line 155) | type S3PutObjectWriter struct
method Close (line 172) | func (oow *S3PutObjectWriter) Close() error {
method WriteAt (line 203) | func (oow *S3PutObjectWriter) WriteAt(buf []byte, off int64) (int, err...
type ObjectFileInfo (line 217) | type ObjectFileInfo struct
method Name (line 224) | func (ofi *ObjectFileInfo) Name() string {
method ModTime (line 228) | func (ofi *ObjectFileInfo) ModTime() time.Time {
method Size (line 232) | func (ofi *ObjectFileInfo) Size() int64 {
method Mode (line 236) | func (ofi *ObjectFileInfo) Mode() os.FileMode {
method IsDir (line 240) | func (ofi *ObjectFileInfo) IsDir() bool {
method Sys (line 244) | func (ofi *ObjectFileInfo) Sys() interface{} {
type S3ObjectLister (line 248) | type S3ObjectLister struct
method ListAt (line 302) | func (sol *S3ObjectLister) ListAt(result []os.FileInfo, o int64) (int,...
function aclToMode (line 262) | func aclToMode(owner *aws_s3.Owner, grants []*aws_s3.Grant) os.FileMode {
type S3ObjectStat (line 429) | type S3ObjectStat struct
method ListAt (line 439) | func (sos *S3ObjectStat) ListAt(result []os.FileInfo, o int64) (int, e...
type S3BucketIO (line 532) | type S3BucketIO struct
method Fileread (line 560) | func (s3io *S3BucketIO) Fileread(req *sftp.Request) (io.ReaderAt, erro...
method Filewrite (line 602) | func (s3io *S3BucketIO) Filewrite(req *sftp.Request) (io.WriterAt, err...
method Filecmd (line 638) | func (s3io *S3BucketIO) Filecmd(req *sftp.Request) error {
method Filelist (line 717) | func (s3io *S3BucketIO) Filelist(req *sftp.Request) (sftp.ListerAt, er...
function buildKey (line 548) | func buildKey(s3b *S3Bucket, path string) Path {
function buildPath (line 552) | func buildPath(s3b *S3Bucket, key string) (string, bool) {
FILE: config.go
type URL (line 18) | type URL struct
method UnmarshalText (line 22) | func (u *URL) UnmarshalText(text []byte) (err error) {
type AWSCredentialsConfig (line 27) | type AWSCredentialsConfig struct
type S3BucketConfig (line 32) | type S3BucketConfig struct
type AuthUser (line 53) | type AuthUser struct
type AuthConfig (line 59) | type AuthConfig struct
type S3SFTPProxyConfig (line 65) | type S3SFTPProxyConfig struct
function validateAndFixupBucketConfig (line 76) | func validateAndFixupBucketConfig(bCfg *S3BucketConfig) error {
function validateAndFixupAuthConfigInplace (line 117) | func validateAndFixupAuthConfigInplace(aCfg *AuthConfig) error {
function validateAndFixupAuthConfig (line 128) | func validateAndFixupAuthConfig(aCfg *AuthConfig) error {
function ReadConfig (line 137) | func ReadConfig(tomlStr string) (*S3SFTPProxyConfig, error) {
function ReadConfigFromFile (line 199) | func ReadConfigFromFile(tomlFile string) (*S3SFTPProxyConfig, error) {
FILE: fakee_unix.go
function BuildFakeFileInfoSys (line 9) | func BuildFakeFileInfoSys() interface{} {
FILE: fakee_windows.go
function BuildFakeFileInfoSys (line 7) | func BuildFakeFileInfoSys() interface{} {
FILE: io.go
type BytesWriter (line 9) | type BytesWriter struct
method Close (line 33) | func (bw *BytesWriter) Close() error {
method grow (line 38) | func (bw *BytesWriter) grow(newCap int) {
method Truncate (line 57) | func (bw *BytesWriter) Truncate(n int64) error {
method Seek (line 69) | func (bw *BytesWriter) Seek(offset int64, whence int) (int64, error) {
method Write (line 91) | func (bw *BytesWriter) Write(p []byte) (n int, err error) {
method WriteAt (line 97) | func (bw *BytesWriter) WriteAt(p []byte, offset int64) (n int, err err...
method Size (line 111) | func (bw *BytesWriter) Size() int64 {
method Bytes (line 115) | func (bw *BytesWriter) Bytes() []byte {
function NewBytesWriter (line 14) | func NewBytesWriter() *BytesWriter {
function castInt64ToInt (line 20) | func castInt64ToInt(n int64) (int, error) {
function IsEOF (line 119) | func IsEOF(e error) bool {
function IsTimeout (line 123) | func IsTimeout(e error) bool {
FILE: logging.go
type DebugLogger (line 3) | type DebugLogger interface
type InfoLogger (line 7) | type InfoLogger interface
type ErrorLogger (line 11) | type ErrorLogger interface
FILE: main.go
function init (line 25) | func init() {
function buildSSHServerConfig (line 31) | func buildSSHServerConfig(buckets *S3Buckets, cfg *S3SFTPProxyConfig) (*...
function bail (line 105) | func bail(msg string, status ...interface{}) {
function main (line 118) | func main() {
FILE: merged_context.go
type mergedContext (line 9) | type mergedContext struct
method Deadline (line 15) | func (ctxs *mergedContext) Deadline() (time.Time, bool) {
method Done (line 29) | func (ctxs *mergedContext) Done() <-chan struct{} {
method Err (line 33) | func (ctxs *mergedContext) Err() error {
method Value (line 37) | func (ctxs *mergedContext) Value(key interface{}) interface{} {
method watcher (line 47) | func (ctxs *mergedContext) watcher() {
function combineContext (line 74) | func combineContext(ctxs ...context.Context) context.Context {
FILE: path.go
type Path (line 5) | type Path
method Canonicalize (line 51) | func (p Path) Canonicalize() Path {
method IsEmpty (line 68) | func (p Path) IsEmpty() bool {
method IsRoot (line 72) | func (p Path) IsRoot() bool {
method IsAbs (line 76) | func (p Path) IsAbs() bool {
method Join (line 80) | func (p Path) Join(another Path) Path {
method String (line 88) | func (p Path) String() string {
method IsPrefixed (line 92) | func (p Path) IsPrefixed(another Path) bool {
method Prefix (line 104) | func (p Path) Prefix() Path {
method BasePart (line 118) | func (p Path) BasePart() Path {
method Base (line 132) | func (p Path) Base() string {
method Equal (line 146) | func (p Path) Equal(p2 Path) bool {
function splitIntoPathInner (line 7) | func splitIntoPathInner(p Path, path string, state int) Path {
function SplitIntoPathAsAbs (line 37) | func SplitIntoPathAsAbs(path string) Path {
function SplitIntoPath (line 44) | func SplitIntoPath(path string) Path {
FILE: path_test.go
function TestSplitIntoPath (line 8) | func TestSplitIntoPath(t *testing.T) {
function TestSplitIntoPathAbsolute (line 20) | func TestSplitIntoPathAbsolute(t *testing.T) {
FILE: phantom_object_map.go
type PhantomObjectInfo (line 8) | type PhantomObjectInfo struct
method GetOne (line 16) | func (info *PhantomObjectInfo) GetOne() PhantomObjectInfo {
method setKey (line 22) | func (info *PhantomObjectInfo) setKey(v Path) {
method SetLastModified (line 28) | func (info *PhantomObjectInfo) SetLastModified(v time.Time) {
method SetSize (line 34) | func (info *PhantomObjectInfo) SetSize(v int64) {
type phantomObjectInfoMap (line 40) | type phantomObjectInfoMap
type PhantomObjectMap (line 42) | type PhantomObjectMap struct
method add (line 48) | func (pom *PhantomObjectMap) add(info *PhantomObjectInfo) bool {
method Add (line 64) | func (pom *PhantomObjectMap) Add(info *PhantomObjectInfo) bool {
method remove (line 70) | func (pom *PhantomObjectMap) remove(key Path) *PhantomObjectInfo {
method Remove (line 88) | func (pom *PhantomObjectMap) Remove(key Path) *PhantomObjectInfo {
method removeByInfoPtr (line 94) | func (pom *PhantomObjectMap) removeByInfoPtr(info *PhantomObjectInfo) ...
method RemoveByInfoPtr (line 107) | func (pom *PhantomObjectMap) RemoveByInfoPtr(info *PhantomObjectInfo) ...
method rename (line 113) | func (pom *PhantomObjectMap) rename(old, new Path) bool {
method Rename (line 123) | func (pom *PhantomObjectMap) Rename(old, new Path) bool {
method get (line 129) | func (pom *PhantomObjectMap) get(p Path) *PhantomObjectInfo {
method Get (line 137) | func (pom *PhantomObjectMap) Get(p Path) *PhantomObjectInfo {
method List (line 143) | func (pom *PhantomObjectMap) List(p Path) []*PhantomObjectInfo {
method Size (line 155) | func (pom *PhantomObjectMap) Size() int {
function NewPhantomObjectMap (line 162) | func NewPhantomObjectMap() *PhantomObjectMap {
FILE: phantom_object_map_test.go
function TestPhantomObjectMapAdd (line 8) | func TestPhantomObjectMapAdd(t *testing.T) {
function TestPhantomObjectMapRemove (line 20) | func TestPhantomObjectMapRemove(t *testing.T) {
FILE: server.go
type Server (line 15) | type Server struct
method HandleChannel (line 39) | func (s *Server) HandleChannel(ctx context.Context, bucket *S3Bucket, ...
method HandleClient (line 103) | func (s *Server) HandleClient(ctx context.Context, conn *net.TCPConn) ...
method RunListenerEventLoop (line 172) | func (s *Server) RunListenerEventLoop(ctx context.Context, lsnr *net.T...
function asHandlers (line 30) | func asHandlers(handlers interface {
FILE: user.go
type User (line 10) | type User struct
type UserStore (line 16) | type UserStore struct
method Add (line 24) | func (us *UserStore) Add(u *User) {
method Lookup (line 29) | func (us *UserStore) Lookup(name string) *User {
type UserStores (line 22) | type UserStores
function parseAuthorizedKeys (line 34) | func parseAuthorizedKeys(pubKeys []ssh.PublicKey, pubKeyFileContent []by...
function buildUsersFromAuthConfigInplace (line 47) | func buildUsersFromAuthConfigInplace(users []*User, aCfg *AuthConfig) ([...
function buildUsersFromAuthConfig (line 77) | func buildUsersFromAuthConfig(users []*User, aCfg *AuthConfig) ([]*User,...
function NewUserStoresFromConfig (line 86) | func NewUserStoresFromConfig(cfg *S3SFTPProxyConfig) (UserStores, error) {
FILE: utils.go
type PrintlnLike (line 5) | type PrintlnLike
function F (line 7) | func F(p PrintlnLike, f string, args ...interface{}) {
Condensed preview — 20 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (73K chars).
[
{
"path": "CONTRIBUTORS",
"chars": 38,
"preview": "Moriyoshi Koizumi\nDmitry Chepurovskiy\n"
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "Copyright (c) 2018 Moriyoshi Koizumi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nth"
},
{
"path": "README.md",
"chars": 7652,
"preview": "# s3-sftp-proxy\n\n`s3-sftp-proxy` is a tiny program that exposes the resources on your AWS S3 buckets through SFTP protoc"
},
{
"path": "bucket.go",
"chars": 5435,
"preview": "package main\n\nimport (\n\t\"crypto\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"strings\"\n\n\taws \"github.com/aws/aws-sdk-go/aws\"\n\taws_creds \""
},
{
"path": "bucketio.go",
"chars": 19104,
"preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path\"\n\t\"sync\"\n\t\"time\"\n\n\taws \"github.com/aws/aws-sdk-go/a"
},
{
"path": "config.go",
"chars": 6735,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"github.com/BurntSushi/toml\"\n\t\"github.com/pkg/errors\"\n\t\"io/ioutil\"\n\t\"net/url\"\n)\n\nvar (\n\tm"
},
{
"path": "fakee_unix.go",
"chars": 149,
"preview": "// +build !windows\n\npackage main\n\nimport (\n\t\"syscall\"\n)\n\nfunc BuildFakeFileInfoSys() interface{} {\n\treturn &syscall.Stat"
},
{
"path": "fakee_windows.go",
"chars": 136,
"preview": "// +build windows\n\npackage main\n\nimport \"syscall\"\n\nfunc BuildFakeFileInfoSys() interface{} {\n\treturn syscall.Win32FileAt"
},
{
"path": "io.go",
"chars": 2219,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"unsafe\"\n)\n\ntype BytesWriter struct {\n\tbuf []byte\n\tpos int\n}\n\nfunc NewBytesWriter()"
},
{
"path": "logging.go",
"chars": 191,
"preview": "package main\n\ntype DebugLogger interface {\n\tDebug(args ...interface{})\n}\n\ntype InfoLogger interface {\n\tInfo(args ...inte"
},
{
"path": "main.go",
"chars": 4729,
"preview": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net\"\n\t\"os\"\n\t\"os/signal\"\n\t\"time\"\n\n\t\"github.com/p"
},
{
"path": "merged_context.go",
"chars": 1576,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"time\"\n)\n\ntype mergedContext struct {\n\tctxs []context.Context\n\tdoneCha"
},
{
"path": "path.go",
"chars": 2317,
"preview": "package main\n\nimport \"strings\"\n\ntype Path []string\n\nfunc splitIntoPathInner(p Path, path string, state int) Path {\n\ts :="
},
{
"path": "path_test.go",
"chars": 1289,
"preview": "package main\n\nimport (\n\t\"github.com/stretchr/testify/assert\"\n\t\"testing\"\n)\n\nfunc TestSplitIntoPath(t *testing.T) {\n\tasser"
},
{
"path": "phantom_object_map.go",
"chars": 3546,
"preview": "package main\n\nimport (\n\t\"sync\"\n\t\"time\"\n)\n\ntype PhantomObjectInfo struct {\n\tKey Path\n\tLastModified time.Time\n\tSi"
},
{
"path": "phantom_object_map_test.go",
"chars": 1495,
"preview": "package main\n\nimport (\n\t\"github.com/stretchr/testify/assert\"\n\t\"testing\"\n)\n\nfunc TestPhantomObjectMapAdd(t *testing.T) {\n"
},
{
"path": "s3-sftp-proxy.example.toml",
"chars": 518,
"preview": "host_key_file = \"./host_key\"\n\n[buckets.test]\n# endpoint = \"http://endpoint\"\n# s3_force_path_style = false\n# disable_ssl "
},
{
"path": "server.go",
"chars": 4712,
"preview": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/pkg/sftp\"\n\t\"golang.org/x/crypto/ssh\""
},
{
"path": "user.go",
"chars": 2440,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"github.com/pkg/errors\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"io/ioutil\"\n)\n\ntype User struct {\n\tNa"
},
{
"path": "utils.go",
"chars": 152,
"preview": "package main\n\nimport \"fmt\"\n\ntype PrintlnLike func(...interface{})\n\nfunc F(p PrintlnLike, f string, args ...interface{}) "
}
]
About this extraction
This page contains the full source code of the moriyoshi/s3-sftp-proxy GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 20 files (64.0 KB), approximately 19.5k tokens, and a symbol index with 138 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.