aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMohamed Sekour <mohamed.sekour@exfo.com>2025-05-05 20:43:49 +0200
committerGitHub <noreply@github.com>2025-05-05 11:43:49 -0700
commit93aed187e94dcaebf8e8f60cc5f180b49289649f (patch)
tree7cc3796e3fb47487717539caefdcf2e34968047c
parenta2c5510ae1a17446c23d80eddb29d06df1fd0bbe (diff)
downloadseaweedfs-93aed187e94dcaebf8e8f60cc5f180b49289649f.tar.xz
seaweedfs-93aed187e94dcaebf8e8f60cc5f180b49289649f.zip
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>
-rw-r--r--docker/compose/userstore.json37
-rw-r--r--go.mod6
-rw-r--r--go.sum10
-rw-r--r--k8s/charts/seaweedfs/Chart.yaml2
-rw-r--r--k8s/charts/seaweedfs/templates/_helpers.tpl32
-rw-r--r--k8s/charts/seaweedfs/templates/s3-secret.yaml12
-rw-r--r--k8s/charts/seaweedfs/templates/sftp-deployment.yaml292
-rw-r--r--k8s/charts/seaweedfs/templates/sftp-secret.yaml33
-rw-r--r--k8s/charts/seaweedfs/templates/sftp-service.yaml39
-rw-r--r--k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml33
-rw-r--r--k8s/charts/seaweedfs/values.yaml76
-rw-r--r--weed/command/command.go1
-rw-r--r--weed/command/filer.go27
-rw-r--r--weed/command/server.go29
-rw-r--r--weed/command/sftp.go193
-rw-r--r--weed/ftpd/ftp_server.go81
-rw-r--r--weed/sftpd/auth/auth.go76
-rw-r--r--weed/sftpd/auth/password.go64
-rw-r--r--weed/sftpd/auth/permissions.go267
-rw-r--r--weed/sftpd/auth/publickey.go68
-rw-r--r--weed/sftpd/sftp_filer.go457
-rw-r--r--weed/sftpd/sftp_helpers.go126
-rw-r--r--weed/sftpd/sftp_server.go59
-rw-r--r--weed/sftpd/sftp_service.go394
-rw-r--r--weed/sftpd/sftp_userstore.go143
-rw-r--r--weed/sftpd/user/filestore.go228
-rw-r--r--weed/sftpd/user/homemanager.go204
-rw-r--r--weed/sftpd/user/user.go111
28 files changed, 2997 insertions, 103 deletions
diff --git a/docker/compose/userstore.json b/docker/compose/userstore.json
new file mode 100644
index 000000000..e9f8d8ab4
--- /dev/null
+++ b/docker/compose/userstore.json
@@ -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
+ }
+]
diff --git a/go.mod b/go.mod
index d4b8c46b3..618763904 100644
--- a/go.mod
+++ b/go.mod
@@ -29,7 +29,6 @@ require (
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
github.com/facebookgo/stats v0.0.0-20151006221625-1b76add642e4
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/go-redsync/redsync/v4 v4.13.0
github.com/go-sql-driver/mysql v1.9.1
@@ -101,7 +100,7 @@ require (
gocloud.dev v0.41.0
gocloud.dev/pubsub/natspubsub 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/image v0.24.0
golang.org/x/net v0.39.0
@@ -141,6 +140,7 @@ require (
github.com/minio/crc64nvme v1.0.1
github.com/orcaman/concurrent-map/v2 v2.0.1
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/rclone/rclone v1.69.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/protoc-gen-validate v1.2.1 // 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/flynn/noise v1.0.1 // 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/log v1.1.1-0.20221110025148-ca232912c9f3 // 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
diff --git a/go.sum b/go.sum
index 622891a97..4f25ac747 100644
--- a/go.sum
+++ b/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.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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.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.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-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.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.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.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
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/raft v1.1.3 h1:5B6hgneQ7IuU4Ceom/f6QUt8pEeqjcsRo+IxlyPZCws=
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.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
diff --git a/k8s/charts/seaweedfs/Chart.yaml b/k8s/charts/seaweedfs/Chart.yaml
index 4168b6f24..05887d922 100644
--- a/k8s/charts/seaweedfs/Chart.yaml
+++ b/k8s/charts/seaweedfs/Chart.yaml
@@ -3,4 +3,4 @@ description: SeaweedFS
name: seaweedfs
appVersion: "3.87"
# Dev note: Trigger a helm chart release by `git tag -a helm-<version>`
-version: 4.0.387
+version: 4.0.388
diff --git a/k8s/charts/seaweedfs/templates/_helpers.tpl b/k8s/charts/seaweedfs/templates/_helpers.tpl
index 4f4b27157..9b9e44240 100644
--- a/k8s/charts/seaweedfs/templates/_helpers.tpl
+++ b/k8s/charts/seaweedfs/templates/_helpers.tpl
@@ -73,6 +73,16 @@ Inject extra environment vars in the format key:value, if populated
{{- 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 */}}
{{- define "volume.image" -}}
{{- 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 -}}
{{- $repositoryName := .Values.image.repository | toString -}}
{{- $name := .Values.global.imageName | toString -}}
-{{- $tag := .Chart.AppVersion | toString -}}
+{{- $tag := default .Chart.AppVersion .Values.image.tag | toString -}}
{{- if $registryName -}}
{{- printf "%s/%s%s:%s" $registryName $repositoryName $name $tag -}}
{{- else -}}
@@ -168,3 +178,23 @@ Usage:
{{- $value }}
{{- 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 -}} \ No newline at end of file
diff --git a/k8s/charts/seaweedfs/templates/s3-secret.yaml b/k8s/charts/seaweedfs/templates/s3-secret.yaml
index 969b31f52..1dd11ab87 100644
--- a/k8s/charts/seaweedfs/templates/s3-secret.yaml
+++ b/k8s/charts/seaweedfs/templates/s3-secret.yaml
@@ -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)) }}
-{{- $access_key_admin := randAlphaNum 16 -}}
-{{- $secret_key_admin := randAlphaNum 32 -}}
-{{- $access_key_read := randAlphaNum 16 -}}
-{{- $secret_key_read := randAlphaNum 32 -}}
+{{- $access_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "admin_access_key_id" "length" 20) -}}
+{{- $secret_key_admin := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "admin_secret_access_key" "length" 40) -}}
+{{- $access_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "read_access_key_id" "length" 20) -}}
+{{- $secret_key_read := include "getOrGeneratePassword" (dict "namespace" .Release.Namespace "secretName" "seaweedfs-s3-secret" "key" "read_secret_access_key" "length" 40) -}}
apiVersion: v1
kind: Secret
type: Opaque
@@ -11,7 +11,7 @@ metadata:
namespace: {{ .Release.Namespace }}
annotations:
"helm.sh/resource-policy": keep
- "helm.sh/hook": "pre-install"
+ "helm.sh/hook": "pre-install,pre-upgrade"
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
@@ -32,4 +32,4 @@ stringData:
s3_auditLogConfig.json: |
{{ toJson .Values.s3.auditLogConfig | nindent 4 }}
{{- end }}
-{{- end }}
+{{- end }} \ No newline at end of file
diff --git a/k8s/charts/seaweedfs/templates/sftp-deployment.yaml b/k8s/charts/seaweedfs/templates/sftp-deployment.yaml
new file mode 100644
index 000000000..fe7f4f7e3
--- /dev/null
+++ b/k8s/charts/seaweedfs/templates/sftp-deployment.yaml
@@ -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 }} \ No newline at end of file
diff --git a/k8s/charts/seaweedfs/templates/sftp-secret.yaml b/k8s/charts/seaweedfs/templates/sftp-secret.yaml
new file mode 100644
index 000000000..7dd28626d
--- /dev/null
+++ b/k8s/charts/seaweedfs/templates/sftp-secret.yaml
@@ -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 }} \ No newline at end of file
diff --git a/k8s/charts/seaweedfs/templates/sftp-service.yaml b/k8s/charts/seaweedfs/templates/sftp-service.yaml
new file mode 100644
index 000000000..979153c76
--- /dev/null
+++ b/k8s/charts/seaweedfs/templates/sftp-service.yaml
@@ -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 }} \ No newline at end of file
diff --git a/k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml b/k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
new file mode 100644
index 000000000..4c7188866
--- /dev/null
+++ b/k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
@@ -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 }}
diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml
index f2ed59b8b..ddbf69123 100644
--- a/k8s/charts/seaweedfs/values.yaml
+++ b/k8s/charts/seaweedfs/values.yaml
@@ -46,6 +46,7 @@ global:
image:
registry: ""
repository: ""
+ tag: ""
master:
enabled: true
@@ -948,7 +949,82 @@ s3:
# additional ingress annotations for the s3 endpoint
annotations: {}
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
# 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
diff --git a/weed/command/command.go b/weed/command/command.go
index 33cdb12d1..e3aff4f97 100644
--- a/weed/command/command.go
+++ b/weed/command/command.go
@@ -43,6 +43,7 @@ var Commands = []*Command{
cmdVersion,
cmdVolume,
cmdWebDav,
+ cmdSftp,
}
type Command struct {
diff --git a/weed/command/filer.go b/weed/command/filer.go
index d1241999a..05b1e88c7 100644
--- a/weed/command/filer.go
+++ b/weed/command/filer.go
@@ -35,6 +35,8 @@ var (
filerWebDavOptions WebDavOption
filerStartIam *bool
filerIamOptions IamOptions
+ filerStartSftp *bool
+ filerSftpOptions SftpOptions
)
type FilerOptions struct {
@@ -141,6 +143,19 @@ func init() {
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.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 {
@@ -235,6 +250,18 @@ func runFiler(cmd *Command, args []string) bool {
time.Sleep(delay * time.Second)
filerIamOptions.startIamServer()
}(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()
diff --git a/weed/command/server.go b/weed/command/server.go
index 797cde0dd..dd3b0c8b4 100644
--- a/weed/command/server.go
+++ b/weed/command/server.go
@@ -28,6 +28,7 @@ var (
masterOptions MasterOptions
filerOptions FilerOptions
s3Options S3Options
+ sftpOptions SftpOptions
iamOptions IamOptions
webdavOptions WebDavOption
mqBrokerOptions MessageQueueBrokerOptions
@@ -73,6 +74,7 @@ var (
isStartingVolumeServer = cmdServer.Flag.Bool("volume", true, "whether to start volume server")
isStartingFiler = cmdServer.Flag.Bool("filer", false, "whether to start filer")
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")
isStartingWebDav = cmdServer.Flag.Bool("webdav", false, "whether to start WebDAV gateway")
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.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")
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 {
*isStartingFiler = true
}
+ if *isStartingSftp {
+ *isStartingFiler = true
+ }
if *isStartingIam {
*isStartingFiler = true
}
@@ -223,6 +239,9 @@ func runServer(cmd *Command, args []string) bool {
if *s3Options.bindIp == "" {
s3Options.bindIp = serverBindIp
}
+ if sftpOptions.bindIp == nil || *sftpOptions.bindIp == "" {
+ sftpOptions.bindIp = serverBindIp
+ }
iamOptions.ip = serverBindIp
iamOptions.masters = masterOptions.peers
webdavOptions.ipBind = serverBindIp
@@ -246,11 +265,13 @@ func runServer(cmd *Command, args []string) bool {
mqBrokerOptions.dataCenter = serverDataCenter
mqBrokerOptions.rack = serverRack
s3Options.dataCenter = serverDataCenter
+ sftpOptions.dataCenter = serverDataCenter
filerOptions.disableHttp = serverDisableHttp
masterOptions.disableHttp = serverDisableHttp
filerAddress := string(pb.NewServerAddress(*serverIp, *filerOptions.port, *filerOptions.portGrpc))
s3Options.filer = &filerAddress
+ sftpOptions.filer = &filerAddress
iamOptions.filer = &filerAddress
webdavOptions.filer = &filerAddress
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 {
go func() {
time.Sleep(2 * time.Second)
diff --git a/weed/command/sftp.go b/weed/command/sftp.go
new file mode 100644
index 000000000..117f01d6e
--- /dev/null
+++ b/weed/command/sftp.go
@@ -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
+}
diff --git a/weed/ftpd/ftp_server.go b/weed/ftpd/ftp_server.go
deleted file mode 100644
index 7334fa3c7..000000000
--- a/weed/ftpd/ftp_server.go
+++ /dev/null
@@ -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")
-}
diff --git a/weed/sftpd/auth/auth.go b/weed/sftpd/auth/auth.go
new file mode 100644
index 000000000..64dee5989
--- /dev/null
+++ b/weed/sftpd/auth/auth.go
@@ -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)
+}
diff --git a/weed/sftpd/auth/password.go b/weed/sftpd/auth/password.go
new file mode 100644
index 000000000..acd59bbca
--- /dev/null
+++ b/weed/sftpd/auth/password.go
@@ -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
+}
diff --git a/weed/sftpd/auth/permissions.go b/weed/sftpd/auth/permissions.go
new file mode 100644
index 000000000..8a0a3eade
--- /dev/null
+++ b/weed/sftpd/auth/permissions.go
@@ -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
+}
diff --git a/weed/sftpd/auth/publickey.go b/weed/sftpd/auth/publickey.go
new file mode 100644
index 000000000..83c5092a1
--- /dev/null
+++ b/weed/sftpd/auth/publickey.go
@@ -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
+}
diff --git a/weed/sftpd/sftp_filer.go b/weed/sftpd/sftp_filer.go
new file mode 100644
index 000000000..dbe6a438d
--- /dev/null
+++ b/weed/sftpd/sftp_filer.go
@@ -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)
+}
diff --git a/weed/sftpd/sftp_helpers.go b/weed/sftpd/sftp_helpers.go
new file mode 100644
index 000000000..0545528b9
--- /dev/null
+++ b/weed/sftpd/sftp_helpers.go
@@ -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)
+}
diff --git a/weed/sftpd/sftp_server.go b/weed/sftpd/sftp_server.go
new file mode 100644
index 000000000..be3af6fd1
--- /dev/null
+++ b/weed/sftpd/sftp_server.go
@@ -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)
+}
diff --git a/weed/sftpd/sftp_service.go b/weed/sftpd/sftp_service.go
new file mode 100644
index 000000000..76cba305c
--- /dev/null
+++ b/weed/sftpd/sftp_service.go
@@ -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)
+ }
+}
diff --git a/weed/sftpd/sftp_userstore.go b/weed/sftpd/sftp_userstore.go
new file mode 100644
index 000000000..8c59ed576
--- /dev/null
+++ b/weed/sftpd/sftp_userstore.go
@@ -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
+}
diff --git a/weed/sftpd/user/filestore.go b/weed/sftpd/user/filestore.go
new file mode 100644
index 000000000..d40d77c8c
--- /dev/null
+++ b/weed/sftpd/user/filestore.go
@@ -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
+}
diff --git a/weed/sftpd/user/homemanager.go b/weed/sftpd/user/homemanager.go
new file mode 100644
index 000000000..c9051939c
--- /dev/null
+++ b/weed/sftpd/user/homemanager.go
@@ -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)
+}
diff --git a/weed/sftpd/user/user.go b/weed/sftpd/user/user.go
new file mode 100644
index 000000000..c04a8446a
--- /dev/null
+++ b/weed/sftpd/user/user.go
@@ -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
+}