Browse Source

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>
pull/6720/head
Mohamed Sekour 1 week ago
committed by GitHub
parent
commit
93aed187e9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 37
      docker/compose/userstore.json
  2. 6
      go.mod
  3. 10
      go.sum
  4. 2
      k8s/charts/seaweedfs/Chart.yaml
  5. 32
      k8s/charts/seaweedfs/templates/_helpers.tpl
  6. 12
      k8s/charts/seaweedfs/templates/s3-secret.yaml
  7. 292
      k8s/charts/seaweedfs/templates/sftp-deployment.yaml
  8. 33
      k8s/charts/seaweedfs/templates/sftp-secret.yaml
  9. 39
      k8s/charts/seaweedfs/templates/sftp-service.yaml
  10. 33
      k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
  11. 76
      k8s/charts/seaweedfs/values.yaml
  12. 1
      weed/command/command.go
  13. 27
      weed/command/filer.go
  14. 29
      weed/command/server.go
  15. 193
      weed/command/sftp.go
  16. 81
      weed/ftpd/ftp_server.go
  17. 76
      weed/sftpd/auth/auth.go
  18. 64
      weed/sftpd/auth/password.go
  19. 267
      weed/sftpd/auth/permissions.go
  20. 68
      weed/sftpd/auth/publickey.go
  21. 457
      weed/sftpd/sftp_filer.go
  22. 126
      weed/sftpd/sftp_helpers.go
  23. 59
      weed/sftpd/sftp_server.go
  24. 394
      weed/sftpd/sftp_service.go
  25. 143
      weed/sftpd/sftp_userstore.go
  26. 228
      weed/sftpd/user/filestore.go
  27. 204
      weed/sftpd/user/homemanager.go
  28. 111
      weed/sftpd/user/user.go

37
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
}
]

6
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

10
go.sum

@ -865,10 +865,6 @@ github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+ne
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.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=

2
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

32
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 -}}

12
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 }}

292
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 }}

33
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 }}

39
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 }}

33
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 }}

76
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

1
weed/command/command.go

@ -43,6 +43,7 @@ var Commands = []*Command{
cmdVersion,
cmdVolume,
cmdWebDav,
cmdSftp,
}
type Command struct {

27
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()

29
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)

193
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
}

81
weed/ftpd/ftp_server.go

@ -1,81 +0,0 @@
package ftpd
import (
"crypto/tls"
"errors"
"github.com/seaweedfs/seaweedfs/weed/util"
"net"
ftpserver "github.com/fclairamb/ftpserverlib"
"google.golang.org/grpc"
)
type FtpServerOption struct {
Filer string
IP string
IpBind string
Port int
FilerGrpcAddress string
FtpRoot string
GrpcDialOption grpc.DialOption
PassivePortStart int
PassivePortStop int
}
type SftpServer struct {
option *FtpServerOption
ftpListener net.Listener
}
var _ = ftpserver.MainDriver(&SftpServer{})
// NewFtpServer returns a new FTP server driver
func NewFtpServer(ftpListener net.Listener, option *FtpServerOption) (*SftpServer, error) {
var err error
server := &SftpServer{
option: option,
ftpListener: ftpListener,
}
return server, err
}
// GetSettings returns some general settings around the server setup
func (s *SftpServer) GetSettings() (*ftpserver.Settings, error) {
var portRange *ftpserver.PortRange
if s.option.PassivePortStart > 0 && s.option.PassivePortStop > s.option.PassivePortStart {
portRange = &ftpserver.PortRange{
Start: s.option.PassivePortStart,
End: s.option.PassivePortStop,
}
}
return &ftpserver.Settings{
Listener: s.ftpListener,
ListenAddr: util.JoinHostPort(s.option.IpBind, s.option.Port),
PublicHost: s.option.IP,
PassiveTransferPortRange: portRange,
ActiveTransferPortNon20: true,
IdleTimeout: -1,
ConnectionTimeout: 20,
}, nil
}
// ClientConnected is called to send the very first welcome message
func (s *SftpServer) ClientConnected(cc ftpserver.ClientContext) (string, error) {
return "Welcome to SeaweedFS FTP Server", nil
}
// ClientDisconnected is called when the user disconnects, even if he never authenticated
func (s *SftpServer) ClientDisconnected(cc ftpserver.ClientContext) {
}
// AuthUser authenticates the user and selects an handling driver
func (s *SftpServer) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) {
return nil, nil
}
// GetTLSConfig returns a TLS Certificate to use
// The certificate could frequently change if we use something like "let's encrypt"
func (s *SftpServer) GetTLSConfig() (*tls.Config, error) {
return nil, errors.New("no TLS certificate configured")
}

76
weed/sftpd/auth/auth.go

