Add SFTP Server Support (#6753)
* Add SFTP Server Support Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> * fix s3 tests and helm lint Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> * increase helm chart version * adjust version --------- Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> Co-authored-by: chrislu <chris.lu@gmail.com>
This commit is contained in:
37
docker/compose/userstore.json
Normal file
37
docker/compose/userstore.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"Username": "admin",
|
||||||
|
"Password": "myadminpassword",
|
||||||
|
"PublicKeys": [
|
||||||
|
],
|
||||||
|
"HomeDir": "/",
|
||||||
|
"Permissions": {
|
||||||
|
"/": ["*"]
|
||||||
|
},
|
||||||
|
"Uid": 0,
|
||||||
|
"Gid": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Username": "user1",
|
||||||
|
"Password": "myuser1password",
|
||||||
|
"PublicKeys": [""],
|
||||||
|
"HomeDir": "/user1",
|
||||||
|
"Permissions": {
|
||||||
|
"/user1": ["*"],
|
||||||
|
"/public": ["read", "list","write"]
|
||||||
|
},
|
||||||
|
"Uid": 1111,
|
||||||
|
"Gid": 1111
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Username": "readonly",
|
||||||
|
"Password": "myreadonlypassword",
|
||||||
|
"PublicKeys": [],
|
||||||
|
"HomeDir": "/public",
|
||||||
|
"Permissions": {
|
||||||
|
"/public": ["read", "list"]
|
||||||
|
},
|
||||||
|
"Uid": 1112,
|
||||||
|
"Gid": 1112
|
||||||
|
}
|
||||||
|
]
|
||||||
6
go.mod
6
go.mod
@@ -29,7 +29,6 @@ require (
|
|||||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||||
github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4
|
github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4
|
||||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||||
github.com/fclairamb/ftpserverlib v0.25.0
|
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
github.com/go-redsync/redsync/v4 v4.13.0
|
github.com/go-redsync/redsync/v4 v4.13.0
|
||||||
github.com/go-sql-driver/mysql v1.9.1
|
github.com/go-sql-driver/mysql v1.9.1
|
||||||
@@ -101,7 +100,7 @@ require (
|
|||||||
gocloud.dev v0.41.0
|
gocloud.dev v0.41.0
|
||||||
gocloud.dev/pubsub/natspubsub v0.41.0
|
gocloud.dev/pubsub/natspubsub v0.41.0
|
||||||
gocloud.dev/pubsub/rabbitpubsub v0.41.0
|
gocloud.dev/pubsub/rabbitpubsub v0.41.0
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
golang.org/x/crypto v0.37.0
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||||
golang.org/x/image v0.24.0
|
golang.org/x/image v0.24.0
|
||||||
golang.org/x/net v0.39.0
|
golang.org/x/net v0.39.0
|
||||||
@@ -141,6 +140,7 @@ require (
|
|||||||
github.com/minio/crc64nvme v1.0.1
|
github.com/minio/crc64nvme v1.0.1
|
||||||
github.com/orcaman/concurrent-map/v2 v2.0.1
|
github.com/orcaman/concurrent-map/v2 v2.0.1
|
||||||
github.com/parquet-go/parquet-go v0.24.0
|
github.com/parquet-go/parquet-go v0.24.0
|
||||||
|
github.com/pkg/sftp v1.13.7
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
github.com/rclone/rclone v1.69.1
|
github.com/rclone/rclone v1.69.1
|
||||||
github.com/rdleal/intervalst v1.4.1
|
github.com/rdleal/intervalst v1.4.1
|
||||||
@@ -232,7 +232,6 @@ require (
|
|||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/fclairamb/go-log v0.5.0 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/flynn/noise v1.0.1 // indirect
|
github.com/flynn/noise v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||||
@@ -305,7 +304,6 @@ require (
|
|||||||
github.com/pingcap/kvproto v0.0.0-20230403051650-e166ae588106 // indirect
|
github.com/pingcap/kvproto v0.0.0-20230403051650-e166ae588106 // indirect
|
||||||
github.com/pingcap/log v1.1.1-0.20221110025148-ca232912c9f3 // indirect
|
github.com/pingcap/log v1.1.1-0.20221110025148-ca232912c9f3 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/sftp v1.13.7 // indirect
|
|
||||||
github.com/pkg/xattr v0.4.10 // indirect
|
github.com/pkg/xattr v0.4.10 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
|
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
|
||||||
|
|||||||
10
go.sum
10
go.sum
@@ -865,10 +865,6 @@ github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+ne
|
|||||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/fclairamb/ftpserverlib v0.25.0 h1:swV2CK+WiN9KEkqkwNgGbSIfRoYDWNno41hoVtYwgfA=
|
|
||||||
github.com/fclairamb/ftpserverlib v0.25.0/go.mod h1:LIDqyiFPhjE9IuzTkntST8Sn8TaU6NRgzSvbMpdfRC4=
|
|
||||||
github.com/fclairamb/go-log v0.5.0 h1:Gz9wSamEaA6lta4IU2cjJc2xSq5sV5VYSB5w/SUHhVc=
|
|
||||||
github.com/fclairamb/go-log v0.5.0/go.mod h1:XoRO1dYezpsGmLLkZE9I+sHqpqY65p8JA+Vqblb7k40=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fluent/fluent-logger-golang v1.9.0 h1:zUdY44CHX2oIUc7VTNZc+4m+ORuO/mldQDA7czhWXEg=
|
github.com/fluent/fluent-logger-golang v1.9.0 h1:zUdY44CHX2oIUc7VTNZc+4m+ORuO/mldQDA7czhWXEg=
|
||||||
@@ -917,15 +913,11 @@ github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JS
|
|||||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
|
|
||||||
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
|
||||||
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
|
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
|
||||||
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
|
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
|
||||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
|
|
||||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -1500,8 +1492,6 @@ github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz3
|
|||||||
github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
|
github.com/seaweedfs/goexif v1.0.3/go.mod h1:Oni780Z236sXpIQzk1XoJlTwqrJ02smEin9zQeff7Fk=
|
||||||
github.com/seaweedfs/raft v1.1.3 h1:5B6hgneQ7IuU4Ceom/f6QUt8pEeqjcsRo+IxlyPZCws=
|
github.com/seaweedfs/raft v1.1.3 h1:5B6hgneQ7IuU4Ceom/f6QUt8pEeqjcsRo+IxlyPZCws=
|
||||||
github.com/seaweedfs/raft v1.1.3/go.mod h1:9cYlEBA+djJbnf/5tWsCybtbL7ICYpi+Uxcg3MxjuNs=
|
github.com/seaweedfs/raft v1.1.3/go.mod h1:9cYlEBA+djJbnf/5tWsCybtbL7ICYpi+Uxcg3MxjuNs=
|
||||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
|
||||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ description: SeaweedFS
|
|||||||
name: seaweedfs
|
name: seaweedfs
|
||||||
appVersion: "3.87"
|
appVersion: "3.87"
|
||||||
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
|
||||||
version: 4.0.387
|
version: 4.0.388
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ Inject extra environment vars in the format key:value, if populated
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* Return the proper sftp image */}}
|
||||||
|
{{- define "sftp.image" -}}
|
||||||
|
{{- if .Values.sftp.imageOverride -}}
|
||||||
|
{{- $imageOverride := .Values.sftp.imageOverride -}}
|
||||||
|
{{- printf "%s" $imageOverride -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- include "common.image" . }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
{{/* Return the proper volume image */}}
|
{{/* Return the proper volume image */}}
|
||||||
{{- define "volume.image" -}}
|
{{- define "volume.image" -}}
|
||||||
{{- if .Values.volume.imageOverride -}}
|
{{- if .Values.volume.imageOverride -}}
|
||||||
@@ -88,7 +98,7 @@ Inject extra environment vars in the format key:value, if populated
|
|||||||
{{- $registryName := default .Values.image.registry .Values.global.registry | toString -}}
|
{{- $registryName := default .Values.image.registry .Values.global.registry | toString -}}
|
||||||
{{- $repositoryName := .Values.image.repository | toString -}}
|
{{- $repositoryName := .Values.image.repository | toString -}}
|
||||||
{{- $name := .Values.global.imageName | toString -}}
|
{{- $name := .Values.global.imageName | toString -}}
|
||||||
{{- $tag := .Chart.AppVersion | toString -}}
|
{{- $tag := default .Chart.AppVersion .Values.image.tag | toString -}}
|
||||||
{{- if $registryName -}}
|
{{- if $registryName -}}
|
||||||
{{- printf "%s/%s%s:%s" $registryName $repositoryName $name $tag -}}
|
{{- printf "%s/%s%s:%s" $registryName $repositoryName $name $tag -}}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
@@ -168,3 +178,23 @@ Usage:
|
|||||||
{{- $value }}
|
{{- $value }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
getOrGeneratePassword will check if a password exists in a secret and return it,
|
||||||
|
or generate a new random password if it doesn't exist.
|
||||||
|
*/}}
|
||||||
|
{{- define "getOrGeneratePassword" -}}
|
||||||
|
{{- $params := . -}}
|
||||||
|
{{- $namespace := $params.namespace -}}
|
||||||
|
{{- $secretName := $params.secretName -}}
|
||||||
|
{{- $key := $params.key -}}
|
||||||
|
{{- $length := default 16 $params.length -}}
|
||||||
|
|
||||||
|
{{- $existingSecret := lookup "v1" "Secret" $namespace $secretName -}}
|
||||||
|
{{- if and $existingSecret (index $existingSecret.data $key) -}}
|
||||||
|
{{- index $existingSecret.data $key | b64dec -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- randAlphaNum $length -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{{- if or (and .Values.filer.s3.enabled .Values.filer.s3.enableAuth (not .Values.filer.s3.existingConfigSecret)) (and .Values.s3.enabled .Values.s3.enableAuth (not .Values.s3.existingConfigSecret)) }}
|
{{- if or (and .Values.filer.s3.enabled .Values.filer.s3.enableAuth (not .Values.filer.s3.existingConfigSecret)) (and .Values.s3.enabled .Values.s3.enableAuth (not .Values.s3.existingConfigSecret)) }}
|
||||||
{{- $access_key_admin := randAlphaNum 16 -}}
|
{{- $access_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "admin_access_key_id" "length" 20) -}}
|
||||||
{{- $secret_key_admin := randAlphaNum 32 -}}
|
{{- $secret_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "admin_secret_access_key" "length" 40) -}}
|
||||||
{{- $access_key_read := randAlphaNum 16 -}}
|
{{- $access_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "read_access_key_id" "length" 20) -}}
|
||||||
{{- $secret_key_read := randAlphaNum 32 -}}
|
{{- $secret_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "read_secret_access_key" "length" 40) -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
type: Opaque
|
type: Opaque
|
||||||
@@ -11,7 +11,7 @@ metadata:
|
|||||||
namespace: {{ .Release.Namespace }}
|
namespace: {{ .Release.Namespace }}
|
||||||
annotations:
|
annotations:
|
||||||
"helm.sh/resource-policy": keep
|
"helm.sh/resource-policy": keep
|
||||||
"helm.sh/hook": "pre-install"
|
"helm.sh/hook": "pre-install,pre-upgrade"
|
||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
@@ -32,4 +32,4 @@ stringData:
|
|||||||
s3_auditLogConfig.json: |
|
s3_auditLogConfig.json: |
|
||||||
{{ toJson .Values.s3.auditLogConfig | nindent 4 }}
|
{{ toJson .Values.s3.auditLogConfig | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
292
k8s/charts/seaweedfs/templates/sftp-deployment.yaml
Normal file
292
k8s/charts/seaweedfs/templates/sftp-deployment.yaml
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
{{- if .Values.sftp.enabled }}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ template "seaweedfs.name" . }}-sftp
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- if .Values.sftp.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml .Values.sftp.annotations | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.sftp.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
{{ with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.sftp.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
annotations:
|
||||||
|
{{ with .Values.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.sftp.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
restartPolicy: {{ default .Values.global.restartPolicy .Values.sftp.restartPolicy }}
|
||||||
|
{{- if .Values.sftp.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{ tpl .Values.sftp.tolerations . | nindent 8 | trim }}
|
||||||
|
{{- end }}
|
||||||
|
{{- include "seaweedfs.imagePullSecrets" . | nindent 6 }}
|
||||||
|
terminationGracePeriodSeconds: 10
|
||||||
|
{{- if .Values.sftp.priorityClassName }}
|
||||||
|
priorityClassName: {{ .Values.sftp.priorityClassName | quote }}
|
||||||
|
{{- end }}
|
||||||
|
enableServiceLinks: false
|
||||||
|
{{- if .Values.sftp.serviceAccountName }}
|
||||||
|
serviceAccountName: {{ .Values.sftp.serviceAccountName | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.initContainers }}
|
||||||
|
initContainers:
|
||||||
|
{{ tpl .Values.sftp.initContainers . | nindent 8 | trim }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.podSecurityContext.enabled }}
|
||||||
|
securityContext: {{- omit .Values.sftp.podSecurityContext "enabled" | toYaml | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: seaweedfs
|
||||||
|
image: {{ template "sftp.image" . }}
|
||||||
|
imagePullPolicy: {{ default "IfNotPresent" .Values.global.imagePullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: SEAWEEDFS_FULLNAME
|
||||||
|
value: "{{ template "seaweedfs.name" . }}"
|
||||||
|
{{- if .Values.sftp.extraEnvironmentVars }}
|
||||||
|
{{- range $key, $value := .Values.sftp.extraEnvironmentVars }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
{{- if kindIs "string" $value }}
|
||||||
|
value: {{ $value | quote }}
|
||||||
|
{{- else }}
|
||||||
|
valueFrom:
|
||||||
|
{{ toYaml $value | nindent 16 | trim }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.global.extraEnvironmentVars }}
|
||||||
|
{{- range $key, $value := .Values.global.extraEnvironmentVars }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
{{- if kindIs "string" $value }}
|
||||||
|
value: {{ $value | quote }}
|
||||||
|
{{- else }}
|
||||||
|
valueFrom:
|
||||||
|
{{ toYaml $value | nindent 16 | trim }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
command:
|
||||||
|
- "/bin/sh"
|
||||||
|
- "-ec"
|
||||||
|
- |
|
||||||
|
exec /usr/bin/weed \
|
||||||
|
{{- if or (eq .Values.sftp.logs.type "hostPath") (eq .Values.sftp.logs.type "emptyDir") }}
|
||||||
|
-logdir=/logs \
|
||||||
|
{{- else }}
|
||||||
|
-logtostderr=true \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.loggingOverrideLevel }}
|
||||||
|
-v={{ .Values.sftp.loggingOverrideLevel }} \
|
||||||
|
{{- else }}
|
||||||
|
-v={{ .Values.global.loggingLevel }} \
|
||||||
|
{{- end }}
|
||||||
|
sftp \
|
||||||
|
-ip.bind={{ .Values.sftp.bindAddress }} \
|
||||||
|
-port={{ .Values.sftp.port }} \
|
||||||
|
{{- if .Values.sftp.metricsPort }}
|
||||||
|
-metricsPort={{ .Values.sftp.metricsPort }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.metricsIp }}
|
||||||
|
-metricsIp={{ .Values.sftp.metricsIp }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.sshPrivateKey }}
|
||||||
|
-sshPrivateKey={{ .Values.sftp.sshPrivateKey }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.hostKeysFolder }}
|
||||||
|
-hostKeysFolder={{ .Values.sftp.hostKeysFolder }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.authMethods }}
|
||||||
|
-authMethods={{ .Values.sftp.authMethods }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.maxAuthTries }}
|
||||||
|
-maxAuthTries={{ .Values.sftp.maxAuthTries }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.bannerMessage }}
|
||||||
|
-bannerMessage="{{ .Values.sftp.bannerMessage }}" \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.loginGraceTime }}
|
||||||
|
-loginGraceTime={{ .Values.sftp.loginGraceTime }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.clientAliveInterval }}
|
||||||
|
-clientAliveInterval={{ .Values.sftp.clientAliveInterval }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.clientAliveCountMax }}
|
||||||
|
-clientAliveCountMax={{ .Values.sftp.clientAliveCountMax }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.dataCenter }}
|
||||||
|
-dataCenter={{ .Values.sftp.dataCenter }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.localSocket }}
|
||||||
|
-localSocket={{ .Values.sftp.localSocket }} \
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.global.enableSecurity }}
|
||||||
|
-cert.file=/usr/local/share/ca-certificates/client/tls.crt \
|
||||||
|
-key.file=/usr/local/share/ca-certificates/client/tls.key \
|
||||||
|
{{- end }}
|
||||||
|
-userStoreFile=/etc/sw/seaweedfs_sftp_config \
|
||||||
|
-filer={{ template "seaweedfs.name" . }}-filer-client.{{ .Release.Namespace }}:{{ .Values.filer.port }}
|
||||||
|
volumeMounts:
|
||||||
|
{{- if or (eq .Values.sftp.logs.type "hostPath") (eq .Values.sftp.logs.type "emptyDir") }}
|
||||||
|
- name: logs
|
||||||
|
mountPath: "/logs/"
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.enableAuth }}
|
||||||
|
- mountPath: /etc/sw
|
||||||
|
name: config-users
|
||||||
|
readOnly: true
|
||||||
|
{{- end }}
|
||||||
|
- mountPath: /etc/sw/ssh
|
||||||
|
name: config-ssh
|
||||||
|
readOnly: true
|
||||||
|
{{- if .Values.global.enableSecurity }}
|
||||||
|
- name: security-config
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /etc/seaweedfs/security.toml
|
||||||
|
subPath: security.toml
|
||||||
|
- name: ca-cert
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /usr/local/share/ca-certificates/ca/
|
||||||
|
- name: master-cert
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /usr/local/share/ca-certificates/master/
|
||||||
|
- name: volume-cert
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /usr/local/share/ca-certificates/volume/
|
||||||
|
- name: filer-cert
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /usr/local/share/ca-certificates/filer/
|
||||||
|
- name: client-cert
|
||||||
|
readOnly: true
|
||||||
|
mountPath: /usr/local/share/ca-certificates/client/
|
||||||
|
{{- end }}
|
||||||
|
{{ tpl .Values.sftp.extraVolumeMounts . | nindent 12 | trim }}
|
||||||
|
ports:
|
||||||
|
- containerPort: {{ .Values.sftp.port }}
|
||||||
|
name: swfs-sftp
|
||||||
|
{{- if .Values.sftp.metricsPort }}
|
||||||
|
- containerPort: {{ .Values.sftp.metricsPort }}
|
||||||
|
name: metrics
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.readinessProbe.enabled }}
|
||||||
|
readinessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: {{ .Values.sftp.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.sftp.readinessProbe.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.sftp.readinessProbe.periodSeconds }}
|
||||||
|
successThreshold: {{ .Values.sftp.readinessProbe.successThreshold }}
|
||||||
|
failureThreshold: {{ .Values.sftp.readinessProbe.failureThreshold }}
|
||||||
|
timeoutSeconds: {{ .Values.sftp.readinessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.livenessProbe.enabled }}
|
||||||
|
livenessProbe:
|
||||||
|
tcpSocket:
|
||||||
|
port: {{ .Values.sftp.port }}
|
||||||
|
initialDelaySeconds: {{ .Values.sftp.livenessProbe.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.sftp.livenessProbe.periodSeconds }}
|
||||||
|
successThreshold: {{ .Values.sftp.livenessProbe.successThreshold }}
|
||||||
|
failureThreshold: {{ .Values.sftp.livenessProbe.failureThreshold }}
|
||||||
|
timeoutSeconds: {{ .Values.sftp.livenessProbe.timeoutSeconds }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.sftp.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.containerSecurityContext.enabled }}
|
||||||
|
securityContext: {{- omit .Values.sftp.containerSecurityContext "enabled" | toYaml | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.sidecars }}
|
||||||
|
{{- include "common.tplvalues.render" (dict "value" .Values.sftp.sidecars "context" $) | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
{{- if .Values.sftp.enableAuth }}
|
||||||
|
- name: config-users
|
||||||
|
secret:
|
||||||
|
defaultMode: 420
|
||||||
|
{{- if .Values.sftp.existingConfigSecret }}
|
||||||
|
secretName: {{ .Values.sftp.existingConfigSecret }}
|
||||||
|
{{- else }}
|
||||||
|
secretName: seaweedfs-sftp-secret
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
- name: config-ssh
|
||||||
|
secret:
|
||||||
|
defaultMode: 420
|
||||||
|
{{- if .Values.sftp.existingSshConfigSecret }}
|
||||||
|
secretName: {{ .Values.sftp.existingSshConfigSecret }}
|
||||||
|
{{- else }}
|
||||||
|
secretName: seaweedfs-sftp-ssh-secret
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Values.sftp.logs.type "hostPath" }}
|
||||||
|
- name: logs
|
||||||
|
hostPath:
|
||||||
|
path: {{ .Values.sftp.logs.hostPathPrefix }}/logs/seaweedfs/sftp
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq .Values.sftp.logs.type "emptyDir" }}
|
||||||
|
- name: logs
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.global.enableSecurity }}
|
||||||
|
- name: security-config
|
||||||
|
configMap:
|
||||||
|
name: {{ template "seaweedfs.name" . }}-security-config
|
||||||
|
- name: ca-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ template "seaweedfs.name" . }}-ca-cert
|
||||||
|
- name: master-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ template "seaweedfs.name" . }}-master-cert
|
||||||
|
- name: volume-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ template "seaweedfs.name" . }}-volume-cert
|
||||||
|
- name: filer-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ template "seaweedfs.name" . }}-filer-cert
|
||||||
|
- name: client-cert
|
||||||
|
secret:
|
||||||
|
secretName: {{ template "seaweedfs.name" . }}-client-cert
|
||||||
|
{{- end }}
|
||||||
|
{{ tpl .Values.sftp.extraVolumes . | indent 8 | trim }}
|
||||||
|
{{- if .Values.sftp.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{ tpl .Values.sftp.nodeSelector . | indent 8 | trim }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
33
k8s/charts/seaweedfs/templates/sftp-secret.yaml
Normal file
33
k8s/charts/seaweedfs/templates/sftp-secret.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{- if .Values.sftp.enabled }}
|
||||||
|
{{- $admin_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-sftp-secret" "key" "admin_password" 20) -}}
|
||||||
|
{{- $read_user_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-sftp-secret" "key" "readonly_password" 20) -}}
|
||||||
|
{{- $public_user_pwd := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-sftp-secret" "key" "public_user_password" 20) -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
type: Opaque
|
||||||
|
metadata:
|
||||||
|
name: seaweedfs-sftp-secret
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
annotations:
|
||||||
|
"helm.sh/resource-policy": keep
|
||||||
|
"helm.sh/hook": "pre-install,pre-upgrade"
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
stringData:
|
||||||
|
admin_password: {{ $admin_pwd }}
|
||||||
|
readonly_password: {{ $read_user_pwd }}
|
||||||
|
public_user_password: {{ $public_user_pwd }}
|
||||||
|
seaweedfs_sftp_config: '[{"Username":"admin","Password":"{{ $admin_pwd }}","PublicKeys":[],"HomeDir":"/","Permissions":{"/":["read","write","list"]},"Uid":0,"Gid":0},{"Username":"readonly_user","Password":"{{ $read_user_pwd }}","PublicKeys":[],"HomeDir":"/","Permissions":{"/":["read","list"]},"Uid":1112,"Gid":1112},{"Username":"public_user","Password":"{{ $public_user_pwd }}","PublicKeys":[],"HomeDir":"/public","Permissions":{"/public":["write","read","list"]},"Uid":1113,"Gid":1113}]'
|
||||||
|
seaweedfs_sftp_ssh_private_key: |
|
||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
QyNTUxOQAAACDH4McwcDphteXVullu6q7ephEN1N60z+w0qZw0UVW8OwAAAJDjxkmk48ZJ
|
||||||
|
pAAAAAtzc2gtZWQyNTUxOQAAACDH4McwcDphteXVullu6q7ephEN1N60z+w0qZw0UVW8Ow
|
||||||
|
AAAEAeVy/4+gf6rjj2jla/AHqJpC1LcS5hn04IUs4q+iVq/MfgxzBwOmG15dW6WW7qrt6m
|
||||||
|
EQ3U3rTP7DSpnDRRVbw7AAAADHNla291ckAwMDY2NwE=
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
|
{{- end }}
|
||||||
39
k8s/charts/seaweedfs/templates/sftp-service.yaml
Normal file
39
k8s/charts/seaweedfs/templates/sftp-service.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{{- if .Values.sftp.enabled }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ template "seaweedfs.name" . }}-sftp
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- if .Values.sftp.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml .Values.sftp.annotations | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.sftp.service.type | default "ClusterIP" }}
|
||||||
|
internalTrafficPolicy: {{ .Values.sftp.internalTrafficPolicy | default "Cluster" }}
|
||||||
|
ports:
|
||||||
|
- name: "swfs-sftp"
|
||||||
|
port: {{ .Values.sftp.port }}
|
||||||
|
targetPort: {{ .Values.sftp.port }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- if and (eq (.Values.sftp.service.type | default "ClusterIP") "NodePort") .Values.sftp.service.nodePort }}
|
||||||
|
nodePort: {{ .Values.sftp.service.nodePort }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.metricsPort }}
|
||||||
|
- name: "metrics"
|
||||||
|
port: {{ .Values.sftp.metricsPort }}
|
||||||
|
targetPort: {{ .Values.sftp.metricsPort }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- if and (eq (.Values.sftp.service.type | default "ClusterIP") "NodePort") .Values.sftp.service.metricsNodePort }}
|
||||||
|
nodePort: {{ .Values.sftp.service.metricsNodePort }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
{{- end }}
|
||||||
33
k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
Normal file
33
k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{- if .Values.sftp.enabled }}
|
||||||
|
{{- if .Values.sftp.metricsPort }}
|
||||||
|
{{- if .Values.global.monitoring.enabled }}
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: ServiceMonitor
|
||||||
|
metadata:
|
||||||
|
name: {{ template "seaweedfs.name" . }}-sftp
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
{{- with .Values.global.monitoring.additionalLabels }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.sftp.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml .Values.sftp.annotations | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
endpoints:
|
||||||
|
- interval: 30s
|
||||||
|
port: metrics
|
||||||
|
scrapeTimeout: 5s
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
|
||||||
|
app.kubernetes.io/component: sftp
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -46,6 +46,7 @@ global:
|
|||||||
image:
|
image:
|
||||||
registry: ""
|
registry: ""
|
||||||
repository: ""
|
repository: ""
|
||||||
|
tag: ""
|
||||||
|
|
||||||
master:
|
master:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -948,7 +949,82 @@ s3:
|
|||||||
# additional ingress annotations for the s3 endpoint
|
# additional ingress annotations for the s3 endpoint
|
||||||
annotations: {}
|
annotations: {}
|
||||||
tls: []
|
tls: []
|
||||||
|
sftp:
|
||||||
|
enabled: false
|
||||||
|
imageOverride: null
|
||||||
|
restartPolicy: null
|
||||||
|
replicas: 1
|
||||||
|
bindAddress: 0.0.0.0
|
||||||
|
port: 2022 # Default SFTP port
|
||||||
|
metricsPort: 9327
|
||||||
|
metricsIp: "" # If empty, defaults to bindAddress
|
||||||
|
service:
|
||||||
|
type: ClusterIP # Can be ClusterIP, NodePort, LoadBalancer
|
||||||
|
nodePort: null # Optional: specific nodePort for SFTP
|
||||||
|
metricsNodePort: null # Optional: specific nodePort for metrics
|
||||||
|
loggingOverrideLevel: null
|
||||||
|
|
||||||
|
# SSH server configuration
|
||||||
|
sshPrivateKey: "/etc/sw/seaweedfs_sftp_ssh_private_key" # Path to the SSH private key file for host authentication
|
||||||
|
hostKeysFolder: "/etc/sw/ssh" # path to folder containing SSH private key files for host authentication
|
||||||
|
authMethods: "password,publickey" # Comma-separated list of allowed auth methods: password, publickey, keyboard-interactive
|
||||||
|
maxAuthTries: 6 # Maximum number of authentication attempts per connection
|
||||||
|
bannerMessage: "SeaweedFS SFTP Server" # Message displayed before authentication
|
||||||
|
loginGraceTime: "2m" # Timeout for authentication
|
||||||
|
clientAliveInterval: "5s" # Interval for sending keep-alive messages
|
||||||
|
clientAliveCountMax: 3 # Maximum number of missed keep-alive messages before disconnecting
|
||||||
|
dataCenter: "" # Prefer to read and write to volumes in this data center
|
||||||
|
localSocket: "" # Default to /tmp/seaweedfs-sftp-<port>.sock
|
||||||
|
|
||||||
|
# User authentication
|
||||||
|
enableAuth: false
|
||||||
|
# Set to the name of an existing kubernetes Secret with the sftp json config file
|
||||||
|
# Should have a secret key called seaweedfs_sftp_config with an inline json config
|
||||||
|
existingConfigSecret: null
|
||||||
|
# Set to the name of an existing kubernetes Secret with the list of ssh private keys for sftp
|
||||||
|
existingSshConfigSecret: null
|
||||||
|
|
||||||
|
# Additional resources
|
||||||
|
sidecars: []
|
||||||
|
initContainers: ""
|
||||||
|
extraVolumes: ""
|
||||||
|
extraVolumeMounts: ""
|
||||||
|
podLabels: {}
|
||||||
|
podAnnotations: {}
|
||||||
|
annotations: {}
|
||||||
|
resources: {}
|
||||||
|
tolerations: ""
|
||||||
|
nodeSelector: |
|
||||||
|
kubernetes.io/arch: amd64
|
||||||
|
priorityClassName: ""
|
||||||
|
serviceAccountName: ""
|
||||||
|
podSecurityContext: {}
|
||||||
|
containerSecurityContext: {}
|
||||||
|
|
||||||
|
logs:
|
||||||
|
type: "hostPath"
|
||||||
|
hostPathPrefix: /storage
|
||||||
|
|
||||||
|
extraEnvironmentVars: {}
|
||||||
|
|
||||||
|
# Health checks
|
||||||
|
# Health checks for SFTP - using tcpSocket instead of httpGet
|
||||||
|
livenessProbe:
|
||||||
|
enabled: true
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 60
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 20
|
||||||
|
timeoutSeconds: 10
|
||||||
|
|
||||||
|
# Health checks for SFTP - using tcpSocket instead of httpGet
|
||||||
|
readinessProbe:
|
||||||
|
enabled: true
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 15
|
||||||
|
successThreshold: 1
|
||||||
|
failureThreshold: 100
|
||||||
|
timeoutSeconds: 10
|
||||||
# Deploy Kubernetes COSI Driver for SeaweedFS
|
# Deploy Kubernetes COSI Driver for SeaweedFS
|
||||||
# Requires COSI CRDs and controller to be installed in the cluster
|
# Requires COSI CRDs and controller to be installed in the cluster
|
||||||
# For more information, visit: https://container-object-storage-interface.github.io/docs/deployment-guide
|
# For more information, visit: https://container-object-storage-interface.github.io/docs/deployment-guide
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ var Commands = []*Command{
|
|||||||
cmdVersion,
|
cmdVersion,
|
||||||
cmdVolume,
|
cmdVolume,
|
||||||
cmdWebDav,
|
cmdWebDav,
|
||||||
|
cmdSftp,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ var (
|
|||||||
filerWebDavOptions WebDavOption
|
filerWebDavOptions WebDavOption
|
||||||
filerStartIam *bool
|
filerStartIam *bool
|
||||||
filerIamOptions IamOptions
|
filerIamOptions IamOptions
|
||||||
|
filerStartSftp *bool
|
||||||
|
filerSftpOptions SftpOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
type FilerOptions struct {
|
type FilerOptions struct {
|
||||||
@@ -141,6 +143,19 @@ func init() {
|
|||||||
filerStartIam = cmdFiler.Flag.Bool("iam", false, "whether to start IAM service")
|
filerStartIam = cmdFiler.Flag.Bool("iam", false, "whether to start IAM service")
|
||||||
filerIamOptions.ip = cmdFiler.Flag.String("iam.ip", *f.ip, "iam server http listen ip address")
|
filerIamOptions.ip = cmdFiler.Flag.String("iam.ip", *f.ip, "iam server http listen ip address")
|
||||||
filerIamOptions.port = cmdFiler.Flag.Int("iam.port", 8111, "iam server http listen port")
|
filerIamOptions.port = cmdFiler.Flag.Int("iam.port", 8111, "iam server http listen port")
|
||||||
|
|
||||||
|
filerStartSftp = cmdFiler.Flag.Bool("sftp", false, "whether to start the SFTP server")
|
||||||
|
filerSftpOptions.port = cmdFiler.Flag.Int("sftp.port", 2022, "SFTP server listen port")
|
||||||
|
filerSftpOptions.sshPrivateKey = cmdFiler.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||||
|
filerSftpOptions.hostKeysFolder = cmdFiler.Flag.String("sftp.hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||||
|
filerSftpOptions.authMethods = cmdFiler.Flag.String("sftp.authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||||
|
filerSftpOptions.maxAuthTries = cmdFiler.Flag.Int("sftp.maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||||
|
filerSftpOptions.bannerMessage = cmdFiler.Flag.String("sftp.bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||||
|
filerSftpOptions.loginGraceTime = cmdFiler.Flag.Duration("sftp.loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||||
|
filerSftpOptions.clientAliveInterval = cmdFiler.Flag.Duration("sftp.clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||||
|
filerSftpOptions.clientAliveCountMax = cmdFiler.Flag.Int("sftp.clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||||
|
filerSftpOptions.userStoreFile = cmdFiler.Flag.String("sftp.userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||||
|
filerSftpOptions.localSocket = cmdFiler.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
func filerLongDesc() string {
|
func filerLongDesc() string {
|
||||||
@@ -235,6 +250,18 @@ func runFiler(cmd *Command, args []string) bool {
|
|||||||
time.Sleep(delay * time.Second)
|
time.Sleep(delay * time.Second)
|
||||||
filerIamOptions.startIamServer()
|
filerIamOptions.startIamServer()
|
||||||
}(startDelay)
|
}(startDelay)
|
||||||
|
startDelay++
|
||||||
|
}
|
||||||
|
|
||||||
|
if *filerStartSftp {
|
||||||
|
sftpOptions.filer = &filerAddress
|
||||||
|
if *f.dataCenter != "" && *filerSftpOptions.dataCenter == "" {
|
||||||
|
filerSftpOptions.dataCenter = f.dataCenter
|
||||||
|
}
|
||||||
|
go func(delay time.Duration) {
|
||||||
|
time.Sleep(delay * time.Second)
|
||||||
|
sftpOptions.startSftpServer()
|
||||||
|
}(startDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.masters = pb.ServerAddresses(*f.mastersString).ToServiceDiscovery()
|
f.masters = pb.ServerAddresses(*f.mastersString).ToServiceDiscovery()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ var (
|
|||||||
masterOptions MasterOptions
|
masterOptions MasterOptions
|
||||||
filerOptions FilerOptions
|
filerOptions FilerOptions
|
||||||
s3Options S3Options
|
s3Options S3Options
|
||||||
|
sftpOptions SftpOptions
|
||||||
iamOptions IamOptions
|
iamOptions IamOptions
|
||||||
webdavOptions WebDavOption
|
webdavOptions WebDavOption
|
||||||
mqBrokerOptions MessageQueueBrokerOptions
|
mqBrokerOptions MessageQueueBrokerOptions
|
||||||
@@ -73,6 +74,7 @@ var (
|
|||||||
isStartingVolumeServer = cmdServer.Flag.Bool("volume", true, "whether to start volume server")
|
isStartingVolumeServer = cmdServer.Flag.Bool("volume", true, "whether to start volume server")
|
||||||
isStartingFiler = cmdServer.Flag.Bool("filer", false, "whether to start filer")
|
isStartingFiler = cmdServer.Flag.Bool("filer", false, "whether to start filer")
|
||||||
isStartingS3 = cmdServer.Flag.Bool("s3", false, "whether to start S3 gateway")
|
isStartingS3 = cmdServer.Flag.Bool("s3", false, "whether to start S3 gateway")
|
||||||
|
isStartingSftp = cmdServer.Flag.Bool("sftp", false, "whether to start Sftp server")
|
||||||
isStartingIam = cmdServer.Flag.Bool("iam", false, "whether to start IAM service")
|
isStartingIam = cmdServer.Flag.Bool("iam", false, "whether to start IAM service")
|
||||||
isStartingWebDav = cmdServer.Flag.Bool("webdav", false, "whether to start WebDAV gateway")
|
isStartingWebDav = cmdServer.Flag.Bool("webdav", false, "whether to start WebDAV gateway")
|
||||||
isStartingMqBroker = cmdServer.Flag.Bool("mq.broker", false, "whether to start message queue broker")
|
isStartingMqBroker = cmdServer.Flag.Bool("mq.broker", false, "whether to start message queue broker")
|
||||||
@@ -159,6 +161,17 @@ func init() {
|
|||||||
s3Options.bindIp = cmdServer.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
s3Options.bindIp = cmdServer.Flag.String("s3.ip.bind", "", "ip address to bind to. If empty, default to same as -ip.bind option.")
|
||||||
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 10, "connection idle seconds")
|
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 10, "connection idle seconds")
|
||||||
|
|
||||||
|
sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port")
|
||||||
|
sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||||
|
sftpOptions.hostKeysFolder = cmdServer.Flag.String("sftp.hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||||
|
sftpOptions.authMethods = cmdServer.Flag.String("sftp.authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||||
|
sftpOptions.maxAuthTries = cmdServer.Flag.Int("sftp.maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||||
|
sftpOptions.bannerMessage = cmdServer.Flag.String("sftp.bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||||
|
sftpOptions.loginGraceTime = cmdServer.Flag.Duration("sftp.loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||||
|
sftpOptions.clientAliveInterval = cmdServer.Flag.Duration("sftp.clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||||
|
sftpOptions.clientAliveCountMax = cmdServer.Flag.Int("sftp.clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||||
|
sftpOptions.userStoreFile = cmdServer.Flag.String("sftp.userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||||
|
sftpOptions.localSocket = cmdServer.Flag.String("sftp.localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||||
iamOptions.port = cmdServer.Flag.Int("iam.port", 8111, "iam server http listen port")
|
iamOptions.port = cmdServer.Flag.Int("iam.port", 8111, "iam server http listen port")
|
||||||
|
|
||||||
webdavOptions.port = cmdServer.Flag.Int("webdav.port", 7333, "webdav server http listen port")
|
webdavOptions.port = cmdServer.Flag.Int("webdav.port", 7333, "webdav server http listen port")
|
||||||
@@ -190,6 +203,9 @@ func runServer(cmd *Command, args []string) bool {
|
|||||||
if *isStartingS3 {
|
if *isStartingS3 {
|
||||||
*isStartingFiler = true
|
*isStartingFiler = true
|
||||||
}
|
}
|
||||||
|
if *isStartingSftp {
|
||||||
|
*isStartingFiler = true
|
||||||
|
}
|
||||||
if *isStartingIam {
|
if *isStartingIam {
|
||||||
*isStartingFiler = true
|
*isStartingFiler = true
|
||||||
}
|
}
|
||||||
@@ -223,6 +239,9 @@ func runServer(cmd *Command, args []string) bool {
|
|||||||
if *s3Options.bindIp == "" {
|
if *s3Options.bindIp == "" {
|
||||||
s3Options.bindIp = serverBindIp
|
s3Options.bindIp = serverBindIp
|
||||||
}
|
}
|
||||||
|
if sftpOptions.bindIp == nil || *sftpOptions.bindIp == "" {
|
||||||
|
sftpOptions.bindIp = serverBindIp
|
||||||
|
}
|
||||||
iamOptions.ip = serverBindIp
|
iamOptions.ip = serverBindIp
|
||||||
iamOptions.masters = masterOptions.peers
|
iamOptions.masters = masterOptions.peers
|
||||||
webdavOptions.ipBind = serverBindIp
|
webdavOptions.ipBind = serverBindIp
|
||||||
@@ -246,11 +265,13 @@ func runServer(cmd *Command, args []string) bool {
|
|||||||
mqBrokerOptions.dataCenter = serverDataCenter
|
mqBrokerOptions.dataCenter = serverDataCenter
|
||||||
mqBrokerOptions.rack = serverRack
|
mqBrokerOptions.rack = serverRack
|
||||||
s3Options.dataCenter = serverDataCenter
|
s3Options.dataCenter = serverDataCenter
|
||||||
|
sftpOptions.dataCenter = serverDataCenter
|
||||||
filerOptions.disableHttp = serverDisableHttp
|
filerOptions.disableHttp = serverDisableHttp
|
||||||
masterOptions.disableHttp = serverDisableHttp
|
masterOptions.disableHttp = serverDisableHttp
|
||||||
|
|
||||||
filerAddress := string(pb.NewServerAddress(*serverIp, *filerOptions.port, *filerOptions.portGrpc))
|
filerAddress := string(pb.NewServerAddress(*serverIp, *filerOptions.port, *filerOptions.portGrpc))
|
||||||
s3Options.filer = &filerAddress
|
s3Options.filer = &filerAddress
|
||||||
|
sftpOptions.filer = &filerAddress
|
||||||
iamOptions.filer = &filerAddress
|
iamOptions.filer = &filerAddress
|
||||||
webdavOptions.filer = &filerAddress
|
webdavOptions.filer = &filerAddress
|
||||||
mqBrokerOptions.filerGroup = filerOptions.filerGroup
|
mqBrokerOptions.filerGroup = filerOptions.filerGroup
|
||||||
@@ -291,6 +312,14 @@ func runServer(cmd *Command, args []string) bool {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *isStartingSftp {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
sftpOptions.localSocket = filerOptions.localSocket
|
||||||
|
sftpOptions.startSftpServer()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if *isStartingIam {
|
if *isStartingIam {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
193
weed/command/sftp.go
Normal file
193
weed/command/sftp.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||||
|
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd"
|
||||||
|
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sftpOptionsStandalone SftpOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
// SftpOptions holds configuration options for the SFTP server.
|
||||||
|
type SftpOptions struct {
|
||||||
|
filer *string
|
||||||
|
bindIp *string
|
||||||
|
port *int
|
||||||
|
sshPrivateKey *string
|
||||||
|
hostKeysFolder *string
|
||||||
|
authMethods *string
|
||||||
|
maxAuthTries *int
|
||||||
|
bannerMessage *string
|
||||||
|
loginGraceTime *time.Duration
|
||||||
|
clientAliveInterval *time.Duration
|
||||||
|
clientAliveCountMax *int
|
||||||
|
userStoreFile *string
|
||||||
|
dataCenter *string
|
||||||
|
metricsHttpPort *int
|
||||||
|
metricsHttpIp *string
|
||||||
|
localSocket *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdSftp defines the SFTP command similar to the S3 command.
|
||||||
|
var cmdSftp = &Command{
|
||||||
|
UsageLine: "sftp [-port=2022] [-filer=<ip:port>] [-sshPrivateKey=</path/to/private_key>]",
|
||||||
|
Short: "start an SFTP server that is backed by a SeaweedFS filer",
|
||||||
|
Long: `Start an SFTP server that leverages the SeaweedFS filer service to handle file operations.
|
||||||
|
|
||||||
|
Instead of reading from or writing to a local filesystem, all file operations
|
||||||
|
are routed through the filer (filer_pb) gRPC API. This allows you to centralize
|
||||||
|
your file management in SeaweedFS.
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register the command to avoid cyclic dependencies.
|
||||||
|
cmdSftp.Run = runSftp
|
||||||
|
|
||||||
|
sftpOptionsStandalone.filer = cmdSftp.Flag.String("filer", "localhost:8888", "filer server address (ip:port)")
|
||||||
|
sftpOptionsStandalone.bindIp = cmdSftp.Flag.String("ip.bind", "0.0.0.0", "ip address to bind SFTP server")
|
||||||
|
sftpOptionsStandalone.port = cmdSftp.Flag.Int("port", 2022, "SFTP server listen port")
|
||||||
|
sftpOptionsStandalone.sshPrivateKey = cmdSftp.Flag.String("sshPrivateKey", "", "path to the SSH private key file for host authentication")
|
||||||
|
sftpOptionsStandalone.hostKeysFolder = cmdSftp.Flag.String("hostKeysFolder", "", "path to folder containing SSH private key files for host authentication")
|
||||||
|
sftpOptionsStandalone.authMethods = cmdSftp.Flag.String("authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive")
|
||||||
|
sftpOptionsStandalone.maxAuthTries = cmdSftp.Flag.Int("maxAuthTries", 6, "maximum number of authentication attempts per connection")
|
||||||
|
sftpOptionsStandalone.bannerMessage = cmdSftp.Flag.String("bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication")
|
||||||
|
sftpOptionsStandalone.loginGraceTime = cmdSftp.Flag.Duration("loginGraceTime", 2*time.Minute, "timeout for authentication")
|
||||||
|
sftpOptionsStandalone.clientAliveInterval = cmdSftp.Flag.Duration("clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages")
|
||||||
|
sftpOptionsStandalone.clientAliveCountMax = cmdSftp.Flag.Int("clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting")
|
||||||
|
sftpOptionsStandalone.userStoreFile = cmdSftp.Flag.String("userStoreFile", "", "path to JSON file containing user credentials and permissions")
|
||||||
|
sftpOptionsStandalone.dataCenter = cmdSftp.Flag.String("dataCenter", "", "prefer to read and write to volumes in this data center")
|
||||||
|
sftpOptionsStandalone.metricsHttpPort = cmdSftp.Flag.Int("metricsPort", 0, "Prometheus metrics listen port")
|
||||||
|
sftpOptionsStandalone.metricsHttpIp = cmdSftp.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.")
|
||||||
|
sftpOptionsStandalone.localSocket = cmdSftp.Flag.String("localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSftp is the command entry point.
|
||||||
|
func runSftp(cmd *Command, args []string) bool {
|
||||||
|
// Load security configuration as done in other SeaweedFS services.
|
||||||
|
util.LoadSecurityConfiguration()
|
||||||
|
|
||||||
|
// Configure metrics
|
||||||
|
switch {
|
||||||
|
case *sftpOptionsStandalone.metricsHttpIp != "":
|
||||||
|
// nothing to do, use sftpOptionsStandalone.metricsHttpIp
|
||||||
|
case *sftpOptionsStandalone.bindIp != "":
|
||||||
|
*sftpOptionsStandalone.metricsHttpIp = *sftpOptionsStandalone.bindIp
|
||||||
|
}
|
||||||
|
go stats_collect.StartMetricsServer(*sftpOptionsStandalone.metricsHttpIp, *sftpOptionsStandalone.metricsHttpPort)
|
||||||
|
|
||||||
|
return sftpOptionsStandalone.startSftpServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sftpOpt *SftpOptions) startSftpServer() bool {
|
||||||
|
filerAddress := pb.ServerAddress(*sftpOpt.filer)
|
||||||
|
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
|
||||||
|
|
||||||
|
// metrics read from the filer
|
||||||
|
var metricsAddress string
|
||||||
|
var metricsIntervalSec int
|
||||||
|
var filerGroup string
|
||||||
|
|
||||||
|
// Connect to the filer service and try to retrieve basic configuration.
|
||||||
|
for {
|
||||||
|
err := pb.WithGrpcFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||||
|
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get filer %s configuration: %v", filerAddress, err)
|
||||||
|
}
|
||||||
|
metricsAddress, metricsIntervalSec = resp.MetricsAddress, int(resp.MetricsIntervalSec)
|
||||||
|
filerGroup = resp.FilerGroup
|
||||||
|
glog.V(0).Infof("SFTP read filer configuration, using filer at: %s", filerAddress)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
glog.V(0).Infof("Waiting to connect to filer %s grpc address %s...", *sftpOpt.filer, filerAddress.ToGrpcAddress())
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
} else {
|
||||||
|
glog.V(0).Infof("Connected to filer %s grpc address %s", *sftpOpt.filer, filerAddress.ToGrpcAddress())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go stats_collect.LoopPushingMetric("sftp", stats_collect.SourceName(uint32(*sftpOpt.port)), metricsAddress, metricsIntervalSec)
|
||||||
|
|
||||||
|
// Parse auth methods
|
||||||
|
var authMethods []string
|
||||||
|
if *sftpOpt.authMethods != "" {
|
||||||
|
authMethods = util.StringSplit(*sftpOpt.authMethods, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new SFTP service instance with all options
|
||||||
|
service := sftpd.NewSFTPService(&sftpd.SFTPServiceOptions{
|
||||||
|
GrpcDialOption: grpcDialOption,
|
||||||
|
DataCenter: *sftpOpt.dataCenter,
|
||||||
|
FilerGroup: filerGroup,
|
||||||
|
Filer: filerAddress,
|
||||||
|
SshPrivateKey: *sftpOpt.sshPrivateKey,
|
||||||
|
HostKeysFolder: *sftpOpt.hostKeysFolder,
|
||||||
|
AuthMethods: authMethods,
|
||||||
|
MaxAuthTries: *sftpOpt.maxAuthTries,
|
||||||
|
BannerMessage: *sftpOpt.bannerMessage,
|
||||||
|
LoginGraceTime: *sftpOpt.loginGraceTime,
|
||||||
|
ClientAliveInterval: *sftpOpt.clientAliveInterval,
|
||||||
|
ClientAliveCountMax: *sftpOpt.clientAliveCountMax,
|
||||||
|
UserStoreFile: *sftpOpt.userStoreFile,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up Unix socket if on non-Windows platforms
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
localSocket := *sftpOpt.localSocket
|
||||||
|
if localSocket == "" {
|
||||||
|
localSocket = fmt.Sprintf("/tmp/seaweedfs-sftp-%d.sock", *sftpOpt.port)
|
||||||
|
}
|
||||||
|
if err := os.Remove(localSocket); err != nil && !os.IsNotExist(err) {
|
||||||
|
glog.Fatalf("Failed to remove %s, error: %s", localSocket, err.Error())
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
// start on local unix socket
|
||||||
|
sftpSocketListener, err := net.Listen("unix", localSocket)
|
||||||
|
if err != nil {
|
||||||
|
glog.Fatalf("Failed to listen on %s: %v", localSocket, err)
|
||||||
|
}
|
||||||
|
if err := service.Serve(sftpSocketListener); err != nil {
|
||||||
|
glog.Fatalf("Failed to serve SFTP on socket %s: %v", localSocket, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the SFTP service on TCP
|
||||||
|
listenAddress := fmt.Sprintf("%s:%d", *sftpOpt.bindIp, *sftpOpt.port)
|
||||||
|
sftpListener, sftpLocalListener, err := util.NewIpAndLocalListeners(*sftpOpt.bindIp, *sftpOpt.port, time.Duration(10)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
glog.Fatalf("SFTP server listener on %s error: %v", listenAddress, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
glog.V(0).Infof("Start Seaweed SFTP Server %s at %s", util.Version(), listenAddress)
|
||||||
|
|
||||||
|
if sftpLocalListener != nil {
|
||||||
|
go func() {
|
||||||
|
if err := service.Serve(sftpLocalListener); err != nil {
|
||||||
|
glog.Fatalf("SFTP Server failed to serve on local listener: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.Serve(sftpListener); err != nil {
|
||||||
|
glog.Fatalf("SFTP Server failed to serve: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package ftpd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
|
||||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
||||||
"net"
|
|
||||||
|
|
||||||
ftpserver "github.com/fclairamb/ftpserverlib"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FtpServerOption struct {
|
|
||||||
Filer string
|
|
||||||
IP string
|
|
||||||
IpBind string
|
|
||||||
Port int
|
|
||||||
FilerGrpcAddress string
|
|
||||||
FtpRoot string
|
|
||||||
GrpcDialOption grpc.DialOption
|
|
||||||
PassivePortStart int
|
|
||||||
PassivePortStop int
|
|
||||||
}
|
|
||||||
|
|
||||||
type SftpServer struct {
|
|
||||||
option *FtpServerOption
|
|
||||||
ftpListener net.Listener
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = ftpserver.MainDriver(&SftpServer{})
|
|
||||||
|
|
||||||
// NewFtpServer returns a new FTP server driver
|
|
||||||
func NewFtpServer(ftpListener net.Listener, option *FtpServerOption) (*SftpServer, error) {
|
|
||||||
var err error
|
|
||||||
server := &SftpServer{
|
|
||||||
option: option,
|
|
||||||
ftpListener: ftpListener,
|
|
||||||
}
|
|
||||||
return server, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSettings returns some general settings around the server setup
|
|
||||||
func (s *SftpServer) GetSettings() (*ftpserver.Settings, error) {
|
|
||||||
var portRange *ftpserver.PortRange
|
|
||||||
if s.option.PassivePortStart > 0 && s.option.PassivePortStop > s.option.PassivePortStart {
|
|
||||||
portRange = &ftpserver.PortRange{
|
|
||||||
Start: s.option.PassivePortStart,
|
|
||||||
End: s.option.PassivePortStop,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ftpserver.Settings{
|
|
||||||
Listener: s.ftpListener,
|
|
||||||
ListenAddr: util.JoinHostPort(s.option.IpBind, s.option.Port),
|
|
||||||
PublicHost: s.option.IP,
|
|
||||||
PassiveTransferPortRange: portRange,
|
|
||||||
ActiveTransferPortNon20: true,
|
|
||||||
IdleTimeout: -1,
|
|
||||||
ConnectionTimeout: 20,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientConnected is called to send the very first welcome message
|
|
||||||
func (s *SftpServer) ClientConnected(cc ftpserver.ClientContext) (string, error) {
|
|
||||||
return "Welcome to SeaweedFS FTP Server", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientDisconnected is called when the user disconnects, even if he never authenticated
|
|
||||||
func (s *SftpServer) ClientDisconnected(cc ftpserver.ClientContext) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthUser authenticates the user and selects an handling driver
|
|
||||||
func (s *SftpServer) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTLSConfig returns a TLS Certificate to use
|
|
||||||
// The certificate could frequently change if we use something like "let's encrypt"
|
|
||||||
func (s *SftpServer) GetTLSConfig() (*tls.Config, error) {
|
|
||||||
return nil, errors.New("no TLS certificate configured")
|
|
||||||
}
|
|
||||||
76
weed/sftpd/auth/auth.go
Normal file
76
weed/sftpd/auth/auth.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Package auth provides authentication and authorization functionality for the SFTP server
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider defines the interface for authentication providers
|
||||||
|
type Provider interface {
|
||||||
|
// GetAuthMethods returns the SSH server auth methods
|
||||||
|
GetAuthMethods() []ssh.AuthMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles authentication and authorization
|
||||||
|
type Manager struct {
|
||||||
|
userStore user.Store
|
||||||
|
passwordAuth *PasswordAuthenticator
|
||||||
|
publicKeyAuth *PublicKeyAuthenticator
|
||||||
|
permissionChecker *PermissionChecker
|
||||||
|
enabledAuthMethods []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new authentication manager
|
||||||
|
func NewManager(userStore user.Store, fsHelper FileSystemHelper, enabledAuthMethods []string) *Manager {
|
||||||
|
manager := &Manager{
|
||||||
|
userStore: userStore,
|
||||||
|
enabledAuthMethods: enabledAuthMethods,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize authenticators based on enabled methods
|
||||||
|
passwordEnabled := false
|
||||||
|
publicKeyEnabled := false
|
||||||
|
|
||||||
|
for _, method := range enabledAuthMethods {
|
||||||
|
switch method {
|
||||||
|
case "password":
|
||||||
|
passwordEnabled = true
|
||||||
|
case "publickey":
|
||||||
|
publicKeyEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.passwordAuth = NewPasswordAuthenticator(userStore, passwordEnabled)
|
||||||
|
manager.publicKeyAuth = NewPublicKeyAuthenticator(userStore, publicKeyEnabled)
|
||||||
|
manager.permissionChecker = NewPermissionChecker(fsHelper)
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSSHServerConfig returns an SSH server config with the appropriate authentication methods
|
||||||
|
func (m *Manager) GetSSHServerConfig() *ssh.ServerConfig {
|
||||||
|
config := &ssh.ServerConfig{}
|
||||||
|
|
||||||
|
// Add password authentication if enabled
|
||||||
|
if m.passwordAuth.Enabled() {
|
||||||
|
config.PasswordCallback = m.passwordAuth.Authenticate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public key authentication if enabled
|
||||||
|
if m.publicKeyAuth.Enabled() {
|
||||||
|
config.PublicKeyCallback = m.publicKeyAuth.Authenticate
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPermission checks if a user has the required permission on a path
|
||||||
|
func (m *Manager) CheckPermission(user *user.User, path, permission string) error {
|
||||||
|
return m.permissionChecker.CheckFilePermission(user, path, permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser retrieves a user from the user store
|
||||||
|
func (m *Manager) GetUser(username string) (*user.User, error) {
|
||||||
|
return m.userStore.GetUser(username)
|
||||||
|
}
|
||||||
64
weed/sftpd/auth/password.go
Normal file
64
weed/sftpd/auth/password.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordAuthenticator handles password-based authentication
|
||||||
|
type PasswordAuthenticator struct {
|
||||||
|
userStore user.Store
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasswordAuthenticator creates a new password authenticator
|
||||||
|
func NewPasswordAuthenticator(userStore user.Store, enabled bool) *PasswordAuthenticator {
|
||||||
|
return &PasswordAuthenticator{
|
||||||
|
userStore: userStore,
|
||||||
|
enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled returns whether password authentication is enabled
|
||||||
|
func (a *PasswordAuthenticator) Enabled() bool {
|
||||||
|
return a.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate validates a password for a user
|
||||||
|
func (a *PasswordAuthenticator) Authenticate(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||||
|
username := conn.User()
|
||||||
|
|
||||||
|
// Check if password auth is enabled
|
||||||
|
if !a.enabled {
|
||||||
|
return nil, fmt.Errorf("password authentication disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password against user store
|
||||||
|
if a.userStore.ValidatePassword(username, password) {
|
||||||
|
return &ssh.Permissions{
|
||||||
|
Extensions: map[string]string{
|
||||||
|
"username": username,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay to prevent brute force attacks
|
||||||
|
time.Sleep(time.Duration(100+rand.Intn(100)) * time.Millisecond)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword checks if the provided password is valid for the user
|
||||||
|
func ValidatePassword(store user.Store, username string, password []byte) bool {
|
||||||
|
user, err := store.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare plaintext password
|
||||||
|
return string(password) == user.Password
|
||||||
|
}
|
||||||
267
weed/sftpd/auth/permissions.go
Normal file
267
weed/sftpd/auth/permissions.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permission constants for clarity and consistency
|
||||||
|
const (
|
||||||
|
PermRead = "read"
|
||||||
|
PermWrite = "write"
|
||||||
|
PermExecute = "execute"
|
||||||
|
PermList = "list"
|
||||||
|
PermDelete = "delete"
|
||||||
|
PermMkdir = "mkdir"
|
||||||
|
PermTraverse = "traverse"
|
||||||
|
PermAll = "*"
|
||||||
|
PermAdmin = "admin"
|
||||||
|
PermReadWrite = "readwrite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionChecker handles permission checking for file operations
|
||||||
|
// It verifies both Unix-style permissions and explicit ACLs defined in user configuration.
|
||||||
|
type PermissionChecker struct {
|
||||||
|
fsHelper FileSystemHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSystemHelper provides necessary filesystem operations for permission checking
|
||||||
|
type FileSystemHelper interface {
|
||||||
|
GetEntry(path string) (*Entry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry represents a filesystem entry with attributes
|
||||||
|
type Entry struct {
|
||||||
|
IsDirectory bool
|
||||||
|
Attributes *EntryAttributes
|
||||||
|
IsSymlink bool // Added to track symlinks
|
||||||
|
Target string // For symlinks, stores the target path
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntryAttributes contains file attributes
|
||||||
|
type EntryAttributes struct {
|
||||||
|
Uid uint32
|
||||||
|
Gid uint32
|
||||||
|
FileMode uint32
|
||||||
|
SymlinkTarget string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionError represents a permission-related error
|
||||||
|
type PermissionError struct {
|
||||||
|
Path string
|
||||||
|
Perm string
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PermissionError) Error() string {
|
||||||
|
return fmt.Sprintf("permission denied: %s required on %s for user %s", e.Perm, e.Path, e.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPermissionChecker creates a new permission checker
|
||||||
|
func NewPermissionChecker(fsHelper FileSystemHelper) *PermissionChecker {
|
||||||
|
return &PermissionChecker{
|
||||||
|
fsHelper: fsHelper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilePermission verifies if a user has the required permission on a path
|
||||||
|
// It first checks if the path is in the user's home directory with explicit permissions.
|
||||||
|
// If not, it falls back to Unix permission checking followed by explicit permission checking.
|
||||||
|
// Parameters:
|
||||||
|
// - user: The user requesting access
|
||||||
|
// - path: The filesystem path to check
|
||||||
|
// - perm: The permission being requested (read, write, execute, etc.)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - nil if permission is granted, error otherwise
|
||||||
|
func (pc *PermissionChecker) CheckFilePermission(user *user.User, path string, perm string) error {
|
||||||
|
if user == nil {
|
||||||
|
return &PermissionError{Path: path, Perm: perm, User: "unknown"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve metadata via helper
|
||||||
|
entry, err := pc.fsHelper.GetEntry(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get entry for path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle symlinks by resolving them
|
||||||
|
if entry.IsSymlink {
|
||||||
|
// Get the actual entry for the resolved path
|
||||||
|
entry, err = pc.fsHelper.GetEntry(entry.Attributes.SymlinkTarget)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get entry for resolved path %s: %w", entry.Attributes.SymlinkTarget, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the original target
|
||||||
|
entry.Target = entry.Attributes.SymlinkTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: root user always has permission
|
||||||
|
if user.Username == "root" || user.Uid == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is within user's home directory and has explicit permissions
|
||||||
|
if isPathInHomeDirectory(user, path) {
|
||||||
|
// Check if user has explicit permissions for this path
|
||||||
|
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For paths outside home directory or without explicit home permissions,
|
||||||
|
// check UNIX-style perms first
|
||||||
|
isOwner := user.Uid == entry.Attributes.Uid
|
||||||
|
isGroup := user.Gid == entry.Attributes.Gid
|
||||||
|
mode := os.FileMode(entry.Attributes.FileMode)
|
||||||
|
|
||||||
|
if HasUnixPermission(isOwner, isGroup, mode, entry.IsDirectory, perm) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check explicit ACLs
|
||||||
|
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PermissionError{Path: path, Perm: perm, User: user.Username}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilePermissionWithContext is a context-aware version of CheckFilePermission
|
||||||
|
// that supports cancellation and timeouts
|
||||||
|
func (pc *PermissionChecker) CheckFilePermissionWithContext(ctx context.Context, user *user.User, path string, perm string) error {
|
||||||
|
// Check for context cancellation
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pc.CheckFilePermission(user, path, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPathInHomeDirectory checks if a path is in the user's home directory
|
||||||
|
func isPathInHomeDirectory(user *user.User, path string) bool {
|
||||||
|
return strings.HasPrefix(path, user.HomeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnixPermission checks if the user has the required Unix permission
|
||||||
|
// Uses bit masks for clarity and maintainability
|
||||||
|
func HasUnixPermission(isOwner, isGroup bool, fileMode os.FileMode, isDirectory bool, requiredPerm string) bool {
|
||||||
|
const (
|
||||||
|
ownerRead = 0400
|
||||||
|
ownerWrite = 0200
|
||||||
|
ownerExec = 0100
|
||||||
|
groupRead = 0040
|
||||||
|
groupWrite = 0020
|
||||||
|
groupExec = 0010
|
||||||
|
otherRead = 0004
|
||||||
|
otherWrite = 0002
|
||||||
|
otherExec = 0001
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check read permission
|
||||||
|
hasRead := (isOwner && (fileMode&ownerRead != 0)) ||
|
||||||
|
(isGroup && (fileMode&groupRead != 0)) ||
|
||||||
|
(fileMode&otherRead != 0)
|
||||||
|
|
||||||
|
// Check write permission
|
||||||
|
hasWrite := (isOwner && (fileMode&ownerWrite != 0)) ||
|
||||||
|
(isGroup && (fileMode&groupWrite != 0)) ||
|
||||||
|
(fileMode&otherWrite != 0)
|
||||||
|
|
||||||
|
// Check execute permission
|
||||||
|
hasExec := (isOwner && (fileMode&ownerExec != 0)) ||
|
||||||
|
(isGroup && (fileMode&groupExec != 0)) ||
|
||||||
|
(fileMode&otherExec != 0)
|
||||||
|
|
||||||
|
switch requiredPerm {
|
||||||
|
case PermRead:
|
||||||
|
return hasRead
|
||||||
|
case PermWrite:
|
||||||
|
return hasWrite
|
||||||
|
case PermExecute:
|
||||||
|
return hasExec
|
||||||
|
case PermList:
|
||||||
|
if isDirectory {
|
||||||
|
return hasRead && hasExec
|
||||||
|
}
|
||||||
|
return hasRead
|
||||||
|
case PermDelete:
|
||||||
|
return hasWrite
|
||||||
|
case PermMkdir:
|
||||||
|
return isDirectory && hasWrite
|
||||||
|
case PermTraverse:
|
||||||
|
return isDirectory && hasExec
|
||||||
|
case PermReadWrite:
|
||||||
|
return hasRead && hasWrite
|
||||||
|
case PermAll, PermAdmin:
|
||||||
|
return hasRead && hasWrite && hasExec
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExplicitPermission checks if the user has explicit permission from user config
|
||||||
|
func HasExplicitPermission(user *user.User, filepath, requiredPerm string, isDirectory bool) bool {
|
||||||
|
// Find the most specific permission that applies to this path
|
||||||
|
var bestMatch string
|
||||||
|
var perms []string
|
||||||
|
|
||||||
|
for p, userPerms := range user.Permissions {
|
||||||
|
// Check if the path is either the permission path exactly or is under that path
|
||||||
|
if strings.HasPrefix(filepath, p) && len(p) > len(bestMatch) {
|
||||||
|
bestMatch = p
|
||||||
|
perms = userPerms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching permissions found
|
||||||
|
if bestMatch == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
if containsString(perms, PermAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user has list permission and is requesting traverse/execute permission, grant it
|
||||||
|
if isDirectory && requiredPerm == PermExecute && containsString(perms, PermList) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the required permission is in the list
|
||||||
|
for _, perm := range perms {
|
||||||
|
if perm == requiredPerm || perm == PermAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle combined permissions
|
||||||
|
if perm == PermReadWrite && (requiredPerm == PermRead || requiredPerm == PermWrite) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory-specific permissions
|
||||||
|
if isDirectory && perm == PermList && requiredPerm == PermRead {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isDirectory && perm == PermTraverse && requiredPerm == PermExecute {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a string is in a slice
|
||||||
|
func containsString(slice []string, s string) bool {
|
||||||
|
for _, item := range slice {
|
||||||
|
if item == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
68
weed/sftpd/auth/publickey.go
Normal file
68
weed/sftpd/auth/publickey.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicKeyAuthenticator handles public key-based authentication
|
||||||
|
type PublicKeyAuthenticator struct {
|
||||||
|
userStore user.Store
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPublicKeyAuthenticator creates a new public key authenticator
|
||||||
|
func NewPublicKeyAuthenticator(userStore user.Store, enabled bool) *PublicKeyAuthenticator {
|
||||||
|
return &PublicKeyAuthenticator{
|
||||||
|
userStore: userStore,
|
||||||
|
enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled returns whether public key authentication is enabled
|
||||||
|
func (a *PublicKeyAuthenticator) Enabled() bool {
|
||||||
|
return a.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate validates a public key for a user
|
||||||
|
func (a *PublicKeyAuthenticator) Authenticate(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||||
|
username := conn.User()
|
||||||
|
|
||||||
|
// Check if public key auth is enabled
|
||||||
|
if !a.enabled {
|
||||||
|
return nil, fmt.Errorf("public key authentication disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert key to string format for comparison
|
||||||
|
keyData := string(key.Marshal())
|
||||||
|
|
||||||
|
// Validate public key
|
||||||
|
if ValidatePublicKey(a.userStore, username, keyData) {
|
||||||
|
return &ssh.Permissions{
|
||||||
|
Extensions: map[string]string{
|
||||||
|
"username": username,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePublicKey checks if the provided public key is valid for the user
|
||||||
|
func ValidatePublicKey(store user.Store, username string, keyData string) bool {
|
||||||
|
user, err := store.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range user.PublicKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
457
weed/sftpd/sftp_filer.go
Normal file
457
weed/sftpd/sftp_filer.go
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
// sftp_filer_refactored.go
|
||||||
|
package sftpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||||
|
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||||
|
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTimeout = 30 * time.Second
|
||||||
|
defaultListLimit = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Filer RPC Helpers ====================
|
||||||
|
|
||||||
|
// callWithClient wraps a gRPC client call with timeout and client creation.
|
||||||
|
func (fs *SftpServer) callWithClient(streaming bool, fn func(ctx context.Context, client filer_pb.SeaweedFilerClient) error) error {
|
||||||
|
return fs.withTimeoutContext(func(ctx context.Context) error {
|
||||||
|
return fs.WithFilerClient(streaming, func(client filer_pb.SeaweedFilerClient) error {
|
||||||
|
return fn(ctx, client)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEntry retrieves a single directory entry by path.
|
||||||
|
func (fs *SftpServer) getEntry(p string) (*filer_pb.Entry, error) {
|
||||||
|
dir, name := util.FullPath(p).DirAndName()
|
||||||
|
var entry *filer_pb.Entry
|
||||||
|
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
r, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: dir, Name: name})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Entry == nil {
|
||||||
|
return fmt.Errorf("%s not found in %s", name, dir)
|
||||||
|
}
|
||||||
|
entry = r.Entry
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lookup %s: %w", p, err)
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateEntry sends an UpdateEntryRequest for the given entry.
|
||||||
|
func (fs *SftpServer) updateEntry(dir string, entry *filer_pb.Entry) error {
|
||||||
|
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
_, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: dir, Entry: entry})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FilerClient Interface ====================
|
||||||
|
|
||||||
|
func (fs *SftpServer) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
|
||||||
|
func (fs *SftpServer) GetDataCenter() string { return fs.dataCenter }
|
||||||
|
func (fs *SftpServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||||
|
addr := fs.filerAddr.ToGrpcAddress()
|
||||||
|
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
|
||||||
|
return fn(filer_pb.NewSeaweedFilerClient(conn))
|
||||||
|
}, addr, false, fs.grpcDialOption)
|
||||||
|
}
|
||||||
|
func (fs *SftpServer) withTimeoutContext(fn func(ctx context.Context) error) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Command Dispatcher ====================
|
||||||
|
|
||||||
|
func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
|
||||||
|
glog.V(0).Infof("Dispatch: %s %s", r.Method, r.Filepath)
|
||||||
|
switch r.Method {
|
||||||
|
case "Remove":
|
||||||
|
return fs.removeEntry(r)
|
||||||
|
case "Rename":
|
||||||
|
return fs.renameEntry(r)
|
||||||
|
case "Mkdir":
|
||||||
|
return fs.makeDir(r)
|
||||||
|
case "Rmdir":
|
||||||
|
return fs.removeDir(r)
|
||||||
|
case "Setstat":
|
||||||
|
return fs.setFileStat(r)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported: %s", r.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== File Operations ====================
|
||||||
|
|
||||||
|
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
|
||||||
|
if err := fs.checkFilePermission(r.Filepath, "read"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, err := fs.getEntry(r.Filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SeaweedFileReaderAt{fs: fs, entry: entry}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// putFile uploads a file to the filer and sets ownership metadata.
|
||||||
|
func (fs *SftpServer) putFile(filepath string, data []byte, user *user.User) error {
|
||||||
|
dir, filename := util.FullPath(filepath).DirAndName()
|
||||||
|
uploadUrl := fmt.Sprintf("http://%s%s", fs.filerAddr, filepath)
|
||||||
|
|
||||||
|
// Create a reader from our buffered data and calculate MD5 hash
|
||||||
|
hash := md5.New()
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
body := io.TeeReader(reader, hash)
|
||||||
|
fileSize := int64(len(data))
|
||||||
|
|
||||||
|
// Create and execute HTTP request
|
||||||
|
proxyReq, err := http.NewRequest(http.MethodPut, uploadUrl, body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %v", err)
|
||||||
|
}
|
||||||
|
proxyReq.ContentLength = fileSize
|
||||||
|
proxyReq.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(proxyReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upload to filer: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Process response
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result weed_server.FilerPostResult
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return fmt.Errorf("parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error != "" {
|
||||||
|
return fmt.Errorf("filer error: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file ownership using the same pattern as other functions
|
||||||
|
if user != nil {
|
||||||
|
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
// Look up the file to get its current entry
|
||||||
|
lookupResp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Name: filename,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lookup file for attribute update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lookupResp.Entry == nil {
|
||||||
|
return fmt.Errorf("file not found after upload: %s/%s", dir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the entry with new uid/gid
|
||||||
|
entry := lookupResp.Entry
|
||||||
|
entry.Attributes.Uid = user.Uid
|
||||||
|
entry.Attributes.Gid = user.Gid
|
||||||
|
|
||||||
|
// Update the entry in the filer
|
||||||
|
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Entry: entry,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Log the error but don't fail the whole operation
|
||||||
|
glog.Errorf("Failed to update file ownership for %s: %v", filepath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
|
||||||
|
return &filerFileWriter{fs: *fs, req: r, permissions: 0644, uid: fs.user.Uid, gid: fs.user.Gid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) removeEntry(r *sftp.Request) error {
|
||||||
|
return fs.deleteEntry(r.Filepath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) renameEntry(r *sftp.Request) error {
|
||||||
|
if err := fs.checkFilePermission(r.Filepath, "rename"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldDir, oldName := util.FullPath(r.Filepath).DirAndName()
|
||||||
|
newDir, newName := util.FullPath(r.Target).DirAndName()
|
||||||
|
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
|
||||||
|
OldDirectory: oldDir, OldName: oldName,
|
||||||
|
NewDirectory: newDir, NewName: newName,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) setFileStat(r *sftp.Request) error {
|
||||||
|
if err := fs.checkFilePermission(r.Filepath, "write"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry, err := fs.getEntry(r.Filepath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := util.FullPath(r.Filepath).DirAndName()
|
||||||
|
// apply attrs
|
||||||
|
if r.AttrFlags().Permissions {
|
||||||
|
entry.Attributes.FileMode = uint32(r.Attributes().FileMode())
|
||||||
|
}
|
||||||
|
if r.AttrFlags().UidGid {
|
||||||
|
entry.Attributes.Uid = uint32(r.Attributes().UID)
|
||||||
|
entry.Attributes.Gid = uint32(r.Attributes().GID)
|
||||||
|
}
|
||||||
|
if r.AttrFlags().Acmodtime {
|
||||||
|
entry.Attributes.Mtime = int64(r.Attributes().Mtime)
|
||||||
|
}
|
||||||
|
if r.AttrFlags().Size {
|
||||||
|
entry.Attributes.FileSize = uint64(r.Attributes().Size)
|
||||||
|
}
|
||||||
|
return fs.updateEntry(dir, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Directory Operations ====================
|
||||||
|
|
||||||
|
func (fs *SftpServer) listDir(r *sftp.Request) (sftp.ListerAt, error) {
|
||||||
|
if err := fs.checkFilePermission(r.Filepath, "list"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.Method == "Stat" || r.Method == "Lstat" {
|
||||||
|
entry, err := fs.getEntry(r.Filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fi := &EnhancedFileInfo{FileInfo: FileInfoFromEntry(entry), uid: entry.Attributes.Uid, gid: entry.Attributes.Gid}
|
||||||
|
return listerat([]os.FileInfo{fi}), nil
|
||||||
|
}
|
||||||
|
return fs.listAllPages(r.Filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
|
||||||
|
var all []os.FileInfo
|
||||||
|
last := ""
|
||||||
|
for {
|
||||||
|
page, err := fs.fetchDirectoryPage(dirPath, last)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page...)
|
||||||
|
if len(page) < defaultListLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last = page[len(page)-1].Name()
|
||||||
|
}
|
||||||
|
return listerat(all), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, error) {
|
||||||
|
var list []os.FileInfo
|
||||||
|
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
r, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil || r.Entry == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := path.Join(dirPath, r.Entry.Name)
|
||||||
|
if err := fs.checkFilePermission(p, "list"); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, &EnhancedFileInfo{FileInfo: FileInfoFromEntry(r.Entry), uid: r.Entry.Attributes.Uid, gid: r.Entry.Attributes.Gid})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDir creates a new directory with proper permissions.
|
||||||
|
func (fs *SftpServer) makeDir(r *sftp.Request) error {
|
||||||
|
if fs.user == nil {
|
||||||
|
return fmt.Errorf("cannot create directory: no user info")
|
||||||
|
}
|
||||||
|
dir, name := util.FullPath(r.Filepath).DirAndName()
|
||||||
|
if err := fs.checkFilePermission(dir, "mkdir"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// default mode and ownership
|
||||||
|
err := filer_pb.Mkdir(fs, string(dir), name, func(entry *filer_pb.Entry) {
|
||||||
|
mode := uint32(0755 | os.ModeDir)
|
||||||
|
if strings.HasPrefix(r.Filepath, fs.user.HomeDir) {
|
||||||
|
mode = uint32(0700 | os.ModeDir)
|
||||||
|
}
|
||||||
|
entry.Attributes.FileMode = mode
|
||||||
|
entry.Attributes.Uid = fs.user.Uid
|
||||||
|
entry.Attributes.Gid = fs.user.Gid
|
||||||
|
now := time.Now().Unix()
|
||||||
|
entry.Attributes.Crtime = now
|
||||||
|
entry.Attributes.Mtime = now
|
||||||
|
if entry.Extended == nil {
|
||||||
|
entry.Extended = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
entry.Extended["creator"] = []byte(fs.user.Username)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeDir deletes a directory.
|
||||||
|
func (fs *SftpServer) removeDir(r *sftp.Request) error {
|
||||||
|
return fs.deleteEntry(r.Filepath, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Common Arguments Helpers ====================
|
||||||
|
|
||||||
|
func FileInfoFromEntry(e *filer_pb.Entry) FileInfo {
|
||||||
|
return FileInfo{name: e.Name, size: int64(e.Attributes.FileSize), mode: os.FileMode(e.Attributes.FileMode), modTime: time.Unix(e.Attributes.Mtime, 0), isDir: e.IsDirectory}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) deleteEntry(p string, recursive bool) error {
|
||||||
|
if err := fs.checkFilePermission(p, "delete"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, name := util.FullPath(p).DirAndName()
|
||||||
|
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
|
||||||
|
r, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{Directory: dir, Name: name, IsDeleteData: true, IsRecursive: recursive})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Error != "" {
|
||||||
|
return fmt.Errorf("%s", r.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Custom Types ====================
|
||||||
|
|
||||||
|
type EnhancedFileInfo struct {
|
||||||
|
FileInfo
|
||||||
|
uid uint32
|
||||||
|
gid uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *EnhancedFileInfo) Sys() interface{} {
|
||||||
|
return &syscall.Stat_t{Uid: fi.uid, Gid: fi.gid}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *EnhancedFileInfo) Owner() (uid, gid int) {
|
||||||
|
return int(fi.uid), int(fi.gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeaweedFileReaderAt implements io.ReaderAt for SeaweedFS files
|
||||||
|
|
||||||
|
type SeaweedFileReaderAt struct {
|
||||||
|
fs *SftpServer
|
||||||
|
entry *filer_pb.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ra *SeaweedFileReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
|
// Create a new reader for each ReadAt call
|
||||||
|
reader := filer.NewFileReader(ra.fs, ra.entry)
|
||||||
|
if reader == nil {
|
||||||
|
return 0, fmt.Errorf("failed to create file reader")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're reading past the end of the file
|
||||||
|
fileSize := int64(ra.entry.Attributes.FileSize)
|
||||||
|
if off >= fileSize {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to the offset
|
||||||
|
if seeker, ok := reader.(io.Seeker); ok {
|
||||||
|
_, err = seeker.Seek(off, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("seek error: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the reader doesn't implement Seek, we need to read and discard bytes
|
||||||
|
toSkip := off
|
||||||
|
skipBuf := make([]byte, 8192)
|
||||||
|
for toSkip > 0 {
|
||||||
|
skipSize := int64(len(skipBuf))
|
||||||
|
if skipSize > toSkip {
|
||||||
|
skipSize = toSkip
|
||||||
|
}
|
||||||
|
read, err := reader.Read(skipBuf[:skipSize])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("skip error: %v", err)
|
||||||
|
}
|
||||||
|
if read == 0 {
|
||||||
|
return 0, fmt.Errorf("unable to skip to offset %d", off)
|
||||||
|
}
|
||||||
|
toSkip -= int64(read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust read length if it would go past EOF
|
||||||
|
readLen := len(p)
|
||||||
|
remaining := fileSize - off
|
||||||
|
if int64(readLen) > remaining {
|
||||||
|
readLen = int(remaining)
|
||||||
|
if readLen == 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the data
|
||||||
|
n, err = io.ReadFull(reader, p[:readLen])
|
||||||
|
|
||||||
|
// Handle EOF correctly
|
||||||
|
if err == io.ErrUnexpectedEOF || (err == nil && n < len(p)) {
|
||||||
|
err = io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *SftpServer) checkFilePermission(filepath string, permissions string) error {
|
||||||
|
return fs.authManager.CheckPermission(fs.user, filepath, permissions)
|
||||||
|
}
|
||||||
126
weed/sftpd/sftp_helpers.go
Normal file
126
weed/sftpd/sftp_helpers.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// sftp_helpers.go
|
||||||
|
package sftpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileInfo implements os.FileInfo.
|
||||||
|
type FileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *FileInfo) Name() string { return fi.name }
|
||||||
|
func (fi *FileInfo) Size() int64 { return fi.size }
|
||||||
|
func (fi *FileInfo) Mode() os.FileMode { return fi.mode }
|
||||||
|
func (fi *FileInfo) ModTime() time.Time { return fi.modTime }
|
||||||
|
func (fi *FileInfo) IsDir() bool { return fi.isDir }
|
||||||
|
func (fi *FileInfo) Sys() interface{} { return nil }
|
||||||
|
|
||||||
|
// bufferReader wraps a byte slice to io.ReaderAt.
|
||||||
|
type bufferReader struct {
|
||||||
|
b []byte
|
||||||
|
i int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBufferReader(b []byte) *bufferReader { return &bufferReader{b: b} }
|
||||||
|
|
||||||
|
func (r *bufferReader) Read(p []byte) (int, error) {
|
||||||
|
if r.i >= int64(len(r.b)) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(p, r.b[r.i:])
|
||||||
|
r.i += int64(n)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bufferReader) ReadAt(p []byte, off int64) (int, error) {
|
||||||
|
if off >= int64(len(r.b)) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(p, r.b[off:])
|
||||||
|
if n < len(p) {
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listerat implements sftp.ListerAt.
|
||||||
|
type listerat []os.FileInfo
|
||||||
|
|
||||||
|
func (l listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
|
||||||
|
if offset >= int64(len(l)) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(ls, l[offset:])
|
||||||
|
if n < len(ls) {
|
||||||
|
return n, io.EOF
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filerFileWriter buffers writes and flushes on Close.
|
||||||
|
type filerFileWriter struct {
|
||||||
|
fs SftpServer
|
||||||
|
req *sftp.Request
|
||||||
|
mu sync.Mutex
|
||||||
|
data []byte
|
||||||
|
permissions os.FileMode
|
||||||
|
uid uint32
|
||||||
|
gid uint32
|
||||||
|
offset int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *filerFileWriter) Write(p []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
end := w.offset + int64(len(p))
|
||||||
|
if end > int64(len(w.data)) {
|
||||||
|
newBuf := make([]byte, end)
|
||||||
|
copy(newBuf, w.data)
|
||||||
|
w.data = newBuf
|
||||||
|
}
|
||||||
|
n := copy(w.data[w.offset:], p)
|
||||||
|
w.offset += int64(n)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *filerFileWriter) WriteAt(p []byte, off int64) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
end := int(off) + len(p)
|
||||||
|
if end > len(w.data) {
|
||||||
|
newBuf := make([]byte, end)
|
||||||
|
copy(newBuf, w.data)
|
||||||
|
w.data = newBuf
|
||||||
|
}
|
||||||
|
n := copy(w.data[off:], p)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *filerFileWriter) Close() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
dir, _ := util.FullPath(w.req.Filepath).DirAndName()
|
||||||
|
|
||||||
|
// Check permissions based on file metadata and user permissions
|
||||||
|
if err := w.fs.checkFilePermission(dir, "write"); err != nil {
|
||||||
|
glog.Errorf("Permission denied for %s", dir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the extracted putFile method on SftpServer
|
||||||
|
return w.fs.putFile(w.req.Filepath, w.data, w.fs.user)
|
||||||
|
}
|
||||||
59
weed/sftpd/sftp_server.go
Normal file
59
weed/sftpd/sftp_server.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// sftp_server.go
|
||||||
|
package sftpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SftpServer struct {
|
||||||
|
filerAddr pb.ServerAddress
|
||||||
|
grpcDialOption grpc.DialOption
|
||||||
|
dataCenter string
|
||||||
|
filerGroup string
|
||||||
|
user *user.User
|
||||||
|
authManager *auth.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSftpServer constructs the server.
|
||||||
|
func NewSftpServer(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string, user *user.User) SftpServer {
|
||||||
|
// Create a file system helper for the auth manager
|
||||||
|
fsHelper := NewFileSystemHelper(filerAddr, grpcDialOption, dataCenter, filerGroup)
|
||||||
|
|
||||||
|
// Create an auth manager for permission checking
|
||||||
|
authManager := auth.NewManager(nil, fsHelper, []string{})
|
||||||
|
|
||||||
|
return SftpServer{
|
||||||
|
filerAddr: filerAddr,
|
||||||
|
grpcDialOption: grpcDialOption,
|
||||||
|
dataCenter: dataCenter,
|
||||||
|
filerGroup: filerGroup,
|
||||||
|
user: user,
|
||||||
|
authManager: authManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fileread is invoked for “get” requests.
|
||||||
|
func (fs *SftpServer) Fileread(req *sftp.Request) (io.ReaderAt, error) {
|
||||||
|
return fs.readFile(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filewrite is invoked for “put” requests.
|
||||||
|
func (fs *SftpServer) Filewrite(req *sftp.Request) (io.WriterAt, error) {
|
||||||
|
return fs.newFileWriter(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filecmd handles Remove, Rename, Mkdir, Rmdir, etc.
|
||||||
|
func (fs *SftpServer) Filecmd(req *sftp.Request) error {
|
||||||
|
return fs.dispatchCmd(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filelist handles directory listings.
|
||||||
|
func (fs *SftpServer) Filelist(req *sftp.Request) (sftp.ListerAt, error) {
|
||||||
|
return fs.listDir(req)
|
||||||
|
}
|
||||||
394
weed/sftpd/sftp_service.go
Normal file
394
weed/sftpd/sftp_service.go
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
// sftp_service.go
|
||||||
|
package sftpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||||
|
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SFTPService holds configuration for the SFTP service.
|
||||||
|
type SFTPService struct {
|
||||||
|
options SFTPServiceOptions
|
||||||
|
userStore user.Store
|
||||||
|
authManager *auth.Manager
|
||||||
|
homeManager *user.HomeManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFTPServiceOptions contains all configuration options for the SFTP service.
|
||||||
|
type SFTPServiceOptions struct {
|
||||||
|
GrpcDialOption grpc.DialOption
|
||||||
|
DataCenter string
|
||||||
|
FilerGroup string
|
||||||
|
Filer pb.ServerAddress
|
||||||
|
|
||||||
|
// SSH Configuration
|
||||||
|
SshPrivateKey string // Legacy single host key
|
||||||
|
HostKeysFolder string // Multiple host keys for different algorithms
|
||||||
|
AuthMethods []string // Enabled auth methods: "password", "publickey", "keyboard-interactive"
|
||||||
|
MaxAuthTries int // Limit authentication attempts
|
||||||
|
BannerMessage string // Pre-auth banner message
|
||||||
|
LoginGraceTime time.Duration // Timeout for authentication
|
||||||
|
|
||||||
|
// Connection Management
|
||||||
|
ClientAliveInterval time.Duration // Keep-alive check interval
|
||||||
|
ClientAliveCountMax int // Max missed keep-alives before disconnect
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
UserStoreFile string // Path to user store file
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSFTPService creates a new service instance.
|
||||||
|
func NewSFTPService(options *SFTPServiceOptions) *SFTPService {
|
||||||
|
service := SFTPService{options: *options}
|
||||||
|
|
||||||
|
// Initialize user store
|
||||||
|
userStore, err := user.NewFileStore(options.UserStoreFile)
|
||||||
|
if err != nil {
|
||||||
|
glog.Fatalf("Failed to initialize user store: %v", err)
|
||||||
|
}
|
||||||
|
service.userStore = userStore
|
||||||
|
|
||||||
|
// Initialize file system helper for permission checking
|
||||||
|
fsHelper := NewFileSystemHelper(
|
||||||
|
options.Filer,
|
||||||
|
options.GrpcDialOption,
|
||||||
|
options.DataCenter,
|
||||||
|
options.FilerGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize auth manager
|
||||||
|
service.authManager = auth.NewManager(userStore, fsHelper, options.AuthMethods)
|
||||||
|
|
||||||
|
// Initialize home directory manager
|
||||||
|
service.homeManager = user.NewHomeManager(fsHelper)
|
||||||
|
|
||||||
|
return &service
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSystemHelper implements auth.FileSystemHelper interface
|
||||||
|
type FileSystemHelper struct {
|
||||||
|
filerAddr pb.ServerAddress
|
||||||
|
grpcDialOption grpc.DialOption
|
||||||
|
dataCenter string
|
||||||
|
filerGroup string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileSystemHelper(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string) *FileSystemHelper {
|
||||||
|
return &FileSystemHelper{
|
||||||
|
filerAddr: filerAddr,
|
||||||
|
grpcDialOption: grpcDialOption,
|
||||||
|
dataCenter: dataCenter,
|
||||||
|
filerGroup: filerGroup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEntry implements auth.FileSystemHelper interface
|
||||||
|
func (fs *FileSystemHelper) GetEntry(path string) (*auth.Entry, error) {
|
||||||
|
dir, name := util.FullPath(path).DirAndName()
|
||||||
|
var entry *filer_pb.Entry
|
||||||
|
|
||||||
|
err := fs.withTimeoutContext(func(ctx context.Context) error {
|
||||||
|
return fs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||||
|
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Entry == nil {
|
||||||
|
return fmt.Errorf("entry not found")
|
||||||
|
}
|
||||||
|
entry = resp.Entry
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &auth.Entry{
|
||||||
|
IsDirectory: entry.IsDirectory,
|
||||||
|
Attributes: &auth.EntryAttributes{
|
||||||
|
Uid: entry.Attributes.GetUid(),
|
||||||
|
Gid: entry.Attributes.GetGid(),
|
||||||
|
FileMode: entry.Attributes.GetFileMode(),
|
||||||
|
SymlinkTarget: entry.Attributes.GetSymlinkTarget(),
|
||||||
|
},
|
||||||
|
IsSymlink: entry.Attributes.GetSymlinkTarget() != "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement FilerClient interface for FileSystemHelper
|
||||||
|
func (fs *FileSystemHelper) AdjustedUrl(location *filer_pb.Location) string {
|
||||||
|
return location.Url
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileSystemHelper) GetDataCenter() string {
|
||||||
|
return fs.dataCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileSystemHelper) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||||
|
addr := fs.filerAddr.ToGrpcAddress()
|
||||||
|
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
|
||||||
|
return fn(filer_pb.NewSeaweedFilerClient(conn))
|
||||||
|
}, addr, false, fs.grpcDialOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileSystemHelper) withTimeoutContext(fn func(ctx context.Context) error) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve accepts incoming connections on the provided listener and handles them.
|
||||||
|
func (s *SFTPService) Serve(listener net.Listener) error {
|
||||||
|
// Build SSH server config
|
||||||
|
sshConfig, err := s.buildSSHConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create SSH config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
glog.V(0).Infof("Starting Seaweed SFTP service on %s", listener.Addr().String())
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to accept incoming connection: %v", err)
|
||||||
|
}
|
||||||
|
go s.handleSSHConnection(conn, sshConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSSHConfig creates the SSH server configuration with proper authentication.
|
||||||
|
func (s *SFTPService) buildSSHConfig() (*ssh.ServerConfig, error) {
|
||||||
|
// Get base config from auth manager
|
||||||
|
config := s.authManager.GetSSHServerConfig()
|
||||||
|
|
||||||
|
// Set additional options
|
||||||
|
config.MaxAuthTries = s.options.MaxAuthTries
|
||||||
|
config.BannerCallback = func(conn ssh.ConnMetadata) string {
|
||||||
|
return s.options.BannerMessage
|
||||||
|
}
|
||||||
|
config.ServerVersion = "SSH-2.0-SeaweedFS-SFTP" // Custom server version
|
||||||
|
|
||||||
|
hostKeysAdded := 0
|
||||||
|
// Add legacy host key if specified
|
||||||
|
if s.options.SshPrivateKey != "" {
|
||||||
|
if err := s.addHostKey(config, s.options.SshPrivateKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hostKeysAdded++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all host keys from the specified folder
|
||||||
|
if s.options.HostKeysFolder != "" {
|
||||||
|
files, err := os.ReadDir(s.options.HostKeysFolder)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read host keys folder: %v", err)
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue // Skip directories
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPath := filepath.Join(s.options.HostKeysFolder, file.Name())
|
||||||
|
if err := s.addHostKey(config, keyPath); err != nil {
|
||||||
|
// Log the error but continue with other keys
|
||||||
|
log.Printf("Warning: failed to add host key %s: %v", keyPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostKeysAdded++
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostKeysAdded == 0 {
|
||||||
|
log.Printf("Warning: no valid host keys found in folder %s", s.options.HostKeysFolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have at least one host key
|
||||||
|
if hostKeysAdded == 0 {
|
||||||
|
return nil, fmt.Errorf("no host keys provided")
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addHostKey adds a host key to the SSH server configuration.
|
||||||
|
func (s *SFTPService) addHostKey(config *ssh.ServerConfig, keyPath string) error {
|
||||||
|
keyBytes, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read host key %s: %v", keyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as private key
|
||||||
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
// Try parsing with passphrase if available
|
||||||
|
if passphraseErr, ok := err.(*ssh.PassphraseMissingError); ok {
|
||||||
|
return fmt.Errorf("host key %s requires passphrase: %v", keyPath, passphraseErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to parse host key %s: %v", keyPath, err)
|
||||||
|
}
|
||||||
|
config.AddHostKey(signer)
|
||||||
|
glog.V(0).Infof("Added host key %s (%s)", keyPath, signer.PublicKey().Type())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSSHConnection handles an incoming SSH connection.
|
||||||
|
func (s *SFTPService) handleSSHConnection(conn net.Conn, config *ssh.ServerConfig) {
|
||||||
|
// Set connection deadline for handshake
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(s.options.LoginGraceTime))
|
||||||
|
|
||||||
|
// Perform SSH handshake
|
||||||
|
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
|
||||||
|
if err != nil {
|
||||||
|
glog.Errorf("Failed to handshake: %v", err)
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear deadline after successful handshake
|
||||||
|
_ = conn.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Set up connection monitoring
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start keep-alive monitoring
|
||||||
|
go s.monitorConnection(ctx, sshConn)
|
||||||
|
|
||||||
|
username := sshConn.Permissions.Extensions["username"]
|
||||||
|
glog.V(0).Infof("New SSH connection from %s (%s) as user %s",
|
||||||
|
sshConn.RemoteAddr(), sshConn.ClientVersion(), username)
|
||||||
|
|
||||||
|
// Get user from store
|
||||||
|
sftpUser, err := s.authManager.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
glog.Errorf("Failed to retrieve user %s: %v", username, err)
|
||||||
|
sshConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user-specific filesystem
|
||||||
|
userFs := NewSftpServer(
|
||||||
|
s.options.Filer,
|
||||||
|
s.options.GrpcDialOption,
|
||||||
|
s.options.DataCenter,
|
||||||
|
s.options.FilerGroup,
|
||||||
|
sftpUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure home directory exists with proper permissions
|
||||||
|
if err := s.homeManager.EnsureHomeDirectory(sftpUser); err != nil {
|
||||||
|
glog.Errorf("Failed to ensure home directory for user %s: %v", username, err)
|
||||||
|
// We don't close the connection here, as the user might still be able to access other directories
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSH requests and channels
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
for newChannel := range chans {
|
||||||
|
go s.handleChannel(newChannel, &userFs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorConnection monitors an SSH connection with keep-alives.
|
||||||
|
func (s *SFTPService) monitorConnection(ctx context.Context, sshConn *ssh.ServerConn) {
|
||||||
|
if s.options.ClientAliveInterval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.options.ClientAliveInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
missedCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Send keep-alive request
|
||||||
|
_, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil)
|
||||||
|
if err != nil {
|
||||||
|
missedCount++
|
||||||
|
glog.V(0).Infof("Keep-alive missed for %s: %v (%d/%d)",
|
||||||
|
sshConn.RemoteAddr(), err, missedCount, s.options.ClientAliveCountMax)
|
||||||
|
|
||||||
|
if missedCount >= s.options.ClientAliveCountMax {
|
||||||
|
glog.Warningf("Closing unresponsive connection from %s", sshConn.RemoteAddr())
|
||||||
|
sshConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missedCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChannel handles a single SSH channel.
|
||||||
|
func (s *SFTPService) handleChannel(newChannel ssh.NewChannel, fs *SftpServer) {
|
||||||
|
if newChannel.ChannelType() != "session" {
|
||||||
|
_ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
channel, requests, err := newChannel.Accept()
|
||||||
|
if err != nil {
|
||||||
|
glog.Errorf("Could not accept channel: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(in <-chan *ssh.Request) {
|
||||||
|
for req := range in {
|
||||||
|
switch req.Type {
|
||||||
|
case "subsystem":
|
||||||
|
// Check that the subsystem is "sftp".
|
||||||
|
if string(req.Payload[4:]) == "sftp" {
|
||||||
|
_ = req.Reply(true, nil)
|
||||||
|
s.handleSFTP(channel, fs)
|
||||||
|
} else {
|
||||||
|
_ = req.Reply(false, nil)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
_ = req.Reply(false, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSFTP starts the SFTP server on the SSH channel.
|
||||||
|
func (s *SFTPService) handleSFTP(channel ssh.Channel, fs *SftpServer) {
|
||||||
|
// Create server options with initial working directory set to user's home
|
||||||
|
serverOptions := sftp.WithStartDirectory(fs.user.HomeDir)
|
||||||
|
server := sftp.NewRequestServer(channel, sftp.Handlers{
|
||||||
|
FileGet: fs,
|
||||||
|
FilePut: fs,
|
||||||
|
FileCmd: fs,
|
||||||
|
FileList: fs,
|
||||||
|
}, serverOptions)
|
||||||
|
|
||||||
|
if err := server.Serve(); err == io.EOF {
|
||||||
|
server.Close()
|
||||||
|
glog.V(0).Info("SFTP client exited session.")
|
||||||
|
} else if err != nil {
|
||||||
|
glog.Errorf("SFTP server finished with error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
weed/sftpd/sftp_userstore.go
Normal file
143
weed/sftpd/sftp_userstore.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package sftpd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserStore interface for user management.
|
||||||
|
type UserStore interface {
|
||||||
|
GetUser(username string) (*User, error)
|
||||||
|
ValidatePassword(username string, password []byte) bool
|
||||||
|
ValidatePublicKey(username string, keyData string) bool
|
||||||
|
GetUserPermissions(username string, path string) []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// User represents an SFTP user with authentication and permission details.
|
||||||
|
type User struct {
|
||||||
|
Username string
|
||||||
|
Password string // Plaintext password
|
||||||
|
PublicKeys []string // Authorized public keys
|
||||||
|
HomeDir string // User's home directory
|
||||||
|
Permissions map[string][]string // path -> permissions (read, write, list, etc.)
|
||||||
|
Uid uint32 // User ID for file ownership
|
||||||
|
Gid uint32 // Group ID for file ownership
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileUserStore implements UserStore using a JSON file.
|
||||||
|
type FileUserStore struct {
|
||||||
|
filePath string
|
||||||
|
users map[string]*User
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileUserStore creates a new user store from a JSON file.
|
||||||
|
func NewFileUserStore(filePath string) (*FileUserStore, error) {
|
||||||
|
store := &FileUserStore{
|
||||||
|
filePath: filePath,
|
||||||
|
users: make(map[string]*User),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.loadUsers(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUsers loads users from the JSON file.
|
||||||
|
func (s *FileUserStore) loadUsers() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if _, err := os.Stat(s.filePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("user store file not found: %s", s.filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read user store file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []*User
|
||||||
|
if err := json.Unmarshal(data, &users); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse user store file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
s.users[user.Username] = user
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by username.
|
||||||
|
func (s *FileUserStore) GetUser(username string) (*User, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
user, ok := s.users[username]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("user not found: %s", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword checks if the password is valid for the user.
|
||||||
|
func (s *FileUserStore) ValidatePassword(username string, password []byte) bool {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare plaintext password using constant time comparison for security
|
||||||
|
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePublicKey checks if the public key is valid for the user.
|
||||||
|
func (s *FileUserStore) ValidatePublicKey(username string, keyData string) bool {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range user.PublicKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserPermissions returns the permissions for a user on a path.
|
||||||
|
func (s *FileUserStore) GetUserPermissions(username string, path string) []string {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exact path match first
|
||||||
|
if perms, ok := user.Permissions[path]; ok {
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parent directories
|
||||||
|
var bestMatch string
|
||||||
|
var bestPerms []string
|
||||||
|
|
||||||
|
for p, perms := range user.Permissions {
|
||||||
|
if strings.HasPrefix(path, p) && len(p) > len(bestMatch) {
|
||||||
|
bestMatch = p
|
||||||
|
bestPerms = perms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPerms
|
||||||
|
}
|
||||||
228
weed/sftpd/user/filestore.go
Normal file
228
weed/sftpd/user/filestore.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileStore implements Store using a JSON file
|
||||||
|
type FileStore struct {
|
||||||
|
filePath string
|
||||||
|
users map[string]*User
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileStore creates a new user store from a JSON file
|
||||||
|
func NewFileStore(filePath string) (*FileStore, error) {
|
||||||
|
store := &FileStore{
|
||||||
|
filePath: filePath,
|
||||||
|
users: make(map[string]*User),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the file if it doesn't exist
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
// Create an empty users array
|
||||||
|
if err := os.WriteFile(filePath, []byte("[]"), 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user store file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.loadUsers(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadUsers loads users from the JSON file
|
||||||
|
func (s *FileStore) loadUsers() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(s.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read user store file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []*User
|
||||||
|
if err := json.Unmarshal(data, &users); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse user store file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing users and add the loaded ones
|
||||||
|
s.users = make(map[string]*User)
|
||||||
|
for _, user := range users {
|
||||||
|
// Process public keys to ensure they're in the correct format
|
||||||
|
for i, keyData := range user.PublicKeys {
|
||||||
|
// Try to parse the key as an authorized key format
|
||||||
|
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData))
|
||||||
|
if err == nil {
|
||||||
|
// If successful, store the marshaled binary format
|
||||||
|
user.PublicKeys[i] = string(pubKey.Marshal())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.users[user.Username] = user
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveUsers saves users to the JSON file
|
||||||
|
func (s *FileStore) saveUsers() error {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Convert map to slice for JSON serialization
|
||||||
|
var users []*User
|
||||||
|
for _, user := range s.users {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(users, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to serialize users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(s.filePath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write user store file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by username
|
||||||
|
func (s *FileStore) GetUser(username string) (*User, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
user, ok := s.users[username]
|
||||||
|
if !ok {
|
||||||
|
return nil, &UserNotFoundError{Username: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePassword checks if the password is valid for the user
|
||||||
|
func (s *FileStore) ValidatePassword(username string, password []byte) bool {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare plaintext password using constant time comparison for security
|
||||||
|
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePublicKey checks if the public key is valid for the user
|
||||||
|
func (s *FileStore) ValidatePublicKey(username string, keyData string) bool {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range user.PublicKeys {
|
||||||
|
if key == keyData {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserPermissions returns the permissions for a user on a path
|
||||||
|
func (s *FileStore) GetUserPermissions(username string, path string) []string {
|
||||||
|
user, err := s.GetUser(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exact path match first
|
||||||
|
if perms, ok := user.Permissions[path]; ok {
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parent directories
|
||||||
|
var bestMatch string
|
||||||
|
var bestPerms []string
|
||||||
|
|
||||||
|
for p, perms := range user.Permissions {
|
||||||
|
if len(p) > len(bestMatch) && os.IsPathSeparator(p[len(p)-1]) && path[:len(p)] == p {
|
||||||
|
bestMatch = p
|
||||||
|
bestPerms = perms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPerms
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUser saves or updates a user
|
||||||
|
func (s *FileStore) SaveUser(user *User) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.users[user.Username] = user
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return s.saveUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user
|
||||||
|
func (s *FileStore) DeleteUser(username string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
_, exists := s.users[username]
|
||||||
|
if !exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return &UserNotFoundError{Username: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.users, username)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return s.saveUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns all usernames
|
||||||
|
func (s *FileStore) ListUsers() ([]string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
usernames := make([]string, 0, len(s.users))
|
||||||
|
for username := range s.users {
|
||||||
|
usernames = append(usernames, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return usernames, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user with the given username and password
|
||||||
|
func (s *FileStore) CreateUser(username, password string) (*User, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if _, exists := s.users[username]; exists {
|
||||||
|
return nil, fmt.Errorf("user already exists: %s", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
user := NewUser(username)
|
||||||
|
|
||||||
|
// Store plaintext password
|
||||||
|
user.Password = password
|
||||||
|
|
||||||
|
// Add default permissions
|
||||||
|
user.Permissions[user.HomeDir] = []string{"all"}
|
||||||
|
|
||||||
|
// Save the user
|
||||||
|
s.users[username] = user
|
||||||
|
if err := s.saveUsers(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
204
weed/sftpd/user/homemanager.go
Normal file
204
weed/sftpd/user/homemanager.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||||
|
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HomeManager handles user home directory operations
|
||||||
|
type HomeManager struct {
|
||||||
|
filerClient FilerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilerClient defines the interface for interacting with the filer
|
||||||
|
type FilerClient interface {
|
||||||
|
WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error
|
||||||
|
GetDataCenter() string
|
||||||
|
AdjustedUrl(location *filer_pb.Location) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHomeManager creates a new home directory manager
|
||||||
|
func NewHomeManager(filerClient FilerClient) *HomeManager {
|
||||||
|
return &HomeManager{
|
||||||
|
filerClient: filerClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureHomeDirectory creates the user's home directory if it doesn't exist
|
||||||
|
func (hm *HomeManager) EnsureHomeDirectory(user *User) error {
|
||||||
|
if user.HomeDir == "" {
|
||||||
|
return fmt.Errorf("user has no home directory configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
glog.V(0).Infof("Ensuring home directory exists for user %s: %s", user.Username, user.HomeDir)
|
||||||
|
|
||||||
|
// Check if home directory exists and create it if needed
|
||||||
|
err := hm.createDirectoryIfNotExists(user.HomeDir, user)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure home directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user permissions map to include the home directory with full access if not already present
|
||||||
|
if user.Permissions == nil {
|
||||||
|
user.Permissions = make(map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add permissions if not already present
|
||||||
|
if _, exists := user.Permissions[user.HomeDir]; !exists {
|
||||||
|
user.Permissions[user.HomeDir] = []string{"all"}
|
||||||
|
glog.V(0).Infof("Added full permissions for user %s to home directory %s",
|
||||||
|
user.Username, user.HomeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDirectoryIfNotExists creates a directory path if it doesn't exist
|
||||||
|
func (hm *HomeManager) createDirectoryIfNotExists(dirPath string, user *User) error {
|
||||||
|
// Split the path into components
|
||||||
|
components := strings.Split(strings.Trim(dirPath, "/"), "/")
|
||||||
|
currentPath := "/"
|
||||||
|
|
||||||
|
for _, component := range components {
|
||||||
|
if component == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPath := filepath.Join(currentPath, component)
|
||||||
|
err := hm.createSingleDirectory(nextPath, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath = nextPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSingleDirectory creates a single directory if it doesn't exist
|
||||||
|
func (hm *HomeManager) createSingleDirectory(dirPath string, user *User) error {
|
||||||
|
var dirExists bool
|
||||||
|
|
||||||
|
err := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dir, name := util.FullPath(dirPath).DirAndName()
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || resp.Entry == nil {
|
||||||
|
// Directory doesn't exist, create it
|
||||||
|
glog.V(0).Infof("Creating directory %s for user %s", dirPath, user.Username)
|
||||||
|
|
||||||
|
err = filer_pb.Mkdir(hm, string(dir), name, func(entry *filer_pb.Entry) {
|
||||||
|
// Set appropriate permissions
|
||||||
|
entry.Attributes.FileMode = uint32(0700 | os.ModeDir) // rwx------ for user
|
||||||
|
entry.Attributes.Uid = user.Uid
|
||||||
|
entry.Attributes.Gid = user.Gid
|
||||||
|
|
||||||
|
// Set creation and modification times
|
||||||
|
now := time.Now().Unix()
|
||||||
|
entry.Attributes.Crtime = now
|
||||||
|
entry.Attributes.Mtime = now
|
||||||
|
|
||||||
|
// Add extended attributes
|
||||||
|
if entry.Extended == nil {
|
||||||
|
entry.Extended = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
entry.Extended["creator"] = []byte(user.Username)
|
||||||
|
entry.Extended["auto_created"] = []byte("true")
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %v", dirPath, err)
|
||||||
|
}
|
||||||
|
} else if !resp.Entry.IsDirectory {
|
||||||
|
return fmt.Errorf("path %s exists but is not a directory", dirPath)
|
||||||
|
} else {
|
||||||
|
dirExists = true
|
||||||
|
|
||||||
|
// Update ownership if needed
|
||||||
|
if resp.Entry.Attributes.Uid != user.Uid || resp.Entry.Attributes.Gid != user.Gid {
|
||||||
|
glog.V(0).Infof("Updating ownership of directory %s for user %s", dirPath, user.Username)
|
||||||
|
|
||||||
|
entry := resp.Entry
|
||||||
|
entry.Attributes.Uid = user.Uid
|
||||||
|
entry.Attributes.Gid = user.Gid
|
||||||
|
|
||||||
|
_, updateErr := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Entry: entry,
|
||||||
|
})
|
||||||
|
|
||||||
|
if updateErr != nil {
|
||||||
|
glog.Warningf("Failed to update directory ownership: %v", updateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dirExists {
|
||||||
|
// Verify the directory was created
|
||||||
|
verifyErr := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dir, name := util.FullPath(dirPath).DirAndName()
|
||||||
|
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||||
|
Directory: dir,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || resp.Entry == nil {
|
||||||
|
return fmt.Errorf("directory not found after creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Entry.IsDirectory {
|
||||||
|
return fmt.Errorf("path exists but is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
dirExists = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if verifyErr != nil {
|
||||||
|
return fmt.Errorf("failed to verify directory creation: %v", verifyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement necessary methods to satisfy the filer_pb.FilerClient interface
|
||||||
|
func (hm *HomeManager) AdjustedUrl(location *filer_pb.Location) string {
|
||||||
|
return hm.filerClient.AdjustedUrl(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hm *HomeManager) GetDataCenter() string {
|
||||||
|
return hm.filerClient.GetDataCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFilerClient delegates to the underlying filer client
|
||||||
|
func (hm *HomeManager) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||||
|
return hm.filerClient.WithFilerClient(streamingMode, fn)
|
||||||
|
}
|
||||||
111
weed/sftpd/user/user.go
Normal file
111
weed/sftpd/user/user.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Package user provides user management functionality for the SFTP server
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents an SFTP user with authentication and permission details
|
||||||
|
type User struct {
|
||||||
|
Username string // Username for authentication
|
||||||
|
Password string // Plaintext password
|
||||||
|
PublicKeys []string // Authorized public keys
|
||||||
|
HomeDir string // User's home directory
|
||||||
|
Permissions map[string][]string // path -> permissions (read, write, list, etc.)
|
||||||
|
Uid uint32 // User ID for file ownership
|
||||||
|
Gid uint32 // Group ID for file ownership
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defines the interface for user storage and retrieval
|
||||||
|
type Store interface {
|
||||||
|
// GetUser retrieves a user by username
|
||||||
|
GetUser(username string) (*User, error)
|
||||||
|
|
||||||
|
// ValidatePassword checks if the password is valid for the user
|
||||||
|
ValidatePassword(username string, password []byte) bool
|
||||||
|
|
||||||
|
// ValidatePublicKey checks if the public key is valid for the user
|
||||||
|
ValidatePublicKey(username string, keyData string) bool
|
||||||
|
|
||||||
|
// GetUserPermissions returns the permissions for a user on a path
|
||||||
|
GetUserPermissions(username string, path string) []string
|
||||||
|
|
||||||
|
// SaveUser saves or updates a user
|
||||||
|
SaveUser(user *User) error
|
||||||
|
|
||||||
|
// DeleteUser removes a user
|
||||||
|
DeleteUser(username string) error
|
||||||
|
|
||||||
|
// ListUsers returns all usernames
|
||||||
|
ListUsers() ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserNotFoundError is returned when a user is not found
|
||||||
|
type UserNotFoundError struct {
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UserNotFoundError) Error() string {
|
||||||
|
return fmt.Sprintf("user not found: %s", e.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUser creates a new user with default settings
|
||||||
|
func NewUser(username string) *User {
|
||||||
|
// Generate a random UID/GID between 1000 and 60000
|
||||||
|
// This range is typically safe for regular users in most systems
|
||||||
|
// 0-999 are often reserved for system users
|
||||||
|
randomId := 1000 + rand.Intn(59000)
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
Username: username,
|
||||||
|
Permissions: make(map[string][]string),
|
||||||
|
HomeDir: filepath.Join("/home", username),
|
||||||
|
Uid: uint32(randomId),
|
||||||
|
Gid: uint32(randomId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword sets a plaintext password for the user
|
||||||
|
func (u *User) SetPassword(password string) {
|
||||||
|
u.Password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPublicKey adds a public key to the user
|
||||||
|
func (u *User) AddPublicKey(key string) {
|
||||||
|
// Check if key already exists
|
||||||
|
for _, existingKey := range u.PublicKeys {
|
||||||
|
if existingKey == key {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.PublicKeys = append(u.PublicKeys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePublicKey removes a public key from the user
|
||||||
|
func (u *User) RemovePublicKey(key string) bool {
|
||||||
|
for i, existingKey := range u.PublicKeys {
|
||||||
|
if existingKey == key {
|
||||||
|
// Remove the key by replacing it with the last element and truncating
|
||||||
|
u.PublicKeys[i] = u.PublicKeys[len(u.PublicKeys)-1]
|
||||||
|
u.PublicKeys = u.PublicKeys[:len(u.PublicKeys)-1]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermission sets permissions for a specific path
|
||||||
|
func (u *User) SetPermission(path string, permissions []string) {
|
||||||
|
u.Permissions[path] = permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePermission removes permissions for a specific path
|
||||||
|
func (u *User) RemovePermission(path string) bool {
|
||||||
|
if _, exists := u.Permissions[path]; exists {
|
||||||
|
delete(u.Permissions, path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user