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