@ -0,0 +1,76 @@
// Package auth provides authentication and authorization functionality for the SFTP server
package auth
import (
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// Provider defines the interface for authentication providers
type Provider interface {
// GetAuthMethods returns the SSH server auth methods
GetAuthMethods() []ssh.AuthMethod
}
// Manager handles authentication and authorization
type Manager struct {
userStore user.Store
passwordAuth *PasswordAuthenticator
publicKeyAuth *PublicKeyAuthenticator
permissionChecker *PermissionChecker
enabledAuthMethods []string
}
// NewManager creates a new authentication manager
func NewManager(userStore user.Store, fsHelper FileSystemHelper, enabledAuthMethods []string) *Manager {
manager := &Manager{
userStore: userStore,
enabledAuthMethods: enabledAuthMethods,
}
// Initialize authenticators based on enabled methods
passwordEnabled := false
publicKeyEnabled := false
for _, method := range enabledAuthMethods {
switch method {
case "password":
passwordEnabled = true
case "publickey":
publicKeyEnabled = true
}
}
manager.passwordAuth = NewPasswordAuthenticator(userStore, passwordEnabled)
manager.publicKeyAuth = NewPublicKeyAuthenticator(userStore, publicKeyEnabled)
manager.permissionChecker = NewPermissionChecker(fsHelper)
return manager
}
// GetSSHServerConfig returns an SSH server config with the appropriate authentication methods
func (m *Manager) GetSSHServerConfig() *ssh.ServerConfig {
config := &ssh.ServerConfig{}
// Add password authentication if enabled
if m.passwordAuth.Enabled() {
config.PasswordCallback = m.passwordAuth.Authenticate
}
// Add public key authentication if enabled
if m.publicKeyAuth.Enabled() {
config.PublicKeyCallback = m.publicKeyAuth.Authenticate
}
return config
}
// CheckPermission checks if a user has the required permission on a path
func (m *Manager) CheckPermission(user *user.User, path, permission string) error {
return m.permissionChecker.CheckFilePermission(user, path, permission)
}
// GetUser retrieves a user from the user store
func (m *Manager) GetUser(username string) (*user.User, error) {
return m.userStore.GetUser(username)
}

64
weed/sftpd/auth/password.go

@ -0,0 +1,64 @@
package auth
import (
"fmt"
"math/rand"
"time"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// PasswordAuthenticator handles password-based authentication
type PasswordAuthenticator struct {
userStore user.Store
enabled bool
}
// NewPasswordAuthenticator creates a new password authenticator
func NewPasswordAuthenticator(userStore user.Store, enabled bool) *PasswordAuthenticator {
return &PasswordAuthenticator{
userStore: userStore,
enabled: enabled,
}
}
// Enabled returns whether password authentication is enabled
func (a *PasswordAuthenticator) Enabled() bool {
return a.enabled
}
// Authenticate validates a password for a user
func (a *PasswordAuthenticator) Authenticate(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
username := conn.User()
// Check if password auth is enabled
if !a.enabled {
return nil, fmt.Errorf("password authentication disabled")
}
// Validate password against user store
if a.userStore.ValidatePassword(username, password) {
return &ssh.Permissions{
Extensions: map[string]string{
"username": username,
},
}, nil
}
// Add delay to prevent brute force attacks
time.Sleep(time.Duration(100+rand.Intn(100)) * time.Millisecond)
return nil, fmt.Errorf("authentication failed")
}
// ValidatePassword checks if the provided password is valid for the user
func ValidatePassword(store user.Store, username string, password []byte) bool {
user, err := store.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password
return string(password) == user.Password
}

267
weed/sftpd/auth/permissions.go

@ -0,0 +1,267 @@
package auth
import (
"context"
"fmt"
"os"
"strings"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
)
// Permission constants for clarity and consistency
const (
PermRead = "read"
PermWrite = "write"
PermExecute = "execute"
PermList = "list"
PermDelete = "delete"
PermMkdir = "mkdir"
PermTraverse = "traverse"
PermAll = "*"
PermAdmin = "admin"
PermReadWrite = "readwrite"
)
// PermissionChecker handles permission checking for file operations
// It verifies both Unix-style permissions and explicit ACLs defined in user configuration.
type PermissionChecker struct {
fsHelper FileSystemHelper
}
// FileSystemHelper provides necessary filesystem operations for permission checking
type FileSystemHelper interface {
GetEntry(path string) (*Entry, error)
}
// Entry represents a filesystem entry with attributes
type Entry struct {
IsDirectory bool
Attributes *EntryAttributes
IsSymlink bool // Added to track symlinks
Target string // For symlinks, stores the target path
}
// EntryAttributes contains file attributes
type EntryAttributes struct {
Uid uint32
Gid uint32
FileMode uint32
SymlinkTarget string
}
// PermissionError represents a permission-related error
type PermissionError struct {
Path string
Perm string
User string
}
func (e *PermissionError) Error() string {
return fmt.Sprintf("permission denied: %s required on %s for user %s", e.Perm, e.Path, e.User)
}
// NewPermissionChecker creates a new permission checker
func NewPermissionChecker(fsHelper FileSystemHelper) *PermissionChecker {
return &PermissionChecker{
fsHelper: fsHelper,
}
}
// CheckFilePermission verifies if a user has the required permission on a path
// It first checks if the path is in the user's home directory with explicit permissions.
// If not, it falls back to Unix permission checking followed by explicit permission checking.
// Parameters:
// - user: The user requesting access
// - path: The filesystem path to check
// - perm: The permission being requested (read, write, execute, etc.)
//
// Returns:
// - nil if permission is granted, error otherwise
func (pc *PermissionChecker) CheckFilePermission(user *user.User, path string, perm string) error {
if user == nil {
return &PermissionError{Path: path, Perm: perm, User: "unknown"}
}
// Retrieve metadata via helper
entry, err := pc.fsHelper.GetEntry(path)
if err != nil {
return fmt.Errorf("failed to get entry for path %s: %w", path, err)
}
// Handle symlinks by resolving them
if entry.IsSymlink {
// Get the actual entry for the resolved path
entry, err = pc.fsHelper.GetEntry(entry.Attributes.SymlinkTarget)
if err != nil {
return fmt.Errorf("failed to get entry for resolved path %s: %w", entry.Attributes.SymlinkTarget, err)
}
// Store the original target
entry.Target = entry.Attributes.SymlinkTarget
}
// Special case: root user always has permission
if user.Username == "root" || user.Uid == 0 {
return nil
}
// Check if path is within user's home directory and has explicit permissions
if isPathInHomeDirectory(user, path) {
// Check if user has explicit permissions for this path
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
return nil
}
} else {
// For paths outside home directory or without explicit home permissions,
// check UNIX-style perms first
isOwner := user.Uid == entry.Attributes.Uid
isGroup := user.Gid == entry.Attributes.Gid
mode := os.FileMode(entry.Attributes.FileMode)
if HasUnixPermission(isOwner, isGroup, mode, entry.IsDirectory, perm) {
return nil
}
// Then check explicit ACLs
if HasExplicitPermission(user, path, perm, entry.IsDirectory) {
return nil
}
}
return &PermissionError{Path: path, Perm: perm, User: user.Username}
}
// CheckFilePermissionWithContext is a context-aware version of CheckFilePermission
// that supports cancellation and timeouts
func (pc *PermissionChecker) CheckFilePermissionWithContext(ctx context.Context, user *user.User, path string, perm string) error {
// Check for context cancellation
if ctx.Err() != nil {
return ctx.Err()
}
return pc.CheckFilePermission(user, path, perm)
}
// isPathInHomeDirectory checks if a path is in the user's home directory
func isPathInHomeDirectory(user *user.User, path string) bool {
return strings.HasPrefix(path, user.HomeDir)
}
// HasUnixPermission checks if the user has the required Unix permission
// Uses bit masks for clarity and maintainability
func HasUnixPermission(isOwner, isGroup bool, fileMode os.FileMode, isDirectory bool, requiredPerm string) bool {
const (
ownerRead = 0400
ownerWrite = 0200
ownerExec = 0100
groupRead = 0040
groupWrite = 0020
groupExec = 0010
otherRead = 0004
otherWrite = 0002
otherExec = 0001
)
// Check read permission
hasRead := (isOwner && (fileMode&ownerRead != 0)) ||
(isGroup && (fileMode&groupRead != 0)) ||
(fileMode&otherRead != 0)
// Check write permission
hasWrite := (isOwner && (fileMode&ownerWrite != 0)) ||
(isGroup && (fileMode&groupWrite != 0)) ||
(fileMode&otherWrite != 0)
// Check execute permission
hasExec := (isOwner && (fileMode&ownerExec != 0)) ||
(isGroup && (fileMode&groupExec != 0)) ||
(fileMode&otherExec != 0)
switch requiredPerm {
case PermRead:
return hasRead
case PermWrite:
return hasWrite
case PermExecute:
return hasExec
case PermList:
if isDirectory {
return hasRead && hasExec
}
return hasRead
case PermDelete:
return hasWrite
case PermMkdir:
return isDirectory && hasWrite
case PermTraverse:
return isDirectory && hasExec
case PermReadWrite:
return hasRead && hasWrite
case PermAll, PermAdmin:
return hasRead && hasWrite && hasExec
}
return false
}
// HasExplicitPermission checks if the user has explicit permission from user config
func HasExplicitPermission(user *user.User, filepath, requiredPerm string, isDirectory bool) bool {
// Find the most specific permission that applies to this path
var bestMatch string
var perms []string
for p, userPerms := range user.Permissions {
// Check if the path is either the permission path exactly or is under that path
if strings.HasPrefix(filepath, p) && len(p) > len(bestMatch) {
bestMatch = p
perms = userPerms
}
}
// No matching permissions found
if bestMatch == "" {
return false
}
// Check if user has admin role
if containsString(perms, PermAdmin) {
return true
}
// If user has list permission and is requesting traverse/execute permission, grant it
if isDirectory && requiredPerm == PermExecute && containsString(perms, PermList) {
return true
}
// Check if the required permission is in the list
for _, perm := range perms {
if perm == requiredPerm || perm == PermAll {
return true
}
// Handle combined permissions
if perm == PermReadWrite && (requiredPerm == PermRead || requiredPerm == PermWrite) {
return true
}
// Directory-specific permissions
if isDirectory && perm == PermList && requiredPerm == PermRead {
return true
}
if isDirectory && perm == PermTraverse && requiredPerm == PermExecute {
return true
}
}
return false
}
// Helper function to check if a string is in a slice
func containsString(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}

68
weed/sftpd/auth/publickey.go

@ -0,0 +1,68 @@
package auth
import (
"crypto/subtle"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"golang.org/x/crypto/ssh"
)
// PublicKeyAuthenticator handles public key-based authentication
type PublicKeyAuthenticator struct {
userStore user.Store
enabled bool
}
// NewPublicKeyAuthenticator creates a new public key authenticator
func NewPublicKeyAuthenticator(userStore user.Store, enabled bool) *PublicKeyAuthenticator {
return &PublicKeyAuthenticator{
userStore: userStore,
enabled: enabled,
}
}
// Enabled returns whether public key authentication is enabled
func (a *PublicKeyAuthenticator) Enabled() bool {
return a.enabled
}
// Authenticate validates a public key for a user
func (a *PublicKeyAuthenticator) Authenticate(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
username := conn.User()
// Check if public key auth is enabled
if !a.enabled {
return nil, fmt.Errorf("public key authentication disabled")
}
// Convert key to string format for comparison
keyData := string(key.Marshal())
// Validate public key
if ValidatePublicKey(a.userStore, username, keyData) {
return &ssh.Permissions{
Extensions: map[string]string{
"username": username,
},
}, nil
}
return nil, fmt.Errorf("authentication failed")
}
// ValidatePublicKey checks if the provided public key is valid for the user
func ValidatePublicKey(store user.Store, username string, keyData string) bool {
user, err := store.GetUser(username)
if err != nil {
return false
}
for _, key := range user.PublicKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
return true
}
}
return false
}

