Browse Source
Add SFTP Server Support (#6753)
Add SFTP Server Support (#6753)
* Add SFTP Server Support Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> * fix s3 tests and helm lint Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> * increase helm chart version * adjust version --------- Signed-off-by: Mohamed Sekour <mohamed.sekour@exfo.com> Co-authored-by: chrislu <chris.lu@gmail.com>pull/6720/head
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2997 additions and 103 deletions
-
37docker/compose/userstore.json
-
6go.mod
-
10go.sum
-
2k8s/charts/seaweedfs/Chart.yaml
-
32k8s/charts/seaweedfs/templates/_helpers.tpl
-
12k8s/charts/seaweedfs/templates/s3-secret.yaml
-
292k8s/charts/seaweedfs/templates/sftp-deployment.yaml
-
33k8s/charts/seaweedfs/templates/sftp-secret.yaml
-
39k8s/charts/seaweedfs/templates/sftp-service.yaml
-
33k8s/charts/seaweedfs/templates/sftp-servicemonitor.yaml
-
76k8s/charts/seaweedfs/values.yaml
-
1weed/command/command.go
-
27weed/command/filer.go
-
29weed/command/server.go
-
193weed/command/sftp.go
-
81weed/ftpd/ftp_server.go
-
76weed/sftpd/auth/auth.go
-
64weed/sftpd/auth/password.go
-
267weed/sftpd/auth/permissions.go
-
68weed/sftpd/auth/publickey.go
-
457weed/sftpd/sftp_filer.go
-
126weed/sftpd/sftp_helpers.go
-
59weed/sftpd/sftp_server.go
-
394weed/sftpd/sftp_service.go
-
143weed/sftpd/sftp_userstore.go
-
228weed/sftpd/user/filestore.go
-
204weed/sftpd/user/homemanager.go
-
111weed/sftpd/user/user.go
@ -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 |
|||
} |
|||
] |
@ -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 }} |
@ -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 }} |
@ -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 }} |
@ -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 }} |
@ -0,0 +1,193 @@ |
|||
package command |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"net" |
|||
"os" |
|||
"runtime" |
|||
"time" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/glog" |
|||
"github.com/seaweedfs/seaweedfs/weed/pb" |
|||
filer_pb "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" |
|||
"github.com/seaweedfs/seaweedfs/weed/security" |
|||
"github.com/seaweedfs/seaweedfs/weed/sftpd" |
|||
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" |
|||
"github.com/seaweedfs/seaweedfs/weed/util" |
|||
) |
|||
|
|||
var ( |
|||
sftpOptionsStandalone SftpOptions |
|||
) |
|||
|
|||
// SftpOptions holds configuration options for the SFTP server.
|
|||
type SftpOptions struct { |
|||
filer *string |
|||
bindIp *string |
|||
port *int |
|||
sshPrivateKey *string |
|||
hostKeysFolder *string |
|||
authMethods *string |
|||
maxAuthTries *int |
|||
bannerMessage *string |
|||
loginGraceTime *time.Duration |
|||
clientAliveInterval *time.Duration |
|||
clientAliveCountMax *int |
|||
userStoreFile *string |
|||
dataCenter *string |
|||
metricsHttpPort *int |
|||
metricsHttpIp *string |
|||
localSocket *string |
|||
} |
|||
|
|||
// cmdSftp defines the SFTP command similar to the S3 command.
|
|||
var cmdSftp = &Command{ |
|||
UsageLine: "sftp [-port=2022] [-filer=<ip:port>] [-sshPrivateKey=</path/to/private_key>]", |
|||
Short: "start an SFTP server that is backed by a SeaweedFS filer", |
|||
Long: `Start an SFTP server that leverages the SeaweedFS filer service to handle file operations. |
|||
|
|||
Instead of reading from or writing to a local filesystem, all file operations |
|||
are routed through the filer (filer_pb) gRPC API. This allows you to centralize |
|||
your file management in SeaweedFS. |
|||
`, |
|||
} |
|||
|
|||
func init() { |
|||
// Register the command to avoid cyclic dependencies.
|
|||
cmdSftp.Run = runSftp |
|||
|
|||
sftpOptionsStandalone.filer = cmdSftp.Flag.String("filer", "localhost:8888", "filer server address (ip:port)") |
|||
sftpOptionsStandalone.bindIp = cmdSftp.Flag.String("ip.bind", "0.0.0.0", "ip address to bind SFTP server") |
|||
sftpOptionsStandalone.port = cmdSftp.Flag.Int("port", 2022, "SFTP server listen port") |
|||
sftpOptionsStandalone.sshPrivateKey = cmdSftp.Flag.String("sshPrivateKey", "", "path to the SSH private key file for host authentication") |
|||
sftpOptionsStandalone.hostKeysFolder = cmdSftp.Flag.String("hostKeysFolder", "", "path to folder containing SSH private key files for host authentication") |
|||
sftpOptionsStandalone.authMethods = cmdSftp.Flag.String("authMethods", "password,publickey", "comma-separated list of allowed auth methods: password, publickey, keyboard-interactive") |
|||
sftpOptionsStandalone.maxAuthTries = cmdSftp.Flag.Int("maxAuthTries", 6, "maximum number of authentication attempts per connection") |
|||
sftpOptionsStandalone.bannerMessage = cmdSftp.Flag.String("bannerMessage", "SeaweedFS SFTP Server - Unauthorized access is prohibited", "message displayed before authentication") |
|||
sftpOptionsStandalone.loginGraceTime = cmdSftp.Flag.Duration("loginGraceTime", 2*time.Minute, "timeout for authentication") |
|||
sftpOptionsStandalone.clientAliveInterval = cmdSftp.Flag.Duration("clientAliveInterval", 5*time.Second, "interval for sending keep-alive messages") |
|||
sftpOptionsStandalone.clientAliveCountMax = cmdSftp.Flag.Int("clientAliveCountMax", 3, "maximum number of missed keep-alive messages before disconnecting") |
|||
sftpOptionsStandalone.userStoreFile = cmdSftp.Flag.String("userStoreFile", "", "path to JSON file containing user credentials and permissions") |
|||
sftpOptionsStandalone.dataCenter = cmdSftp.Flag.String("dataCenter", "", "prefer to read and write to volumes in this data center") |
|||
sftpOptionsStandalone.metricsHttpPort = cmdSftp.Flag.Int("metricsPort", 0, "Prometheus metrics listen port") |
|||
sftpOptionsStandalone.metricsHttpIp = cmdSftp.Flag.String("metricsIp", "", "metrics listen ip. If empty, default to same as -ip.bind option.") |
|||
sftpOptionsStandalone.localSocket = cmdSftp.Flag.String("localSocket", "", "default to /tmp/seaweedfs-sftp-<port>.sock") |
|||
} |
|||
|
|||
// runSftp is the command entry point.
|
|||
func runSftp(cmd *Command, args []string) bool { |
|||
// Load security configuration as done in other SeaweedFS services.
|
|||
util.LoadSecurityConfiguration() |
|||
|
|||
// Configure metrics
|
|||
switch { |
|||
case *sftpOptionsStandalone.metricsHttpIp != "": |
|||
// nothing to do, use sftpOptionsStandalone.metricsHttpIp
|
|||
case *sftpOptionsStandalone.bindIp != "": |
|||
*sftpOptionsStandalone.metricsHttpIp = *sftpOptionsStandalone.bindIp |
|||
} |
|||
go stats_collect.StartMetricsServer(*sftpOptionsStandalone.metricsHttpIp, *sftpOptionsStandalone.metricsHttpPort) |
|||
|
|||
return sftpOptionsStandalone.startSftpServer() |
|||
} |
|||
|
|||
func (sftpOpt *SftpOptions) startSftpServer() bool { |
|||
filerAddress := pb.ServerAddress(*sftpOpt.filer) |
|||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client") |
|||
|
|||
// metrics read from the filer
|
|||
var metricsAddress string |
|||
var metricsIntervalSec int |
|||
var filerGroup string |
|||
|
|||
// Connect to the filer service and try to retrieve basic configuration.
|
|||
for { |
|||
err := pb.WithGrpcFilerClient(false, 0, filerAddress, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error { |
|||
resp, err := client.GetFilerConfiguration(context.Background(), &filer_pb.GetFilerConfigurationRequest{}) |
|||
if err != nil { |
|||
return fmt.Errorf("get filer %s configuration: %v", filerAddress, err) |
|||
} |
|||
metricsAddress, metricsIntervalSec = resp.MetricsAddress, int(resp.MetricsIntervalSec) |
|||
filerGroup = resp.FilerGroup |
|||
glog.V(0).Infof("SFTP read filer configuration, using filer at: %s", filerAddress) |
|||
return nil |
|||
}) |
|||
if err != nil { |
|||
glog.V(0).Infof("Waiting to connect to filer %s grpc address %s...", *sftpOpt.filer, filerAddress.ToGrpcAddress()) |
|||
time.Sleep(time.Second) |
|||
} else { |
|||
glog.V(0).Infof("Connected to filer %s grpc address %s", *sftpOpt.filer, filerAddress.ToGrpcAddress()) |
|||
break |
|||
} |
|||
} |
|||
|
|||
go stats_collect.LoopPushingMetric("sftp", stats_collect.SourceName(uint32(*sftpOpt.port)), metricsAddress, metricsIntervalSec) |
|||
|
|||
// Parse auth methods
|
|||
var authMethods []string |
|||
if *sftpOpt.authMethods != "" { |
|||
authMethods = util.StringSplit(*sftpOpt.authMethods, ",") |
|||
} |
|||
|
|||
// Create a new SFTP service instance with all options
|
|||
service := sftpd.NewSFTPService(&sftpd.SFTPServiceOptions{ |
|||
GrpcDialOption: grpcDialOption, |
|||
DataCenter: *sftpOpt.dataCenter, |
|||
FilerGroup: filerGroup, |
|||
Filer: filerAddress, |
|||
SshPrivateKey: *sftpOpt.sshPrivateKey, |
|||
HostKeysFolder: *sftpOpt.hostKeysFolder, |
|||
AuthMethods: authMethods, |
|||
MaxAuthTries: *sftpOpt.maxAuthTries, |
|||
BannerMessage: *sftpOpt.bannerMessage, |
|||
LoginGraceTime: *sftpOpt.loginGraceTime, |
|||
ClientAliveInterval: *sftpOpt.clientAliveInterval, |
|||
ClientAliveCountMax: *sftpOpt.clientAliveCountMax, |
|||
UserStoreFile: *sftpOpt.userStoreFile, |
|||
}) |
|||
|
|||
// Set up Unix socket if on non-Windows platforms
|
|||
if runtime.GOOS != "windows" { |
|||
localSocket := *sftpOpt.localSocket |
|||
if localSocket == "" { |
|||
localSocket = fmt.Sprintf("/tmp/seaweedfs-sftp-%d.sock", *sftpOpt.port) |
|||
} |
|||
if err := os.Remove(localSocket); err != nil && !os.IsNotExist(err) { |
|||
glog.Fatalf("Failed to remove %s, error: %s", localSocket, err.Error()) |
|||
} |
|||
go func() { |
|||
// start on local unix socket
|
|||
sftpSocketListener, err := net.Listen("unix", localSocket) |
|||
if err != nil { |
|||
glog.Fatalf("Failed to listen on %s: %v", localSocket, err) |
|||
} |
|||
if err := service.Serve(sftpSocketListener); err != nil { |
|||
glog.Fatalf("Failed to serve SFTP on socket %s: %v", localSocket, err) |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// Start the SFTP service on TCP
|
|||
listenAddress := fmt.Sprintf("%s:%d", *sftpOpt.bindIp, *sftpOpt.port) |
|||
sftpListener, sftpLocalListener, err := util.NewIpAndLocalListeners(*sftpOpt.bindIp, *sftpOpt.port, time.Duration(10)*time.Second) |
|||
if err != nil { |
|||
glog.Fatalf("SFTP server listener on %s error: %v", listenAddress, err) |
|||
} |
|||
|
|||
glog.V(0).Infof("Start Seaweed SFTP Server %s at %s", util.Version(), listenAddress) |
|||
|
|||
if sftpLocalListener != nil { |
|||
go func() { |
|||
if err := service.Serve(sftpLocalListener); err != nil { |
|||
glog.Fatalf("SFTP Server failed to serve on local listener: %v", err) |
|||
} |
|||
}() |
|||
} |
|||
|
|||
if err := service.Serve(sftpListener); err != nil { |
|||
glog.Fatalf("SFTP Server failed to serve: %v", err) |
|||
} |
|||
|
|||
return true |
|||
} |
@ -1,81 +0,0 @@ |
|||
package ftpd |
|||
|
|||
import ( |
|||
"crypto/tls" |
|||
"errors" |
|||
"github.com/seaweedfs/seaweedfs/weed/util" |
|||
"net" |
|||
|
|||
ftpserver "github.com/fclairamb/ftpserverlib" |
|||
"google.golang.org/grpc" |
|||
) |
|||
|
|||
type FtpServerOption struct { |
|||
Filer string |
|||
IP string |
|||
IpBind string |
|||
Port int |
|||
FilerGrpcAddress string |
|||
FtpRoot string |
|||
GrpcDialOption grpc.DialOption |
|||
PassivePortStart int |
|||
PassivePortStop int |
|||
} |
|||
|
|||
type SftpServer struct { |
|||
option *FtpServerOption |
|||
ftpListener net.Listener |
|||
} |
|||
|
|||
var _ = ftpserver.MainDriver(&SftpServer{}) |
|||
|
|||
// NewFtpServer returns a new FTP server driver
|
|||
func NewFtpServer(ftpListener net.Listener, option *FtpServerOption) (*SftpServer, error) { |
|||
var err error |
|||
server := &SftpServer{ |
|||
option: option, |
|||
ftpListener: ftpListener, |
|||
} |
|||
return server, err |
|||
} |
|||
|
|||
// GetSettings returns some general settings around the server setup
|
|||
func (s *SftpServer) GetSettings() (*ftpserver.Settings, error) { |
|||
var portRange *ftpserver.PortRange |
|||
if s.option.PassivePortStart > 0 && s.option.PassivePortStop > s.option.PassivePortStart { |
|||
portRange = &ftpserver.PortRange{ |
|||
Start: s.option.PassivePortStart, |
|||
End: s.option.PassivePortStop, |
|||
} |
|||
} |
|||
|
|||
return &ftpserver.Settings{ |
|||
Listener: s.ftpListener, |
|||
ListenAddr: util.JoinHostPort(s.option.IpBind, s.option.Port), |
|||
PublicHost: s.option.IP, |
|||
PassiveTransferPortRange: portRange, |
|||
ActiveTransferPortNon20: true, |
|||
IdleTimeout: -1, |
|||
ConnectionTimeout: 20, |
|||
}, nil |
|||
} |
|||
|
|||
// ClientConnected is called to send the very first welcome message
|
|||
func (s *SftpServer) ClientConnected(cc ftpserver.ClientContext) (string, error) { |
|||
return "Welcome to SeaweedFS FTP Server", nil |
|||
} |
|||
|
|||
// ClientDisconnected is called when the user disconnects, even if he never authenticated
|
|||
func (s *SftpServer) ClientDisconnected(cc ftpserver.ClientContext) { |
|||
} |
|||
|
|||
// AuthUser authenticates the user and selects an handling driver
|
|||
func (s *SftpServer) AuthUser(cc ftpserver.ClientContext, username, password string) (ftpserver.ClientDriver, error) { |
|||
return nil, nil |
|||
} |
|||
|
|||
// GetTLSConfig returns a TLS Certificate to use
|
|||
// The certificate could frequently change if we use something like "let's encrypt"
|
|||
func (s *SftpServer) GetTLSConfig() (*tls.Config, error) { |
|||
return nil, errors.New("no TLS certificate configured") |
|||
} |
@ -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) |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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) |
|||
} |
@ -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) |
|||
} |
@ -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) |
|||
} |
@ -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) |
|||
} |
|||
} |
@ -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 |
|||
} |
@ -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 |
|||
} |
@ -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) |
|||
} |
@ -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 |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue