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