457
weed/sftpd/sftp_filer.go

@ -0,0 +1,457 @@
// sftp_filer_refactored.go
package sftpd
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"syscall"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
)
const (
defaultTimeout = 30 * time.Second
defaultListLimit = 1000
)
// ==================== Filer RPC Helpers ====================
// callWithClient wraps a gRPC client call with timeout and client creation.
func (fs *SftpServer) callWithClient(streaming bool, fn func(ctx context.Context, client filer_pb.SeaweedFilerClient) error) error {
return fs.withTimeoutContext(func(ctx context.Context) error {
return fs.WithFilerClient(streaming, func(client filer_pb.SeaweedFilerClient) error {
return fn(ctx, client)
})
})
}
// getEntry retrieves a single directory entry by path.
func (fs *SftpServer) getEntry(p string) (*filer_pb.Entry, error) {
dir, name := util.FullPath(p).DirAndName()
var entry *filer_pb.Entry
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
r, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{Directory: dir, Name: name})
if err != nil {
return err
}
if r.Entry == nil {
return fmt.Errorf("%s not found in %s", name, dir)
}
entry = r.Entry
return nil
})
if err != nil {
return nil, fmt.Errorf("lookup %s: %w", p, err)
}
return entry, nil
}
// updateEntry sends an UpdateEntryRequest for the given entry.
func (fs *SftpServer) updateEntry(dir string, entry *filer_pb.Entry) error {
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
_, err := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: dir, Entry: entry})
return err
})
}
// ==================== FilerClient Interface ====================
func (fs *SftpServer) AdjustedUrl(location *filer_pb.Location) string { return location.Url }
func (fs *SftpServer) GetDataCenter() string { return fs.dataCenter }
func (fs *SftpServer) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
addr := fs.filerAddr.ToGrpcAddress()
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
return fn(filer_pb.NewSeaweedFilerClient(conn))
}, addr, false, fs.grpcDialOption)
}
func (fs *SftpServer) withTimeoutContext(fn func(ctx context.Context) error) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
return fn(ctx)
}
// ==================== Command Dispatcher ====================
func (fs *SftpServer) dispatchCmd(r *sftp.Request) error {
glog.V(0).Infof("Dispatch: %s %s", r.Method, r.Filepath)
switch r.Method {
case "Remove":
return fs.removeEntry(r)
case "Rename":
return fs.renameEntry(r)
case "Mkdir":
return fs.makeDir(r)
case "Rmdir":
return fs.removeDir(r)
case "Setstat":
return fs.setFileStat(r)
default:
return fmt.Errorf("unsupported: %s", r.Method)
}
}
// ==================== File Operations ====================
func (fs *SftpServer) readFile(r *sftp.Request) (io.ReaderAt, error) {
if err := fs.checkFilePermission(r.Filepath, "read"); err != nil {
return nil, err
}
entry, err := fs.getEntry(r.Filepath)
if err != nil {
return nil, err
}
return &SeaweedFileReaderAt{fs: fs, entry: entry}, nil
}
// putFile uploads a file to the filer and sets ownership metadata.
func (fs *SftpServer) putFile(filepath string, data []byte, user *user.User) error {
dir, filename := util.FullPath(filepath).DirAndName()
uploadUrl := fmt.Sprintf("http://%s%s", fs.filerAddr, filepath)
// Create a reader from our buffered data and calculate MD5 hash
hash := md5.New()
reader := bytes.NewReader(data)
body := io.TeeReader(reader, hash)
fileSize := int64(len(data))
// Create and execute HTTP request
proxyReq, err := http.NewRequest(http.MethodPut, uploadUrl, body)
if err != nil {
return fmt.Errorf("create request: %v", err)
}
proxyReq.ContentLength = fileSize
proxyReq.Header.Set("Content-Type", "application/octet-stream")
client := &http.Client{}
resp, err := client.Do(proxyReq)
if err != nil {
return fmt.Errorf("upload to filer: %v", err)
}
defer resp.Body.Close()
// Process response
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %v", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
}
var result weed_server.FilerPostResult
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("parse response: %v", err)
}
if result.Error != "" {
return fmt.Errorf("filer error: %s", result.Error)
}
// Update file ownership using the same pattern as other functions
if user != nil {
err := fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
// Look up the file to get its current entry
lookupResp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: filename,
})
if err != nil {
return fmt.Errorf("lookup file for attribute update: %v", err)
}
if lookupResp.Entry == nil {
return fmt.Errorf("file not found after upload: %s/%s", dir, filename)
}
// Update the entry with new uid/gid
entry := lookupResp.Entry
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
// Update the entry in the filer
_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
return err
})
if err != nil {
// Log the error but don't fail the whole operation
glog.Errorf("Failed to update file ownership for %s: %v", filepath, err)
}
}
return nil
}
func (fs *SftpServer) newFileWriter(r *sftp.Request) (io.WriterAt, error) {
return &filerFileWriter{fs: *fs, req: r, permissions: 0644, uid: fs.user.Uid, gid: fs.user.Gid}, nil
}
func (fs *SftpServer) removeEntry(r *sftp.Request) error {
return fs.deleteEntry(r.Filepath, false)
}
func (fs *SftpServer) renameEntry(r *sftp.Request) error {
if err := fs.checkFilePermission(r.Filepath, "rename"); err != nil {
return err
}
oldDir, oldName := util.FullPath(r.Filepath).DirAndName()
newDir, newName := util.FullPath(r.Target).DirAndName()
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: oldDir, OldName: oldName,
NewDirectory: newDir, NewName: newName,
})
return err
})
}
func (fs *SftpServer) setFileStat(r *sftp.Request) error {
if err := fs.checkFilePermission(r.Filepath, "write"); err != nil {
return err
}
entry, err := fs.getEntry(r.Filepath)
if err != nil {
return err
}
dir, _ := util.FullPath(r.Filepath).DirAndName()
// apply attrs
if r.AttrFlags().Permissions {
entry.Attributes.FileMode = uint32(r.Attributes().FileMode())
}
if r.AttrFlags().UidGid {
entry.Attributes.Uid = uint32(r.Attributes().UID)
entry.Attributes.Gid = uint32(r.Attributes().GID)
}
if r.AttrFlags().Acmodtime {
entry.Attributes.Mtime = int64(r.Attributes().Mtime)
}
if r.AttrFlags().Size {
entry.Attributes.FileSize = uint64(r.Attributes().Size)
}
return fs.updateEntry(dir, entry)
}
// ==================== Directory Operations ====================
func (fs *SftpServer) listDir(r *sftp.Request) (sftp.ListerAt, error) {
if err := fs.checkFilePermission(r.Filepath, "list"); err != nil {
return nil, err
}
if r.Method == "Stat" || r.Method == "Lstat" {
entry, err := fs.getEntry(r.Filepath)
if err != nil {
return nil, err
}
fi := &EnhancedFileInfo{FileInfo: FileInfoFromEntry(entry), uid: entry.Attributes.Uid, gid: entry.Attributes.Gid}
return listerat([]os.FileInfo{fi}), nil
}
return fs.listAllPages(r.Filepath)
}
func (fs *SftpServer) listAllPages(dirPath string) (sftp.ListerAt, error) {
var all []os.FileInfo
last := ""
for {
page, err := fs.fetchDirectoryPage(dirPath, last)
if err != nil {
return nil, err
}
all = append(all, page...)
if len(page) < defaultListLimit {
break
}
last = page[len(page)-1].Name()
}
return listerat(all), nil
}
func (fs *SftpServer) fetchDirectoryPage(dirPath, start string) ([]os.FileInfo, error) {
var list []os.FileInfo
err := fs.callWithClient(true, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{Directory: dirPath, StartFromFileName: start, Limit: defaultListLimit})
if err != nil {
return err
}
for {
r, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil || r.Entry == nil {
continue
}
p := path.Join(dirPath, r.Entry.Name)
if err := fs.checkFilePermission(p, "list"); err != nil {
continue
}
list = append(list, &EnhancedFileInfo{FileInfo: FileInfoFromEntry(r.Entry), uid: r.Entry.Attributes.Uid, gid: r.Entry.Attributes.Gid})
}
return nil
})
return list, err
}
// makeDir creates a new directory with proper permissions.
func (fs *SftpServer) makeDir(r *sftp.Request) error {
if fs.user == nil {
return fmt.Errorf("cannot create directory: no user info")
}
dir, name := util.FullPath(r.Filepath).DirAndName()
if err := fs.checkFilePermission(dir, "mkdir"); err != nil {
return err
}
// default mode and ownership
err := filer_pb.Mkdir(fs, string(dir), name, func(entry *filer_pb.Entry) {
mode := uint32(0755 | os.ModeDir)
if strings.HasPrefix(r.Filepath, fs.user.HomeDir) {
mode = uint32(0700 | os.ModeDir)
}
entry.Attributes.FileMode = mode
entry.Attributes.Uid = fs.user.Uid
entry.Attributes.Gid = fs.user.Gid
now := time.Now().Unix()
entry.Attributes.Crtime = now
entry.Attributes.Mtime = now
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended["creator"] = []byte(fs.user.Username)
})
return err
}
// removeDir deletes a directory.
func (fs *SftpServer) removeDir(r *sftp.Request) error {
return fs.deleteEntry(r.Filepath, false)
}
// ==================== Common Arguments Helpers ====================
func FileInfoFromEntry(e *filer_pb.Entry) FileInfo {
return FileInfo{name: e.Name, size: int64(e.Attributes.FileSize), mode: os.FileMode(e.Attributes.FileMode), modTime: time.Unix(e.Attributes.Mtime, 0), isDir: e.IsDirectory}
}
func (fs *SftpServer) deleteEntry(p string, recursive bool) error {
if err := fs.checkFilePermission(p, "delete"); err != nil {
return err
}
dir, name := util.FullPath(p).DirAndName()
return fs.callWithClient(false, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
r, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{Directory: dir, Name: name, IsDeleteData: true, IsRecursive: recursive})
if err != nil {
return err
}
if r.Error != "" {
return fmt.Errorf("%s", r.Error)
}
return nil
})
}
// ==================== Custom Types ====================
type EnhancedFileInfo struct {
FileInfo
uid uint32
gid uint32
}
func (fi *EnhancedFileInfo) Sys() interface{} {
return &syscall.Stat_t{Uid: fi.uid, Gid: fi.gid}
}
func (fi *EnhancedFileInfo) Owner() (uid, gid int) {
return int(fi.uid), int(fi.gid)
}
// SeaweedFileReaderAt implements io.ReaderAt for SeaweedFS files
type SeaweedFileReaderAt struct {
fs *SftpServer
entry *filer_pb.Entry
}
func (ra *SeaweedFileReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
// Create a new reader for each ReadAt call
reader := filer.NewFileReader(ra.fs, ra.entry)
if reader == nil {
return 0, fmt.Errorf("failed to create file reader")
}
// Check if we're reading past the end of the file
fileSize := int64(ra.entry.Attributes.FileSize)
if off >= fileSize {
return 0, io.EOF
}
// Seek to the offset
if seeker, ok := reader.(io.Seeker); ok {
_, err = seeker.Seek(off, io.SeekStart)
if err != nil {
return 0, fmt.Errorf("seek error: %v", err)
}
} else {
// If the reader doesn't implement Seek, we need to read and discard bytes
toSkip := off
skipBuf := make([]byte, 8192)
for toSkip > 0 {
skipSize := int64(len(skipBuf))
if skipSize > toSkip {
skipSize = toSkip
}
read, err := reader.Read(skipBuf[:skipSize])
if err != nil {
return 0, fmt.Errorf("skip error: %v", err)
}
if read == 0 {
return 0, fmt.Errorf("unable to skip to offset %d", off)
}
toSkip -= int64(read)
}
}
// Adjust read length if it would go past EOF
readLen := len(p)
remaining := fileSize - off
if int64(readLen) > remaining {
readLen = int(remaining)
if readLen == 0 {
return 0, io.EOF
}
}
// Read the data
n, err = io.ReadFull(reader, p[:readLen])
// Handle EOF correctly
if err == io.ErrUnexpectedEOF || (err == nil && n < len(p)) {
err = io.EOF
}
return n, err
}
func (fs *SftpServer) checkFilePermission(filepath string, permissions string) error {
return fs.authManager.CheckPermission(fs.user, filepath, permissions)
}

126
weed/sftpd/sftp_helpers.go

@ -0,0 +1,126 @@
// sftp_helpers.go
package sftpd
import (
"io"
"os"
"sync"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// FileInfo implements os.FileInfo.
type FileInfo struct {
name string
size int64
mode os.FileMode
modTime time.Time
isDir bool
}
func (fi *FileInfo) Name() string { return fi.name }
func (fi *FileInfo) Size() int64 { return fi.size }
func (fi *FileInfo) Mode() os.FileMode { return fi.mode }
func (fi *FileInfo) ModTime() time.Time { return fi.modTime }
func (fi *FileInfo) IsDir() bool { return fi.isDir }
func (fi *FileInfo) Sys() interface{} { return nil }
// bufferReader wraps a byte slice to io.ReaderAt.
type bufferReader struct {
b []byte
i int64
}
func NewBufferReader(b []byte) *bufferReader { return &bufferReader{b: b} }
func (r *bufferReader) Read(p []byte) (int, error) {
if r.i >= int64(len(r.b)) {
return 0, io.EOF
}
n := copy(p, r.b[r.i:])
r.i += int64(n)
return n, nil
}
func (r *bufferReader) ReadAt(p []byte, off int64) (int, error) {
if off >= int64(len(r.b)) {
return 0, io.EOF
}
n := copy(p, r.b[off:])
if n < len(p) {
return n, io.EOF
}
return n, nil
}
// listerat implements sftp.ListerAt.
type listerat []os.FileInfo
func (l listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
if offset >= int64(len(l)) {
return 0, io.EOF
}
n := copy(ls, l[offset:])
if n < len(ls) {
return n, io.EOF
}
return n, nil
}
// filerFileWriter buffers writes and flushes on Close.
type filerFileWriter struct {
fs SftpServer
req *sftp.Request
mu sync.Mutex
data []byte
permissions os.FileMode
uid uint32
gid uint32
offset int64
}
func (w *filerFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
end := w.offset + int64(len(p))
if end > int64(len(w.data)) {
newBuf := make([]byte, end)
copy(newBuf, w.data)
w.data = newBuf
}
n := copy(w.data[w.offset:], p)
w.offset += int64(n)
return n, nil
}
func (w *filerFileWriter) WriteAt(p []byte, off int64) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
end := int(off) + len(p)
if end > len(w.data) {
newBuf := make([]byte, end)
copy(newBuf, w.data)
w.data = newBuf
}
n := copy(w.data[off:], p)
return n, nil
}
func (w *filerFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
dir, _ := util.FullPath(w.req.Filepath).DirAndName()
// Check permissions based on file metadata and user permissions
if err := w.fs.checkFilePermission(dir, "write"); err != nil {
glog.Errorf("Permission denied for %s", dir)
return err
}
// Call the extracted putFile method on SftpServer
return w.fs.putFile(w.req.Filepath, w.data, w.fs.user)
}

59
weed/sftpd/sftp_server.go

@ -0,0 +1,59 @@
// sftp_server.go
package sftpd
import (
"io"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"google.golang.org/grpc"
)
type SftpServer struct {
filerAddr pb.ServerAddress
grpcDialOption grpc.DialOption
dataCenter string
filerGroup string
user *user.User
authManager *auth.Manager
}
// NewSftpServer constructs the server.
func NewSftpServer(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string, user *user.User) SftpServer {
// Create a file system helper for the auth manager
fsHelper := NewFileSystemHelper(filerAddr, grpcDialOption, dataCenter, filerGroup)
// Create an auth manager for permission checking
authManager := auth.NewManager(nil, fsHelper, []string{})
return SftpServer{
filerAddr: filerAddr,
grpcDialOption: grpcDialOption,
dataCenter: dataCenter,
filerGroup: filerGroup,
user: user,
authManager: authManager,
}
}
// Fileread is invoked for “get” requests.
func (fs *SftpServer) Fileread(req *sftp.Request) (io.ReaderAt, error) {
return fs.readFile(req)
}
// Filewrite is invoked for “put” requests.
func (fs *SftpServer) Filewrite(req *sftp.Request) (io.WriterAt, error) {
return fs.newFileWriter(req)
}
// Filecmd handles Remove, Rename, Mkdir, Rmdir, etc.
func (fs *SftpServer) Filecmd(req *sftp.Request) error {
return fs.dispatchCmd(req)
}
// Filelist handles directory listings.
func (fs *SftpServer) Filelist(req *sftp.Request) (sftp.ListerAt, error) {
return fs.listDir(req)
}

394
weed/sftpd/sftp_service.go

@ -0,0 +1,394 @@
// sftp_service.go
package sftpd
import (
"context"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"time"
"github.com/pkg/sftp"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/sftpd/auth"
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
"github.com/seaweedfs/seaweedfs/weed/util"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
)
// SFTPService holds configuration for the SFTP service.
type SFTPService struct {
options SFTPServiceOptions
userStore user.Store
authManager *auth.Manager
homeManager *user.HomeManager
}
// SFTPServiceOptions contains all configuration options for the SFTP service.
type SFTPServiceOptions struct {
GrpcDialOption grpc.DialOption
DataCenter string
FilerGroup string
Filer pb.ServerAddress
// SSH Configuration
SshPrivateKey string // Legacy single host key
HostKeysFolder string // Multiple host keys for different algorithms
AuthMethods []string // Enabled auth methods: "password", "publickey", "keyboard-interactive"
MaxAuthTries int // Limit authentication attempts
BannerMessage string // Pre-auth banner message
LoginGraceTime time.Duration // Timeout for authentication
// Connection Management
ClientAliveInterval time.Duration // Keep-alive check interval
ClientAliveCountMax int // Max missed keep-alives before disconnect
// User Management
UserStoreFile string // Path to user store file
}
// NewSFTPService creates a new service instance.
func NewSFTPService(options *SFTPServiceOptions) *SFTPService {
service := SFTPService{options: *options}
// Initialize user store
userStore, err := user.NewFileStore(options.UserStoreFile)
if err != nil {
glog.Fatalf("Failed to initialize user store: %v", err)
}
service.userStore = userStore
// Initialize file system helper for permission checking
fsHelper := NewFileSystemHelper(
options.Filer,
options.GrpcDialOption,
options.DataCenter,
options.FilerGroup,
)
// Initialize auth manager
service.authManager = auth.NewManager(userStore, fsHelper, options.AuthMethods)
// Initialize home directory manager
service.homeManager = user.NewHomeManager(fsHelper)
return &service
}
// FileSystemHelper implements auth.FileSystemHelper interface
type FileSystemHelper struct {
filerAddr pb.ServerAddress
grpcDialOption grpc.DialOption
dataCenter string
filerGroup string
}
func NewFileSystemHelper(filerAddr pb.ServerAddress, grpcDialOption grpc.DialOption, dataCenter, filerGroup string) *FileSystemHelper {
return &FileSystemHelper{
filerAddr: filerAddr,
grpcDialOption: grpcDialOption,
dataCenter: dataCenter,
filerGroup: filerGroup,
}
}
// GetEntry implements auth.FileSystemHelper interface
func (fs *FileSystemHelper) GetEntry(path string) (*auth.Entry, error) {
dir, name := util.FullPath(path).DirAndName()
var entry *filer_pb.Entry
err := fs.withTimeoutContext(func(ctx context.Context) error {
return fs.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return err
}
if resp.Entry == nil {
return fmt.Errorf("entry not found")
}
entry = resp.Entry
return nil
})
})
if err != nil {
return nil, err
}
return &auth.Entry{
IsDirectory: entry.IsDirectory,
Attributes: &auth.EntryAttributes{
Uid: entry.Attributes.GetUid(),
Gid: entry.Attributes.GetGid(),
FileMode: entry.Attributes.GetFileMode(),
SymlinkTarget: entry.Attributes.GetSymlinkTarget(),
},
IsSymlink: entry.Attributes.GetSymlinkTarget() != "",
}, nil
}
// Implement FilerClient interface for FileSystemHelper
func (fs *FileSystemHelper) AdjustedUrl(location *filer_pb.Location) string {
return location.Url
}
func (fs *FileSystemHelper) GetDataCenter() string {
return fs.dataCenter
}
func (fs *FileSystemHelper) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
addr := fs.filerAddr.ToGrpcAddress()
return pb.WithGrpcClient(streamingMode, util.RandomInt32(), func(conn *grpc.ClientConn) error {
return fn(filer_pb.NewSeaweedFilerClient(conn))
}, addr, false, fs.grpcDialOption)
}
func (fs *FileSystemHelper) withTimeoutContext(fn func(ctx context.Context) error) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return fn(ctx)
}
// Serve accepts incoming connections on the provided listener and handles them.
func (s *SFTPService) Serve(listener net.Listener) error {
// Build SSH server config
sshConfig, err := s.buildSSHConfig()
if err != nil {
return fmt.Errorf("failed to create SSH config: %v", err)
}
glog.V(0).Infof("Starting Seaweed SFTP service on %s", listener.Addr().String())
for {
conn, err := listener.Accept()
if err != nil {
return fmt.Errorf("failed to accept incoming connection: %v", err)
}
go s.handleSSHConnection(conn, sshConfig)
}
}
// buildSSHConfig creates the SSH server configuration with proper authentication.
func (s *SFTPService) buildSSHConfig() (*ssh.ServerConfig, error) {
// Get base config from auth manager
config := s.authManager.GetSSHServerConfig()
// Set additional options
config.MaxAuthTries = s.options.MaxAuthTries
config.BannerCallback = func(conn ssh.ConnMetadata) string {
return s.options.BannerMessage
}
config.ServerVersion = "SSH-2.0-SeaweedFS-SFTP" // Custom server version
hostKeysAdded := 0
// Add legacy host key if specified
if s.options.SshPrivateKey != "" {
if err := s.addHostKey(config, s.options.SshPrivateKey); err != nil {
return nil, err
}
hostKeysAdded++
}
// Add all host keys from the specified folder
if s.options.HostKeysFolder != "" {
files, err := os.ReadDir(s.options.HostKeysFolder)
if err != nil {
return nil, fmt.Errorf("failed to read host keys folder: %v", err)
}
for _, file := range files {
if file.IsDir() {
continue // Skip directories
}
keyPath := filepath.Join(s.options.HostKeysFolder, file.Name())
if err := s.addHostKey(config, keyPath); err != nil {
// Log the error but continue with other keys
log.Printf("Warning: failed to add host key %s: %v", keyPath, err)
continue
}
hostKeysAdded++
}
if hostKeysAdded == 0 {
log.Printf("Warning: no valid host keys found in folder %s", s.options.HostKeysFolder)
}
}
// Ensure we have at least one host key
if hostKeysAdded == 0 {
return nil, fmt.Errorf("no host keys provided")
}
return config, nil
}
// addHostKey adds a host key to the SSH server configuration.
func (s *SFTPService) addHostKey(config *ssh.ServerConfig, keyPath string) error {
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("failed to read host key %s: %v", keyPath, err)
}
// Try parsing as private key
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
// Try parsing with passphrase if available
if passphraseErr, ok := err.(*ssh.PassphraseMissingError); ok {
return fmt.Errorf("host key %s requires passphrase: %v", keyPath, passphraseErr)
}
return fmt.Errorf("failed to parse host key %s: %v", keyPath, err)
}
config.AddHostKey(signer)
glog.V(0).Infof("Added host key %s (%s)", keyPath, signer.PublicKey().Type())
return nil
}
// handleSSHConnection handles an incoming SSH connection.
func (s *SFTPService) handleSSHConnection(conn net.Conn, config *ssh.ServerConfig) {
// Set connection deadline for handshake
_ = conn.SetDeadline(time.Now().Add(s.options.LoginGraceTime))
// Perform SSH handshake
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
glog.Errorf("Failed to handshake: %v", err)
conn.Close()
return
}
// Clear deadline after successful handshake
_ = conn.SetDeadline(time.Time{})
// Set up connection monitoring
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start keep-alive monitoring
go s.monitorConnection(ctx, sshConn)
username := sshConn.Permissions.Extensions["username"]
glog.V(0).Infof("New SSH connection from %s (%s) as user %s",
sshConn.RemoteAddr(), sshConn.ClientVersion(), username)
// Get user from store
sftpUser, err := s.authManager.GetUser(username)
if err != nil {
glog.Errorf("Failed to retrieve user %s: %v", username, err)
sshConn.Close()
return
}
// Create user-specific filesystem
userFs := NewSftpServer(
s.options.Filer,
s.options.GrpcDialOption,
s.options.DataCenter,
s.options.FilerGroup,
sftpUser,
)
// Ensure home directory exists with proper permissions
if err := s.homeManager.EnsureHomeDirectory(sftpUser); err != nil {
glog.Errorf("Failed to ensure home directory for user %s: %v", username, err)
// We don't close the connection here, as the user might still be able to access other directories
}
// Handle SSH requests and channels
go ssh.DiscardRequests(reqs)
for newChannel := range chans {
go s.handleChannel(newChannel, &userFs)
}
}
// monitorConnection monitors an SSH connection with keep-alives.
func (s *SFTPService) monitorConnection(ctx context.Context, sshConn *ssh.ServerConn) {
if s.options.ClientAliveInterval <= 0 {
return
}
ticker := time.NewTicker(s.options.ClientAliveInterval)
defer ticker.Stop()
missedCount := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Send keep-alive request
_, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
missedCount++
glog.V(0).Infof("Keep-alive missed for %s: %v (%d/%d)",
sshConn.RemoteAddr(), err, missedCount, s.options.ClientAliveCountMax)
if missedCount >= s.options.ClientAliveCountMax {
glog.Warningf("Closing unresponsive connection from %s", sshConn.RemoteAddr())
sshConn.Close()
return
}
} else {
missedCount = 0
}
}
}
}
// handleChannel handles a single SSH channel.
func (s *SFTPService) handleChannel(newChannel ssh.NewChannel, fs *SftpServer) {
if newChannel.ChannelType() != "session" {
_ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
return
}
channel, requests, err := newChannel.Accept()
if err != nil {
glog.Errorf("Could not accept channel: %v", err)
return
}
go func(in <-chan *ssh.Request) {
for req := range in {
switch req.Type {
case "subsystem":
// Check that the subsystem is "sftp".
if string(req.Payload[4:]) == "sftp" {
_ = req.Reply(true, nil)
s.handleSFTP(channel, fs)
} else {
_ = req.Reply(false, nil)
}
default:
_ = req.Reply(false, nil)
}
}
}(requests)
}
// handleSFTP starts the SFTP server on the SSH channel.
func (s *SFTPService) handleSFTP(channel ssh.Channel, fs *SftpServer) {
// Create server options with initial working directory set to user's home
serverOptions := sftp.WithStartDirectory(fs.user.HomeDir)
server := sftp.NewRequestServer(channel, sftp.Handlers{
FileGet: fs,
FilePut: fs,
FileCmd: fs,
FileList: fs,
}, serverOptions)
if err := server.Serve(); err == io.EOF {
server.Close()
glog.V(0).Info("SFTP client exited session.")
} else if err != nil {
glog.Errorf("SFTP server finished with error: %v", err)
}
}

143
weed/sftpd/sftp_userstore.go

@ -0,0 +1,143 @@
package sftpd
import (
"crypto/subtle"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
)
// UserStore interface for user management.
type UserStore interface {
GetUser(username string) (*User, error)
ValidatePassword(username string, password []byte) bool
ValidatePublicKey(username string, keyData string) bool
GetUserPermissions(username string, path string) []string
}
// User represents an SFTP user with authentication and permission details.
type User struct {
Username string
Password string // Plaintext password
PublicKeys []string // Authorized public keys
HomeDir string // User's home directory
Permissions map[string][]string // path -> permissions (read, write, list, etc.)
Uid uint32 // User ID for file ownership
Gid uint32 // Group ID for file ownership
}
// FileUserStore implements UserStore using a JSON file.
type FileUserStore struct {
filePath string
users map[string]*User
mu sync.RWMutex
}
// NewFileUserStore creates a new user store from a JSON file.
func NewFileUserStore(filePath string) (*FileUserStore, error) {
store := &FileUserStore{
filePath: filePath,
users: make(map[string]*User),
}
if err := store.loadUsers(); err != nil {
return nil, err
}
return store, nil
}
// loadUsers loads users from the JSON file.
func (s *FileUserStore) loadUsers() error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if file exists
if _, err := os.Stat(s.filePath); os.IsNotExist(err) {
return fmt.Errorf("user store file not found: %s", s.filePath)
}
data, err := os.ReadFile(s.filePath)
if err != nil {
return fmt.Errorf("failed to read user store file: %v", err)
}
var users []*User
if err := json.Unmarshal(data, &users); err != nil {
return fmt.Errorf("failed to parse user store file: %v", err)
}
for _, user := range users {
s.users[user.Username] = user
}
return nil
}
// GetUser returns a user by username.
func (s *FileUserStore) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[username]
if !ok {
return nil, fmt.Errorf("user not found: %s", username)
}
return user, nil
}
// ValidatePassword checks if the password is valid for the user.
func (s *FileUserStore) ValidatePassword(username string, password []byte) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password using constant time comparison for security
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
}
// ValidatePublicKey checks if the public key is valid for the user.
func (s *FileUserStore) ValidatePublicKey(username string, keyData string) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
for _, key := range user.PublicKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(keyData)) == 1 {
return true
}
}
return false
}
// GetUserPermissions returns the permissions for a user on a path.
func (s *FileUserStore) GetUserPermissions(username string, path string) []string {
user, err := s.GetUser(username)
if err != nil {
return nil
}
// Check exact path match first
if perms, ok := user.Permissions[path]; ok {
return perms
}
// Check parent directories
var bestMatch string
var bestPerms []string
for p, perms := range user.Permissions {
if strings.HasPrefix(path, p) && len(p) > len(bestMatch) {
bestMatch = p
bestPerms = perms
}
}
return bestPerms
}

228
weed/sftpd/user/filestore.go

@ -0,0 +1,228 @@
package user
import (
"crypto/subtle"
"encoding/json"
"fmt"
"os"
"sync"
"golang.org/x/crypto/ssh"
)
// FileStore implements Store using a JSON file
type FileStore struct {
filePath string
users map[string]*User
mu sync.RWMutex
}
// NewFileStore creates a new user store from a JSON file
func NewFileStore(filePath string) (*FileStore, error) {
store := &FileStore{
filePath: filePath,
users: make(map[string]*User),
}
// Create the file if it doesn't exist
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// Create an empty users array
if err := os.WriteFile(filePath, []byte("[]"), 0600); err != nil {
return nil, fmt.Errorf("failed to create user store file: %v", err)
}
}
if err := store.loadUsers(); err != nil {
return nil, err
}
return store, nil
}
// loadUsers loads users from the JSON file
func (s *FileStore) loadUsers() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.filePath)
if err != nil {
return fmt.Errorf("failed to read user store file: %v", err)
}
var users []*User
if err := json.Unmarshal(data, &users); err != nil {
return fmt.Errorf("failed to parse user store file: %v", err)
}
// Clear existing users and add the loaded ones
s.users = make(map[string]*User)
for _, user := range users {
// Process public keys to ensure they're in the correct format
for i, keyData := range user.PublicKeys {
// Try to parse the key as an authorized key format
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyData))
if err == nil {
// If successful, store the marshaled binary format
user.PublicKeys[i] = string(pubKey.Marshal())
}
}
s.users[user.Username] = user
}
return nil
}
// saveUsers saves users to the JSON file
func (s *FileStore) saveUsers() error {
s.mu.RLock()
defer s.mu.RUnlock()
// Convert map to slice for JSON serialization
var users []*User
for _, user := range s.users {
users = append(users, user)
}
data, err := json.MarshalIndent(users, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize users: %v", err)
}
if err := os.WriteFile(s.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write user store file: %v", err)
}
return nil
}
// GetUser returns a user by username
func (s *FileStore) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[username]
if !ok {
return nil, &UserNotFoundError{Username: username}
}
return user, nil
}
// ValidatePassword checks if the password is valid for the user
func (s *FileStore) ValidatePassword(username string, password []byte) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
// Compare plaintext password using constant time comparison for security
return subtle.ConstantTimeCompare([]byte(user.Password), password) == 1
}
// ValidatePublicKey checks if the public key is valid for the user
func (s *FileStore) ValidatePublicKey(username string, keyData string) bool {
user, err := s.GetUser(username)
if err != nil {
return false
}
for _, key := range user.PublicKeys {
if key == keyData {
return true
}
}
return false
}
// GetUserPermissions returns the permissions for a user on a path
func (s *FileStore) GetUserPermissions(username string, path string) []string {
user, err := s.GetUser(username)
if err != nil {
return nil
}
// Check exact path match first
if perms, ok := user.Permissions[path]; ok {
return perms
}
// Check parent directories
var bestMatch string
var bestPerms []string
for p, perms := range user.Permissions {
if len(p) > len(bestMatch) && os.IsPathSeparator(p[len(p)-1]) && path[:len(p)] == p {
bestMatch = p
bestPerms = perms
}
}
return bestPerms
}
// SaveUser saves or updates a user
func (s *FileStore) SaveUser(user *User) error {
s.mu.Lock()
s.users[user.Username] = user
s.mu.Unlock()
return s.saveUsers()
}
// DeleteUser removes a user
func (s *FileStore) DeleteUser(username string) error {
s.mu.Lock()
_, exists := s.users[username]
if !exists {
s.mu.Unlock()
return &UserNotFoundError{Username: username}
}
delete(s.users, username)
s.mu.Unlock()
return s.saveUsers()
}
// ListUsers returns all usernames
func (s *FileStore) ListUsers() ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
usernames := make([]string, 0, len(s.users))
for username := range s.users {
usernames = append(usernames, username)
}
return usernames, nil
}
// CreateUser creates a new user with the given username and password
func (s *FileStore) CreateUser(username, password string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if user already exists
if _, exists := s.users[username]; exists {
return nil, fmt.Errorf("user already exists: %s", username)
}
// Create new user
user := NewUser(username)
// Store plaintext password
user.Password = password
// Add default permissions
user.Permissions[user.HomeDir] = []string{"all"}
// Save the user
s.users[username] = user
if err := s.saveUsers(); err != nil {
return nil, err
}
return user, nil
}

204
weed/sftpd/user/homemanager.go

@ -0,0 +1,204 @@
package user
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// HomeManager handles user home directory operations
type HomeManager struct {
filerClient FilerClient
}
// FilerClient defines the interface for interacting with the filer
type FilerClient interface {
WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error
GetDataCenter() string
AdjustedUrl(location *filer_pb.Location) string
}
// NewHomeManager creates a new home directory manager
func NewHomeManager(filerClient FilerClient) *HomeManager {
return &HomeManager{
filerClient: filerClient,
}
}
// EnsureHomeDirectory creates the user's home directory if it doesn't exist
func (hm *HomeManager) EnsureHomeDirectory(user *User) error {
if user.HomeDir == "" {
return fmt.Errorf("user has no home directory configured")
}
glog.V(0).Infof("Ensuring home directory exists for user %s: %s", user.Username, user.HomeDir)
// Check if home directory exists and create it if needed
err := hm.createDirectoryIfNotExists(user.HomeDir, user)
if err != nil {
return fmt.Errorf("failed to ensure home directory: %v", err)
}
// Update user permissions map to include the home directory with full access if not already present
if user.Permissions == nil {
user.Permissions = make(map[string][]string)
}
// Only add permissions if not already present
if _, exists := user.Permissions[user.HomeDir]; !exists {
user.Permissions[user.HomeDir] = []string{"all"}
glog.V(0).Infof("Added full permissions for user %s to home directory %s",
user.Username, user.HomeDir)
}
return nil
}
// createDirectoryIfNotExists creates a directory path if it doesn't exist
func (hm *HomeManager) createDirectoryIfNotExists(dirPath string, user *User) error {
// Split the path into components
components := strings.Split(strings.Trim(dirPath, "/"), "/")
currentPath := "/"
for _, component := range components {
if component == "" {
continue
}
nextPath := filepath.Join(currentPath, component)
err := hm.createSingleDirectory(nextPath, user)
if err != nil {
return err
}
currentPath = nextPath
}
return nil
}
// createSingleDirectory creates a single directory if it doesn't exist
func (hm *HomeManager) createSingleDirectory(dirPath string, user *User) error {
var dirExists bool
err := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
dir, name := util.FullPath(dirPath).DirAndName()
// Check if directory exists
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil || resp.Entry == nil {
// Directory doesn't exist, create it
glog.V(0).Infof("Creating directory %s for user %s", dirPath, user.Username)
err = filer_pb.Mkdir(hm, string(dir), name, func(entry *filer_pb.Entry) {
// Set appropriate permissions
entry.Attributes.FileMode = uint32(0700 | os.ModeDir) // rwx------ for user
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
// Set creation and modification times
now := time.Now().Unix()
entry.Attributes.Crtime = now
entry.Attributes.Mtime = now
// Add extended attributes
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended["creator"] = []byte(user.Username)
entry.Extended["auto_created"] = []byte("true")
})
if err != nil {
return fmt.Errorf("failed to create directory %s: %v", dirPath, err)
}
} else if !resp.Entry.IsDirectory {
return fmt.Errorf("path %s exists but is not a directory", dirPath)
} else {
dirExists = true
// Update ownership if needed
if resp.Entry.Attributes.Uid != user.Uid || resp.Entry.Attributes.Gid != user.Gid {
glog.V(0).Infof("Updating ownership of directory %s for user %s", dirPath, user.Username)
entry := resp.Entry
entry.Attributes.Uid = user.Uid
entry.Attributes.Gid = user.Gid
_, updateErr := client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
if updateErr != nil {
glog.Warningf("Failed to update directory ownership: %v", updateErr)
}
}
}
return nil
})
if err != nil {
return err
}
if !dirExists {
// Verify the directory was created
verifyErr := hm.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dir, name := util.FullPath(dirPath).DirAndName()
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil || resp.Entry == nil {
return fmt.Errorf("directory not found after creation")
}
if !resp.Entry.IsDirectory {
return fmt.Errorf("path exists but is not a directory")
}
dirExists = true
return nil
})
if verifyErr != nil {
return fmt.Errorf("failed to verify directory creation: %v", verifyErr)
}
}
return nil
}
// Implement necessary methods to satisfy the filer_pb.FilerClient interface
func (hm *HomeManager) AdjustedUrl(location *filer_pb.Location) string {
return hm.filerClient.AdjustedUrl(location)
}
func (hm *HomeManager) GetDataCenter() string {
return hm.filerClient.GetDataCenter()
}
// WithFilerClient delegates to the underlying filer client
func (hm *HomeManager) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
return hm.filerClient.WithFilerClient(streamingMode, fn)
}

111
weed/sftpd/user/user.go

@ -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
}
Loading…
Cancel
Save